当用户键入 / 时,他不是在和 LLM 对话,而是在操控 Agent 本身。
12.1 核心问题
前面几章的 Agent 只会做一件事:把用户输入发给 LLM,然后显示回复。但用户还需要控制 Agent 本身的行为:
用户:/clear ← 清空上下文,不发给 LLM
用户:/cost ← 查看本次消费,不发给 LLM
用户:/help ← 列出所有命令
用户:/review-pr ← 执行一段预写好的 prompt 模板
用户:/deploy staging ← 执行另一段自定义 prompt,传入参数
两类需求:
- 元操作(meta operations):不与 LLM 交互,直接执行本地逻辑(清空、计费、退出)
- 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 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) 是命令系统的最终入口,把所有来源合并成一个列表:
// 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 |
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 系统。
接口规范(已提供,不要修改):
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
你需要实现:
parseFrontmatter():用正则匹配---...---,简单 key: value 解析(不依赖外部库)loadSkills():扫描.mini-agent/skills/*.md,每个文件创建 PromptCommand,getPrompt()替换{{ args }}buildCommandRegistry():注册 4 个内置命令(help/clear/cost/exit)+ loadSkills() 的结果parseSlashCommand():/name args→{ name, args },非斜杠返回 nullformatHelp():格式化所有命令的帮助文本
关键约束:
parseFrontmatter()不能依赖js-yaml等外部库,用简单字符串解析loadSkills()跳过解析失败的文件(容错)
验收
cd docs/chapters/12
npm install
npm test
卡住时查看 ../chapters/12/solution/commands.ts。