コンテンツにスキップ

Command ソースコード解説

モジュール概要

Command モジュールは、OpenCode の 設定駆動のコマンドシステム です。Go 版のコマンドダイアログアーキテクチャ(CommandDialogMultiArgumentsDialog など、6ファイルにわたる)とは異なり、TS 版ではわずか3ファイルに簡略化されています。中心となる考え方は、コマンドは実行可能な関数ではなく、テンプレートを持つ設定項目である というものです。

コマンドは4つのソースから提供されます:2つの組み込みコマンド(initreview)、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 + 交差型で手動で型を上書きしています。

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() を通じて管理され、プロジェクトインスタンスごとに1回初期化され、その後はキャッシュが再利用されます。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:組み込みコマンド

initAGENTS.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番目の引数は $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 をレビュー

レビューの優先度は Bugs > Structure > Performance > Behavior Changes の順です。テンプレートは「確信がない場合は指摘しない」「変更された部分のみレビューする」「スタイルに過度にこだわらない」ことを強調しています。

reviewsubtask: true を設定し、メインセッションのコンテキストに影響を与えない独立したサブ Agent で実行されます。

コマンド実行:SessionPrompt.command()

Command モジュールはコマンドの発見とクエリのみを担当し、実行ロジックは SessionPrompt.command() に存在します。

呼び出しチェーン

ユーザーがコマンドを選択 + 引数を入力
  → SessionPrompt.command(input)
    → Command.get(input.command)        // コマンド定義を取得
    → テンプレート変数の置換 ($1, $2, $ARGUMENTS)
    → Shell コマンド置換 (!`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` 構文もサポートしており、Shell コマンドを実行してその結果をインライン展開します。

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)を参照でき、実行時に実際の内容に動的に解決されます。

Shell コマンド置換(!cmd 構文)

テンプレートはインライン Shell コマンドをサポートし、ConfigMarkdown.shell() を通じて照合・実行されます:

// !`cmd` パターンにマッチ
const result = ConfigMarkdown.shell(template)
// 各マッチについて:
//   Process.text(cmd, { nothrow: true }) がコマンドを実行
//   !`cmd` をコマンドの出力で置換

nothrow: true により、Shell コマンドが失敗した場合でもテンプレート処理は中断されません。失敗したコマンドの出力にはエラー情報が含まれますが、テンプレートのパースは継続します。これにより、コマンド実行時に動的な情報(現在の git ブランチ、日付、環境変数など)をテンプレートに注入できます。

サブタスクディスパッチ

const isSubtask =
  (agent.mode === "subagent" && command.subtask !== false) ||
  command.subtask === true
  • サブタスクモード: SubtaskPart を作成し、独立した Agent で TaskTool により実行
  • 通常モード: テンプレートテキストがユーザーメッセージとして Session に直接注入される

Structured Output の特別処理

コマンドが LLM に構造化フォーマット(例:JSON Schema)での出力を要求する場合、Session の実行ループは特別な Structured Output 処理を注入します:

  1. StructuredOutput ツールの注入: 非表示の JSON 出力ツールをツールリストに追加
  2. システムプロンプトの注入: モデルに構造化データ出力のためにこのツールを使用するよう指示
  3. ツール呼び出しの強制: toolChoice: "required" を設定し、モデルが必ず StructuredOutput ツールを呼び出すことを保証
  4. エラー処理: モデルが StructuredOutput ツールを呼び出さずプレーンテキストで応答した場合、StructuredOutputError をスロー
// 簡略化されたロジック
if (needsStructuredOutput) {
  tools["structured_output"] = structuredOutputTool(schema)
  params.toolChoice = "required"  // ツール呼び出しを強制
  // LLM がツールを呼び出さない場合 → StructuredOutputError
}

この設計は、Structured Output をツール呼び出しに偽装することで、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() が Skill コマンドを発見
  ├── Instance       → Instance.state() がコマンドリストのライフサイクルを管理
  ├── BusEvent       → Command.Event.Executed イベント定義
  └── Session/Schema → SessionID、MessageID 型の依存関係

依存されているモジュール:
  ├── SessionPrompt  → command() 関数が Command.get() を消費してコマンドを実行
  └── TUI / API      → Command.list() が利用可能なコマンドリストを表示