Command 源码解读
模块概述
Command 模块是 OpenCode 的配置化命令系统。与 Go 版本的命令对话框架构(CommandDialog、MultiArgumentsDialog 等 6 个文件)截然不同,TS 版本将命令简化为 3 个文件——核心思想是:命令不是可执行函数,而是带有模板的配置项。
命令有四个来源:两个内置命令(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 字段设计:使用 getter 而非普通字符串,因为 MCP prompt 需要异步获取。Zod 对 z.promise().or(z.string()) 的类型推断有 bug,所以手动用 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() 管理——每个项目实例初始化一次,后续复用缓存。get() 和 list() 等待初始化完成后读取:
const state = Instance.state(async () => {
const cfg = await Config.get()
const result: Record<string, Info> = { /* ... */ }
// 四阶段合并 ...
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))
}
四阶段合并逻辑
第一阶段:内置命令
注册 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),
},
}
第二阶段:Config 自定义命令
遍历配置文件的 command 字段,每个条目映射为 Command.Info。Config 命令可以覆盖同名内置命令。
第三阶段:MCP Prompts
MCP prompt 的模板需要异步获取。getter 不能是 async,所以手动返回 Promise。MCP prompt 的参数名按顺序映射为 $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}`) ?? [],
}
}
第四阶段:Skills
Skills 最低优先级。如果已存在同名命令则跳过(if (result[skill.name]) continue),不像 Config/MCP 那样覆盖。
优先级总结:
| 优先级 | 来源 | 覆盖规则 |
|---|---|---|
| 1(最高) | 内置命令(init、review) | — |
| 2 | Config 自定义命令 | 可覆盖内置命令 |
| 3 | MCP Prompts | 可覆盖 Config 命令 |
| 4(最低) | Skills | 同名跳过,不覆盖任何已有命令 |
这种四层优先级设计确保了:
- 内置命令始终可以通过 Config 被定制覆盖
- MCP 提供的命令可以覆盖用户配置(适用于团队共享 MCP Server 的场景)
- Skills 作为最低优先级,永远不会意外覆盖已有命令
MCP Prompt 参数映射
MCP prompt 的参数映射是自动完成的——MCP 协议中 prompt 的 arguments 定义(名称 + 描述)被映射为位置参数:
prompt.arguments
? Object.fromEntries(
prompt.arguments.map((arg, i) => [arg.name, `$${i + 1}`])
)
: {}
例如,一个 MCP prompt 定义了 arguments: [{ name: "language" }, { name: "code" }],会被映射为 { language: "$1", code: "$2" }。用户调用时传入的参数按位置填充:第一个参数给 $1,第二个给 $2。
模板本身是异步获取的——通过 MCP.getPrompt() 返回 Promise,getter 返回这个 Promise,消费方通过 await command.template 统一处理。
内置命令详解
INIT — 项目初始化
模板指导 Agent 分析代码库并生成 AGENTS.md,包含构建命令、代码风格、错误处理约定等。使用 ${path} 指定输出路径,$ARGUMENTS 允许用户附加自定义指令。模板约 15 行,目标是生成约 150 行的项目记忆文件。
REVIEW — 代码审查
模板是一个结构化的代码审查 prompt(约 150 行),根据参数类型自动选择审查范围:
- 无参数 →
git diff审查未提交更改 - commit hash →
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 模式
├─ 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() 解析。解析策略有三层回退:
- 文件系统路径:首先尝试将引用解析为文件路径,读取文件内容替换
- Agent 名称:如果文件不存在,尝试将引用解析为 Agent 名称,获取该 Agent 的配置信息
- Home 目录:支持
~/开头的路径,自动展开为用户 home 目录
// 解析逻辑简化
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 分支、日期、环境变量)可以在命令执行时注入模板。
Subtask 分发
const isSubtask =
(agent.mode === "subagent" && command.subtask !== false) ||
command.subtask === true
- Subtask 模式:创建
SubtaskPart,由 TaskTool 在独立 Agent 中执行 - 普通模式:模板文本直接作为用户消息注入 Session
Structured Output 特殊处理
当命令需要 LLM 以结构化格式输出时(如 JSON Schema),Session 执行循环注入特殊的 Structured Output 处理:
- 注入 StructuredOutput 工具:在工具列表中添加一个隐藏的 JSON 输出工具
- 注入 system prompt:指导模型使用该工具输出结构化数据
- 强制工具调用:设置
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 prompt、Skill 结构各异,但都归约为
Command.Info
为什么用模板替换而不是回调?
- 跨协议兼容:MCP prompt 的参数映射直接使用
$N,不需要额外抽象层 - 可预测性:用户看到模板原文就能理解参数位置
- 延迟绑定:getter 设计让 MCP prompt 可以异步加载,消费方无需关心同步/异步
为什么 MCP prompt 用 Promise 而不是 async getter?
JavaScript getter 不能是 async(必须同步返回值)。new Promise(async ...) 是 workaround——getter 同步返回 Promise,消费方通过 await command.template 统一处理。
与其他模块的关系
Command 模块
├── Config → 读取 cfg.command 自定义命令配置
├── MCP → MCP.prompts() 发现 MCP prompt 命令
├── Skill → Skill.all() 发现技能命令
├── Instance → Instance.state() 管理命令列表生命周期
├── BusEvent → Command.Event.Executed 事件定义
└── Session/Schema → SessionID、MessageID 类型依赖
被依赖:
├── SessionPrompt → command() 函数消费 Command.get() 执行命令
└── TUI / API → Command.list() 展示可用命令列表