Claude Code 源码深度解析
第15章 Skill 与插件系统
第15章 Skill 与插件系统
开篇引言
在前面的章节中,我们深入分析了 Claude Code 的工具系统、权限模型和多 Agent 协作架构。这些机制构成了系统的核心骨骼,但真正赋予 Claude Code 生命力的,是其高度可扩展的 Skill 与插件系统。
想象一个开发者的日常场景:团队内部有一套定制化的代码审查规范,希望 Claude Code 在每次代码变更后自动执行检查;或者需要为特定框架编写一套部署流程,让模型在用户说出 /deploy 时自动执行一系列预定义操作。传统的工具系统虽然强大,但每添加一种新能力都需要修改核心代码。这就引出了一个根本性的架构问题:如何在不修改系统核心的前提下,让用户和社区自由扩展 Claude Code 的能力?
Claude Code 的回答是构建了三层递进的扩展体系:Skill(技能) 提供了声明式的能力描述机制,通过 Markdown 文件即可定义新技能;Plugin(插件) 在 Skill 之上增加了组件化封装,支持技能、Hooks、MCP 服务器的捆绑分发;Hooks(钩子) 则深入到工具执行的生命周期中,允许在关键节点插入自定义逻辑。三者协同工作,配合统一的 Slash 命令系统,形成了一个既灵活又安全的扩展架构。
本章将从源码层面深入剖析这套扩展体系的设计与实现,揭示其背后的架构决策和工程智慧。
本章要点
- Skill 系统的三种来源:文件系统 Skill(
.claude/skills/目录下的 Markdown 文件)、Bundled Skill(编译到 CLI 二进制中的内置技能)、MCP Skill(通过 MCP 协议远程加载的技能),以及它们如何统一转换为Command对象 - BundledSkillDefinition 类型:内置技能的声明式定义,包括名称、描述、触发条件、允许的工具列表、执行上下文等关键字段的设计考量
- SkillTool 的完整生命周期:从输入验证、权限检查、命令查找,到 inline/fork 两种执行模式的分流机制
- Plugin 系统的分层架构:BuiltinPlugin(内置插件)与 Marketplace Plugin(市场插件)的双轨机制,以及
LoadedPlugin类型如何统一表示两者 - Hooks 的四种类型:command(Shell 命令)、prompt(LLM 提示)、agent(Agent 验证器)、http(HTTP 回调),以及它们在 PreToolUse/PostToolUse 等事件节点上的精密执行逻辑
- Slash 命令系统的统一注册架构:80+ 命令的分类管理、优先级机制和动态加载策略
- 三个子系统的协作关系:Skill 定义能力,Plugin 封装和分发能力,Hooks 在执行时拦截和增强能力
15.1 Skill 系统
Claude Code 的扩展体系由 Skill、Plugin 和 Hooks 三层构成,它们通过统一的 Slash 命令系统对外暴露。下图展示了三个子系统之间的关系:
flowchart TB
subgraph Entry["入口层"]
Slash["/command Slash 命令"]
SkillTool["SkillTool"]
end
subgraph CommandReg["统一命令注册表\ngetCommands()"]
direction LR
BundledCmd["Bundled Skills\n编译进二进制"]
FSCmd["FileSystem Skills\n.claude/skills/*.md"]
PluginCmd["Plugin Skills\nGit 仓库/市场"]
MCPCmd["MCP Skills\n远程协议加载"]
end
subgraph Plugin["Plugin 系统"]
PluginDef["Plugin 定义"]
PluginDef --> PluginSkill["Skills"]
PluginDef --> PluginHook["Hooks 配置"]
PluginDef --> PluginMCP["MCP Servers"]
PluginDef --> PluginLSP["LSP Servers"]
end
subgraph Hooks["Hooks 系统"]
Pre["PreToolUse"]
Post["PostToolUse"]
Fail["PostToolUseFailure"]
Denied["PermissionDenied"]
Session["SessionStart / End"]
end
Entry --> CommandReg
PluginCmd --> Plugin
Plugin --> Hooks
Pre & Post -->|"拦截/增强"| ToolExec["工具执行管线"]
15.1.1 Skill 的本质:声明式能力描述
在 Claude Code 的架构中,Skill 本质上是一种声明式的能力描述。每个 Skill 最终都被转换为一个 Command 对象,其中最重要的是 getPromptForCommand 方法——它返回一段 prompt 内容,指导模型如何完成特定任务。这种设计意味着,添加新能力不需要编写任何 TypeScript 代码,只需创建一个 Markdown 文件并用 frontmatter 声明元数据即可。
这个决策的妙处、要放在 AI Agent 产品的特殊语境下才能完全体会——LLM 本质上是”被 prompt 驱动的巨型函数”、所以”新能力”在很多时候就是”新的 prompt”。既然如此、何必强求用户写代码?把 prompt 直接作为一等公民、用 markdown 描述、用 frontmatter 配置元数据、这是顺着 LLM 本身的能力属性来设计的扩展机制。对比传统软件系统的插件——那些系统因为核心逻辑是代码、所以插件也得是代码;Claude Code 的核心能力是 prompt、所以最自然的扩展也是 prompt。这种”让扩展机制和核心能力同构”的思维、是产品设计上一个非常深的原则——它能让系统的扩展成本和核心演化速度匹配、避免出现”核心跑得飞快、扩展生态追不上”的尴尬。
Command 类型定义在 src/types/command.ts 中,是一个联合类型:
// 文件: src/types/command.ts
export type PromptCommand = {
type: 'prompt'
progressMessage: string
contentLength: number
argNames?: string[]
allowedTools?: string[]
model?: string
source: SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'
hooks?: HooksSettings
skillRoot?: string
context?: 'inline' | 'fork'
agent?: string
effort?: EffortValue
paths?: string[]
getPromptForCommand(
args: string,
context: ToolUseContext,
): Promise<ContentBlockParam[]>
}
export type Command = CommandBase &
(PromptCommand | LocalCommand | LocalJSXCommand)
这里的 source 字段清晰地标记了命令的来源:builtin 表示硬编码的内部命令(如 /help、/clear),bundled 表示编译进二进制的内置 Skill,plugin 表示来自插件,mcp 表示来自 MCP 服务器。这个字段在后续的权限检查、遥测上报、prompt 截断等环节都起到了关键的分流作用。
15.1.2 skills/ 目录结构
Skill 系统的源码组织在 src/skills/ 目录下:
src/skills/
bundledSkills.ts -- Bundled Skill 注册表与类型定义
loadSkillsDir.ts -- 文件系统 Skill 加载器
mcpSkillBuilders.ts -- MCP Skill 构建器注册表
bundled/
index.ts -- 所有 Bundled Skill 的初始化入口
simplify.ts -- /simplify 代码审查技能
updateConfig.ts -- /update-config 配置管理技能
keybindings.ts -- /keybindings 快捷键管理技能
verify.ts -- /verify 验证技能
claudeApi.ts -- /claude-api API 开发技能
batch.ts -- /batch 批量处理技能
loop.ts -- /loop 循环执行技能
remember.ts -- /remember 记忆技能
stuck.ts -- /stuck 卡住恢复技能
debug.ts -- /debug 调试技能
... 更多内置技能
这个目录结构体现了清晰的职责分离:bundledSkills.ts 负责类型定义和注册机制,loadSkillsDir.ts 负责从磁盘加载用户自定义 Skill,bundled/ 目录存放所有编译到二进制中的内置 Skill 实现。
15.1.3 BundledSkillDefinition 类型
BundledSkillDefinition 是定义内置 Skill 的核心类型,位于 src/skills/bundledSkills.ts:
// 文件: src/skills/bundledSkills.ts
export type BundledSkillDefinition = {
name: string
description: string
aliases?: string[]
whenToUse?: string
argumentHint?: string
allowedTools?: string[]
model?: string
disableModelInvocation?: boolean
userInvocable?: boolean
isEnabled?: () => boolean
hooks?: HooksSettings
context?: 'inline' | 'fork'
agent?: string
files?: Record<string, string>
getPromptForCommand: (
args: string,
context: ToolUseContext,
) => Promise<ContentBlockParam[]>
}
每个字段都承载着精确的设计意图:
whenToUse:详细描述 Skill 的适用场景,在 SkillTool 的 prompt 中呈现给模型,帮助模型判断何时应自动调用该 SkillallowedTools:声明该 Skill 执行时需要的工具白名单,SkillTool 会通过contextModifier临时将这些工具添加到权限系统中context:'inline'表示 Skill 内容直接展开到当前对话中,'fork'表示在独立的 sub-agent 中执行,隔离上下文和 token 预算files:附加的参考文件,在首次调用时惰性提取到磁盘,模型可以通过 Read/Grep 工具按需访问disableModelInvocation:设为true时,模型无法通过 SkillTool 自动调用该 Skill,仅允许用户通过/前缀手动触发
files 字段的设计尤其值得关注。由于 Bundled Skill 编译进二进制文件中,没有磁盘上的文件目录可供模型读取。通过 files 字段,Skill 可以声明一组参考文件,系统会在首次调用时将它们提取到 getBundledSkillsRoot() 下的安全目录中:
// 文件: src/skills/bundledSkills.ts
if (files && Object.keys(files).length > 0) {
skillRoot = getBundledSkillExtractDir(definition.name)
let extractionPromise: Promise<string | null> | undefined
const inner = definition.getPromptForCommand
getPromptForCommand = async (args, ctx) => {
// 闭包级备忘录:每个进程只提取一次
// 对 Promise 做备忘,而非对结果做备忘,
// 使得并发调用者等待同一次提取,而不是竞争写入
extractionPromise ??= extractBundledSkillFiles(definition.name, files)
const extractedDir = await extractionPromise
const blocks = await inner(args, ctx)
if (extractedDir === null) return blocks
return prependBaseDir(blocks, extractedDir)
}
}
这里用 ??= 运算符做惰性初始化,并且是对 Promise 本身做缓存而非对结果做缓存,这样即使多个并发请求同时到达,也只会执行一次文件提取操作。
15.1.4 Skill 的注册与发现
Bundled Skill 的注册遵循一个简洁的模式:在 src/skills/bundled/index.ts 的 initBundledSkills() 函数中依次调用各个 Skill 的注册函数:
// 文件: src/skills/bundled/index.ts
export function initBundledSkills(): void {
registerUpdateConfigSkill()
registerKeybindingsSkill()
registerVerifySkill()
registerDebugSkill()
registerSimplifySkill()
registerBatchSkill()
registerStuckSkill()
// ... 更多 Skill 注册
// 部分 Skill 受特性开关控制
if (feature('BUILDING_CLAUDE_APPS')) {
const { registerClaudeApiSkill } = require('./claudeApi.js')
registerClaudeApiSkill()
}
}
每个注册函数内部调用 registerBundledSkill(),该函数将 BundledSkillDefinition 转换为 Command 对象并存入内部注册表:
// 文件: src/skills/bundledSkills.ts
const bundledSkills: Command[] = []
export function registerBundledSkill(definition: BundledSkillDefinition): void {
const command: Command = {
type: 'prompt',
name: definition.name,
description: definition.description,
allowedTools: definition.allowedTools ?? [],
source: 'bundled',
loadedFrom: 'bundled',
// ... 将 BundledSkillDefinition 字段映射到 Command 字段
getPromptForCommand,
}
bundledSkills.push(command)
}
Skill 的发现过程由 commands.ts 中的 getSkills() 函数统一协调,它并行加载四种来源的 Skill:
// 文件: src/commands.ts
async function getSkills(cwd: string): Promise<{
skillDirCommands: Command[]
pluginSkills: Command[]
bundledSkills: Command[]
builtinPluginSkills: Command[]
}> {
const [skillDirCommands, pluginSkills] = await Promise.all([
getSkillDirCommands(cwd), // 文件系统 Skill
getPluginSkills(), // 插件 Skill
])
const bundledSkills = getBundledSkills() // 内置 Skill
const builtinPluginSkills = getBuiltinPluginSkillCommands() // 内置插件 Skill
return { skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills }
}
所有这些来源最终在 loadAllCommands() 中合并为一个统一的命令列表,合并时的顺序非常重要——Bundled Skill 优先于文件系统 Skill,文件系统 Skill 优先于插件 Skill,插件 Skill 优先于内置命令:
// 文件: src/commands.ts
const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
const [
{ skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills },
pluginCommands,
workflowCommands,
] = await Promise.all([
getSkills(cwd),
getPluginCommands(),
getWorkflowCommands ? getWorkflowCommands(cwd) : Promise.resolve([]),
])
return [
...bundledSkills,
...builtinPluginSkills,
...skillDirCommands,
...workflowCommands,
...pluginCommands,
...pluginSkills,
...COMMANDS(), // 内置的 slash 命令
]
})
15.1.5 以 simplify 为例:一个 Bundled Skill 的完整实现
/simplify 是一个典型的 Bundled Skill,它演示了如何用最少的代码定义一个功能完整的代码审查技能:
// 文件: src/skills/bundled/simplify.ts
const SIMPLIFY_PROMPT = `# Simplify: Code Review and Cleanup
Review all changed files for reuse, quality, and efficiency. Fix any issues found.
## Phase 1: Identify Changes
Run \`git diff\` to see what changed.
## Phase 2: Launch Three Review Agents in Parallel
Use the ${AGENT_TOOL_NAME} tool to launch all three agents concurrently...
// ... 详细的审查指令
`
export function registerSimplifySkill(): void {
registerBundledSkill({
name: 'simplify',
description:
'Review changed code for reuse, quality, and efficiency, then fix any issues found.',
userInvocable: true,
async getPromptForCommand(args) {
let prompt = SIMPLIFY_PROMPT
if (args) {
prompt += `\n\n## Additional Focus\n\n${args}`
}
return [{ type: 'text', text: prompt }]
},
})
}
这里的核心思想是:Skill 不需要任何”代码逻辑”,只需要一段精心编写的 prompt。模型读到这段 prompt 后,会自动使用 AgentTool 并行启动三个子 Agent,分别执行代码复用检查、代码质量审查和效率审查。整个编排逻辑全部交给模型自主决策。
15.1.6 文件系统 Skill 的加载机制
除了 Bundled Skill,用户还可以通过在 .claude/skills/ 目录中放置 Markdown 文件来定义自定义 Skill。loadSkillsDir.ts 中的加载器负责解析这些文件的 frontmatter 元数据:
// 文件: src/skills/loadSkillsDir.ts
export function parseSkillFrontmatterFields(
frontmatter: FrontmatterData,
markdownContent: string,
resolvedName: string,
): {
displayName: string | undefined
description: string
allowedTools: string[]
whenToUse: string | undefined
model: ReturnType<typeof parseUserSpecifiedModel> | undefined
disableModelInvocation: boolean
hooks: HooksSettings | undefined
executionContext: 'fork' | undefined
agent: string | undefined
effort: EffortValue | undefined
// ... 更多字段
}
一个自定义 Skill 的 Markdown 文件示例:
---
description: 执行项目部署流程
when_to_use: 当用户请求部署或发布时
allowed-tools: ["Bash"]
context: fork
---
# 部署流程
1. 运行测试确保所有用例通过
2. 构建生产版本
3. 推送到部署环境
createSkillCommand() 函数将解析后的元数据和 Markdown 内容组合成 Command 对象。其中 getPromptForCommand 方法会执行一系列替换操作——$ARGUMENTS 替换为用户传入的参数,${CLAUDE_SKILL_DIR} 替换为技能目录的绝对路径,${CLAUDE_SESSION_ID} 替换为当前会话 ID。
15.1.7 SkillTool 的实现
SkillTool 是 Skill 系统的运行时入口,是模型用来调用 Skill 的工具。它定义在 src/tools/SkillTool/SkillTool.ts 中,其工作流程如下:
用户/模型调用 SkillTool
|
v
validateInput() -- 检查 Skill 名称是否有效、是否存在、是否允许模型调用
|
v
checkPermissions() -- 查找 deny/allow 规则,对安全 Skill 自动放行
|
v
call() -- 执行 Skill
/ \
/ \
inline fork
| |
展开到 在子 Agent
当前对话 中隔离执行
输入验证 阶段,SkillTool 接受两个参数——skill(技能名称)和可选的 args(参数)。验证逻辑确保:命令存在、不是 disableModelInvocation 命令、且为 prompt 类型命令。
权限检查 阶段的设计非常精巧。它引入了一个”安全属性白名单”的概念:
// 文件: src/tools/SkillTool/SkillTool.ts
const SAFE_SKILL_PROPERTIES = new Set([
'type', 'progressMessage', 'contentLength', 'model', 'effort',
'source', 'name', 'description', 'aliases', 'argumentHint',
'whenToUse', 'disableModelInvocation', 'userInvocable',
'loadedFrom', 'getPromptForCommand',
// ... 更多安全属性
])
function skillHasOnlySafeProperties(command: Command): boolean {
for (const key of Object.keys(command)) {
if (SAFE_SKILL_PROPERTIES.has(key)) continue
const value = (command as Record<string, unknown>)[key]
if (value === undefined || value === null) continue
if (Array.isArray(value) && value.length === 0) continue
return false
}
return true
}
这个设计的关键在于默认否认:如果未来在 Command 类型上添加了新属性,新属性默认不在安全白名单中,因此带有新属性的 Skill 会自动要求用户确认权限。这避免了因遗忘更新白名单而引入安全漏洞。
执行 阶段根据 context 字段分为两种模式:
-
inline 模式(默认):Skill 的 prompt 内容被包装为用户消息,注入到当前对话上下文中。模型在后续推理中会看到这些内容并据此行动。SkillTool 还通过
contextModifier机制临时修改上下文,添加工具权限和模型覆盖。 -
fork 模式:Skill 在一个独立的 sub-agent 中执行。
executeForkedSkill()调用runAgent()启动子 Agent,子 Agent 拥有独立的消息历史和 token 预算。执行结果通过extractResultText()提取后作为工具结果返回给主对话。
// 文件: src/tools/SkillTool/SkillTool.ts(简化)
async call({ skill, args }, context, canUseTool, parentMessage, onProgress?) {
const commandName = skill.trim().replace(/^\//, '')
const commands = await getAllCommands(context)
const command = findCommand(commandName, commands)
// fork 模式:在子 Agent 中执行
if (command?.type === 'prompt' && command.context === 'fork') {
return executeForkedSkill(command, commandName, args, context, ...)
}
// inline 模式:展开到当前对话
const processedCommand = await processPromptSlashCommand(
commandName, args || '', commands, context,
)
return {
data: { success: true, commandName, allowedTools, model },
newMessages,
contextModifier(ctx) { /* 修改工具权限和模型 */ },
}
}
15.1.8 Skill 的 prompt 生成与预算管理
SkillTool 的 prompt 不仅包含工具使用说明,还包含所有可用 Skill 的列表。这个列表在 src/tools/SkillTool/prompt.ts 中生成,并受到严格的预算控制:
// 文件: src/tools/SkillTool/prompt.ts
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01 // 上下文窗口的 1%
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // 回退值:200K * 4 * 1%
export const MAX_LISTING_DESC_CHARS = 250 // 单条描述的硬上限
当 Skill 列表超出预算时,系统采用分级截断策略:Bundled Skill 的描述永远保留完整(因为它们是官方核心能力),非 Bundled Skill 的描述则按比例缩短。极端情况下,非 Bundled Skill 甚至会退化为仅显示名称。
15.2 插件系统
从 Skill 到 Plugin 的跃迁、是一次”从单元到组件”的抽象升级。单个 Skill 很像一个函数——小、独立、容易理解;但现实世界里、很多能力是由一组函数 + 一组配置 + 一组副作用处理器共同组成的。比如”代码审查”这个能力、它可能需要 3 个不同的 skill(代码风格检查、安全审查、性能评审)、再加上一些 hook(提交前自动触发)、再加上一个 MCP server(接入外部检查工具)。Plugin 就是把这些”围绕同一个业务主题的扩展资源”打包成一个可分发的单元。
这个抽象的必要性、在任何一个扩展生态都能看到——VS Code 的 extension、Chrome 的 extension、IntelliJ 的 plugin、甚至 WordPress 的 plugin、都是”一组相关资源的打包单元”。Claude Code 的 plugin 继承了这套成熟经验、同时针对 AI Agent 场景做了适配——它能打包 skill(prompt 能力)、hook(执行时钩子)、MCP server(外部协议接入)、LSP server(语言服务)这四类完全不同的资源、每一类都有专门的加载路径、但对外暴露的是一个统一的 plugin 标识。用户只需要安装一个 plugin、底层的四种资源就会自动被正确连接到系统的不同位置。这种”对用户是一个单元、对系统是多种资源”的抽象、是插件系统设计的精髓。
15.2.1 plugins/ 目录结构
插件系统是 Skill 系统之上的更高层抽象,它允许将多个 Skill、Hooks、MCP 服务器、LSP 服务器打包为一个可分发的单元。源码组织如下:
src/plugins/
builtinPlugins.ts -- 内置插件注册表
bundled/
index.ts -- 内置插件初始化入口
src/types/
plugin.ts -- 核心类型定义(BuiltinPluginDefinition, LoadedPlugin, PluginError 等)
src/utils/plugins/
loadPluginCommands.ts -- 插件命令/技能加载器
pluginLoader.ts -- 插件加载核心逻辑
pluginIdentifier.ts -- 插件标识符解析
pluginOptionsStorage.ts -- 插件配置持久化
pluginDirectories.ts -- 插件目录管理
cacheUtils.ts -- 插件缓存工具
schemas.ts -- 插件清单 Schema
walkPluginMarkdown.ts -- 插件 Markdown 文件遍历
15.2.2 BuiltinPluginDefinition 类型
BuiltinPluginDefinition 定义在 src/types/plugin.ts 中,是内置插件的声明式描述:
// 文件: src/types/plugin.ts
export type BuiltinPluginDefinition = {
/** 插件名称(用于 `{name}@builtin` 标识符) */
name: string
/** 在 /plugin UI 中显示的描述 */
description: string
/** 可选版本字符串 */
version?: string
/** 此插件提供的 Skill */
skills?: BundledSkillDefinition[]
/** 此插件提供的 Hooks */
hooks?: HooksSettings
/** 此插件提供的 MCP 服务器 */
mcpServers?: Record<string, McpServerConfig>
/** 此插件是否可用(例如基于系统能力判断)。不可用的插件完全隐藏 */
isAvailable?: () => boolean
/** 用户未设置偏好前的默认启用状态(默认 true) */
defaultEnabled?: boolean
}
相比 BundledSkillDefinition,BuiltinPluginDefinition 是一个更高层次的抽象。一个插件可以同时包含多个 Skill、一组 Hooks 配置和多个 MCP 服务器——它们作为一个整体被启用或禁用。
15.2.3 LoadedPlugin:统一的插件表示
无论插件来自哪里(内置、Git 仓库、Marketplace),加载后都被统一表示为 LoadedPlugin:
// 文件: src/types/plugin.ts
export type LoadedPlugin = {
name: string
manifest: PluginManifest
path: string
source: string // 如 "my-plugin@marketplace-name"
repository: string
enabled?: boolean
isBuiltin?: boolean
sha?: string // Git commit SHA,用于版本锁定
commandsPath?: string // 插件命令路径
skillsPath?: string // 插件技能路径
hooksConfig?: HooksSettings
mcpServers?: Record<string, McpServerConfig>
lspServers?: Record<string, LspServerConfig>
settings?: Record<string, unknown>
}
source 字段使用 {name}@{marketplace} 格式作为插件的全局唯一标识符。对于内置插件,格式为 {name}@builtin。
15.2.4 插件生命周期:注册、启用/禁用、持久化
内置插件的注册发生在启动阶段,由 src/plugins/bundled/index.ts 中的 initBuiltinPlugins() 触发:
// 文件: src/plugins/bundled/index.ts
export function initBuiltinPlugins(): void {
// 当前是脚手架代码,准备将 bundled skill 迁移为
// 用户可切换的内置插件
}
插件的启用/禁用状态由用户设置管理,存储在 settings.json 的 enabledPlugins 字段中。getBuiltinPlugins() 函数在每次调用时根据用户偏好和插件默认状态计算最终的启用/禁用列表:
// 文件: src/plugins/builtinPlugins.ts
export function getBuiltinPlugins(): {
enabled: LoadedPlugin[]
disabled: LoadedPlugin[]
} {
const settings = getSettings_DEPRECATED()
const enabled: LoadedPlugin[] = []
const disabled: LoadedPlugin[] = []
for (const [name, definition] of BUILTIN_PLUGINS) {
// 不可用的插件完全跳过
if (definition.isAvailable && !definition.isAvailable()) continue
const pluginId = `${name}@${BUILTIN_MARKETPLACE_NAME}`
const userSetting = settings?.enabledPlugins?.[pluginId]
// 优先级:用户偏好 > 插件默认值 > true
const isEnabled =
userSetting !== undefined
? userSetting === true
: (definition.defaultEnabled ?? true)
// ... 构建 LoadedPlugin 并分入 enabled 或 disabled
}
return { enabled, disabled }
}
这个三层优先级设计值得注意:用户的显式设置覆盖一切;如果用户未设置,则使用插件自身声明的 defaultEnabled;如果连插件都没声明,默认为启用。
15.2.5 插件 Skill 到 Command 的转换
当内置插件的 Skill 需要暴露为命令时,skillDefinitionToCommand() 函数负责转换:
// 文件: src/plugins/builtinPlugins.ts
function skillDefinitionToCommand(definition: BundledSkillDefinition): Command {
return {
type: 'prompt',
name: definition.name,
// 注意这里的 source 是 'bundled' 而非 'builtin'
// 'builtin' 在 Command.source 中表示硬编码的 slash 命令
// 'bundled' 让这些 Skill 保留在 SkillTool 的列表中
source: 'bundled',
loadedFrom: 'bundled',
isEnabled: definition.isEnabled ?? (() => true),
// ... 其他字段映射
}
}
这里有一个微妙但重要的设计决策:source 被设为 'bundled' 而非 'builtin'。注释中解释了原因——'builtin' 在 Command.source 的语义中表示硬编码的内部 slash 命令(如 /help),使用 'bundled' 可以确保这些 Skill 出现在 SkillTool 的技能列表中,也不会被 prompt 截断机制误伤。用户可切换的特性通过 LoadedPlugin.isBuiltin 单独追踪。
15.2.6 PluginError:类型安全的错误处理
插件加载涉及大量可能出错的环节——网络超时、Git 认证失败、清单解析错误、MCP 配置无效等。PluginError 使用判别联合类型(discriminated union)来精确描述每种错误:
// 文件: src/types/plugin.ts
export type PluginError =
| { type: 'path-not-found'; source: string; path: string; component: PluginComponent }
| { type: 'git-auth-failed'; source: string; gitUrl: string; authType: 'ssh' | 'https' }
| { type: 'git-timeout'; source: string; gitUrl: string; operation: 'clone' | 'pull' }
| { type: 'manifest-parse-error'; source: string; parseError: string }
| { type: 'plugin-not-found'; source: string; pluginId: string; marketplace: string }
| { type: 'mcp-server-suppressed-duplicate'; source: string; serverName: string; duplicateOf: string }
| { type: 'dependency-unsatisfied'; source: string; dependency: string; reason: 'not-enabled' | 'not-found' }
// ... 20+ 种错误类型
每种错误类型都携带了丰富的上下文信息,getPluginErrorMessage() 函数可以为每种错误生成可读的消息。这种设计避免了基于字符串匹配的错误处理——当错误消息文本变化时不会导致匹配失败。
15.3 Slash 命令系统
15.3.1 commands.ts 的架构
src/commands.ts 是整个命令系统的中枢,负责注册、加载和管理所有可用命令。文件开头是一组超过 70 个命令的导入语句,它们按功能分为几大类别。值得停下来细品的是”为什么这些命令都走 slash 这个同一入口”——答案和我们在 Unix shell 看到的类似:统一的语法入口让用户的”肌肉记忆”和”探索成本”都能被最大化利用。一个用户只要学会”输 / 然后看补全提示”这一个动作、就能探索整个系统的所有能力——这比让用户去记忆”某个功能要在菜单第几层找”要人性化得多。所有成功的命令行工具、从 vim 的冒号命令、到 bash 的命令名、再到 VS Code 的 Command Palette、都在使用同一种”统一文本入口 + 自动补全”的交互模式——这是一种被几十年证明有效的交互范式、Claude Code 只是在 AI Agent 场景下再次应用了它。
命令分类:
| 类别 | 示例 | 特点 |
|---|---|---|
| 会话管理 | /clear, /compact, /session, /resume | 控制对话流程 |
| 代码操作 | /review, /diff, /commit, /branch | Git 和代码审查 |
| 配置管理 | /config, /memory, /permissions, /hooks | 系统设置 |
| 开发辅助 | /doctor, /debug, /status, /cost | 诊断和监控 |
| 扩展管理 | /skills, /plugin, /mcp, /reload-plugins | 扩展系统管理 |
| UI 控制 | /theme, /color, /vim, /keybindings | 界面定制 |
| 认证相关 | /login, /logout, /usage | 用户认证 |
| 实验特性 | /proactive, /voice, /bridge | 受特性开关保护 |
特性开关保护的命令使用条件性 require 导入,确保相关代码在特性未启用时不会进入最终构建:
// 文件: src/commands.ts
const voiceCommand = feature('VOICE_MODE')
? require('./commands/voice/index.js').default
: null
const workflowsCmd = feature('WORKFLOW_SCRIPTS')
? require('./commands/workflows/index.js').default
: null
15.3.2 Command 类型定义
Command 是一个由 CommandBase 和三种具体命令类型组成的联合类型:
// 文件: src/types/command.ts
export type CommandBase = {
availability?: CommandAvailability[]
description: string
hasUserSpecifiedDescription?: boolean
isEnabled?: () => boolean
isHidden?: boolean
name: string
aliases?: string[]
whenToUse?: string
disableModelInvocation?: boolean
userInvocable?: boolean
loadedFrom?: 'commands_DEPRECATED' | 'skills' | 'plugin' | 'managed' | 'bundled' | 'mcp'
kind?: 'workflow'
immediate?: boolean
userFacingName?: () => string
}
export type Command = CommandBase &
(PromptCommand | LocalCommand | LocalJSXCommand)
三种命令类型对应三种执行模式:
- PromptCommand:返回 prompt 内容供模型处理,是 Skill 的载体
- LocalCommand:在本地同步执行,返回文本结果(如
/cost显示费用) - LocalJSXCommand:渲染 React/Ink 组件,提供交互式 TUI 界面(如
/config配置菜单)
availability 字段是一个新的设计,用于声明命令对哪些认证环境可用。例如,某些命令仅对 claude.ai 订阅用户开放,另一些仅对 Console API 直接用户开放:
// 文件: src/types/command.ts
export type CommandAvailability =
| 'claude-ai' // claude.ai OAuth 订阅用户
| 'console' // Console API key 直接用户
15.3.3 命令的执行流程
当用户输入 /xxx 或模型通过 SkillTool 调用技能时,命令执行经过以下关键阶段:
输入解析 -> 命令查找 -> 可用性检查 -> 启用检查 -> 类型分发 -> 执行
命令查找 由 findCommand() 实现,支持按名称、别名和 userFacingName 匹配:
// 文件: src/commands.ts
export function findCommand(
commandName: string,
commands: Command[],
): Command | undefined {
return commands.find(
_ =>
_.name === commandName ||
getCommandName(_) === commandName ||
_.aliases?.includes(commandName),
)
}
可用性过滤 确保命令只在满足认证条件时可见。值得注意的是,这个检查不会被 memoize——因为认证状态可能在会话中途变化(如执行 /login 后):
// 文件: src/commands.ts
export async function getCommands(cwd: string): Promise<Command[]> {
const allCommands = await loadAllCommands(cwd) // memoized
const dynamicSkills = getDynamicSkills()
// 可用性和启用检查每次都重新评估
return allCommands.filter(
_ => meetsAvailabilityRequirement(_) && isCommandEnabled(_),
)
}
动态 Skill 是一个有趣的特性:在文件操作过程中,系统可能发现新的 Skill 文件并动态加入列表。getDynamicSkills() 获取这些运行时发现的 Skill,并在合适的位置插入到命令列表中——在插件 Skill 之后、内置命令之前。
15.3.4 SkillTool 与 Slash 命令的桥接
SkillTool 是模型自动调用 Skill 的入口,而 Slash 命令是用户手动调用的入口。两者最终都走向同一套 Command 注册表,但暴露的命令集合有所不同:
// 文件: src/commands.ts
// SkillTool 展示的命令:所有 prompt 类型、允许模型调用、非 builtin 的命令
export const getSkillToolCommands = memoize(async (cwd: string) => {
const allCommands = await getCommands(cwd)
return allCommands.filter(cmd =>
cmd.type === 'prompt' &&
!cmd.disableModelInvocation &&
cmd.source !== 'builtin' &&
(cmd.loadedFrom === 'bundled' || cmd.hasUserSpecifiedDescription || cmd.whenToUse)
)
})
这个过滤逻辑确保了几个关键约束:
- 内置的交互式命令(如
/config、/help)不暴露给模型 - 没有描述的 Skill 不出现在模型的技能列表中(避免占用 token 预算却无法被有效匹配)
- 标记了
disableModelInvocation的命令只能用户手动触发
15.4 Hooks 系统
Hook 这个词源于 Unix 时代、最早是指在某个系统调用周围”挂”上用户自定义的代码。在 Claude Code 里、Hook 承担了同样的角色——把 AI Agent 这个”黑盒”里的关键执行节点变成”用户可以挂东西的地方”。为什么这件事特别重要?因为 AI Agent 的行为很难”提前”指定——用户很难在事情发生前就预料到所有边界情况、但用户可以明确地说”当 X 类事情发生时、请做 Y”。Hook 正是把这种”条件化响应能力”交给用户。你可以把它类比成浏览器的 MutationObserver、Kubernetes 的 admission webhook、Git 的 pre-commit hook、Linux 的 LD_PRELOAD——这些机制共同的特征是”让用户在不修改核心代码的前提下、在关键节点注入自己的逻辑”。Claude Code 的 Hook 比这些前辈多走了一步——它不只支持同步命令(command hook)、还支持异步的 prompt hook(让 LLM 自己做判断)、agent hook(深层语义验证)和 http hook(跨服务协作)。这种”Hook 类型的丰富度”、反映了 AI Agent 场景下”决策”这件事本身可能是 AI 驱动的——所以 Hook 也得是 AI-first 的。
Hooks 在工具执行的关键节点上提供拦截能力。下图展示了四种 Hook 类型在工具执行生命周期中的触发时机:
sequenceDiagram
participant Model as Claude 模型
participant Hook as Hooks 系统
participant Tool as 工具执行
participant Perm as 权限系统
Model->>Hook: PreToolUse 事件
Note over Hook: 四种 Hook 类型:\ncommand / prompt / agent / http
alt command Hook
Hook->>Hook: 执行 Shell 命令\n读取 stdout
else prompt Hook
Hook->>Hook: LLM 评估提示\n返回 JSON 决策
else agent Hook
Hook->>Hook: Agent 验证器\n检查工具参数
else http Hook
Hook->>Hook: HTTP 回调\nPOST 到外部服务
end
Hook-->>Tool: allow / deny / updatedInput / stop
Tool->>Tool: 实际执行
Tool->>Hook: PostToolUse 事件
Hook-->>Hook: 日志记录 / 输出修改 / 上下文注入
Note over Tool,Perm: 如果权限被拒绝
Perm->>Hook: PermissionDenied 事件
Hook-->>Hook: 自动重试策略 / 通知
15.4.1 Hooks 的定位
如果说 Skill 定义了”做什么”,Hooks 则定义了”什么时候额外做点什么”。Hooks 系统允许在 Claude Code 的关键生命周期节点上挂载自定义逻辑——在工具调用之前验证输入、在工具调用之后检查输出、在会话开始时初始化环境、在会话结束时清理资源。
Hook 和 Skill 的关系、可以借用 AOP(面向切面编程)的心智模型来理解——Skill 是”主逻辑”、Hook 是”切面”。主逻辑回答”这件事要做什么”、切面回答”在这件事发生之前/之后要顺带做什么”。这种分离的好处在于、主逻辑可以保持简洁和聚焦、切面可以独立演化、两者之间通过事件协议松耦合。比如你想给 Claude Code 的每一次文件写入都加上”自动跑 lint”——你不需要去修改任何内置工具的代码、只需要注册一个 PostToolUse Hook、匹配 FileWriteTool、执行你的 lint 命令。这种”不侵入主逻辑就能增强系统行为”的能力、是 Hook 系统最大的价值主张。在 Java 的 Spring、Python 的装饰器、JavaScript 的中间件、Rust 的 tower::Layer、都能看到同一种思想的不同化身——它们都在解决”横切关注点”这个工程永恒的问题。
15.4.2 Hook 事件类型
Hook 的事件类型覆盖了系统的几乎所有关键阶段。以下是主要的事件及其用途:
| 事件名称 | 触发时机 | 典型用途 |
|---|---|---|
PreToolUse | 工具调用之前 | 验证输入、修改参数、阻止调用 |
PostToolUse | 工具调用之后 | 检查输出、注入额外上下文 |
PostToolUseFailure | 工具调用失败后 | 错误恢复、降级处理 |
PermissionDenied | 自动模式拒绝工具调用后 | 允许重试 |
PermissionRequest | 权限请求时 | 自动审批/拒绝 |
SessionStart | 会话开始时 | 环境初始化 |
SessionEnd | 会话结束时 | 资源清理 |
Notification | 通知发送时 | 自定义通知渠道 |
UserPromptSubmit | 用户提交提示时 | 输入预处理 |
Stop | 模型停止时 | 后处理逻辑 |
SubagentStart/Stop | 子 Agent 启动/停止时 | Agent 监控 |
每个事件都支持 matcher 模式匹配。例如,PreToolUse 事件的 matcher 匹配 tool_name 字段,你可以配置一个 Hook 只在 Bash 工具被调用时触发。
15.4.3 四种 Hook 类型
Hook 的类型定义在 src/schemas/hooks.ts 中,使用 Zod 的判别联合模式:
// 文件: src/schemas/hooks.ts
export const HookCommandSchema = lazySchema(() => {
return z.discriminatedUnion('type', [
BashCommandHookSchema, // Shell 命令
PromptHookSchema, // LLM 提示
AgentHookSchema, // Agent 验证器
HttpHookSchema, // HTTP 回调
])
})
1. Command Hook(Shell 命令)
最基础的 Hook 类型,执行一个 Shell 命令并根据退出码决定后续行为:
const BashCommandHookSchema = z.object({
type: z.literal('command'),
command: z.string(), // 要执行的 Shell 命令
if: IfConditionSchema(), // 条件过滤(使用权限规则语法)
shell: z.enum(SHELL_TYPES).optional(), // Shell 类型
timeout: z.number().positive().optional(),
once: z.boolean().optional(), // 执行一次后自动移除
async: z.boolean().optional(), // 后台异步执行
asyncRewake: z.boolean().optional(), // 异步执行,exit code 2 时唤醒模型
})
退出码语义设计精巧:
- 退出码 0:成功,stdout/stderr 记录在后台
- 退出码 2:阻塞错误,stderr 内容展示给模型并阻止工具调用
- 其他退出码:非阻塞错误,stderr 仅展示给用户
2. Prompt Hook(LLM 提示)
使用 LLM 评估一段 prompt,适合需要智能判断的场景:
const PromptHookSchema = z.object({
type: z.literal('prompt'),
prompt: z.string(), // 使用 $ARGUMENTS 占位符获取 Hook 输入
model: z.string().optional(), // 模型覆盖
timeout: z.number().positive().optional(),
})
3. Agent Hook(Agent 验证器)
启动一个完整的 Agent 来执行验证,适合复杂的验证任务:
const AgentHookSchema = z.object({
type: z.literal('agent'),
prompt: z.string(), // 描述验证要求
model: z.string().optional(),
timeout: z.number().positive().optional(),
})
4. HTTP Hook(HTTP 回调)
向指定 URL 发送 POST 请求,适合与外部服务集成:
const HttpHookSchema = z.object({
type: z.literal('http'),
url: z.string().url(),
headers: z.record(z.string(), z.string()).optional(),
allowedEnvVars: z.array(z.string()).optional(), // 允许在 header 中引用的环境变量
})
allowedEnvVars 字段的设计体现了安全意识:header 中的 $VAR_NAME 引用只会解析列表中明确声明的环境变量,避免意外泄漏敏感信息。
15.4.4 HooksSettings 与 Matcher 机制
Hooks 的配置结构是一个以事件名称为 key、以 matcher 数组为 value 的部分记录:
// 文件: src/schemas/hooks.ts
export const HookMatcherSchema = lazySchema(() =>
z.object({
matcher: z.string().optional(), // 匹配模式
hooks: z.array(HookCommandSchema()), // 匹配时执行的 Hook 列表
}),
)
export const HooksSchema = lazySchema(() =>
z.partialRecord(z.enum(HOOK_EVENTS), z.array(HookMatcherSchema())),
)
export type HooksSettings = Partial<Record<HookEvent, HookMatcher[]>>
一个实际的 Hooks 配置示例:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo '检查 Bash 命令安全性' && validate-bash-input.sh",
"if": "Bash(rm *)"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "eslint --fix $TOOL_INPUT_FILE_PATH"
}
]
}
]
}
}
if 条件使用权限规则语法(如 "Bash(git *)" 匹配所有以 git 开头的 Bash 命令),在实际执行 Hook 之前过滤不匹配的调用,避免为每次工具调用都启动进程。
15.4.5 Hook 的执行与结果处理
Hook 的执行由 src/utils/hooks.ts 和 src/services/tools/toolHooks.ts 协同完成。Pre/Post Tool Use Hooks 的执行流程如下:
工具调用请求到达
|
v
executePreToolHooks()
-- 遍历所有 PreToolUse matcher
-- 对匹配的 matcher 执行其 hooks
-- 收集结果
|
v
结果聚合
-- 有阻塞错误? -> 阻止工具调用,错误信息反馈给模型
-- 有权限决策? -> 影响权限判断(approve/block/passthrough)
-- 有 updatedInput? -> 修改工具输入参数
|
v
工具实际执行
|
v
executePostToolHooks()
-- 遍历所有 PostToolUse matcher
-- 对匹配的 matcher 执行其 hooks
-- 结果可以:注入额外上下文、阻止后续推理、更新 MCP 工具输出
Hook 的结果通过 JSON 输出协议与系统通信。同步 Hook 可以返回丰富的结构化数据:
// 文件: src/types/hooks.ts
export type HookResult = {
message?: Message // 要添加到对话中的消息
blockingError?: HookBlockingError // 阻塞错误信息
outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled'
preventContinuation?: boolean // 是否阻止后续推理
stopReason?: string // 停止原因
permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough'
additionalContext?: string // 注入到对话中的额外上下文
updatedInput?: Record<string, unknown> // 修改后的工具输入
updatedMCPToolOutput?: unknown // 修改后的 MCP 工具输出
}
15.4.6 Skill 级别的 Hooks 注册
Skill 不仅可以定义 prompt 内容,还可以在 frontmatter 中声明 Hooks,这些 Hooks 在 Skill 被调用时动态注册到会话中。这个机制由 src/utils/hooks/registerSkillHooks.ts 实现:
// 文件: src/utils/hooks/registerSkillHooks.ts
export function registerSkillHooks(
setAppState: (updater: (prev: AppState) => AppState) => void,
sessionId: string,
hooks: HooksSettings,
skillName: string,
skillRoot?: string,
): void {
for (const eventName of HOOK_EVENTS) {
const matchers = hooks[eventName]
if (!matchers) continue
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
// once: true 的 Hook 执行一次后自动移除
const onHookSuccess = hook.once
? () => removeSessionHook(setAppState, sessionId, eventName, hook)
: undefined
addSessionHook(setAppState, sessionId, eventName, matcher.matcher || '', hook, onHookSuccess, skillRoot)
}
}
}
}
这种设计使得 Skill 可以在执行期间临时增强系统的行为。例如,一个部署 Skill 可以注册一个 PostToolUse Hook,在每次 Bash 命令执行后检查部署状态;并且通过 once: true 声明,确保 Hook 只执行一次就自动清除,不会污染后续的对话。
15.4.7 超时与后台执行
Hook 的超时管理涉及两个层面:
// 文件: src/utils/hooks.ts
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 // 工具 Hook: 10 分钟
const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500 // 会话结束 Hook: 1.5 秒
常规 Hook 有 10 分钟的慷慨超时,因为它们可能执行编译、测试等耗时操作。但 SessionEnd Hook 的超时仅为 1.5 秒——这是因为会话结束时系统正在关闭,不能让 Hook 无限期阻塞退出流程。用户可以通过 CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS 环境变量调整这个值。
异步 Hook 通过 async: true 声明后台执行,不阻塞主流程。更高级的 asyncRewake: true 选项允许异步 Hook 在退出码为 2 时”唤醒”模型——它将结果作为一条通知消息排入消息队列,模型在空闲时或下一次查询时处理。
15.5 三者的关系与协作
在我们展开三者关系之前、有必要先厘清”为什么需要三层”这个根本问题。理论上、似乎只要有一个”超级 Skill”就够了——它可以包含 prompt、可以附带 hook、可以拉起 MCP server、可以配置 LSP server。但这种”全能实体”的设计有一个严重的问题——它会让”一个最简单的扩展”的学习曲线陡然上升。用户只想写一个简单 markdown 来加一个能力、却要学会所有的 hook 语法、plugin 打包流程、MCP 协议规范——这就像让一个只想煎鸡蛋的人先学会整个米其林三星厨房的运作流程、荒谬且反效率。Claude Code 的三层拆分、正是遵循”简单事情简单做、复杂事情也能做”的产品设计原则——Skill 给个体能力使用、Plugin 给批量打包分发使用、Hooks 给深度定制使用。每一层都有清晰的适用场景、用户可以按需选择。这种分层设计、让”扩展的门槛”和”扩展的深度”得以解耦——门槛足够低所以生态容易繁荣、深度足够大所以高阶用户不会被框住。
15.5.1 架构全景图
+-------------------------------------------------------------+
| 用户界面层 |
| Slash 命令 (/xxx) SkillTool (模型自动调用) |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| 统一命令注册表 (commands.ts) |
| getCommands() -> filter(availability) -> filter(isEnabled) |
+-------------------------------------------------------------+
| | | |
v v v v
+----------+ +----------+ +----------+ +----------+
| Bundled | |FileSystem| | Plugin | | MCP |
| Skills | | Skills | | Skills | | Skills |
+----------+ +----------+ +----------+ +----------+
| 编译进 | | .claude/ | | Git仓库/ | | MCP协议 |
| 二进制 | | skills/ | | 市场插件 | | 远程加载 |
+----------+ +----------+ +----------+ +----------+
|
v
+------------------+
| Plugin System |
| (打包多种组件) |
+------------------+
| - Skills |
| - Hooks |
| - MCP Servers |
| - LSP Servers |
| - Output Styles |
+------------------+
|
v
+-------------------------------------------------------------+
| Hooks 系统 (hooks.ts) |
| PreToolUse -> 工具执行 -> PostToolUse |
| SessionStart -> ... -> SessionEnd |
| 来源: settings.json / Skill frontmatter / Plugin hooksConfig|
+-------------------------------------------------------------+
15.5.2 从声明到执行的完整链路
下图展示了 SkillTool 从接收模型调用到执行 Skill 内容的完整决策流程:
flowchart TB
Invoke["模型调用 SkillTool"] --> Lookup["命令查找"]
Lookup --> Found{"找到命令?"}
Found -->|"否"| Error["返回错误\n命令不存在"]
Found -->|"是"| ModeCheck{"执行模式?"}
ModeCheck -->|"inline"| Inline["内联执行"]
ModeCheck -->|"fork"| ForkAgent["Fork 子 Agent"]
Inline --> Prompt["加载 Skill prompt"]
Prompt --> Budget["预算管理"]
Budget --> Inject["注入对话上下文"]
ForkAgent --> NewCtx["创建新对话"]
NewCtx --> SubLoop["独立查询循环"]
SubLoop --> Return["结果回传"]
让我们追踪一个完整的例子,展示三个系统如何协同工作。假设用户安装了一个名为 my-linter 的插件,它包含一个 Skill 和一个 Hook:
阶段一:启动时加载
loadAllCommands()并行加载所有命令源getPluginSkills()从插件的skills/目录加载 Markdown 文件- 解析 frontmatter,调用
createSkillCommand()创建Command对象 - 插件的
hooksConfig被加载到LoadedPlugin中
阶段二:模型发现 Skill
- SkillTool 的 prompt 包含技能列表,模型看到
my-linter:lint技能 - 系统级 prompt 中通过
system-reminder通知模型可用的 Skill
阶段三:Skill 执行
- 模型调用
SkillTool({ skill: "my-linter:lint", args: "src/" }) validateInput()验证技能存在且允许模型调用checkPermissions()检查权限规则call()根据context决定 inline 或 fork 执行- 如果 Skill 的 frontmatter 声明了 Hooks,
registerSkillHooks()将其注册到会话中
阶段四:Hooks 介入
- Skill prompt 指导模型使用
Write工具修改文件 PreToolUseHook 检查写入路径是否安全- 工具执行完成后,
PostToolUseHook 自动运行 linter - linter 发现问题时(退出码 2),错误信息反馈给模型
- 模型根据反馈修正代码
15.5.3 扩展性设计原则
Claude Code 的扩展架构遵循几个核心设计原则:
声明式优先:Skill 通过 Markdown + frontmatter 声明,Hook 通过 JSON 配置声明,插件通过 manifest 声明。尽可能避免要求用户编写程序代码。
渐进复杂度:最简单的扩展只需一个 Markdown 文件(自定义 Skill);需要更多控制时可以添加 frontmatter 声明 Hooks 和工具限制;需要完整打包分发时封装为插件;需要与外部系统集成时使用 HTTP Hook 或 MCP 服务器。
安全默认值:
- Skill 的工具权限默认为空,需要显式声明
allowed-tools - 新的
Command属性默认不在安全白名单中,需要显式审核 - 插件的
defaultEnabled默认为true,但isAvailable可以基于系统能力动态判断 - Hook 的
if条件使用权限规则语法精确过滤,避免不必要的进程启动
来源追踪:每个 Command 都携带 source 和 loadedFrom 字段,用于遥测上报、权限判断和 UI 展示。插件命令还额外携带 pluginInfo,记录来源仓库和清单信息。
容错降级:每层加载逻辑都有独立的错误处理,一个插件加载失败不会影响其他插件。PluginError 的判别联合类型确保每种错误都能被精确处理并给出有针对性的用户提示。
15.5.4 本机账本:一套真实 ~/.claude/ 的分布切片
前面的讲解都停留在源码层面、容易让人把 Skill/Plugin/Hook 想成纸上概念。下面这份账本来自本书写作环境的真实 ~/.claude/ 目录,它把抽象的三层模型落回到具体的文件系统分布上——看到实际的文件数与 JSON 结构、才能对这套扩展体系的运行态有感知。
第一层:用户自定义 Skill(~/.claude/skills/)。这里是用户在任何项目之外共享的 Skill 放置点,每个子目录是一个 Skill、核心文件是 SKILL.md,其 frontmatter 对应 15.1.6 讲过的 parseSkillFrontmatterFields() 字段集合。当前这台机器只有 1 个自定义 Skill——stamp-commits,附带一个 hooks/post-commit 脚本(注意这是 git hook、不是 Claude Code Hook,两者同名但语义不同)。Skill 的 description 行在章节里展示的机制之外还有一个实际作用——SkillTool 的 prompt 会把它作为触发判断依据、所以作者在这里密集堆了中英文触发词(“存证、盖戳、上链、install hook、stamp commit”),让 LLM 在用户说”帮我盖个戳”时也能命中。
第二层:市场元数据(~/.claude/plugins/known_marketplaces.json)。这是 Plugin 系统的”源清单”、决定了 /plugin UI 里能浏览到哪些插件目录。当前只注册了一个市场——anthropics 官方的 claude-plugins-official、对应 github:anthropics/claude-plugins-official 仓库、本地 clone 到 ~/.claude/plugins/marketplaces/claude-plugins-official/。该市场在 plugins/ 子目录下一共暴露了 33 个官方插件(含 12 个 *-lsp 语言服务插件、以及 code-review、commit-commands、skill-creator、frontend-design 等能力插件),另外还有一个 external_plugins/ 目录承载第三方扩展。这层对用户永远可见、但不会立即加载——只有用户显式 install 后才会进入第三层。
第三层:已安装插件(~/.claude/plugins/installed_plugins.json)。这份文件是 15.2.4 里讲过的”用户偏好”的落地形式之一,version: 2 的 schema 为每个插件维护一个安装记录数组(同一插件可能有 user/project 两个 scope)。本机装了 3 个插件:
| pluginId | 版本 | 安装时间 | 特点 |
|---|---|---|---|
clangd-lsp@claude-plugins-official | 1.0.0 | 2026-04-11 | 固定版本号、走 release tag 路径 |
frontend-design@claude-plugins-official | unknown | 2026-02-01 | 无 tag、走 HEAD、带 gitCommitSha: 27d2b86d... 版本锁 |
rust-analyzer-lsp@claude-plugins-official | 1.0.0 | 2026-04-11 | 固定版本号 |
gitCommitSha 的存在对应 15.2.3 里 LoadedPlugin.sha 字段——它让”安装快照”和”远端 HEAD”解耦、保证用户下次启动仍然是同一份代码,不会被上游 commit 突然更改行为。每个插件还记录 installPath 指向 ~/.claude/plugins/cache/<marketplace>/<name>/<version>/ 的本地缓存、这就是 LoadedPlugin.path 在运行时解析得到的值。
第四层:可加载工件(~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/)。以 frontend-design/unknown/ 为例、里面只有 skills/frontend-design/SKILL.md、说明这个插件本质是个”纯 Skill 壳”、没有 Hook、也没有 MCP server。而 clangd-lsp/1.0.0/ 下则会携带 lspServers 配置来启动 C 语言服务——两种形态对应 15.2.2 BuiltinPluginDefinition 里 skills? 与 lspServers? 两个可选字段的不同取值、插件系统允许它们任意组合而不强求每个插件都填满所有类别。
把这四层连起来看、你能很清楚地看到 15.2 节讲的那些类型定义最后是如何落到磁盘上的——类型不是抽象符号、而是一套严格的目录约定。一个插件的生命周期就是在这套约定里流动:市场发现 → 用户安装(写 installed_plugins.json)→ Git 下载到 cache(写 gitCommitSha)→ 启动时 pluginLoader.ts 扫描 → 转为 LoadedPlugin → 注入统一命令注册表。理解了这条物理链路、再回头看 LoadedPlugin.source = "{name}@{marketplace}" 这个格式、就不再是一行注释、而是这条链路每一段都用得到的全局 ID。
15.5.5 四种 Hook 类型的能力矩阵
15.4.3 分别介绍了 command / prompt / agent / http 四种 Hook 的 schema、但散落的描述不容易让人一眼看清”什么场景该选哪一种”。下面这张矩阵把四者按关键维度并排对比——这是读者在写第一个自定义 Hook 前应该先建立的判断框架:
| 维度 | command | prompt | agent | http |
|---|---|---|---|---|
| 执行主体 | 本地 Shell | LLM 单次推理 | 完整子 Agent(含工具循环) | 远端 HTTP 服务 |
| 输入方式 | stdin JSON + 环境变量 | prompt 文本($ARGUMENTS 注入事件 JSON) | prompt 文本描述验证要求 | POST body 为事件 JSON |
| 阻塞语义 | 退出码 2 → 阻塞、stderr 回传模型;其他非零 → 仅展示给用户 | LLM 返回结构化 JSON 决策 | 子 Agent 通过结果文本表达 approve/block | HTTP 响应体作为 HookResult |
| 可用字段 | command、shell、timeout、once、async、asyncRewake、if | prompt、model、timeout | prompt、model、timeout | url、headers、allowedEnvVars |
| 默认超时 | 10 分钟(TOOL_HOOK_EXECUTION_TIMEOUT_MS),SessionEnd 为 1.5 秒 | 与 command 同 | 与 command 同;但可能因 Agent 内工具调用变得更长 | 与 command 同 |
| 异步支持 | async: true 后台跑,asyncRewake: true 可在 exit 2 时唤醒模型 | 不支持后台 | 不支持后台 | 不支持后台(但调用本身可以是异步 fire-and-forget) |
| 成本级别 | 仅进程开销(毫秒级) | 一次 LLM tokens | 一轮完整 Agent tokens(可能十倍于 prompt) | 网络往返 + 外部服务开销 |
| 适用判断 | 是否要验证/修改文件?有现成脚本吗? | 判断是否需要”AI 语义理解”但不需要多步工具? | 是否要让另一个 Agent 真的”去验证”(跑测试、检查分支状态等)? | 是否要通知/审批流跨出本机(通知 Slack、调用内部审批系统)? |
| 典型用例 | eslint --fix 写后 lint、validate-bash-input.sh 命令守卫 | ”这段 diff 是否动了生产配置?“的快速 LLM 判断 | ”启动一个子 Agent 验证测试用例是否新增并通过” | POST 到 CI 系统触发部署审批、POST 到告警系统通知异常 |
| 安全敏感点 | Shell 注入(用户输入拼接到 command) | prompt 注入(事件内容可能构造对抗性文本) | 同 prompt、但 Agent 可能调用更多工具、权限边界更宽 | 泄漏 header 中的 secret、允许的 env 未显式声明 |
选型顺序的经验法则是”成本先、语义后”——能用 command 解决的不要上 prompt,能用 prompt 解决的不要上 agent,能用 agent 解决的不要开 http。原因是每往上一级,延迟、token 成本、可预测性都会显著劣化。但反过来如果一个任务 command 真的做不到(比如”判断这次 commit 是不是重构”这种语义题),就不要硬写正则、老老实实上 prompt Hook、让 LLM 出判断。
还有一个很容易踩坑的点需要单独拎出来——asyncRewake 的唤醒语义。它不是”立即中断当前推理”、而是”把结果作为通知排入队列、让模型在下次有机会时看到”——所以你不能用它做”严格阻塞”的守卫、那种场景只能用同步 command 配 exit 2。反过来你如果需要”长耗时任务不阻塞当前对话、但完成后要让模型知道”(典型如部署到远端集群、跑完后想告诉模型状态)、asyncRewake: true 就是正解。这两种语义在 15.4.7 里是连着讲的、实际使用时却经常被混在一起用错、所以值得在矩阵之后再强调一次。
小结
本章深入剖析了 Claude Code 的三层扩展体系——Skill 系统、Plugin 系统和 Hooks 系统,以及将它们统一起来的 Slash 命令架构。
Skill 系统 是扩展的基础单元,将”能力”抽象为一段声明式的 prompt 内容加上元数据。通过 BundledSkillDefinition 类型和 registerBundledSkill() 注册机制,内置 Skill 编译进二进制中随 CLI 分发;通过 loadSkillsDir.ts 的磁盘加载器,用户可以在 .claude/skills/ 目录中用 Markdown 文件自由添加新技能;通过 MCP 协议,还可以从远程服务器加载技能。所有来源的 Skill 最终统一转换为 Command 对象,融入同一套命令注册表。
Plugin 系统 在 Skill 之上增加了组件化封装层,一个插件可以同时捆绑 Skill、Hooks、MCP 服务器和 LSP 服务器。BuiltinPluginDefinition 和 LoadedPlugin 类型分别表示内置插件定义和加载后的统一插件表示。三层启用/禁用优先级(用户偏好 > 插件默认 > 系统默认)确保了灵活性,而 20+ 种精确类型化的 PluginError 则为复杂的加载流程提供了可靠的错误处理。
Hooks 系统 深入到工具执行的生命周期中,支持 command、prompt、agent、http 四种 Hook 类型,覆盖了从 PreToolUse 到 SessionEnd 的 20 余种事件。通过 matcher 机制精确过滤触发条件,通过退出码语义约定传递执行结果,通过 JSON 输出协议支持阻塞、修改输入、注入上下文等丰富的交互模式。
Slash 命令系统 是统一的入口层,commands.ts 管理着 80+ 条命令的注册、加载、过滤和分发。getCommands() 函数融合了 Bundled Skill、文件系统 Skill、插件命令、工作流命令和内置命令六种来源,通过可用性检查和启用状态过滤确保每个用户看到的都是正确的命令集合。SkillTool 则是模型侧的入口,将 Skill 的能力暴露为模型可以自主调用的工具。
这套三层递进的扩展架构,既保证了系统的安全性和一致性,又为开发者和社区提供了灵活的扩展点。从一个简单的 Markdown 文件到一个完整的插件包,从一条 Shell 命令钩子到一个 Agent 级别的验证器,Claude Code 的扩展能力始终在”声明式简洁”和”程序化灵活”之间找到了恰到好处的平衡。
四个值得提炼的决策:
- 声明式优先:大量扩展点走 Markdown + frontmatter 而非自定义代码。一方面降低门槛(非工程师也能写 Skill),另一方面提升可审查性(Markdown 意图易读,代码意图难)。与《Tokio 源码》第 18 章所讨论的 configuration-over-code 思路同源。
- 渐进复杂度:最简单的 Skill 是单个 Markdown;稍复杂加 frontmatter;再复杂封装为插件;最终接入 MCP。用户可以从最轻的参与开始,按需求增长逐步加深投入。
- 统一命令总线:bundled / filesystem / plugin / MCP 各种源头最终汇合为同一个
Command对象,通过单一注册表暴露给用户。“多源头、单汇合”架构让命令运行时保持简单,同时无限扩展源头——类似 Tokio 的spawn接受任何Future但统一返回JoinHandle。 - Hook 的语义分层:command / prompt / agent / http 四种类型各服务不同场景,避免”一个万能 Hook 处理所有情况”的陷阱。类似 Linux 内核的 eBPF / perf / kprobe / ftrace 各自针对特定 observability 层次。
Claude Code 内置 Skill 只覆盖最高频场景,大量细分能力交给插件和自定义 Skill,甚至 slash 命令本身都来自文件系统的 Markdown 而非硬编码——这是现代生态型产品的典型策略。