9 · 扩展体系

「扩展点」的意思是:让第三方或用户自己,在不修改主程序源代码的前提下,往系统里添加能力。Claude Code 有四类扩展点,机制各不相同。

9.1 四类扩展点对照

类型形态谁触发能做什么
技能
Skill
Markdown 文件 模型(通过 SkillTool) 把一段固定的操作流程或专业知识,做成模型可以按需调用的能力
插件
Plugin
代码包 安装即生效 注册新的工具、斜杠命令、钩子、智能体类型
MCP 独立进程 / HTTP 服务 模型(工具调用) 接入外部系统的工具和资源,跨语言、跨进程
钩子
Hook
脚本 / 命令 系统在特定时机 在 15 个生命周期节点上拦截、修改、阻断

9.2 技能系统

形态

一个技能就是一个 SKILL.md 文件,头部有 YAML 元数据(业内叫 frontmatter):

~/.claude/skills/my-skill/SKILL.md --- name: my-skill description: 一句话说明这个技能干什么,什么时候该用 allowed-tools: Read, Bash(git:*) ← 可选:限制这个技能能用哪些工具 hooks: ... ← 可选:技能自带的钩子 --- # 技能正文 这里写详细的操作步骤、注意事项、示例…… 可以很长,几千 token 都没关系。

核心机制:渐进式披露

程序启动时 └─ 扫描技能目录,只解析每个文件的头部元数据 (name + description,每个几十 token) ★ 这部分是"常驻"的,每一轮都要重发 ↓ 模型判断某个技能可能相关时 └─ 调用 SkillTool(名字) ↓ 运行时 └─ 完整的 SKILL.md 正文才被注入上下文(可能几千 token)

Claude Code 有一个专门的函数量化常驻成本:

export function estimateSkillFrontmatterTokens(skill: Command): number

因为所有技能的头部元数据都常驻上下文,装 100 个技能的固定成本必须可测量 —— 否则用户装着装着就发现每轮都在白烧几千 token。

加载器的实现细节

// skills/loadSkillsDir.ts
export type LoadedFrom = ...                    // 从哪个来源加载的
export function getSkillsPath(...)              // 技能目录路径
export function estimateSkillFrontmatterTokens(skill: Command): number
function parseHooksFromFrontmatter(...)         // 解析技能自带的钩子
function parseSkillPaths(frontmatter): string[] | undefined
export function parseSkillFrontmatterFields(...)
export function createSkillCommand({...})       // 把技能包装成一个命令对象
function isSkillFile(filePath: string): boolean
function transformSkillFiles(files: MarkdownFile[]): MarkdownFile[]
function buildNamespace(targetDir: string, baseDir: string): string   // 命名空间
function getSkillCommandName(filePath: string, baseDir: string): string
export const getSkillDirCommands = memoize(...)  // ★ 结果被缓存
export function clearSkillCaches()

// 动态技能:运行时注册的,不在磁盘上
const dynamicSkillDirs  = new Set<string>()
const dynamicSkills     = new Map<string, Command>()

几个值得注意的点:

技能是「把命令变成工具」的桥

回顾第 0.3 节的那条切分线:tools/ 是模型能调的,commands/ 是只有人能敲的。

技能打破了这条界限 —— 它让一段「命令式」的内容(固定流程、专业知识)以工具的形式暴露给模型。而且暴露的成本很低,因为常驻的只有一句描述。

9.3 插件系统

utils/plugins/ 目录:

文件职责
pluginLoader.ts(107 KB)发现、加载、校验、注册插件
marketplaceManager.ts(91 KB)插件市场:浏览、安装、更新
schemas.ts(57 KB)插件清单文件的格式定义与校验

对应的斜杠命令在 commands/plugin/ 下:

注意界面代码比逻辑代码还大。这是终端界面的典型特征 —— 在终端里画一个可交互的列表、处理键盘导航、渲染滚动条,代码量远超同样功能的网页版。

缓存优先加载

// QueryEngine.ts
// Cache-only: headless/SDK/CCR startup must not block on network for
// ref-tracked plugins. CCR populates the cache via CLAUDE_CODE_SYNC_PLUGIN_INSTALL
// (headlessPluginInstall) or CLAUDE_CODE_PLUGIN_SEED_DIR before this runs;
// SDK callers that need fresh source can call /reload-plugins.
const [skills, { enabled: enabledPlugins }] = await Promise.all([
  getSlashCommandToolSkills(getCwd()),
  loadAllPluginsCacheOnly(),        // ★ 只读缓存,不发网络请求
])

译:仅缓存模式:无头 / 开发工具包 / 远程环境的启动,不能因为要拉取「按引用追踪的插件」而阻塞在网络上。……需要最新源码的调用方可以执行 /reload-plugins 命令。

这是一个重要的启动性能约束:任何自动化场景下的启动都不能依赖网络。网络可能慢、可能不通、可能需要认证 —— 而一个跑在流水线里的任务不能因此卡死。

9.4 MCP 客户端

MCP 是 Model Context Protocol(模型上下文协议)的缩写,一个让智能体接入外部工具服务的开放标准。services/mcp/ 目录实现了客户端。

两种传输方式

方式说明
标准输入输出
stdio
Claude Code 启动一个子进程,通过它的标准输入输出通信。适合本地工具
HTTP连接一个网络服务。适合远程服务、需要认证的服务

工具名的前缀

MCP 工具的名字会被加上前缀:mcp__服务名__工具名。这样:

但也有一个例外模式:环境变量 CLAUDE_AGENT_SDK_MCP_NO_PREFIX 可以关掉前缀。所以 Tool 接口里有一个专门的字段应对:

/**
 * For MCP tools: the server and tool names as received from the MCP server
 * (unnormalized). Present on all MCP tools regardless of whether `name` is
 * prefixed (mcp__server__tool) or unprefixed (CLAUDE_AGENT_SDK_MCP_NO_PREFIX mode).
 */
mcpInfo?: { serverName: string; toolName: string }

无论名字有没有前缀,原始的服务名和工具名都单独保存一份。这样权限判定、埋点、错误信息都能拿到准确的来源信息,不用去解析名字字符串。

向用户索取信息(Elicitation)

MCP 协议支持服务端反过来向用户要信息(比如「请输入你的 API 密钥」)。Claude Code 有专门的处理:

/**
 * Optional handler for URL elicitations triggered by tool call errors (-32042).
 * In print/SDK mode, this delegates to structuredIO.handleElicitation.
 * In REPL mode, this is undefined and the queue-based UI path is used.
 */
handleElicitation?: (
  serverName: string,
  params: ElicitRequestURLParams,
  signal: AbortSignal,
) => Promise<ElicitResult>

两条路径:交互模式下走界面队列弹对话框(对应的组件 ElicitationDialog.tsx 有 175 KB);无头模式下走结构化输入输出协议,把请求转发给外层调用方。

-32042 是 MCP 协议里的一个特定错误码,表示「我需要用户提供信息才能继续」。

MCP 相关的其他工具

9.5 钩子:15 类生命周期事件

钩子让用户在系统的特定时机执行自己的脚本。这是最强大也最危险的扩展点 —— 因为钩子可以阻断操作、修改参数。

// types/hooks.ts 里的事件类型
hookEventName: z.literal('PreToolUse')          // 工具执行前
hookEventName: z.literal('PostToolUse')         // 工具执行后
hookEventName: z.literal('PostToolUseFailure')  // 工具执行失败后
hookEventName: z.literal('PermissionRequest')   // 权限请求时
hookEventName: z.literal('PermissionDenied')    // 权限被拒时
hookEventName: z.literal('UserPromptSubmit')    // 用户提交提问时
hookEventName: z.literal('SessionStart')        // 会话开始
hookEventName: z.literal('Setup')               // 初始化 / 维护
hookEventName: z.literal('SubagentStart')       // 子智能体启动
hookEventName: z.literal('Notification')        // 通知
hookEventName: z.literal('Elicitation')         // MCP 索取信息
hookEventName: z.literal('ElicitationResult')   // 索取结果
hookEventName: z.literal('CwdChanged')          // 工作目录变了
hookEventName: z.literal('FileChanged')         // 文件被外部修改
hookEventName: z.literal('WorktreeCreate')      // 创建工作树

claude-code/src/types/hooks.ts

另外还有几类在别处定义的:Stop(结束前)、PreCompact(压缩前)、PostSampling(模型采样后)。

钩子的执行引擎

utils/hooks/ 目录:

文件职责
execAgentHook.ts执行「智能体型」钩子 —— 钩子本身是一次模型调用
execHttpHook.ts执行 HTTP 钩子 —— 把事件 POST 到一个网址
execPromptHook.ts执行提示词钩子
ssrfGuard.ts服务端请求伪造防护 —— 防止 HTTP 钩子被诱导去访问内网地址
AsyncHookRegistry.ts异步钩子注册表
hookEvents.ts钩子执行的事件流(开始/进度/响应)
hooksConfigManager.ts / hooksConfigSnapshot.ts配置管理与快照
registerSkillHooks.ts / registerFrontmatterHooks.ts注册技能自带的钩子
fileChangedWatcher.ts文件变更监听
skillImprovement.ts技能自我改进

ssrfGuard.ts 的存在值得注意。HTTP 钩子会把事件内容发到用户配置的网址。如果不加防护,一个恶意的(或被诱导的)配置可以让 Claude Code 去访问 http://169.254.169.254/(云服务商的元数据接口,能拿到临时凭据)—— 这是经典的服务端请求伪造攻击。

钩子的进度反馈

export function startHookProgressInterval(params: {...}): ...
export const HOOK_TIMING_DISPLAY_THRESHOLD_MS = 500

钩子是用户自己写的脚本,耗时完全不可控。所以:

钩子的条件匹配

钩子配置可以带条件,比如「只在 Bash 工具执行 git 命令时触发」。这需要工具配合:

/**
 * Prepare a matcher for hook `if` conditions (permission-rule patterns like
 * "git *" from "Bash(git *)"). Called once per hook-input pair; any
 * expensive parsing happens here. Returns a closure that is called per
 * hook pattern. If not implemented, only tool-name-level matching works.
 */
preparePermissionMatcher?(input: z.infer<Input>): Promise<(pattern: string) => boolean>

注意设计:返回的是一个闭包,而不是直接做匹配。因为一个工具调用可能要对照几十条钩子模式,而解析(比如把 shell 命令解析成语法树)很贵。所以把「贵的准备工作」做一次,返回一个「便宜的匹配函数」重复调用

9.6 输出样式

outputStyles/ 是一个小但有意思的扩展点:允许用户替换系统提示词的「人格」部分

它在代码里的影响之一,是让查询来源标识变成动态的:

// Prefix-match because promptCategory.ts sets the querySource to
// 'repl_main_thread:outputStyle:<style>' when a non-default output style
// is active. The bare 'repl_main_thread' is only used for the default style.
function isMainThreadSource(querySource: QuerySource | undefined): boolean {
  return !querySource || querySource.startsWith('repl_main_thread')
}

注释里还提到了一个因此产生的 bug:

「query.ts:350/1451 use the same startsWith pattern; the pre-existing cached-MC === 'repl_main_thread' check was a latent bug — users with a non-default output style were silently excluded from cached MC.」

译:……之前缓存微压缩里那个「完全等于 repl_main_thread」的判断是一个潜伏的 bug —— 使用了非默认输出样式的用户被静默地排除在缓存微压缩之外。

这是一个典型的「特性交互 bug」:输出样式功能改了一个标识字符串的格式,而另一个完全不相关的功能(缓存微压缩)恰好在用精确匹配检查这个字符串。没有报错,没有告警 —— 只是那部分用户悄悄失去了一个优化。