oh-my-pi coding-agent 斜杠命令深度解析:从多源发现到模板展开
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
oh-my-pi 的coding-agent支持来自多个外部工具(Claude Code、OpenAI Codex、OpenCode 等)以及自身原生目录的 Markdown 斜杠命令。本文基于仓库内 slash-command-internals.md 的设计文档,结合 capability 注册表、命令展开工具 与 交互式模式实现 逐层剖析:命令如何被发现、按什么优先级去重、在交互界面里如何呈现,以及提交 prompt 时如何被展开并路由到 LLM。读完你能掌握「命令从磁盘文件到实际 prompt」的完整链路,并能准确解释同名命令为何某个赢、某个被标记为 shadowed。
一、发现模型:capability 注册表与 first-wins 去重
斜杠命令在coding-agent中被建模为一个 capability(能力),其定义位于 slash-command.ts:
export const slashCommandCapability = defineCapability<SlashCommand>({ id: "slash-commands", displayName: "Slash Commands", description: "Custom slash commands defined as markdown files", key: cmd => cmd.name, toExtensionId: cmd => `slash-command:${cmd.name}`, validate: cmd => { /* name/path/content/level 校验 */ }, });核心契约:
- id 为
slash-commands:所有 provider 通过这个 id 注册自己,注册入口是 builtin.ts 等文件里的registerProvider<SlashCommand>(slashCommandCapability.id, {...})调用。 - key 是命令名(
cmd.name):去重完全以名字为准。 - validate 校验四要素:
name非空、path非空、content非 undefined、level必须是user/project/native三者之一。校验失败的条目会被注册表丢弃并记录警告。
优先级排序与 first-wins 语义
capability/index.ts 中的registerProvider会把 provider 按 priority降序插入数组(findIndex(p => p.priority < provider.priority)),加载时loadImpl并行调用所有 provider,然后按注册顺序遍历条目执行去重。源码中的去重逻辑:
for (const item of allItems) { const key = capability.key(item); if (key === undefined) { deduped.push(item); continue; } const keySeen = seen.has(key); seen.add(key); // ... if (keySeen || aliasSeen) { item._shadowed = true; } else { deduped.push(item); } }即先出现(优先级最高)的条目赢得 key 所有权,后来同名者被标记_shadowed = true。这个行为跨 provider 生效,同一 provider 内部返回重名也会触发。
当前 provider 优先级
| 优先级 | provider | 说明 |
|---|---|---|
| 100 | native | OMP 原生(.omp目录),见 builtin.ts 中PRIORITY = 100 |
| 90 | omp-plugins | 扩展包 / npm / link 插件,见 omp-plugins.ts |
| 80 | claude | Claude Code(.claude),见 claude.ts |
| 70 | claude-plugins | Claude 插件市场/本地插件,见 claude-plugins.ts |
| 70 | agents | .agent/.agents标准目录,见 agents.ts |
| 70 | codex | OpenAI Codex,见 codex.ts |
| 55 | opencode | OpenCode,见 opencode.ts |
平级(tie)行为:优先级相同的 provider 保持注册顺序。当前导入顺序是claude-plugins→agents→codex,因此三者同名碰撞时插件命令胜出。这一点可以从 capability/index.ts 的插入策略推断:只有priority <才插到前面,相等时追加到队尾。
内建命令不走这个 capability
注意一个关键边界:内建命令(built-ins)不是slash-commandscapability 的条目。它们住在统一的内建注册表 builtin-registry.ts,在 TUI 和 ACP/RPC 模式下先于会话级扩展/自定义/文件命令被分派;自动补全与 ACP 可用性也优先预留内建名字和别名。这一设计让/help、/model等系统命令永远不会被用户文件命令覆盖。
文件扫描行为
各 provider 大多复用 helpers.ts 中的loadFilesFromDir(...)。该函数:
- 默认非递归匹配
*.md(recursive参数默认false,pattern 为*.md而非**/*.md); - 使用原生 glob,
gitignore: true、hidden: false、fileType: FileType.File; - 并行读取匹配文件并转换(transform)成
SlashCommand条目。
源码中有一段注释解释了为什么必须显式传递recursive:
原生 glob 默认
recursive为 true 并会把*.{ts,js}重写为**/*.{ts,js},否则会遍历整棵子树(例如把~/.codex/tools下 venv 的 site-packages 当成工具导入,#8552)。
因此隐藏文件/目录不会被加载、被 git 忽略的路径会被跳过,文件顺序遵循原生 glob 返回顺序,除非 provider 自己追加排序。
二、各 provider 的源路径与本地优先级
nativeprovider(builtin.ts)
搜索根来自.omp目录,getConfigDirs()返回项目优先:
- 项目级:
<cwd>/.omp/commands/*.md - 用户级:当前激活 profile 的 agent 目录
commands/*.md(默认 profile 是~/.omp/agent/commands/*.md;命名 profile 是~/.omp/profiles/<name>/agent/commands/*.md)
源码确认:
const projectDir = await ifNonEmptyDir(ctx.cwd, PATHS.projectDir); if (projectDir) result.push({ dir: projectDir, level: "project" }); const userDir = await ifNonEmptyDir(getAgentDir()); if (userDir) result.push({ dir: userDir, level: "user" });所以项目级 native 命令在同名时压过用户级 native 命令。native 的 slash 命令 loader 只匹配md扩展名,命令名是文件名去掉.md后缀。
omp-pluginsprovider(omp-plugins.ts)
扫描已配置扩展包根目录以及已启用 npm/link 插件中的commands/*.md。根优先级依次为:调用/CLI → 项目 settings → 用户 settings → 已安装插件。市场(marketplace)根在此处被刻意排除,避免重复发现,改由claude-plugins处理。
claudeprovider(claude.ts)
受commands.enableClaudeUser与commands.enableClaudeProject两个 settings 门控:
- 用户级:
~/.claude/commands/**/*.md(递归) - 项目级:
<cwd>/.claude/commands/**/*.md(递归)
递归扫描后有一个 Claude 特有的处理:子目录中的命令额外获得一个命名空间别名。addClaudeCommandNamespaceAliases把foo/bar.md同时注册为bar(原始名)和foo:bar(别名):
nestedCommands.push(command); aliases.push({ ...command, name: relativeName.replace(/[\\/]+/g, ":") });注意该 provider 先 push 用户条目再 push 项目条目,所以在此 provider 内部同名碰撞时用户 Claude 命令压过项目 Claude 命令(跨 provider 仍由全局优先级裁决)。
codexprovider(codex.ts)
加载:
- 用户级:
~/.codex/commands/*.md - 项目级:
<cwd>/.codex/commands/*.md
两侧加载后以用户优先顺序展平,故同 provider 内用户命令压过项目命令。Codex 命令内容经过parseFrontmatter剥离,frontmatter 中的name可以覆盖命令名,否则使用文件名。
opencodeprovider(opencode.ts)
受commands.enableOpencodeUser与commands.enableOpencodeProject门控:
- 用户级:
~/.config/opencode/commands/*.md - 项目级:
<cwd>/.opencode/commands/*.md
同样用户优先展平,frontmattername可覆盖文件名。
claude-pluginsprovider(claude-plugins.ts)
通过listClaudePluginRoots(...)读取三份注册表:~/.claude/plugins/installed_plugins.json、~/.omp/plugins/installed_plugins.json,以及从 cwd 解析出的最近项目级注册表。对每个插件根扫描<pluginRoot>/commands/*.md(目录可被插件配置的commands/slash-commands键重映射),命令名加插件名前缀:<plugin>:<command>。
跨三份注册表时按优先级合并而非排序:--plugin-dir注入的根最先,项目级条目次之(对同 plugin id 遮蔽用户级条目),用户级条目最后;对同一 plugin id,OMP 注册表优先于 Claude 注册表。每份注册表内部保留 JSON 数据的逐插件顺序,没有额外排序步骤。
agentsprovider(agents.ts)
从 cwd 向上走到仓库根,扫描.agent/与.agents/下的非递归commands/*.md,然后扫描~/.agent/commands与~/.agents/commands。provider 内部顺序:最近的项目根第一;.agent先于.agents;项目条目先于用户条目。
三、物化为运行时FileSlashCommand
extensibility/slash-commands.ts 中的loadSlashCommands()把 capability 条目转换为 prompt 时刻使用的FileSlashCommand对象:
export interface FileSlashCommand { name: string; description: string; content: string; source: string; // 例如 "via Claude Code (User)" _source?: { providerName: string; level: "user" | "project" | "native" }; }转换步骤(对每个命令):
- 解析 frontmatter/body(
parseFrontmatter); - description 来源:
- 有
frontmatter.description则取它; - 否则取正文第一行非空内容(超过 60 字符截断并追加
...);
- 有
- 保留解析后的 body 作为可执行模板内容;
- 计算展示用 source 字符串,例如
via Claude Code Project(via ${providerName} ${首字母大写的 level})。
frontmatter 解析的严重级别与条目 level 相关:
- 发现来的 user/project 命令使用
warn级别解析,解析失败时回退到 key/value 宽松解析; - 显式标记
native的 capability 条目使用fatal解析; - 打包的 fallback 模板同样
fatal。
源码对应行:
const { description, body } = parseCommandTemplate(cmd.content, { source: cmd.path ?? `slash-command:${cmd.name}`, level: cmd.level === "native" ? "fatal" : "warn", });打包的 fallback 命令
文件命令加载完成后,task/commands.ts 中嵌入的EMBEDDED_COMMAND_TEMPLATES会以source: "bundled"追加——仅当同名命令尚未出现时。当前嵌入集合来自构建期以{ type: "text" }内联的 Markdown(如init命令):
import initMd from "../prompts/agents/init.md" with { type: "text" }; const EMBEDDED_COMMANDS = [{ name: "init.md", content: prompt.render(initMd) }]; export const EMBEDDED_COMMAND_TEMPLATES: ReadonlyArray<{ name: string; content: string }> = EMBEDDED_COMMANDS;这意味着打包命令永远垫底,任何用户/项目/插件命令都可以按名字覆盖它们。
四、交互模式:命令列表从哪来
交互模式(interactive-mode.ts)把多个来源合并用于自动补全与命令路由。构造时先构建 pending 命令列表:
- 内建命令(
BUILTIN_SLASH_COMMANDS,含参数补全与选中命令的内联提示) - 扩展注册的命令(
extensionRunner.getRegisteredCommands(...)) - TypeScript 自定义命令(
session.customCommands,映射为斜杠命令标签) - 可选的skill 命令(
/skill:<name>,受skills.enableSkillCommands门控)
随后init()调用refreshSlashCommandState(...),加载文件命令并安装一个自动补全 provider(createPromptActionAutocompleteProvider,一个PromptActionAutocompleteProvider,内部包着CombinedAutocompleteProvider),其候选集包含:
- 上述 pending 命令;
- 发现的基于文件的命令;
- 名字未被内建/hook/自定义/skill/文件命令占用的 prompt-template 命令。
refreshSlashCommandState还会同步session.setSlashCommands(...),保证prompt 展开与自动补全看到同一份文件命令集合(agent-session.ts 的setSlashCommands)。交互模式中对 prompt-template 的排除逻辑值得注意(interactive-mode.ts):
const reservedNames = new Set<string>(); for (const command of this.#pendingSlashCommands) { reservedNames.add(command.name); for (const alias of command.aliases ?? []) reservedNames.add(alias); } for (const command of fileSlashCommands) { reservedNames.add(command.name); for (const alias of command.aliases ?? []) reservedNames.add(alias); } const promptTemplateCommands = this.session.promptTemplates .filter(template => !reservedNames.has(template.name)) .map(...);注释解释了原因:AgentSession.prompt()先expandSlashCommand再expandPromptTemplate,内建命令执行会先解析别名,所以补全候选要镜像这个解析顺序,避免同一个 token 既指向文件命令又指向 prompt 模板。
刷新生命周期
斜杠命令状态在这些时机被刷新:
- 交互模式初始化时;
/move切换工作目录后(applyCwdChange重置 capability 并对新 cwd 重新发现,interactive-mode.ts 中可见对refreshSlashCommandState(newCwd)的调用,且支持切回原目录时回滚);- 编辑器组件被替换时(selector-controller.ts);
- 显式插件重载流程,如
/reload-plugins。
没有持续的文件 watcher——命令目录变化不会实时反映,必须依赖上述刷新点。
其他展示面
Extensions 仪表盘也加载slash-commandscapability,展示 active/shadowed 命令条目,包括带_shadowed标记的重复项——这是诊断「为什么我的命令没生效」的直接入口。
五、路由与 prompt 管线中的位置
统一内建注册表在 TUI 与 ACP/RPC 模式下先于AgentSession.prompt(...)被检查。一个内建命令可以消费输入,也可以返回残余的 prompt 文本。TUI-only 内建命令不出现在 ACP 可用列表与分派中;ACP 可见的内建命令是有 text-modehandle的条目(见 acp-builtins.ts)。
越过内建边界后,AgentSession.prompt(...)在expandPromptTemplates !== false时按以下顺序处理斜杠输入(agent-session.ts):
- 扩展命令(
#tryExecuteExtensionCommand):/name命中扩展注册命令时立即执行 handler,prompt 返回false(已本地消费,不发给 LLM); - TypeScript 自定义命令与 MCP prompt 命令(
#tryExecuteCustomCommand):命中时可能返回string→ 用该字符串替换 prompt 文本(若为空串则表示已处理,不再发 LLM);void/undefined→ 视为已处理,不产生 LLM prompt;
- 基于文件的斜杠命令(
expandSlashCommand):文本仍以/开头时尝试 Markdown 命令展开; - Prompt 模板(
expandPromptTemplate):在斜杠/自定义处理之后应用; - 投递:
- idle:prompt 直接发给 agent;
- streaming:按
streamingBehavior排队为 steer/follow-up。
源码中这段管线一目了然:
if (expandPromptTemplates && text.startsWith("/")) { const handled = await this.#tryExecuteExtensionCommand(text); if (handled) return false; const customResult = await this.#tryExecuteCustomCommand(text); if (customResult !== null) { if (customResult === "") return false; text = customResult; } // Only if text still starts with "/" (wasn't transformed by custom command) if (text.startsWith("/")) { text = expandSlashCommand(text, this.#slashCommands); } } const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;这也解释了两个设计决策:内建名字在文件命令之前被预留;文件命令展开先于 prompt-template 展开;自定义命令可以「抹掉」开头的/,从而阻止后续文件命令匹配。
六、基于文件的斜杠命令展开语义
expandSlashCommand(text, fileCommands)(extensibility/slash-commands.ts)的行为:
- 仅当文本以
/开头时运行; - 从
/后的第一个 token 解析命令名(空格前); - 剩余文本经
parseCommandArgs解析为参数; - 在已加载的
fileCommands中做精确名字匹配(不模糊、不补全); - 命中后依次应用:
- 位置替换:
$1、$2…(1-based,越界得到空串); - 切片替换:
$@[start]/$@[start:length](1-based,start 越界或 length 非正得到空串); - 聚合替换:
$ARGUMENTS与$@; prompt.render渲染,上下文为{ args, ARGUMENTS, arguments };- 当模板没有使用任何内联参数占位符时,把参数追加在末尾(inline-argument fallback,
appendInlineArgsFallback)。
- 位置替换:
$替换的正则(command-args.ts):
content.replace( /\$@\[(\d+)(?::(\d*)?)?\]|\$ARGUMENTS|\$@|\$(\d+)/g, (match, startRaw?, lengthRaw?, positionalNum?) => { ... } );文档特别强调:替换只发生在模板字符串上。参数值里包含$1、$@等模式不会被递归替换——这避免了二次注入式展开。
parseCommandArgs的注意点
该解析器是简单的引号感知分词(command-args.ts):
- 支持
'单引号'与"双引号"保留空格; - 剥离引号定界符;
- 不实现反斜杠转义规则;
- 未闭合引号不是错误:解析器会消费到字符串末尾。
例如/commit "fix: handle edge" 'raw $1'会得到两个参数:fix: handle edge与raw $1(后者原样保留)。
七、未知/...输入的行为
核心斜杠逻辑不会拒绝未知斜杠输入。当内建、扩展、自定义、文件命令都没有命中时,expandSlashCommand返回原文本,字面上的/...prompt 会继续走 prompt-template 展开并投递给 LLM。TUI 与 ACP/RPC 都在session.prompt(...)之前分派共享内建注册表;TUI-only 内建命令在 ACP 侧既不广播也不处理,因此某个拼写在 ACP 里可能作为普通 prompt 文本落下去。
八、ACP/RPC 可用性
available-commands.ts 中的buildAvailableSlashCommands(...)以 first-wins 顺序发布命令:
- 具备文本能力的内建命令;
- 可选 skill 命令;
- 扩展命令;
- TypeScript/MCP 自定义命令;
- 发现的基于文件的命令。
内建主名与别名被预留;名字前缀会被解析成内建命令的扩展名(例如model:foo,其前缀model是内建命令)会从 ACP 可用性中过滤掉,防止歧义。同一份文件命令加载会同步更新会话的展开集合,保证 ACP 广播与展开一致性。
九、Streaming 与 idle 的差异
idle 路径:session.prompt("/x ...")跑完整命令管线,要么立即执行命令,要么把展开后的文本直接发送。
streaming 路径(session.isStreaming === true):prompt(...)仍然先跑扩展/自定义/文件/模板变换;随后要求显式streamingBehavior:
"steer"→ 排队中断消息(agent.steer);"followUp"→ 排队回合后消息(agent.followUp);- 缺省
streamingBehavior时 prompt 抛出AgentBusyError(agent-session.ts):
if (this.isStreaming) { const streamingBehavior = options?.streamingBehavior; if (!streamingBehavior) throw new AgentBusyError(); ... await this.#queueUserMessage(expandedText, options?.images, streamingBehavior, submittedAt); return true; }命令在 streaming 下的特殊行为
- 扩展命令即使 streaming 中也立即执行(不排队成文本);
steer(...)/followUp(...)辅助方法通过#throwIfExtensionCommand拒绝扩展命令文本,避免把「必须同步执行的 handler」排成普通文本消息;- compaction 队列重放使用
isKnownSlashCommand(...)判断:已知斜杠命令走session.prompt(...)(保证命令管线重跑),否则走裸 steer/follow-up 方法(ui-helpers.ts 中可见该判断被用于消息队列重放)。
十、错误处理与失败面
- provider 加载失败相互隔离:
loadImpl用Promise.all包裹每个 provider 的load,单个 provider 抛错只在该 provider 上记[{displayName}] Failed to load: ...警告,其余 provider 继续(capability/index.ts); - 无效斜杠命令条目被 capability 校验丢弃:缺 name/path/content 或 level 非法的条目被
validate剔除并警告; - frontmatter 解析失败分级:
- native 命令:fatal 解析错误向外冒泡;
- 非 native 命令:warning + 回退 key/value 宽松解析;
- 扩展/自定义命令 handler 异常被捕获并通过扩展错误通道报告(无扩展 runner 的自定义命令回退到 logger),且视为已处理——不会意外落回文件命令或模板展开继续执行。
十一、内建命令注记:/pause
/pause仅在交互式 TUI 可用。它为主 agent、进程内子 agent 与 advisor 开启一个进程级闸门;每个 agent 在下一个安全边界停泊:在途调用会跑完,不中止任何工作,闸门释放前不启动新工作。从暂停屏幕按 Esc、Enter、Space 或 Ctrl+C 恢复——注意Ctrl+C 是恢复而非中止任何 agent。
十二、实操要点速查
把上文收敛成可操作的清单:
- 想让命令对所有项目生效:放到
~/.omp/agent/commands/*.md(native,优先级 100,项目级.omp/commands可覆盖它); - 想复用 Claude Code 生态命令:保持
.claude/commands/(递归)即可被发现;子目录命令额外获得dir:cmd别名;用户级需commands.enableClaudeUser: true(~/.claude属于外部工具用户源,默认关闭、按能力 opt-in,见 capability/index.ts 的FOREIGN_USER_PROVIDERS); - 想避免与内建命令撞名:内建名字全局预留,同名文件命令永远不会执行——可用 Extensions 仪表盘查看
_shadowed条目定位冲突; - 模板参数约定:
$1…位置参数、$@[2:3]从第 2 个取 3 个、$ARGUMENTS/$@全部参数;模板未引用任何占位符时参数自动追加在末尾; - 改完命令文件后:无需 watcher,
/move、/reload-plugins或重开会话都会触发refreshSlashCommandState; - ACP/RPC 场景:只广播有文本能力的内建 + 非内建名冲突的扩展/自定义/文件命令;
model:foo这类「前缀即内建名」的扩展命令会被过滤。
参考文件
| 主题 | 文件 |
|---|---|
| 设计文档 | docs/slash-command-internals.md |
| capability 定义与校验 | packages/coding-agent/src/capability/slash-command.ts |
| 注册表、优先级与去重 | packages/coding-agent/src/capability/index.ts |
| 文件扫描公共工具 | packages/coding-agent/src/discovery/helpers.ts |
| native 发现 | packages/coding-agent/src/discovery/builtin.ts |
| 运行时物化与展开 | packages/coding-agent/src/extensibility/slash-commands.ts |
| 参数解析与替换 | packages/coding-agent/src/utils/command-args.ts |
| 打包 fallback 命令 | packages/coding-agent/src/task/commands.ts |
| 内建注册表 / ACP 可用性 | packages/coding-agent/src/slash-commands/builtin-registry.ts、packages/coding-agent/src/slash-commands/available-commands.ts |
| 会话 prompt 管线 | packages/coding-agent/src/session/agent-session.ts |
| 交互模式刷新与补全 | packages/coding-agent/src/modes/interactive-mode.ts、packages/coding-agent/src/modes/controllers/input-controller.ts |
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考