第 28 课:插件、技能与钩子体系

3 阅读9分钟

模块十:扩展与 UI | 前置依赖:第 14 课 | 预计学习时间:75 分钟


学习目标

完成本课后,你将能够:

  1. 解释 Plugin、Skill、Hook 三大扩展体系各自的职责边界与交互方式
  2. 描述插件的完整生命周期(安装、启用、禁用、卸载、自动更新)
  3. 区分 Skill 的三种来源(bundled/disk/MCP)及其加载机制
  4. 理解 Hook 的 26 种事件类型和 matcher 匹配规则
  5. 分析 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):enabledPluginsextraKnownMarketplacesstrictKnownMarketplacesblockedMarketplaces。这是从一次缓存投毒 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.tsinitBundledSkills() 中:

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 支持的字段:

字段类型说明
namestring显示名称
descriptionstring简短描述
when-to-usestring模型自动选择的触发条件
allowed-toolsstring[]可使用的工具白名单
modelstring指定运行模型
context'inline' / 'fork'内联执行还是在子 Agent 中
hooksHooksSettings绑定的钩子
argument-hintstring参数提示

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.tsinstallPlugin 的完整调用链,画出从用户执行 /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 模式实现。