Claude Code Mods 实战:自定义工具与终端 TUI 扩展

说明:本文事实与代码示例主要来自一篇社区教程(juejin 社区帖),其中涉及的版本号、更新日志、官方文档表述、API 名称与行为均属社区作者转述,尚未经一手官方资料独立核验。读者在照搬前,请以本地生成的类型文件与官方文档为准。

据该社区作者转述,Claude Code 2.1.287 的更新日志第一条是 Claude Code Mods(日志原文写作 Claude Mods)。按作者描述,Mods 允许你写一段 JS 或 TS 代码插进 Claude Code 内部:Claude 每次调用工具、收到 prompt、在界面上画东西之前,都先经过你的代码。

作者同时指出,Claude Code 原本已有 hook,能在工具调用前后执行脚本,拦命令、改参数、替换结果都能做。据其说法,Mods 新增的是 hook 做不到的两件事:给 Claude 加工具,以及在 Claude Code 界面上画东西。本文沿用该社区教程的 rm-guard 防误删插件示例,把这两件事各演示一遍。

hook 的能力边界(据社区作者转述)

据该社区作者转述,官方文档列出五种 hook:shell 命令、HTTP 请求、调用 MCP 工具、prompt 判断、subagent。以 shell 命令型为例,每次事件触发时启动进程,通过 stdin/stdout 交换 JSON。作者称 PreToolUse 可以拦下工具调用,也可以用 updatedInput 改写参数;PostToolUse 可以用 updatedToolOutput 替换返回结果,并提到官方 hooks 文档里有一个拦截 rm -rf 的示例。

按作者的归纳,只是拦危险命令,hook 就够了。但 hook 没办法给 Claude 添加新工具,也没办法在界面上画状态行或面板,最多回一条消息或发终端通知。作者还转述称,官方 hooks 文档开头写明:插件可以把 hook 写成 JavaScript 函数,在 Claude Code 自己的进程里运行,既能处理事件,也能在界面上画东西,这样的插件就是 mod;两种 hook 可以同时使用。

以上关于官方文档内容的描述均为社区作者转述,本文无法独立核验其原文表述,建议读者查阅官方 hooks 文档确认。

rm-guard 的三层结构

rm-guard 解决的问题很具体:Claude 有时用 rm -r 删目录,删错了找不回来。按社区教程,它做三件事:

部分 做什么 作者称 hook 能否做
拦截 递归删除时拦下 Bash 命令 能
safe_delete 工具 不真删,移到项目 .trash/,可找回 不能
状态栏与回收站面板 显示拦截次数、回收站项数,/guard 打开面板 不能

作者称整个插件 193 行 TypeScript,外加 8 行类型声明和 28 行单元测试。

能力一:注册自定义工具

以下代码与 API 名称均来自该社区教程,未经一手资料核验,仅作思路参考。

工具分两步定义。第一步在会话开始时注册,写清名字、给 Claude 看的说明和参数格式:

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____,由插件自己作答:

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}/`])
  return { result: `已把 ${e.path} 移到 ${trash.slice(root.length + 1)}/,需要时可以从那里找回` }
})

拦截部分和 hook 一样:Bash 命令里出现递归删除就返回 deny,拒绝信息里告诉 Claude 改用 mcp__rm-guard__safe_delete。

按作者描述的实际效果:用户只说“把 build 目录删掉”,没提 rm-guard,Claude 先列目录确认只有打包文件、没被 git 追踪,然后说“This project has an rm-guard safe-delete tool, so I'll delete through that”,直接调用 safe_delete。目录被移到 .trash/1790906456516/build。

被拦下后,Claude 会停下来问。用户明确要求 rm -rf 时,命令被拦,Claude 没有绕过去,而是给出两个选项:用 safe_delete 可恢复删除,或用户自己在提示框用 ! rm -rf 执行——作者称用户自己的命令不经过 hook。另一轮测试里,用户让 Claude“用 Bash 执行 rm -r”,被拦后它没问,直接改用 safe_delete,理由是“目标还是删掉这个目录”。

只注册工具还不够。作者称第一次测试时 rm-guard 只拦同时带 -r 和 -f 的命令,工具说明写“代替 rm -rf”。Claude 用的是 rm -r,没加 -f,既没被拦也没用新工具,目录被永久删除。改两处后才正常:拦截规则放宽到所有递归删除;工具说明改成“代替 rm -r 和 rm -rf”。作者的结论是:工具说明是写给模型看的,Claude 会不会用,取决于说明文字有没有用它自己的说法写出它正要做的事。

能力二:在终端画界面

状态栏挂在 ui.render 事件,component 选 AbovePrompt:

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)
  }
  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} 项` }),
    ]}),
  ]})
})

按作者描述,实时刷新靠 $.state。它是 Claude Code 替插件保管的会话内数据,界面绘制时读取它就自动订阅变化。拦截 hook 写入新数据后,读过这份数据的界面自动重画,不需要手动通知。

回收站面板用 $.ui.open 打开,component 换成 Pane,用 requestId 指明是哪个面板。作者称面板位置由终端布局决定,这次停靠右侧,放不下时改放提示框上方,插件不用写两套代码。按钮处理函数写在插件里:

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,面板和状态栏跟着重画。作者称按下数字键后,build 被恢复,三处界面同时变化:弹出提示、面板只剩一项、状态栏变成“回收站 1 项”。

作者还提到画界面时踩过两个坑:按钮数字快捷键默认不显示,只画成 [ 恢复 ],加 plain: true 后才显示“1: 恢复”;面板停靠右侧后对话区变窄,状态栏几段文字被拆成一列,改成外层 Text 包住带颜色的 Text,再设 wrap: 'truncate-end',变窄时整行从末尾截断。作者称这两处都在会话运行时改,用 --plugin-dir 加载的插件保存后自动重载,对话里出现“rm-guard: reloaded”,不用重启。

插件结构、校验与测试

rm-guard/
├── .claude-plugin/plugin.json
├── hooks/hooks.json
├── hooks/register.ts
├── types/index.d.ts
└── tests/register.test.ts

按作者描述,每个 hook 签名都是 ($, e, next):$ 是 Claude Code 提供的接口,e 是事件输入,next(e) 把事件交给下一个插件,最后由 Claude Code 执行原本行为。

作者称写完先跑 claude plugin validate,它按 Claude Code 的方式读源码,列出插件挂了哪些事件、调用了哪些接口。用到 $.state 时,校验要求在类型声明文件里登记用到的键,否则报“rm-guard.view is not declared”。补上后通过。

单元测试用 claude plugin test 跑,不需要调用模型。测试里注册的 hook 排在插件后面,扮演 Claude Code 本身;界面可以用 $.ui.mount 挂到终端上,再读它画出来的文字:

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()

上述命令、API 与行为均来自社区教程,未在本文中独立验证。请以本地生成类型与官方文档为准。

rm-guard 拦不住什么

按作者描述,rm-guard 判断是否拦截,靠识别命令写法:按 ;、&&、| 切开,找到 rm,看参数里有没有 -r、-R 或 --recursive。find -delete、python -c "import shutil; shutil.rmtree(...)"、xargs rm 都能删文件,里面却没有这种写法,rm-guard 拦不住。作者转述官方教程对同类例子的评价也一样:它是一张安全网,不是权限系统。真要防住执意删除的 agent,要靠权限规则、操作系统沙箱和文件权限。

上手前要知道的几件事

按作者描述,接口仍是 early access。作者称官方仓库 README 和类型文件头部都写着,接口可能在版本之间变化,不另行通知。类型以本地生成的为准:插件被加载或校验时,Claude Code 会在插件目录下生成 .claude-plugin/types/,文件头写着生成它的版本号。作者称在 2.1.287 上不需要设置任何开关,用 --plugin-dir 加载的插件就能生效。

以上版本号、early access 状态与自动重载行为均来自社区教程,建议读者以本地生成类型与官方文档为准,避免照搬导致操作风险。

从工程化角度看,如果 Mods 的能力如社区教程所述,它把扩展点从“进程外脚本”推进到“进程内插件”,适合需要注册工具或渲染界面的场景;纯拦截、改参数、替换结果仍可继续用 hook,两者可并存。企业内推广时,建议把插件纳入代码仓库管理,用 claude plugin validate 和 claude plugin test 做 CI 门禁,并在类型声明中显式登记 $.state 键,避免运行时才发现未声明。对于安全类插件,应明确其定位是护栏而非权限系统,真正的删除防护仍需权限规则、沙箱和文件权限兜底。

作者还提到官方教程里有两个完整界面例子:Blast Radius 在执行危险命令前打开面板,列出会受影响的文件,等人确认;Replay Theater 记录一轮里的每次编辑,结束后在面板里逐个回放。


资料来源(均为社区帖中引用的链接,本文未独立核验):Claude Code CHANGELOG、Customize Claude Code with mods、Getting started with Claude Code mods、Claude Code hooks 文档、anthropics/claude-code 仓库的 mods 目录。