Holds risky Bash commands and shows what they would change before they run.

My Claude Code setup: mods, plugins, skills and settings, so a new machine is one clone away.
git clone git@github-personal:aidan-nunn-sys/claude-config.git ~/Work/personal/claude-config
~/Work/personal/claude-config/install.sh
Then:
<SET-ME> values install.sh reports in ~/.claude.json (MCP secrets are never committed).~/.config/mise/config.toml: "npm:optimaizr" = { version = "0.11.0", allow_low_downloads = true }Mods and skills are symlinked from this repo, so edits land here directly. Settings changed through Claude Code live in ~/.claude; run ./capture.sh and review git diff to bring them back.
| Path | What |
|---|---|
mods/ | Mods loaded through CLAUDE_CODE_PLUGIN_DIRS (built by install.sh from this folder) |
plugins/plugins.json | Marketplaces and which marketplace plugins are enabled |
skills/ | Personal skills, symlinked into ~/.claude/skills |
settings/settings.json | ~/.claude/settings.json minus plugin dirs, plugins and hooks; ${HOME} for paths |
settings/mcp.json | User MCP servers, secrets blanked |
settings/rulebook.json | Generic example rules (copied only when the machine has none; not captured) |
docs/specs/ | Design specs |
| Mod | Source | Local changes |
|---|---|---|
| hud | own | prompt hint, context weather bar, cache countdown, kaomoji mascot |
| rulebook | own | hard rules in ~/.claude/rulebook.json, /rules, mcp__rulebook__add_rule |
| xagent | own | dispatch/route tools for Codex, Copilot, Antigravity |
| handoff | own | /handoff [next] writes a handoff doc to ~/.claude/handoffs/ |
| agent-radar | hamzafer/claude-code-mods @ 87f108479bbf | /radar runs mid-turn (immediate) and toggles closed; shows xagent's external agents |
| mission-control | hamzafer/claude-code-mods @ 87f108479bbf | shows xagent's external agents; code-map view (Haiku diff summaries, headless Chrome) stripped |
| blast-radius | hamzafer/claude-code-mods @ 87f108479bbf | 60 s auto-cancel |
| review-watch | hamzafer/claude-code-mods @ 87f108479bbf | none |
| where-am-i | hamzafer/claude-code-mods @ 87f108479bbf | shows only "next" |
| secret-box | ray-amjad/claude-code-mods @ 467b20300d8e | none |
| task-board | MarcoCarnevali/claude-code-mods @ a3e528dd00ce | /board (not /tasks), no auto-open, mid-turn + toggle |
Upstream commits are the repos' HEAD on 2026-10-14, when this repo was made; installs were from around then. Read upstream changes before pulling them in.
~/.claude/.credentials.json and MCP secrets.diagnose-crash, omarchy, symlinks into /usr/share/omarchy), themes/omarchy.json, the herdr SessionStart hook..claude-plugin/types/ in each mod: SDK types Claude Code writes itself.hooks/register.tsx 265 lines1// Blast Radius: holds risky Bash commands and shows what they would change.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { HeldCommand } from '../types'
6
7const PANE = 'blast-radius'
8const MAX_LISTED = 200
9const DEFAULT_TIMEOUT_SECONDS = 60
10
11// Held by the host, so the drawing redraws when a command is held or released.
12const held = atom({ plugin: 'blast-radius', key: 'held' } as const, null as HeldCommand | null)
13
14const MIGRATION =
15 /\b(prisma\s+(migrate|db\s+push)|supabase\s+(db\s+(reset|push)|migration\s+up)|drizzle-kit\s+(push|migrate)|knex\s+migrate|sequelize(-cli)?\s+db:migrate|rails\s+db:(migrate|reset|drop)|alembic\s+(upgrade|downgrade)|typeorm\s+migration:run)\b/
16
17type Risk = { risk: HeldCommand['risk']; cwd?: string; targets?: string[]; hasQuotes?: boolean }
18
19export const register: Register = (on, options) => {
20 // How long a held command waits for a press before it is cancelled; 0 waits forever.
21 const timeoutSeconds = timeoutFrom(options.timeoutSeconds)
22 // The press of a Button, by held command id.
23 const decisions = new Map<string, 'proceed' | 'cancel'>()
24 // The call holding the pane now. Checked and claimed in one step, so two waiting calls can't both take it.
25 let holder: string | null = null
26
27 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
28 const found = classify(e.command)
29 if (!found) return next(e)
30
31 // Nothing draws the session (a plain `claude -p` run): nobody can see the buttons, so refuse now.
32 if ((await $.session.surfaces()).length === 0) {
33 const { summary } = await measure($, found)
34 return {
35 deny:
36 `blast-radius: cancelled this command because this session has no screen, so nobody can answer. ` +
37 `It would have: ${summary}. Ask the user to run it themselves.`,
38 }
39 }
40
41 // One command is held at a time; a second waits its turn (the first one's timeout bounds the wait).
42 for (;;) {
43 // The call ended while it waited its turn: leave the held one alone.
44 if (next.signal.aborted) return { deny: 'blast-radius: held this command, but the call was stopped before it was shown.' }
45 if (holder === null && (await read($, held)) === null && holder === null) break
46 await $.process.run(['sleep', '0.25'])
47 }
48 holder = e.tool_use_id
49 try {
50 const measured = await measure($, found)
51 const one: HeldCommand = {
52 id: e.tool_use_id,
53 command: e.command,
54 risk: found.risk,
55 ...measured,
56 where: 'pane',
57 secondsLeft: timeoutSeconds > 0 ? timeoutSeconds : null,
58 }
59 await update($, held, () => one)
60
61 const opened = await $.ui.open({ id: PANE, title: 'Blast Radius', focus: true })
62 if (!opened.isPlaced) {
63 await update($, held, h => (h ? { ...h, where: 'band' as const } : h)) // too narrow for a pane: draw above the prompt
64 }
65
66 // Time spent inside $ calls doesn't count against the hook's time limit.
67 // Nobody pressing within the timeout counts as Cancel, so an unattended session never stalls.
68 const deadline = timeoutSeconds > 0 ? (await $.clock.now()) + timeoutSeconds * 1000 : null
69 let isTimedOut = false
70 while (!decisions.has(one.id) && !next.signal.aborted) {
71 if (deadline !== null) {
72 const left = Math.ceil((deadline - (await $.clock.now())) / 1000)
73 if (left <= 0) {
74 isTimedOut = !decisions.has(one.id) // a press that landed at the deadline still counts
75 break
76 }
77 if (left !== one.secondsLeft) {
78 one.secondsLeft = left
79 await update($, held, h => (h && h.id === one.id ? { ...h, secondsLeft: left } : h))
80 }
81 }
82 await $.process.run(['sleep', '0.25'])
83 }
84 const decision = isTimedOut ? 'cancel' : (decisions.get(one.id) ?? 'cancel')
85 decisions.delete(one.id)
86 await update($, held, () => null)
87 await $.ui.close({ id: PANE })
88
89 if (decision === 'proceed') return next(e) // runs as written
90 if (isTimedOut) {
91 return {
92 deny:
93 `blast-radius: held this command and nobody answered within ${timeoutSeconds} s, so it was cancelled. ` +
94 `It would have: ${one.summary}. Don't retry it on your own: ask the user to run it, or run it again once they're back.`,
95 }
96 }
97 return {
98 deny: `blast-radius: the user pressed Cancel on this command. It would have: ${one.summary}.`,
99 }
100 } finally {
101 holder = null
102 // If the hold failed partway, don't leave its command blocking the next call.
103 await update($, held, h => (h && h.id === e.tool_use_id ? null : h)).catch(() => {})
104 }
105 })
106
107 // The hook's loop picks the press up.
108 const decide = (id: string, decision: 'proceed' | 'cancel') => {
109 decisions.set(id, decision)
110 }
111
112 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
113 const one = await read($, held)
114 const { Text } = $.ui.resolve(e)
115 if (!one) return <Text dimColor>Nothing held.</Text>
116 return report($, e, one, decide)
117 })
118
119 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
120 const one = await read($, held)
121 if (!one || one.where !== 'band' || e.props.hasSurvey) return next(e)
122 return report($, e, one, decide) // a held command takes the whole band until answered
123 })
124}
125
126function report(
127 $: EngineInterface,
128 e: Parameters<EngineInterface['ui']['resolve']>[0],
129 one: HeldCommand,
130 decide: (id: string, d: 'proceed' | 'cancel') => void,
131) {
132 const { Box, Text, Button } = $.ui.resolve(e)
133 const label = { delete: 'Deletes files', 'force-push': 'Force push', migration: 'Database migration' }[one.risk]
134 const shown = one.where === 'band' ? one.details.slice(0, 5) : one.details
135 return (
136 <Box flexDirection="column" paddingX={1}>
137 <Text>
138 <Text color="red" bold>{'⚠ Blast Radius held a command '}</Text>
139 <Text dimColor>{`(${label})`}</Text>
140 </Text>
141 <Text color="yellow" wrap="truncate-end">{`$ ${one.command}`}</Text>
142 <Text bold>{one.summary}</Text>
143 {shown.map(line => (
144 <Text dimColor wrap="truncate-end">{` ${line}`}</Text>
145 ))}
146 {one.details.length > shown.length && <Text dimColor>{` … ${one.details.length - shown.length} more`}</Text>}
147 <Box flexDirection="row" gap={1}>
148 <Button key="cancel" label="Cancel" hotkey="c" variant="primary" autoFocus onPress={() => decide(one.id, 'cancel')} />
149 <Button key="proceed" label="Proceed" hotkey="y" onPress={() => decide(one.id, 'proceed')} />
150 {one.secondsLeft != null && (
151 <Text key="countdown" color={one.secondsLeft <= 10 ? 'yellow' : undefined} dimColor={one.secondsLeft > 10}>
152 {`auto-cancels in ${one.secondsLeft} s`}
153 </Text>
154 )}
155 </Box>
156 </Box>
157 )
158}
159
160// The timeoutSeconds option as whole seconds: a number or a numeric string, 0 to wait forever.
161export function timeoutFrom(value: unknown): number {
162 const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
163 if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return DEFAULT_TIMEOUT_SECONDS
164 return Math.ceil(n) // a positive fraction stays a timeout, never 0 (wait forever)
165}
166
167// Which risky kind a command is, if any, looking at each part of a compound command.
168export function classify(command: string): Risk | null {
169 let cwd: string | undefined
170 for (const part of command.split(/&&|\|\||;|\n/)) {
171 const words = split(part.trim())
172 if (words[0] === 'cd' && words[1]) cwd = words[1]
173 const at = words.findIndex(w => w === 'rm')
174 if (at !== -1 && (at === 0 || words[at - 1] === 'sudo')) {
175 const args = words.slice(at + 1)
176 const flags = args.filter(a => a.startsWith('-'))
177 const isRecursive = flags.some(f => f === '--recursive' || (/^-[a-zA-Z]+$/.test(f) && /[rR]/.test(f)))
178 // Quotes are gone after split: remember they were there, so '~' or '$HOME' are not expanded as if bare.
179 if (isRecursive) return { risk: 'delete', cwd, targets: args.filter(a => !a.startsWith('-')), ...(/['"\\]/.test(command) ? { hasQuotes: true } : {}) }
180 }
181 if (words[0] === 'git' && words.includes('push') && words.some(w => w === '-f' || w.startsWith('--force') || /^\+/.test(w))) {
182 return { risk: 'force-push', cwd }
183 }
184 if (MIGRATION.test(part)) return { risk: 'migration', cwd }
185 }
186 return null
187}
188
189// What the command would touch, from the tools' own commands.
190// Paths the preview can't resolve without running the command: shell variables, command substitution, ~user.
191const UNRESOLVED = /[$`]|^~[^/]/
192const UNRESOLVED_QUOTED = /[$`~]/ // with quotes around, even ~ and $HOME are unknown
193
194async function measure($: EngineInterface, found: Risk): Promise<Pick<HeldCommand, 'summary' | 'details'>> {
195 // ~ and $HOME are expanded here; anything else with a variable is reported as unknown, never as "nothing".
196 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
197 // With quotes in the command we can't tell '~' (literal) from ~ (home): don't expand, report it instead.
198 const expand = (p: string) => (home && !found.hasQuotes ? p.replace(/^~(?=\/|$)/, home).replace(/\$\{HOME\}|\$HOME\b/g, home) : p)
199 const cwd = found.cwd ? expand(found.cwd) : undefined
200 const unresolved = found.hasQuotes ? UNRESOLVED_QUOTED : UNRESOLVED
201 const init = cwd && !unresolved.test(cwd) ? { cwd, timeoutMs: 10_000 } : { timeoutMs: 10_000 }
202 try {
203 if (found.risk === 'delete') {
204 const targets = (found.targets ?? []).map(expand)
205 const unknown = targets.filter(t => unresolved.test(t))
206 if (unknown.length > 0 || (cwd !== undefined && unresolved.test(cwd))) {
207 const list = unknown.length > 0 ? unknown : [`cd ${found.cwd}`]
208 return { summary: `can't preview: ${list.length === 1 ? 'a path uses' : `${list.length} paths use`} a shell variable, check by hand`, details: list.slice(0, MAX_LISTED) }
209 }
210 // Unquoted $t expands globs, and nothing else, without running the command.
211 const script =
212 'shopt -s nullglob; for t in "$@"; do for p in $t; do [ -e "$p" ] || continue; ' +
213 'echo "S $(du -sk "$p" | cut -f1)"; find "$p" -type f | head -n 5000 | sed "s/^/F /"; done; done'
214 const r = await $.process.run(['bash', '-c', script, 'blast-radius', ...targets], init)
215 const lines = r.stdout.split('\n')
216 const files = lines.filter(l => l.startsWith('F ')).map(l => short(l.slice(2), targets))
217 const kb = lines.filter(l => l.startsWith('S ')).reduce((sum, l) => sum + Number(l.slice(2)), 0)
218 if (files.length === 0 && kb === 0) return { summary: 'delete nothing that exists right now', details: [] }
219 return {
220 summary: `delete ${files.length >= 5000 ? '5000+' : files.length} file${files.length === 1 ? '' : 's'} (${size(kb)})`,
221 details: files.slice(0, MAX_LISTED),
222 }
223 }
224 if (found.risk === 'force-push') {
225 const lost = await $.process.run(['git', 'log', '--oneline', 'HEAD..@{u}'], init)
226 const ahead = await $.process.run(['git', 'log', '--oneline', '@{u}..HEAD'], init)
227 const gone = lost.stdout.split('\n').filter(Boolean)
228 const added = ahead.stdout.split('\n').filter(Boolean)
229 return {
230 summary: `overwrite the remote branch: ${gone.length} remote commit${gone.length === 1 ? '' : 's'} lost, ${added.length} pushed (as of the last fetch)`,
231 details: [...gone.map(c => `lost ${c}`), ...added.map(c => `pushed ${c}`)].slice(0, MAX_LISTED),
232 }
233 }
234 const status = await $.process.run(['git', 'status', '--porcelain'], init)
235 const touched = status.stdout.split('\n').filter(l => /migrat|schema|prisma|supabase|drizzle/i.test(l))
236 return {
237 summary: 'change the database schema (not previewable here)',
238 details: touched.map(l => `uncommitted ${l.trim()}`).slice(0, MAX_LISTED),
239 }
240 } catch {
241 return { summary: `run a risky ${found.risk} command (could not measure it)`, details: [] }
242 }
243}
244
245// Shell-like words: quotes kept together, the quotes themselves dropped.
246function split(text: string) {
247 return (text.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, ''))
248}
249
250// A path from the folder being deleted (build/chunk-1.js), not the whole path.
251function short(file: string, targets: string[]) {
252 for (const t of targets) {
253 const base = t.replace(/\/+$/, '')
254 const parent = base.includes('/') ? base.slice(0, base.lastIndexOf('/') + 1) : ''
255 if (parent && file.startsWith(parent)) return file.slice(parent.length)
256 }
257 return file
258}
259
260function size(kb: number) {
261 if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`
262 if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
263 return `${kb} KB`
264}
265types/index.d.ts 17 lines1export type HeldCommand = {
2 id: string
3 command: string
4 risk: 'delete' | 'force-push' | 'migration'
5 summary: string
6 details: string[]
7 where: 'pane' | 'band'
8 /** Seconds until the hold auto-cancels; null when it waits for a press forever. */
9 secondsLeft: number | null
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'blast-radius': { held: HeldCommand | null }
15 }
16}
17