模块十:扩展与 UI | 前置依赖:第 14 课 | 预计学习时间:75 分钟
学习目标
完成本课后,你将能够:
- 解释 Plugin、Skill、Hook 三大扩展体系各自的职责边界与交互方式
- 描述插件的完整生命周期(安装、启用、禁用、卸载、自动更新)
- 区分 Skill 的三种来源(bundled/disk/MCP)及其加载机制
- 理解 Hook 的 26 种事件类型和 matcher 匹配规则
- 分析 useQueueProcessor 的优先级队列设计(now > next > later)
28.1 三大扩展体系全景
┌──────────────────────────────────────────────────────────────────┐
│ Claude Code 扩展架构 │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │
│ │ Plugin │ │ Skill │ │ Hook │ │
│ │ (容器) │ │ (能力) │ │ (触发器) │ │
│ │ │ │ │ │ │ │
│ │ 可包含: │ │ 类型: │ │ 时机: │ │
│ │ - Skills │──→│ - bundled │ │ - PreToolUse │ │
│ │ - Hooks │──→│ - disk │ │ - PostToolUse │ │
│ │ - MCP servers│ │ - MCP │ │ - Stop │ │
│ │ - Commands │ │ - plugin │ │ - 还有 23 种...│ │
│ │ - Agents │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └──────────────┘ │
│ ↑ ↑ ↑ │
│ │ │ │ │
│ services/plugins/ skills/ utils/plugins/ │
│ pluginOperations.ts bundledSkills.ts loadPluginHooks.ts │
│ PluginInstallation loadSkillsDir.ts │
│ Manager.ts mcpSkillBuilders.ts │
└──────────────────────────────────────────────────────────────────┘
关系总结:Plugin 是容器,可以包含 Skill、Hook、MCP Server、Command、Agent。Skill 是独立能力单元,可以来自 Plugin,也可以独立存在(bundled 或 disk)。Hook 是事件触发器,可以在 Plugin 内配置,也可以在用户 settings.json 中直接配置。
28.2 Plugin 系统
插件类型
插件来源 标识格式
┌──────────────┐
│ Built-in │ {name}@builtin ← 随 CLI 发布,可启用/禁用
│ Marketplace │ {name}@{market} ← 从市场安装
│ Local │ 本地路径 ← 直接指向目录
│ Managed │ 策略管理 ← 企业管理员推送
└──────────────┘
builtinPlugins.ts — 内置插件注册
内置插件存储在一个 Map<string, BuiltinPluginDefinition> 中:
const BUILTIN_PLUGINS: Map<string, BuiltinPluginDefinition> = new Map()
export function registerBuiltinPlugin(definition: BuiltinPluginDefinition): void {
BUILTIN_PLUGINS.set(definition.name, definition)
}
内置插件与 bundled skill 的区别在源码注释中有明确说明:
Built-in plugins differ from bundled skills (src/skills/bundled/) in that:
- They appear in the /plugin UI under a "Built-in" section
- Users can enable/disable them (persisted to user settings)
- They can provide multiple components (skills, hooks, MCP servers)
启用状态的判定链:用户设置 > 插件默认值 > true
const isEnabled = userSetting !== undefined
? userSetting === true
: (definition.defaultEnabled ?? true)
pluginOperations.ts — 核心操作
pluginOperations.ts 是插件管理的核心模块,提供纯函数形式的操作接口(不调用 process.exit,不直接写控制台):
| 操作 | 函数 | 说明 |
|---|---|---|
| 安装 | installPlugin() | 从市场解析、下载、缓存、注册 |
| 卸载 | uninstallPlugin() | 检查反向依赖、移除安装、清理数据 |
| 启用 | enablePlugin() | 写入 settings.json 的 enabledPlugins |
| 禁用 | disablePlugin() | 从 enabledPlugins 移除 |
| 更新 | updatePlugin() | 检查新版本、替换缓存 |
作用域系统:
作用域优先级: local > project > user > managed
┌─────────────────────────────────────────┐
│ user → ~/.claude/settings.json │
│ project → .claude/settings.json │
│ local → .claude/settings.local.json │
│ managed → 策略管理路径 │
└─────────────────────────────────────────┘
插件搜索(findPluginInSettings)从最具体的作用域开始查找:local → project → user。
PluginInstallationManager.ts — 后台安装
启动时的插件安装是异步的,不阻塞主界面:
export async function performBackgroundPluginInstallations(
setAppState: SetAppState,
): Promise<void> {
// 1. 计算差异:哪些 marketplace 需要安装/更新
const diff = diffMarketplaces(declared, materialized)
// 2. 初始化 UI 状态(pending spinners)
setAppState(prev => ({
...prev,
plugins: {
...prev.plugins,
installationStatus: {
marketplaces: pendingNames.map(name => ({
name, status: 'pending',
})),
},
},
}))
// 3. 执行安装,通过 onProgress 回调更新状态
const result = await reconcileMarketplaces({
onProgress: event => {
updateMarketplaceStatus(setAppState, event.name, event.type)
},
})
// 4. 新安装 → 自动刷新;仅更新 → 设置 needsRefresh 通知
if (result.installed.length > 0) {
await refreshActivePlugins(setAppState)
} else if (result.updated.length > 0) {
setAppState(prev => ({
...prev,
plugins: { ...prev.plugins, needsRefresh: true },
}))
}
}
热重载
插件系统支持热重载:当 policySettings(远程管理设置)变化时,自动检测插件相关设置是否发生变化,如果有则重新加载:
export function setupPluginHookHotReload(): void {
settingsChangeDetector.subscribe(source => {
if (source === 'policySettings') {
const newSnapshot = getPluginAffectingSettingsSnapshot()
if (newSnapshot === lastPluginSettingsSnapshot) return // 无变化
clearPluginCache(...)
clearPluginHookCache()
void loadPluginHooks() // fire-and-forget
}
})
}
快照比较四个字段(不仅仅是 enabledPlugins):enabledPlugins、extraKnownMarketplaces、strictKnownMarketplaces、blockedMarketplaces。这是从一次缓存投毒 bug (#23085) 中总结出的教训。
28.3 Skill 系统
三种 Skill 来源
┌────────────────────────────────────────────────────────────────┐
│ Skill 加载管线 │
│ │
│ ┌──────────┐ initBundledSkills() ┌───────────────────────┐ │
│ │ Bundled │ ──────────────────→ │ │ │
│ │ (编译内置) │ │ Command[] 注册表 │ │
│ └──────────┘ │ │ │
│ │ getBundledSkills() │ │
│ ┌──────────┐ loadSkillsDir() │ + getAllSkillCommands() │ │
│ │ Disk │ ──────────────────→ │ │ │
│ │ (.md 文件) │ │ 统一 Command 接口 │ │
│ └──────────┘ │ │ │
│ │ │ │
│ ┌──────────┐ getMCPSkillBuilders()│ │ │
│ │ MCP │ ──────────────────→ │ │ │
│ │ (远程服务) │ └───────────────────────┘ │
│ └──────────┘ │
└────────────────────────────────────────────────────────────────┘
Bundled Skills — 编译时内置
bundledSkills.ts 定义了核心数据结构:
export type BundledSkillDefinition = {
name: string
description: string
aliases?: string[]
whenToUse?: string // ← 模型自动选择的提示
allowedTools?: string[] // ← 限制可用工具
model?: string // ← 指定模型
files?: Record<string, string> // ← 参考文件(按需提取到磁盘)
getPromptForCommand: (args, ctx) => Promise<ContentBlockParam[]>
}
注册流程在 skills/bundled/index.ts 的 initBundledSkills() 中:
export function initBundledSkills(): void {
registerUpdateConfigSkill()
registerKeybindingsSkill()
registerVerifySkill()
registerDebugSkill()
registerLoremIpsumSkill()
registerSkillifySkill()
registerRememberSkill()
registerSimplifySkill()
registerBatchSkill()
registerStuckSkill()
// Feature-gated skills 使用 require() 动态加载
if (feature('KAIROS') || feature('KAIROS_DREAM')) {
const { registerDreamSkill } = require('./dream.js')
registerDreamSkill()
}
// ... 更多 feature-gated skills
}
关键设计 — files 字段与懒加载:
有些 bundled skill 携带参考文件(如模板、示例代码)。这些文件不是一启动就全部提取到磁盘,而是在 skill 首次被调用时才提取:
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) => {
// 闭包级 memoize:提取一次,并发调用等待同一个 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) // 前缀 "Base directory: ..."
}
}
安全写入使用 O_NOFOLLOW | O_EXCL 标志防止符号链接攻击,配合进程级 nonce 目录和 0o600 权限。
Disk Skills — 文件系统 Markdown
用户可以在多个位置放置 .md 文件作为 Skill:
~/.claude/skills/*.md ← 用户级
.claude/skills/*.md ← 项目级
managed-path/.claude/skills/*.md ← 策略管理级
loadSkillsDir.ts 负责扫描这些目录,解析 Frontmatter 元数据(名称、描述、工具限制、模型等),生成 Command 对象。
Frontmatter 支持的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| name | string | 显示名称 |
| description | string | 简短描述 |
| when-to-use | string | 模型自动选择的触发条件 |
| allowed-tools | string[] | 可使用的工具白名单 |
| model | string | 指定运行模型 |
| context | 'inline' / 'fork' | 内联执行还是在子 Agent 中 |
| hooks | HooksSettings | 绑定的钩子 |
| argument-hint | string | 参数提示 |
MCP Skills — 远程服务
mcpSkillBuilders.ts 实现了一个依赖注入注册表,解决 MCP 模块和 loadSkillsDir 之间的循环依赖:
// 写一次注册表 — 避免循环依赖
let builders: MCPSkillBuilders | null = null
export function registerMCPSkillBuilders(b: MCPSkillBuilders): void {
builders = b
}
export function getMCPSkillBuilders(): MCPSkillBuilders {
if (!builders) {
throw new Error('MCP skill builders not registered')
}
return builders
}
注册发生在 loadSkillsDir.ts 模块初始化时(通过 commands.ts 的静态导入在启动时执行)。MCP 连接建立后,MCP 服务器上的 skill 通过这些 builders 转换为标准的 Command 对象。
SkillTool — 模型调用入口
SkillTool 是模型调用 Skill 的统一入口。模型看到的是一个工具:
SkillTool({
skill: "commit",
args: "-m 'Fix bug'"
})
SkillTool 负责查找对应的 Command,调用其 getPromptForCommand,并将结果注入对话上下文。
28.4 Hook 系统
26 种事件类型
Hook 系统支持 26 种事件类型,覆盖了几乎所有系统节点:
┌─────────────────────────────────────────────────────────┐
│ Hook 事件分类 │
│ │
│ 工具相关: │
│ - PreToolUse ← 工具执行前(验证、参数修改) │
│ - PostToolUse ← 工具执行后(自动格式化、检查) │
│ - PostToolUseFailure ← 工具执行失败后 │
│ - PermissionRequest ← 权限请求时 │
│ - PermissionDenied ← 权限被拒绝时 │
│ │
│ 会话相关: │
│ - SessionStart ← 会话开始 │
│ - SessionEnd ← 会话结束 │
│ - Stop / StopFailure ← Agent 停止时 │
│ - UserPromptSubmit ← 用户提交提示 │
│ │
│ Agent 相关: │
│ - SubagentStart ← 子 Agent 启动 │
│ - SubagentStop ← 子 Agent 停止 │
│ - TeammateIdle ← Teammate 空闲 │
│ │
│ 任务相关: │
│ - TaskCreated ← 任务创建 │
│ - TaskCompleted ← 任务完成 │
│ │
│ 上下文相关: │
│ - PreCompact ← 压缩前 │
│ - PostCompact ← 压缩后 │
│ - InstructionsLoaded ← 指令加载 │
│ │
│ 系统相关: │
│ - Notification ← 通知事件 │
│ - Elicitation / ElicitationResult ← 用户交互 │
│ - ConfigChange ← 配置变更 │
│ - CwdChanged ← 工作目录变更 │
│ - FileChanged ← 文件变更 │
│ - WorktreeCreate / WorktreeRemove ← 工作树操作 │
│ - Setup ← 初始设置 │
└─────────────────────────────────────────────────────────┘
Matcher 匹配规则
Hook 通过 matcher 决定匹配哪些工具:
{
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "prettier --write $CLAUDE_FILE_PATH"
}
]
}
]
}
matcher 支持:
- 工具名(如
"Bash") - 管道分隔的多工具(如
"Edit|Write") - 空字符串匹配所有工具
插件 Hook 加载
loadPluginHooks.ts 实现了从插件到全局 Hook 注册表的转换:
function convertPluginHooksToMatchers(plugin: LoadedPlugin)
: Record<HookEvent, PluginHookMatcher[]>
{
// 为每个事件类型初始化空数组(26 种事件)
const pluginMatchers: Record<HookEvent, PluginHookMatcher[]> = {
PreToolUse: [], PostToolUse: [], Stop: [],
// ... 26 种事件
}
// 遍历插件的 hooksConfig,附加 pluginRoot 和 pluginName 上下文
for (const [event, matchers] of Object.entries(plugin.hooksConfig)) {
for (const matcher of matchers) {
pluginMatchers[hookEvent].push({
matcher: matcher.matcher,
hooks: matcher.hooks,
pluginRoot: plugin.path, // ← 用于定位插件目录
pluginName: plugin.name, // ← 用于日志
pluginId: plugin.source, // ← 用于权限检查
})
}
}
return pluginMatchers
}
原子替换:加载新 Hook 时使用 clear + register 原子对,而不是先 clear 后 register。这是从 gh-29767 bug 学到的教训 — 之前 clearAllCaches() 会清除 Hook 注册,导致 Stop hook 在插件管理操作后永远不执行。
卸载插件的 Hook 修剪
当插件被禁用但还没有完全重载时,pruneRemovedPluginHooks 负责立即移除已禁用插件的 Hook:
export async function pruneRemovedPluginHooks(): Promise<void> {
const { enabled } = await loadAllPluginsCacheOnly()
const enabledRoots = new Set(enabled.map(p => p.path))
// 重新读取(await 期间可能有并发变更)
const current = getRegisteredHooks()
// 只保留仍然启用的插件的 Hook
const survivors = filterByEnabledRoots(current, enabledRoots)
clearRegisteredPluginHooks()
registerHookCallbacks(survivors)
}
28.5 useQueueProcessor — 优先级队列
Hook 和其他异步事件通过统一的命令队列处理:
export function useQueueProcessor({
executeQueuedInput,
hasActiveLocalJsxUI,
queryGuard,
}: UseQueueProcessorParams): void {
// 订阅查询状态(是否正在执行 query)
const isQueryActive = useSyncExternalStore(
queryGuard.subscribe, queryGuard.getSnapshot,
)
// 订阅统一命令队列
const queueSnapshot = useSyncExternalStore(
subscribeToCommandQueue, getCommandQueueSnapshot,
)
useEffect(() => {
if (isQueryActive) return // 有活跃查询 → 不处理
if (hasActiveLocalJsxUI) return // 有活跃 JSX UI → 不处理
if (queueSnapshot.length === 0) return // 队列为空 → 不处理
processQueueIfReady({ executeInput: executeQueuedInput })
}, [queueSnapshot, isQueryActive, ...])
}
优先级机制:dequeue() 自动按优先级排序:
'now' ← 最高优先级(系统紧急命令)
'next' ← 中等优先级(用户输入)
'later' ← 最低优先级(任务通知、task-notification)
这确保了用户输入总是优先于后台任务通知被处理,而系统紧急命令则最先处理。
28.6 三个系统的协同
完整的扩展加载流程:
启动时:
1. initBundledSkills() ← 注册编译时内置 Skill
2. loadSkillsDir() ← 扫描磁盘 Skill
3. loadPluginHooks() ← 从已启用插件加载 Hook
4. performBackgroundPluginInstallations() ← 后台安装新插件
5. MCP 连接建立 ← 加载 MCP Skill
工具执行时:
PreToolUse hooks → 工具执行 → PostToolUse hooks
模型选择 Skill 时:
SkillTool → 查找 Command → getPromptForCommand → 注入上下文
插件管理时:
/plugin install → pluginOperations → refreshActivePlugins
→ clearPluginCache → loadPluginHooks → 更新 AppState
课后练习
练习 1:插件生命周期
跟踪 pluginOperations.ts 中 installPlugin 的完整调用链,画出从用户执行 /plugin install foo 到插件可用的每一步。重点关注缓存策略和错误处理。
练习 2:Skill 冲突解决
如果三个地方定义了同名 Skill(bundled、~/.claude/skills/、.claude/skills/),最终哪个生效?阅读 loadSkillsDir.ts 中的去重逻辑,验证你的假设。
练习 3:自定义 Hook
编写一个 PostToolUse Hook 配置(settings.json 格式),实现:每次 Edit 或 Write 工具执行后,自动运行 eslint --fix 并将结果写入 .claude/lint-log.txt。
练习 4:架构分析
分析 pruneRemovedPluginHooks 中"await 之后重新读取"的设计。如果不重新读取,在什么并发场景下会出现 bug?绘制时序图说明。
本课小结
| 要点 | 内容 |
|---|---|
| Plugin | 容器角色,包含 Skill/Hook/MCP/Command/Agent,支持 4 种作用域 |
| Skill 来源 | bundled(编译内置)、disk(.md 文件)、MCP(远程服务) |
| Hook 事件 | 26 种事件类型,覆盖工具/会话/Agent/任务/上下文/系统 |
| 队列优先级 | now > next > later,确保用户输入优先 |
| 热重载 | 插件设置变更自动检测,原子替换 Hook 注册 |
| 安全设计 | 文件提取使用 O_NOFOLLOW + O_EXCL,防止符号链接攻击 |
下一课预告
第 29 课:终端 UI 层 — 深入 Ink 渲染引擎的 3 阶段管线(Reconciler → Yoga 布局 → Screen Diff)、146 个组件的设计体系、14 文件的键绑定系统、以及完整的 Vim 模式实现。