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 上实际跑过,这是我写这篇时的最新版本。

上面一行是 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 hook | Mod | |
|---|---|---|
| 在哪跑 | 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: '…' },工具根本不会执行。

多个 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/ 目录下,我把仓库拉下来看了一眼:

目录里有四个内置 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 时一样。

默认模式下的规则很简单:项目里没有 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}".`,
],
}
})
}

这段代码里有几个点,正好对应前面说的 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 交给下一层的命令是什么:

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 并安装:

开发阶段不用每次都安装,直接 claude --plugin-dir ./pnpm-guard 启动,改完文件会热重载。已经开着的会话里装了新 mod,执行一次 /reload-plugins 就能加载。
需要说明的是:我写这篇的机器上没有登录 Claude Code,所以没能在真实对话里看着 Claude 敲 npm install 再被改写。上面的 validate、test、安装三步都是实际跑过的,测试跑的是 Claude Code 自带的测试运行时,但”在会话里生效”这一步我没有亲眼验证。
四、什么时候用 hook,什么时候用 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 版本。