SLOPSHOPPER

blast-radius

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

newpanebandguardprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ blast-radius ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing held. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by blast-radius: blast-radius: held this command a │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · blast-radius
Nothing held.
README

blast-radius

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 ━━━━━━━━────

What it holds

CommandPreviewSeverity
rm -r / rm -rfevery file and the total size, grouped by folder; how many git can restorehigh, critical for /, ~, .git, .
find … -deletethe same find without -delete (not previewed when it has -exec)high
git push --forceremote commits lost and commits pushed, as of the last fetchhigh; medium for --force-with-lease or nothing lost
git reset --hard, git checkout -- …, git restore, git stash drop/clearuncommitted edits discarded, commits leaving the branch, stashes droppedhigh
git clean -f…git's own dry run (git clean -n); warns when -x takes ignored fileshigh
git branch -Dcommits not in HEAD, whether the branch exists on the remotemedium; high when its commits exist nowhere else
migrations (prisma, supabase, drizzle, rails, alembic, django, knex, …)uncommitted migration filesmedium; critical for resets that drop data
DROP / TRUNCATE / DELETE FROM through a SQL clientthe statementscritical

Previews only read: dry runs, git log, git status, du, find. Nothing is changed until you press Proceed.

Install

/plugin install blast-radius@arhun-plugins

Behaviour

  • No answer within 60 s cancels the command, so an unattended session never stalls. Set timeoutSeconds in /config (or pluginConfigs["blast-radius"].options in settings); 0 waits forever.
  • Allow this session runs it and lets the exact same command through for the rest of the session.
  • With no screen attached (claude -p, an SDK host that draws nothing) it cancels at once: nobody could answer.
  • One command is held at a time; a second waits its turn, then gets its own countdown.
  • It opens as a pane; where no pane fits it draws above the prompt instead.
  • Paths with shell variables (other than ~ and $HOME) say "can't preview" rather than guess.

Files

  • 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 .
Source 5 files
hooks/register.tsx 136 lines
1// 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}
136
hooks/classify.ts 178 lines
1// 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'}`
178
hooks/measure.ts 237 lines
1// 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}
237
hooks/view.tsx 104 lines
1// 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}
104
types/index.d.ts 41 lines
1// 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