SLOPSHOPPER

blast-radius

Stops destructive shell commands, lists exactly which files would be lost, and waits for you to Proceed or Cancel.

newpaneguardprocess
v0.1.0no licenseupdated 2026-10-12Isaacg1000/isaac-mods/plugins/blast-radius
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 │ ┃ No destructive command pending. │ ⏺ 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: the user cancelled │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · blast-radius
No destructive command pending.
README

isaac-mods

Eight mods for Claude Code (terminal and the desktop app's Code tab). Requires Claude Code 2.1.287 or newer.

ModWhat it doesCommands
savvy-progressProgress bar above the prompt; an Agents side panel with a pixel crab per subagent showing model, effort, task progress, context, tokens and estimated cost/savvy, /crab-style <keyword> <color> <hat>
tool-skinsThemed icons and colors for tool calls (Tokyo Night, Nord, Dracula, Gruvbox, Solarized, Catppuccin, Rose Pine)/skin <theme>
you-should-knowAfter a long reply, a Haiku side agent flags costs, risks and actions you might miss/ysk on, /ysk off, /ysk focus <text>
file-treeA panel showing which files Claude is reading or editing; turns green once committed/files
cache-taxWarns before a prompt reloads a cold prompt cache, with a token and cost estimate/keepwarm, /cachetax ttl <minutes>
blast-radiusStops rm -r, Remove-Item -Recurse, git clean -f, git reset --hard and similar, lists the files at stake, and waits for Proceed or Cancelautomatic
reflectSpots corrections in your prompts and offers to save them as rules in CLAUDE.md/reflect <rule>
terminal-browserA text-mode browser pane; Claude can open pages there too/browse <url or path>

Cost figures are estimates at API list prices. you-should-know and reflect make small Haiku calls, which count toward your usage.

Install

At a Claude Code prompt in a terminal:

/plugin install savvy-progress --marketplace Isaacg1000/isaac-mods

Answer y to add the marketplace, then pick the user scope. Repeat with each mod name you want.

Or from a shell, all at once:

claude plugin marketplace add Isaacg1000/isaac-mods
claude plugin install savvy-progress@isaac-mods --scope user
claude plugin install tool-skins@isaac-mods --scope user
claude plugin install you-should-know@isaac-mods --scope user
claude plugin install file-tree@isaac-mods --scope user
claude plugin install cache-tax@isaac-mods --scope user
claude plugin install blast-radius@isaac-mods --scope user
claude plugin install reflect@isaac-mods --scope user
claude plugin install terminal-browser@isaac-mods --scope user

Developing

Each mod lives in plugins/<name>/: hooks/register.tsx is the hooks module, types/index.d.ts its state contract. Check and test one with:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

When this folder is added as a local marketplace (claude plugin marketplace add <this folder>), edits are picked up with /reload-plugins.

Source 2 files
hooks/register.tsx 177 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Blast } from '../types'
5
6const PANE = 'blast-radius'
7const pending = atom({ plugin: 'blast-radius', key: 'pending' } as const, null as Blast | null)
8
9const MAX_LISTED = 400
10const MAX_WALK = 5000
11
12type Danger = { reason: string; kind: 'paths'; targets: string[] } | { reason: string; kind: 'git-clean'; args: string[] } | { reason: string; kind: 'git-discard' }
13
14// Split on shell separators, then into words (quotes respected, no expansion).
15function segments(command: string): string[][] {
16  return command
17    .split(/&&|\|\||[;|\n]/)
18    .map(seg => (seg.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, '')))
19    .filter(ws => ws.length > 0)
20}
21
22function classify(command: string): Danger[] {
23  const found: Danger[] = []
24  for (const raw of segments(command)) {
25    const words = raw[0] === 'sudo' ? raw.slice(1) : raw
26    const cmd = (words[0] ?? '').toLowerCase()
27    const flags = words.slice(1).filter(w => /^-/.test(w) || /^\/[a-zA-Z]$/.test(w))
28    const args = words.slice(1).filter(w => !flags.includes(w))
29    const has = (re: RegExp) => flags.some(f => re.test(f))
30
31    if (cmd === 'rm' && (has(/^-[a-zA-Z]*[rR]/) || has(/^--recursive$/))) {
32      found.push({ reason: 'rm -r/-f', kind: 'paths', targets: args })
33    } else if (/^(remove-item|ri|rm|del|erase|rd|rmdir)$/.test(cmd) && has(/^-r(ecurse)?$/i)) {
34      found.push({ reason: 'Remove-Item -Recurse', kind: 'paths', targets: args.filter(a => !/^-/.test(a)) })
35    } else if (/^(rd|rmdir|del|erase)$/.test(cmd) && has(/^\/s$/i)) {
36      found.push({ reason: `${cmd} /s`, kind: 'paths', targets: args })
37    } else if (cmd === 'find' && words.includes('-delete')) {
38      found.push({ reason: 'find -delete', kind: 'paths', targets: args.slice(0, 1) })
39    } else if (cmd === 'git' && words[1] === 'clean' && has(/^-[a-zA-Z]*f/)) {
40      found.push({ reason: 'git clean -f', kind: 'git-clean', args: words.slice(2) })
41    } else if (cmd === 'git' && words[1] === 'reset' && words.includes('--hard')) {
42      found.push({ reason: 'git reset --hard', kind: 'git-discard' })
43    } else if (cmd === 'git' && (words[1] === 'checkout' || words[1] === 'restore') && (words.includes('.') || words.includes('--'))) {
44      found.push({ reason: `git ${words[1]} (discards changes)`, kind: 'git-discard' })
45    }
46  }
47  return found
48}
49
50const join = (base: string, p: string) =>
51  /^([a-zA-Z]:[\\/]|[\\/])/.test(p) ? p : `${base.replace(/[\\/]$/, '')}/${p.replace(/^\.[\\/]/, '')}`
52
53async function measure($: EngineInterface, dangers: Danger[], command: string): Promise<Blast> {
54  const cwd = await $.session.cwd()
55  const blast: Blast = { command, reason: dangers.map(d => d.reason).join(', '), files: [], fileCount: 0, dirCount: 0, bytes: 0, isTruncated: false, notes: [] }
56  const add = (path: string, size: number) => {
57    blast.fileCount += 1
58    blast.bytes += size
59    if (blast.files.length < MAX_LISTED) blast.files.push(path.replace(/\\/g, '/'))
60    else blast.isTruncated = true
61  }
62  let walked = 0
63  const walk = async (dir: string): Promise<void> => {
64    blast.dirCount += 1
65    const entries = await $.fs.list(dir).catch(() => [])
66    for (const entry of entries) {
67      if (++walked > MAX_WALK) {
68        blast.isTruncated = true
69        return
70      }
71      const full = `${dir.replace(/[\\/]$/, '')}/${entry.name}`
72      if (entry.kind === 'dir' && !entry.isLink) await walk(full)
73      else add(full, entry.size)
74    }
75  }
76
77  for (const d of dangers) {
78    if (d.kind === 'paths') {
79      if (d.targets.length === 0) blast.notes.push(`${d.reason}: no explicit target`)
80      for (const t of d.targets) {
81        if (/[*?[\]$`~]/.test(t)) {
82          blast.notes.push(`pattern not expanded: ${t}`)
83          continue
84        }
85        const full = join(cwd, t)
86        const st = await $.fs.stat(full).catch(() => undefined)
87        if (!st) blast.notes.push(`does not exist: ${t}`)
88        else if (st.kind === 'dir' && !st.isLink) await walk(full)
89        else add(full, st.size)
90        if (/^([a-zA-Z]:[\\/]?|\/|~|\.|\.\.)$/.test(t.replace(/[\\/]$/, ''))) blast.notes.push(`⚠ targets a root or the whole working directory: ${t}`)
91      }
92    } else if (d.kind === 'git-clean') {
93      const dry = d.args.map(a => (/^-[a-zA-Z]*f/.test(a) ? a.replace(/f/g, '') : a)).filter(a => a !== '-')
94      const out = await $.process.run(['git', 'clean', '-n', ...dry], { cwd, timeoutMs: 15000 }).catch(() => undefined)
95      for (const line of (out?.stdout ?? '').split('\n')) {
96        const m = line.match(/^Would remove (.+)$/)
97        if (m) add(m[1]!, 0)
98      }
99      if (!out) blast.notes.push('could not run git clean -n to preview')
100    } else {
101      const out = await $.process.run(['git', 'status', '--porcelain'], { cwd, timeoutMs: 15000 }).catch(() => undefined)
102      for (const line of (out?.stdout ?? '').split('\n')) {
103        if (line.length > 3 && !line.startsWith('??')) add(`${line.slice(3)} (uncommitted changes lost)`, 0)
104      }
105      if (!out) blast.notes.push('could not run git status to preview')
106    }
107  }
108  return blast
109}
110
111const fmtBytes = (n: number) => (n > 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : n > 1e3 ? `${(n / 1e3).toFixed(1)} KB` : `${n} B`)
112
113export const register: Register = on => {
114  on('tool.call', async ($, e, next) => {
115    if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
116    const command = e.command
117    const dangers = classify(command)
118    if (dangers.length === 0) return next(e)
119
120    const blast = await measure($, dangers, command)
121    await update($, pending, () => blast)
122    await $.ui.open({ id: PANE, title: 'Blast Radius' })
123
124    const what =
125      blast.fileCount > 0
126        ? `${blast.fileCount}${blast.isTruncated ? '+' : ''} file(s)${blast.bytes ? `, ${fmtBytes(blast.bytes)}` : ''}`
127        : 'an unknown set of files'
128    let answer = 'Cancel'
129    try {
130      answer = await $.ui.ask(`Blast Radius: "${command.slice(0, 80)}" would affect ${what}. See the Blast Radius pane. Proceed?`, {
131        header: 'Blast Radius',
132        options: ['Cancel', 'Proceed'],
133      })
134    } catch {
135      answer = 'Cancel'
136    }
137    await update($, pending, () => null)
138    await $.ui.close({ id: PANE })
139
140    if (answer !== 'Proceed') {
141      return { deny: `Blast Radius: the user cancelled "${command.slice(0, 120)}" (${blast.reason}, ${what}). Do not retry it another way without asking.` }
142    }
143    return next(e)
144  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'Blast Radius: its safety check failed, so the command was blocked.' }))
145
146  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
147    const { Box, Text } = $.ui.resolve(e)
148    const b = await read($, pending)
149    if (!b) return <Text dimColor>No destructive command pending.</Text>
150    const room = Math.max(5, (e.viewport?.rows ?? 30) - 12)
151    return (
152      <Box flexDirection="column">
153        <Text bold color="error">
154          ⚠ Destructive command intercepted ({b.reason})
155        </Text>
156        <Box borderStyle="round" borderColor="error" paddingX={1}>
157          <Text wrap="wrap">{b.command}</Text>
158        </Box>
159        <Text>
160          {b.fileCount}
161          {b.isTruncated ? '+' : ''} file(s) in {b.dirCount} folder(s){b.bytes ? ` · ${fmtBytes(b.bytes)}` : ''} would be gone:
162        </Text>
163        {b.files.slice(0, room).map(f => (
164          <Text color="diffRemoved" wrap="truncate-start">
165            − {f}
166          </Text>
167        ))}
168        {b.files.length > room && <Text dimColor>…and {b.fileCount - room} more</Text>}
169        {b.notes.map(n => (
170          <Text color="warning">{n}</Text>
171        ))}
172        <Text dimColor>Answer Proceed or Cancel in the dialog.</Text>
173      </Box>
174    )
175  })
176}
177
types/index.d.ts 17 lines
1export type Blast = {
2  command: string
3  reason: string
4  files: string[]
5  fileCount: number
6  dirCount: number
7  bytes: number
8  isTruncated: boolean
9  notes: string[]
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'blast-radius': { pending: Blast | null }
15  }
16}
17