今天要解的問題
Claude Code 在 2026-10-02 推出了 mod(v2.1.287 起預設開啟),這篇是我試著把它導入 llm-wiki 的紀錄。
在這之前,Claude Code 能擴充的方式已經有四種:skill、settings hook、MCP、plugin。llm-wiki 每一種都用到了:十幾個 skill、Day18 那套擋主 checkout 寫入的守門 hook、.claude/ 由 git 讓兩台共用。新東西進來,當然就要來做比較拉:mod 能做什麼,是其他四種做不到的?
我的答案從一個一直存在的不便開始。Day18 之後,每個 session 都關在自己的 worktree 裡,我在 session 裡工作時看不到 vault 現在的樣子,要知道只能開 Obsidian,或叫 Claude 去讀。這是「看不到」的問題,skill 解不了:skill 能讓 Claude 讀得更好,但不會讓我看得到。
所以這篇的主軸是「為什麼要用 mod」,順序照我認識它的順序:先從最直觀、看得到的介面開始,再往下看它的 hook,跟我現有的守門 hook 有什麼不同。

上圖是示意(Pane、橫條、狀態列各自在哪),不是截圖。
想法與取捨
先分清楚:五種擴充,用「要改的是什麼」來分
官方文件有一張比較表,我把它壓成一句話:
改 Claude 的腦(知識、流程)→ skill;給 Claude 手(外部系統)→ MCP;守門(擋或放)→ settings hook;改 Claude Code 這個 app 本身(介面、事件流、免 turn 的指令)→ mod;要打包帶走 → plugin。
幾個容易混的點:
- mod 就是 plugin:準確說是「
hooks/hooks.json裡有modules這個 key 的 plugin」。plugin 是容器,skill、agent、MCP、settings hook、mod 都能裝進去 - mod 跟 settings hook 最像,但跑法不同:settings hook 每次另外開一個 shell 程式;mod 是 Claude Code 行程裡直接呼叫的函式,所以能畫介面、能在 hook 之間共享變數。官方明說兩者並存,settings hook 沒有被淘汰
- 吃不吃 context:skill 的名稱與描述每個 turn 都佔 context;mod 不吃(除非它主動往 prompt 塞東西)
只有 mod 做得到的有五件:
- 畫可操作的介面
- 重畫 Claude Code 自己的介面(tool call 列、spinner…)
- 介入 tool call 或 request
- 跑不開 Claude turn 的
/command - hook 之間共享變數。
放到 llm-wiki,第一個結論反而是「不改的」:/note-enrich、/wiki-build、/book-review 是教 Claude 一套判斷,留在 skill,mod 不會讓 Claude 判斷得更好;frontmatter 檢查、根目錄禁寫這些守門規則已經有 settings hook 在擋,沒有介面需求,也不必改。mod 真正補的洞是另一種:把 vault 的狀態變成畫面。叫 Claude 去讀 TODO.md,每次要花一個 turn 和一堆 token;mod 畫的東西不經過模型、不吃 context,這是我選擇先拿它當儀表板的原因。
從介面開始:mod 能畫在哪
mod 是一個 TypeScript 模組,export register(on),用 on('事件', hook) 掛進 Claude Code 的各種事件。能畫的位置有側邊面板(Pane)、輸入框上方的橫條、狀態列,還有輸入框本身。改檔存檔就熱重載,不用重開 session。
我第一個做的是 TODO 面板,做著做著問了一個問題:每次都要打 /todo 才叫得出來,還是應該把 UI 分開?
答案是本來就分開了,只是我沒意識到:橫條、狀態列一直都在,不用叫;只有側邊面板要開。所以第一版就分成兩層:常駐的橫條放今日三頁、狀態列放幾個數字、輸入框標連結;面板放 TODO 和近期新增的 wiki。每個位置各放什麼、要我關注什麼,留到 Day27 細講。
指令也從 /todo 改掉了——面板早就不只 TODO。mod 本身後來也改名叫 navi(薩爾達傳說裡林克的妖精夥伴,「Hey! Listen!」正是它在做的事),起因是狀態列:Claude Code 會在每個 mod 的狀態列前面加上 mod 名稱,vault-todo: 太長。改名時我才知道資料夾、mod 名稱、指令、面板標題是四個各自獨立的設定,但我選擇全部統一叫 navi:一致對我來說比較好管理,少記一個名字。所以指令是 /navi,順手加了參數:close 關面板、refresh 立即重算、reroll 重抽今日三頁。
「近期新增」從哪裡算
最直接的是看 wiki 檔的修改時間。但 session 都在 worktree 裡,worktree 是新切出來的,所有檔案都是同一個時間點,這個資訊沒用。
改用 git 歷史:git log --diff-filter=A 只列「新增」的檔案,rename 會被 git 判成 R,不會混進來。
已知代價有兩個:還沒 commit 的新頁不會出現;看到的是當下 worktree 那條分支的歷史,不一定等於 main。
我後來又把「今天新增」改成「本週新增」——這是我自己改的,今天新增大多數時候是 0,看了沒感覺。
怎麼「點了就在 Obsidian 開」
mod 的 Link 元素只收 https: 網址(或 http://localhost),obsidian:// 不行。
所以改走 $.process.run,讓作業系統去開 URI:Windows 用 rundll32 url.dll,FileProtocolHandler,macOS 用 open。選 rundll32 而不是 cmd /c start,是因為 URI 裡有 &(vault=llm-wiki&file=...),經過 cmd 會被當成指令分隔符吃掉;rundll32 不經 shell。
mod 裡沒有現成的「現在是哪個作業系統」可以問,所以寫成:先試 rundll32,起不來就是 Mac,換 open。
TODO 項目點了要做什麼
可以做的有兩種:直接把 - [ ] 改成 - [x],或把對應指令填進輸入框。
勾掉看起來比較爽,但 session 在 worktree 裡,勾掉的是這條分支的 TODO.md,要等合回 main,Obsidian 才看得到。我選了填輸入框:點「note-explore:為何而寫 → ideas/raw/為何而寫-explore.md」,輸入框就出現 /note-explore ideas/raw/為何而寫-explore.md,按 Enter 才開工,決定權還在我手上。
wiki-build 的項目常常只寫了 ideas/wiki/ 這種目錄,填進去也沒用,所以這種只填指令、topic 留給我補。
「框有點醜」
第一版每個項目都是預設 Button,終端機上畫成 [ 文字 ],一整面方括號。我的感想就一句:「目前這個框有點醜」。
改法是 Button 加 plain,變回純文字,focus 時才反白;→ 路徑 收進一個 display="none" 的 Box,滑鼠滑過才出現。
輸入框標色
這是我看 mod 能做什麼時最意外的一項:輸入框本身也能畫。我打 [[頁名]] 時,頁面存在就變綠,不存在就畫紅色底線——斷鏈在打字的當下就看得到,不用等 /vault-maintain 巡檢。
再往下:mod 的 hook,旁觀、改寫、接手
介面做出來之後,我才搞懂「常駐」的意思:mod 整個 session 都載著,但不是一直在跑,只有它掛的事件發生時才被呼叫。畫面也是一種事件(ui.render),所以 mod 的本體其實是 hook,介面只是其中幾個事件的結果。
同一個事件的所有 hook 串成 middleware 鏈,每一個都拿到 next,能做的事只有三種,navi 正好各用到一種:
| 行為 | 寫法 | navi 裡的例子 |
|---|---|---|
| 旁觀 | await next(e) 之後再做事 | tool.call:Claude 每次用工具都叫一次,跑完看有沒有碰到 TODO.md,有就刷新 |
| 改寫 | 改 next 回來的結果 | prompt.edit:我每按一個鍵叫一次,在結果上加顏色 |
| 接手 | 不呼叫 next,直接回結果 | /navi:直接開面板,不經過 Claude、不開 turn |
它排在我的守門 hook 前面
這是導入前最需要停下來看的一段。mod 以使用者權限執行:讀寫任意檔案、啟動程式、連網、看到每個 prompt 和 tool call、改寫它們,還能在你被詢問前就核准 tool call。它不是 sandbox。
更直接的是執行順序:組織 prepend → 使用者的 mod → 組織 append → 內建 mod,而非 managed 的 PreToolUse settings hook 在最後一個 mod 的 next 之後才跑。vault 的 write_guard 正是非 managed 的 PreToolUse hook。所以只要某個 mod 在 tool.call 直接接手、不呼叫 next,Day18 那套守門就根本不會被執行。
這條決定了三件事:
- navi 的
tool.call一定呼叫next,並把結果原樣回傳,write_guard 照常在後面跑 - 裝別人的 mod 前先
claude plugin validate,看它掛了哪些事件、呼叫了哪些 API,特別是不呼叫next的tool.call - 守門要改寫成 mod 時,settings hook 先別拔。兩版並行一段時間再決定;而且 mod 不是每個環境都會載入:
| 在哪裡 | hooks 會跑 | 介面畫得出來 |
|---|---|---|
| 終端機、Desktop Code 分頁 | ✓ | ✓ |
| Desktop 的 WSL session | ✗ | ✗ |
VS Code、claude -p、Agent SDK | ✓ | ✗ |
| 雲端 session | 要能帶進雲端 | ✗ |
如果守門只剩 mod 版,雲端 session 可能根本沒載到它,等於沒有護欄。
mod 放哪:只有這個 session、本機,還是跟著 vault
寫 mod 的預設位置是 ~/.claude/dev-mods/<session-id>/,只在當下這個 session 有效。要長期用有三條路:
| 放法 | 代價 |
|---|---|
--plugin-dir / 環境變數指到本機資料夾 | 兩台各設一次,改了要手動同步 |
~/.claude/ 使用者層 | 不進 git,Mac 拿不到 |
vault 的 .claude/skills/navi/ | 只在 vault 裡有效 |
我選了第三個,原話是「搬進 vault 的 .claude/ 讓兩台共用」。理由在 Day18 已經鋪好了:.claude/ 本來就由 git 同步,skill 和 hook 兩台是同一份,Claude Code 也會自動載入專案 .claude/skills/<name>/ 底下的 mod。
「只在 vault 裡有效」反而是對的:這個 mod 讀的是 TODO.md 和 */wiki/,在別的專案開只會顯示「找不到 TODO.md」。
代價是 vault 的維護規則:/vault-maintain 會比對 .claude/skills/ 每個資料夾在 CLAUDE.md 的 skill 表裡有沒有一列。mod 不是 SKILL.md 型的 skill,但住在同一個資料夾,所以表裡補了一列(現在是 /navi),註明「mod,非 SKILL.md 型」。
實作
整個 mod 是三個檔:
{"name":"navi","version":"0.2.0","description":"Hey! Listen! — llm-wiki vault 的夥伴:/navi 面板、今日三頁、狀態列、[[連結]] 標色","types":"./types/index.d.ts"}
{ "modules": ["./register.tsx"] }
第三個是 hooks/register.tsx,最後長到 507 行。下面挑跟取捨對得上的幾段。
狀態放在 host,不放在模組變數
const sections = atom({ plugin: 'navi', key: 'sections' } as const, [])
const recent = atom({ plugin: 'navi', key: 'recent' } as const, [])
const domains = atom({ plugin: 'navi', key: 'domains' } as const, [])
const picks = atom({ plugin: 'navi', key: 'picks' } as const, [])
const names = atom({ plugin: 'navi', key: 'linkIndex' } as const, [])
const overview = atom({ plugin: 'navi', key: 'overview' } as const, {
todo: 0,
inbox: 0,
week: 0,
})
熱重載時模組會整個重跑,模組裡的變數歸零;atom 的值存在 Claude Code 那邊,重載後還在。畫面用 read($, atom) 讀、別處用 update($, atom, fn) 寫,寫了就自動重畫讀它的那幾塊,不用自己呼叫 redraw。
「重載後還在」也有反面:今日一頁原本存成單一物件、沒有時是 null,改成三頁的陣列後,舊的 null 還留在 state 裡。後來換成新的 key picks,乾脆不跟舊值打交道。
兩個 refresh:輕的 30 秒、重的 30 分鐘
async function refreshStats($: $) {
// 每一步各自 try:一步失敗(例如讀不到指令清單)不拖垮其他統計,並用 toast 說出原因
let pages: WikiPage[] = []
try {
const scanned = await scanDomains($)
pages = scanned.pages
await update($, domains, () => scanned.stats)
} catch (err) {
$.ui.toast(`navi:各域統計失敗 ${String(err).slice(0, 80)}`)
}
try {
const hubList = await scanHubs($)
await update($, hubs, () => hubList)
} catch (err) {
$.ui.toast(`navi:HUB 統計失敗 ${String(err).slice(0, 80)}`)
}
try {
const all = await collectNames($)
await update($, names, () => all)
try {
const fx = await scanFacts($, all, await $.clock.now())
await update($, facts, () => fx)
} catch (err) {
$.ui.toast(`navi:趣味統計失敗 ${String(err).slice(0, 80)}`)
}
} catch (err) {
$.ui.toast(`navi:頁名清單失敗 ${String(err).slice(0, 80)}`)
}
try {
const cmds = (await $.command.list()).map(c => c.name.replace(/^\//, ''))
await update($, commands, () => cmds)
} catch (err) {
$.ui.toast(`navi:指令清單失敗 ${String(err).slice(0, 80)}`)
}
const current = (await read($, picks)) ?? []
if (current.length === 0 && pages.length > 0) {
const id = await $.session.id()
await update($, picks, () => pickFor(pages, id, 3))
}
await refresh($)
}
$.clock.every(30_000, () => void refresh($))
$.clock.every(1_800_000, () => void refreshStats($))
第一版只有一個 refresh,每 30 秒連統計一起算。統計要讀全部 wiki 頁(下面「踩到的坑」第 4 點),後來拆開,先 10 分鐘、最後放寬到 30 分鐘。每一步各自包 try:有一次某一步出錯,後面的今日三頁、狀態列全都沒更新,現在一步失敗只跳 toast 說原因,其他照跑。狀態列需要「幾個域堆積」,輕量那條就直接讀上一次統計留在 state 裡的結果。
輕量 refresh 另外在 Claude 的工具呼叫碰到 TODO.md 或 /wiki/ 時跑:
on('tool.call', async ($, e, next) => {
const ran = await next(e)
const s = JSON.stringify(e)
if (s.includes(FILE) || s.includes('/wiki/')) await refresh($)
return ran
})
await next(e) 先讓工具真的跑完,再刷新。這個 hook 不擋任何東西,只是旁觀;也因為一定呼叫 next,排在後面的 write_guard 照常會跑。如果哪天把 await next(e) 拿掉、直接回一個結果,守門就被繞過了。
本週新增:git log 加日期比對
const log = await $.process.run([
'git', '-c', 'core.quotepath=false', 'log', '--diff-filter=A',
'--name-only', '--format=@%ad', '--date=short', '-n', '200',
'--', '*/wiki/*.md',
])
const added = parseLog(log.stdout)
const monday = mondayOf(now)
week = added.filter(p => p.date >= monday).length
core.quotepath=false 是因為 vault 大部分檔名是中文,不加的話 git 會吐 "ideas/wiki/\346\210\221..." 這種八進位跳脫。--format=@%ad 讓每個 commit 以 @日期 開頭一行,後面接檔名,parseLog 照這個格式拆。
今日三頁:每個 session 一組
function pickFor(pages: WikiPage[], key: string, n: number): WikiPage[] {
const pool = [...pages]
const out: WikiPage[] = []
let seed = 2166136261
for (let i = 0; i < key.length; i++) seed = Math.imul(seed ^ key.charCodeAt(i), 16777619) >>> 0
while (out.length < n && pool.length > 0) {
out.push(pool.splice(seed % pool.length, 1)[0])
seed = (seed * 1103515245 + 12345) >>> 0
}
return out
}
種子是 session id 的 FNV-1a 雜湊:同一個 session 怎麼重算都是同一組,換一個 session 就換一組。而且只在 picks 是空的時候才抽,30 分鐘一輪的統計不會把它換掉,要換就 /navi reroll 清空重抽。挑過的從 pool 拿掉,三頁不會重複。
演變是:一頁、以日期為種子 → 三頁、以日期為種子 → 三頁、以 session 為種子。改成三頁的理由在心得。
輸入框裡的 [[連結]] 標色
on('prompt.edit', async ($, e, next) => {
const r = await next(e)
// ...
return { ...r, decorations: [...(r.decorations ?? []), ...mine] }
})
prompt.edit 在每次按鍵時觸發。先 await next(e) 讓編輯照常套用,再在結果上加 decorations:一組「第幾個字到第幾個字怎麼畫」,只影響顯示,不改我打的字。保留 r.decorations 是因為別的 mod 也可能在畫。
第一版只分兩色:找得到就綠、找不到就紅。實際用起來,我在輸入框打 [[cfgc-cupping-form]],它真的變綠了——這是整個 mod 裡我覺得最實用的一個,斷鏈以前要等 /vault-maintain 巡檢才抓得到,現在打字的當下就知道。之後擴充成依「連到哪一層」上色,順序由上往下、符合就停:
function linkDecorations(text: string, entries: string[]): Deco[] {
const where = new Map<string, string[]>()
for (const e of entries) {
const [loc, name] = e.split('\t')
where.set(name, [...(where.get(name) ?? []), loc])
}
const out: Deco[] = []
for (const m of text.matchAll(/\[\[([^\[\]]+?)\]\]/g)) {
const link = m[1].split('|')[0].split('#')[0].trim()
const parts = link.split('/')
const base = parts.pop()!.trim()
const dir = parts.join('/')
if (!base) continue
const start = m.index!
const end = start + m[0].length
const locs = (where.get(base) ?? []).filter(l => !dir || l === dir || l.endsWith(`/${dir}`))
if (locs.length === 0) out.push({ start, end, color: 'red', underline: true })
else if (!dir && locs.includes('books/wiki') && locs.includes('books/raw')) out.push({ start, end, color: 'yellow' })
else if (locs.some(l => l.endsWith('/wiki'))) out.push({ start, end, color: 'green' })
else if (locs.includes('hubs')) out.push({ start, end, color: 'cyan', bold: true })
else if (locs.some(l => l.endsWith('/raw') || l.endsWith('/logs'))) out.push({ start, end, dimColor: true })
else out.push({ start, end, color: 'green' })
}
return out
}
entries 是 vault 每個 .md 記成「所在資料夾+檔名」。Obsidian 靠 basename 解析連結,所以同一個名字可能同時在 books/raw 和 books/wiki——這正是 CLAUDE.md 要求連書必須寫路徑的原因,打裸的 [[書名]] 就標黃提醒。後來又加了兩種:vault 路徑不存在畫紅底線、開頭的指令拼錯畫刪除線(指令清單來自 $.command.list())。面板的「說明」分頁用實際顏色畫了一份圖例。
TODO 項目 → skill 指令
const COMMANDS: [RegExp, string][] = [
[/^enrich/, '/note-enrich'],
[/^note-explore/, '/note-explore'],
[/^wiki-build/, '/wiki-build'],
]
function fillFor(item: Item): string {
const cmd = COMMANDS.find(([re]) => re.test(item.summary))?.[1]
// wiki-build 的路徑常是目錄(`ideas/wiki/`)或缺漏,交給人補 topic
if (cmd && item.path && !item.path.endsWith('/')) return `${cmd} ${item.path}`
if (cmd) return `${cmd} `
return item.path ?? item.summary
}
能這樣對應,是因為 /inbox-triage 寫進 TODO.md 的格式是固定的:- [ ] {動作}:{摘要} → \{路徑}``。triage 當初定下這個格式是為了人讀,現在剛好也讓程式讀得懂。
畫面那邊:
<Button
plain
label={`· ${item.summary}`}
onPress={() => void $.prompt.fill({ text: fillFor(item) })}
/>
{item.path && (
<Box display="none" hover={{ display: 'flex' }} paddingLeft={2}>
<Text dimColor wrap="truncate-end">
→ {item.path}
</Text>
</Box>
)}
在 Obsidian 開
async function openInObsidian($: $, p: WikiPage) {
const file = encodeURIComponent(`${p.domain}/wiki/${p.name}`)
const uri = `obsidian://open?vault=llm-wiki&file=${file}`
try {
await $.process.run(['rundll32', 'url.dll,FileProtocolHandler', uri])
} catch {
try {
await $.process.run(['open', uri])
} catch {
$.ui.toast(`無法開啟 Obsidian:${p.name}`)
return
}
}
$.ui.toast(`Obsidian 開啟:${p.name}`)
}
vault=llm-wiki 直接寫死:Obsidian 的 vault 名稱就是資料夾名,兩台都叫 llm-wiki。
踩到的坑
1. claude plugin validate 說 hooks.json 格式不對
第一版寫完驗證:
Validating hooks: ...\vault-todo\hooks\hooks.json
✘ Found 1 error:
❯ hooks: Invalid input: expected record, received undefined
hooks.json 是照文件寫的 { "modules": ["./register.tsx"] }。查了一下版本:PATH 上的 claude 是 2.1.154,而跑這個 session 的引擎是 2.1.286。舊版 CLI 只認得舊式 command hooks 的 { "hooks": {...} },根本不知道 modules 是什麼。
winget upgrade --id Anthropic.ClaudeCode 升到 2.1.286 後,這個錯消失了——然後露出底下兩個真的錯。
2. 正則被跳脫壞掉,整個檔案 parse 不了
register.tsx does not parse: Syntax Error (line 33, column 33)
第 33 行是 parseLog 的 text.split(/\r?\n/)。加「近期新增 wiki」那次,是用 bash heredoc 包 Python 去改檔的,\\r?\\n 經過兩層跳脫,落地變成真的 CR 和 LF 字元:
0000040 / \r ? \n / ) ) { \n
也就是 /、CR、?、換行、/,正則被拆成兩行。這段在舊版 CLI 驗證時被第一個錯蓋住,所以那時其實整個 mod 都載不起來。
接著試了好幾次 sed -i、Python、Node 去修那一行,檔案修改時間都沒動,原因到最後沒查清楚。最後改用 Write 整檔重寫,才過。
3. $ 不能傳給 register 裡的區域函式
compiled line 74 `await refresh($);`: $ is passed to "refresh", which is not a function
declared at the top of this file (a function declaration, or a const bound to one)
我原本把 refresh 寫成 register 裡面的 const refresh = async ($) => {...}。驗證器會靜態追蹤 $ 被傳去哪、呼叫了哪些 API(輸出裡會列出 $.fs.read (via refresh) 這種東西),所以只接受檔案最上層宣告的函式。把 refresh 搬到最外層就過了。之後的 openInObsidian、scanDomains 也都照這個規矩寫在最上層。
4. raw 堆積的第一版:七個域裡五個亮黃燈
CLAUDE.md 有一條「某域 raw/ 同主題聚集 ≥ 5 檔且無對應 wiki → 提議 /wiki-build」。第一版直接翻成程式:「raw 檔名沒有同名 wiki 頁」就算未消化。跑出來:
art wiki=14 orphanRaw=4
books wiki=47 orphanRaw=233
coffee wiki=16 orphanRaw=95
engineer wiki=34 orphanRaw=80
finance wiki=1 orphanRaw=1
ideas wiki=93 orphanRaw=28
others wiki=2 orphanRaw=15
books 233、coffee 95,等於全部亮燈,沒有資訊量。原因是 raw 和 wiki 本來就不是一對一:books/raw 是讀書筆記的沉澱,很多書不會另外寫心得頁;一頁 wiki 常常是好幾份 raw 合成的,檔名不會一樣。
改成「raw 的檔名有沒有出現在該域任何 wiki 頁的內文裡」(wiki-build 產出的頁面 sources 會連回 raw),並排除 books:
async function countUndigested($: $, domain: string, wiki: string[]): Promise<number> {
if (domain === 'books') return 0
const raw = await mdNames($, `${domain}/raw`)
if (raw.length === 0) return 0
let corpus = ''
for (const name of wiki) {
try {
corpus += await $.fs.read(`${domain}/wiki/${name}.md`)
} catch {}
}
return raw.filter(n => !wiki.includes(n) && !corpus.includes(n)).length
}
art unreferenced=1 of 7
coffee unreferenced=21 of 95
engineer unreferenced=34 of 83
finance unreferenced=1 of 1
ideas unreferenced=12 of 31
others unreferenced=15 of 15
還是有四個域亮燈,但這次的數字是真的:engineer 有 34 份 raw 從沒被任何 wiki 提過。這不是「同主題聚集」——判斷同主題要語意,mod 裡做不到——比較接近「還沒消化的量」。
5. h is not a function:變數名稱蓋掉 JSX
加了 HUB 表之後,打 /navi 整個面板壞掉:
ui.render hook skipped: threw HooksError: h is not a function. (In 'h(Box, {
mod 裡寫的 <Box> 會編譯成全域函式 h(Box, …)。我寫了 hubList.map(h => <Box>…),迴圈參數 h 在那段裡把全域的 h 蓋掉,h(Box, …) 變成拿一筆 hub 資料當函式呼叫。驗證器只看語法與 API 用法,抓不到;我自己的測試用空資料畫面板,hub 清單是空的、map 根本沒執行,也抓不到。參數改名 hub 就好了。
6. 表格跑版
統計表第一版每格給固定 width,我看了說「表格有點跑版」:中文與 emoji 在 Desktop 和終端機的顯示寬度不一,欄位就歪掉。改成以「欄」為單位排——每一欄是一個直的 Box,同欄的格子自然靠齊,欄與欄之間再放一欄 │;按鈕的高度跟文字不一定一樣,所以 wiki-build 按鈕移到表格下方,不放進表格裡。
小結
今天在 Claude Code 裡長出一塊 vault 儀表板:
- 常駐:橫條(今日三頁)、狀態列、輸入框
[[連結]]標色 /navi側邊面板:TODO(分區配色,點了填 skill 指令)、近期新增 wiki(點了在 Obsidian 開)、各域 wiki 數與 raw 堆積
面板後來長成五個分頁,每一塊要我關注什麼、看到畫面之後又改了哪些,留到 Day27。
改動落在 3 個檔,register.tsx 長到 1131 行,放在 vault 的 .claude/skills/navi/,兩台共用。
還沒做完的事:Mac 那條 open 還沒在 Mac 上跑過,Mac 的 claude CLI 也要先升版。
儀表板之外,mod 還能攔截工具呼叫、改寫送出的 prompt、給 Claude 加一個工具。Day18 那套在主目錄擋寫入的 hook,現在是 sh、PowerShell、Python 三種版本各一份;改寫成 mod 就能兩台共用一份。這些我都想陸續導入,先在 TODO.md 開了一個 ## Mods 區,下一步從守門 hook 開始。
心得
mod 非常適合用在 llm-wiki:把整個 vault 的狀態攤在 session 旁邊,很實用。
最直接的收穫是明確知道自己缺什麼。看到儀表板,才知道還有很多 llm-wiki 的事情沒做——engineer 34 份 raw 從沒被任何 wiki 提過,這個數字平常是看不到的。
每日抽的頁也比預期好用:它會隨機把舊的 wiki 挖出來,看到某一頁時常常又能接上新的想法。這也是後來把一頁改成三頁的原因。
至於做法,這次幾乎都是「先問 Claude 有哪些可以選,再挑」。原因很單純:我對 mod 這個新技術完全不熟,連它能畫在哪些位置都不知道,所以想透過 AI 先把可能性發散出來,我再決定要哪些。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day26-vault-mod/
