# Claude Code mods 入门：改写工具调用

> mods 是跑在 Claude Code 进程里的 TypeScript 函数。讲清它和 hooks 的区别，读内置 /diff 源码，再写一个把 npm 改写成 pnpm 的 mod。

- 发布于: 2026-10-07
- 原文: https://shanyue.tech/posts/claude-code-mods/
- 标签: ai, claude-code, agent, ai-engineering

---

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 把事件交给下一层](https://shanyue.tech/images/26-10-07/cc-mods-overview.webp)

上面一行是 `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` 的签名都一样：

```ts
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，再真正执行工具](https://shanyue.tech/images/26-10-07/cc-mods-chain.webp)

多个 `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](https://shanyue.tech/images/26-10-07/cc-mods-repo-mods.webp)

目录里有四个内置 `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](https://shanyue.tech/images/26-10-07/cc-mods-agents-md.webp)

默认模式下的规则很简单：项目里没有 `CLAUDE.md` 时读 `AGENTS.md`；两个都有时，默认只读 `CLAUDE.md`。这一点在多个 `Agent` 工具之间共用一份项目说明时很关键，展开讲就跑题了，我单独写了一篇 [AGENTS.md 使用指南](https://shanyue.tech/posts/agents-md-guide/)。

读这几份源码，比读任何教程都快。一来它们是官方团队按自己的规范写的，`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 及以上：

```bash
claude --version   # 2.1.292 (Claude Code)
```

一个 `mod` 就是一个插件目录，加上一个测试文件，一共四个文件：

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

`plugin.json` 是普通的插件清单，`mod` 不需要额外字段：

```json
{
  "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` 文件可以直接加载，不用打包：

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

核心是 `register.ts`，入口导出 `register(on)`：

```ts
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)](https://shanyue.tech/images/26-10-07/cc-mods-pnpm-guard.webp)

这段代码里有几个点，正好对应前面说的 `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`，一个代替真正的工具执行，顺手记下收到的命令：

```ts
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](https://shanyue.tech/images/26-10-07/cc-mods-validate-test.webp)

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

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

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

然后添加 `marketplace` 并安装：

![本地 marketplace 安装 pnpm-guard 的真实终端输出：marketplace add、plugin install 成功，plugin list 显示已启用](https://shanyue.tech/images/26-10-07/cc-mods-install.webp)

开发阶段不用每次都安装，直接 `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 前先读代码](https://shanyue.tech/images/26-10-07/cc-mods-when.webp)

我的判断标准是一个问题：**你只是想拦一下、放一下、记一下，还是要改 `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` 版本。
