Dry-runs risky Bash commands (recursive deletes, destructive git, kubectl delete, SQL wipes) and asks Proceed or Cancel first.

Mods for Claude Code (2.1.287+): plugins of function hooks that observe, rewrite or answer what Claude Code does and draw their own UI. Based on Getting started with Claude Code mods.
Landing page: https://cskwork.github.io/claude-code-mods/
| Mod | What it does |
|---|---|
token-weather | A one-line forecast of the context window above the prompt: ☀ Clear → ↯ Compact soon, tokens used, a sparkline of the last 12 turns, and how much the last turn added. |
blast-radius | Before a risky Bash command runs (recursive rm, git reset --hard, git clean -f, force push, git branch -D, git stash drop/clear, kubectl delete, SQL DROP/TRUNCATE, migrations), dry-runs what it would touch and asks Proceed or Cancel. Cancel or dismiss denies the call. |
template | Starter mod, not installed: a slash command, a status line entry, a band, and a $.state contract with a test. |
Inside Claude Code:
/plugin marketplace add cskwork/claude-code-mods
/plugin install token-weather@claude-code-mods
/plugin install blast-radius@claude-code-mods
/reload-plugins
scripts/new-mod.sh my-mod "One line about what it does"
claude --plugin-dir "$PWD/my-mod" # hot-reloads on every save
The script copies template/, renames it, adds it to .claude-plugin/marketplace.json, then runs claude plugin validate and claude plugin test on it.
A mod is three files plus an optional state contract:
my-mod/
├── .claude-plugin/plugin.json name, version, description, "types"
├── hooks/hooks.json { "modules": ["./register.tsx"] }
├── hooks/register.tsx export const register: Register = on => { ... }
├── types/index.d.ts PluginState contract for $.state values
└── tests/my-mod.test.ts claude plugin test my-mod
Rules that bite:
($, e, next). Call next(e) to pass on, next({ ...e, x }) to rewrite, return without it to answer.$.state (atom / read / update); module variables reset on every hot reload.$. UI elements come from $.ui.resolve(e).on('process.run', ($, e) => ({ value: ... }))..claude-plugin/types/ beside it, so tsc -p <mod> type-checks it (gitignored).for m in token-weather blast-radius template; do claude plugin validate $m && claude plugin test $m; donehooks/register.ts 50 lines1// Blast Radius: before a risky Bash command runs, dry-run what it would touch
2// and let the person choose Proceed or Cancel.
3import type { EngineInterface, Register } from 'claude-code'
4
5import { analyze } from './analyze'
6import type { Check, Risk } from './analyze'
7
8const PROCEED = 'Proceed'
9const CANCEL = 'Cancel'
10const MAX_LINES = 8
11
12export const register: Register = on => {
13 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
14 const risks = analyze(e.command)
15 if (risks.length === 0) return next(e)
16
17 const report = (await Promise.all(risks.map(risk => describe($, risk)))).join('\n\n')
18 const question = `Blast radius of: ${e.command.slice(0, 120)}\n\n${report}\n\nRun it?`
19 $.ui.status('blast-radius: waiting for your call')
20 try {
21 const answer = await $.ui.ask(question, { header: 'Blast', options: [PROCEED, CANCEL] })
22 if (answer === PROCEED) return next(e)
23 return { deny: `blast-radius: the user cancelled this command after seeing its dry run.\n${report}` }
24 } catch {
25 return { deny: 'blast-radius: nobody confirmed this risky command (dialog dismissed or no one to ask).' }
26 } finally {
27 $.ui.status(undefined)
28 }
29 })
30}
31
32async function describe($: EngineInterface, risk: Risk) {
33 const lines = await Promise.all(risk.checks.map(check => runCheck($, check)))
34 return [`⚠ ${risk.reason}`, ...lines].join('\n')
35}
36
37async function runCheck($: EngineInterface, check: Check) {
38 try {
39 const ran = await $.process.run(check.argv, { cwd: check.cwd, timeoutMs: 5_000 })
40 const out = ran.stdout.trim().split('\n').filter(Boolean)
41 if (check.countOnly) return ` ${check.label}: ${out.length}${ran.isStdoutTruncated ? '+' : ''}`
42 if (out.length === 0) return ` ${check.label}: ${ran.exitCode === 0 ? '(nothing)' : ran.stderr.trim().split('\n')[0] ?? 'failed'}`
43 const shown = out.slice(0, MAX_LINES).map(l => ` ${l}`)
44 const more = out.length > MAX_LINES ? [` … ${out.length - MAX_LINES} more`] : []
45 return [` ${check.label}:`, ...shown, ...more].join('\n')
46 } catch (error) {
47 return ` ${check.label}: dry run failed (${error instanceof Error ? error.message : String(error)})`
48 }
49}
50hooks/analyze.ts 97 lines1// Pure: turns a shell command into the risks it carries and the read-only
2// commands that show how far each one reaches. No `$`, so tests import it as is.
3
4export type Check = { label: string; argv: string[]; cwd?: string; countOnly?: true }
5export type Risk = { reason: string; checks: Check[] }
6
7const SQL_WIPE = /\b(drop\s+(table|database|schema)|truncate\s+table|delete\s+from\s+\w+\s*(;|$|"|'))/i
8const MIGRATION = /\b(flyway\s+(migrate|clean)|liquibase\s+update|prisma\s+migrate|alembic\s+upgrade|rails\s+db:|knex\s+migrate|sequelize\s+db:migrate)/i
9const WIDE = ['/', '/*', '~', '~/', '.', '..', '*']
10
11export function analyze(command: string): Risk[] {
12 const risks: Risk[] = []
13 if (SQL_WIPE.test(command)) risks.push({ reason: 'SQL that drops or wipes data (no dry run possible)', checks: [] })
14 if (MIGRATION.test(command)) risks.push({ reason: 'Database migration (no dry run possible)', checks: [] })
15
16 // Follows `cd dir && ...` so each check runs where the command would.
17 let cwd: string | undefined
18 for (const segment of command.split(/&&|\|\||;|\|/)) {
19 const words = segment.trim().split(/\s+/).filter(Boolean)
20 while (words[0] && (words[0] === 'sudo' || /^\w+=/.test(words[0]))) words.shift()
21 const [cmd, ...args] = words
22 if (!cmd) continue
23 if (cmd === 'cd' && args[0]) {
24 cwd = args[0].startsWith('/') || !cwd ? args[0] : `${cwd}/${args[0]}`
25 continue
26 }
27 const risk = cmd === 'rm' ? rm(args) : cmd === 'git' ? git(args) : cmd === 'kubectl' ? kubectl(args) : undefined
28 if (risk) risks.push({ ...risk, checks: risk.checks.map(c => (c.cwd || !cwd ? c : { ...c, cwd })) })
29 }
30 return risks
31}
32
33function rm(args: string[]): Risk | undefined {
34 const isRecursive = args.some(a => a === '--recursive' || /^-[a-zA-Z]*[rR]/.test(a))
35 const paths = args.filter(a => !a.startsWith('-'))
36 if (!isRecursive || paths.length === 0) return undefined
37 const isWide = paths.some(p => WIDE.includes(p))
38 return {
39 reason: `Recursive delete of ${paths.join(' ')}${isWide ? ' (VERY WIDE TARGET)' : ''}`,
40 checks: [
41 { label: 'size', argv: ['du', '-sh', '--', ...paths] },
42 { label: 'files', argv: ['find', ...paths, '-type', 'f'], countOnly: true },
43 ],
44 }
45}
46
47function git(args: string[]): Risk | undefined {
48 let cwd: string | undefined
49 const rest = [...args]
50 while (rest[0]?.startsWith('-')) {
51 if (rest.shift() === '-C') cwd = rest.shift()
52 }
53 const [sub, ...opts] = rest
54 const has = (...names: string[]) => opts.some(o => names.includes(o))
55 const short = (letter: string) => opts.some(o => new RegExp(`^-[a-zA-Z]*${letter}`).test(o))
56 const at = (risk: Risk): Risk => (cwd ? { ...risk, checks: risk.checks.map(c => ({ ...c, cwd })) } : risk)
57
58 if (sub === 'reset' && has('--hard'))
59 return at({
60 reason: 'git reset --hard discards every uncommitted change',
61 checks: [
62 { label: 'uncommitted', argv: ['git', 'status', '--short'] },
63 { label: 'diff', argv: ['git', 'diff', '--stat', 'HEAD'] },
64 ],
65 })
66 if (sub === 'clean' && (short('f') || has('--force')))
67 return at({
68 reason: 'git clean deletes untracked files',
69 checks: [{ label: 'would remove', argv: ['git', 'clean', '-n', ...(short('d') ? ['-d'] : []), ...(short('x') ? ['-x'] : [])] }],
70 })
71 if (sub === 'push' && opts.some(o => o === '-f' || o.startsWith('--force') || o.startsWith('+')))
72 return at({
73 reason: 'Force push rewrites the remote branch',
74 checks: [{ label: 'remote commits lost', argv: ['git', 'log', '--oneline', 'HEAD..@{u}'] }],
75 })
76 if ((sub === 'checkout' && has('--', '.')) || (sub === 'restore' && !has('--staged')))
77 return at({ reason: `git ${sub} overwrites working-tree changes`, checks: [{ label: 'diff', argv: ['git', 'diff', '--stat'] }] })
78 if (sub === 'branch' && (has('-D') || (has('-d', '--delete') && has('--force', '-f')))) {
79 const names = opts.filter(o => !o.startsWith('-'))
80 return at({
81 reason: `Force-deleting branch ${names.join(' ')}`,
82 checks: names.map(n => ({ label: `unpushed on ${n}`, argv: ['git', 'log', '--oneline', n, '--not', '--remotes'] })),
83 })
84 }
85 if (sub === 'stash' && (opts[0] === 'drop' || opts[0] === 'clear'))
86 return at({ reason: `git stash ${opts[0]} loses stashed work`, checks: [{ label: 'stashes', argv: ['git', 'stash', 'list'] }] })
87 return undefined
88}
89
90function kubectl(args: string[]): Risk | undefined {
91 if (args[0] !== 'delete') return undefined
92 return {
93 reason: 'kubectl delete removes cluster resources',
94 checks: [{ label: 'would delete', argv: ['kubectl', ...args, '--dry-run=client', '-o', 'name'] }],
95 }
96}
97