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

# 第 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 驱动的操作
```

每种命令的接口:

```typescript
// 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` 是命令注册的总入口。内置命令在模块加载时静态注册:

```typescript
// 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 的启动时间不因命令数量增加而变慢。

```typescript
// 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 正文):

```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 main` → `getPromptForCommand("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)` 是命令系统的最终入口,把所有来源合并成一个列表:

```typescript
// 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 正文中支持两类变量:

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

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

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

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

调用 `/review-pr main alice` → `{{ branch }}` 替换为 `main`,`{{ reviewer }}` 替换为 `alice`。

### 12.2.7 fork 执行上下文

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

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

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

---

## 12.3 源码索引

| 文件 | 关键函数/类型 | 作用 |
|------|-------------|------|
| `src/types/command.ts` | `Command`、`PromptCommand`、`LocalCommand` | 命令类型定义 |
| `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 系统。

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

```typescript
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()` 跳过解析失败的文件(容错)

### 验收

```bash
cd docs/chapters/12
npm install
npm test
```

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

## 12.5 本章小结