跳到正文
山月行
返回

用 mods 改写 Claude Code 的工具调用

发布于

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

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

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

Claude Code hooks 与 mods 对比总览图:settings hook 每次触发时调用 handler(命令起进程,HTTP 发请求),读 JSON 后返回决定;mod 是同进程里的函数,通过 next 把事件交给下一层,直到 Claude Code 内置逻辑

上面一行是 settings hook:事件来了,Claude Code 调用你配的 handler。命令类 hook 会起一个进程,把 JSON 从 stdin 喂给脚本,脚本从 stdout 回一个决定;HTTP 类 hook 不起进程,而是把同样的 JSON 当请求体发出去。下面一行是 mod:事件来了,Claude Code 直接调用你注册的函数,你的函数决定要不要、以及用什么参数调用 next(e),把事件交给后面的 mod 和 Claude Code 自己的逻辑。图里绿色是你写的代码,橙色是传递和执行,红色是做决定的环节,黄色是 Claude Code 内置的部分,灰色是事件来源,后面几张图也沿用这套颜色。

一、一句话区别

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

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

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

这里要纠正一个常见误解:settings hook 其实也能改工具参数。Hooks 文档里写得很清楚,PreToolUse 返回的 updatedInput 会替换工具的输入,mods 文档的对比表里也写了它能改”工具调用的参数和结果”。区别在于方式:updatedInput 会整个替换输入对象,没改的字段也得原样写回去;每次调用都要起进程或发 HTTP 请求、解析 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 链的示意图:托管设置里的 PreToolUse 最先跑,放行后依次经过先加载的 mod、后加载的 mod,再到其余 PreToolUse 和权限检查,最后执行工具,结果沿原路返回

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

还有一点容易误会: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 时一样。

默认模式叫 claude-md-or-agents-md,README 里的规则是:从根目录走到当前工作目录,这一路上只要有一份 CLAUDE.md、.claude/CLAUDE.md 或 CLAUDE.local.md,这个项目就交给引擎自己处理,不加载 AGENTS.md;一份都没有时,才把这一路上的 AGENTS.md 和 .claude/AGENTS.md 全部加载。托管的 CLAUDE.md、用户级的 ~/.claude/CLAUDE.md、.claude/rules 下的文件和额外添加目录里的 CLAUDE.md 都不算数。在多个 Agent 工具之间共用一份项目说明时,这条规则决定了 Claude Code 到底读哪份文件。

内置 agents-md mod 的默认规则:构建上下文时,沿根目录到工作目录查找 CLAUDE.md 类文件,用户级和托管文件不算,一份都没有时才加载 AGENTS.md 和 .claude/AGENTS.md

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

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

挑一个我自己天天遇到的场景:项目用 pnpm,Claude 却时不时跑一句 npm install。前面说过,settings hook 也能做这件事,用 updatedInput 把命令改成 pnpm 就行。我还是选 mod,原因有四个:只改 command 一个字段,不用整份输入写回去;next 返回后还能接着看结果、给 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, options),options 装的是插件清单里 userConfig 字段的值;这个 mod 没有配置项,所以只写了 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}".`,
      ],
    }
  })
}

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

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

pnpm-guard 处理一次 Bash 调用的流程:toPnpm 判断能否改写,不能就原样放行;再检查 pnpm-lock.yaml,没有也原样放行;都满足时改写后调用 next,结果后补一句 context,并用 $.ui.log 记一行

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

保守也意味着有不少命令它管不到,用之前心里要有数:

  • npx、npm uninstall 这类不在列表里的子命令原样放行,而 Claude 很爱用 npx;
  • 前面带环境变量或 sudo 的命令,比如 FOO=1 npm install,第一个词不是 npm,也不改;
  • npm ci 一律改成 pnpm install --frozen-lockfile,后面跟的其他参数会被丢掉;
  • 只在当前工作目录找 pnpm-lock.yaml,monorepo 里从子目录启动时可能找不到;
  • 按空白切分参数,不解析引号,参数里带空格的命令可能切错。

另外,每个 hook 自己的代码有 10 秒的时间限制(next.budget.ms),等 next 和等 $ 调用的时间不算在内,$.clock.sleep 除外。超时的 hook 会被跳过,命令按原样执行,所以别在 hook 里自己做重活,要等就等在 $ 调用里。

测试文件也不复杂,核心是在 mod 后面挂几个替身:一个回答 $.fs.exists,一个接住 $.ui.log,一个代替真正的工具执行,顺手记下收到的命令。下面是完整测试文件里的第一个用例:

test('pnpm 项目里 npm install 被改写成 pnpm add', async ($, on) => {
  const ran: string[] = []
  const logs: string[] = []
  on('fs.exists', ($, e) => ({ value: e.path.endsWith('pnpm-lock.yaml') }))
  on('ui.log', ($, e) => {
    logs.push(e.text)
    return { 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')
  expect(logs).toEqual(['pnpm-guard: npm install -D vitest → pnpm add -D vitest'])
})

完整文件一共四个用例,另外三个是”带管道的命令原样放行""没有 pnpm-lock.yaml 时不改写”和”npx 不在改写范围内”,写法和上面一样,只是断言命令没被改。我第一次写这个测试时就踩了个坑:替身里拿 e.path === 'pnpm-lock.yaml' 判断,结果测试失败,命令没被改写。翻了测试文档才知道,传到替身里的路径已经是绝对路径,要用 endsWith 比较。

写完先做静态检查,再跑测试:

claude plugin validate ./pnpm-guard
claude plugin test ./pnpm-guard

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

claude plugin validate 和 claude plugin test 的真实终端输出:验证通过,列出 tool.call 事件和 $.fs.exists、$.ui.log 两个调用,四个测试全部 pass

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

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

mods/
├── .claude-plugin/
│   └── marketplace.json
└── pnpm-guard/

marketplace.json 里只登记这一个插件:

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

然后在 mods/ 目录下添加 marketplace 并安装,pnpm-guard@my-mods 后半截就是上面的 name:

claude plugin marketplace add ./
claude plugin install pnpm-guard@my-mods --scope user
claude plugin list

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

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

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

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

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

继续用 settings hook 的场景:

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

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

什么时候用 settings hook 什么时候用 mod:先问只是拦、放、记录,还是要改 Claude Code 本身;只拦放记录就用 settings hook,比如拦命令、格式化、通知;不止于此就写 mod,用于改写、状态、UI、替换内置

该换成 mod 的场景:

  • 要改写,而且改完还要接着处理结果、或者失败了重试;
  • 要在多次事件之间保留状态,比如统计工具调用次数;
  • 要画东西:面板、提示框上方的信息条、按钮;
  • 要注册自己的 /command,由你的函数直接作答;注册时加上 immediate: true,Claude 正在干活时也能执行;
  • 要替换或关掉内置功能,比如换掉 /diff。

官方举的几个团队场景也很有代表性:在对话旁边开一个面板显示流水线状态,构建通过或失败时实时更新;命令一碰生产配置就要求先确认;一个最先加载的 mod 记录其他所有 mod 的每一次调用,用来做审计。其中面板和审计这两件 settings 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/;mod 自己没有 tsconfig.json 的话,还会在根目录补一个,编辑器补全和 tsc -p 都能直接用。文档和类型对不上时,以类型为准。

升级 Claude Code 之后检查 mod 的四步:用 --plugin-dir 加载写出新类型,tsc -p 按当前版本的声明做类型检查,plugin validate 确认事件名与 $ 调用,plugin test 确认行为没变

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


分享本文:

上一篇
二零二零上半年总结
下一篇
AGENTS.md 怎么写:让 Agent 读懂项目