Claude Code 2.1.287 在 10 月 1 日发布,更新日志第一条就是 Claude Code Mods(日志原文写作 Claude Mods)。用一句话说,Claude Code Mods 让你写一段 JS 或 TS 代码,插进 Claude Code 内部:Claude 每次要调用工具、收到你的 prompt、在界面上画东西之前,都先经过你的代码。
Claude Code 原来就有 hook,能在 Claude 调用工具前后执行你的脚本,拦下命令、改写参数、替换结果都做得到。Claude Code Mods 新增的,是 hook 做不到的两件事:
- 给 Claude 加工具。你用代码定义一个新工具,Claude 能看到它、能调用它,工具的行为完全由你的代码决定。
- 在 Claude Code 的界面上画东西。提示框上方可以多一行常驻状态,屏幕旁边可以开一个带按钮的面板,内容随事件实时刷新。
本文用一个防误删插件 rm-guard 把这两件事各演示一遍。下面的截图都来自本机 Claude Code 2.1.287 的真实终端。
先说清楚:hook 已经能做什么
不先讲清这一点,就看不出 Claude Code Mods 新在哪里。
官方文档列出的 hook 有五种:shell 命令、HTTP 请求、调用 MCP 工具、交给模型判断的 prompt、以及 subagent。以最常用的 shell 命令型为例,每次事件触发时启动一个进程,通过 stdin 和 stdout 交换 JSON。能力上:
- PreToolUse 可以拦下工具调用,也可以用
updatedInput改写调用参数。 - PostToolUse 可以用
updatedToolOutput替换工具返回给 Claude 的结果。 - 官方 hooks 文档里就有一个拦截
rm -rf的示例脚本。
所以只是「拦下危险命令」,用 hook 就够了。但 hook 的能力到此为止。它没办法给 Claude 添加一个新工具,也没办法在界面上画出一行状态或一个面板,最多回一条消息或发一个终端通知。
官方 hooks 文档开头对两者的关系是这么写的:插件可以把 hook 写成 JavaScript 函数,在 Claude Code 自己的进程里运行,既能处理事件,也能在界面上画东西,这样的插件就是 mod。两种 hook 可以同时使用。
例子:一个防误删插件 rm-guard
rm-guard 解决的问题很具体:Claude 有时会用 rm -r 删目录,删错了就找不回来。它做三件事:
| 部分 | 做什么 | hook 能不能做 |
|---|---|---|
| 拦截 | Claude 在 Bash 里执行递归删除时,拦下这条命令 | 能 |
safe_delete 工具 |
给 Claude 一个新工具:不真删目录,只移到项目里的 .trash/,之后能找回 |
不能 |
| 状态栏和回收站面板 | 提示框上方显示拦截次数和回收站里有几项;/guard 打开回收站,每项一个「恢复」按钮 |
不能 |
整个插件 193 行 TypeScript,外加一个 8 行的类型声明文件和一个 28 行的单元测试。
能力一:给 Claude 加一个工具
代码:注册工具,再实现它
工具分两步定义。第一步在会话开始时注册,写清名字、给 Claude 看的说明和参数格式:
ts
on('session.start', async ($, e, next) => {
const result = await next(e)
await $.tool.register({
name: 'safe_delete',
description: '删除项目里的目录时用这个工具代替 rm -r 和 rm -rf:' +
'它把目标移到项目根目录的 .trash/ 下,之后可以找回。只接受项目目录以内的路径。',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
})
return result
})
第二步实现它。Claude 调用这个工具时,Claude Code 会发出一个 tool.call 事件,工具名是 mcp__<插件名>__<工具名>,由插件自己作答(节选):
ts
on('tool.call', { tool: 'mcp__rm-guard__safe_delete' }, async ($, e) => {
const root = await rootOf($) // 项目根目录的真实路径
const stat = await $.fs.stat(`${root}/${e.path}`, { resolve: true }).catch(() => null)
if (!stat?.realPath?.startsWith(`${root}/`)) { // 解析软链接后再判断,只许删项目里的东西
return { deny: 'safe_delete 拒绝:不在项目目录内,或者不存在' }
}
const trash = `${root}/.trash/${await $.clock.now()}`
await $.process.run(['mkdir', '-p', trash])
await $.process.run(['mv', stat.realPath, `${trash}/`]) // argv 形式,不经过 shell
return { result: `已把 ${e.path} 移到 ${trash.slice(root.length + 1)}/,需要时可以从那里找回` }
})
拦截部分和 hook 的做法一样:Bash 命令里出现递归删除,就返回 deny,拒绝信息里告诉 Claude 改用 mcp__rm-guard__safe_delete。
实际效果:Claude 主动选用了新工具

我只说了一句「把 build 目录删掉,里面都是打包产物,不要了」,没有提 rm-guard,也没有提 safe_delete。Claude 先列出目录内容,确认只有三个打包文件、没有被 git 追踪,然后说「This project has an rm-guard safe-delete tool, so I'll delete through that」,直接调用了 safe_delete。目录被移到 .trash/1790906456516/build,Claude 在回复里告诉我还能从那里找回来。
截图最下方提示框上面那一行「rm-guard 已拦截 0 次 · 回收站 1 项」,是能力二要讲的内容。
被拦下之后,Claude 会停下来问

接着我明确要求「用 rm -rf 把 old-logs 也删掉」。命令被 rm-guard 拦下,Claude 没有换一种删除命令绕过去,而是说明原因并给了两个选项:用 safe_delete 做可恢复的删除,或者我自己在提示框里用 ! rm -rf 执行,用户自己的命令不经过 hook。我回复「用 safe_delete」,old-logs 也进了回收站。
另一轮测试里,我让 Claude「用 Bash 执行 rm -r」,被拦下后它没有问,直接改用了 safe_delete,理由是「目标还是删掉这个目录」。两种反应都在合理范围内:用户点名要求某种做法时,它倾向于先确认。
只注册工具还不够
safe_delete 加上去之后的第一次测试并不顺利。当时 rm-guard 只拦同时带 -r 和 -f 的命令,工具说明写的是「代替 rm -rf」。Claude 用的是 rm -r,没有加 -f,既没被拦,也没有去用新工具,目录被永久删除。
改了两处之后才变成上面截图里的样子:
- 拦截规则放宽到所有递归删除,不管带不带
-f。 - 工具说明从「代替 rm -rf」改成「代替 rm -r 和 rm -rf」。
工具说明是写给模型看的。Claude 用 rm -r 的时候,显然不认为「代替 rm -rf」这句话在说自己。注册一个工具,Claude 会不会用,取决于说明文字有没有用它自己的说法写出它正要做的事。
能力二:在 Claude Code 里画界面
状态栏:提示框上方常驻一行
上一节第二张截图里,提示框上方一直有一行 rm-guard 的状态:拦截了几次、回收站里有几项、最近拦下的是哪条命令。命令被拦下的那一刻,这一行从「已拦截 0 次」变成「已拦截 1 次 · 最近拦下:rm -rf old-logs && ls -la」,不需要任何操作。
做法是挂一个 ui.render 事件,component 选 AbovePrompt,返回要画的内容:
ts
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const { value: view } = await $.state.get(VIEW) // 读界面数据,同时订阅它的变化
if (e.props.hasSurvey || !view || (view.blocked === 0 && view.trash.length === 0)) {
return next(e) // 没东西可显示,交还给 Claude Code
}
const { Box, Text } = $.ui.resolve(e) // 这个界面能用的元素
return Box({ paddingX: 1, children: [
Text({ wrap: 'truncate-end', children: [
Text({ color: 'red', bold: true, children: 'rm-guard' }),
Text({ children: ` 已拦截 ${view.blocked} 次` }),
Text({ dimColor: true, children: ' · ' }),
Text({ color: 'green', children: `回收站 ${view.trash.length} 项` }),
// ......最近拦下的命令、/guard 提示......
] }),
] })
})
能实时刷新,靠的是 $.state。它是 Claude Code 替插件保管的一块会话内数据。界面在绘制时读取它,就自动订阅了它的变化。拦截命令的 hook 写入新数据后,读过这份数据的界面会自动重画,不需要手动通知。
回收站面板:按一个数字键恢复

输入 /guard,rm-guard 用 $.ui.open 打开一个面板。面板放在哪由终端的布局决定,这次停靠在对话的右侧;放不下时,会改放到提示框上方,插件不用为此写两套代码。面板里列出 .trash/ 中的每一项,前面是一个带数字快捷键的「恢复」按钮。

按下 2,build 被移回原位置。三处界面同时变化:弹出「已恢复 build」的提示,面板里只剩 old-logs 一项,提示框上方的状态栏变成「回收站 1 项」。我在终端外面检查过,build/assets/ 下的三个文件都在。
面板的写法和状态栏类似,component 换成 Pane,用 requestId 指明是哪个面板。按钮的处理函数就写在插件里(节选):
ts
on('ui.render', { component: 'Pane', requestId: 'rm-guard-trash' }, async ($, e) => {
const { value: view } = await $.state.get(VIEW)
const { Box, Text, Button } = $.ui.resolve(e)
return Box({ flexDirection: 'column', paddingX: 1, children: [
Text({ bold: true, children: `.trash/ 里有 ${view.trash.length} 项,按数字键恢复到原位置` }),
...view.trash.slice(0, 9).map((item, i) => Box({ key: item.id, flexDirection: 'row', children: [
Button({ label: '恢复', hotkey: String(i + 1), plain: true, onPress: () => restore($, item) }),
Text({ children: ` ${item.name}` }),
Text({ dimColor: true, children: ` ${clock(item.at)} 移入` }),
] })),
] })
})
restore 把目录从 .trash/ 移回原处,再更新 $.state,面板和状态栏就跟着重画。
画界面时踩的两个坑
- 按钮的数字快捷键默认不显示,按钮只画成「 恢复 」。加上
plain: true后,才画成「1: 恢复」这种带编号的样子。 - 面板停靠在右侧后,对话区变窄,状态栏里的几段文字被各自拆成一列,读不成一句话。改成用一个外层
Text包住几段带颜色的Text,再设wrap: 'truncate-end',变窄时就整行从末尾截断。
这两处都是在会话运行时改的。用 --plugin-dir 加载的插件,保存文件后会自动重新加载,对话里会出现一行「rm-guard: reloaded」,不用重启 Claude Code。
插件的结构、校验和测试
text
rm-guard/
├── .claude-plugin/plugin.json # 插件清单;"types" 指向下面的类型声明
├── hooks/hooks.json # {"modules": ["./register.ts"]}
├── hooks/register.ts # 193 行,全部逻辑
├── types/index.d.ts # 8 行,声明 $.state 里存的数据结构
└── tests/register.test.ts # 28 行,单元测试
每个 hook 的签名都是 ($, e, next):$ 是 Claude Code 提供给插件的接口,e 是这次事件的输入,next(e) 把事件交给下一个插件,最后由 Claude Code 执行原本的行为。
写完先跑 claude plugin validate,它按 Claude Code 的方式读源码,列出插件挂了哪些事件、调用了哪些接口。用到 $.state 时,校验要求在类型声明文件里登记用到的键,否则会报「rm-guard.view is not declared」。补上 types/index.d.ts 后通过:
text
❯ ./register.ts hooks: session.start, tool.call{tool=Bash}, tool.call{tool=mcp__rm-guard__safe_delete}, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=rm-guard-trash}, command.run{command=guard}
❯ ./register.ts state writes: rm-guard.view
✔ Validation passed
单元测试用 claude plugin test 跑,不需要调用模型。测试里注册的 hook 排在插件后面,扮演 Claude Code 本身;界面可以用 $.ui.mount 挂到终端上,再读它画出来的文字:
ts
const denied = await $.tool.call({ tool: 'Bash', command: 'rm -r ./build' })
expect(denied.deny).toContain('mcp__rm-guard__safe_delete')
const ui = await $.ui.mount({ plugin: 'rm-guard', surface: 'terminal', component: 'AbovePrompt', props: { /* ... */ } })
expect(await ui.find({ type: 'Text', text: /已拦截 1 次/ })).toBeDefined()
text
(pass) register > 递归删除被拒绝并指向 safe_delete,状态栏随之更新 [35.23ms]
1 pass
0 fail
Ran 1 test across 1 file. [0.19s]
rm-guard 拦不住什么
rm-guard 判断一条命令要不要拦,靠的是识别命令的写法:把命令按 ;、&&、| 切开,找到 rm,看参数里有没有 -r、-R 或 --recursive。find -delete、python -c "import shutil; shutil.rmtree(...)"、xargs rm 都能删掉文件,里面却没有这种写法,rm-guard 拦不住。官方教程对同类例子的评价也一样:它是一张安全网,不是权限系统。
所以 rm-guard 适合当防误删的护栏。真要防住一个执意删除的 agent,要靠权限规则、操作系统沙箱和文件权限。
上手前要知道的几件事
- 接口仍是 early access。官方仓库的 README 和类型文件头部都写着,接口可能在版本之间变化,不另行通知。
- 类型以本地生成的为准。插件被加载或校验时,Claude Code 会在插件目录下生成
.claude-plugin/types/,文件头写着生成它的版本号。 - 我在 2.1.287 上没有设置任何开关,用
--plugin-dir加载的插件就能生效。 - 想看更多界面写法,官方教程里有两个完整例子:Blast Radius 在执行危险命令前打开面板,列出会受影响的文件,等人确认;Replay Theater 记录一轮里的每次编辑,结束后在面板里逐个回放。
截图说明:截图来自 tmux 里运行的 Claude Code 2.1.287,按终端原样的文字和颜色渲染。裁掉了顶部的账号信息、底部的用量统计,以及一条来自我本机其他配置、与本文无关的 Stop hook 报错。
资料来源:Claude Code CHANGELOG;Customize Claude Code with mods;Getting started with Claude Code mods;Claude Code hooks 文档;anthropics/claude-code 仓库的 mods 目录。