gajae-code · 확장점 형식: Hooks / Skills / Slash-Commands
gajae-code · 확장점 형식: Hooks / Skills / Slash-Commands
한 줄 요약
gajae-code(GJC)는 코어를 안 건드리고 행동을 덧붙이는 세 확장 구멍 — 끼어드는 콜백(Hooks), 지식 묶음(Skills), 사용자 단축키(Slash-Commands) — 을 “파일을 정해진 곳에 두면 자동 발견된다”는 한 약속 위에 올린다. → 왜 배우나: 같은 “확장”이라도 누가 언제 부르느냐에 따라 권한을 얼마나 좁혀야 하는지, 그리고 그 좁힘을 주석이 아니라 타입과 게이트로 강제하는 설계를 배우기 위해서다.
그림
flowchart TD
subgraph Hooks["Hooks · 에이전트가 도는 동안 끼어듦"]
H1["생명주기 이벤트<br/>tool_call·context·session 등"] --> H2[pi.on 핸들러 실행]
H2 --> H3{반환 Result}
H3 -->|"차단·수정"| H4["도구 실행·메시지 변경"]
end
subgraph Skills["Skills · 필요할 때 펼치는 지식"]
S1["부팅: loadSkills"] --> S2["목록에는 이름+설명만<br/>가벼운 카탈로그"]
S2 --> S3[모델이 설명만 훑음]
S3 -->|"skill:// 또는 /skill:"| S4["본문 펼침<br/>임베디드 content 또는 파일"]
end
subgraph Slash["Slash-Commands · 사용자가 직접 호출"]
C1["/명령 입력/"] --> C2{"어떤 종류?"}
C2 -->|builtin| C3[코어 핸들러가 처리]
C2 -->|마크다운| C4[프롬프트로 펼침]
C2 -->|TS 모듈| C5["execute 실행 → 문자열=프롬프트"]
end
쉽게 풀기
세 확장점을 **“한 식당의 세 직원”**으로 비유하면 쉽다.
-
Hooks = 주방 안 검수원 — 요리(에이전트 루프)가 도는 도중에 끼어든다. “이 재료(도구 호출) 빼라”, “나가기 전 간 봐라(메시지 수정)” 같은 일. 단 검수원에겐 권한을 일부러 좁게 준다. 루프 도중 화면(에디터)을 빼앗으면 흐름이 꼬이고 데드락이 나기 때문. 그래서 받는 UI는 “선택지·확인·알림” 정도뿐, 에디터 교체나 테마 변경은 막혀 있다.
-
Skills = 필요할 때만 펼치는 매뉴얼 책장 —
SKILL.md한 장이 전문 지식 한 묶음이다. 핵심은 2단계 지연 로딩: 평소엔 제목+한 줄 소개(name+description)만 책장에 꽂고, 모델이 필요하다 싶을 때만skill://<이름>으로 본문(절차·규칙)을 펼친다. 가벼운 목록 → 필요 시 무거운 본문, 이 구조로 토큰을 아낀다. -
Slash-Commands = 손님이 직접 누르는 단축 버튼 —
/명령으로 실행. 마크다운형은 인자만 끼워 프롬프트로 펼치는 템플릿, TS형은 코드가 돌아 반환한 문자열이 다시 LLM 프롬프트가 된다(아무것도 안 반환하면 조용히 끝).
공통 약속 — 셋 다 정해진 폴더에 파일을 두면 GJC가 알아서 발견한다(capability discovery). 번들 기본값은 본문이 코드에 박혀(content 임베디드) .gjc 폴더를 통째로 지워도 살아남는다.
핵심 정리
| 확장점 | 누가·언제 부르나 | 한 줄 정체 |
|---|---|---|
| Hooks | 코어가 생명주기 시점에 자동 호출 | 좁은 권한의 끼어들기 콜백 |
| Skills | 모델이 필요할 때 스스로 펼침 | 지연 로딩되는 지식 묶음 |
| Slash-Commands | 사용자가 /명령으로 직접 호출 | 마크다운·TS 두 갈래 단축키 |
Hooks의 권한이 왜 두 등급으로 나뉘는지가 이 설계의 핵심이다. 자동 호출 맥락과 사용자 명령 맥락이 받는 권한이 다르다.
flowchart LR A["자동 호출 훅<br/>HookContext"] -->|"관찰·차단만"| O["ui·cwd·model<br/>읽기전용 session<br/>isIdle·abort"] B["사용자가 친 명령<br/>HookCommandContext"] -->|위험 제어 추가| P["newSession·branch<br/>navigateTree·waitForIdle"] A -.데드락 방지로 차단.-> X["시스템프롬프트·shutdown 없음"]
[!note]- 펼쳐보기: Hooks가 좁힌 권한 두 단계 ① HookUIContext (ExtensionUIContext보다 좁음) — 에디터 컴포넌트 오버라이드 금지·터미널 입력 리스너 없음·테마 관리 없음. 가진 것:
select/confirm/input/notify/setStatus/custom/editor, 에디터 “텍스트만” 만지는setEditorText/getEditorText, 읽기 전용theme. 이유는 코드 주석에 박힘 — 훅은 에이전트 루프 안에서 호출되므로 에디터 소유권을 빼앗으면 안 된다. ② HookContext vs HookCommandContext — 자동 호출 일반 훅(HookContext)은ui/cwd/model/sessionManager(읽기전용)/isIdle()/abort()등 관찰·차단 위주만(시스템프롬프트·shutdown 없음 — 데드락 방지). 세션을 직접 바꾸는 위험 제어(newSession/branch/navigateTree/waitForIdle)는 사용자가 직접 명령을 친 안전한 맥락HookCommandContext에만 있다.
[!note]- 펼쳐보기: 전체 Hooks 이벤트 / Skills·Slash-Commands 동작 목록 Hooks 이벤트 — 할 수 있는 일로 묶기
- 관찰만:
session_start/switch/shutdown,agent_start/end,turn_start/end,auto_compaction_*,todo_reminder- 차단 가능(cancel):
session_before_switch/_branch/_compact/_tree- 메시지 갈아끼움:
context(LLM 전송 직전 messages 교체),before_agent_start(영속·TUI 표시 메시지 주입)- 도구 게이팅:
tool_call(실행 차단),tool_result(결과 수정)Skills —
hide/content두 동작
hide: true→<skills>목록·슬래시 별칭에서 빠지지만skill://·/skill:<name>으로는 여전히 도달read도구가 없으면 시스템 프롬프트에 스킬 자체를 노출 안 함(펼칠 수단이 없으니)content(임베디드 본문)가 있으면 파일 대신 그걸 씀 →.gjc삭제 후에도 번들 디폴트 작동- 임베디드 스킬(
baseDir가embedded:)은 상대 자산 파일을 못 가짐Slash-Commands — 발견 우선순위
- ① 번들 기본(green/review 등) 먼저 로드 (최저 우선순위)
- ② user/project
commands/*/index.{ts,js,mjs,cjs}→ 번들을 덮어쓸 수 있음- user ↔ project끼리 같은 이름이면 충돌 에러 (번들 덮어쓰기만 허용)
실제 예시
1) Hooks — 좁힌 UI 표면 + 이벤트 타입
핵심만: 이벤트 핸들러는 HookHandler<E,R> = (event, ctx) => Promise<R|void>|R|void이고 pi.on(event, handler)로 구독한다. 차단·수정은 항상 핸들러의 반환 Result로 한다(예: tool_call에서 {block:true} 반환). 훅 모듈은 default export 함수(HookFactory).
[!note]- 펼쳐보기: 전체 타입 — HookUIContext / ToolCallEvent
// packages/coding-agent/src/extensibility/hooks/types.ts // hooks는 ExtensionUIContext보다 좁은 UI — 터미널 입력 리스너·에디터 컴포넌트 // 오버라이드·테마 관리 없음. 루프 안에서 호출돼 에디터 소유권을 못 빼앗게. export interface HookUIContext { select(title: string, options: string[]): Promise<string | undefined>; confirm(title: string, message: string): Promise<boolean>; input(title: string, placeholder?: string): Promise<string | undefined>; notify(message: string, type?: "info" | "warning" | "error"): void; setStatus(key: string, text: string | undefined): void; setEditorText(text: string): void; // "텍스트"만, 컴포넌트 교체 불가 getEditorText(): string; readonly theme: Theme; } export interface ToolCallEvent { type: "tool_call"; toolName: string; // "bash" | "edit" | "write" ... toolCallId: string; input: Record<string, unknown>; } export type HookFactory = (pi: HookAPI) => void; // default export 함수
내가 직접 만들 때 (Hook 템플릿)
// .gjc/hooks/guard.ts
import type { HookFactory } from "@gajae-code/coding-agent";
const hook: HookFactory = (pi) => {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && String(event.input.command).includes("rm -rf /")) {
ctx.ui.notify("위험 명령 차단", "error");
return { block: true, reason: "destructive command" }; // ToolCallEventResult
}
});
pi.on("before_agent_start", async () => ({
message: { customType: "guard", content: `cwd=${process.cwd()}`, display: false, attribution: "agent" },
}));
};
export default hook;
-
default export함수인가 - UI는 좁은 표면만 (에디터 컴포넌트 교체·테마 변경 금지)
- 차단/수정은 핸들러 반환 Result로
- 세션 제어는 명령 핸들러(HookCommandContext)에서만
2) Skills — SKILL.md + skill:// + 임베디드 본문
frontmatter는 name/description이 필수, argument-hint·level·source·hide는 선택이다. 시스템 프롬프트의 <skills> 리스팅은 name+description만 싣고 본문은 뺀다 — 그래서 카탈로그가 가볍다. read 도구가 있을 때만 노출하고, hide:true는 목록에서 뺀다(여전히 skill://·/skill:<name>으로 로드 가능).
스킬이 목록에서 본문으로 펼쳐지는 경로가 이 절의 핵심이다.
flowchart TD
L[loadSkills 부팅] --> R{"read 도구 있나?"}
R -->|없음| Z["스킬 노출 안 함<br/>펼칠 수단 없음"]
R -->|있음| F{"hide:true?"}
F -->|예| H["목록에서 뺌<br/>(skill://·/skill:로는 여전히 도달)"]
F -->|아니오| LIST["<skills> 목록<br/>name+description만"]
LIST -->|모델 매칭| RES["skill:// 해석"]
H -->|명시 호출| RES
RES --> C{"content 임베디드?"}
C -->|있음| EMB["그 본문 사용<br/>.gjc 삭제에도 생존"]
C -->|없음| FILE["파일 읽고<br/>frontmatter 제거 + Skill 메타"]
[!note]- 펼쳐보기: 전체 코드 — SKILL.md / 리스팅 템플릿 / hide 필터 / skill:// / 본문 주입
<!-- packages/coding-agent/src/defaults/gjc/skills/ralplan/SKILL.md --> --- name: ralplan description: Consensus planning entrypoint that auto-gates vague team/ultragoal requests before execution argument-hint: "[--interactive] [--deliberate] [--architect openai-code] [--critic openai-code] <task description>" level: 4 source: "forked from upstream ralplan skill and rebranded for GJC" --- # Ralplan (Consensus Planning Alias) ...{{!-- packages/coding-agent/src/prompts/system/custom-system-prompt.md --}} {{#if skills.length}} Skills are specialized knowledge. Scan descriptions for your task domain. If a task-specific instruction applies, you MUST read the referenced local file before proceeding. <skills> {{#list skills join="\n"}} <skill name="{{name}}"> {{description}} </skill> {{/list}} </skills> {{/if}}// packages/coding-agent/src/system-prompt.ts // - hide:true 스킬 제외 (skill:// 와 /skill:<name>으로는 여전히 로드 가능) const hasRead = tools?.has("read"); const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : [];// packages/coding-agent/src/internal-urls/skill-protocol.ts // skill://<name>=SKILL.md, skill://<name>/<path>=baseDir 상대경로(traversal 차단) if (skill.baseDir.startsWith("embedded:")) { throw new Error(`Embedded skill ${skill.name} does not include relative file assets`); }// packages/coding-agent/src/extensibility/skills.ts — buildSkillPromptMessage // content 있으면 그걸, 없으면 파일 읽어 frontmatter 떼고 Skill: <path> 메타 부착 const content = typeof skill.content === "string" ? skill.content : await Bun.file(skill.filePath).text(); const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); const metaLines = [`Skill: ${skill.filePath}`];
[!note]- 펼쳐보기: 런타임 Skill 객체 핵심 필드 필수:
name/description/filePath/baseDir/source("<provider>:<level>", 예native:project). 선택:hide(목록·별칭 숨김),_source(표시용),content(.gjc삭제에도 사는 임베디드 본문).baseDir는filePath에서/SKILL.md를 뗀 상대자산 루트.
내가 직접 만들 때 (Skill 템플릿)
---
name: my-skill
description: 무엇을 언제 쓰는지 한 줄 (모델이 이걸로 매칭)
argument-hint: "<file> [--dry-run]"
level: 3
---
# My Skill
1. 절차...
참고 자산은 skill://my-skill/notes.md 로 읽어라.
-
name+description필수 (description 없으면 로더가 경고/제외) - 폴더 구조
<dir>/<skill>/SKILL.md, baseDir에 상대자산 배치 - 숨기려면
hide: true(여전히skill://·/skill:로 도달) - 본문엔 절차/규칙만 (카탈로그엔 description만 노출)
3) Slash-Commands — 두 로더 + builtin-registry
세 종류가 있고 로드 우선순위로 덮어쓰기를 관리한다.
flowchart TD
IN["/명령 입력/"] --> K{종류}
K -->|builtin| BR["builtin-registry<br/>/provider·/login 직접 처리<br/>subcommands 자동완성"]
K -->|마크다운| MD["FileSlashCommand<br/>{{args}}/ARGUMENTS 치환 → 프롬프트"]
K -->|TS 모듈| TS["execute 실행<br/>string=LLM 프롬프트 / void=종료"]
subgraph 로드순서[로드 우선순위]
B1["번들 기본<br/>최저"] --> B2["user/project<br/>번들 덮어쓰기 가능"]
B2 --> B3{"user↔project<br/>같은 이름?"}
B3 -->|예| ERR[충돌 에러]
end
- (a) 마크다운 커맨드 (
slash-commands.ts,FileSlashCommand): 필드는name/description(frontmatter 또는 본문 첫 줄 60자)/content/source.expandSlashCommand가/명령 인자를 파싱해{{args}}/ARGUMENTS치환 후 펼친다.EMBEDDED_COMMAND_TEMPLATES는 번들 디폴트(중복 이름이면 skip,source:"bundled"). - (b) TS 커스텀 커맨드 (
custom-commands/): 필드name(네임스페이스git:commit가능)/description/execute(string 반환=LLM 프롬프트, void=조용히 종료). 팩토리는default export함수,CustomCommandAPI(cwd/exec/typebox/zod/pi) 주입. - (c) builtin-registry: 선언적 정의로
/provider·/login같은 코어 명령을 직접 처리.subcommands로 드롭다운 자동완성·인라인 힌트 생성.
[!note]- 펼쳐보기: 전체 코드 — 로더 우선순위 / BuiltinSlashCommand
// packages/coding-agent/src/extensibility/custom-commands/loader.ts // 1. 번들 커맨드 먼저 (최저 우선순위 - 덮어쓰기 가능) for (const loaded of loadBundledCommands(sharedApi)) { ... } // 2. user/project 커맨드 (번들 override) // user/project 충돌 → error; 번들 충돌은 splice로 교체// packages/coding-agent/src/slash-commands/types.ts export interface BuiltinSlashCommand { name: string; description: string; subcommands?: SubcommandDef[]; // /mcp add, /mcp list 같은 드롭다운 inlineHint?: string; }
내가 직접 만들 때 (TS 커스텀 커맨드 템플릿)
// .gjc/commands/deploy/index.ts
import type { CustomCommandFactory } from "@gajae-code/coding-agent";
const factory: CustomCommandFactory = (pi) => ({
name: "deploy",
description: "Deploy current branch",
async execute(args, ctx) {
if (!(await ctx.ui.confirm("Deploy", "진행?"))) return; // void = 프롬프트 없음
const r = await pi.exec("./deploy.sh", [args[0] ?? "staging"]);
return `배포 결과:\n${r.stdout}\n다음 단계 제안해줘`; // string = LLM 프롬프트
},
});
export default factory;
또는 마크다운: .gjc/commands/foo.md에 frontmatter description + 본문({{args}}/ARGUMENTS 치환).
-
name/description/execute셋 다 있는가 - user/project 같은 이름 충돌은 에러 (번들만 덮어쓰기 가능)
- 폴더형 커맨드는
index.{ts,js,mjs,cjs}진입점
4) 한 발 더 — 모호성 게이팅·영수증 패턴
ralplan은 실행 스킬(team/ultragoal)이 모호한 요청(“team improve the app”)으로 시작되는 걸 막는 pre-execution gate를 둔다.
flowchart LR
Q[요청] --> G{"구체 신호 있나?<br/>파일경로·이슈번호·camelCase"}
G -->|1개 이상| PASS["통과 → 실행"]
G -->|"15단어 이하 & 앵커 없음"| RD["redirect (force:/! 로 우회)"]
PASS --> W["단계 산출물<br/>.gjc/plans/ralplan/<run-id>/ 영속"]
W --> RC["역할 에이전트는 RECEIPT-ONLY<br/>run_id·path·sha256·stage만 반환"]
역할 에이전트는 본문을 부모 대화에 붙여넣지 않고 receipt(run_id/path/sha256/stage/stage_n/created_at)만 반환한다 — 감사 추적은 남기되 컨텍스트 중복은 막는다. 같은 영수증 사고가 skill 본문 끝의 Skill: <path> 메타 라인에도 보인다.
요약 & 셀프체크
- Hooks: 코어가 생명주기 시점에 자동으로 부르는 끼어들기 콜백. 루프 안에서 돌기에 일부러 좁은 권한만 받고, 차단·수정은 반환 Result로 한다.
- Skills: 카탈로그(이름+설명)만 가볍게 노출하다 필요할 때
skill://로 본문을 펼치는 2단계 지연 로딩.content임베디드 덕에.gjc삭제 후에도 번들 디폴트가 산다. - Slash-Commands: 사용자가 부르는 단축키로 마크다운·TS 두 갈래. 발견 우선순위(번들 → user/project, 충돌은 에러)로 덮어쓰기를 관리한다.
[!question] 스스로 답해보기
- 왜
HookUIContext는 에디터 컴포넌트 교체를 막고, 세션 제어를HookCommandContext로 따로 떼어냈을까?<skills>에 description만 싣고 본문을 빼는 2단계 구조는 어떤 비용을 아끼나?read도구가 없으면 스킬을 아예 안 보이는 이유는?hide: true스킬이 목록에서 사라지는데도 실행 가능한 이유는? 어떤 두 경로로 도달하나?
근거 파일
[!note]- 펼쳐보기: 전체 근거 파일 목록
packages/coding-agent/src/extensibility/hooks/types.ts(HookUIContext, HookContext, HookEvent 유니온, HookAPI, HookFactory)packages/coding-agent/src/extensibility/hooks/loader.ts(createHookAPI, loadHook/loadHooks, discoverAndLoadHooks)packages/coding-agent/src/extensibility/skills.ts(Skill 인터페이스·hide·content, loadSkills, resolveSkillSlashCommands, buildSkillPromptMessage)packages/coding-agent/src/defaults/gjc/skills/ralplan/SKILL.md(frontmatter 실측, pre-execution gate, RECEIPT-ONLY)packages/coding-agent/src/extensibility/slash-commands.ts(FileSlashCommand, BUILTIN_SLASH_COMMANDS, loadSlashCommands, expandSlashCommand, EMBEDDED 템플릿)packages/coding-agent/src/extensibility/custom-commands/loader.ts(discover/loadCustomCommands, 번들→user/project 우선순위·override)packages/coding-agent/src/extensibility/custom-commands/types.ts(CustomCommand, CustomCommandAPI, CustomCommandFactory)packages/coding-agent/src/slash-commands/types.ts(BuiltinSlashCommand, SubcommandDef)packages/coding-agent/src/slash-commands/builtin-registry.ts(BUILTIN_SLASH_COMMAND_DEFS, executeBuiltinSlashCommand)packages/coding-agent/src/prompts/system/custom-system-prompt.md(<skills>리스팅 템플릿)packages/coding-agent/src/system-prompt.ts(hide 필터, read 도구 게이팅)packages/coding-agent/src/internal-urls/skill-protocol.ts(skill:// 해석, embedded 자산 제한)packages/coding-agent/src/capability/skill.ts(CapabilitySkill.content/frontmatter)