「扩展点」的意思是:让第三方或用户自己,在不修改主程序源代码的前提下,往系统里添加能力。Claude Code 有四类扩展点,机制各不相同。
| 类型 | 形态 | 谁触发 | 能做什么 |
|---|---|---|---|
| 技能 Skill |
Markdown 文件 | 模型(通过 SkillTool) | 把一段固定的操作流程或专业知识,做成模型可以按需调用的能力 |
| 插件 Plugin |
代码包 | 安装即生效 | 注册新的工具、斜杠命令、钩子、智能体类型 |
| MCP | 独立进程 / HTTP 服务 | 模型(工具调用) | 接入外部系统的工具和资源,跨语言、跨进程 |
| 钩子 Hook |
脚本 / 命令 | 系统在特定时机 | 在 15 个生命周期节点上拦截、修改、阻断 |
一个技能就是一个 SKILL.md 文件,头部有 YAML 元数据(业内叫 frontmatter):
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>()
几个值得注意的点:
buildNamespace —— 技能有命名空间。放在 skills/git/commit/SKILL.md 的技能,名字会是 git:commit。避免不同来源的技能重名。memoize —— 加载结果被缓存。技能目录扫描涉及大量文件读取,不能每次都做。配套有 clearSkillCaches() 供 /reload 命令使用。回顾第 0.3 节的那条切分线:tools/ 是模型能调的,commands/ 是只有人能敲的。
技能打破了这条界限 —— 它让一段「命令式」的内容(固定流程、专业知识)以工具的形式暴露给模型。而且暴露的成本很低,因为常驻的只有一句描述。
utils/plugins/ 目录:
| 文件 | 职责 |
|---|---|
pluginLoader.ts(107 KB) | 发现、加载、校验、注册插件 |
marketplaceManager.ts(91 KB) | 插件市场:浏览、安装、更新 |
schemas.ts(57 KB) | 插件清单文件的格式定义与校验 |
对应的斜杠命令在 commands/plugin/ 下:
ManagePlugins.tsx(314 KB)—— 插件管理界面BrowseMarketplace.tsx(117 KB)—— 市场浏览界面PluginSettings.tsx(126 KB)—— 插件设置注意界面代码比逻辑代码还大。这是终端界面的典型特征 —— 在终端里画一个可交互的列表、处理键盘导航、渲染滚动条,代码量远超同样功能的网页版。
// 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 命令。
这是一个重要的启动性能约束:任何自动化场景下的启动都不能依赖网络。网络可能慢、可能不通、可能需要认证 —— 而一个跑在流水线里的任务不能因此卡死。
MCP 是 Model Context Protocol(模型上下文协议)的缩写,一个让智能体接入外部工具服务的开放标准。services/mcp/ 目录实现了客户端。
| 方式 | 说明 |
|---|---|
| 标准输入输出 stdio | Claude Code 启动一个子进程,通过它的标准输入输出通信。适合本地工具 |
| HTTP | 连接一个网络服务。适合远程服务、需要认证的服务 |
MCP 工具的名字会被加上前缀:mcp__服务名__工具名。这样:
mcp__github 匹配该服务下所有工具)但也有一个例外模式:环境变量 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 }
无论名字有没有前缀,原始的服务名和工具名都单独保存一份。这样权限判定、埋点、错误信息都能拿到准确的来源信息,不用去解析名字字符串。
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 协议里的一个特定错误码,表示「我需要用户提供信息才能继续」。
ListMcpResourcesTool / ReadMcpResourceTool —— MCP 除了工具还能提供「资源」(可读的数据),这两个工具让模型访问它们McpAuthTool —— 处理 OAuth 认证流程ReadMcpResourceDirTool —— 列出资源目录(对声明支持的服务)钩子让用户在系统的特定时机执行自己的脚本。这是最强大也最危险的扩展点 —— 因为钩子可以阻断操作、修改参数。
// 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 命令解析成语法树)很贵。所以把「贵的准备工作」做一次,返回一个「便宜的匹配函数」重复调用。
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」:输出样式功能改了一个标识字符串的格式,而另一个完全不相关的功能(缓存微压缩)恰好在用精确匹配检查这个字符串。没有报错,没有告警 —— 只是那部分用户悄悄失去了一个优化。