Command ソースコード解説
モジュール概要
Command モジュールは、OpenCode の 設定駆動のコマンドシステム です。Go 版のコマンドダイアログアーキテクチャ(CommandDialog、MultiArgumentsDialog など、6ファイルにわたる)とは異なり、TS 版ではわずか3ファイルに簡略化されています。中心となる考え方は、コマンドは実行可能な関数ではなく、テンプレートを持つ設定項目である というものです。
コマンドは4つのソースから提供されます:2つの組み込みコマンド(init、review)、Config カスタムコマンド、MCP Prompts、および Skills です。コマンドの実行は SessionPrompt.command() によって消費され、テンプレート変数の置換を行い、その結果をユーザーメッセージとして Session に注入します。
主要ファイル
| ファイル | 責務 |
|---|---|
src/command/index.ts | モジュールメインファイル:Command.Info Schema、コマンドの発見とマージ、get / list クエリインターフェース |
src/command/template/initialize.txt | init コマンドテンプレート:Agent にコードベースを分析して AGENTS.md を生成させる指示 |
src/command/template/review.txt | review コマンドテンプレート:Agent にコードレビューを実行させる指示 |
src/session/prompt.ts | SessionPrompt.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
}
| フィールド | 型 | 説明 |
|---|---|---|
name | string | 一意なコマンド識別子 |
description | string? | コマンドの機能説明 |
agent | string? | 実行する Agent を指定(デフォルトを上書き) |
model | string? | モデルを指定(デフォルトを上書き) |
source | "command" | "mcp" | "skill"? | コマンドソースのタグ |
template | Promise<string> | string | コマンドテンプレート、非同期読み込み対応 |
subtask | boolean? | サブタスクとして実行するかどうか(独立した Agent インスタンス) |
hints | string[] | パラメータプレースホルダーのリスト(例:["$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:組み込みコマンド
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) | — |
| 2 | Config カスタムコマンド | 組み込みコマンドを上書き可能 |
| 3 | MCP Prompts | Config コマンドを上書き可能 |
| 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 の順です。テンプレートは「確信がない場合は指摘しない」「変更された部分のみレビューする」「スタイルに過度にこだわらない」ことを強調しています。
review は subtask: 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層のフォールバックがあります:
- ファイルシステムパス: 参照をファイルパスとして解決を試み、ファイル内容を読み取って置換
- Agent 名: ファイルが存在しない場合、参照を Agent 名として解決し、その Agent の設定を取得
- ホームディレクトリ:
~/で始まるパスをサポートし、ユーザーのホームディレクトリに自動展開
// 簡略化された解決ロジック
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 処理を注入します:
- StructuredOutput ツールの注入: 非表示の JSON 出力ツールをツールリストに追加
- システムプロンプトの注入: モデルに構造化データ出力のためにこのツールを使用するよう指示
- ツール呼び出しの強制:
toolChoice: "required"を設定し、モデルが必ず StructuredOutput ツールを呼び出すことを保証 - エラー処理: モデルが StructuredOutput ツールを呼び出さずプレーンテキストで応答した場合、
StructuredOutputErrorをスロー
// 簡略化されたロジック
if (needsStructuredOutput) {
tools["structured_output"] = structuredOutputTool(schema)
params.toolChoice = "required" // ツール呼び出しを強制
// LLM がツールを呼び出さない場合 → StructuredOutputError
}
この設計は、Structured Output をツール呼び出しに偽装することで、LLM の既存のツール呼び出し機能を活用し、出力フォーマットの信頼性を保証します。
設計上のトレードオフ
なぜコマンドは実行可能な関数ではなく設定なのか?
Go 版の Handler は func(cmd Command) tea.Cmd であり、任意のロジックを実行できました。TS 版ではこれを設定 + テンプレートに簡略化しています:
- セキュリティ: テンプレートは単なるテキストでありコードを実行しないため、任意のコード実行のリスクを排除
- 直列化可能性: 純粋なデータ設定は JSON Schema で検証でき、MCP プロトコル経由で送信可能
- 統一インターフェース: 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() が利用可能なコマンドリストを表示