用 /ship 依序跑專案的階段(檢查、測試、打包、部署),並在 prompt 上方的深色面板用橘色吉祥物顯示跑到哪一步

我為自己的開發流程寫的四個 Claude Code mod。Mod 是跑在 Claude Code 裡面的 TypeScript 事件處理函式,可以攔截工具呼叫、改寫 prompt,或在介面上畫出自己的區塊。
| Mod | 做什麼 | 指令 |
|---|---|---|
| ding-dong | 回合跑超過一分鐘才結束時,發一則系統通知叫你回來 | /ding 預覽通知 |
| ship-it | 依序執行專案的階段(檢查、測試、打包、部署),在 prompt 上方的深色面板用像素吉祥物顯示進度 | /ship、/ship demo、/ship init |
| secret-guard | 遮蔽 prompt 與工具輸出裡的金鑰,擋下讀寫 .env 與私鑰 | /guard、/guard on、/guard off |
| pii-mask | 把個資換成代號再送給模型,對照表只留在本機,之後可以還原 | /pii、/pii on、/pii add、/pii restore |
ding-dong 的系統通知只支援 macOS,其他平台會改用 Claude Code 內的 toast把這個 repo 加成 marketplace,再安裝想要的 mod:
claude plugin marketplace add builtbyjia/claude-code-mods
claude plugin install ship-it@builtbyjia-mods
只想試用一次的話,clone 下來後用 --plugin-dir 載入,只在那個 session 生效:
claude --plugin-dir ./ship-it
Mod 會用你的權限執行,可以讀寫檔案、執行指令。安裝任何人的 mod 之前(包括這裡的),建議先用
claude plugin validate <資料夾>看它掛了哪些事件、呼叫了哪些 API,並讀過原始碼。
回合依時長分成四種語氣(一到三分鐘、三到十分鐘、十分鐘以上、出錯),每種隨機抽一句文案,副標題顯示專案資料夾、耗時與工具次數。你自己中斷的回合不會通知。
通知預設由系統的 osascript 發出。想讓通知圖示變成橘色像素吉祥物,執行一次:
./ding-dong/notifier/build.sh
它會在本機編譯出 DingDong.app,之後通知就由這個小程式發出。文案與門檻秒數都在 ding-dong/hooks/register.ts 最上方。
/ship 會在 prompt 正上方畫出一塊深色的橫式面板,吉祥物沿著軌道走向正在執行的階段,每個階段顯示耗時;失敗時流程停下,並列出最後幾行錯誤輸出。面板右上角的「關」可以收起來,再輸入一次 /ship 就會重新出現。
「現在是哪個階段」不是偵測出來的:mod 自己依序執行每個階段的指令,正在跑的那個指令就是目前階段,指令結束時的結束碼是 0 才會往下一個走。吉祥物在兩個節點之間的位置只是依時間估算的動畫,不代表真實進度。
階段的來源依序是:
.claude/ship.jsonpackage.json 裡的 lint、test、build、deploy 這四個 script用 /ship init 可以產生設定檔,格式如下:
{
"stages": [
{ "name": "跑測試", "run": "npm test" },
{ "name": "打包", "run": "npm run build" },
{ "name": "部署", "run": "npm run deploy" }
]
}
run 會交給 bash -c 執行,所以只在你信任的專案裡使用 /ship。/ship demo 用 sleep 模擬五個階段,不會動到專案。
三層防護:
[已遮蔽:GitHub token] 這類標記。.env、私鑰、.npmrc 等檔案,以及 cat .env、printenv 這類指令;.env.example 這類範本檔放行。已知限制:
預設關閉。用 /pii on 開啟後,prompt 和工具輸出裡的個資會先換成 <PERSON_1>、<TW_MOBILE_1> 這類代號才送給模型;同一個原文永遠對應同一個代號。對照表存在本機,只會畫在 /pii 打開的面板上,不會進入對話。
模型產出的檔案裡如果有代號,用 /pii restore 檔案路徑 換回原文,結果另存成 .restored 檔,原檔不動。
能辨識的類型:
| 類型 | 辨識方式 |
|---|---|
| 手機、市話、Email | 格式比對 |
| 身分證、居留證、信用卡 | 格式比對加檢查碼 |
| 統一編號 | 前面寫明「統編」或「統一編號」的八位數字 |
| 地址 | 正式寫法(縣市、鄉鎮市區、路街) |
| 公司 | 以「有限公司」「企業社」「商行」「事務所」結尾 |
| 人名 | 「姓名:」「客戶:」「聯絡人:」這類欄位後面的名字,或用 /pii add 加進名單的詞 |
已知限制:
/pii 檢查對照表是否抓齊。每個 mod 是一個獨立的 plugin 資料夾:
<mod>/
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json
│ └── register.ts
└── tests/
檢查 Claude Code 會從 mod 讀到什麼:
claude plugin validate ./secret-guard
在 mod 的資料夾裡執行測試,不需要開 session:
claude plugin test
用 --plugin-dir 載入時,存檔就會自動重新載入。
通知圖示與 ship-it 裡的像素小生物是我自己畫的,靈感來自 Claude 的橘色吉祥物,不是 Anthropic 的官方素材。這個專案與 Anthropic 沒有從屬關係。
以 MIT 授權釋出,詳見 LICENSE。
hooks/register.ts 514 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3// 每個專案自己的階段設定檔,放在專案根目錄下
4const CONFIG_PATH = '.claude/ship.json'
5const TICK_MS = 250
6const STAGE_TIMEOUT_MS = 10 * 60 * 1000
7
8// 深色面板的配色。固定用色碼,不管終端機是淺色還是深色主題,看起來都一樣
9const PANEL = '#2B2F3B'
10const INK = '#E6E8EC'
11const MUTED = '#7D8494'
12const GREEN = '#4CC38A'
13const YELLOW = '#E5C07B'
14const RED = '#F47067'
15const CORAL = '#E8806A'
16
17type StageDef = { name: string; run: string }
18type Stage = StageDef & { status: 'waiting' | 'running' | 'done' | 'failed'; ms: number }
19type Run = {
20 stages: Stage[]
21 source: string
22 outcome: 'running' | 'done' | 'failed'
23 elapsedMs: number
24 stageMs: number
25 frame: number
26 tail: string[]
27}
28type Found = { stages: StageDef[]; source: string; problem?: string }
29type Span = { text: string; color?: string; bold?: boolean }
30
31// 吉祥物:上面兩列是頭和身體,腳有兩格動畫輪流換
32const SPRITE_BODY = ['▄█▀████▀█▄', '▀████████▀']
33const SPRITE_LEGS = [' █ █ █ █ ', ' ██ ██ ']
34const SPRITE_WIDTH = 10
35const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
36// 軌道左邊留白讓吉祥物能站在第一個節點正上方,右邊留給最後一個標籤
37const MARGIN = 4
38const LAST_LABEL = 12
39// 面板左右的內距,以及面板寬度的上下限
40const PAD = 2
41const MIN_WIDTH = 30
42const MAX_WIDTH = 110
43
44const DEMO: readonly StageDef[] = [
45 { name: '檢查程式碼', run: 'sleep 2' },
46 { name: '跑測試', run: 'sleep 4' },
47 { name: '打包', run: 'sleep 3' },
48 { name: '部署', run: 'sleep 5' },
49 { name: '健康檢查', run: 'sleep 1' },
50]
51
52// 沒有設定檔時,從 package.json 的 scripts 依這個順序找階段
53const NPM_SCRIPTS: readonly (readonly [script: string, name: string])[] = [
54 ['lint', '檢查程式碼'],
55 ['test', '跑測試'],
56 ['build', '打包'],
57 ['deploy', '部署'],
58]
59
60const HELP = [
61 '找不到可以跑的階段。三種方式擇一:',
62 '・/ship demo:先看示範動畫',
63 '・/ship init:在這個專案建立 .claude/ship.json,再編輯成你的階段',
64 '・在 package.json 的 scripts 加上 lint、test、build 或 deploy',
65].join('\n')
66
67let run: Run | null = null
68let ticker: Timer | null = null
69let lastArgs = ''
70// 按了「關」之後先收起來,下次 /ship 再出現
71let isHidden = false
72
73function isStageDef(value: unknown): value is StageDef {
74 const stage = value as StageDef | null
75
76 return typeof stage?.name === 'string' && typeof stage.run === 'string' && stage.run.trim() !== ''
77}
78
79function stagesFromConfig(text: string): StageDef[] {
80 const list = (JSON.parse(text) as { stages?: unknown }).stages
81
82 return Array.isArray(list) ? list.filter(isStageDef).map(({ name, run }) => ({ name, run })) : []
83}
84
85function stagesFromPackage(text: string): StageDef[] {
86 const scripts = (JSON.parse(text) as { scripts?: Record<string, unknown> }).scripts ?? {}
87
88 return NPM_SCRIPTS.filter(([script]) => {
89 const body = scripts[script]
90
91 // npm init 預設的 test 只會印錯誤然後失敗,不算真的階段
92 return typeof body === 'string' && !body.includes('no test specified')
93 }).map(([script, name]) => ({ name, run: `npm run ${script}` }))
94}
95
96async function findStages($: EngineInterface, cwd: string): Promise<Found> {
97 if (await $.fs.exists(`${cwd}/${CONFIG_PATH}`)) {
98 try {
99 const stages = stagesFromConfig(await $.fs.read(`${cwd}/${CONFIG_PATH}`))
100
101 return stages.length > 0
102 ? { stages, source: CONFIG_PATH }
103 : { stages, source: CONFIG_PATH, problem: `${CONFIG_PATH} 裡沒有可用的階段,每一項都要有 name 和 run。` }
104 } catch {
105 return { stages: [], source: CONFIG_PATH, problem: `${CONFIG_PATH} 不是合法的 JSON,請檢查格式。` }
106 }
107 }
108
109 if (await $.fs.exists(`${cwd}/package.json`)) {
110 try {
111 return { stages: stagesFromPackage(await $.fs.read(`${cwd}/package.json`)), source: 'package.json' }
112 } catch {
113 // package.json 讀不懂就當作沒有
114 }
115 }
116
117 return { stages: [], source: '' }
118}
119
120async function init($: EngineInterface): Promise<string> {
121 const cwd = await $.session.cwd()
122
123 if (await $.fs.exists(`${cwd}/${CONFIG_PATH}`)) {
124 return `${CONFIG_PATH} 已經存在,直接編輯它就好。`
125 }
126
127 const found = await findStages($, cwd)
128 const stages =
129 found.stages.length > 0
130 ? found.stages
131 : [
132 { name: '打包', run: 'npm run build' },
133 { name: '部署', run: 'npm run deploy' },
134 ]
135 await $.fs.write(`${cwd}/${CONFIG_PATH}`, `${JSON.stringify({ stages }, null, 2)}\n`)
136
137 return `已建立 ${CONFIG_PATH}(${stages.map(stage => stage.name).join(' → ')})。name 是顯示的名稱,run 是要執行的指令,改完再輸入 /ship。`
138}
139
140function tailOf(output: string): string[] {
141 return output
142 .split('\n')
143 .map(line => line.trimEnd())
144 .filter(line => line !== '')
145 .slice(-4)
146}
147
148async function work($: EngineInterface, mine: Run, cwd: string): Promise<void> {
149 try {
150 for (const stage of mine.stages) {
151 stage.status = 'running'
152 mine.stageMs = 0
153 $.ui.invalidate('ui.render')
154 const startedAt = await $.clock.now()
155 let exitCode = 1
156 let output = ''
157
158 try {
159 const ran = await $.process.run(['bash', '-c', stage.run], { cwd, timeoutMs: STAGE_TIMEOUT_MS })
160 exitCode = ran.exitCode
161 output = `${ran.stdout}\n${ran.stderr}`
162 } catch (error) {
163 output = error instanceof Error ? error.message : String(error)
164 }
165
166 stage.ms = (await $.clock.now()) - startedAt
167
168 if (exitCode !== 0) {
169 stage.status = 'failed'
170 mine.outcome = 'failed'
171 mine.tail = tailOf(output)
172 $.ui.toast(`${stage.name} 失敗了,流程停在這裡`)
173
174 return
175 }
176
177 stage.status = 'done'
178 }
179
180 mine.outcome = 'done'
181 $.ui.toast(`${mine.stages.length} 個階段全部完成`)
182 } finally {
183 if (run === mine) {
184 ticker?.cancel()
185 ticker = null
186 }
187
188 $.ui.invalidate('ui.render')
189 }
190}
191
192async function start($: EngineInterface, args: string): Promise<string> {
193 if (run?.outcome === 'running') {
194 return '已經有一輪在跑了,等它結束再重跑。'
195 }
196
197 const cwd = await $.session.cwd()
198 const found: Found = args === 'demo' ? { stages: [...DEMO], source: '示範' } : await findStages($, cwd)
199
200 if (found.problem !== undefined) {
201 return found.problem
202 }
203
204 if (found.stages.length === 0) {
205 return HELP
206 }
207
208 const mine: Run = {
209 stages: found.stages.map(stage => ({ ...stage, status: 'waiting', ms: 0 })),
210 source: found.source,
211 outcome: 'running',
212 elapsedMs: 0,
213 stageMs: 0,
214 frame: 0,
215 tail: [],
216 }
217 run = mine
218 lastArgs = args
219 ticker?.cancel()
220 ticker = $.clock.every(TICK_MS, () => {
221 mine.elapsedMs += TICK_MS
222 mine.stageMs += TICK_MS
223 mine.frame += 1
224 $.ui.invalidate('ui.render')
225 })
226 isHidden = false
227 $.ui.invalidate('ui.render')
228 // 不等它跑完:指令先回覆,階段在背景繼續,面板會跟著更新
229 void work($, mine, cwd)
230
231 return `開始跑 ${mine.stages.length} 個階段(來源:${mine.source}):${mine.stages.map(stage => stage.name).join(' → ')}`
232}
233
234function isWideChar(code: number): boolean {
235 return (
236 (code >= 0x1100 && code <= 0x115f) ||
237 (code >= 0x2e80 && code <= 0xa4cf) ||
238 (code >= 0xac00 && code <= 0xd7a3) ||
239 (code >= 0xf900 && code <= 0xfaff) ||
240 (code >= 0xfe30 && code <= 0xfe4f) ||
241 (code >= 0xff00 && code <= 0xff60) ||
242 (code >= 0xffe0 && code <= 0xffe6)
243 )
244}
245
246// 中文字在終端機佔兩格,排版要用格數而不是字數
247function cellsOf(text: string): number {
248 let cells = 0
249
250 for (const char of text) {
251 cells += isWideChar(char.codePointAt(0) ?? 0) ? 2 : 1
252 }
253
254 return cells
255}
256
257// 截斷或補空白到剛好 width 格
258function fit(text: string, width: number): string {
259 let out = ''
260 let cells = 0
261
262 for (const char of text) {
263 const next = isWideChar(char.codePointAt(0) ?? 0) ? 2 : 1
264
265 if (cells + next > width) {
266 break
267 }
268
269 out += char
270 cells += next
271 }
272
273 return out + ' '.repeat(width - cells)
274}
275
276function seconds(ms: number): string {
277 const total = Math.round(ms / 1000)
278
279 return total < 60 ? `${total}s` : `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
280}
281
282function timer(ms: number): string {
283 const total = Math.round(ms / 1000)
284
285 return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
286}
287
288function toneOf(stage: Stage): Span {
289 if (stage.status === 'done') {
290 return { text: '', color: GREEN }
291 }
292
293 if (stage.status === 'running') {
294 return { text: '', color: YELLOW, bold: true }
295 }
296
297 return stage.status === 'failed' ? { text: '', color: RED, bold: true } : { text: '', color: MUTED }
298}
299
300function markOf(now: Run, stage: Stage): string {
301 if (stage.status === 'done') {
302 return `✓ ${seconds(stage.ms)}`
303 }
304
305 if (stage.status === 'running') {
306 return `${SPINNER[now.frame % SPINNER.length]} ${seconds(now.stageMs)}`
307 }
308
309 return stage.status === 'failed' ? `✗ ${seconds(stage.ms)}` : ''
310}
311
312function headlineOf(now: Run): Span {
313 if (now.outcome === 'done') {
314 return { text: '✓ 全部完成', color: INK, bold: true }
315 }
316
317 const stage = now.stages.find(one => one.status === 'running' || one.status === 'failed')
318
319 return now.outcome === 'failed'
320 ? { text: `✗ ${stage?.name ?? ''} 失敗`, color: RED, bold: true }
321 : { text: `▶ 進行中:${stage?.name ?? ''}`, color: INK, bold: true }
322}
323
324function spriteRows(left: number, frame: number, isSad: boolean): Span[][] {
325 const rows = [...SPRITE_BODY, SPRITE_LEGS[frame % SPRITE_LEGS.length]]
326
327 return rows.map(row => [{ text: ' '.repeat(left) + row, color: isSad ? MUTED : CORAL }])
328}
329
330// 寬的版面:一條橫向軌道,每個階段一個節點,吉祥物走向正在跑的那個節點
331function trackRows(now: Run, width: number, gap: number): Span[][] {
332 const count = now.stages.length
333 const at = now.stages.findIndex(stage => stage.status === 'running' || stage.status === 'failed')
334 // 走得越久越慢,快到節點前停住,等階段真的完成才踩上去
335 const progress = 0.95 * (1 - 1 / (1 + now.stageMs / 4000))
336 const filled = Math.round(progress * (gap - 1))
337 const position = at === -1 ? MARGIN + (count - 1) * gap : at === 0 ? MARGIN : MARGIN + (at - 1) * gap + 1 + filled
338 const left = Math.max(0, Math.min(position - SPRITE_WIDTH / 2, width - SPRITE_WIDTH))
339 const isWalking = now.outcome === 'running'
340
341 const track: Span[] = [{ text: ' '.repeat(MARGIN) }]
342 const labels: Span[] = [{ text: ' '.repeat(MARGIN) }]
343 const marks: Span[] = [{ text: ' '.repeat(MARGIN) }]
344
345 now.stages.forEach((stage, index) => {
346 const isLast = index === count - 1
347 const cell = isLast ? LAST_LABEL : gap
348 const tone = toneOf(stage)
349
350 track.push({ ...tone, text: stage.status === 'waiting' ? '○' : stage.status === 'failed' ? '✗' : '●' })
351 labels.push({ ...tone, text: fit(stage.name, cell - 1) + ' ' })
352 marks.push({ ...tone, text: fit(markOf(now, stage), cell - 1) + ' ' })
353
354 if (isLast) {
355 return
356 }
357
358 // 節點後面這段線,代表下一個階段的進度
359 const ahead = now.stages[index + 1]
360
361 if (ahead.status === 'done') {
362 track.push({ text: '━'.repeat(gap - 1), color: GREEN })
363 } else if (ahead.status === 'waiting') {
364 track.push({ text: '─'.repeat(gap - 1), color: MUTED })
365 } else {
366 track.push({ text: '━'.repeat(filled), color: ahead.status === 'failed' ? RED : YELLOW })
367 track.push({ text: '─'.repeat(gap - 1 - filled), color: MUTED })
368 }
369 })
370
371 return [...spriteRows(left, isWalking ? now.frame : 0, now.outcome === 'failed'), [], track, labels, marks]
372}
373
374// 窄的版面(終端機很窄時):吉祥物在上,階段直向列出
375function listRows(now: Run): Span[][] {
376 const isWalking = now.outcome === 'running'
377
378 return [
379 ...spriteRows(MARGIN, isWalking ? now.frame : 0, now.outcome === 'failed'),
380 [],
381 ...now.stages.map(stage => {
382 const mark = stage.status === 'waiting' ? '○' : markOf(now, stage)
383
384 return [{ text: ' '.repeat(MARGIN) }, { ...toneOf(stage), text: `${fit(mark, 7)} ${stage.name}` }]
385 }),
386 ]
387}
388
389// 把一列補到剛好 width 格:左右各留 PAD,中間不足的用空白填。
390// 每一列都一樣寬,面板才會是一塊完整的深色矩形
391function padded(spans: readonly Span[], width: number): Span[] {
392 const inner = width - PAD * 2
393 const kept: Span[] = []
394 let used = 0
395
396 for (const span of spans) {
397 const text = fit(span.text, Math.max(0, Math.min(cellsOf(span.text), inner - used)))
398
399 if (text !== '') {
400 kept.push({ ...span, text })
401 used += cellsOf(text)
402 }
403 }
404
405 return [{ text: ' '.repeat(PAD) }, ...kept, { text: ' '.repeat(inner - used + PAD) }]
406}
407
408// 回傳面板的寬度、標題列(右邊要留位置給按鈕)和其餘各列
409function draw(now: Run, columns: number, buttonCells: number): { width: number; head: Span[]; rows: Span[][] } {
410 const width = Math.max(MIN_WIDTH, Math.min(columns, MAX_WIDTH))
411 const inner = width - PAD * 2
412 const count = now.stages.length
413 const finished = now.stages.filter(stage => stage.status === 'done').length
414 const total = now.outcome === 'running' ? now.elapsedMs : now.stages.reduce((sum, stage) => sum + stage.ms, 0)
415 const counter = `${finished}/${count} ${timer(total)} `
416 const headline = headlineOf(now)
417 const gap = count > 1 ? Math.floor((inner - MARGIN - LAST_LABEL) / (count - 1)) : 0
418 const body = count > 1 && gap >= 9 ? trackRows(now, inner, gap) : listRows(now)
419 const headRoom = Math.max(0, inner - buttonCells - MARGIN - cellsOf(counter))
420
421 return {
422 width,
423 head: [
424 { text: ' '.repeat(PAD + MARGIN) },
425 { ...headline, text: fit(headline.text, headRoom) },
426 { text: counter, color: INK },
427 ],
428 rows: [
429 [],
430 ...body,
431 ...(now.tail.length > 0 ? [[]] : []),
432 ...now.tail.map(line => [{ text: ' '.repeat(MARGIN) }, { text: line, color: RED }]),
433 [],
434 ].map(spans => padded(spans, width)),
435 }
436}
437
438export const register: Register = on => {
439 on('session.start', async ($, e, next) => {
440 await $.command.register({
441 name: 'ship',
442 description: '依序跑這個專案的階段並顯示進程(/ship demo 看示範,/ship init 建立設定檔)',
443 })
444
445 return next(e)
446 })
447
448 on('command.run', { command: 'ship' }, async ($, e) => {
449 const args = e.args.trim()
450
451 return { text: args === 'init' ? await init($) : await start($, args) }
452 })
453
454 // 畫在 prompt 正上方的橫條裡,寬度就是整個終端機,所以軌道一定是橫的
455 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
456 if (run === null || isHidden || e.props.hasSurvey) {
457 return next(e)
458 }
459
460 const { Box, Button, Text } = $.ui.resolve(e)
461 const isBusy = run.outcome === 'running'
462 // 終端機上的按鈕畫成「[ 文字 ]」,這裡先替它們留好位置
463 const view = draw(run, e.props.bodyColumns, isBusy ? 8 : 18)
464 const textOf = (span: Span, index: number) =>
465 Text({
466 key: `span-${index}`,
467 color: span.color,
468 backgroundColor: PANEL,
469 bold: span.bold,
470 wrap: 'truncate-end',
471 children: span.text,
472 })
473
474 return Box({
475 flexDirection: 'column',
476 width: view.width,
477 backgroundColor: PANEL,
478 children: [
479 Box({ key: 'top', flexDirection: 'row', children: padded([], view.width).map(textOf) }),
480 Box({
481 key: 'head',
482 flexDirection: 'row',
483 children: [
484 ...view.head.map(textOf),
485 ...(isBusy
486 ? []
487 : [
488 Button({
489 key: 'rerun',
490 label: '重跑',
491 hotkey: 'r',
492 onPress: () => {
493 void start($, lastArgs)
494 },
495 }),
496 Text({ key: 'between', backgroundColor: PANEL, children: ' ' }),
497 ]),
498 Button({
499 key: 'close',
500 label: '關',
501 hotkey: 'c',
502 onPress: () => {
503 isHidden = true
504 $.ui.invalidate('ui.render')
505 },
506 }),
507 ],
508 }),
509 ...view.rows.map((spans, row) => Box({ key: `row-${row}`, flexDirection: 'row', children: spans.map(textOf) })),
510 ],
511 })
512 })
513}
514