跳转到内容

Command 源码解读

模块概述

Command 模块是 OpenCode 的配置化命令系统。与 Go 版本的命令对话框架构(CommandDialogMultiArgumentsDialog 等 6 个文件)截然不同,TS 版本将命令简化为 3 个文件——核心思想是:命令不是可执行函数,而是带有模板的配置项

命令有四个来源:两个内置命令(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 字段设计:使用 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)
2Config 自定义命令可覆盖内置命令
3MCP 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() 解析。解析策略有三层回退:

  1. 文件系统路径:首先尝试将引用解析为文件路径,读取文件内容替换
  2. Agent 名称:如果文件不存在,尝试将引用解析为 Agent 名称,获取该 Agent 的配置信息
  3. 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 处理:

  1. 注入 StructuredOutput 工具:在工具列表中添加一个隐藏的 JSON 输出工具
  2. 注入 system prompt:指导模型使用该工具输出结构化数据
  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 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() 展示可用命令列表