Blocks destructive shell commands (rm -rf /, force push, DROP TABLE...)

A few mods for Claude Code, packaged as a plugin marketplace.
| Mod | What it does |
|---|---|
| pomodoro | A pixel-art pomodoro timer in a side pane. Day and night skies, animated sun, clouds, stars and meteors, a grass field and a daily tomato tally. Toast and chime when a phase ends. |
| agent-progress | Live Powerline-style progress bars for running subagents, with a moving glint and the model each one runs on. |
| cmd-guard | Stops destructive shell commands (rm -rf on /, ~ or *, force pushes, hard resets, dropping tables, mkfs, dd…) and asks what to do: refuse, allow once, trust for this session, or use a safer alternative. |
| model-router | Runs Explore subagents on haiku and general-purpose subagents on sonnet to save cost. An explicit model on the Agent call always wins; forks and workflows are left alone. Optionally lets Jev pick the model per task. |
<img src="docs/pomodoro-focus.png" alt="Pomodoro focus mode: day sky with sun and clouds" width="280"> <img src="docs/pomodoro-break.png" alt="Pomodoro break mode: night sky with moon and stars" width="280">
claude plugin marketplace add redjackfred/claude-code-mods
claude plugin install pomodoro@claude-code-mods
claude plugin install agent-progress@claude-code-mods
claude plugin install cmd-guard@claude-code-mods
claude plugin install model-router@claude-code-mods
Install only the ones you want. Restart Claude Code afterwards.
/focus [minutes] start a focus session (default 25)
/focus skip skip to the next phase
/focus stop stop the timer
/focus show|hide show or hide the side pane
afplay, so sound is macOS only.
/agent-progress toggles the bars on and off.

Works automatically; /guard off turns it off for the session and /guard on back on. When nobody answers the prompt (or it fails), the command is blocked.
The dialog speaks the language you mostly write in during the session: English, or Traditional Chinese (繁體中文).
It matches commands with regexes, not a shell parser, so treat it as a seatbelt against slips rather than a sandbox: a determined command can still get past it. It also can't tell running a command from mentioning one, so a commit message or heredoc that only quotes a risky command gets stopped too. Reword the text, or /guard off for a moment.
<img src="docs/model-router.png" alt="model-router toasts: Explore routed to haiku, general-purpose to sonnet" width="360">
Works automatically and shows a toast for each subagent it reroutes. To steer the main agent, you can add this to your ~/.claude/CLAUDE.md:
A model-router mod runs Explore subagents on haiku and general-purpose ones on sonnet
unless the Agent call names a model. For complex reasoning, large refactors, or
hard-to-find bugs, pass `model: "opus"` on the Agent call.
Jev is a fast "System One" decision model. With it on, each general-purpose subagent's task is classified as haiku, sonnet or opus work before it starts. In a quick test it took about 0.7 s per call and costs well under a cent.
export TYPESAFE_API_KEY=... # then restart Claude Code
/router jev on # /router jev off to stop
general-purpose → haiku · jev 1.00.Each mod is a Claude Code plugin with TypeScript hooks in hooks/.
claude plugin validate . # the marketplace
claude plugin validate pomodoro # one plugin
claude plugin test pomodoro # its tests
MIT
hooks/register.ts 107 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { check, langOf } from './rules'
5import type { Lang, Rule } from './rules'
6
7// session-only on purpose: the guard comes back on, and forgets allowances, in every new session
8const off = atom({ plugin: 'cmd-guard', key: 'off' } as const, false)
9const allowed = atom({ plugin: 'cmd-guard', key: 'allowed' } as const, [])
10// how many of the session's prompts were in each language; the dialog speaks the majority's
11const prompts = atom({ plugin: 'cmd-guard', key: 'prompts' } as const, { en: 0, zh: 0, last: 'en' })
12
13type Choice = 'block' | 'once' | 'session' | 'safer'
14const CHOICES: Choice[] = ['block', 'once', 'session', 'safer']
15const UI = {
16 en: {
17 labels: { block: 'Block (recommended)', once: 'Allow once', session: 'Trust for this session', safer: 'Use the safer alternative' },
18 impact: 'Impact', safer: 'Instead', ask: 'What should happen?',
19 allowedOnce: 'Allowed once', trusted: 'Trusted for this session', blocked: 'Blocked',
20 },
21 zh: {
22 labels: { block: '拒絕執行(建議)', once: '僅允許此次', session: '本工作階段信任此類', safer: '改用安全替代方案' },
23 impact: '影響', safer: '替代', ask: '要如何處理?',
24 allowedOnce: '已放行一次', trusted: '本工作階段信任', blocked: '已攔截',
25 },
26} as const
27
28export const sessionLang = (p: { en: number; zh: number; last: string }): Lang =>
29 p.zh > p.en ? 'zh' : p.en > p.zh ? 'en' : p.last === 'zh' ? 'zh' : 'en'
30
31export const question = (rule: Rule, command: string, lang: Lang) => {
32 const t = rule.text[lang]
33 const ui = UI[lang]
34 const cmd = command.length > 120 ? `${command.slice(0, 117)}...` : command
35 return [
36 `🛡 ${rule.level} · ${t.name}`,
37 `$ ${cmd}`,
38 `${ui.impact}: ${t.impact}`,
39 `${ui.safer}: ${t.safer}`,
40 ui.ask,
41 ].join('\n')
42}
43
44// ask in the engine's own dialog; anything but an explicit choice blocks.
45// Returns the choice, or the text the user typed under "Other".
46const ask = async ($: Parameters<Parameters<Register>[0]>[2] extends never ? never : any, rule: Rule, command: string, lang: Lang): Promise<Choice | string> => {
47 const labels = UI[lang].labels
48 try {
49 const answer: string = await $.ui.ask(question(rule, command, lang), { header: '🛡 cmd-guard', options: CHOICES.map(c => labels[c]) })
50 return CHOICES.find(c => labels[c] === answer) ?? answer
51 } catch {
52 return 'block' // dismissed, or nobody to ask
53 }
54}
55
56export const register: Register = on => {
57 on('session.start', async ($, e, next) => {
58 await $.command.register({ name: 'guard', description: 'Toggle the destructive-command guard for this session (on | off)' })
59 return next(e)
60 })
61
62 on('prompt.submit', async ($, e, next) => {
63 const lang = langOf(e.text)
64 await update($, prompts, p => ({ ...p, [lang]: p[lang] + 1, last: lang }))
65 return next(e)
66 })
67
68 on('command.run', { command: 'guard' }, async ($, e) => {
69 const arg = e.args.trim()
70 const value = arg === 'off' ? true : arg === 'on' ? false : !(await read($, off))
71 await update($, off, () => value)
72 if (!value) await update($, allowed, () => [])
73 $.ui.status(value ? ' guard off' : undefined)
74 return { text: `🛡 Command guard ${value ? 'OFF for this session' : 'ON · session allowances cleared'}` }
75 })
76
77 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
78 const rule = check(e.command)
79 if (!rule || (await read($, off)) || (await read($, allowed)).includes(rule.id)) return next(e)
80
81 const lang = sessionLang(await read($, prompts))
82 const ui = UI[lang]
83 const name = rule.text[lang].name
84 const answer = await ask($, rule, e.command, lang)
85
86 if (answer === 'once') {
87 $.ui.toast(`🛡 ${ui.allowedOnce} · ${name}`)
88 return next(e)
89 }
90 if (answer === 'session') {
91 await update($, allowed, l => [...l, rule.id])
92 $.ui.toast(`🛡 ${ui.trusted} · ${name}`)
93 return next(e)
94 }
95 $.ui.toast(`🛡 ${ui.blocked} · ${name}`)
96 // the model reads English either way
97 const { name: en, impact, safer } = rule.text.en
98 if (answer === 'safer') {
99 return { deny: `cmd-guard: the user blocked this command (${en}: ${impact}) and wants the safer alternative: ${safer}. Explain the alternative briefly, then use it.` }
100 }
101 if (answer !== 'block') {
102 return { deny: `cmd-guard: the user blocked this command (${en}) and said: ${answer}` }
103 }
104 return { deny: `cmd-guard: the user blocked this command (${en}: ${impact}). Do not retry it; ask the user how to proceed.` }
105 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'cmd-guard: its check failed, so the command was blocked.' }))
106}
107hooks/rules.ts 77 lines1export type Lang = 'en' | 'zh'
2export type RuleText = { name: string; impact: string; safer: string }
3export type Rule = {
4 re: RegExp
5 // stable id: what session allowances remember
6 id: string
7 level: 'CRITICAL' | 'HIGH'
8 text: Record<Lang, RuleText>
9}
10
11// matched against the whole Bash command, so chained commands are caught too.
12// ponytail: regexes, not a shell parser; a guard against slips, not a sandbox
13const BROAD = String.raw`(\/|\/\*|~|~\/|~\/\*|\$HOME|\$HOME\/\*?|\*|\.|\.\.|\.\/\*?)`
14// `git`, then any global options (-C dir, -c key=value, --git-dir=...), then the subcommand
15const GIT = String.raw`\bgit(\s+(-[cC]\s+\S+|--[a-z-]+(=\S+)?))*\s+`
16// the rest of one command: quoted strings whole, stopping at ; & | or newline
17const SEG = String.raw`([^;&|\n'"]|'[^']*'|"[^"]*")*`
18export const RULES: Rule[] = [
19 { re: new RegExp(String.raw`\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|-[a-zA-Z]*f[a-zA-Z]*\s+|--recursive\s+|--force\s+)+${BROAD}(\s|;|&|\||$)`),
20 id: 'rm-broad', level: 'CRITICAL', text: {
21 en: { name: 'Broad rm -rf', impact: 'Recursively deletes the root, home or a wildcard path; cannot be undone', safer: 'Name exact paths, or ls first; use trash to move files to the Trash' },
22 zh: { name: 'rm -rf 大範圍刪除', impact: '遞迴刪除根目錄、家目錄或萬用字元路徑,無法復原', safer: '指定精確路徑,或先 ls 確認再刪;改用 trash 移到垃圾桶' } } },
23 // --force, -f in any flag cluster (-fu), or a +refspec
24 { re: new RegExp(String.raw`${GIT}push\b(?=.*\s(--force(?!-with-lease)\b|-[a-zA-Z]*f[a-zA-Z]*\b|\+\S))`),
25 id: 'git-push-force', level: 'HIGH', text: {
26 en: { name: 'git push --force', impact: 'Overwrites remote history and can erase other people\'s commits', safer: 'git push --force-with-lease' },
27 zh: { name: 'git push --force', impact: '覆寫遠端歷史,可能抹掉他人的 commit', safer: 'git push --force-with-lease' } } },
28 { re: new RegExp(String.raw`${GIT}reset\b${SEG}\s--hard\b`),
29 id: 'git-reset-hard', level: 'HIGH', text: {
30 en: { name: 'git reset --hard', impact: 'Discards every uncommitted change; cannot be undone', safer: 'git stash (keeps the changes; pop them back any time)' },
31 zh: { name: 'git reset --hard', impact: '捨棄所有未提交的變更,無法復原', safer: 'git stash(保留變更,可隨時 pop 回來)' } } },
32 // a dry run (-n, --dry-run) deletes nothing; flags are read only within this
33 // command (SEG), so a later command's -n can't pass for a dry run
34 { re: new RegExp(String.raw`${GIT}clean\b(?!${SEG}\s(-[a-zA-Z]*n[a-zA-Z]*|--dry-run)\b)(?=${SEG}\s(-[a-zA-Z]*f|--force\b))`),
35 id: 'git-clean', level: 'HIGH', text: {
36 en: { name: 'git clean -f', impact: 'Deletes every untracked file', safer: 'git clean -n to preview what would go' },
37 zh: { name: 'git clean -f', impact: '刪除所有未追蹤的檔案', safer: 'git clean -n 先預覽要刪的檔案' } } },
38 { re: /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i,
39 id: 'sql-drop', level: 'CRITICAL', text: {
40 en: { name: 'SQL DROP / TRUNCATE', impact: 'Deletes a whole table or database', safer: 'Back up first (pg_dump / mysqldump), or run it in a transaction' },
41 zh: { name: 'SQL DROP / TRUNCATE', impact: '刪除整張資料表或資料庫', safer: '先備份(pg_dump / mysqldump),或在交易中執行' } } },
42 { re: /\bmkfs(\.\w+)?\b/,
43 id: 'mkfs', level: 'CRITICAL', text: {
44 en: { name: 'mkfs format', impact: 'Formats a disk partition and destroys its data', safer: 'Check the device (diskutil list), then run it yourself' },
45 zh: { name: 'mkfs 格式化', impact: '格式化磁碟分割區,資料全毀', safer: '確認裝置代號(diskutil list)後由你手動執行' } } },
46 // writing to exactly the null / zero / std streams is harmless
47 { re: /\bdd\b.*\bof=\/dev\/(?!(null|zero|stdout|stderr)(\s|;|&|\||$))/,
48 id: 'dd-device', level: 'CRITICAL', text: {
49 en: { name: 'dd to a device', impact: 'Overwrites a disk device directly', safer: 'Check the of= device, then run it yourself' },
50 zh: { name: 'dd 寫入裝置', impact: '直接覆寫磁碟裝置', safer: '確認 of= 的裝置代號後由你手動執行' } } },
51 { re: /\bchmod\s+(-R\s+)?777\s+\/(\s|$)/,
52 id: 'chmod-root', level: 'CRITICAL', text: {
53 en: { name: 'chmod 777 /', impact: 'Makes every file on the system writable by anyone', safer: 'Grant the least permission, only where it is needed' },
54 zh: { name: 'chmod 777 /', impact: '讓整個系統所有檔案都可被任何人寫入', safer: '只對需要的目錄設定最小權限' } } },
55 { re: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
56 id: 'fork-bomb', level: 'CRITICAL', text: {
57 en: { name: 'fork bomb', impact: 'Exhausts system resources and hangs the machine', safer: 'Do not run it' },
58 zh: { name: 'fork bomb', impact: '耗盡系統資源,導致當機', safer: '不要執行' } } },
59 { re: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/,
60 id: 'curl-pipe-sh', level: 'HIGH', text: {
61 en: { name: 'Pipe download to shell', impact: 'Runs a remote script without reading it', safer: 'Download it to a file, read it, then run it' },
62 zh: { name: '下載後直接執行', impact: '未經檢查就執行遠端腳本', safer: '先下載成檔案、讀過內容再執行' } } },
63]
64
65// also read with the quotes gone, the way the shell joins "$HOME"/ or ~/'*' into one word
66export const check = (command: string) => {
67 const unquoted = command.replace(/["']/g, '')
68 return RULES.find(r => r.re.test(command) || r.re.test(unquoted))
69}
70
71// a prompt's language: Chinese when it has at least as many Han characters as Latin words
72export const langOf = (text: string): Lang => {
73 const han = text.match(/\p{Script=Han}/gu)?.length ?? 0
74 const words = text.match(/[A-Za-z]+/g)?.length ?? 0
75 return han > 0 && han >= words ? 'zh' : 'en'
76}
77types/index.d.ts 10 lines1export type GuardOff = boolean
2export type GuardAllowed = string[]
3export type GuardPrompts = { en: number; zh: number; last: string }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'cmd-guard': { off: GuardOff; allowed: GuardAllowed; prompts: GuardPrompts }
8 }
9}
10