# Step 52: 插件系统

一句话导读:一个插件就是一个带清单的目录,一次打包贡献工具、命令、钩子三类能力;而实现它几乎没写新机制——只是把前面几步搭好的注册表串起来复用,这正是「好架构」到这一步的复利兑现。


# 一、这一步做了什么(What)

给 mini Claude Code 加上插件。一个插件 = plugins/<name>/ 目录,结构是:

plugins/hello/
├── plugin.json     清单:{ name, version, description }
├── tools.mjs       export const tools = [ToolDef...]      → 贡献工具
├── commands.mjs    export const commands = [Command...]   → 贡献斜杠命令
└── hooks.mjs       export function register(hooks) {}     → 贡献生命周期钩子
1
2
3
4
5

utils/plugins.ts 的 loadPlugins() 在启动时:扫描 plugins/*/plugin.json → 对每个插件动态 import 它的三个 .mjs → 把贡献分别并入工具注册表 / 命令注册表 / 钩子系统。三个贡献文件都可有可无,缺哪个就不贡献那一类。

实测:hello 插件贡献 greet 工具 + /hello 命令 + hello-plugin-audit 钩子,全部装载,工具数 36→37。


# 二、面试官视角:插件系统的「新代码」为什么这么少?(Why)

面试题:一个插件系统听起来要做很多——加载、隔离、能力注入。但你说这一步「几乎没写新机制」,为什么?这背后是什么架构决策在起作用?

因为能力早就被做成了「注册表」。插件不过是「批量往已有注册表里塞东西」,每一类贡献都对接前面某一步搭好的基础设施:

插件贡献 进哪个注册表 来自哪一步
tools ToolRegistry step11
commands CommandRegistry step35
hooks HookRegistry step36

想象反面:如果工具是硬编码一个大数组、命令是一堆 if/else、钩子是写死的调用点——那插件就得去改这些核心代码,每加一个插件都动一次主程序,根本无法「装载」。正因为前面每一类能力都被抽象成可动态注册的表,插件才能纯粹从外部「投喂」,主程序对插件零耦合。

所以这一步的题眼不是「插件怎么写」,而是**「注册表是插件化的前提」**——它是一次对前面所有解耦工作的回收和验收。好架构的价值往往不在当下,而在若干步之后某个新需求突然「几乎免费」地实现了。


# 三、原理:它是怎么工作的(How)

# 数据流

index.ts 启动
   ↓ await loadPlugins()
扫描 plugins/*/          → 每个子目录看有没有 plugin.json(没有 = 不是插件,跳过)
   ↓ 有清单
动态 import tools.mjs / commands.mjs / hooks.mjs(pathToFileURL + await import)
   ↓ 返回 LoadedPlugin { name, tools[], commands[], registerHooks? }
index.ts 把 plugin.tools    → registry 注册
         把 plugin.commands → 命令注册表注册
         调 plugin.registerHooks(hooks) → 挂进 HookRegistry
1
2
3
4
5
6
7
8
9

# 声明式发现 + 动态 import

loadPlugins() 的核心逻辑——用 plugin.json 的存在作为「这是不是插件」的判据:

for (const entry of readdirSync(dir, { withFileTypes: true })) {
  if (!entry.isDirectory()) continue;
  const manifestPath = join(pdir, "plugin.json");
  if (!existsSync(manifestPath)) continue; // 没清单 = 不是插件,跳过
  const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
  const plugin: LoadedPlugin = {
    name: manifest.name || entry.name,
    tools:    await importArray(join(pdir, "tools.mjs"), "tools"),
    commands: await importArray(join(pdir, "commands.mjs"), "commands"),
  };
  // hooks.mjs 导出 register(hooks)
  const hooksFile = join(pdir, "hooks.mjs");
  if (existsSync(hooksFile)) {
    const mod = await import(pathToFileURL(hooksFile).href);
    if (typeof mod.register === "function") plugin.registerHooks = mod.register;
  }
  out.push(plugin);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

importArray 处理「文件不存在就返回空数组」,让三类贡献可有可无:

async function importArray(file: string, exportName: string): Promise<any[]> {
  if (!existsSync(file)) return [];               // 缺了就不贡献这类
  const mod = await import(pathToFileURL(file).href);
  const v = mod[exportName] ?? mod.default;
  return Array.isArray(v) ? v : [];
}
1
2
3
4
5
6

# 两个关键选择

  • 为什么用 .mjs 而不是 .ts:插件贡献用纯 JS,pathToFileURL + await import 运行时直接加载,无需编译。分发插件时不用带 tsconfig / 构建链。接口靠鸭子类型对齐我们的 ToolDef/Command——只要形状对得上就能用,不做静态类型绑定。
  • 为什么用 pathToFileURL:Windows 的绝对路径(D:\...)直接 import() 会被当成非法 URL。pathToFileURL 把它转成 file:///D:/... 的合法 URL,跨平台动态 import 才不炸。

# 四、深入追问(面试常见 follow-up)

Q:怎么「卸载」一个插件?需要卸载逻辑吗? A:不需要专门的卸载代码——删掉 plugins/hello/ 目录就等于卸载。因为插件是自包含的目录,扫描时它不在了就不加载,主程序零改动。这是最朴素也最可靠的隔离:用文件系统的「在不在」表达「启用不启用」,比写一套 enable/disable 状态机简单得多。

Q:动态 import 一个第三方插件,安全吗? A:不安全——await import 会执行插件代码,等于把主进程的全部权限交给它。我们这版是教学最小实现,没有沙箱、没有权限隔离、没有命名空间。真实 Claude Code 从 marketplace 安装、按 settings 启用、有命名空间隔离,正是为了缓解这个信任问题。这是「玩具版」和「生产版」之间必须补的一大块。

Q:插件贡献的工具会不会和内置工具、或别的插件撞名? A:这版没做命名空间隔离,撞名会后注册覆盖先注册(取决于注入顺序)。真实版靠命名空间(类似 MCP 的 mcp__<server>__<tool>)避免。这也是「还缺」清单里的一项。

Q:register(hooks) 为什么用回调注入,而不是像 tools/commands 那样导出数组? A:因为钩子不是「一份数据」,而是「往钩子系统里挂监听」的动作,需要拿到 HookRegistry 实例才能调 onPostToolUse 之类。导出一个 register(hooks) 函数,把注册表实例注入给插件,让它自己决定挂什么钩子——比让插件导出一个静态钩子描述再由主程序翻译更灵活。


# 五、踩坑 / 设计权衡

  • 编译 vs 免编译:选 .mjs 免编译,代价是失去 TypeScript 的静态检查,只能靠鸭子类型和运行时容错(importArray 的 Array.isArray 兜底)。对「可分发的第三方扩展」这个场景,免编译的分发便利性压倒了类型安全。
  • 发现机制的边界:只认 plugins/ 下一层目录 + plugin.json。真实版从 marketplace 装、支持版本/依赖、启用开关——我们全没做,聚焦讲清「注册表复用」这个核心。
  • 装载时机:每类贡献都要在对应注册表建好之后才能并入,钩子还要在 HookRegistry 就绪后 register。顺序错了会往一个还不存在的表里塞东西。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
plugins/<name>/ + plugin.json .claude-plugin/plugin.json + 贡献目录
贡献 tools / commands / hooks 真实还能贡献 skills / MCP servers / agents
本地目录扫描 从 marketplace 安装、按 settings 启用
动态 import 无隔离 有命名空间隔离 / 信任模型
还缺:marketplace、版本/依赖、启用开关、命名空间 真实版都有

# 七、一句话总结

插件 = 声明式发现 + 动态 import + 往已有注册表批量投喂:它几乎没写新机制,全靠 step11/35/36 把工具、命令、钩子都做成了可注册的表——这一步与其说是新功能,不如说是对前面所有解耦工作的一次「复利结算」。

# 下一节预告

下一步 step53 做 LSP 集成——给 AI 装上「编译器的眼睛」,改完代码能拿到诊断从而自我纠错(LSP server 正好由本步的插件系统贡献配置)。