第 12 章:斜杠命令与 Skills 系统

当用户键入 / 时,他不是在和 LLM 对话,而是在操控 Agent 本身。


12.1 核心问题

前面几章的 Agent 只会做一件事:把用户输入发给 LLM,然后显示回复。但用户还需要控制 Agent 本身的行为:

用户:/clear           ← 清空上下文,不发给 LLM
用户:/cost            ← 查看本次消费,不发给 LLM
用户:/help            ← 列出所有命令

用户:/review-pr       ← 执行一段预写好的 prompt 模板
用户:/deploy staging  ← 执行另一段自定义 prompt,传入参数

两类需求

  1. 元操作(meta operations):不与 LLM 交互,直接执行本地逻辑(清空、计费、退出)
  2. Prompt 模板(Skills):用 Markdown 文件定义可复用的指令,调用时展开为 LLM 消息

本章实现这两套机制,并揭示 Claude Code 如何用 Markdown 文件驱动整个命令系统。


12.2 原理讲解

12.2.1 三种命令类型

Claude Code 定义了三种命令类型(src/types/command.ts):

type Command = LocalCommand | LocalJSXCommand | PromptCommand

LocalCommand    → 纯 JS 函数,返回文本结果
                  适合:/clear、/cost、/exit 等元操作

LocalJSXCommand → 返回 React 组件,渲染到终端 UI
                  适合:/help(需要交互式列表)、/config(表单)

PromptCommand   → Markdown 模板,展开为 LLM 消息
                  适合:/review-pr、/commit 等 AI 驱动的操作

每种命令的接口:

// LocalCommand:call() 同步/异步返回文本
type LocalCommand = {
  type: 'local'
  name: string
  description: string
  supportsNonInteractive: boolean
  load: () => Promise<{ call: LocalCommandCall }>  // 懒加载实现
}

// PromptCommand:getPromptForCommand() 返回 LLM 消息内容
type PromptCommand = {
  type: 'prompt'
  name: string
  description: string
  whenToUse?: string             // 让 LLM 知道何时调用此命令
  allowedTools?: string[]        // 限制此命令可用的工具
  model?: string                 // 覆盖默认模型
  context?: 'inline' | 'fork'   // inline=当前会话,fork=子 Agent
  getPromptForCommand(args: string, context: ToolUseContext): Promise<ContentBlockParam[]>
}

12.2.2 命令注册表

src/commands.ts 是命令注册的总入口。内置命令在模块加载时静态注册:

// src/commands.ts
const COMMANDS = memoize((): Command[] => [
  clear,    // /clear  — LocalCommand
  help,     // /help   — LocalJSXCommand
  cost,     // /cost   — LocalCommand
  compact,  // /compact — LocalCommand
  resume,   // /resume  — LocalJSXCommand
  memory,   // /memory  — LocalJSXCommand
  rewind,   // /rewind  — LocalJSXCommand
  // ... 70+ 更多内置命令
])

懒加载设计:每个命令的实现通过 load: () => import('./xxx.js') 延迟加载,只有当命令真正被调用时才执行 import()。这让 Claude Code 的启动时间不因命令数量增加而变慢。

// src/commands/clear/index.ts — 命令元数据(立即加载)
const clear = {
  type: 'local',
  name: 'clear',
  description: 'Clear conversation history and free up context',
  aliases: ['reset', 'new'],   // /reset 和 /new 都触发此命令
  supportsNonInteractive: false,
  load: () => import('./clear.js'),  // 实现文件延迟加载
} satisfies Command

12.2.3 Skills 系统:Markdown 驱动的命令

Skills 是 PromptCommand 的文件系统表现——用户不需要写 TypeScript,只需创建一个 Markdown 文件:

文件格式(YAML frontmatter + Markdown 正文):

---
description: 审查当前分支的 PR 改动,给出中文评审意见
when_to_use: 当用户请求 code review 或审查 PR 时
allowed-tools: Bash, Read, Grep
argument-hint: <branch-name>
---

请按以下步骤审查 PR:

1. 运行 `git diff {{ args }}...main` 获取本次改动
2. 逐文件分析改动的正确性、安全性、可读性
3. 用中文给出具体的评审建议,区分必须修改和建议修改

目录格式(推荐):

~/.claude/skills/
└── review-pr/           ← 技能目录名 = 命令名
    └── SKILL.md         ← 固定文件名

调用时,用户输入 /review-pr maingetPromptForCommand("main") 把 Markdown 正文插入为当前轮次的用户消息,LLM 看到完整的指令。

12.2.4 加载优先级与来源

Skills 从多个来源加载,优先级从低到高:

bundled(内置,编译进二进制)
  ↓
user(~/.claude/skills/<name>/SKILL.md)
  ↓
project(.claude/skills/<name>/SKILL.md,随项目代码库提交)
  ↓
managed(企业管理员通过策略下发)
  ↓
plugin(通过 /plugin 安装的第三方插件)
  ↓
mcp(MCP Server 动态注册)

高优先级来源可以覆盖同名命令。企业管理员可以用 managed 覆盖用户的 user 技能;项目可以覆盖全局用户技能。

去重机制:同一文件通过不同路径(如符号链接)加载时,用 realpath() 解析成规范路径,避免注册重复命令。

12.2.5 总装函数:getCommands(cwd)

getCommands(cwd) 是命令系统的最终入口,把所有来源合并成一个列表:

// src/commands.ts(简化)
const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
  const [skills, pluginCommands] = await Promise.all([
    getSkills(cwd),           // 扫描磁盘 skills 目录
    getPluginCommands(),      // 加载已安装插件的命令
  ])

  return [
    ...bundledSkills,         // 编译进二进制的内置 Skills
    ...skillDirCommands,      // ~/.claude/skills/ 和 .claude/skills/
    ...pluginCommands,        // 插件命令
    ...pluginSkills,          // 插件 Skills
    ...COMMANDS(),            // 内置 JS 命令(clear/help/cost 等)
  ]
})

export async function getCommands(cwd: string): Promise<Command[]> {
  const all = await loadAllCommands(cwd)
  return all.filter(cmd => meetsAvailabilityRequirement(cmd) && isCommandEnabled(cmd))
}

整个流程被 memoize 缓存——同一 cwd 只扫描一次磁盘。/reload-plugins 或动态 Skill 触发时,通过 clearCommandMemoizationCaches() 让缓存失效,下次调用重新扫描。

12.2.6 Skill 调用时的变量替换

Markdown 正文中支持两类变量:

当前目录:${CLAUDE_SKILL_DIR}       ← 替换为 SKILL.md 所在目录(绝对路径)
当前会话:${CLAUDE_SESSION_ID}      ← 替换为 UUID

内联 shell(非 MCP Skill):
!`git log --oneline -10`             ← 调用时实时执行,结果嵌入 prompt

参数传递(通过 arguments frontmatter 字段):

---
arguments: [branch, reviewer]
---
请审查 {{ branch }} 分支,审查人:{{ reviewer }}

调用 /review-pr main alice{{ branch }} 替换为 main{{ reviewer }} 替换为 alice

12.2.7 fork 执行上下文

PromptCommand 可以设置 context: 'fork',表示这个 Skill 应在独立的子 Agent 中运行:

---
context: fork
agent: Bash
---
在独立的子 Agent 中运行以下任务,不影响主对话的 token 预算:
...

inline(默认):Skill 内容展开到当前对话,共享 token 预算。
fork:Skill 作为子任务委派给新 Agent,主对话只看到结果摘要。


12.3 源码索引

文件 关键函数/类型 作用
src/types/command.ts CommandPromptCommandLocalCommand 命令类型定义
src/commands.ts COMMANDS() 内置命令注册表(memoized)
src/commands.ts getCommands(cwd) 总装:合并所有来源命令
src/commands.ts meetsAvailabilityRequirement() 按认证状态过滤命令
src/skills/loadSkillsDir.ts getSkillDirCommands(cwd) 扫描 skills/ 目录,memoized
src/skills/loadSkillsDir.ts parseSkillFrontmatterFields() 解析 frontmatter 字段
src/skills/loadSkillsDir.ts createSkillCommand() 从解析数据创建 Command 对象
src/skills/loadSkillsDir.ts LoadedFrom 来源枚举:bundled/skills/plugin/managed/mcp
src/skills/bundledSkills.ts registerBundledSkill() 注册编译进二进制的内置 Skill
src/utils/frontmatterParser.ts parseFrontmatter() 解析 YAML frontmatter + Markdown 正文
src/utils/argumentSubstitution.ts substituteArguments() {{ arg }} 变量替换

12.4 最小化产出物

代码骨架位于 ../chapters/12/src/,参考实现位于 ../chapters/12/solution/前置条件:需要 ANTHROPIC_API_KEY 环境变量(npm start 需要)。

本章要实现什么

../chapters/12/src/commands.ts 中完成斜杠命令和 Skills 系统。

接口规范(已提供,不要修改):

export interface LocalCommand { type: 'local'; name: string; description: string; call(args, ctx): string | null }
export interface PromptCommand { type: 'prompt'; name: string; description: string; whenToUse?: string; getPrompt(args): string }
export type Command = LocalCommand | PromptCommand
export const SKILLS_DIR = '.mini-agent/skills'

export function parseFrontmatter(markdown: string): { frontmatter: Record<string, string>; content: string }
export function loadSkills(): PromptCommand[]
export function buildCommandRegistry(ctx: AgentContext): Map<string, Command>
export function parseSlashCommand(input: string): ParsedCommand | null
export function formatHelp(registry: Map<string, Command>): string

你需要实现

  1. parseFrontmatter():用正则匹配 ---...---,简单 key: value 解析(不依赖外部库)
  2. loadSkills():扫描 .mini-agent/skills/*.md,每个文件创建 PromptCommand,getPrompt() 替换 {{ args }}
  3. buildCommandRegistry():注册 4 个内置命令(help/clear/cost/exit)+ loadSkills() 的结果
  4. parseSlashCommand()/name args{ name, args },非斜杠返回 null
  5. formatHelp():格式化所有命令的帮助文本

关键约束

  • parseFrontmatter() 不能依赖 js-yaml 等外部库,用简单字符串解析
  • loadSkills() 跳过解析失败的文件(容错)

验收

cd docs/chapters/12
npm install
npm test

卡住时查看 ../chapters/12/solution/commands.ts

12.5 本章小结