# 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) {} → 贡献生命周期钩子
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
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);
}
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 : [];
}
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 正好由本步的插件系统贡献配置)。