跳到正文
山月行
返回

Claude Code mods 入门:改写工具调用

发布于

10 月初,Claude Code 推出了 mods(官方入门指南发布于 10 月 1 日),2.1.287 起可用。官方的说法是:一个 mod 就是几行 TypeScript,能改写发给模型的 prompt,能拦下、改写或重试一次工具调用,能画新的界面,还能替换 Claude Code 自带的功能。

我第一反应是:这不就是 hooks 吗?hooks 早就能在 PreToolUse 时拦命令了。读完文档和源码之后发现,两者差别还挺大:hooks 是在外面等事件的脚本,mods 是插进 Claude Code 自己处理流程里的函数。

这篇按四步讲:先说清区别,再看官方自己写的 mod,然后动手写一个改写工具调用的 mod,最后说什么时候用哪个。文中的校验、测试和安装命令都在 Claude Code 2.1.292 上实际跑过,这是我写这篇时的最新版本。

Claude Code hooks 与 mods 对比总览图:settings hook 每次起一个进程用 JSON 通信,mod 是同进程里的函数,通过 next 把事件交给下一层

上面一行是 hook:事件来了,Claude Code 起一个进程,把 JSON 从 stdin 喂给你的脚本,脚本从 stdout 回一个决定。下面一行是 mod:事件来了,Claude Code 直接调用你注册的函数,你的函数决定要不要、以及用什么参数调用 next(e),把事件交给后面的 mod 和 Claude Code 自己的逻辑。

一、一句话区别

为了不混淆,官方文档把原来那套叫 settings hook,也就是写在 settings.json 里、在生命周期事件上执行的外部命令、HTTP 请求或 prompt;mod 里注册的处理函数也叫 hook,但它是 Claude Code 在自己进程里调用的函数。

一句话:hooks 在事件发生时跑一段外部命令,决定放行、拦截或补充上下文;mods 是在 Claude Code 里面运行的函数,可以改写 prompt、拦截并改写工具调用、加界面、替换内置功能。

Settings hookMod
在哪跑Claude Code 外,每次事件起一个进程(或发一个 HTTP 请求)Claude Code 里,加载一次,整个会话常驻
用什么写任意语言的脚本,加一段 settings.json 配置JavaScript 或 TypeScript,不需要构建
能做到哪放行、拦截、询问;改工具参数和结果;给 Claude 补一段上下文观察、改写、直接回答任意事件;保留状态;画面板和按钮;注册命令和工具
怎么分发写进 settings.json,或放在插件的 hooks/hooks.json 里只能放在插件里,按插件安装、更新、禁用

这里要纠正一个常见误解:settings hook 其实也能改工具参数。PreToolUse 返回的 updatedInput 会替换工具的输入,官方文档的对比表里也写了它能改”工具调用的参数和结果”。区别在于方式:updatedInput 会整个替换输入对象,没改的字段也得原样写回去;每次调用都要起进程、解析 JSON;脚本跑完就退出,记不住上一次发生了什么。

mod 的写法是中间件。每个 hook 的签名都一样:

on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
  // $    mods API:ui、fs、process、state、command、model……
  // e    这次事件的输入,只读的普通数据
  // next 把 e 交给下一个 mod,最后交给 Claude Code 自己
  return next(e)
})

在函数里能做三件事:

  • 观察:const r = await next(e),看一眼结果再原样返回。
  • 改写:return next({ ...e, command: safer }),后面的人看到的就是改过的参数。
  • 回答:不调用 next,直接 return { deny: '…' },工具根本不会执行。

tool.call 事件穿过 mod 链的示意图:先加载的 mod、后加载的 mod 依次调用 next,最后到 Claude Code 的权限检查和 settings hook,再真正执行工具

多个 mod 挂在同一个事件上时,按加载顺序排成一条链:先加载的最先看到事件,也最后看到结果。链的最底部是 Claude Code 自己的逻辑,包括权限检查,以及普通设置文件和插件里的 PreToolUse 钩子,它们在最后一个 mod 调用 next 之后才跑。组织在托管设置里配的钩子例外,排在所有 mod 之前,它的拦截是最终结论。

还有一点容易误会:mod 模块本身跑在单独的环境里,没有 DOM 也没有 Node,读文件、起进程、发请求都得通过 $。但 $ 能做的事和 Claude Code 一样多,所以官方明确说 mods 不是沙箱。

二、内置功能本身就是 mod

最能说明 mods 能力边界的,是 Claude Code 把自己的功能也改成了 mod。官方公告里点名的例子是 /diff:它现在是一个 mod,可以在 /plugin 里关掉,也可以换成你自己写的版本。入门指南里还提到,AGENTS.md 支持也是用 mod 实现的。

这些源码就在公开仓库 anthropics/claude-code 的 mods/ 目录下,我把仓库拉下来看了一眼:

anthropics/claude-code 仓库 mods 目录的终端输出:agents-md、diff、sec-default、telemetry 四个内置 mod,以及 diff 的 hooks.json

目录里有四个内置 mod,外加一份类型声明 types/claude-code.d.ts:

  • diff:/diff 命令,在对话旁边开一个面板,按文件列出本次会话还没提交的改动,Claude 每改一次文件、跑一次命令就刷新;
  • agents-md:把 AGENTS.md 当作项目指令加载;
  • sec-default:团队版、企业版或有托管设置的机器上最先加载,挡住用户自装的插件去碰组织管理的东西,比如权限的拒绝规则;
  • telemetry:给其他内置 mod 提供上报分析数据的方法,分析关掉时什么也不发。

每个子目录都是一个完整插件:.claude-plugin/plugin.json 是清单,hooks/hooks.json 里的 modules 指向入口文件,tests/ 里是测试。我们自己写 mod,结构和它们一模一样。想看某个内置 mod 实际跑起来的样子,可以按 README 里写的,用 claude --plugin-dir mods/diff 直接从源码加载。

我建议入门时先读 agents-md,它的逻辑最好懂。它挂了两个关键事件:prompt.context 在构建上下文时把找到的 AGENTS.md 交给引擎,当作项目指令;tool.call 挂在 Read 上,读到子目录里的文件时,把那一层的 AGENTS.md 补进上下文,做法和引擎处理嵌套 CLAUDE.md 时一样。

内置 agents-md mod 的默认规则:构建上下文时检查项目有没有 CLAUDE.md,没有就读 AGENTS.md,有就只用 CLAUDE.md

默认模式下的规则很简单:项目里没有 CLAUDE.md 时读 AGENTS.md;两个都有时,默认只读 CLAUDE.md。这一点在多个 Agent 工具之间共用一份项目说明时很关键,展开讲就跑题了,我单独写了一篇 AGENTS.md 使用指南。

读这几份源码,比读任何教程都快。一来它们是官方团队按自己的规范写的,hook 怎么拆、状态放哪、测试怎么写都有示范;二来 README 里写清了每个 mod 挂了哪些事件、调了 $ 上哪些方法,正好可以对照代码看。

三、动手写一个改写工具调用的 mod

挑一个我自己天天遇到的场景:项目用 pnpm,Claude 却时不时跑一句 npm install。用 hook 拦下来当然可以,但拦完 Claude 还得再来一轮才会改用 pnpm,白白多花一次请求。更顺的做法是直接把命令改掉,让它照常执行,再告诉 Claude 实际跑的是哪条。

其实不想手写也行。在会话里直接描述你想要的 mod,Claude 会按内置的 plugin-authoring 技能把它写到 ~/.claude/dev-mods/ 下以会话 ID 命名的目录里,第一次保存时问你要不要开热重载,同意后改动在每轮结束时生效。但第一次写,我建议自己手敲一遍,弄明白每个文件是干什么的,之后再让 Claude 代劳也看得懂它写了什么。

先确认版本,mods 需要 2.1.287 及以上:

claude --version   # 2.1.292 (Claude Code)

一个 mod 就是一个插件目录,加上一个测试文件,一共四个文件:

pnpm-guard/
├── .claude-plugin/
│   └── plugin.json
├── hooks/
│   ├── hooks.json
│   └── register.ts
└── tests/
    └── register.test.ts

plugin.json 是普通的插件清单,mod 不需要额外字段:

{
  "name": "pnpm-guard",
  "version": "0.1.0",
  "description": "In a pnpm project, rewrite Claude's npm install / npm run commands to pnpm before they run",
  "author": { "name": "shanyue" }
}

hooks/hooks.json 里有 modules 字段,这个插件就是一个 mod。它只能列一个入口文件,.ts 文件可以直接加载,不用打包:

{
  "description": "Rewrite npm commands to pnpm in pnpm projects",
  "modules": ["./register.ts"]
}

核心是 register.ts,入口导出 register(on):

import type { On } from 'claude-code'

// 只改写单条、无管道/串联的 npm 命令,其余原样放行
const SHELL_OPS = /[;&|`$()<>]/

// npm 命令 -> pnpm 命令;返回 undefined 表示不改写
export function toPnpm(command: string): string | undefined {
  const cmd = command.trim()
  if (SHELL_OPS.test(cmd)) return undefined

  const [bin, sub, ...rest] = cmd.split(/\s+/)
  if (bin !== 'npm' || sub === undefined) return undefined

  if (sub === 'install' || sub === 'i' || sub === 'add') {
    const pkgs = rest.filter(arg => !arg.startsWith('-'))
    // npm install 不带包名:装依赖;带包名:加依赖
    return pkgs.length === 0
      ? ['pnpm', 'install', ...rest].join(' ')
      : ['pnpm', 'add', ...rest].join(' ')
  }
  if (sub === 'ci') return 'pnpm install --frozen-lockfile'
  if (sub === 'run' || sub === 'test' || sub === 'exec') {
    return ['pnpm', sub, ...rest].join(' ')
  }
  return undefined
}

export function register(on: On) {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    if (e.tool !== 'Bash') return next(e)

    const rewritten = toPnpm(e.command)
    // 不是 pnpm 项目,或者命令不用改:原样交给下一层
    if (rewritten === undefined || !(await $.fs.exists('pnpm-lock.yaml'))) {
      return next(e)
    }

    // 改写参数,再交给后面的 mod 和 Claude Code 本身(权限检查、真正执行)
    const result = await next({ ...e, command: rewritten })
    $.ui.log(`pnpm-guard: ${e.command} → ${rewritten}`)

    if (result.deny !== undefined) return result
    // 在工具结果后面补一句,让 Claude 知道实际跑的是哪条命令
    return {
      ...result,
      context: [
        ...(result.context ?? []),
        `This project uses pnpm. The command "${e.command}" was run as "${rewritten}".`,
      ],
    }
  })
}

pnpm-guard 处理一次 Bash 调用的流程:toPnpm 判断能否改写,再检查 pnpm-lock.yaml,改写后调用 next,结果后补一句 context;不满足条件就原样 next(e)

这段代码里有几个点,正好对应前面说的 hook 和 mod 的区别:

  • 改参数只要展开再覆盖。{ ...e, command: rewritten } 只改一个字段,类型声明还会按 Bash 的参数结构检查改写后的对象,不用像 updatedInput 那样把整个输入写一遍。
  • 改写之后还能看结果。await next(...) 拿到执行结果,再往 context 里补一句话。这句话 Claude 能读到,用户看不到,相当于在同一个函数里做了 PreToolUse 加 PostToolUse 的事。
  • $.ui.log 在对话记录里留一行灰字,你能看到命令被改过,Claude 读不到这一行。
  • 保守匹配。带管道、&&、$() 的命令一律不碰,只改单条命令,宁可漏改也别改错。

补那句 context 不是多余的。不补的话,Claude 以为自己跑了 npm install,看到的却是 pnpm 的输出,下一步可能又换回 npm 重试;告诉它项目用的是 pnpm,后面它自己就会改口。

测试文件也不复杂,核心是在 mod 后面挂两个替身,一个回答 $.fs.exists,一个代替真正的工具执行,顺手记下收到的命令:

test('pnpm 项目里 npm install 被改写成 pnpm add', async ($, on) => {
  const ran: string[] = []
  on('fs.exists', ($, e) => ({ value: e.path.endsWith('pnpm-lock.yaml') }))
  on('ui.log', () => ({ value: undefined }))
  on('tool.call', ($, e) => {
    if (e.tool === 'Bash') ran.push(e.command)
    return { result: 'ok' }
  })

  const r = await $.tool.call({ tool: 'Bash', command: 'npm install -D vitest' })

  expect(ran).toEqual(['pnpm add -D vitest'])
  expect(r.context?.[0]).toContain('pnpm add -D vitest')
})

我第一次写这个测试时就踩了个坑:替身里拿 e.path === 'pnpm-lock.yaml' 判断,结果测试失败,命令没被改写。翻了测试文档才知道,传到替身里的路径已经是绝对路径,要用 endsWith 比较。另外两个用例分别验证”带管道的命令不改”和”没有 pnpm-lock.yaml 时不改”。

写完先用 claude plugin validate 做静态检查。它不运行代码,只分析源码,列出 mod 挂了哪些事件、调了 $ 上哪些方法。然后用 claude plugin test 跑测试,测试里用 on 注册的处理函数排在 mod 后面,替 Claude Code 作答,所以能直接断言 mod 交给下一层的命令是什么:

claude plugin validate 和 claude plugin test 的真实终端输出:验证通过,三个测试全部 pass

validate 输出里那行 gating hook without .catch 是提示:这个 hook 抛错时,Claude Code 会跳过它,命令按原样执行。对一个只做改写的 mod 来说,出错时放行正合适;如果你写的是拦截类的 mod,就该加 .catch 让它出错时也拒绝。另外我用仓库里 mods/types 的类型声明(文件头写着由 2.1.277 生成)跑了一遍 tsc,没有报错。

最后是安装。mod 就是插件,按插件的方式分发:在上一级目录放一个 .claude-plugin/marketplace.json,把它当作本地 marketplace:

{
  "name": "my-mods",
  "owner": { "name": "shanyue" },
  "plugins": [{ "name": "pnpm-guard", "source": "./pnpm-guard" }]
}

然后添加 marketplace 并安装:

本地 marketplace 安装 pnpm-guard 的真实终端输出:marketplace add、plugin install 成功,plugin list 显示已启用

开发阶段不用每次都安装,直接 claude --plugin-dir ./pnpm-guard 启动,改完文件会热重载。已经开着的会话里装了新 mod,执行一次 /reload-plugins 就能加载。

需要说明的是:我写这篇的机器上没有登录 Claude Code,所以没能在真实对话里看着 Claude 敲 npm install 再被改写。上面的 validate、test、安装三步都是实际跑过的,测试跑的是 Claude Code 自带的测试运行时,但”在会话里生效”这一步我没有亲眼验证。

四、什么时候用 hook,什么时候用 mod

什么时候用 hook 什么时候用 mod:只做拦截放行记录就写 settings hook,要改 Claude Code 本身的行为就写 mod,装别人的 mod 前先读代码

我的判断标准是一个问题:你只是想拦一下、放一下、记一下,还是要改 Claude Code 本身的行为?

继续用 settings hook 的场景:

  • 拦危险命令,比如 rm -rf、强推;
  • 改完文件跑格式化或 lint;
  • 任务结束发一条桌面通知;
  • 手上已经有现成的脚本,hook 能直接调用。

这些事 hook 做得很好,配置写进 settings.json 就生效,跟着项目仓库走,团队成员不用额外装东西。

该换成 mod 的场景:

  • 要改写,而且改完还要接着处理结果、或者失败了重试;
  • 要在多次事件之间保留状态,比如统计工具调用次数;
  • 要画东西:面板、提示框上方的信息条、按钮;
  • 要注册一个不走模型、立即执行的 /command;
  • 要替换或关掉内置功能,比如换掉 /diff。

官方举的几个团队场景也很有代表性:在对话旁边开一个面板显示流水线状态,构建通过或失败时实时更新;命令一碰生产配置就要求先确认;一个最先加载的 mod 记录其他所有 mod 的每一次调用,用来做审计。其中面板和审计这两件 hook 做不到:它画不了界面,也看不到别的 mod 在做什么。

还有一条一定要说:mod 跑在 Claude Code 里,权限和 Claude Code 一样大。它能读写你账号能访问的所有文件,能起进程、发网络请求,能看到你发的每一条 prompt,还能在你被询问之前就批准一次工具调用。开了沙箱也没用,沙箱隔离的是 Claude 跑的 Bash 命令,mod 起的进程在沙箱外面。

所以装第三方插件之前,先把代码拉下来读一遍。至少跑一次 claude plugin validate,看 hooks: 和 calls: 两行:一个号称只改界面的 mod,却调了 $.process.run 和 $.http,就值得多看两眼。团队和企业版里还有一个内置的 sec-default 会最先加载,挡住用户自装 mod 绕过组织权限规则这类操作,但个人用户只能靠自己把关。

五、API 还在变

mods 刚发布一周,接口变化很快。我写这篇用的是 2.1.292,npm 上的发布时间是北京时间 10 月 7 日凌晨。这一版又加了几个和 mod 相关的能力:新事件 prompt.autocomplete,可以往输入框的自动补全列表里加自己的条目;$.model.complete 支持 prompt caching;agent.spawn 能看到 workflow agent,mod 可以拒绝它们。仓库里 mods/README.md 也写着,这套 API 可能在版本之间变化,且不另行通知。

所以别把这篇里的代码当成定稿。官方给了一个更靠谱的办法:用 --plugin-dir 加载 mod 时,Claude Code 会把当前版本的类型声明写进 mod 目录下的 .claude-plugin/types/,编辑器补全和 tsc 都能直接用。文档和类型对不上时,以类型为准。

我自己的习惯是把这三步当成升级后的固定检查:先看类型声明有没有变,再跑 claude plugin validate 确认事件名和调用都还认得,最后跑 claude plugin test。哪里不兼容,跑一遍就知道了。发给别人用的 mod,也记得在 README 里写上测过的 Claude Code 版本。


分享本文:

上一篇
二零二零上半年总结