Holds destructive commands (rm -r, force pushes, git resets and cleans, branch deletes, migrations, SQL drops) and shows exactly what they would destroy before…

A Claude Code mod that holds destructive commands before they run and shows exactly what they would destroy: the files and their size, the commits a push or reset drops, the untracked files a clean removes. You press Cancel, Proceed or Allow this session; Claude gets the reason either way.
Adapted from hamzafer/claude-code-mods (commit
e687416) by Hamza Zafar, MIT licensed. See LICENSE. Rewritten here with many more commands, dry-run previews, severity, recovery hints and a new layout.
⚠ Deletes files · critical
rm -rf build ~
Would delete 9 files (1.1 MB)
✖ Targets your whole home folder
● 7 tracked by git (restorable) · 2 untracked (gone for good)
build/chunk-0.js
build/chunk-1.js
… and 7 more
[ Cancel ] [ Proceed ] [ Allow this session ] auto-cancels in 42 s ━━━━━━━━────
| Command | Preview | Severity |
|---|---|---|
rm -r / rm -rf | every file and the total size, grouped by folder; how many git can restore | high, critical for /, ~, .git, . |
find … -delete | the same find without -delete (not previewed when it has -exec) | high |
git push --force | remote commits lost and commits pushed, as of the last fetch | high; medium for --force-with-lease or nothing lost |
git reset --hard, git checkout -- …, git restore, git stash drop/clear | uncommitted edits discarded, commits leaving the branch, stashes dropped | high |
git clean -f… | git's own dry run (git clean -n); warns when -x takes ignored files | high |
git branch -D | commits not in HEAD, whether the branch exists on the remote | medium; high when its commits exist nowhere else |
| migrations (prisma, supabase, drizzle, rails, alembic, django, knex, …) | uncommitted migration files | medium; critical for resets that drop data |
DROP / TRUNCATE / DELETE FROM through a SQL client | the statements | critical |
Previews only read: dry runs, git log, git status, du, find. Nothing is changed until you press Proceed.
/plugin install blast-radius@arhun-plugins
timeoutSeconds in /config (or pluginConfigs["blast-radius"].options in settings); 0 waits forever.claude -p, an SDK host that draws nothing) it cancels at once: nobody could answer.~ and $HOME) say "can't preview" rather than guess.hooks/classify.ts: which commands are destructive, from the text alone.hooks/measure.ts: the dry runs and what they find.hooks/view.tsx: the panel.hooks/register.tsx: the hold, the countdown, the answers.tests/blast-radius.test.tsx: claude plugin test .hooks/register.tsx 136 lines1// blast-radius: holds destructive Bash commands and shows what they would destroy, until the person answers.
2// Adapted from hamzafer/claude-code-mods (MIT).
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { HeldCommand } from '../types'
8import { classify, timeoutFrom, type Found } from './classify'
9import { measure as measureWith } from './measure'
10import { report, type Decision } from './view'
11
12export { classify, timeoutFrom } from './classify'
13
14const PANE = 'blast-radius'
15
16// Held by the host, so the drawing redraws when a command is held, counts down or is released.
17const held = atom({ plugin: 'blast-radius', key: 'held' } as const, null as HeldCommand | null)
18const allowed = atom({ plugin: 'blast-radius', key: 'allowed' } as const, [] as string[])
19
20/** What the command would destroy: the dry runs in ./measure, run through the host. */
21async function measure($: EngineInterface, found: Found) {
22 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
23 return measureWith(found, {
24 home,
25 run: async (argv, cwd) => {
26 const r = await $.process.run(argv, cwd ? { cwd, timeoutMs: 10_000 } : { timeoutMs: 10_000 })
27 return { exitCode: r.exitCode ?? 1, stdout: r.stdout }
28 },
29 })
30}
31
32export const register: Register = (on, options) => {
33 // How long a held command waits for a press before it is cancelled; 0 waits forever.
34 const timeoutSeconds = timeoutFrom(options.timeoutSeconds)
35 const decisions = new Map<string, Decision>()
36 // The call holding the pane now. Checked and claimed in one step, so two waiting calls can't both take it.
37 let holder: string | null = null
38
39 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
40 const found = classify(e.command)
41 if (!found) return next(e)
42 if ((await read($, allowed)).includes(e.command)) return next(e)
43
44 // Nothing draws the session (a plain `claude -p` run): nobody can see the buttons, so refuse now.
45 if ((await $.session.surfaces()).length === 0) {
46 const { summary } = await measure($, found)
47 return {
48 deny:
49 `blast-radius: cancelled this command because this session has no screen, so nobody can answer. ` +
50 `It would ${summary}. Ask the user to run it themselves.`,
51 }
52 }
53
54 // One command is held at a time; a second waits its turn (the first one's timeout bounds the wait).
55 for (;;) {
56 if (next.signal.aborted) return { deny: 'blast-radius: held this command, but the call was stopped before it was shown.' }
57 if (holder === null && (await read($, held)) === null && holder === null) break
58 await $.process.run(['sleep', '0.25'])
59 }
60 holder = e.tool_use_id
61 try {
62 const one: HeldCommand = {
63 id: e.tool_use_id,
64 command: e.command,
65 risk: found.risk,
66 ...(await measure($, found)),
67 where: 'pane',
68 secondsLeft: timeoutSeconds > 0 ? timeoutSeconds : null,
69 timeoutSeconds: timeoutSeconds > 0 ? timeoutSeconds : null,
70 }
71 await update($, held, () => one)
72
73 const opened = await $.ui.open({ id: PANE, title: 'Blast Radius', focus: true })
74 if (!opened.isPlaced) {
75 await update($, held, h => (h ? { ...h, where: 'band' as const } : h)) // too narrow for a pane: draw above the prompt
76 }
77
78 // Nobody pressing within the timeout counts as Cancel, so an unattended session never stalls.
79 const deadline = timeoutSeconds > 0 ? (await $.clock.now()) + timeoutSeconds * 1000 : null
80 let isTimedOut = false
81 while (!decisions.has(one.id) && !next.signal.aborted) {
82 if (deadline !== null) {
83 const left = Math.ceil((deadline - (await $.clock.now())) / 1000)
84 if (left <= 0) {
85 isTimedOut = !decisions.has(one.id) // a press that landed at the deadline still counts
86 break
87 }
88 if (left !== one.secondsLeft) {
89 one.secondsLeft = left
90 await update($, held, h => (h && h.id === one.id ? { ...h, secondsLeft: left } : h))
91 }
92 }
93 await $.process.run(['sleep', '0.25'])
94 }
95 const decision = isTimedOut ? 'cancel' : (decisions.get(one.id) ?? 'cancel')
96 decisions.delete(one.id)
97 await update($, held, () => null)
98 await $.ui.close({ id: PANE })
99
100 if (decision === 'allow') await update($, allowed, list => [...list, one.command])
101 if (decision === 'proceed' || decision === 'allow') return next(e) // runs as written
102 if (isTimedOut) {
103 return {
104 deny:
105 `blast-radius: held this command and nobody answered within ${timeoutSeconds} s, so it was cancelled. ` +
106 `It would ${one.summary}. Don't retry it on your own: ask the user to run it, or run it again once they're back.`,
107 }
108 }
109 return { deny: `blast-radius: the user pressed Cancel on this command. It would ${one.summary}.` }
110 } finally {
111 holder = null
112 // If the hold failed partway, don't leave its command blocking the next call.
113 await update($, held, h => (h && h.id === e.tool_use_id ? null : h)).catch(() => {})
114 }
115 })
116
117 // The hook's loop picks the press up.
118 const decide = (id: string, decision: Decision) => {
119 decisions.set(id, decision)
120 }
121
122 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
123 const one = await read($, held)
124 const { Text } = $.ui.resolve(e)
125 if (!one) return <Text dimColor>Nothing held.</Text>
126 return report($.ui.resolve(e), one, decide)
127 })
128
129 // A held command takes the band until answered, above what is drawn there.
130 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
131 const one = await read($, held)
132 if (!one || one.where !== 'band' || e.props.hasSurvey) return next(e)
133 return report($.ui.resolve(e), one, decide)
134 })
135}
136hooks/classify.ts 178 lines1// Which commands are destructive, read from the command text alone: no `$`, nothing runs.
2
3import type { BlastRisk, BlastSeverity } from '../types'
4
5export type Found = {
6 risk: BlastRisk
7 severity: BlastSeverity
8 /** The folder the command runs in, from a `cd` or `git -C` before it. */
9 cwd?: string
10 /** rm: the paths; find: the roots. */
11 targets?: string[]
12 isFind?: boolean
13 /** find … -delete: the same search without -delete, for a dry run; absent when it can't run safely (-exec). */
14 findArgs?: string[]
15 /** The command had quotes: '~' and '$HOME' may be literal, so they are not expanded. */
16 hasQuotes?: boolean
17 /** git: the subcommand and its arguments (`['clean', '-fdx']`). */
18 git?: string[]
19 /** Statements or tool words worth listing as they are (SQL, a migration). */
20 quote?: string
21 isLease?: boolean
22}
23
24const MIGRATION =
25 /\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)|rake\s+db:(migrate|reset|drop)|alembic\s+(upgrade|downgrade)|typeorm\s+migration:run|django-admin\s+migrate|manage\.py\s+(migrate|flush))\b/
26/** A migration that throws the data away rather than changing the schema. */
27const DATA_RESET = /\b(prisma\s+migrate\s+reset|supabase\s+db\s+reset|(rails|rake)\s+db:(reset|drop)|manage\.py\s+flush|--force-reset|--accept-data-loss)\b/
28const SQL_CLIENT = /\b(psql|mysql|mariadb|sqlite3|duckdb|clickhouse(-client)?|pgcli|mycli|cockroach\s+sql)\b/
29const SQL_DROP = /\b(drop\s+(table|database|schema|view)\b[^;'"]*|truncate(\s+table)?\s+[\w."]+|delete\s+from\s+[\w."]+\s*(;|$|['"]))/gi
30
31/** Shell-like words: quotes kept together, the quotes themselves dropped. */
32export function split(text: string): string[] {
33 return (text.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, ''))
34}
35
36const hasFlag = (args: string[], short: string, long?: string) =>
37 args.some(a => (long !== undefined && a === long) || (/^-[a-zA-Z]+$/.test(a) && a.includes(short)))
38
39/** The git subcommand and its arguments, past global options (`-C dir`, `-c k=v`); the -C folder as cwd. */
40function gitWords(words: string[]): { sub: string; args: string[]; cwd?: string } | null {
41 if (words[0] !== 'git') return null
42 let cwd: string | undefined
43 let i = 1
44 while (i < words.length && words[i]!.startsWith('-')) {
45 if (words[i] === '-C') cwd = words[i + 1]
46 i += words[i] === '-C' || words[i] === '-c' ? 2 : 1
47 }
48 const sub = words[i]
49 return sub ? { sub, args: words.slice(i + 1), cwd } : null
50}
51
52function classifyGit(words: string[], cwd: string | undefined): Found | null {
53 const g = gitWords(words)
54 if (!g) return null
55 const { sub, args } = g
56 const at = g.cwd ?? cwd
57 const git = [sub, ...args]
58 if (sub === 'push' && args.some(a => a === '-f' || a.startsWith('--force') || /^\+/.test(a))) {
59 const isLease = args.some(a => a.startsWith('--force-with-lease') || a.startsWith('--force-if-includes')) && !args.some(a => a === '-f' || a === '--force')
60 return { risk: 'force-push', severity: isLease ? 'medium' : 'high', cwd: at, git, isLease }
61 }
62 if (sub === 'reset' && args.includes('--hard')) return { risk: 'git-discard', severity: 'high', cwd: at, git }
63 if (sub === 'checkout' && !hasFlag(args, 'b') && !hasFlag(args, 'B') && (args.includes('--') || args.includes('.'))) {
64 return { risk: 'git-discard', severity: 'high', cwd: at, git }
65 }
66 if (sub === 'restore') {
67 const isStagedOnly = (args.includes('--staged') || hasFlag(args, 'S')) && !(args.includes('--worktree') || hasFlag(args, 'W'))
68 if (!isStagedOnly) return { risk: 'git-discard', severity: 'high', cwd: at, git }
69 }
70 if (sub === 'stash' && (args[0] === 'drop' || args[0] === 'clear')) return { risk: 'git-discard', severity: 'high', cwd: at, git }
71 if (sub === 'clean' && (args.includes('--force') || hasFlag(args, 'f')) && !(args.includes('--dry-run') || hasFlag(args, 'n'))) {
72 return { risk: 'git-clean', severity: 'high', cwd: at, git }
73 }
74 if (sub === 'branch' && (hasFlag(args, 'D') || (args.includes('--delete') && args.includes('--force')))) {
75 return { risk: 'branch-delete', severity: 'medium', cwd: at, git }
76 }
77 return null
78}
79
80/** Which destructive kind a command is, if any, looking at each part of a compound command. */
81export function classify(command: string): Found | null {
82 let cwd: string | undefined
83 const hasQuotes = /['"\\]/.test(command)
84 for (const raw of command.split(/&&|\|\||;|\n|\|/)) {
85 const part = raw.trim()
86 const words = split(part)
87 const first = words[0] === 'sudo' ? 1 : 0
88 const head = words[first]
89 if (head === 'cd' && words[first + 1]) cwd = words[first + 1]
90
91 if (head === 'rm') {
92 const args = words.slice(first + 1)
93 const isRecursive = args.some(f => f === '--recursive' || (/^-[a-zA-Z]+$/.test(f) && /[rR]/.test(f)))
94 if (isRecursive) {
95 return { risk: 'delete', severity: 'high', cwd, targets: args.filter(a => !a.startsWith('-')), ...(hasQuotes ? { hasQuotes } : {}) }
96 }
97 }
98
99 if (head === 'find' && words.includes('-delete')) {
100 const args = words.slice(first + 1)
101 const firstExpr = args.findIndex(a => a.startsWith('-') || a === '(' || a === '!')
102 const roots = firstExpr === -1 ? args : args.slice(0, firstExpr)
103 const isPreviewable = !args.some(a => /^-(exec|execdir|ok|okdir|fprint|fls)$/.test(a))
104 return {
105 risk: 'delete',
106 severity: 'high',
107 cwd,
108 isFind: true,
109 targets: roots.length > 0 ? roots : ['.'],
110 ...(isPreviewable ? { findArgs: args.filter(a => a !== '-delete') } : {}),
111 ...(hasQuotes ? { hasQuotes } : {}),
112 }
113 }
114
115 const git = classifyGit(words.slice(first), cwd)
116 if (git) return git
117
118 if (SQL_CLIENT.test(part)) {
119 const drops = part.match(SQL_DROP)
120 if (drops) return { risk: 'sql-drop', severity: 'critical', cwd, quote: drops.map(s => s.trim()).join('; ') }
121 }
122
123 const migration = part.match(MIGRATION)
124 if (migration) {
125 const isReset = DATA_RESET.test(part)
126 return { risk: 'migration', severity: isReset ? 'critical' : 'medium', cwd, quote: migration[0] }
127 }
128 }
129 return null
130}
131
132/** The timeoutSeconds option as whole seconds: a number or a numeric string, 0 to wait forever. */
133export function timeoutFrom(value: unknown, fallback = 60): number {
134 const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
135 if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return fallback
136 return Math.ceil(n)
137}
138
139/** Warnings for a deletion that reaches far: the root, the home folder, a .git folder, everything here. */
140export function dangerOf(targets: string[], home: string): string[] {
141 const out = new Set<string>()
142 for (const t of targets) {
143 const p = t.replace(/\/+$/, '') || '/'
144 const bare = p.replace(/\/\*$/, '')
145 if (p === '/' || p === '/*' || bare === '') out.add('Targets the filesystem root')
146 else if (home && (bare === home || p === '~' || p === '~/*')) out.add('Targets your whole home folder')
147 else if (/(^|\/)\.git$/.test(p)) out.add("Deletes a .git folder: the repository's whole history")
148 else if (p === '.' || p === '*' || p === './*' || p === '.*') out.add('Deletes everything in the current folder')
149 else if (p === '..' || p.startsWith('../') && p.split('/').every(s => s === '..' || s === '*')) out.add('Reaches into the parent folder')
150 }
151 return [...out]
152}
153
154/** Files grouped by the folder they sit in, two levels deep, biggest first: what a deletion reaches. */
155export function groupFiles(files: string[], max = 8): { label: string; note: string }[] {
156 if (files.length <= max) return files.map(f => ({ label: f, note: '' }))
157 const counts = new Map<string, number>()
158 for (const f of files) {
159 const parts = f.split('/')
160 const key = parts.length > 2 ? `${parts.slice(0, 2).join('/')}/` : parts.length === 2 ? `${parts[0]}/` : f
161 counts.set(key, (counts.get(key) ?? 0) + 1)
162 }
163 // One folder holds them all: grouping would say nothing, so list the files themselves.
164 if (counts.size <= 1) return files.slice(0, max).map(f => ({ label: f, note: '' }))
165 return [...counts.entries()]
166 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
167 .slice(0, max)
168 .map(([label, n]) => ({ label, note: n === 1 && !label.endsWith('/') ? '' : `${n} file${n === 1 ? '' : 's'}` }))
169}
170
171export function size(kb: number): string {
172 if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`
173 if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
174 return `${kb} KB`
175}
176
177export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
178hooks/measure.ts 237 lines1// What a held command would destroy, read with the tools' own dry runs (`git clean -n`, `find` without -delete,
2// `git log`, `git status`, `du`). Nothing here changes anything.
3
4import type { BlastItem, BlastSeverity, HeldCommand } from '../types'
5import { dangerOf, groupFiles, plural, size, type Found } from './classify'
6
7export type Measured = Pick<HeldCommand, 'summary' | 'warnings' | 'recovery' | 'items' | 'total' | 'severity'>
8
9const MAX_FILES = 5000
10const MAX_ITEMS = 40
11// Paths the preview can't resolve without running the command: shell variables, command substitution, ~user.
12const UNRESOLVED = /[$`]|^~[^/]/
13const UNRESOLVED_QUOTED = /[$`~]/ // with quotes around, even ~ and $HOME are unknown
14
15const lines = (s: string) => s.split('\n').filter(l => l.trim() !== '')
16
17/** A path from the folder being deleted (build/chunk-1.js), not the whole path. */
18function short(file: string, targets: string[]): string {
19 for (const t of targets) {
20 const base = t.replace(/\/+$/, '')
21 const parent = base.includes('/') ? base.slice(0, base.lastIndexOf('/') + 1) : ''
22 if (parent && file.startsWith(parent)) return file.slice(parent.length)
23 }
24 return file.replace(/^\.\//, '')
25}
26
27const worst = (a: BlastSeverity, b: BlastSeverity): BlastSeverity =>
28 a === 'critical' || b === 'critical' ? 'critical' : a === 'high' || b === 'high' ? 'high' : 'medium'
29
30/** Runs a command (argv, no shell) in a folder and answers its exit code and output; the hooks module wraps $.process.run. */
31export type Run = (argv: string[], cwd?: string) => Promise<{ exitCode: number; stdout: string }>
32
33export async function measure(found: Found, io: { home: string; run: Run }): Promise<Measured> {
34 const home = io.home
35 // With quotes in the command '~' may be literal: don't expand, report it instead.
36 const expand = (p: string) => (home && !found.hasQuotes ? p.replace(/^~(?=\/|$)/, home).replace(/\$\{HOME\}|\$HOME\b/g, home) : p)
37 const cwd = found.cwd ? expand(found.cwd) : undefined
38 const unresolved = found.hasQuotes ? UNRESOLVED_QUOTED : UNRESOLVED
39 const at = cwd && !unresolved.test(cwd) ? cwd : undefined
40 const run = (argv: string[]) => io.run(argv, at)
41 const base = { warnings: [] as string[], recovery: null as string | null, items: [] as BlastItem[], total: 0, severity: found.severity }
42
43 try {
44 switch (found.risk) {
45 case 'delete': {
46 const targets = (found.targets ?? []).map(expand)
47 const warnings = dangerOf(targets, home)
48 const severity: BlastSeverity = warnings.length > 0 ? 'critical' : found.severity
49 const unknown = targets.filter(t => unresolved.test(t))
50 if (unknown.length > 0 || (cwd !== undefined && unresolved.test(cwd))) {
51 const list = unknown.length > 0 ? unknown : [`cd ${found.cwd}`]
52 return {
53 ...base,
54 severity,
55 warnings,
56 summary: `can't preview: ${list.length === 1 ? 'a path uses' : `${list.length} paths use`} a shell variable, check it by hand`,
57 items: list.map(label => ({ label })),
58 total: list.length,
59 }
60 }
61 let files: string[]
62 let kb = 0
63 if (found.isFind && !found.findArgs) {
64 return { ...base, severity, warnings, summary: "delete files (a find with -exec can't be previewed safely)" }
65 }
66 if (found.findArgs) {
67 const r = await run(['find', ...found.findArgs])
68 files = lines(r.stdout).slice(0, MAX_FILES).map(f => short(f, targets))
69 } else {
70 // Unquoted $t expands globs, and nothing else, without running the command.
71 const script =
72 'shopt -s nullglob dotglob; for t in "$@"; do for p in $t; do [ -e "$p" ] || continue; ' +
73 `echo "S $(du -sk "$p" | cut -f1)"; find "$p" -type f | head -n ${MAX_FILES} | sed "s/^/F /"; done; done`
74 const r = await run(['bash', '-c', script, 'blast-radius', ...targets])
75 const out = r.stdout.split('\n')
76 files = out.filter(l => l.startsWith('F ')).map(l => short(l.slice(2), targets))
77 kb = out.filter(l => l.startsWith('S ')).reduce((sum, l) => sum + Number(l.slice(2)), 0)
78 }
79 if (files.length === 0 && kb === 0) return { ...base, severity, warnings, summary: 'delete nothing that exists right now' }
80 const count = files.length >= MAX_FILES ? `${MAX_FILES}+ files` : plural(files.length, 'file')
81 return {
82 ...base,
83 severity,
84 warnings,
85 summary: `delete ${count}${kb > 0 ? ` (${size(kb)})` : ''}`,
86 recovery: await recoveryOf(run, targets, files.length),
87 items: groupFiles(files),
88 total: files.length,
89 }
90 }
91
92 case 'force-push': {
93 const lost = lines((await run(['git', 'log', '--oneline', 'HEAD..@{u}'])).stdout)
94 const added = lines((await run(['git', 'log', '--oneline', '@{u}..HEAD'])).stdout)
95 const items = [...lost.map(c => ({ label: c, note: 'lost' })), ...added.map(c => ({ label: c, note: 'pushed' }))]
96 return {
97 ...base,
98 severity: lost.length === 0 ? 'medium' : found.severity,
99 summary: `overwrite the remote branch: ${plural(lost.length, 'remote commit')} lost, ${added.length} pushed (as of the last fetch)`,
100 warnings: lost.length > 0 && !found.isLease ? ['Plain --force: anything pushed since your last fetch is lost too'] : [],
101 recovery:
102 lost.length === 0
103 ? 'No remote commit is lost, as of your last fetch'
104 : 'Lost commits survive only in clones that already have them, or your own reflog if you had them locally',
105 items: items.slice(0, MAX_ITEMS),
106 total: items.length,
107 }
108 }
109
110 case 'git-discard': {
111 const [sub, ...args] = found.git ?? []
112 if (sub === 'stash') {
113 const stashes = lines((await run(['git', 'stash', 'list'])).stdout)
114 const dropped = args[0] === 'clear' ? stashes : stashes.filter(s => s.startsWith(args[1] ?? 'stash@{0}'))
115 return {
116 ...base,
117 summary: dropped.length === 0 ? 'drop nothing: no such stash' : `drop ${plural(dropped.length, 'stash')}`,
118 recovery: 'A dropped stash can still be found with `git fsck --unreachable` until git garbage-collects it',
119 items: dropped.slice(0, MAX_ITEMS).map(label => ({ label })),
120 total: dropped.length,
121 }
122 }
123 const paths = args.includes('--') ? args.slice(args.indexOf('--') + 1) : args.filter(a => !a.startsWith('-'))
124 const ref = sub === 'reset' ? args.find(a => !a.startsWith('-')) : undefined
125 const scope = sub === 'reset' ? [] : ['--', ...paths.filter(p => p !== ref)]
126 const changed = lines((await run(['git', 'status', '--porcelain', '--untracked-files=no', ...scope])).stdout)
127 const stat = (await run(['git', 'diff', '--shortstat', 'HEAD', ...scope])).stdout.trim()
128 const commits = ref ? lines((await run(['git', 'log', '--oneline', `${ref}..HEAD`])).stdout) : []
129 const items = [
130 ...changed.map(l => ({ label: l.slice(3), note: l.slice(0, 2).trim() === 'D' ? 'deleted' : 'edited' })),
131 ...commits.map(c => ({ label: c, note: 'leaves the branch' })),
132 ]
133 const parts = [
134 changed.length > 0 ? `discard uncommitted edits in ${plural(changed.length, 'file')}` : 'discard no uncommitted edits',
135 ...(commits.length > 0 ? [`move the branch back ${plural(commits.length, 'commit')}`] : []),
136 ]
137 return {
138 ...base,
139 severity: changed.length === 0 && commits.length === 0 ? 'medium' : found.severity,
140 summary: `${parts.join(', ')}${stat ? ` (${stat.replace(/ changed|\(\+\)|\(-\)/g, '')})` : ''}`,
141 recovery:
142 changed.length > 0
143 ? `Uncommitted edits are gone for good${commits.length > 0 ? '; the commits stay in `git reflog` for about 90 days' : ''}`
144 : commits.length > 0
145 ? 'The commits stay in `git reflog` for about 90 days'
146 : null,
147 items: items.slice(0, MAX_ITEMS),
148 total: items.length,
149 }
150 }
151
152 case 'git-clean': {
153 const [, ...args] = found.git ?? []
154 // The same flags without -f, plus -n: git's own dry run.
155 const dry = args.map(a => (/^-[a-zA-Z]+$/.test(a) ? a.replace(/f/g, '') : a)).filter(a => a !== '-' && a !== '--force')
156 const removed = lines((await run(['git', 'clean', '-n', ...dry])).stdout).map(l => l.replace(/^Would remove /, ''))
157 const withIgnored = args.some(a => /^-[a-zA-Z]*[xX]/.test(a))
158 return {
159 ...base,
160 summary: removed.length === 0 ? 'delete nothing: no untracked files' : `delete ${plural(removed.length, 'untracked path')}`,
161 warnings: withIgnored ? ['-x also deletes ignored files: .env, node_modules, build output'] : [],
162 recovery: removed.length > 0 ? 'Untracked files are not in git: they are gone for good' : null,
163 items: groupFiles(removed),
164 total: removed.length,
165 }
166 }
167
168 case 'branch-delete': {
169 const [, ...args] = found.git ?? []
170 const branches = args.filter(a => !a.startsWith('-'))
171 const items: BlastItem[] = []
172 const warnings: string[] = []
173 let severity: BlastSeverity = found.severity
174 for (const b of branches) {
175 const unmerged = lines((await run(['git', 'log', '--oneline', `HEAD..${b}`])).stdout)
176 const remotes = lines((await run(['git', 'branch', '-r', '--contains', b])).stdout)
177 items.push({ label: b, note: `${plural(unmerged.length, 'commit')} not in HEAD${remotes.length > 0 ? ', on the remote' : ', local only'}` })
178 if (unmerged.length > 0 && remotes.length === 0) {
179 warnings.push(`${b} has ${plural(unmerged.length, 'commit')} that exist nowhere else`)
180 severity = worst(severity, 'high')
181 }
182 }
183 return {
184 ...base,
185 severity,
186 summary: `delete ${branches.length} branch${branches.length === 1 ? '' : 'es'}`,
187 warnings,
188 recovery: 'A deleted branch can be brought back from `git reflog` for about 90 days',
189 items,
190 total: items.length,
191 }
192 }
193
194 case 'migration': {
195 const status = lines((await run(['git', 'status', '--porcelain'])).stdout)
196 const touched = status.filter(l => /migrat|schema|prisma|supabase|drizzle|alembic|db\//i.test(l))
197 return {
198 ...base,
199 summary: found.severity === 'critical' ? 'reset the database: every row is deleted' : 'change the database schema (no preview)',
200 warnings: found.severity === 'critical' ? [`${found.quote ?? 'This command'} drops the data, not only the schema`] : [],
201 recovery: found.severity === 'critical' ? 'Only a backup brings the data back' : 'Most migrations can be rolled back; data dropped by one cannot',
202 items: touched.map(l => ({ label: l.slice(3), note: 'uncommitted' })).slice(0, MAX_ITEMS),
203 total: touched.length,
204 }
205 }
206
207 case 'sql-drop': {
208 const statements = (found.quote ?? '').split('; ').filter(Boolean)
209 return {
210 ...base,
211 summary: `run ${plural(statements.length, 'destructive SQL statement')}`,
212 recovery: 'Only a backup brings dropped or truncated data back',
213 items: statements.map(label => ({ label })),
214 total: statements.length,
215 }
216 }
217 }
218 } catch {
219 return { ...base, summary: `run a destructive ${found.risk} command (could not measure it)` }
220 }
221}
222
223/** How many of the deleted files git could restore. */
224async function recoveryOf(run: (argv: string[]) => ReturnType<Run>, targets: string[], files: number): Promise<string | null> {
225 try {
226 const r = await run(['git', 'ls-files', '--', ...targets])
227 if (r.exitCode !== 0) return 'Not in a git repository: nothing to restore them from'
228 const tracked = Math.min(files, lines(r.stdout).length)
229 const untracked = files - tracked
230 if (tracked === 0) return 'None are tracked by git: they are gone for good'
231 if (untracked === 0) return `All are tracked by git: \`git checkout -- ${targets.join(' ')}\` brings them back`
232 return `${tracked} tracked by git (restorable) · ${untracked} untracked (gone for good)`
233 } catch {
234 return null
235 }
236}
237hooks/view.tsx 104 lines1// The held command, as a pane (or above the prompt where no pane fits). Host elements and theme colours only,
2// laid out as the other mods in this marketplace: a bold header, dim detail, a blank row between sections,
3// the buttons on their own row with the safe choice first.
4
5import type { Elements, RenderElement } from 'claude-code'
6
7import type { BlastRisk, BlastSeverity, HeldCommand } from '../types'
8
9export type Decision = 'proceed' | 'cancel' | 'allow'
10
11const TITLE: Record<BlastRisk, string> = {
12 delete: 'Deletes files',
13 'force-push': 'Force push',
14 'git-discard': 'Discards git changes',
15 'git-clean': 'Deletes untracked files',
16 'branch-delete': 'Deletes branches',
17 migration: 'Database migration',
18 'sql-drop': 'Destructive SQL',
19}
20
21const SEVERITY_COLOR: Record<BlastSeverity, 'error' | 'warning'> = { critical: 'error', high: 'error', medium: 'warning' }
22const SEVERITY_LABEL: Record<BlastSeverity, string> = { critical: 'critical', high: 'high risk', medium: 'medium risk' }
23
24/** Whether the recovery line is good news, mixed, or bad. */
25function recoveryTone(text: string): 'success' | 'warning' | 'error' {
26 if (/gone for good|Only a backup|nothing to restore|nowhere else/.test(text)) return 'error'
27 if (/^All are tracked|^No remote commit/.test(text)) return 'success'
28 return 'warning'
29}
30
31const BAR = 12
32
33type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Code'>
34
35export function report(E: Els, one: HeldCommand, decide: (id: string, d: Decision) => void): RenderElement {
36 const { Box, Text, Button, Code } = E
37 const color = SEVERITY_COLOR[one.severity]
38 const isBand = one.where === 'band'
39 const shown = one.items.slice(0, isBand ? 4 : 12)
40 const more = one.total - shown.length
41 const noteW = Math.max(0, ...shown.map(i => (i.note ?? '').length))
42
43 const countdown =
44 one.secondsLeft !== null && one.timeoutSeconds ? (
45 <Text key="countdown" dimColor={one.secondsLeft > 10} color={one.secondsLeft <= 10 ? 'warning' : undefined}>
46 {`auto-cancels in ${one.secondsLeft} s `}
47 <Text color={one.secondsLeft <= 10 ? 'warning' : undefined}>{'━'.repeat(Math.max(1, Math.round((one.secondsLeft / one.timeoutSeconds) * BAR)))}</Text>
48 <Text dimColor>{'─'.repeat(Math.max(0, BAR - Math.round((one.secondsLeft / one.timeoutSeconds) * BAR)))}</Text>
49 </Text>
50 ) : null
51
52 return (
53 <Box key="blast-radius" flexDirection="column" rowGap={1}>
54 <Box flexDirection="column">
55 <Text wrap="truncate-end">
56 <Text color={color}>{'⚠ '}</Text>
57 <Text bold>{TITLE[one.risk]}</Text>
58 <Text dimColor>{' · '}</Text>
59 <Text color={color}>{SEVERITY_LABEL[one.severity]}</Text>
60 </Text>
61 <Code source={one.command} language="bash" wrap={isBand ? 'truncate-end' : 'wrap'} />
62 </Box>
63
64 <Box flexDirection="column">
65 <Text bold wrap="wrap">{`Would ${one.summary}`}</Text>
66 {one.warnings.map(w => (
67 <Text color="error" wrap="wrap">{`✖ ${w}`}</Text>
68 ))}
69 {one.recovery ? (
70 <Text wrap="wrap">
71 <Text color={recoveryTone(one.recovery)}>{'● '}</Text>
72 <Text dimColor>{one.recovery}</Text>
73 </Text>
74 ) : null}
75 </Box>
76
77 {shown.length > 0 ? (
78 <Box flexDirection="column">
79 {shown.map(item => (
80 <Box flexDirection="row" columnGap={2}>
81 <Box flexGrow={1} flexShrink={1} minWidth={0}>
82 <Text wrap="truncate-middle">{` ${item.label}`}</Text>
83 </Box>
84 {noteW > 0 ? (
85 <Box width={noteW} flexShrink={0}>
86 <Text dimColor>{item.note ?? ''}</Text>
87 </Box>
88 ) : null}
89 </Box>
90 ))}
91 {more > 0 ? <Text dimColor>{` … and ${more} more`}</Text> : null}
92 </Box>
93 ) : null}
94
95 <Box flexDirection="row" columnGap={1} alignItems="center">
96 <Button key="cancel" label="Cancel" hotkey="c" variant="primary" autoFocus onPress={() => decide(one.id, 'cancel')} />
97 <Button key="proceed" label="Proceed" hotkey="y" onPress={() => decide(one.id, 'proceed')} />
98 <Button key="allow" label="Allow this session" hotkey="a" onPress={() => decide(one.id, 'allow')} />
99 {countdown}
100 </Box>
101 </Box>
102 )
103}
104types/index.d.ts 41 lines1// Contract for blast-radius: the values it keeps in $.state.
2
3export type BlastRisk = 'delete' | 'force-push' | 'git-discard' | 'git-clean' | 'branch-delete' | 'migration' | 'sql-drop'
4
5/** critical: irreversible and wide (home folder, .git, SQL drop); high: data loss; medium: usually recoverable. */
6export type BlastSeverity = 'critical' | 'high' | 'medium'
7
8/** One line of the preview: a folder and how many files under it, a commit, a changed file. */
9export type BlastItem = { label: string; note?: string }
10
11export type HeldCommand = {
12 id: string
13 command: string
14 risk: BlastRisk
15 severity: BlastSeverity
16 /** One line: what running it does ("delete 340 files (38.2 MB)"). */
17 summary: string
18 /** Loud warnings above the preview ("Targets your home folder"). */
19 warnings: string[]
20 /** Whether what it destroys can be got back, and how; null when not known. */
21 recovery: string | null
22 items: BlastItem[]
23 /** How many items there are in all, when more than are listed. */
24 total: number
25 where: 'pane' | 'band'
26 /** Seconds until the hold auto-cancels; null when it waits for a press forever. */
27 secondsLeft: number | null
28 /** The timeout it started from, for the countdown bar; null when it waits forever. */
29 timeoutSeconds: number | null
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'blast-radius': {
35 held: HeldCommand | null
36 /** Commands the person allowed for the rest of the session, exactly as written. */
37 allowed: string[]
38 }
39 }
40}
41