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

Eight mods for Claude Code (terminal and the desktop app's Code tab). Requires Claude Code 2.1.287 or newer.
| Mod | What it does | Commands |
|---|---|---|
| savvy-progress | Progress 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-skins | Themed icons and colors for tool calls (Tokyo Night, Nord, Dracula, Gruvbox, Solarized, Catppuccin, Rose Pine) | /skin <theme> |
| you-should-know | After a long reply, a Haiku side agent flags costs, risks and actions you might miss | /ysk on, /ysk off, /ysk focus <text> |
| file-tree | A panel showing which files Claude is reading or editing; turns green once committed | /files |
| cache-tax | Warns before a prompt reloads a cold prompt cache, with a token and cost estimate | /keepwarm, /cachetax ttl <minutes> |
| blast-radius | Stops rm -r, Remove-Item -Recurse, git clean -f, git reset --hard and similar, lists the files at stake, and waits for Proceed or Cancel | automatic |
| reflect | Spots corrections in your prompts and offers to save them as rules in CLAUDE.md | /reflect <rule> |
| terminal-browser | A 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.
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
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.
hooks/register.tsx 177 lines1import { 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}
177types/index.d.ts 17 lines1export 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