手動核對三個交稿路徑;存在不代表內容合格。尚待 Claude Code 原生驗證的教學原始碼。

狀態:2026-10-09,站主已在「交稿檢查 v2」面板完成 缺件 → 補件 → 按鈕刷新:起初兩份存在、封面需求缺少;補入封面需求後重新檢查,三份存在且 UTC 時間更新。這修正了前版缺檔卻顯示無法檢查的問題。作者與獨立普通 Node 檢查各 10/10 通過;操作錄影及其餘原生案例仍未完成。詳見 驗證紀錄。
這個範例將一套固定交付清單放進面板。它只在使用者開啟或按「重新檢查」時,查詢明確指定資料夾與以下三個路徑的狀態:
| 檔名 | 用途 |
|---|---|
article.md | 文章 |
sources.md | 資料依據 |
cover-brief.md | 封面需求 |
前版依賴子路徑錯誤的 code,站主實測發現缺檔無法辨識。修正版不解析錯誤文字,改依成功的資料夾清單判斷三個檔名;清單讀取失敗或任何項目格式無效,三項全部顯示無法檢查。只有大小寫不同也顯示無法檢查,因為路徑拼法不能代表檔案系統的大小寫規則。清單包含符號連結或同名資料夾時,只表示路徑項目存在,不能推論內容或連結目標可讀。
.claude-plugin/plugin.json:套件名稱、版本及用途。hooks/hooks.json:指向唯一入口。hooks/register.mjs:官方 API 的接線,包含指令、手動狀態查詢及面板。hooks/delivery-logic.mjs:純資料函式,解析路徑、組合三個目標、區分結果、格式化時間。沒有主機 API 或檔案存取。API-EVIDENCE.md:官方來源、選用方法與尚未驗證的部分。普通 Node 測試可以檢查純資料函式;這不是 Claude Code 的 plugin validate、plugin test,也不證明面板、回呼、錯誤傳遞或真實 Mod 已工作。入口程式不匯入 Node、第三方套件或外部檔案。
完整起始內容與教學提示位於 ../../teaching-rewrite.md。目錄安排如下;複製整個 Mod 目錄,不只複製入口檔。
mods-lesson/
delivery-practice/ 起初只有 article.md 和 sources.md
delivery-check/ 這份完整程式目錄
先複製 delivery-practice 的絕對路徑。這個候選版本接受本機 Windows 磁碟路徑或 POSIX 絕對路徑;反斜線不做 shell 跳脫。相對路徑、~ 展開、UNC 網路位置與 Windows 裝置路徑不在範例範圍。路徑必須對執行 Claude Code 的電腦有效。
前版已由站主載入;以下指令用於修正版的重新驗證。 先前代理的 CLI 呼叫遭自動審查拒絕,站主手動驗證不代表代理的執行限制已解除。需在適用且允許的環境確認版本、原始碼及能力,再於 mods-lesson 單次載入:
claude plugin validate ./delivery-check
claude --plugin-dir ./delivery-check
在該互動對話輸入下列指令,把欄位換成真正路徑並保留雙引號:
/deliverables "<delivery-practice 的完整路徑>"
第一次未指定路徑,面板會要求提供,不猜預設位置。重新執行無參數的 /deliverables,會再查本次記住的資料夾;明確提供新路徑則換成新資料夾。記憶僅在目前模組生命週期,不保存到磁碟;重載或結束對話後需要重新指定。
面板位置由 Claude Code 決定,不保證固定右側。按「重新檢查」才更新狀態和 UTC 時間;重新繪圖本身不查檔,也沒有背景定時器。時間取得失敗會明說,不自行生成時間。關閉用面板按鈕或 Esc;關閉面板不等於卸載。結束這個單次載入的對話,之後不帶 --plugin-dir 啟動,才是不再以這種方式載入。
fs.list 成功回傳後能正確找出缺件;無效或失敗清單不可當空清單。呼叫 $.command.register、$.fs.stat、$.fs.list、$.clock.now 與 $.ui.open/close/resolve/invalidate。先檢查使用者指定的資料夾是否可查且為資料夾,再讀取它的單層項目清單,核對三個固定檔名;不讀檔案內容、不遞迴、不補檔、不寫檔。
程式沒有模型、提示提交、網路、程序、環境變數或持久儲存呼叫,也不修改或攔截其他工具。這只描述本程式;不表示 Claude Code 整個對話離線、免費或受同一層沙盒保護。
hooks/register.mjs 159 lines1import {
2 TARGETS,
3 parseDirectoryArg,
4 targetPath,
5 classifyListedTarget,
6 classifyStatError,
7 formatCheckedAt,
8} from './delivery-logic.mjs';
9
10const PANE = 'delivery-check';
11let generation = 0;
12let state = initialState();
13
14function initialState() {
15 return { directory: null, rows: [], checkedAt: null, checking: false, notice: '' };
16}
17
18// Keep $ calls in this file and spell them in full for Claude's static analysis.
19// The imported helpers receive only data, never $ or one of its namespaces.
20async function refresh($) {
21 const directory = state.directory;
22 if (!directory) return;
23 const request = ++generation;
24 state = { directory, rows: [], checkedAt: null, checking: true, notice: '' };
25 $.ui.invalidate('ui.render');
26
27 try {
28 const folder = await $.fs.stat(directory);
29 if (request !== generation) return;
30 if (folder?.kind !== 'dir') {
31 state = { ...state, checking: false, notice: '指定路徑不是資料夾;請重新指定。' };
32 $.ui.invalidate('ui.render');
33 return;
34 }
35 } catch (error) {
36 if (request !== generation) return;
37 const result = classifyStatError(error);
38 state = {
39 ...state,
40 checking: false,
41 notice: result.status === 'missing'
42 ? '找不到指定資料夾;請核對完整路徑。尚未檢查三個交稿路徑。'
43 : '資料夾無法檢查。' + result.detail,
44 };
45 $.ui.invalidate('ui.render');
46 return;
47 }
48
49 let rows;
50 try {
51 // A successful directory listing establishes missing names without
52 // depending on whether the host preserves errno fields across the bridge.
53 const entries = await $.fs.list(directory);
54 rows = TARGETS.map((target) => ({
55 ...target,
56 path: targetPath(directory, target.name),
57 ...classifyListedTarget(entries, directory, target.name),
58 }));
59 } catch {
60 // In particular, a denied/failed listing is not an empty directory.
61 rows = TARGETS.map((target) => ({
62 ...target,
63 path: targetPath(directory, target.name),
64 status: 'unknown',
65 label: '無法檢查',
66 detail: '無法讀取資料夾清單;請核對資料夾、存取權限及版本。',
67 }));
68 }
69 if (request !== generation) return;
70
71 let checkedAt = null;
72 try {
73 checkedAt = formatCheckedAt(await $.clock.now());
74 } catch {
75 // Keep the check results, but never invent a completion timestamp.
76 }
77 if (request !== generation) return;
78 state = {
79 directory,
80 rows,
81 checkedAt,
82 checking: false,
83 notice: checkedAt === null ? '未取得檢查時間;以上結果不可當成即時監看。' : '',
84 };
85 $.ui.invalidate('ui.render');
86}
87
88export function register(on) {
89 on('session.start', async ($, e, next) => {
90 generation += 1;
91 state = initialState();
92 await $.command.register({
93 name: 'deliverables',
94 description: '手動檢查文章、資料依據和封面需求的路徑',
95 argumentHint: '"資料夾完整路徑"',
96 });
97 return next(e);
98 });
99
100 on('session.end', async ($, e, next) => {
101 generation += 1;
102 state = initialState();
103 $.ui.invalidate('ui.render');
104 return next(e);
105 });
106
107 on('command.run', { command: 'deliverables' }, async ($, e) => {
108 const request = ++generation;
109 const parsed = parseDirectoryArg(e.args);
110 if (!parsed.ok) {
111 // Cancel pending work and clear old results so an invalid new target
112 // cannot leave a convincing-looking snapshot of the previous folder.
113 state = { ...initialState(), notice: parsed.error };
114 } else if (parsed.directory !== null) {
115 state = { ...initialState(), directory: parsed.directory };
116 } else if (!state.directory) {
117 state.notice = '請輸入 /deliverables "資料夾完整路徑",先指定要查哪裡。';
118 }
119
120 // The pinned declaration returns void. Do not infer visibility from its result.
121 await $.ui.open({ id: PANE, title: '交稿檢查', focus: true, closeOnEscape: true });
122 if (request !== generation) return {};
123 $.ui.invalidate('ui.render');
124 if (parsed.ok && state.directory) await refresh($);
125 // No text/context, prompt submission, model call, or fall-through command.
126 return {};
127 });
128
129 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
130 if (e.requestId !== PANE) return next(e);
131 const { Box, Text, Button } = $.ui.resolve(e);
132 const children = [
133 Text({ children: ['交稿檢查 v2'] }),
134 Text({ children: ['檢查資料夾:' + (state.directory ?? '尚未指定')] }),
135 Text({ children: ['上次檢查(UTC):' + (state.checkedAt ?? '尚未完成或未取得時間')] }),
136 ];
137 if (state.checking) children.push(Text({ children: ['正在檢查指定路徑……'] }));
138 if (state.notice) children.push(Text({ children: [state.notice] }));
139 for (const row of state.rows) {
140 children.push(Text({ children: [row.name + '(' + row.purpose + '):' + row.label] }));
141 if (row.status === 'unknown') children.push(Text({ children: [row.detail] }));
142 }
143 if (state.directory) {
144 children.push(Button({
145 key: 'refresh',
146 label: '重新檢查',
147 onPress: async () => { await refresh($); },
148 }));
149 }
150 children.push(Text({ children: ['路徑檢查不判斷內容品質;畫面只保留上次查詢結果。'] }));
151 children.push(Button({
152 key: 'close',
153 label: '關閉面板',
154 onPress: async () => { await $.ui.close({ id: PANE }); },
155 }));
156 return Box({ flexDirection: 'column', children });
157 });
158}
159hooks/delivery-logic.mjs 117 lines1// Pure data helpers: no Claude API, Node imports, file access, or side effects.
2export const TARGETS = Object.freeze([
3 Object.freeze({ name: 'article.md', purpose: '文章' }),
4 Object.freeze({ name: 'sources.md', purpose: '資料依據' }),
5 Object.freeze({ name: 'cover-brief.md', purpose: '封面需求' }),
6]);
7
8const CONTROL = /[\u0000-\u001f\u007f]/;
9const WINDOWS_ABSOLUTE = /^[A-Za-z]:[\\/]/;
10
11export function parseDirectoryArg(raw) {
12 if (typeof raw !== 'string') return invalid('請提供資料夾完整路徑。');
13 let directory = raw.trim();
14 if (!directory) return { ok: true, directory: null };
15
16 const quote = directory[0];
17 if (quote === '"' || quote === "'") {
18 if (directory.length < 2 || directory.at(-1) !== quote) {
19 return invalid('路徑的引號沒有成對;請重新複製完整路徑。');
20 }
21 // Command args are raw text, not shell syntax: preserve every backslash.
22 directory = directory.slice(1, -1);
23 if (directory.includes(quote)) return invalid('請只提供一個完整路徑。');
24 }
25 return validateDirectory(directory);
26}
27
28function validateDirectory(directory) {
29 if (!directory || CONTROL.test(directory) || /[<>]/.test(directory)) {
30 return invalid('請把提示欄位換成實際路徑,且不要包含換行或控制字元。');
31 }
32 if (/^[\\/]{2}/.test(directory)) {
33 return invalid('這個範例只接受本機完整路徑,不接受網路或裝置路徑。');
34 }
35 if (!WINDOWS_ABSOLUTE.test(directory) && !directory.startsWith('/')) {
36 return invalid('需要完整路徑,例如 C:\\練習資料夾 或 /Users/you/delivery-practice。');
37 }
38 if (directory.includes('"')) return invalid('請只提供一個完整路徑,並檢查引號。');
39 return { ok: true, directory };
40}
41
42function invalid(error) {
43 return { ok: false, directory: null, error };
44}
45
46export function targetPath(directory, name) {
47 if (typeof name !== 'string' || !name || name === '.' || name === '..' ||
48 /[\\/]/.test(name) || CONTROL.test(name)) {
49 throw new Error('Target must be one file name.');
50 }
51 const parsed = typeof directory === 'string' ? validateDirectory(directory) : invalid('Invalid path.');
52 if (!parsed.ok || parsed.directory === null || parsed.directory !== directory) {
53 throw new Error('Target directory must be an explicit absolute path.');
54 }
55 if (WINDOWS_ABSOLUTE.test(directory)) {
56 const separator = directory.includes('\\') ? '\\' : '/';
57 return directory.replace(/[\\/]+$/, '') + separator + name;
58 }
59 // Backslashes are ordinary POSIX filename characters, not separators.
60 return directory.replace(/\/+$/, '') + '/' + name;
61}
62
63export function classifyStat(stat) {
64 if (!stat || !['file', 'dir', 'other'].includes(stat.kind)) {
65 return unknown('狀態回傳格式無法辨識;請核對目前版本。');
66 }
67 return {
68 status: 'exists',
69 label: '路徑存在,內容待審閱',
70 detail: '本次只核對路徑,不判斷內容、品質或是否為一般檔案。',
71 };
72}
73
74export function classifyListedTarget(entries, directory, name) {
75 // Validate the whole successful listing before treating absence as evidence.
76 // A rejected call must never be replaced with an empty array by the caller.
77 if (!Array.isArray(entries) || entries.some((entry) =>
78 !entry || typeof entry.name !== 'string' || !entry.name ||
79 entry.name === '.' || entry.name === '..' || /[\/\u0000]/.test(entry.name) ||
80 !['file', 'dir', 'other'].includes(entry.kind))) {
81 return unknown('資料夾清單格式無法辨識;請核對目前版本。');
82 }
83 targetPath(directory, name);
84 const entry = entries.find((item) => item.name === name);
85 if (entry) return classifyStat(entry);
86 // Path spelling alone cannot tell us whether this filesystem folds case.
87 // Neither claim missing nor imply the requested spelling works in this case.
88 if (entries.some((item) => item.name.toLowerCase() === name.toLowerCase())) {
89 return unknown('找到大小寫不同的檔名;請核對約定檔名。');
90 }
91 return { status: 'missing', label: '缺少', detail: '本次成功讀取的資料夾清單中沒有這個檔名。' };
92}
93
94export function classifyStatError(error) {
95 // Folder diagnostics only. Child absence comes from a successful listing.
96 // Never guess codes from messages: a permission error can mention ENOENT.md.
97 const code = error && typeof error.code === 'string' ? error.code : null;
98 if (code === 'ENOENT') {
99 return { status: 'missing', label: '缺少', detail: '找不到這個指定路徑。' };
100 }
101 if (code === 'EACCES' || code === 'EPERM') {
102 return unknown('存取遭拒;請核對資料夾與存取權限。');
103 }
104 if (code === 'ENOTDIR') return unknown('路徑中有一段不是資料夾;請核對完整路徑。');
105 return unknown('無法取得可判讀的狀態;請核對資料夾、存取權限及版本。');
106}
107
108function unknown(detail) {
109 return { status: 'unknown', label: '無法檢查', detail };
110}
111
112export function formatCheckedAt(milliseconds) {
113 if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)) return null;
114 const date = new Date(milliseconds);
115 return Number.isNaN(date.getTime()) ? null : date.toISOString();
116}
117