生活分享

Claude Code|建立第一個 mod:在 Claude Code 行程內數工具呼叫

寫一個三檔案的 mod,用驗證器與測試確認它掛上的事件。文件把 mod 定義成多了入口檔的 plugin:入口檔叫 hooks module,Claude Code 在事件發生時呼叫裡面的函式,函式可以觀察、改寫或接手事件。

閱讀時間約 10 分鐘

建立第一個 mod:在 Claude Code 行程內數工具呼叫:文件、螢幕與完成記號的幾何插圖
圖片:Mokaair (© Mokaair)
本篇目錄
  1. mod 是什麼,和設定檔 Hook 差在哪
  2. 寫三個檔案
  3. 載入並試用
  4. 改一行字,看熱重載
  5. 用驗證器看 Claude Code 讀到什麼
  6. 寫一個不用 session 的測試
  7. 安全與界線
  8. 完成判準與小練習

設定檔 Hook 在 Claude Code 外部執行。想在行程內數工具呼叫、把次數顯示在 spinner 旁,文件的做法是 mod。本篇照官方教學建立 first-mod:三個檔案、四個事件函式,再用 claude plugin validate 看 Claude Code 讀到什麼、用 claude plugin test 寫不需要 session 的測試。交付是能載入的 first-mod、一個通過的測試,與驗證器的 hooks、calls 兩行紀錄。

先讀 、與。下載第 97 篇材料,在 starter 的 first-mod 操作:它只有 plugin.json 與 hooks.json,其餘由你寫,reference 有四個檔可對照。需要 Claude Code v2.1.287 或更新版本(用 claude --version 檢查);互動試用需要你自己登入的 Claude Code,validate 與 test 不需要 session。閱讀約 20 分鐘,實作約 40 分鐘。

mod 是什麼,和設定檔 Hook 差在哪

mod 是什麼,和設定檔 Hook 差在哪 → 寫三個檔案 → 載入並試用
mod 是什麼,和設定檔 Hook 差在哪 → 寫三個檔案 → 載入並試用 · 圖片:Mokaair (© Mokaair)

文件把 mod 定義成多了入口檔的 plugin:入口檔叫 hooks module,Claude Code 在事件發生時呼叫裡面的函式,函式可以觀察、改寫或接手事件。

用詞先講清楚:Claude Code 把兩種都叫 hook;mods 文件裡單說 hook 指 mod 的事件處理函式,設定檔那種叫 settings hook。本篇固定稱後者「設定檔 Hook」、前者「mod 的事件函式」。設定檔 Hook 在行程外運作、不能畫進介面;mod 在行程內,可畫 pane 或改寫 spinner。

寫三個檔案

plugin.json 是一般的 manifest,mod 沒有額外必填欄位。hooks.json 的 modules 是只放一個相對路徑的陣列,文件說有了它才算 mod。register.js 是入口。

編輯器:確認目錄結構 · text
first-mod/
├── .claude-plugin/
│   └── plugin.json
├── hooks/
│   ├── hooks.json
│   └── register.js
└── tests/
    └── first-mod.test.ts
寫入 first-mod/.claude-plugin/plugin.json · 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": "Mokaair tutorial" }
}
寫入 first-mod/hooks/hooks.json · json
{
  "description": "The first-mod hooks module",
  "modules": ["./register.js"]
}
寫入 first-mod/hooks/register.js · javascript
// The count, shared by the hooks below
let calls = 0

// Claude Code calls this once when the mod loads
export function register(on) {
  // Runs when the session starts, before your first prompt
  on('session.start', async ($, e, next) => {
    // Add the /tally command
    await $.command.register({
      name: 'tally',
      description: 'Show how many tool calls Claude has made',
    })
    // Let the session start as usual
    return next(e)
  })

  // Runs each time Claude is about to use a tool
  on('tool.call', async ($, e, next) => {
    calls += 1
    // Ask Claude Code to draw the interface again, so the new count shows
    $.ui.invalidate('ui.render')
    // Let the tool run as usual
    return next(e)
  })

  // Runs when you type /tally, and only then, because of the matcher
  on('command.run', { command: 'tally' }, async () => {
    // The text to print in the transcript
    return { text: 'Claude has made ' + calls + ' tool calls since this mod loaded' }
  })

  // Runs each time Claude Code draws the spinner
  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
    // Keep Claude Code's spinner, with the count added after its word
    return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
  })
}

session.start 註冊 /tally,tool.call 計數並要求重畫,command.run 只在 /tally 時回覆,ui.render 把次數接在 spinner 字後。

每個事件函式拿到三個引數:$ 是 mods API,能呼叫的方法都在上面;e 是凍結的事件資料,要改就傳複本;next 把事件交給後面的 mod 與 Claude Code 自己的行為。處理有三種:觀察(回傳 next(e))、接手(如 command.run,不呼叫 next,第二個引數 { command: 'tally' } 是 matcher)、改寫(如 ui.render,把複本交給 next)。

載入並試用

用 --plugin-dir 載入,只限這一次 session,不會安裝。

專案終端機:載入 first-mod · text
claude --plugin-dir ./first-mod
Claude Code 對話框:要求幾次工具呼叫 · text
請列出這個資料夾的檔案,再讀 README。

文件說明的預期:Claude 工作時,spinner 字後有往上數的次數,如 Thinking · tool calls: 2…;完成後輸入 /tally,transcript 出現 first-mod: Claude has made 2 tool calls since this mod loaded(次數是你自己的),plugin 名稱由 Claude Code 加在指令文字前。指令清單裡沒有 /tally,文件說是 module 沒載入。在提示列執行 /plugin,文件說分頁列下方有一行暗色字列出數量與名稱,如 1 mod active · first-mod;overview 也用這一行確認以 --plugin-dir 載入的範例 mod。

不開互動 session 也能檢查指令:

專案終端機:非互動檢查 /tally · text
claude -p "/tally" --plugin-dir ./first-mod

文件列的預期輸出是 first-mod: Claude has made 0 tool calls since this mod loaded。

以上都是文件的預期。本站只在容器用 Claude Code 2.1.289 跑過 validate 與 test,沒跑互動 session,也沒跑 claude -p。請自己寫 observation.md:版本、spinner 原文、/tally 與 claude -p 的輸出、/plugin 那一行;不同就照實寫。

改一行字,看熱重載

session 不關,把 ui.render 的 ' · tool calls: ' 改成 ' · tools used: ' 並存檔。文件說預期 transcript 出現一行,說 first-mod 已重載並列出它的事件函式,下一次 spinner 變成 Thinking · tools used: 1…;請記下原文。

修改 first-mod/hooks/register.js 的 ui.render 事件函式 · javascript
  // Runs each time Claude Code draws the spinner
  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
    // Keep Claude Code's spinner, with the count added after its word
    return next({ ...e, props: { ...e.props, suffix: ' · tools used: ' + calls + '…' } })
  })

文件說 Claude Code 會監看 --plugin-dir 載入的目錄,檔案一改就熱重載 hooks module;每次重載都再執行 register,所以 calls 歸零,/tally 從頭數。要跨重載保留數值,文件指向 Draw in the interface 頁的「Keep state」一節。

用驗證器看 Claude Code 讀到什麼

claude plugin validate 檢查 manifest,並對 hooks module 的原始碼跑 Claude Code 載入 mod 時的同一套靜態分析,不執行程式也不開 session。本站在容器以 2.1.289 對本課材料執行過;輸出的行首符號是 > 與 √,文件範例是 ❯ 與 ✔,其餘文字相同:

專案終端機:驗證 first-mod · text
claude plugin validate ./first-mod
專案終端機:驗證輸出(容器實際輸出,節錄) · text
  > ./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 方法。文件說事件沒列在第一行,Claude Code 也不會呼叫那個事件函式;常見原因是事件名拼錯,例如拼成 tool.calls,命令會報 "tool.calls" is not an event。文件列的規則:

  • mods API 呼叫要寫完整,如 `$.store.get('notes')`;`const ui = $.ui` 會報 `$.ui is used as a value`。
  • 事件名要是字串字面值,用變數會報 `the event name passed to on() is not a string literal`。
  • 只用相對路徑匯入 plugin 目錄內的檔案,唯一允許的裸匯入是 `claude-code`;寫成 ES module,用 `import` 不用 `require`。

文件說每次以 --plugin-dir 載入或重載 mod,Claude Code 會把依你版本產生的 .d.ts 型別檔寫進 mod 目錄的 .claude-plugin/types/;它們與任何頁面不一致時,以它們為準。

寫一個不用 session 的測試

測試檔名以 .test.ts 結尾。這個測試觸發兩次工具呼叫,再執行 /tally。

寫入 first-mod/tests/first-mod.test.ts · typescript
import { expect, test } from 'claude-code/testing'

test('/tally reports the tool calls the mod has seen', async ($, on) => {
  // Answer each tool call in Claude Code's place, so no tool runs
  on('tool.call', () => ({ result: 'ok' }))

  // Fire two tool calls, which the mod's tool.call hook counts
  await $.tool.call({ tool: 'Bash', command: 'ls' })
  await $.tool.call({ tool: 'Read', file_path: 'README.md' })

  // Run /tally and check the text its hook returns
  const answer = await $.command.run({ command: 'tally', args: '' })
  expect(answer.text).toBe('Claude has made 2 tool calls since this mod loaded')
})

測試的 $ 扮演 Claude Code,不是事件函式拿到的 mods API:它的每個方法觸發同名事件並送進 mod 的事件函式。$.tool.call(...) 先經過 mod 的 tool.call 事件函式,再交給 stub:on('tool.call', () => ({ result: 'ok' })) 代替 Claude Code 回答,所以沒有 ls 真的執行。stub 要在第一次呼叫 $ 前登記。

專案終端機:在 first-mod 目錄跑測試 · text
claude plugin test

文件說耗時每次不同,所以下面括號裡的時間與文件範例不一樣。

專案終端機:測試輸出(容器實際輸出) · text
tests/first-mod.test.ts:
(pass) /tally reports the tool calls the mod has seen [72.16ms]

 1 pass
 0 fail
Ran 1 test across 1 file. [0.32s]

文件說測試失敗時命令以狀態 1 結束,所以可進 CI。文件也說測試裡 session.start 不會自己執行,而 $.command.run 直接把事件送進 mod 的 command.run 事件函式;因此這個測試沒有經過 $.command.register,通過不代表 /tally 已出現在指令清單,那要看互動 session 的紀錄。

安全與界線

mod 以你的權限在行程內執行:可讀寫你帳號能碰的檔案、啟動程式、發網路請求、讀環境變數與設定檔裡的 API key、看到每個提示與工具呼叫,也能在你被詢問前核准工具呼叫。它不在沙盒裡;文件的結論是只從信得過的作者與 marketplace 安裝。

validate 的 calls 行是檢查點。文件說,事件函式要在自己的程式碼之外做任何事,例如畫面、加指令、呼叫模型、讀檔、啟動程式或發網路請求,都得呼叫 mods API,沒有別的途徑,所以 Claude Code 能在安裝前列出 mod 做什麼。first-mod 只呼叫 $.command.register 與 $.ui.invalidate,沒有檔案、行程或網路。安裝別人的 mod 前,可先對取得的目錄跑 validate,原始碼仍要讀。

文件說 --plugin-dir 載入的目錄是 protected path,請 Claude 修改時,default 與 acceptEdits 模式會逐次詢問你。分享給別人時,在 README 寫明你測試用的版本。

完成判準與小練習

完成判準:validate 的 hooks 行列出四個事件、calls 行列出兩個方法;plugin test 一個測試通過;熱重載後 spinner 文字改變有紀錄。前兩項本站在容器驗證過(2.1.289,2026-10-04),熱重載一項是文件預期,沒完成就標成待測。

下表是本課三種最常見的卡點;每一列的處理都能用 validate 或 test 的輸出核對。

症狀檢查位置處理
/tally 不在指令清單validate 的 hooks 行有沒有 session.start事件名用字串字面值,重載後再看 /plugin
驗證器說 is not an eventon 的第一個引數對照 reference 頁的事件名改回
驗證器說 is used as a value有沒有把 $ 或 $.ui 存進變數每次呼叫都完整寫 $.命名空間.方法

故障練習請保存實際輸出:把 'tool.call' 改成 'tool.calls',預期 "tool.calls" is not an event;改回後,把 $.ui.invalidate('ui.render') 改成先寫 const ui = $.ui 再呼叫,預期 $.ui is used as a value。最後改回原樣,重跑 validate 與 test。

小練習:替測試檔加第二個測試,不觸發工具呼叫就跑 /tally,文件說每個測試開始時 module 都是剛載入、模組層變數是初始值,所以預期 Claude has made 0 tool calls since this mod loaded。同一個 plugin 裡的設定檔 Hook 換到別台機器失效時,讀。

回總目錄

  • 生活分享

    Claude Code|Git Worktree 平行工作

    隔離多個任務的檔案與分支。Git Worktree 讓同一儲存庫擁有多個工作目錄,各自使用分支與檔案。本篇會把待辦篩選與文件整理分開,確認兩個 session 不會直接改到彼此的檔案,再把其中一個成果整合回主分支。你也會知道何時可以安全清理工作目錄。

  • 生活分享

    Claude Code|雙 Worktree 實作與衝突整合

    隔離兩項功能,最後完成整合與回歸。兩個 Claude 工作階段同時編輯專案,最容易出現的問題是互相改到同一份檔案,或各自測試通過、整合後卻失敗。本篇用兩個 Worktree 分別處理篩選預設值與介面文字,故意製造一次小衝突,再完成整合、驗證與清理。你不需要先啟用 Agent Teams。

  • 生活分享

    Claude Code|比較流程品質、用量與執行時間

    以同一資料集比較兩種工作方法。比較兩種 Claude 工作方法時,不能只挑成功那一次,也不能只看第一個答案有多快。本篇用固定案例、原始紀錄和一致判準,比較品質、重試、等待與人工整合時間,最後寫出有樣本數與限制的報告,而不是保證某個方法一定省錢。

  • 生活分享

    Claude Code|用量、成本與效率調整

    縮小任務、整理上下文與選擇模型。提升 Claude Code 效率,先減少不必要的工作,再考慮模型與設定。本篇用同一個待辦功能比較任務範圍、上下文與驗證方式,建立一份用量觀察表。你會分清楚訂閱額度、API 費用估計與實際帳務,不用單一數字判斷整個工作是否划算。

最新旅遊情報攻略

資料來源

生活分享