SLOPSHOPPER

ding-dong

回合跑超過一分鐘才結束時,發一則可愛的 macOS 通知叫你回來;/ding 可以預覽

newguardcommandtoastprocess
v0.1.0MITupdated 2026-10-03builtbyjia/claude-code-mods/ding-dong
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ding-dong
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /ding ⎿ ding-dong: 已送出預覽通知(snack):叮咚!出爐囉 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code Mods

我為自己的開發流程寫的四個 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

需求

  • Claude Code 2.1.287 以上
  • 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,並讀過原始碼。

ding-dong

回合依時長分成四種語氣(一到三分鐘、三到十分鐘、十分鐘以上、出錯),每種隨機抽一句文案,副標題顯示專案資料夾、耗時與工具次數。你自己中斷的回合不會通知。

通知預設由系統的 osascript 發出。想讓通知圖示變成橘色像素吉祥物,執行一次:

./ding-dong/notifier/build.sh

它會在本機編譯出 DingDong.app,之後通知就由這個小程式發出。文案與門檻秒數都在 ding-dong/hooks/register.ts 最上方。

ship-it

/ship 會在 prompt 正上方畫出一塊深色的橫式面板,吉祥物沿著軌道走向正在執行的階段,每個階段顯示耗時;失敗時流程停下,並列出最後幾行錯誤輸出。面板右上角的「關」可以收起來,再輸入一次 /ship 就會重新出現。

「現在是哪個階段」不是偵測出來的:mod 自己依序執行每個階段的指令,正在跑的那個指令就是目前階段,指令結束時的結束碼是 0 才會往下一個走。吉祥物在兩個節點之間的位置只是依時間估算的動畫,不代表真實進度。

階段的來源依序是:

  1. 專案裡的 .claude/ship.json
  2. package.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 模擬五個階段,不會動到專案。

secret-guard

三層防護:

  1. Prompt:送出前把 API key、token、私鑰換成 [已遮蔽:GitHub token] 這類標記。
  2. 工具輸出:檔案內容或指令輸出裡的金鑰,在送給模型之前遮蔽。
  3. 敏感檔案:擋下讀寫 .env、私鑰、.npmrc 等檔案,以及 cat .env、printenv 這類指令;.env.example 這類範本檔放行。

已知限制:

  • 靠格式比對,認不出格式的密碼不會被遮蔽。
  • Shell 指令的檢查是字面比對,可以被繞過,不能當作唯一的防線。
  • 模型看到的是標記,所以它無法修改被遮蔽的那一行。
  • 圖片與 PDF 的內容不在遮蔽範圍內。

pii-mask

預設關閉。用 /pii on 開啟後,prompt 和工具輸出裡的個資會先換成 <PERSON_1>、<TW_MOBILE_1> 這類代號才送給模型;同一個原文永遠對應同一個代號。對照表存在本機,只會畫在 /pii 打開的面板上,不會進入對話。

模型產出的檔案裡如果有代號,用 /pii restore 檔案路徑 換回原文,結果另存成 .restored 檔,原檔不動。

能辨識的類型:

類型辨識方式
手機、市話、Email格式比對
身分證、居留證、信用卡格式比對加檢查碼
統一編號前面寫明「統編」或「統一編號」的八位數字
地址正式寫法(縣市、鄉鎮市區、路街)
公司以「有限公司」「企業社」「商行」「事務所」結尾
人名「姓名:」「客戶:」「聯絡人:」這類欄位後面的名字,或用 /pii add 加進名單的詞

已知限制:

  • 沒有語言模型,自由書寫的文字裡不在名單上的人名、公司、口語地址不會被辨識。
  • 圖片、PDF 的內容不在處理範圍內。
  • 模型看到的是代號,所以它無法修改含有個資的那一行原始檔案。
  • 這是降低意外外洩的工具,不是保證。處理真正敏感的資料前,先用 /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。

Source 1 files
hooks/register.ts 185 lines
1import type { EngineInterface, Register, TurnCompleteReason } from 'claude-code'
2
3// 回合至少跑這麼久才通知;短回合你人還在螢幕前,不用吵你
4const MIN_SECONDS = 60
5
6type Mood = 'snack' | 'tea' | 'marathon' | 'oops'
7type Card = { title: string; body: string }
8type Preview = { mood: Mood; seconds: number }
9// 至少有一個元素的陣列,取第一個時型別不會是 undefined
10type NonEmpty<T> = readonly [T, ...T[]]
11
12// 每種心情的文案,通知時隨機抽一張。想改語氣,改這裡就好
13const CARDS: Record<Mood, NonEmpty<Card>> = {
14  // 1 到 3 分鐘
15  snack: [
16    { title: '叮咚!出爐囉', body: '熱騰騰的,趁熱回來看 ٩(ˊᗜˋ*)و' },
17    { title: '好了好了~', body: '我有乖乖做完,快來誇我 (。•̀ᴗ-)✧' },
18    { title: '報告長官', body: '任務完成,請回來驗收!' },
19  ],
20  // 3 到 10 分鐘
21  tea: [
22    { title: '你的茶泡好了嗎', body: '我這邊收工囉,慢慢走回來就好' },
23    { title: '呼~做完了', body: '慢工出細活,回來看看成果吧 ₍ᐢ. .ᐢ₎' },
24    { title: '下課鐘響', body: '作業寫完了,可以來檢查了' },
25  ],
26  // 10 分鐘以上
27  marathon: [
28    { title: '馬拉松跑完了', body: '腿好痠,給我一點掌聲' },
29    { title: '我還活著!', body: '這回合好長,但我撐到終點了' },
30    { title: '大工程竣工', body: '先伸個懶腰再回來看' },
31  ],
32  // 出錯或被拒絕
33  oops: [
34    { title: '呱…卡住了', body: '這回合沒有順利結束,回來救我一下 (;ω;)' },
35    { title: '嗚嗚出包了', body: '我跌倒了,需要你回來看看' },
36  ],
37}
38
39// macOS 內建音效,檔案在 /System/Library/Sounds
40const SOUNDS: Record<Mood, string> = {
41  snack: 'Pop',
42  tea: 'Glass',
43  marathon: 'Hero',
44  oops: 'Frog',
45}
46
47// /ding 預覽時輪流展示的心情,秒數是示範用的假時長
48const PREVIEWS: NonEmpty<Preview> = [
49  { mood: 'snack', seconds: 95 },
50  { mood: 'tea', seconds: 320 },
51  { mood: 'marathon', seconds: 1260 },
52  { mood: 'oops', seconds: 75 },
53]
54
55function moodOf(reason: TurnCompleteReason, seconds: number): Mood {
56  if (reason !== 'answer') {
57    return 'oops'
58  }
59
60  if (seconds < 180) {
61    return 'snack'
62  }
63
64  return seconds < 600 ? 'tea' : 'marathon'
65}
66
67function pickCard(mood: Mood): Card {
68  const cards = CARDS[mood]
69
70  return cards[Math.floor(Math.random() * cards.length)] ?? cards[0]
71}
72
73function clock(seconds: number): string {
74  const hours = Math.floor(seconds / 3600)
75  const minutes = Math.floor((seconds % 3600) / 60)
76
77  if (hours > 0) {
78    return `${hours} 小時 ${minutes} 分`
79  }
80
81  return minutes > 0 ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`
82}
83
84function folderOf(path: string): string {
85  return path.split('/').filter(Boolean).pop() ?? path
86}
87
88async function send($: EngineInterface, mood: Mood, card: Card, seconds: number, tools: number): Promise<void> {
89  const folder = folderOf(await $.session.cwd())
90  const subtitle = `${folder} · ${clock(seconds)} · ${tools} 次工具`
91  const notifier = `${$.plugin.root}/notifier`
92
93  try {
94    // 首選:透過 DingDong.app 發通知,圖示才會是橘色吉祥物。
95    // app 讀同資料夾的 message.txt,四行依序是標題、副標題、內文、音效
96    if (await $.fs.exists(`${notifier}/DingDong.app`)) {
97      await $.fs.write(`${notifier}/message.txt`, [card.title, subtitle, card.body, SOUNDS[mood]].join('\n'))
98      const opened = await $.process.run(['open', '-g', '-n', '-a', `${notifier}/DingDong.app`], { timeoutMs: 5000 })
99
100      if (opened.exitCode === 0) {
101        return
102      }
103    }
104
105    // 備援:沒有 app 時直接用 osascript,圖示會是系統的「工序指令編寫程式」。
106    // 文字用 argv 傳給 AppleScript,不拼進腳本字串,引號和反斜線就不必跳脫
107    const sent = await $.process.run(
108      [
109        'osascript',
110        '-e',
111        'on run argv',
112        '-e',
113        'display notification (item 1 of argv) with title (item 2 of argv) subtitle (item 3 of argv) sound name (item 4 of argv)',
114        '-e',
115        'end run',
116        card.body,
117        card.title,
118        subtitle,
119        SOUNDS[mood],
120      ],
121      { timeoutMs: 5000 },
122    )
123
124    if (sent.exitCode === 0) {
125      return
126    }
127  } catch {
128    // 兩種都發不出去(例如不是 macOS)時,改用下面的 toast
129  }
130
131  $.ui.toast(`${card.title} ${card.body}`)
132}
133
134export const register: Register = on => {
135  let tools = 0
136  let previews = 0
137
138  on('session.start', async ($, e, next) => {
139    await $.command.register({
140      name: 'ding',
141      description: '預覽回合完成通知,每次換一種心情',
142    })
143
144    return next(e)
145  })
146
147  on('turn.start', ($, e, next) => {
148    tools = 0
149
150    return next(e)
151  })
152
153  on('tool.call', ($, e, next) => {
154    // 只算主對話的工具,subagent 的不算
155    if (e.agentId === undefined) {
156      tools += 1
157    }
158
159    return next(e)
160  })
161
162  on('turn.complete', async ($, e, next) => {
163    const done = await next(e)
164    const seconds = Math.round(e.durationMs / 1000)
165    // 被你中斷的回合不通知:你人就在現場
166    const isWorthIt = e.agentId === undefined && !e.isAborted && seconds >= MIN_SECONDS
167
168    if (isWorthIt) {
169      const mood = moodOf(e.reason, seconds)
170      await send($, mood, pickCard(mood), seconds, tools)
171    }
172
173    return done
174  })
175
176  on('command.run', { command: 'ding' }, async $ => {
177    const { mood, seconds } = PREVIEWS[previews % PREVIEWS.length] ?? PREVIEWS[0]
178    const card = pickCard(mood)
179    previews += 1
180    await send($, mood, card, seconds, 7)
181
182    return { text: `已送出預覽通知(${mood}):${card.title}` }
183  })
184}
185