给 opencode 写自定义命令:把重复指令固化成一条斜杠命令

2026-08-04

本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。

先把名字说清楚:这篇讲的 opencode 是那个跑在终端里的开源 AI 编码 Agent 项目(仓库 anomalyco/opencode),不是泛指”开源代码”,也不是任何名字相近的模型。

**它的自定义命令不是”给提示词起个别名”,而是把一段提示词连同「用哪个 agent、哪个模型、要不要开子会话、执行前先跑哪几条 shell」一起冻结下来的可复用输入。**想清楚这一点,“什么该做成命令”就有了判断依据:只有当你能提前把一件事的输入形状写死——固定的上下文来源、固定的产出格式、参数只在少数几个位置变化——它才值得做成命令。否则你只是把一段每次都要改的话搬进了文件里,还多了一层找文件的成本。

opencode 是一个 MIT 许可证的开源项目(LICENSE 里署名 Copyright 2025 opencode),仓库是个 monorepo,packages/ 下有 32 个包。下面这条链路涉及的代码分散在三四个包里,但读起来并不绕。

站内已有的几篇相邻文章分工不同:Claude Code 的斜杠命令怎么写讲的是另一个工具的命令体系,命令导航与组织命令与 agent 的映射关系讲的是跨工具的组织方法论;这一篇只做一件事——把 opencode 这个项目里命令从磁盘到模型输入的完整链路拆开,每个结论都对得上具体文件。

一、命令从哪来:两个定义入口、四轮汇总、一个命名空间

命令有两个定义入口。一是 markdown 文件,二是配置文件里的 command 字段。

文件入口的扫描逻辑在 packages/opencode/src/config/command.ts

for (const item of await Glob.scan("{command,commands}/**/*.md", {
  cwd: dir,
  absolute: true,
  dot: true,
  symlink: true,
})) {
  const md = await ConfigMarkdown.parse(item).catch(() => undefined)
  if (!md) continue

  const name = configEntryNameFromPath(path.relative(dir, item), ["command/", "commands/"])

  const config = {
    name,
    ...md.data,
    template: md.content.trim(),
  }

这几行信息量不小。{command,commands} 说明单数复数目录都认——官方文档 packages/web/src/content/docs/commands.mdx 里写的是 commands/,而 opencode 自己仓库用的是 .opencode/command/,两种写法都能被扫到。**/*.md 说明支持子目录嵌套,而名字由 configEntryNameFromPath 生成:它把相对路径去掉 command/commands/ 前缀、再去掉扩展名,剩下什么就是什么。也就是说放在子目录里的文件,目录段会留在命令名里,这一点值得你自己建一个子目录试一次再决定要不要分层。

frontmatter 的字段由 packages/core/src/v1/config/command.ts 里的 schema 定义,只有 templatedescriptionagentmodelvariantsubtask 这几个键,其中 template 是必填的——但在文件写法里它由正文自动填充(template: md.content.trim()),所以你不要在 frontmatter 里再写一个 template:

JSON 入口在配置里直接写,文档给的形状是这样:

{
  "$schema": "https://opencode.ai/config.json",
  "command": {
    "test": {
      "template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes.",
      "description": "Run tests with coverage",
      "agent": "build",
      "model": "anthropic/claude-3-5-sonnet-20241022"
    }
  }
}

两个入口最后汇进同一张表。汇总发生在 packages/opencode/src/command/index.ts,顺序是:先塞进两条内置命令 initreview,再遍历配置里的 command 逐个写入,再遍历 MCP 提示词写入,最后遍历 skill 写入。前三轮都是直接赋值——同名就覆盖,所以自定义命令能盖掉内置的 initreview(文档也明说了这一点);只有 skill 那一轮带了让位判断:

for (const item of yield* skill.all()) {
  if (commands[item.name]) continue

结论落到实践上就是:命令名、MCP 提示词名、skill 名共用一个 / 前缀的命名空间。你新加一条 /review,覆盖掉的是内置的代码评审命令;你把 skill 和命令起成同一个名字,露出来的是命令、skill 被跳过。冲突不会报错,只会静悄悄地生效一个。

组成部分它负责什么对应仓库位置你什么时候会碰到它
命令文件加载器扫描 command/commands/ 下的 md,生成命令名与模板packages/opencode/src/config/command.ts新建命令文件、命令死活不出现时
frontmatter 解析解析 YAML 头,失败时走宽松兜底packages/opencode/src/config/markdown.tspackages/core/src/config/markdown.ts头里写了带冒号的描述、命令被静默跳过时
命令注册表合并内置命令、配置命令、MCP 提示词、skill,算出参数提示packages/opencode/src/command/index.ts命令重名、想知道 / 列表从哪来时
执行与替换切参数、替占位符、跑 shell、决定走不走子代理packages/opencode/src/session/prompt.ts参数传得不对、shell 输出没进提示词时
目录解析决定扫哪些目录、谁覆盖谁packages/opencode/src/config/paths.ts全局命令和项目命令撞名时

二、参数到底怎么传:四条替换规则

这是最容易凭印象猜错的地方,所以直接看 packages/opencode/src/session/prompt.ts 里的原文:

const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
const templateCommand = yield* Effect.promise(async () => cmd.template)

const placeholders = templateCommand.match(placeholderRegex) ?? []
let last = 0
for (const item of placeholders) {
  const value = Number(item.slice(1))
  if (value > last) last = value
}

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]
})
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)

if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
  template = template + "\n\n" + input.arguments
}

配套的四个正则写在同一个文件末尾:

const bashRegex = /!`([^`]+)`/g
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g

四条规则由此确定。

第一,切分认引号。argsRegex 把双引号或单引号包起来的整段当成一个参数,之后 quoteTrimRegex 只剥掉首尾的那一个引号。所以带空格的参数必须加引号,文档里 /create-file config.json src "{ \"key\": \"value\" }" 这个例子能成立就是靠这条。正则里还留了 [Image \d+] 这个分支,图片占位标记会被当作一个独立参数,不会被空格切碎。

第二,编号最大的占位符会吃掉剩余全部参数。代码先扫出模板里所有 $数字,取其中最大的那个记作 last;替换时如果当前位置等于 last,返回的是 args.slice(argIndex).join(" ")——从这个位置起把后面所有参数用空格连起来。这意味着模板里只写 $1,效果几乎等同于 $ARGUMENTS;而写了 $1 $2 的模板,多传的第三、第四个参数会全部并进 $2。这不是 bug,是让”最后一位收尾巴”的设计,但你写模板时要知道它在。

第三,参数不够就是空串,不报错。if (argIndex >= args.length) return ""——少传一个参数,模板对应位置留下一个空洞,句子结构还在,模型很容易照着残缺的句子自己脑补。这比报错难查得多。

第四,模板里一个占位符都没有时,参数不会被丢掉,而是被追加到模板末尾(隔一个空行)。这条挺好用:一条不带占位符的 /commit 命令,你临时打 /commit 只改 web 包 就能补一句上下文,不必改文件。

还有一个细节:$ARGUMENTS 替换进去的是 input.arguments 原文,没有经过切分和去引号,而 $1$2 拿到的是切分并去引号后的片段。两者不是同一份东西。

命令注册表里的 hints 函数会把模板里出现的占位符收集起来供界面提示:

export function hints(template: 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
}

这里的 .sort() 没带比较函数,走的是字符串排序,所以占位符编号一旦上双位数,提示里的排列顺序和你直觉的数字顺序不一致。写到 $9 以上本身就该反思拆命令了。

三、! 反引号和 @:把上下文自动抓进提示词

替换完参数,接着处理 shell:

const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
  const cfg = yield* config.get()
  const sh = Shell.preferred(cfg.shell)
  const results = yield* Effect.promise(() =>
    Promise.all(
      shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
    ),
  )
  let index = 0
  template = template.replace(bashRegex, () => results[index++])
}

模板里写成 !`git diff` 的片段会被抓出来实际执行,输出替换回原位。三个要点:一是 Promise.all,多条命令并发跑,别指望它们按书写顺序串行;二是 nothrow: true,命令失败不会中断流程,失败时拿到的文本照样会被塞进提示词,模型可能把一段报错当成代码来分析;三是它无条件执行——只要命令被调用,这些 shell 就会在你的机器上跑一遍,跟模型说什么无关。

opencode 自己仓库里的 .opencode/command/commit.md 就是这个用法的实例,模板末尾挂了三段:

## GIT DIFF

!`git diff`

## GIT DIFF --cached

!`git diff --cached`

## GIT STATUS --short

!`git status --short`

这是”把重复指令固化”最典型的形态:你原本每次要手动贴 diff,现在命令自己去取。

顺序上还有一处必须点明——参数替换在前,shell 抓取在后。这意味着 $ARGUMENTS 的内容可以出现在反引号命令里并被当作命令的一部分执行,仓库里的 .opencode/command/changelog.md 就写了 !`bun script/raw-changelog.ts $ARGUMENTS`。好用,同时也是一处实打实的注入面:这条命令的参数是直接进 shell 的字符串,你在自己机器上敲当然没问题,但一旦这类模板被团队共享、或者参数来自你没细看的粘贴内容,跑起来的就不只是你想跑的那条命令。

文件引用是另一条通路。packages/opencode/src/config/markdown.ts 里:

export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export const SHELL_REGEX = /!`([^`]+)`/g

模板里写 @src/components/Button.tsx,文件内容会被解析成提示词的一部分。前面的负向后顾保证了紧跟在单词字符或反引号后面的 @ 不算引用,邮箱地址之类不会被误当成路径去读。

三条通路叠加起来的实际含义是:一条命令可以在你完全不看的情况下,把 diff、命令输出和若干文件内容打包发给模型服务商。放什么进去,等于决定了外泄面有多大。带密钥的 .env、私有仓库的整段源码,一旦写进模板就是每次调用都发一遍。相关的权限尺度可以参考最小权限怎么设计

四、路由:agent、model 与 subtask

三个可选键决定这条命令由谁执行。agent 指定 agent,model 覆盖模型,subtask 强制走子代理。判定逻辑一行说完:

const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true

即:命令指到一个 subagent 模式的 agent 上,默认就走子代理,除非显式写 subtask: false;或者显式写 subtask: true 强制走。模型的选取顺序也在同一段代码里:命令自带的 model 优先,其次是命令所指 agent 的模型,再次才是当前会话的模型。

走子代理这条路有个容易忽略的后果。子代理分支下构造的是单个 subtask 部件,它的 prompt 取自 templateParts.find((y) => y.type === "text")?.text ?? ""——只取模板解析出的第一段文本。也就是说,模板里那些 @文件 引用不会作为文件部件一起传给子代理。如果你的命令重度依赖 @ 引用,又打开了 subtask,结果和你在主会话里跑不是一回事。

什么时候该开 subtask?文档给的理由是”不污染主会话上下文”。判断标准可以更朴素一点:这条命令的产出你只想要一个结论(比如一份评审意见、一份变更清单),中间那几十轮工具调用你根本不想看见,就开;产出需要你接着往下聊、要在同一份上下文里继续改,就别开。这类分工的一般性讨论见命令与 agent 的映射关系,会话上下文预算见Agent 上下文预算

内置的两条命令正好演示了两种取向。init 走主会话,因为生成 AGENTS.md 之后你多半要接着改;reviewpackages/opencode/src/command/index.ts 里直接写了 subtask: true,因为评审要的就是一份结论。review 的模板还展示了一种参数用法——不是把参数塞进句子,而是让模型根据参数长相自己分支:模板里写 Input: $ARGUMENTS,然后列出”没有参数就看未提交改动、像 commit hash 就 git show、像分支名就 diff、像 PR 就走 gh”这几条规则。参数不够结构化时,这比硬塞占位符更稳。

五、边界与代价:这个设计放弃了什么

自定义命令这套机制刻意做得很薄,薄就意味着有它明确不管的事。

**它不管条件与循环。**模板是纯文本替换,没有分支、没有判断、没有”如果上一步失败就重试”。想要条件逻辑,只有两条路:要么写进提示词交给模型自己判断(就像内置 review 那样),要么把逻辑塞进反引号里的 shell 脚本。前者不确定,后者跑的是真命令。

**它不管参数校验。**schema 里没有参数类型、没有必填标记。少传就是空串,多传就被最大编号那个占位符吸走。想要”参数不对就拒绝执行”,机制层面给不了,只能在提示词里写要求并接受模型可能不照做。参数校验的一般做法见Agent 参数校验

**它不管热更新。**仓库内置的定制说明文件 packages/core/src/plugin/skill/customize-opencode.md 写得很清楚:配置在启动时加载一次,不热重载,改完 agent 文件、命令文件或配置,要退出重开才生效。改完命令发现行为没变,先别怀疑语法。

**它不适合频次低的事。**做一条命令的成本不只是写文件,还有你记住它存在、记住参数顺序的成本。一个月用一次的流程,你多半会忘掉自己当初怎么设计的参数,到时候还得打开文件看一眼——那不如直接把话打出来。

**它不适合每次都要改一大段的事。**判断方法很直接:把你最近五次实际敲的指令并排放,如果变化的部分只是一两个名词,做成命令;如果变化的是要求本身、每次都在换角度,那说明这件事的”输入形状”还没稳定,现在固化只会让你多绕一步——先在会话里多跑几次,等形状稳了再固化。

**它不替你分担风险。**这类工具本来就会在你的机器上跑 shell、直接改代码文件、把内容发给模型服务商。做成命令之后,这些动作从”你每次手敲一遍”变成”敲两个字符就全跑一遍”,误触的代价随之放大。带删除、带推送、带部署的动作固化成命令之前,先想清楚手滑按下去会发生什么。

六、上手与避坑清单

**命令文件建好了却不出现在列表里。**加载器对 frontmatter 解析失败的处理是 .catch(() => undefined); if (!md) continue——YAML 解析失败的文件被静默跳过,不报错、不提示。最常见的触发方式是描述里写了没加引号的冒号。packages/core/src/config/markdown.ts 里有个 sanitize 兜底,会把含冒号的未加引号值改写成块标量再试一次,注释里明说这是为了兼容其它编码 Agent 的宽松写法,但它只处理这一类情况。避法:描述一律加引号,改完重启,再用 opencode debug configpackages/opencode/src/cli/cmd/debug/config.ts,描述是 show resolved configuration)看合并后的配置里到底有没有你这条。

**字段值写错的表现和上一条不一样。**schema 校验失败走的是抛 InvalidError,带上文件路径。看到这类错误别去猜,直接对着 packages/core/src/v1/config/command.ts 的六个键核一遍。

全局命令和项目命令撞名。packages/opencode/src/config/paths.ts 里的目录列表是全局配置目录打头,然后是从当前目录向上走到 worktree 找到的 .opencode,再加上从 home 找到的那些。加载时逐个目录深合并,后加载的覆盖同名的先加载的。定制说明文件给出的口径是项目覆盖全局。真正容易翻车的是 monorepo:子包和仓库根都有 .opencode/command/deploy.md 时,生效的是哪个别靠推理,跑一次 opencode debug config 看结果。

在模板里贴敏感文件。@ 引用和反引号 shell 都会把内容原样送进提示词。把 .env、私钥文件、含客户数据的 fixture 写进模板,等于每次调用都发一遍,而且因为是模板,你不会每次都想起来它在那儿。避法:模板只引用你愿意公开的路径,敏感文件走人工按需粘贴。

**把参数直接拼进反引号命令。**替换顺序决定了参数会成为 shell 字符串的一部分。自己本机的小工具无所谓,共享给团队的命令要么别这么写,要么在 shell 脚本里自己做校验。

给命令挂了别的模型却没意识到成本结构变了。model 键会覆盖当前会话的模型选择,一条你天天敲的命令挂在一个更贵的模型上,消耗是按调用次数累加的。各家计费与限流规则不同且会调整,以官方最新说明为准;这里要提醒的只是”命令让调用变廉价,而调用背后的账单没变廉价”。相关做法见Agent 成本失控怎么控

**用子目录分层之前先试一次。**命令名由相对路径推导,子目录会体现在名字里。分层之前建一个文件试出真实的调用名,别按想当然的短名去写文档。

收束:三个问题和一份阅读顺序

要不要把手上这段重复指令做成命令,问自己三句话就够:这段指令最近一个月我敲过几次;每次变化的是名词还是要求本身;它跑起来会不会动我不想被动的东西(推送、删除、部署)。三个答案分别是”够多""只是名词""不会”,再动手。

上手顺序建议从模仿开始:先打开 opencode 仓库自带的 .opencode/command/commit.md,它把 frontmatter 的 modelsubtask、正文的指令约束、末尾的三段 shell 抓取全用上了,是一份现成的骨架。想弄清参数为什么替换成那样,回到 packages/opencode/src/session/prompt.ts 看那段替换逻辑和文件末尾的四个正则——注意这个文件本身上千行,你只需要找到命令执行那一段,别从头读;想弄清命令为什么没加载出来,看 packages/opencode/src/config/command.ts,整个文件不到四十行;想知道谁盖了谁,看 packages/opencode/src/command/index.ts 里四轮写入的先后,这个文件一百七十来行。真正跟命令有关的代码合起来三百行上下,比任何二手说明都可靠——这个项目仍在高频改动,读代码永远是最短的路。

本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 opencode 读哪些规则文件:终端编码 Agent 的规则加载顺序开源终端 Agent opencode 接 MCP:两类接法与认证避坑

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。