컨텐츠로 건너뛰기

Command 소스 코드 분석

모듈 개요

Command 모듈은 OpenCode의 설정 기반 명령 시스템입니다. Go 버전의 명령 다이얼로그 아키텍처(CommandDialog, MultiArgumentsDialog 등, 6개 파일에 걸친 구현)와 달리, TS 버전은 명령을 단 3개 파일로 단순화합니다. 핵심 아이디어: 명령은 실행 가능한 함수가 아니라 템플릿이 있는 설정 항목입니다.

명령은 4가지 소스에서 제공됩니다: 두 개의 내장 명령(init, review), Config 커스텀 명령, MCP Prompts, 그리고 Skills입니다. 명령 실행은 SessionPrompt.command()가 소비하며, 이 함수는 템플릿 변수 치환을 수행하고 결과를 Session에 사용자 메시지로 주입합니다.

주요 파일

파일역할
src/command/index.ts모듈 메인 파일: Command.Info Schema, 명령 탐색 및 병합, get / list 쿼리 인터페이스
src/command/template/initialize.txtinit 명령 템플릿: Agent에게 코드베이스 분석 및 AGENTS.md 생성 지시
src/command/template/review.txtreview 명령 템플릿: Agent에게 코드 리뷰 수행 지시
src/session/prompt.tsSessionPrompt.command() 함수: 템플릿 변수 치환, 서브태스크 디스패치, Session 주입

타입 시스템

Command.Info Schema

명령의 핵심 데이터 모델은 Zod Schema를 사용하여 정의됩니다:

export const Info = z
  .object({
    name: z.string(),
    description: z.string().optional(),
    agent: z.string().optional(),
    model: z.string().optional(),
    source: z.enum(["command", "mcp", "skill"]).optional(),
    // workaround for zod not supporting async functions natively
    template: z.promise(z.string()).or(z.string()),
    subtask: z.boolean().optional(),
    hints: z.array(z.string()),
  })
  .meta({ ref: "Command" })

export type Info = Omit<z.infer<typeof Info>, "template"> & {
  template: Promise<string> | string
}
필드타입설명
namestring고유한 명령 식별자
descriptionstring?명령 기능에 대한 설명
agentstring?실행할 Agent 지정 (기본값 오버라이드)
modelstring?모델 지정 (기본값 오버라이드)
source"command" | "mcp" | "skill"?명령 소스 태그
templatePromise<string> | string명령 템플릿, 비동기 로딩 지원
subtaskboolean?서브태스크로 실행할지 여부 (독립 Agent 인스턴스)
hintsstring[]매개변수 플레이스홀더 목록, 예: ["$1", "$ARGUMENTS"]

template 필드 설계: MCP 프롬프트는 비동기 검색이 필요하기 때문에 일반 문자열 대신 getter를 사용합니다. Zod에는 z.promise().or(z.string())에 대한 타입 추론 버그가 있어, Omit + 교차 타입(intersection type)으로 수동 오버라이드합니다.

hints() 헬퍼 함수

템플릿 텍스트에서 매개변수 플레이스홀더를 추출합니다 — $1, $2… 중복 제거 후 정렬하며, $ARGUMENTS는 마지막에 추가됩니다:

export function hints(template: string): string[] {
  const result: string[] = []
  const numbered = template.match(/\$\d+/g)
  if (numbered) {
    for (const match of [...new Set(numbered)].sort()) result.push(match)
  }
  if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
  return result
}

Command.Event

명령 실행 후 발행되는 이벤트로, 명령 이름, Session ID, 인수, 메시지 ID를 포함합니다:

export const Event = {
  Executed: BusEvent.define("command.executed", z.object({
    name: z.string(),
    sessionID: SessionID.zod,
    arguments: z.string(),
    messageID: MessageID.zod,
  })),
}

명령 탐색 및 등록

InstanceState 지연 초기화

명령 목록은 Instance.state()를 통해 관리됩니다 — 프로젝트 인스턴스당 한 번 초기화되며, 이후에는 캐시가 재사용됩니다. get()list()는 초기화 완료를 기다린 후 읽기를 수행합니다:

const state = Instance.state(async () => {
  const cfg = await Config.get()
  const result: Record<string, Info> = { /* ... */ }
  // 4단계 병합 ...
  return result
})

export async function get(name: string) {
  return state().then((x) => x[name])
}
export async function list() {
  return state().then((x) => Object.values(x))
}

4단계 병합 로직

1단계: 내장 명령

init(AGENTS.md 생성/업데이트)과 review(코드 리뷰)를 등록합니다. 템플릿은 getter를 사용하여 ${path}Instance.worktree로 동적으로 치환합니다:

const result: Record<string, Info> = {
  [Default.INIT]: {
    name: "init",
    description: "create/update AGENTS.md",
    source: "command",
    get template() {
      return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
    },
    hints: hints(PROMPT_INITIALIZE),
  },
  [Default.REVIEW]: {
    name: "review",
    description: "review changes [commit|branch|pr], defaults to uncommitted",
    source: "command",
    get template() {
      return PROMPT_REVIEW.replace("${path}", Instance.worktree)
    },
    subtask: true,
    hints: hints(PROMPT_REVIEW),
  },
}

2단계: Config 커스텀 명령

설정 파일의 command 필드를 순회하며 각 항목을 Command.Info로 매핑합니다. Config 명령은 동일한 이름의 내장 명령을 오버라이드할 수 있습니다.

3단계: MCP Prompts

MCP 프롬프트 템플릿은 비동기 검색이 필요합니다. getter는 async가 될 수 없으므로 Promise를 수동으로 반환합니다. MCP 프롬프트 인수 이름은 위치 기반으로 $1, $2, …에 매핑됩니다:

for (const [name, prompt] of Object.entries(await MCP.prompts())) {
  result[name] = {
    name,
    source: "mcp",
    description: prompt.description,
    get template() {
      return new Promise(async (resolve, reject) => {
        const template = await MCP.getPrompt(
          prompt.client, prompt.name,
          prompt.arguments
            ? Object.fromEntries(
                prompt.arguments.map((arg, i) => [arg.name, `$${i + 1}`])
              )
            : {},
        ).catch(reject)
        resolve(
          template?.messages
            .map((m) => (m.content.type === "text" ? m.content.text : ""))
            .join("\n") || "",
        )
      })
    },
    hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
  }
}

4단계: Skills

Skills는 가장 낮은 우선순위를 가집니다. 동일한 이름의 명령이 이미 존재하면 건너뜁니다(if (result[skill.name]) continue). Config/MCP가 오버라이드를 수행하는 것과 다른 동작입니다.

우선순위 요약:

우선순위소스오버라이드 규칙
1 (최고)내장 명령 (init, review)
2Config 커스텀 명령내장 명령을 오버라이드 가능
3MCP PromptsConfig 명령을 오버라이드 가능
4 (최저)Skills동일 이름 존재 시 건너뜀; 기존 명령을 오버라이드하지 않음

이 4계층 우선순위 설계는 다음을 보장합니다:

  • 내장 명령은 항상 Config를 통해 커스터마이즈 및 오버라이드 가능
  • MCP 제공 명령은 사용자 설정을 오버라이드 가능 (팀이 MCP Server를 공유할 때 유용)
  • Skills는 가장 낮은 우선순위이므로 기존 명령을 실수로 오버라이드하지 않음

MCP 프롬프트 인수 매핑

MCP 프롬프트 인수 매핑은 자동입니다 — MCP 프로토콜의 프롬프트 arguments 정의(이름 + 설명)가 위치 매개변수로 매핑됩니다:

prompt.arguments
  ? Object.fromEntries(
      prompt.arguments.map((arg, i) => [arg.name, `$${i + 1}`])
    )
  : {}

예를 들어, arguments: [{ name: "language" }, { name: "code" }]로 정의된 MCP 프롬프트는 { language: "$1", code: "$2" }로 매핑됩니다. 사용자가 명령을 호출하면 인수가 위치 순서대로 채워집니다: 첫 번째 인수는 $1에, 두 번째 인수는 $2에 할당됩니다.

템플릿 자체는 비동기적으로 검색됩니다 — MCP.getPrompt()가 Promise를 반환하고, getter가 이 Promise를 반환하며, 소비자는 await command.template을 통해 동일하게 처리합니다.

내장 명령 상세

INIT — 프로젝트 초기화

템플릿은 Agent에게 코드베이스를 분석하고 빌드 명령, 코드 스타일, 에러 처리 규칙 등을 포함하는 AGENTS.md를 생성하도록 지시합니다. ${path}를 사용하여 출력 경로를 지정하고, $ARGUMENTS를 통해 사용자가 커스텀 지침을 추가할 수 있습니다. 템플릿은 약 15줄이며, 약 150줄의 프로젝트 메모리 파일을 생성하는 것을 목표로 합니다.

REVIEW — 코드 리뷰

템플릿은 구조화된 코드 리뷰 프롬프트(약 150줄)로, 인수 유형에 따라 자동으로 리뷰 범위를 선택합니다:

  • 인수 없음 → git diff로 커밋되지 않은 변경사항 리뷰
  • 커밋 해시 → git show로 해당 커밋 리뷰
  • 브랜치 이름 → git diff branch...HEAD
  • PR URL/번호 → gh pr diff로 PR 리뷰

리뷰 우선순위: 버그 > 구조 > 성능 > 동작 변경. 템플릿은 “확실하지 않으면 지적하지 말 것”, “변경된 부분만 리뷰할 것”, “스타일에 과도하게 집중하지 말 것”을 강조합니다.

reviewsubtask: true로 설정되어 독립적인 서브 Agent에서 실행되며, 메인 세션 컨텍스트에 영향을 주지 않습니다.

코어 플로우

Command 모듈은 명령 탐색 및 쿼리만 담당하며, 실행 로직은 SessionPrompt.command()에 있습니다.

호출 체인

사용자가 명령 선택 + 인수 입력
  → SessionPrompt.command(input)
    → Command.get(input.command)        // 명령 정의 가져오기
    → 템플릿 변수 치환 ($1, $2, $ARGUMENTS)
    → 셸 명령 치환 (!`cmd` 구문)
    → resolvePromptParts(template)       // @file 참조 파싱
    → 서브태스크 모드 결정
      ├─ subtask=true  → SubtaskPart 주입 → TaskTool 실행
      └─ subtask=false → 사용자 메시지 주입 → SessionPrompt.loop() → Agent 실행
    → Command.Event.Executed 발행

템플릿 변수 치환

// 1. 사용자 인수 파싱 (따옴표 및 [Image N] 마커 지원)
const args = (input.arguments.match(argsRegex) ?? [])
  .map((arg) => arg.replace(quoteTrimRegex, ""))

// 2. 위치 인수 치환: $1→args[0], $2→args[1]...
//    마지막 플레이스홀더는 나머지 인수를 모두 흡수
const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
  const position = Number(index)
  const argIndex = position - 1
  if (argIndex >= args.length) return ""
  if (position === last) return args.slice(argIndex).join(" ")
  return args[argIndex]
})

// 3. $ARGUMENTS를 전체 인수 텍스트로 치환
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)

// 4. 폴백: 플레이스홀더가 없지만 인수가 있는 경우 → 템플릿 끝에 추가
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
  template = template + "\n\n" + input.arguments
}

마지막 플레이스홀더 흡수 동작: 템플릿에 $1 $2 $3이 있고 사용자가 5개의 인수를 제공하면, $3이 인수 3~5를 모두 흡수합니다. 이를 통해 사용자 입력이 더 자연스럽게 느껴집니다.

템플릿은 !`command` 구문도 지원하여 셸 명령을 실행하고 결과를 인라인할 수 있습니다.

resolvePromptParts — 파일 참조 해석

템플릿의 @file 참조는 resolvePromptParts()에 의해 해석됩니다. 해석 전략은 3계층 폴백으로 구성됩니다:

  1. 파일 시스템 경로: 먼저 참조를 파일 경로로 해석하여, 파일 내용을 읽고 치환
  2. Agent 이름: 파일이 존재하지 않으면 참조를 Agent 이름으로 해석하여, 해당 Agent의 설정을 가져옴
  3. 홈 디렉토리: ~/로 시작하는 경로를 지원하며, 사용자 홈 디렉토리로 자동 확장
// 단순화된 해석 로직
for (const ref of references) {
  if (existsSync(ref))       → readFileContent(ref)
  else if (isAgentName(ref)) → getAgentInfo(ref)
  else if (ref.startsWith("~/")) → readFileContent(expandHome(ref))
}

이를 통해 템플릿은 프로젝트 파일(@src/main.ts)이나 Agent 설정(@explore)을 참조할 수 있으며, 실행 시점에 실제 내용으로 동적으로 해석됩니다.

셸 명령 치환 (!cmd 구문)

템플릿은 인라인 셸 명령을 지원하며, ConfigMarkdown.shell()을 통해 매칭 및 실행됩니다:

// !`cmd` 패턴 매칭
const result = ConfigMarkdown.shell(template)
// 각 매치에 대해:
//   Process.text(cmd, { nothrow: true })가 명령 실행
//   !`cmd`를 명령 출력으로 치환

nothrow: true는 셸 명령이 실패하더라도 템플릿 처리가 중단되지 않도록 보장합니다 — 실패한 명령의 출력에는 에러 정보가 포함되지만, 템플릿은 계속 파싱됩니다. 이를 통해 명령 실행 시점에 동적 정보(현재 git 브랜치, 날짜, 환경 변수 등)를 템플릿에 주입할 수 있습니다.

서브태스크 디스패치

const isSubtask =
  (agent.mode === "subagent" && command.subtask !== false) ||
  command.subtask === true
  • 서브태스크 모드: SubtaskPart를 생성하여 TaskTool이 독립적인 Agent에서 실행
  • 일반 모드: 템플릿 텍스트가 사용자 메시지로 Session에 직접 주입됨

구조화된 출력 특수 처리

명령이 LLM의 구조화된 형식(예: JSON Schema) 출력을 필요로 하는 경우, Session 실행 루프는 특수한 구조화된 출력 처리를 주입합니다:

  1. StructuredOutput 도구 주입: 도구 목록에 숨겨진 JSON 출력 도구를 추가
  2. 시스템 프롬프트 주입: 모델에게 이 도구를 사용하여 구조화된 데이터를 출력하도록 지시
  3. 도구 호출 강제: toolChoice: "required"를 설정하여 모델이 반드시 StructuredOutput 도구를 호출하도록 보장
  4. 에러 처리: 모델이 StructuredOutput 도구를 호출하는 대신 일반 텍스트로 응답하면 StructuredOutputError를 발생시킴
// 단순화된 로직
if (needsStructuredOutput) {
  tools["structured_output"] = structuredOutputTool(schema)
  params.toolChoice = "required"  // 도구 호출 강제
  // LLM이 도구를 호출하지 않으면 → StructuredOutputError
}

이 설계는 구조화된 출력을 도구 호출로 위장시켜, LLM의 기존 도구 호출 기능을 활용하여 출력 형식의 신뢰성을 보장합니다.

설계 트레이드오프

명령이 실행 가능한 함수가 아니라 설정인 이유는?

Go 버전의 Handlerfunc(cmd Command) tea.Cmd로, 임의의 로직을 실행할 수 있었습니다. TS 버전은 이를 설정 + 템플릿으로 단순화합니다:

  1. 보안: 템플릿은 단순 텍스트이며 코드를 실행하지 않아, 임의 코드 실행 위험을 제거
  2. 직렬화 가능성: 순수 데이터 설정은 JSON Schema로 검증할 수 있고 MCP 프로토콜을 통해 전송 가능
  3. 통합 인터페이스: Config 명령, MCP 프롬프트, Skills는 각기 다른 구조이지만 모두 Command.Info로 통일됨

콜백 대신 템플릿 치환인 이유는?

  • 크로스 프로토콜 호환성: MCP 프롬프트 인수 매핑이 $N을 직접 사용하므로 추가 추상화 계층이 불필요
  • 예측 가능성: 사용자가 원시 템플릿을 읽어 매개변수 위치를 이해할 수 있음
  • 지연 바인딩: getter 설계를 통해 MCP 프롬프트를 비동기적으로 로딩할 수 있으며, 소비자는 동기/비동기 여부를 신경 쓸 필요 없음

MCP 프롬프트가 async getter 대신 Promise를 사용하는 이유는?

JavaScript의 getter는 async가 될 수 없습니다 (동기적으로 값을 반환해야 함). new Promise(async ...)는 워크아운드입니다 — getter는 동기적으로 Promise를 반환하고, 소비자는 await command.template을 통해 동일하게 처리합니다.

다른 모듈과의 관계

Command 모듈
  ├── Config         → cfg.command 커스텀 명령 설정 읽기
  ├── MCP            → MCP.prompts()로 MCP 프롬프트 명령 탐색
  ├── Skill          → Skill.all()로 스킬 명령 탐색
  ├── Instance       → Instance.state()로 명령 목록 수명 주기 관리
  ├── BusEvent       → Command.Event.Executed 이벤트 정의
  └── Session/Schema → SessionID, MessageID 타입 의존성

의존하는 모듈:
  ├── SessionPrompt  → command() 함수가 Command.get()을 소비하여 명령 실행
  └── TUI / API      → Command.list()로 사용 가능한 명령 목록 표시