教程Claude Code系列第 11 / 11 篇

Claude Code mod 的测试和排错:不开会话就能跑的测试,以及 mod 没反应时从哪查

mod 出了错不会让会话崩掉,它只是被悄悄跳过,看上去像什么都没做。本站用官方教程的示例 mod 实测了两件事:用 claude plugin test 写不开会话、不联网的测试,三个测试 0.67 秒跑完;再把 mod 故意弄坏五种方式,记下静态检查查得出哪几种、哪几种要到运行时才报、报错出现在哪。

发布 · 约 13 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-13。

本页目录(7 节)
  1. 示例 mod
  2. 一、给 mod 写测试
  3. 二、mod 没反应,从哪查
  4. 排查的顺序
  5. 通过中转站用,有什么不一样
  6. 这一趟花了多少
  7. 这次实测没有覆盖到的

这篇教程解决一个问题:mod 是跑在 Claude Code 进程里的代码。它出错的时候不会报一个醒目的错,Claude Code 只是跳过它,会话照常继续。所以坏掉的 mod 和什么都不做的 mod,看起来一模一样。

对付这种情况有两手:改之前有测试兜着,出了问题知道去哪看。这篇两样都实测了一遍。

mod 是什么、怎么写,见Mods 是什么和从零写第一个 mod。这篇用的示例是官方教程里的 first-mod:它数 Claude 调用了多少次工具,把数字显示在转圈的文字旁边,并加一个 /tally 命令把数字打印出来。

实测环境
日期 2026-10-13
版本 Claude Code 2.1.296
系统 Ubuntu 24.04
连接方式 通过酷站AI 中转站,不是直连官方
加载方式 全程用 --plugin-dir 指向工作目录,没有安装

示例 mod

三个文件。hooks/hooks.json 里的 modules 指向代码文件,有它这个插件才算一个 mod:

{
  "description": "The first-mod hooks module",
  "modules": ["./register.js"]
}

hooks/register.js 是代码本身:

// 计数,下面几个 hook 共用
let calls = 0

export function register(on) {
  on('session.start', async ($, e, next) => {
    await $.command.register({ name: 'tally', description: 'Show how many tool calls Claude has made' })
    return next(e)
  })
  on('tool.call', async ($, e, next) => {
    calls += 1
    $.ui.invalidate('ui.render')
    return next(e)
  })
  on('command.run', { command: 'tally' }, async () => {
    return { text: 'Claude has made ' + calls + ' tool calls since this mod loaded' }
  })
  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
    return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
  })
}

再加一个只有名字、版本和描述的 .claude-plugin/plugin.json。

先确认它是好的。交互会话里让 Claude 读两个文件,转圈那一行跟着变:

✻ Herding · tool calls: 0…
✶ Topsy-turvying · tool calls: 1… (5s · ↓ 9 tokens)
* Topsy-turvying · tool calls: 3… (8s · ↓ 219 tokens)

做完后输入 /tally:

  ⎿  first-mod: Claude has made 3 tool calls since this mod loaded

一、给 mod 写测试

测试是怎么回事

claude plugin test 是终端里的命令。它加载你的 mod,像 Claude Code 那样把事件送进它的 hook,再检查 hook 做了什么。不需要会话,不需要登录,不联网,不调模型。

因为没有真的 Claude Code 在后面,凡是 mod 指望 Claude Code 回答的地方,都由测试来给答案。这叫打桩。

测试文件的名字以 .test.ts 结尾,放在插件目录里的任何位置。

第一个测试

first-mod/tests/first-mod.test.ts:

import { expect, test } from 'claude-code/testing'

test('/tally reports the tool calls the mod has seen', async ($, on) => {
  // 替 Claude Code 回答每一次工具调用,所以没有工具真的运行
  on('tool.call', () => ({ result: 'ok' }))
  // 发两次工具调用,mod 的 tool.call hook 会数到它们
  await $.tool.call({ tool: 'Bash', command: 'ls' })
  await $.tool.call({ tool: 'Read', file_path: 'README.md' })
  // 运行 /tally,检查它的 hook 返回的文字
  const answer = await $.command.run({ command: 'tally', args: '' })
  expect(answer.text).toBe('Claude has made 2 tool calls since this mod loaded')
})

测试函数拿到的两个参数容易和 mod 里的弄混:

  • $ 在这里扮演的是 Claude Code。$.tool.call(...) 是“发出一个工具调用事件”,它会经过你的 mod 的 hook。它不是 mod 里那个用来调接口的 $。
  • on 用来注册桩。上面第一行的意思是:有工具调用传到底的时候,回答 { result: 'ok' }。没有 ls 真的被执行,也没有文件被读。

再加两个

本站又写了两个,各验证一件事。

test('每个测试开始时计数都是 0', async ($, on) => {
  const answer = await $.command.run({ command: 'tally', args: '' })
  expect(answer.text).toBe('Claude has made 0 tool calls since this mod loaded')
})

test('session.start 会注册 /tally 命令', async ($, on) => {
  const registered: string[] = []
  on('session.start', () => ({ cwd: '/work' }))
  on('command.register', ($, e) => { registered.push(e.name); return { value: undefined } })
  await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
  expect(registered).toEqual(['tally'])
})

第二个测试说明测试之间互不影响:上一个测试把计数加到了 2,这个测试里它是 0。每个测试开始时模块都是刚加载的状态。

第三个测试里有两种桩,返回的东西不一样:

  • 给事件打桩(session.start、tool.call),返回这个事件自己的结果。
  • 给 mod 调用的接口打桩(command.register 对应 mod 里的 $.command.register),返回一个带 value 字段的对象,value 就是那次调用在 mod 里得到的值。

跑起来

在插件目录里:

claude plugin test
tests/first-mod.test.ts:
(pass) /tally reports the tool calls the mod has seen [105.68ms]
(pass) 每个测试开始时计数都是 0 [47.19ms]
(pass) session.start 会注册 /tally 命令 [58.33ms]

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

三个测试 0.67 秒。

故意写一个期望值不对的:

tests/fail.test.ts:
(fail) (故意写错的)期望值 [166.53ms]
  AssertionError: expect(received).toBe()

  Expected: "Claude has made 2 tool calls since this mod loaded"
  Received: "Claude has made 1 tool calls since this mod loaded"

 3 pass
 1 fail

有测试失败时命令的退出码是 1,通过时是 0,可以直接放进 CI。

两条容易踩的规则

一、桩要在第一次调用 $ 之前全部注册好。 顺序反了:

await $.command.run({ command: 'tally', args: '' })
on('tool.call', () => ({ result: 'ok' }))        // 太晚了
(fail) 规则一:桩要在第一次调用 $ 之前注册
  TypeError: on("tool.call") after the test first called $: the hooks beneath the plugins are
  registered before that, as a module registers its hooks in register()

二、少打了一个桩,测试不一定会失败。 这一条更隐蔽。本站写了一个测试:触发 session.start,但不给 command.register 打桩。mod 的 hook 里调了 $.command.register,没有东西回答它。

结果这个测试通过了。

文档解释了原因:那次调用因为没有桩而被拒绝,测试工具于是跳过了这个 hook,hook 里这次调用之后的代码都没有执行,但测试不会在这一步失败。只有后面的检查恰好依赖那些没执行的代码时,才会暴露出来。

所以一个通过的测试,不代表 hook 完整地跑过。检查要写在 hook 真正产生的结果上。上面第三个测试检查的是“command.register 确实被调用了、名字是 tally”,就是这个用意。

文档里还有的

本站没有实测的部分:

  • 现成的模拟工具:mock.clock(on) 给出一个可以手动拨快的时钟,测定时器不用真的等;mock.store(on, {...}) 模拟存储;mock.env(on, {...}) 模拟环境变量。
  • 测界面绘制:$.ui.mount(...) 把某一处界面通过你的 ui.render hook 画出来,返回一个句柄,可以按按钮、往输入框里打字、找画出来的元素。文档提醒它检查的是你的 hook 返回的结构对不对,不检查应用实际怎么显示,新布局还是要在真的会话里看一眼。
  • 测模型调用:给 model.complete 打桩,返回一段固定的回答,测 mod 拿到回答之后的逻辑。

二、mod 没反应,从哪查

先分清三种“看报错的地方”

mod 的模块没加载上、某个 hook 被跳过时,Claude Code 会写一行带着 mod 名字的说明。这一行在哪,取决于你怎么运行的:

怎么运行的 说明出现在哪
交互会话,用 --plugin-dir 加载 对话记录里的一行灰字
交互会话,mod 是从市场安装的 只在调试日志里,要用 claude --debug 启动
claude -p 加 --plugin-dir 标准错误输出

开发的时候用 --plugin-dir,能直接在界面上看到。装好的 mod 出问题,第一步是用 --debug 重开一次。

第零步:这台机器上 mod 能不能加载

在一个没有 mod 的目录里运行 claude plugin test:

claude plugin test: /home/ccdemo/demo/h8b-mod/empty: no hooks module to load; there is no
hooks/hooks.json naming one in "modules"

看到 no hooks module to load,说明环境没问题,只是这个目录里没有 mod 可测。如果看到的是 hooks modules are turned off,那是设置或者组织策略把 mod 关了,后面的都不用查了。

第一步:静态检查

claude plugin validate ./first-mod

它不运行你的代码,只读文件,告诉你 Claude Code 从里面读出了什么。正常的输出:

  ❯ ./register.js hooks: session.start, tool.call, command.run{command=tally}, ui.render{component=Spinner}
  ❯ ./register.js answers its own command: command.run{command=tally}
  ❯ ./register.js gating hook without .catch: tool.call
  ❯ ./register.js calls: $.command.register, $.ui.invalidate

✔ Validation passed

hooks: 那一行最有用:你打算处理的事件都在不在上面。少了哪个,那个 hook 就不会被调用。calls: 列的是它用到的接口。

实测:五种弄坏的方式

本站把示例 mod 一次改坏一处,每次都跑静态检查,再用 claude -p "/tally" --plugin-dir ./first-mod 实际运行。

改坏的方式 静态检查 实际运行时
事件名拼错:tool.call 写成 tool.calls 不通过,指出文件和行号 标准错误里一行说明,/tally 没有注册上
hooks.json 里删掉 modules 不通过 没有任何说明,/tally 没有注册上
事件名用变量传:on(EV, …) 不通过 (没有运行)
模块顶层就抛异常 通过 标准错误里一行说明
用了 require 通过 标准错误里一行说明

前三种静态检查抓得住,后两种抓不住。

静态检查抓得住的,报错都带着位置和原因:

✘ Found 1 error:
  ❯ modules../register.js: first-mod: …/hooks/register.js:9: "tool.calls" is not an event; …
  ❯ root: hooks.json must have `hooks` (the hook matchers) or `modules` (hooks modules), or both
  ❯ … register.js:9: the event name passed to on() is not a string literal; …

第三条是一个写法上的限制:事件名必须是写死的字符串。用变量、用循环批量注册都不行。原因是静态检查要靠读源码找出所有的 hook,Claude Code 加载 mod 时做的是同样的分析。文档列的同类限制还有:$ 上的调用要完整地写出来;只能用 import、不能动态导入;只能导入插件目录里的文件。

静态检查抓不住的,是要真的执行代码才会出的错。这两种它都报了“通过”,运行时才有:

first-mod: hooks module did not load: SyntaxError: JSON Parse error: Expected '}'
first-mod: hooks module did not load: ReferenceError: require is not defined

所以静态检查通过不等于能加载。还要实际跑一次,或者跑一遍测试:测试会真的加载模块,下面有实测。

一个容易被骗的地方

上面“实际运行时”那一列,命令的退出码全部是 0。

mod 没加载上,/tally 这个命令就不存在。这时 claude -p "/tally" 不会报“未知命令”,而是把这几个字当成一句普通的话发给了模型。模型很认真地回答了,比如其中一次:

/tally 这个命令在当前会话里没有安装,所以没有运行。它来自这个目录里的 first-mod……

它甚至去读了 mod 的源码,试着分析为什么没加载。

问题在于:标准输出里有内容,退出码是 0,如果脚本只看这两样,会以为一切正常。真正的线索在标准错误里的那一行。 另外,这一趟是花了模型的钱的。

脚本里要检查 mod 是否正常,别看 -p 的回答,用 claude plugin test。本站验证了:模块顶层抛异常时,每个测试都以同一句话失败,命令的退出码是 1。

(fail) 每个测试开始时计数都是 0
  HooksError: first-mod: hooks module did not load: SyntaxError: JSON Parse error: Expected '}'
 0 pass
 3 fail

热重载,以及它带来的一个现象

用 --plugin-dir 加载时,改了代码一保存就生效。把转圈旁的文字从 tool calls 改成 tools used,保存,对话记录里出现:

● first-mod: reloaded (4 hooks: session.start, tool.call, command.run, ui.render)

紧接着再输入 /tally:

  ⎿  first-mod: Claude has made 0 tool calls since this mod loaded

刚才数到的 3 没了。 计数存在模块顶层的变量里,重载就是重新执行一遍模块,变量回到初始值。

这不算 bug,但写 mod 的时候要心里有数:重载之后还要留着的值,文档说要存进 $.state 或 $.store。文档还提到 /clear、/resume、/branch 会把 $.state 重置成默认值。这两种存法本站没有实测。

存坏了会怎样

往文件里存进一个语法错误:

● first-mod: reload failed, the previous version stays loaded:
  …/first-mod/hooks/register.js does not parse: Unexpected token (23:0)

上一个能用的版本继续运行,并且告诉你哪一行解析不了。改代码改到一半存了一下,不会把正在用的会话弄坏。

改了没生效

文档里专门有一条:你改的如果是一个已经安装的插件的源文件,不会生效。Claude Code 运行的是安装时缓存的那一份。开发时要用 --plugin-dir 指向工作目录。

调试日志里有什么

claude --debug-file ./mod-debug.log --plugin-dir ./first-mod

另开一个终端盯着:tail -f ./mod-debug.log | grep first-mod。本站这次会话的日志里,和 mod 有关的几行:

hooks modules not loaded until workspace trust is accepted: first-mod, …
hooks module first-mod@inline loaded (worker, environment 1, tier user); events: session.start,tool.call,command.run,ui.render
hooks module first-mod@inline session.start settled in 60.6ms (worker hop, next() included)
hooks module first-mod@inline ui.render settled in 162.7ms (worker hop, next() included)
hooks module first-mod@inline tool.call settled in 130.6ms (worker hop, next() included)

能读出三件事:

  • 没信任的目录里 mod 不加载。 第一行是信任对话框还开着的时候记的。这和 hooks、状态栏是同一条规则。新开一个目录发现 mod 没反应,先看是不是还没点信任。
  • 加载成功有一行明确的记录,列出它处理的事件。用 --plugin-dir 加载的 mod,名字后面带 @inline。
  • 每个 hook 每次运行花了多久都有记录。 ui.render 这种每次重绘都要跑的 hook,慢了界面会跟着卡,这里看得出来。

想往日志里写自己的内容,文档的办法是 $.ui.log('内容', { to: 'debug' })。

文档列的其他情况

本站没有复现,按现象列在这里:

现象或说明里的话 原因
only managed plugins and built-in plugins run 设置里有 disableAllHooks,或者组织只允许它自己的 hook
installed plugins that are not managed load no hooks module in this mode (--bare) 启动时带了 --bare
another plugin of that name loads first 有另一个同名的插件先加载了
所有安装的插件都不加载 启动时带了 --safe-mode
hook skipped 某个 hook 运行时出错,这一次被跳过
it crashed the hooks worker hook 把运行它的进程弄崩了
画的界面不出现 ui.render 返回的结构不合法,日志里有一行说明

排查的顺序

  1. 目录信任了吗?
  2. 在空目录里跑 claude plugin test,确认环境允许 mod 加载。
  3. claude plugin validate,看 hooks: 那一行齐不齐。
  4. 用 --plugin-dir 开一个交互会话,看对话记录里的灰字;或者 claude -p 看标准错误。
  5. 还看不出来,加 --debug-file,在日志里搜 mod 的名字。
  6. 行为不对而不是不加载:写一个测试把它固定下来。

通过中转站用,有什么不一样

没有。mod 的加载、检查、测试全部在本机,测试连模型都不调。

这一趟花了多少

约 1 美元。测试和静态检查不花钱。花钱的是两处:交互会话里那一次问答,以及 mod 被弄坏时落到模型手里的那几次 /tally,后者是没有预料到的开销。这是 Claude Code 按官方价格估算的数字,不是中转站实际扣的金额。

这次实测没有覆盖到的

  • 只测了一个很小的 mod。 没有用到存储、定时器、模型调用、自己画的面板,对应的测试写法只整理了文档。
  • 界面绘制的测试没有写。
  • 只用 --plugin-dir 加载过。 从市场安装的 mod 出问题时“只在调试日志里有说明”,是文档的说法。
  • 组织策略和内置防护造成的拒绝没有复现。
  • $.state 和 $.store 没有试,重载后保留数值的做法只是引用了文档。
  • 文档说 hooks.json 没有 modules 时静态检查会通过、只是不列 hooks 那一行;本站这个版本上是直接不通过。 可能是文件里连 hooks 也没有的缘故,没有再细分。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章