Claude Code Mods 上手:用 TypeScript 改写 AI 编程工具的行为,不登录也能跑通官方测试

10 月 1 日,Anthropic 给 Claude Code 加了一个新的扩展机制:Mods。官方博客的定义很克制——「几行 TypeScript 函数,改变 Claude Code 的行为和外观」。具体能干什么:改写发往模型的提示词、拦截或重试一次工具调用、审批权限请求、把工具输出里的密钥先脱敏再让模型看到,甚至替换像 /diff 这样的内置功能——这个内置命令现在就是用一个 mod 实现的,你可以在 /plugin 里把它关掉,换成自己的版本。Mods 随插件分发,CLI 和桌面端都认,官方博客见文末外链。

它和插件市场里那些「给 Claude 一段说明文字」的 Skill 不是一回事。Skill 教 AI 怎么做事,Mods 直接改工具本身的行为。这篇文章是我在 Mac 上的完整上手记录:从发现本机版本不够、升级、踩了官方脚手架的坑,到手写一个拦截 rm -rf 的 mod 并跑通官方校验和测试。测试这一步不需要登录账号,装个新版本就能玩——这是我对这次发布最有好感的地方。

Mods 和旧 hooks 差在哪

Claude Code 之前就有 hooks 机制:在配置里声明一条命令,事件发生时 Claude Code 把 JSON 从 stdin 喂给你的脚本,读 stdout 拿结果。它能干不少事,但每次触发起一个新进程,脚本无状态,也画不了界面。

Mods 把这件事搬进了进程内。一个 mod 是一个导出 register(on) 函数的 ES 模块,随插件加载后常驻,Claude Code 每个动作都会发事件,mod 订阅自己关心的事件。能挂的事件有八个:session.start、turn.start、turn.complete、prompt.submit、tool.call、command.run、ui.render、ui.press。这八个名字是我在 2.1.287 的二进制里逐一验证过的,和社区整理的清单一致。

对每个事件,mod 有三种玩法:透传(调 next(e) 什么都不改)、改写(改完再传给 next)、应答(不调 next,直接返回结果,比如 { deny: "..." } 拦下这次调用)。多个 mod 挂同一事件时按加载顺序执行——第一个加载的最先看到事件、最后看到结果,中间件那一套。这个顺序设计也是企业版安全机制的原理:Team/Enterprise 内置的 sec-default mod 最先加载,用户后来装的 mod 想覆盖权限规则时过不了它这一关。

Mods 也有分寸感:它不做沙箱,权限和 Claude Code 本身一样大,所以官方建议只装可信来源的 mod——把 mod 当普通代码对待就对了。另外它能画界面:可以给终端或桌面端加面板、按钮、输入框,别的 mod 还能响应你按下的按钮。

实测第一关:版本门槛

我本机的 Claude Code 是 2.1.226,跑 claude plugin --help 时根本没有 test 子命令。Mods 要求 2.1.287 起。升级前后对比是这个功能最直观的证据:

Bash
# 检查本机版本,我的旧环境显示:2.1.226 (Claude Code)
claude --version

# 升级到最新版,升完显示:2.1.287 (Claude Code)
npm install -g @anthropic-ai/claude-code@latest
claude --version

# 2.1.287 的 plugin 子命令里多了这个(2.1.226 没有):
#   test [dir]    Run a mod's tests
claude plugin --help

有个细节值得单独说:2.1.287 的 npm 包已经不是纯 JavaScript 了,装下来是一个 227MB 的原生 arm64 二进制(macOS 上也叫 bin/claude.exe,没错,.exe 后缀)。如果你的升级脚本假设包里是 cli.js,会直接找不到文件——我是踩了这个坑才看到的。

实测第二关:官方脚手架还没跟上

我本以为 claude plugin init bash-guard --with hooks 会生成一个 mod 模板,结果生成的还是旧式 command hook:hooks.json 里是 "type": "command" 加一条 bun 命令,配上一个从 stdin 读 JSON 的脚本。10 月 1 日发布的新机制,一天前更新的脚手架还停在旧世界——想写 mod,得手动改。

Mods 的插件格式其实简单:plugin.json 描述插件元信息,hooks/hooks.json 用一个新的 modules 键指向你的模块。我手写的三件套长这样(完整文件在我的仓库,这里是可以直接抄的核心):

Json
{
  "modules": ["./guard.ts"]
}
Typescript
// bash-guard/hooks/guard.ts — 拦截危险的 rm -rf
export function register(on) {
  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
    const cmd = e?.input?.command ?? "";

    const dangerous =
      /rm\s+(?:-{1,2}[a-zA-Z-]+\s+)*-{1,2}[a-zA-Z]*r[a-zA-Z]*f|rm\s+(?:-{1,2}[a-zA-Z-]+\s+)*-{1,2}[a-zA-Z]*f[a-zA-Z]*r/.test(
        cmd,
      ) && /\brm\b/.test(cmd);

    if (dangerous) {
      // 不调 next(e),直接给结果 = 拦截这次工具调用
      return { deny: `bash-guard: 已拦截危险的删除命令:${cmd.slice(0, 120)}` };
    }

    // 透传给 Claude Code 和下游其他 mod
    return next(e);
  });
}

on(event, filter, handler) 第二个参数是过滤器,上面只对 Bash 工具的调用生效。handler 的三个参数:$ 是 mod 的能力入口(界面、状态、文件、HTTP、注册命令等能力都从这里走),e 是事件本体,next 负责把事件交给 Claude Code 和下游 mod。

关于格式规则,官方文档之外有个野路子特别好用:claude plugin validate 的报错文案本身就是 schema 说明。我用 strings 翻了翻 2.1.287 的二进制,挖出三条文档里没写全的规则:每个插件的 hooks.json 只允许一个 module(原话是「a second entry is refused」);模块文件名必须是「像代码的文件名」否则不加载,且无论什么后缀都按 ES module 处理;validate 会直接读你的源码,报告你挂了哪些事件、用了 $ 的哪些能力——源码即配置。

实测第三关:validate 和 test,全程不需要 API Key

写完跑官方校验,输出比我预期的信息量大:

Bash
$ claude plugin validate ./bash-guard

Validating plugin manifest: .../bash-guard/.claude-plugin/plugin.json
Validating hooks: .../bash-guard/hooks/hooks.json

  ❯ ./guard.ts hooks: tool.call{tool=Bash}
  ❯ ./guard.ts calls: nothing on $

✔ Validation passed

它真的读了我的源码:知道我挂了 tool.call 且过滤 Bash,还提醒我 $ 的能力我一样都没用上。加 --json 可以拿到结构化报告,挂进 CI 很方便。

测试是这次更新的另一个惊喜。新建 guard.test.ts,测试套件从 claude-code/testing 导入,跑 claude plugin test ./bash-guard,每个测试文件在二进制的子进程里、在与 mod 运行时一致的环境里执行——不需要登录,不需要 API Key:

Typescript
import { test, expect } from "claude-code/testing";
import { register } from "./guard.ts";

// 极简事件总线 mock:捕获 register 订阅的 handler,手动喂事件
function makeBus() {
  const handlers = [];
  const on = (event, filter, handler) => {
    if (event === "tool.call") handlers.push({ filter, handler });
  };
  const feed = async (command) => {
    const nextCalled = [];
    const $ = {};
    const next = (e) => {
      nextCalled.push(e);
      return { ok: true };
    };
    let result = null;
    for (const h of handlers) {
      result = await h.handler($, { input: { command } }, next);
      if (result && result.deny) break;
    }
    return { result, nextCalled };
  };
  return { on, feed };
}

test("rm -rf is denied without calling next", async () => {
  const bus = makeBus();
  register(bus.on);
  const { result, nextCalled } = await bus.feed("rm -rf /tmp/legacy-build");
  expect(result?.deny).toContain("bash-guard");
  expect(nextCalled.length).toBe(0);
});

test("normal command passes through next", async () => {
  const bus = makeBus();
  register(bus.on);
  const { result, nextCalled } = await bus.feed("ls -la /tmp");
  expect(result?.ok).toBe(true);
  expect(nextCalled.length).toBe(1);
});

我机器上的真实输出:

Bash
$ claude plugin test ./bash-guard

hooks/guard.test.ts:
(pass) rm -rf is denied without calling next [0.89ms]
(pass) rm -fr variant is denied too [0.15ms]
(pass) normal command passes through next [0.16ms]

 3 pass
 0 fail
Ran 3 tests across 1 file. [0.12s]

实测边界:这些我没测到

本机没有 Claude 登录态,claude -p 直接返回 Not logged in,所以 mod 在真实会话里的表现——deny 真的拦下一条 rm -rf、保存文件后的热重载、终端里画出来的面板、桌面端的按钮——我没有实测,需人工确认。不过 validate 和 test 这两层不依赖登录,官方把「插件的单元测试」做成了本地可跑的东西,写 mod 的核心循环(写代码 → 校验 → 测试)在装好新版本之后就是完整的。

一个判断和几个坑

我的判断:Mods 的真正变化不是「能定制」——hooks 时代就能定制——而是定制单元的升级。从「一次性的 shell 调用」升级成「有状态、能画 UI、能进测试的模块」,plugin test 更是 Claude Code 第一次给扩展系统配官方测试运行器。把这三件事放在一起看很有意思:OpenAI 把 Codex Harness 开源、Google 把 Antigravity 做成托管 Agent,Anthropic 选择把本地 CLI 的可定制性往工程化深处做。三家在 Agent 的「可编程性」上交了三份不同的答卷。

踩坑清单,都是我亲手踩的:

  1. 版本低于 2.1.287 一切免谈,plugin test 子命令都不存在,先升级再动手。
  2. plugin init 生成的是旧格式,照着脚手架写不出 mod,hooks.json 要自己改成 modules 键。
  3. 一个插件只能挂一个 hooks module。想把多个 mod 打包成一个插件?写一个 module 统一 register 就行。
  4. 开发时热重载每次都是全新加载,模块级变量会清零,要跨事件保留的状态放 $.state(官方文档口径,我未实测热重载本身)。

两个不建议:只是想注入一段固定的项目约定或提示词,用 CLAUDE.md 就够了,为这个写 mod 是杀鸡用牛刀;需要跨机器共享的重逻辑,先想想团队里有多少人真的会维护 TypeScript——Mods 适合「要改行为、要画界面、要能被测试」的场景,按需上。

上手路径

  1. claude --version 确认不低于 2.1.287,低了先升级;
  2. 抄本文的 bash-guard 三件套(plugin.json、hooks.json、guard.ts)到本地;
  3. claude plugin validate + claude plugin test 跑通,这一步不需要账号;
  4. 登录后 claude --plugin-dir ./bash-guard 进会话,让 Claude 执行一条 rm -rf 看看拦截效果——这步留给有登录态的你。

想继续折腾 Claude Code 的协作面,可以看之前写过的 /fork 分头行动和跨会话消息;平台侧的全家桶更新记录在 Anthropic CMA 六大更新那篇。等我在有登录态的机器上把会话内拦截和热重载补测完,会在这篇里更新真实表现。

参考链接:官方公告 Customize Claude Code with mods | Mods 设计讨论(GitHub Issue #91870) | 社区深度指南(PJFP) | Claude Code 插件官方文档

推荐阅读

  • OpenAI DevDay 2026 全面解读:Dots 智能体、GPT-6.1 Sol、Codex Cloud,开发者现在能上手什么

    DeepSeek Harness 推出桌面端 v0.2.0-rc.2,从开源框架变成开箱即用的 macOS/Windows 应用。我在 Mac 上实测了完整安装流程:353MB DMG、1GB 体积、…

  • 开源决策模型 Deem 上手:0.8B 塞进 1.4GB 内存,我在 Mac 上复现了「30 进 31 出」

    LibertAI 开源的决策模型 Deem 把「是/否、分类、评分」判断拉回本机。我在 Mac 上实测 0.8B 版:1.4GB 权重、1.37GB 内存,复现了「30 进 31 出」的退货窗口边界,…

  • Perceptron Mk1.5 上手指南:给机器人用的多模态感知模型,$0.15/M 在 OpenRouter 跑通视频追踪与空间标注

    Perceptron Mk1.5 是前 Meta FAIR 团队推出的多模态感知模型,面向无人机、机器人与智能眼镜:输入图文音视频,可输出点、框、多边形和带时间戳的轨迹。本文基于 OpenRouter…

  • Google Gemini Antigravity 上手指南:一行代码启动带 Linux Sandbox 的托管 Agent,代码审计与数据处理实战

    Google Antigravity 成为 Gemini 托管 Agent 默认 harness:一行代码启动 Linux sandbox,支持文件编辑、后台运行与安全凭证管理。附 Python 实战…