SLOPSHOPPER

delivery-check

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

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delivery-check
│ ┃ 交稿檢查 ✕ › fix the failing auth test and add an audit log call │ ┃ 交稿檢查 v2 │ ┃ 檢查資料夾:尚未指定 ⏺ Read(src/auth.ts) │ ┃ 上次檢查(UTC):尚未完成或未取得時間 ⎿ Read 6 lines │ ┃ 請輸入 /deliverables ⏺ Update(src/auth.ts) │ ┃ "資料夾完整路徑",先指定要查哪裡。 ⎿ Added 2 lines, removed 1 line │ ┃ 路徑檢查不判斷內容品質;畫面只保留上次查詢結 ⏺ Bash(bun test) │ ┃ 果。 ⎿ 3 pass, 1 fail │ ┃ [ 關閉面板 ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /deliverables │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 交稿檢查
交稿檢查 v2 檢查資料夾:尚未指定 上次檢查(UTC):尚未完成或未取得時間 請輸入 /deliverables "資料夾完整路徑",先指定要查哪裡。 路徑檢查不判斷內容品質;畫面只保留上次查詢結果。 [ 關閉面板 ]
README

交稿檢查 Mod

第一次跟做請從 Windows 操作教材 開始;那裡有資料夾準備、版本與載入指令、補件、刷新及排錯步驟。本目錄是影片使用的完整範例程式,複製時請保留 .claude-plugin 和 hooks 兩個子目錄。

這個範例做什麼

在 Claude Code 的互動對話輸入 /deliverables "資料夾完整路徑",會開啟交稿檢查面板。它先確認指定位置是可檢查的資料夾,再讀取該目錄的一層檔名清單,對照三個固定名稱:

檔名中文用途
article.md文章
sources.md資料依據
cover-brief.md封面需求

只有開啟指令或按「重新檢查」才查詢,畫面顯示上次查詢結果與 UTC 時間;不持續監看資料夾。改完檔案後,要回面板重新檢查。

結果怎麼讀

  • 路徑存在,內容待審閱: 找到完全相同的名稱;不表示內容正確,也未確認是否為合適的一般檔案。同名資料夾或其他類型的項目也可能存在。
  • 缺少: 成功讀取且格式有效的目錄清單裡,沒有約定名稱,也沒有僅大小寫不同的候選。
  • 無法檢查: 包含不能存取、回傳清單格式不明或僅大小寫不同等情況。先按顯示原因處理,不能當成缺件直接補檔。

例如 cover_brief.md 和 cover-brief.md 不同;空白的 cover-brief.md 即使存在,也沒有可交給設計師的需求。這些情況都需要回到實際檔案核對。

使用範圍

  • 只接受本機完整路徑。相對路徑、網路共享路徑及 Windows 裝置路徑不在本例範圍。
  • 只記住目前模組生命週期內選定的目錄;重新載入或結束對話後要再次指定。
  • 不遞迴、不讀文章內容、不補建或修改檔案。
  • 模組本身不呼叫模型、不連網、不啟動其他程序;這不代表整個 Claude Code 對話免費或離線。
  • 面板位置由 Claude Code 決定,不保證在固定的一側。
  • 「關閉面板」或 Esc 只收起畫面;停止這次單次載入,請依 操作教材 結束該次對話,下次正常啟動時不帶 --plugin-dir。

本例使用 Mods,官方建立指南的門檻為 Claude Code 2.1.287 以上;主案例使用 2.1.293。文件確認日期為 2026-10-09。

程式檔案分工

路徑用途
.claude-plugin/plugin.json套件名稱、版本與用途
hooks/hooks.json指向唯一程式入口
hooks/register.mjs註冊指令、讀取目錄、記錄時間、顯示面板與處理按鈕
hooks/delivery-logic.mjs路徑參數、檔名比對、結果及時間格式的純資料函式
API-EVIDENCE.md官方 API 依據、版本與查核範圍

目前已核對的範圍

主案例已有站主原生操作文字回報:起初文章與資料依據存在、封面需求缺少;補入提供的封面檔,再按重新檢查,三項改為存在且時間更新。影片使用這份結果的整理卡,沒有該次操作錄影。

空白、錯名、存取失敗、關閉及重新載入等其他案例仍需各自核對,不能由主案例推論全部通過。一般程式檢查也不能代替你的版本上的實際操作。

官方來源

Source 2 files
hooks/register.mjs 159 lines
1import {
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}
159
hooks/delivery-logic.mjs 117 lines
1// 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