Mod を作る・試す
Mod(モッド)を Claude に書かせる方法と、自分で書く手順、型定義・validate・自動テスト(claude plugin test)、動かないときの原因の探し方をまとめます。
Mod(モッド、mod)は、エントリファイル(フックモジュール)を持つ Claude Code のプラグインです。フックモジュールは JavaScript か TypeScript のファイルで、イベントが起きると Claude Code がその中の関数を呼びます。Node.js もバンドラーもビルドも要りません(.js と .ts をそのまま読み込みます)。何ができるか、設定のフック・スキル・MCP サーバーとの比べ方は Mod(モッド)を使う(Mod が合うか迷うなら先に読みます)、画面に描く方法は Mod で画面に描く、ファイルの形や API の一覧は Mod のリファレンスにあります。
補足
Mod には Claude Code v2.1.287 以降が必要です。claude --version で確かめます。自分の環境で Mod が読み込まれるかは、このページの「Mod を読み込める環境か確かめる」で調べられます。
作り方は2通りです。
- Claude に書かせる:Claude Code のセッションで欲しいものを説明する
- 自分で書く:チュートリアルで Mod のコードの動き方を学ぶ
Claude に Mod を書かせる#
対話セッションで欲しい Mod を説明すると、Claude が書きます。Claude は組み込みのスキル plugin-authoring に従います。このスキルは、書き込み先・使っている版にあるイベントとメソッド・Mod の読み込まれ方を Claude に教えます。Mod を頼むと Claude が読み込むほか、/plugin-authoring で自分で読み込むこともできます。
Mod が動くのは、あなたが承認したあとです(承認できないセッションは後述)。
- Mod を説明する:たとえば
make a mod that shows the current git branch above the promptのように頼みます。Claude は、そのセッション用の Mod フォルダ(~/.claude/dev-mods/の下にセッション ID のフォルダ)の中に、Mod ごとのディレクトリを作って書きます。例:~/.claude/dev-mods/3f2a9c1e-5b7d-4e8a-9c21-6d0f4b8a7e13/git-branch/ - Mod を承認する:Claude が最初のファイルを保存すると、このセッションでホットリロードを有効にするかを聞かれます。
- 「Enable for this session」:セッションの Mod フォルダにある Mod が、ターンの終わりに読み込まれ、変更のあったターンの終わりごとに読み直される。答えはセッションを再開しても続く
- 「Not now」:今は何も読み込まれない。ファイルは残り、そのセッションを次に始めたときに読み込まれる。読み込ませたくないなら、そのディレクトリを削除する
- 読み込まれたか確かめる:
/pluginを開き、Tab で「Installed」タブへ進みます。Mod が並び、ここでオフにもできます。 - 試す:頼んだ機能を使います。思ったとおりでなければ Claude に直してほしい点を伝えます。ファイルが変わったターンの終わりに読み直されるので、Claude が終えたらすぐ試せます。
補足
default と acceptEdits の権限モードでは、~/.claude が保護されたパスなので、Claude が Mod のファイルを作るたびに確認が出ます。1つずつ承認します。
ほかのセッションで使う#
Claude が書いた Mod は、それを作ったセッションでだけ読み込まれます。そのセッションの Mod フォルダは、cleanupPeriodDays より古くなると削除されます。残すには、Mod のディレクトリを自分の場所(例:~/mods/git-branch)へコピーし、読み込み方を選びます。
- 自分が始めるセッション:
claude --plugin-dir ~/mods/git-branch - ほかの人向け:マーケットプレイスに入れて、インストールしてもらう(「Mod を配る」を参照)
Claude が書いた Mod が読み込まれないセッション#
Claude が書いた Mod は、承認したうえで、Mod の実行が許されている信頼済みのワークスペースでだけ読み込まれます。次のセッションでは読み込まれません。
- 承認する人がいない:確認を出せないセッション(
claude -pの実行やdontAskモード) - ワークスペースが信頼されていない:そのディレクトリの信頼の確認に答えていない
- Mod が無効:
--safe-modeか--bareで起動した、disableAllHooksを設定した、または組織の管理設定が止めている
自分で Mod を書く#
ここでは first-mod を作ります。Claude のツール呼び出しを数えて作業中のスピナーの横に出し、回数を表示する /tally コマンドを足す Mod です。書き終えたら、Claude Code が書き出す型定義と claude plugin validate で、使っている版のイベントとメソッド、Claude Code がコードから読み取る内容を確かめます。
ファイルは3つです。
first-mod/
├── .claude-plugin/
│ └── plugin.json
└── hooks/
├── hooks.json
└── register.js
plugin.json:プラグインのマニフェストhooks.json:コードのファイルを指すregister.js:自分のコード(フックモジュール)
1. ディレクトリを作る#
mkdir -p first-mod/.claude-plugin first-mod/hooks
PowerShell の場合:
New-Item -ItemType Directory -Force first-mod\.claude-plugin, first-mod\hooks
2. マニフェストを書く#
Mod もプラグインなのでマニフェストが要りますが、Mod 用の特別なフィールドはありません。first-mod/.claude-plugin/plugin.json に保存します。
{
"name": "first-mod",
"version": "0.1.0",
"description": "Counts Claude's tool calls, shows the count beside the spinner, and adds a /tally command",
"author": { "name": "Your Name" }
}
3. コードの場所を教える#
プラグインを Mod にするのは、hooks/hooks.json の modules キーです。値には hooks.json からの相対パスでコードのファイルを書きます。first-mod/hooks/hooks.json に保存します。
{
"description": "The first-mod hooks module",
"modules": ["./register.js"]
}
4. コードを書く#
Mod が読み込まれると、Claude Code はこのファイルが export する register を呼び、on 関数を渡します。on を1回呼ぶごとに、指したイベントのハンドラー(フック)が1つ登録されます。first-mod/hooks/register.js に保存します。
// 下のフックが共有する回数
let calls = 0
// Mod が読み込まれたときに1回呼ばれる
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)
})
// Claude がツールを使う直前
on('tool.call', async ($, e, next) => {
calls += 1
// 新しい回数が出るよう描き直しを頼む
$.ui.invalidate('ui.render')
return next(e)
})
// /tally を打ったときだけ(第2引数のマッチャーで絞る)
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 + '…' } })
})
}
| フック | 動くとき・やること |
|---|---|
session.start |
セッションの開始時(最初のプロンプトの前)と、Mod を読み直すたび。/tally コマンドを登録する |
tool.call |
Claude がツールを使う直前。calls に1足し、画面の描き直しを頼む |
command.run |
/tally を打ったとき。表示するテキストを返す |
ui.render |
スピナーを描くたび。スピナーの言葉の後ろに回数を足す |
5. 読み込んで試す#
--plugin-dir を付けると、インストールせずに1セッションだけディレクトリを読み込めます。
claude --plugin-dir ./first-mod
ツールを何回か使う頼みごと(例:list the files here and read the README)をすると、スピナーが Thinking · tool calls: 2… のようになります。終わってから /tally を実行すると、first-mod: Claude has made 2 tool calls since this mod loaded のように出ます(先頭のプラグイン名は Claude Code が付けます)。
コマンドだけなら非対話モードでも確かめられます。
claude -p "/tally" --plugin-dir ./first-mod
first-mod: Claude has made 0 tool calls since this mod loaded
/tally がコマンドの一覧に無ければ、モジュールが読み込まれていません。下の「Mod が何もしない原因を探す」を見ます。
6. セッション中にコードを変える#
セッションを開いたまま、ui.render フックの ' · tool calls: ' を ' · tools used: ' に変えて保存します。
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
return next({ ...e, props: { ...e.props, suffix: ' · tools used: ' + calls + '…' } })
})
トランスクリプトに first-mod を読み直したこととフックの一覧が出て、次のスピナーから Thinking · tools used: 1… になります。
この例の仕組み#
どのフックも、同じ3つの引数を受け取ります。
| 引数 | 中身 |
|---|---|
$(mods API) |
Mod が外へ働きかけるメソッド。$.ui や $.command のようなネームスペースに分かれている |
e(イベント) |
イベントの入力をそのままのデータにしたもの。ツール呼び出しなら、ツール名と引数 |
next |
イベントをほかの Mod、続いて Claude Code 自身の動きへ渡し、結果を返す関数 |
first-mod のフックは、イベントの扱い方の3つの型をそれぞれ使っています。
- 見るだけ:
session.start(コマンドを登録)とtool.call(数えて描き直しを頼む)。どちらもnext(e)を返すので、セッションの開始もツールの実行もいつもどおり進む - 答える:
command.runは自分で結果を返し、nextを呼ばない。第2引数{ command: 'tally' }はマッチャーで、/tallyのときだけ動く - 書き換える:
ui.renderはsuffixを入れたeのコピーをnextに渡す。本体がいつものスピナーを描き、言葉の後ろに自分の文字が付く
--plugin-dir で読み込んだディレクトリは、ファイルが変わるとホットリロードされます。そのたびに register が走り直すので、calls は 0 に戻ります。読み直しをまたいで値を残す方法は、Mod で画面に描くの「状態を保つ」にあります。
Mod を育てる#
Mod が読み込まれたら、Claude に直させる、使っている版の型定義でコードを検査する、Claude Code がコードから読み取るイベントと呼び出しを一覧する、テストする、ができます。
Claude に Mod を直させる#
すでにある Mod を変えるときは、その Mod のディレクトリを --plugin-dir で指してセッションを始めます。Claude が書いたものが同じセッションで読み込まれます。
claude --plugin-dir ./first-mod
そのうえで、たとえば add a /tally-reset command to this mod that sets the tally back to zero と頼みます。Claude はフックモジュールを編集し、claude plugin validate を実行して、出た問題を直します。--plugin-dir で読み込んだディレクトリは保護されたパスなので、default と acceptEdits のモードでは Claude の編集ごとに承認を求められます(ほかの権限モードでの扱いは、権限モードのページの保護されたパスの表)。Claude がターン中に保存したファイルは、ターンの終わりに読み直されるので、終わったらすぐ /tally-reset を試せます。
使っている版の型定義を得る#
--plugin-dir に渡したディレクトリの Mod、または Claude が書いた Mod を Claude Code が読み込む(読み直す)たびに、Mod のディレクトリの .claude-plugin/types/ に TypeScript の型定義ファイル(.d.ts)が書き出されます。いま動かしている Claude Code の版にあるイベント・mods API のメソッド・要素が正確に書かれているので、エディターで補完と型検査ができます。次のファイルが置かれます。
| パス | 宣言していること |
|---|---|
claude-code/index.d.ts |
すべてのイベントと入力・結果、mods API のネームスペースとメソッド、各描画先に描ける要素 |
claude-code-tools/index.d.ts |
組み込みツールの入力と結果(e.tool === 'Bash' の検査で e の型が絞れる) |
claude-code-mcp/index.d.ts |
Mod のファイルを最後に保存した時点でつながっていた MCP ツールの入力 |
プラグイン名のディレクトリの index.d.ts |
そのプラグインが mods API に足すもの。plugin.json の dependencies に並べたプラグインごとに1つ |
tsconfig.json |
フックモジュールに合うコンパイラー設定 |
Mod に自前の tsconfig.json が無いと、生成されたものを継承する tsconfig.json が Mod のルートに足されます。そのため、エディターと tsc -p ./first-mod が追加設定なしで型を検査します。
イベントとメソッドは版ごとに変わりえます。このページを含め、どのページとも食い違ったら、この生成ファイルを信じます。claude-code/index.d.ts は、mods API の全メソッドにコメントと例が付いた、その版でいちばん詳しい資料です。名前(例:'tool.call')でファイルを検索して調べます。Claude Code のリポジトリの mods/types/claude-code.d.ts でもオンラインで読めます(1行目に書いた版の名前が入っています)。
Claude Code が読み取る内容を確かめる#
コードを実行せず、セッションも始めずに、Claude Code から見た Mod を確かめるには claude plugin validate を使います。マニフェストを検査し、Mod の読み込み時と同じ静的解析をフックモジュールのソースに対して行います。Mod のディレクトリに対して実行します。
claude plugin validate ./first-mod
first-mod では、出力に次の行が含まれます。
❯ ./register.js hooks: session.start, tool.call, command.run{command=tally}, ui.render{component=Spinner}
❯ ./register.js calls: $.command.register, $.ui.invalidate
✔ Validation passed
hooks: の行は、モジュールがフックするイベントを、絞り込みを波括弧に入れて並べます。calls: の行は、呼ぶ mods API のメソッドをすべて並べます。環境変数を読み書きするモジュールには env reads: と env writes: の行が、$.state を使うモジュールには state reads: と state writes: の行も付きます。
扱うつもりのイベントが1行目に無ければ、Claude Code もそのフックを呼びません。よくある原因はイベント名の綴りの間違いで、"tool.calls" is not an event のようなエラーで報告されます。
静的解析がすべてのフックと呼び出しを見つけられるよう、次の決まりを守ります。
- mods API の呼び出しは完全な形で書く:
$、ネームスペース、メソッドの順($.store.get('notes'))。$は、同じファイルのトップレベルで宣言した関数には渡せる(自作のloadNotesならcalls:の行が$.store.get (via loadNotes)になる)。メソッド・フックの中で定義した関数・ほかの自分のファイルから import した関数へ$を渡すと検査に失敗する。$.stateが使うreadとupdateの関数だけは、import していても渡せる。$やそのネームスペースを変数に代入する・分割代入する・計算した名前で引くこともしない(const ui = $.uiは$.ui is used as a valueで失敗する) onの呼び出しのイベント名は、文字列リテラル('tool.call'など)で書く。変数や名前の一覧のループはthe event name passed to on() is not a string literalで失敗するregisterの中に、onという名前の変数や引数をもう1つ宣言しない。"on" is declared again (shadowed)で失敗する- import してよいのはプラグインのディレクトリの中のファイルだけで、相対パスで書く。bare import(パスでない名前の import)で許されるのは、型といくつかの補助関数のための
claude-codeだけ import宣言はファイルの先頭に書く(import { name } from './file.js')。動的なimport()はa dynamic import(); a hooks module imports its own files with an import declarationで失敗する- どのファイルも ES モジュールで書き、
requireでなくimportを使う。読み込まれる拡張子はリファレンスにある
Mod をテストする#
claude plugin test は、Mod の自動テストをシェルから実行します。セッション・サインイン・ネットワークは要りません。テストがイベントを発火させ、フックがしたことを確かめるので、問題をセッションに持ち込む前に見つけられます。
テストファイルの決まりは次のとおりです。
- テストキット(
claude-code/testingモジュール)を import する - 名前は
.test.tsで終える。置き場所はプラグインのディレクトリのどこでもよい(例:first-mod.test.ts) test()を最低1つ書く。無いとdeclares no test(): nothing ranで失敗する- Mod 自身のファイルや隣の
.tsの補助ファイルも import できる。ゲームのルールのような普通の関数は、キットなしで単体テストできる
次のテストは、ツール呼び出しを2回発火させてから /tally を実行し、答えが2回を数えているかを見ます。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' }))
// 2回発火させる。Mod の tool.call フックが数える
await $.tool.call({ tool: 'Bash', command: 'ls' })
await $.tool.call({ tool: 'Read', file_path: 'README.md' })
// /tally を実行し、フックが返したテキストを確かめる
const answer = await $.command.run({ command: 'tally', args: '' })
expect(answer.text).toBe('Claude has made 2 tool calls since this mod loaded')
})
first-mod ディレクトリで実行します。
claude plugin test
tests/first-mod.test.ts:
(pass) /tally reports the tool calls the mod has seen [22.87ms]
1 pass
0 fail
Ran 1 test across 1 file. [0.19s]
$.tool.call は Mod の tool.call フックを通ってから(回数が増えてから)スタブへ渡るので、ls は実行されず、ファイルも読まれません。$.command.run の answer は、Mod の command.run フックが返したオブジェクトです。
失敗したテストがあると終了ステータス 1 で終わるので、CI に組み込めます。実行するシェルで Mod を読み込めないときも、claude plugin test: hooks modules are turned off で始まる行と理由を出して、終了ステータス 1 で終わります。
Claude Code の答えをスタブにする#
テストの中ではモデル・ストア・ツールが動かないので、Mod が Claude Code の答えを待つところはテストがスタブで答えます。テスト関数が受け取る2つの引数を使います。
| 引数 | 役目 |
|---|---|
$ |
テストが演じる Claude Code。フックが受け取る mods API ではない。メソッドは同じ名前のイベントを発火して Mod のフックに通し、結果で解決する($.tool.call({ tool: 'Bash', command: 'ls' }) は tool.call を発火)。$.command.run・$.prompt.submit・$.session.start・$.turn.complete も同じ。$.classic.Stop などの $.classic のメソッドは設定フックのイベントを発火する。ui.close のような mods API の呼び出しは直接は発火できず、ペインを閉じるボタンを押すなど Mod を通して起こす |
on |
スタブ(Claude Code の代わりに答えるフック)を登録する。mods API の呼び出しのスタブは $. を除いた名前で登録する(store.get のスタブが Mod の $.store.get に答える)。Mod が $.model.complete や $.store.get を呼ぶと、スタブが答えを返す |
次はモデルの呼び出しをスタブにする例です。grader という Mod の /grade コマンドは、文を1つモデルへ送り、返事が PASS で始まるかを報告します(ファイルにはテスト対象のフックだけを載せています。実際の Mod には plugin.json・hooks.json と、セッションで打つためのコマンドの登録も要ります)。
export function register(on) {
on('command.run', { command: 'grade' }, async ($, e) => {
// e.args は /grade のあとに打った文字
const reply = await $.model.complete({
model: 'haiku',
system: 'Grade the sentence. Start your reply with PASS or FAIL.',
prompt: e.args,
})
const passed = reply.isAnswered && reply.text.startsWith('PASS')
return { text: passed ? 'Passed' : 'Try again' }
})
}
import { expect, test } from 'claude-code/testing'
test('a passing grade is reported', async ($, on) => {
// $.model.complete に決まった返事で答える。モデルは動かない
on('model.complete', () => ({
value: {
isAnswered: true,
text: 'PASS\nNice sentence.',
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
},
}))
const answer = await $.command.run({ command: 'grade', args: 'The cat sat on the mat.' })
expect(answer.text).toBe('Passed')
})
フックの reply は value の中身で、text が PASS で始まるのでテストは通ります。もう一方の枝は、text が FAIL で始まるスタブで2つ目のテストを書き、Try again を期待します。
スタブが返す形は2通りです。
- mods API の呼び出しのスタブ:
valueフィールドを持つオブジェクト。valueが Mod の中でその呼び出しが解決する値になる({ value: 7 }なら$.store.getが7) - Claude Code のイベントのスタブ(
turn.step・tool.callなど):そのイベント自身の結果({ result: 'ok' }など)。$.session.sendと$.prompt.fillも、下の表のとおりイベントの結果の形で返す
スタブの誤りや不足は、失敗したテストの出力の the engine reported: の見出しの下に出ます。
returned neither { value } nor { deny }:mods API の呼び出しのスタブが、値をそのまま返したno implementation forと名前:Mod がその呼び出しをしたが、答えるスタブが無い
ネームスペースごと答えるメモリ内のモックもあります。
mock.clock(on):$.clockに答える。テストが進める模擬の時計を返すので、タイマーのテストが待たずに済むmock.store(on, { count: 7 }):その中身で始まるストアで$.storeに答える。戻り値は無いので、Mod が保存した値を確かめるなら、storeのスタブ2つを自分で書く(下の描画のテストと同じ)mock.env(on, { CI: 'true' }):その環境変数で$.env.getに答える
テストキットの決まり#
書き始めてすぐ出るエラーは、たいてい次のどれかです。
-
スタブは、テストが
$を最初に呼ぶ前にすべて登録する:あとでonを呼ぶと、on("ui.render") after the test first called $のようなエラーになる -
session.startは自動では走らない:各テストは、モジュールを読み込み直した状態(フックは1つも呼ばれておらず、モジュールレベルの変数は初期値)で始まる。session.startで用意するものに頼るフックなら、先に発火させるtypescript// フックが next(e) で渡したあとのイベントに答える on('session.start', () => ({ cwd: '/work' })) // フックが呼ぶ $.command.register に答える on('command.register', () => ({ value: undefined })) await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })2つ目のスタブが無いと、チュートリアルのような
session.startフックの$.command.registerがno implementation for command.registerで拒否され、キットがフックを飛ばすので、その後ろは実行されません。その時点ではテストは失敗せず、あとの検査が失敗したときにだけ、飛ばされたフックがthe engine reported:の下に並びます。 -
next(e)を返すフックには、答えるスタブが要る:たとえば Claude の待機中は何も描かないようui.renderフックがnext(e)を返すと、マウントがno implementation for ui.renderで失敗する。要素をそのままのデータで返すスタブを登録すると、マウントは通り、フックがnext(e)を返したときはui.find({ type: 'Text' })がその要素を返すtypescript// その場所に Claude Code が描くものの代わり on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) -
turn.stepのスタブは非同期ジェネレーター:テストは、結果を得るためにストリームを最後まで読むtypescripton('turn.step', async function* ($, e) { // yield 1つが、ストリームで届く返事の1片 yield { kind: 'text', index: 0, text: 'ok' } // return がリクエスト全体の結果 return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: null } }) const stream = $.turn.step({ turnId: 't', index: 0, model: 'claude-test', messageCount: 1 }) let step = await stream.next() while (step.done !== true) step = await stream.next() const result = step.value読み終えた
resultは、Mod のturn.stepフックが手を加えたあとの、スタブが返したオブジェクトです(ここではresult.answerが'ok')。 -
ツール呼び出しは、ツール名と引数をフィールドにして発火する:
await $.tool.call({ tool: 'Bash', command: 'ls' })。{ result }を返すtool.callのスタブも登録する
スタブが返すものの一覧#
Mod がテストの中でする mods API の呼び出しには、すべてスタブが要ります。例外は、キットが自分で答える $.ui.invalidate と $.state の呼び出しです。$.clock には mock.clock(on) を使います(無いと $.clock.now() が no implementation for clock.now で失敗します)。
表の1列目は Mod がする呼び出し(または next(e) で渡すイベント)、2列目はその名前で on に渡す関数です($.store.get の行なら on('store.get', ($, e) => ({ value: saved.get(e.key) })))。'...' は自分で埋めます。
| Mod がする呼び出し・渡すイベント | スタブ |
|---|---|
$.command.register・$.tool.register・$.ui.toast・$.ui.log・$.ui.status・$.ui.close・$.store.set |
() => ({ value: undefined })(ui.toast と ui.log の文は e.text) |
$.store.get |
($, e) => ({ value: saved.get(e.key) }) |
$.fs.read |
($, e) => ({ value: e.path.endsWith('notes.md') ? '# Notes' : '' })(e.path は絶対パスで届くので endsWith で比べる) |
$.ui.open |
() => ({ value: { isPlaced: true } }) |
$.ui.ask |
tool.call のスタブ(質問は AskUserQuestion ツールの呼び出しとして届く):($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } })。Mod がほかのツール呼び出しも渡すなら、先に e.tool を確かめる |
$.model.complete |
() => ({ value: { isAnswered: true, text: '...', usage } }) |
$.process.run |
($, e) => ({ value: { exitCode: 0, stdout: '...', stderr: '' } })(e.argv が引数の並び、e.init が cwd と timeoutMs) |
| 失敗させたい mods API の呼び出し | () => ({ deny: 'the reason' })(Mod の中で呼び出しが拒否される。例外を投げるスタブは飛ばされる) |
session.start |
() => ({ cwd: '/work' }) |
turn.start |
($, e) => ({ turnId: e.turnId }) |
tool.call |
() => ({ result: '...' }) |
turn.complete |
() => ({ text: '' })($.turn.complete({ turnId, answer, durationMs, isAborted: false, usage: null }) で発火) |
prompt.submit |
($, e) => ({ text: e.text }) |
prompt.fill |
() => ({ isFilled: true }) |
$.prompt.read |
() => ({ value: { text: '...', cursor: 0 } }) |
$.ui.copy |
() => ({ value: { isCopied: true } }) |
$.session.messages |
() => ({ value: [{ role: 'assistant', text: '...', toolUses: [] }] }) |
$.session.id・$.agent.list |
() => ({ value: 'abc123' })・() => ({ value: [] }) |
session.send |
() => ({ isDelivered: true })(e.to は、Mod が { sessionId } を渡しても文字列で届く) |
session.receive |
($, e) => ({ text: e.text })($.session.receive({ origin: { kind: 'peer-send-message' }, text }) で発火) |
ui.render |
() => ({ type: 'Text', props: {}, children: ['...'] }) |
expect には toBe・toEqual・toMatch・toMatchObject・toContain・toBeDefined・toBeUndefined・toThrow があり、どれにも前に .not を付けられます。
タイマーをテストする#
const clock = mock.clock(on) は、0 から始まり、テストが動かしたときだけ進む模擬の時計を返します。待たずに時間を進められるので、タイマーで動く Mod のテストに使います。別の時刻から始めるときはミリ秒で渡します(mock.clock(on, { now: 5000 }))。
| メソッド | すること |
|---|---|
await clock.advance(1000) |
その分のミリ秒だけ時間を進め、期限が来たタイマーを実行する |
await clock.set(5000) |
時間をその値まで進める(advance と同じ) |
clock.now() |
時刻を返す(Mod の $.clock.now() が解決する値) |
await clock.settle() |
すでに期限が来ているタイマー(遅延 0 の $.clock.after の連鎖など)を、時間を進めずに実行する |
await clock.sleep(2000) |
スタブの中で、テストがそれだけ進めたあとにはじめてそのスタブが答えるようにする(遅いモデルやプロセスの再現に使う) |
例は countdown という Mod の /countdown コマンドです。受け取った秒数から1秒ごとの $.clock.every で数え下げ、0 になるとトーストを出します(grader と同じく、テスト対象のフックだけを載せています)。
export function register(on) {
on('command.run', { command: 'countdown' }, async ($, e) => {
let left = Number(e.args)
const timer = $.clock.every(1000, () => {
left -= 1
if (left === 0) {
timer.cancel()
$.ui.toast('Time is up')
}
})
return {}
})
}
import { expect, mock, test } from 'claude-code/testing'
test('the countdown ends with a toast', async ($, on) => {
const clock = mock.clock(on)
// Mod が出したトーストの文を集める
const toasts: string[] = []
on('ui.toast', ($, e) => {
toasts.push(e.text)
return { value: undefined }
})
await $.command.run({ command: 'countdown', args: '3' })
// 2秒たってもまだ出ない
await clock.advance(2000)
expect(toasts).toEqual([])
// 3秒目で 0 になって出る
await clock.advance(1000)
expect(toasts).toEqual(['Time is up'])
})
advance は期限が来たタイマーが走り終わってから解決するので、次の行の検査にはその結果が反映されています。トーストが早く出ないことと、1回だけ出ることを確かめられます。
描画をテストする#
$.ui.mount は、Mod の ui.render フックを通して描画先を1つ描き、描いた要素を押す・入力する・探すためのハンドルを返します。surface で描く先のアプリを選べるので、1つのテストで複数のアプリを試せます。次は Mod で画面に描くの「タブ付きのペインを作る」のペインを、ターミナルとデスクトップアプリで1回ずつ描き、タブを切り替えてボタンを押します。
import { expect, test } from 'claude-code/testing'
// このペインの ui.render フックに Claude Code が渡すもの(アプリを除く)
const PANE = {
plugin: 'hello-tabs',
component: 'Pane',
requestId: 'hello-tabs',
viewport: { columns: 100, rows: 30 },
props: {
title: 'Hello tabs',
isFocused: true,
bodyColumns: 60,
placement: 'inline',
scroll: { offset: 0, bodyRows: 10 },
view: {},
},
} as const
test('the second tab counts presses and saves the count', async ($, on) => {
// $.store を Map で置き換え、保存された値を読めるようにする
const saved = new Map<string, unknown>()
on('store.get', ($, e) => ({ value: saved.get(e.key) }))
on('store.set', ($, e) => {
saved.set(e.key, e.value)
return { value: undefined }
})
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ ...PANE, surface })
// Mod が付けた key でボタンを押す
await ui.press({ key: 'tab-two' })
await ui.press({ key: 'more' })
expect(await ui.find({ type: 'Text', text: /^Count: \d+$/ })).toBeDefined()
await ui.unmount()
}
// 2つのアプリで1回ずつ押したので 2
expect(saved.get('count')).toBe(2)
})
hello-tabs ディレクトリで claude plugin test を実行すると、両方のアプリが回数の行を描き、Mod が 2 を保存していれば通ります。2回のマウントは同じ読み込み済みのモジュールを使うので、回数は次のアプリへ引き継がれます。
ハンドルのメソッドは次のとおりで、要素は付けた key で指します。
| メソッド | すること |
|---|---|
press({ key: 'more' }) |
その key の Button を押す |
input({ key: 'new-note', text: 'buy milk' }) |
その key の Input に文字を打ち、Enter を押す。kind: 'change' を足すと、送信せずに打つだけ |
select({ key: 'size', value: 'large' }) |
その key の Select で、その値の選択肢を選ぶ |
find({ key: 'more' }) または find({ type: 'Text', text: 'Count: 2' }) |
最初に一致した要素を { type, props, children } で返す(無ければ undefined)。text は文字列でも正規表現でもよい |
unmount() |
描画を取り除く |
どのメソッドもハンドラーが終わってから解決するので、次の行で結果を確かめられます。props には、その描画先で Claude Code が渡すものを入れます(描画先ごとの props はリファレンスの描画先の表に、型は使っている版の型定義にあります)。
描画のテストで分かるのは、フックが返した木と、それがそのアプリで有効かどうかまでです。アプリが実際にどう描くかは分からないので、新しいレイアウトは実際のセッションでも見ます。
/clear のあとの描画をテストする#
各テストは、すべての $.state が既定値の状態、つまり /clear の直後と同じ状態で始まります。/clear のあとの動きを試すには、session.start を発火させずに source: 'clear' で classic.SessionStart を発火させ、描かれるものを見ます。
次のテストは、Mod で画面に描くの「/clear のあとに保存した値を読み直す」のモジュールを確かめるもので、上の描画のテストのファイル(PANE を定義している)に足します。
test('the saved count comes back after /clear', async ($, on) => {
// ストアにはすでに 7 が入っている
on('store.get', () => ({ value: 7 }))
// フックが next(e) で渡したあとのイベントに答える
on('classic.SessionStart', () => ({}))
// /clear のあとに起きるイベントを発火させる
await $.classic.SessionStart({ source: 'clear' })
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
await ui.press({ key: 'tab-two' })
// 既定の 0 ではなく、保存した数が出る
expect(await ui.find({ type: 'Text', text: 'Count: 7' })).toBeDefined()
})
classic.SessionStart のフックが、ペインを描く前に 7 を $.state へ写していれば通ります。そのフックが無いと、ペインは Count: 0 を描き、find が undefined を返して toBeDefined で失敗します。
ポリシーの Mod をテストする#
組織が prependPlugins に並べた Mod は、ほかの Mod を読み込む前に拒否できます。こうした Mod を試すには、自分の Mod の層(tier)を決め、許す・拒む相手になる2つ目の Mod をテストに渡します。
tier:テストファイルの先頭で1回呼ぶ(tier('prepend'))。自分の Mod をprepend・append・builtinのどれかで読み込み、動く順での位置を決める。呼ばなければuserplugins:testの本体の前にオプションのオブジェクトを渡し、そのpluginsの配列に、その場で書いた Mod(nameとregister関数を持つ)を並べる。user以外で読み込むならtierを足す
次は、Mod(モッド)を使うの管理者向けの節にあるポリシーの Mod(acme-guard)が、プロセスを起動する Mod を拒むことを確かめます。
import { expect, test, tier } from 'claude-code/testing'
// テストする Mod を、ほかのどの Mod よりも先に読み込む
tier('prepend')
// ポリシーが禁じる $.process.run を呼ぶ2つ目の Mod
const runner = {
name: 'runner',
register(on) {
on('tool.call', async ($, e, next) => {
await $.process.run(['ls'])
return { result: 'runner answered' }
})
},
}
test('refuses a mod that starts a process', { plugins: [runner] }, async ($, on) => {
on('tool.call', () => ({ result: 'claude code answered' }))
let message = ''
try {
// $ を最初に呼んだときに Mod が読み込まれるので、拒否はここで投げられる
await $.tool.call({ tool: 'Bash', command: 'ls' })
} catch (error) {
message = error.message
}
expect(message).toBe('runner: refused by acme-guard: Acme policy: mods may not call process.run')
})
キットは、テストが $ を最初に呼んだときにすべての Mod を読み込みます。自分の Mod が1つを拒むと、その呼び出しが例外を投げ、メッセージに拒まれた Mod・拒んだ Mod・理由が並びます。
逆に、禁じられたものを何も呼ばない Mod(tool.call で { result: 'reader answered' } を返すだけの reader)を plugins: [reader] で渡すと、何も拒まれず、$.tool.call の答えは reader からの { result: 'reader answered' } になります。reader がスタブより先に答えたことで、読み込まれたと分かります。
acme-guard ディレクトリで claude plugin test を実行すると、両方のテストが通ります。
Mod を配る#
Mod はプラグインなので、マニフェストで版を付け、/plugin のコマンドでインストール・更新してもらいます。配り方は相手で変わります。
- 数人:プラグインのディレクトリか、その
.zipを送る(プラグインを作って配るの、マーケットプレイスなしで共有する方法) - チーム:自分のマーケットプレイス(プラグインごとにディレクトリを持つ非公開リポジトリなど)に載せる。リポジトリで作業する全員に追加するには、リポジトリの設定にそのマーケットプレイスを登録する
- 組織全体:管理者が、管理設定で組織の Mod をインストールする(Mod(モッド)を使う)
- 誰でも:マーケットプレイスのリポジトリを公開するか、Anthropic のディレクトリへプラグインを提出する
配る前に、プラグインの name を確かめます。claude- で始まるなど、Anthropic 自身のものに見える名前は claude plugin validate が失敗にします。イベントとメソッドは版ごとに変わりえるので、どの Claude Code の版で確かめたかは README に書きます。
開発は、インストールしたコピーではなく、--plugin-dir で作業ディレクトリに対して続けます。Claude Code は、インストールしたプラグインを版ごとにキャッシュするので、版を上げて入れ直すまで、編集がインストール済みのコピーに届きません。
Mod が何もしない原因を探す#
モジュールやフックが失敗しても、Claude Code はそれを飛ばしてセッションを続けます。そのため、壊れた Mod は「何もしない Mod」に見えます。まず次の2つで手がかりを集め、そのあと下の症状から当てはまるものを探します。
- Claude Code の読み取り内容:
claude plugin validate ./first-modのように Mod のディレクトリを指して実行する。イベント名の綴りの間違い・マニフェストの不備・読めないモジュールが、セッションなしで分かる - Claude Code が書く1行:モジュールが読み込まれない・フックが飛ばされる・ほかの Mod に拒否されると、Mod の名前を含む1行が書かれる。読む場所はセッションの種類で変わる
| セッション | 1行を読む場所 |
|---|---|
プラグインのディレクトリをホットリロードするセッション(--plugin-dir で始めた対話セッション、または Claude が書いた Mod のホットリロードを有効にしたセッション) |
トランスクリプトの薄い色の行 |
| それ以外の対話セッション(マーケットプレイスからインストールした Mod を動かすものなど) | デバッグログだけ(claude --debug で始める) |
--plugin-dir 付きの claude -p |
既定のテキスト出力形式では stderr。別の Mod による拒否はデバッグログだけ |
Mod を読み込める環境か確かめる#
Mod を持たないディレクトリでシェルから claude plugin test を実行すると、インストールもセッションもなしに、自分の設定で Mod が読み込めるかが分かります。
| メッセージに含まれる語 | 意味 |
|---|---|
no hooks module to load |
Mod は読み込める。このディレクトリにテストする Mod が無かっただけ |
hooks modules are turned off here |
設定が Mod を止めている:自分の設定の disableAllHooks、または組織のポリシー |
hooks modules are turned off in this process |
Anthropic が、インストールした Mod を遠隔でオフにしている。手元の設定では戻せない |
組織が自分たちの Mod だけを許す allowManagedModsOnly を設定していても、このコマンドには出ません。その場合、自分で入れた Mod は読み込まれず、下の「組み込みのガードのメッセージ」の行が出ます。
Mod が読み込まれない#
コマンドも描画も動作の変化も、Mod が足すものが何も出ないときです。
| 症状・出る行 | 原因 | 直し方 |
|---|---|---|
claude --version が 2.1.287 より古い |
Mod が既定でオンになる前の版 | Claude Code を更新する |
/plugin の mods active の行に Mod の名前が無い |
フックモジュールが読み込まれていない。拒否されたなら、デバッグログに hooks module・Mod の名前・not loaded: で始まる行がある(--plugin-dir の Mod なら hooks module first-mod@inline not loaded: disableAllHooks in managed settings のように)。設定によっては、Mod だけを止めてプラグインのほかの部分は動かしたままにする |
コロンの後ろの理由を読む(下の「拒否のメッセージ」)。そうした行が無ければ、この表のほかの行を順に調べる |
claude -p で、Mod の名前で始まる hooks module not loaded が stderr に出る |
フックモジュールが拒否された。非対話の実行にはトランスクリプトが無いので stderr に出る | コロンの後ろの理由を読む |
validate が通るのに hooks の行が無い |
hooks/hooks.json に modules キーが無い、または綴りが違う |
"modules": ["./register.js"] を足す |
Mod の名前、hooks module did not load:、理由の順の行 |
トップレベルのコードが例外を投げたなど、モジュールを読み込めなかった。問題がコードにあれば、理由にファイルと行が出る | 理由が示すエラーを直す |
Mod の名前、hooks module did not load: options do not fit plugin.json userConfig:、理由の順の行 |
オプションが userConfig のフィールドの検査に通らない(max を超える数、必須のフィールドに値が無い、など) |
値を設定し直す。行の終わりに、settings.json の pluginConfigs のどの項目かが出る |
| 初めて開いたディレクトリで、どの Mod も読み込まれない | そのディレクトリの信頼の確認に答えていない | そのディレクトリで claude の対話セッションを始め、最初に出る信頼の確認を承認する |
| インストール済みのプラグインがどれも読み込まれない | --safe-mode で起動している |
フラグを外して起動する |
拒否のメッセージ#
デバッグログで、hooks module・Mod の名前・not loaded: の後ろに続く理由です。
| メッセージの始まり | 意味 |
|---|---|
hooks modules are turned off for installed plugins in this process |
Anthropic が、インストールした Mod を遠隔でオフにしている。手元の設定では戻せない |
disableAllHooks in managed settings |
組織が、インストールしたプラグインのフックをオフにした |
only managed plugins and built-in plugins run |
allowManagedHooksOnly が設定されている、または管理設定以外の設定ファイルで disableAllHooks が設定されている |
installed plugins that are not managed load no hooks module in this mode (--bare) |
--bare で起動した |
another plugin of that name loads first |
同じ名前のプラグインが2つある。管理されたもの、または先に読み込まれたものが使われる |
組み込みのガードのメッセージ#
管理設定のあるマシン、または Team か Enterprise プランでサインインしたユーザーでは、組み込みのガードが、Mod やその答えを拒否することがあります。どのメッセージにも、そのルールを変えるために組織の管理者が設定するオプションの名前が出ます。
| メッセージに含まれる語 | 意味 | 出る場所 |
|---|---|---|
mods are limited to your organization's by policy (allowManagedModsOnly) |
組織が自分たちの Mod だけを許しているので、あなたの Mod は読み込まれなかった | デバッグログと、ホットリロードするセッションのトランスクリプト |
tried to lift a deny rule in your settings |
Mod の tool.check フックが、deny ルールの拒否する呼び出しを承認した。呼び出しは拒否されたまま |
トランスクリプトとデバッグログ(セッションごと、Mod ごとに1回)。claude -p ではデバッグログだけ |
the deny rules in your settings could not be checked for this call, so it is refused |
Mod が承認した呼び出しを検査中にガードが失敗し、呼び出しを拒否した | 拒否された呼び出しについて Claude が読む理由 |
フックが飛ばされる、Mod が外される#
読み込まれたあとで、Claude Code がフックの1つを飛ばしたか、Mod を外したときです。
| 出る行 | 原因 | 直し方 |
|---|---|---|
Mod とイベントの名前、hook skipped:、理由(例:first-mod: tool.call hook skipped: threw Error: boom) |
フックが例外を投げた・制限時間を超えた・形の違う結果を返した。行は、同じイベントと種類の失敗ごとに、Mod を読み直すまで1回だけ出る(デバッグログには毎回出る) | エラーを直す |
first-mod registered /tally but no command.run hook answered it のような返事 |
コマンドが答えのないまま連鎖の終わりに届いた。どのフックも答えなかった(command.run フックが無い・マッチャーが別のコマンドを指している・フックが next(e) を返した)か、Claude Code がフックを飛ばした($.ui.open に focus: false を渡したときなど) |
すでにフックがあるなら、command.run を名指す hook skipped の行で理由を見る。コマンドを実行するテストも同じ理由で失敗する |
Mod の名前で始まる it crashed the hooks worker(例:first-mod was unloaded: it crashed the hooks worker) |
インストールした Mod が共有する1つのワーカースレッドが応答しなくなるか落ち、Claude Code がこの Mod のせいと突き止めて外した。await しない無限ループのような、スレッドを塞ぐフックが原因の1つ | フックを直す |
hooks: mods that run in the hooks worker are off for this session: it crashed 3 times |
ワーカーが3回止まり、1つの Mod のせいと突き止められなかったので、組み込み以外のすべての Mod(組織がインストールした Mod も)を外した。この行はどの対話セッションでもトランスクリプトに出る | /reload-plugins で読み込み直す |
ツール呼び出しが拒否される#
Mod は読み込まれてフックも動いているのに、Mod が触ったツール呼び出しが拒否されるときです。
a hook changed this call's input after the model wrote it:自動モードで拒否された呼び出しに付く理由です。サーバー側の分類器が審査したあとにフックが入力を変えたので、審査が実際に動くものを見ていません。変えたのは Mod のtool.callかturn.step、またはPreToolUseの設定フックのどれかですが、メッセージはどれかを言いません。メッセージは Claude に、記録どおりにもう一度呼ぶよう伝えます。それも拒否されるなら、フックが毎回入力を変えているので、その Mod かフックをオフにするか、自動モードをやめて自分で承認します- 設定の deny ルールについてのメッセージ:
tried to lift a deny rule in your settingsとthe deny rules in your settings could not be checked for this call, so it is refusedは組み込みのガードから出ます。上の「組み込みのガードのメッセージ」を見ます
描画が出ない・反応しない#
Mod は読み込まれたのに、ペイン・帯・部品が思ったとおりに動かないときです。
| 症状 | 原因 | 直し方 |
|---|---|---|
| ペインや帯が空、または Claude Code のいつもの内容が出る | フックが返した木が検査に通らなかった。--plugin-dir なら、トランスクリプトに理由付きで ui.render (Pane) refused: と出る(例:first-mod: ui.render (Pane) refused: Box prop "flexDirection" must be one of row, column, row-reverse, column-reverse; the engine drew its own)。デバッグログには同じ理由で a hook returned a tree that does not validate と出る |
その行の理由を読む。よくある原因は、要素が取らない prop と、そのアプリに無い要素 |
$.ui.open を呼んでもペインが出ない |
呼び出しがユーザーの操作から来ておらず、ターミナルがそのペインに要る幅より狭い | コマンドかボタンから開くか、結果の isPlaced を確かめる(Mod で画面に描くの「ペインを適切なタイミングで開く」) |
| ホットキーが効かない | ペインにキーボードフォーカスが無い | Ctrl+X のあと Tab を押すか、ペインをクリックする。コマンドから focus: true で開く |
| ターミナルでは描けるが Desktop アプリでは描けない | その描画先か要素が、そこでは使えない | リファレンスの描画先と要素の表を見る |
編集や値が失われる#
Mod は動いているのに、加えた変更や、Mod が持っていた値が無いときです。
| 症状 | 原因 | 直し方 |
|---|---|---|
| 編集が反映されない | インストールしたプラグインを編集している。Claude Code はその版のキャッシュされたコピーを動かす | claude --plugin-dir ./first-mod のように作業コピーを指して開発する(保存すると読み直される) |
| モジュールを読み直すと値が戻る | モジュールレベルの変数は、読み直すたびに初期化される | 値は $.state か $.store に持つ |
/clear・/resume・/branch のあとに値が戻る |
これらのコマンドは $.state を既定値に戻し、session.start は再び発火しない |
classic.SessionStart フックで、保存した値を読み直す |
デバッグログを読む#
デバッグログには、読み込んだ・拒否したすべてのモジュール、失敗したすべてのフック、拒否したすべての結果の行が残ります。トランスクリプトに何も出ないときはここを見ます。--debug か、出力先を選べる --debug-file <path> を付けて始め、別のターミナルで Mod の名前で絞って追いかけます。
claude --debug-file ./mod-debug.log --plugin-dir ./first-mod
# 別のターミナルで
tail -f ./mod-debug.log | grep first-mod
読み込まれた Mod には、名前と扱うイベントを並べた行が出ます。--plugin-dir で読み込んだ Mod は、名前の後ろに @inline が付きます。
hooks module first-mod@inline loaded (worker, environment 2, tier user); events: session.start,tool.call,command.run,ui.render
検査に通らなかった描画も、拒否された結果として行になります。自分の行をログに書くには $.ui.log('message', { to: 'debug' }) のように第2引数を付けます(付けないと、トランスクリプトに薄い色の行が足されます)。
--plugin-dir の Mod を編集しているあいだは、読み直しのたびに Mod の名前とフックの一覧の行がトランスクリプトに出ます。保存でモジュールが壊れたときは reload failed, the previous version stays loaded: と理由の行が出て、最後に動いた版が動き続けます。
次に読むページ#
- Mod で画面に描く:ペインを開く、プロンプトの上に描く、ボタンや入力欄を足す
- Mod のリファレンス:ファイルの形・イベント・API のメソッド・描ける場所・要素の一覧
- Mod(モッド)を使う:入れ方・信頼・オンオフ・組織での管理・最初から入っている mod
- プラグインを作って配る:マニフェスト・マーケットプレイス・公開
公式ドキュメント(英語)
2026年10月5日時点の内容をもとに、日本語でまとめています。