Shows how far each change can reach: the files that depend on every file Claude edits, and a flag on wide-reaching shell commands. Show only, never blocks.

Seven mods for Claude Code. Each one is a plugin of function hooks: it draws panes, status lines and toasts inside Claude Code, in the terminal and in the desktop app's Code tab.
| Mod | What it does | How to use it | |
|---|---|---|---|
| savvy-progress | A live band above the prompt while Claude works: what it is doing now ("Reading format.ts · 4s"), elapsed time, steps, files read and changed, errors, and a progress bar: the real task list when Claude keeps one, otherwise a time bar guessed from how long your past turns took (clearly labelled a guess). A one-line summary after each turn. | Shows by itself. `/savvy-progress [on\ | off]` |
| skins | Re-skins the spinner (glyphs, verbs, color), the turn's closing line and the prompt hints. Skins: matrix, pirate, zen, retro, space. Your choice is remembered. | /skin (picker), /skin matrix, /skin off | |
| filetree | A live file-tree pane of the project. Marks the files Claude read (R), changed (M) and created (A) this session, and opens the folders they live in. | /filetree, /filetree touched, /filetree all, /filetree close | |
| cache-tax | How well the prompt cache works, and the "tax": the money paid above the ideal where every input token is a cache read. Status line cache 94% · tax $0.12, a per-turn table, and a toast with the likely cause when the cache busts. | /cache-tax, /cache-tax reset | |
| blast-radius | For each file Claude edits, the other files that import it; a flag on wide-reaching shell commands (git reset --hard, rm -rf, force push, ...). Shows only, never blocks. | /blast-radius, /blast-radius session, /blast-radius clear | |
| reflect | Claude critiques its own recent work: what may be wrong or missed, risky assumptions, what to check next. One button puts the follow-ups in your prompt box (it never sends them). Optional auto mode, off by default. | /reflect [focus], /reflect show, `/reflect auto on\ | off` |
| replay-theatre | Records the session and plays it back like a film: scene by scene, step by step, with play/pause, step, speed and a timeline. | /replay, /replay last, /replay copy; keys k play, j/l step, h/n scene, s speed, r restart |
In a Claude Code terminal session, install any mod by name:
/plugin install savvy-progress --marketplace gauravruhela07/claude-mods
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Repeat for each mod you want: skins, filetree, cache-tax, blast-radius, reflect, replay-theatre.
A mod installed from a terminal at the user scope also loads in the desktop app's Code tab.
git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree
reflect calls the model, and only when you run /reflect or turn auto mode on. It asks over the session's own transcript, so most of it is served from the prompt cache.cache-tax/hooks/prices.ts. Claude Code does not report 5-minute and 1-hour cache writes apart, so all writes use the 5-minute price.rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.Each mod folder holds .claude-plugin/plugin.json, hooks/ (the hooks module), types/index.d.ts (its state contract) and tests/.
claude plugin validate filetree
claude plugin test filetreehooks/register.tsx 384 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Blast, View } from '../types'
5import {
6 EDIT_TOOLS,
7 HUGE,
8 LISTED,
9 MAX_KEPT,
10 MAX_RISKY,
11 WIDE,
12 absolute,
13 baseName,
14 classify,
15 clip,
16 clipPath,
17 clock,
18 dependentPattern,
19 grepArgv,
20 headline,
21 inside,
22 moduleRefs,
23 parseHits,
24 plural,
25 rgArgv,
26 statusLine,
27} from './blast'
28
29const PANE = 'blast-radius'
30const TITLE = 'Blast radius'
31const USAGE = 'Usage: /blast-radius [session|turn|clear] (no words: this turn). Nothing changed.'
32const SEARCH_MS = 3000
33const TICK_MS = 500
34
35const root = atom({ plugin: 'blast-radius', key: 'root' } as const, '')
36const turn = atom({ plugin: 'blast-radius', key: 'turn' } as const, 0)
37const files = atom({ plugin: 'blast-radius', key: 'files' } as const, [])
38const turnPaths = atom({ plugin: 'blast-radius', key: 'turnPaths' } as const, [])
39const risky = atom({ plugin: 'blast-radius', key: 'risky' } as const, [])
40const queue = atom({ plugin: 'blast-radius', key: 'queue' } as const, [])
41const toasted = atom({ plugin: 'blast-radius', key: 'toasted' } as const, [])
42const view = atom({ plugin: 'blast-radius', key: 'view' } as const, 'turn')
43const shown = atom({ plugin: 'blast-radius', key: 'shown' } as const, [])
44
45type $ = EngineInterface
46
47// One search at a time: a tick that finds one running leaves the queue to it.
48// Module variables: a reload drops them with the old timers, and the queue in $.state carries on.
49let isBusy = false
50// Set when something may wait in the queue, so an idle tick costs no state read (true at load: look once).
51let isQueued = true
52
53const str = (input: Record<string, unknown>, key: string): string => {
54 const value = input[key]
55
56 return typeof value === 'string' ? value : ''
57}
58
59const isBlast = (file: Blast | undefined): file is Blast => file !== undefined
60
61/** This turn's edited files, in the order they were first edited. */
62const turnFiles = (all: readonly Blast[], paths: readonly string[]): Blast[] =>
63 paths.map(path => all.find(file => file.path === path)).filter(isBlast)
64
65/** Pins (or clears) the status line from this turn's edits. */
66const refreshStatus = async ($: $): Promise<void> => {
67 const all = await read($, files)
68 const paths = await read($, turnPaths)
69
70 $.ui.status(statusLine(turnFiles(all, paths)))
71}
72
73/** The project root: the session's cwd from session.start, asked again if that has not run. */
74const rootOf = async ($: $): Promise<string> => {
75 const known = await read($, root)
76 if (known !== '') return known
77 const cwd = await $.session.cwd().catch(() => '')
78 if (cwd !== '') await update($, root, () => cwd)
79
80 return cwd
81}
82
83/** Records a successful edit at once; the dependent search waits on the queue. */
84const noteEdit = async ($: $, given: string): Promise<void> => {
85 const base = await rootOf($)
86 const path = absolute(base, given)
87 const rel = inside(base, path)
88 const now = await $.clock.now()
89 const current = await read($, turn)
90 let needsSearch = false
91
92 await update($, files, list => {
93 const was = list.find(file => file.path === path)
94 const isCached = was !== undefined && was.searchedTurn === current && was.status !== 'failed'
95 needsSearch = rel !== undefined && !isCached
96 const status = rel === undefined ? 'outside' : isCached ? was.status : 'pending'
97 const next: Blast = {
98 path,
99 rel: rel ?? path,
100 status,
101 count: was?.count ?? 0,
102 dependents: was?.dependents ?? [],
103 searchedTurn: current,
104 edits: (was?.edits ?? 0) + 1,
105 editedAt: now,
106 }
107
108 return was === undefined ? [...list, next] : list.map(file => (file.path === path ? next : file))
109 })
110 await update($, turnPaths, list => (list.includes(path) ? list : [...list, path]))
111 if (needsSearch) {
112 await update($, queue, list => (list.includes(path) ? list : [...list, path]))
113 isQueued = true
114 }
115 await refreshStatus($)
116}
117
118/** Records a wide-reaching shell command and says so once. */
119const noteRisky = async ($: $, command: string, reason: string): Promise<void> => {
120 const now = await $.clock.now()
121 const current = await read($, turn)
122
123 await update($, risky, list => [...list, { command: clip(command, 200), reason, at: now, turn: current }].slice(-MAX_RISKY))
124 $.ui.toast(`Risky command ran: ${clip(command, 80)}`)
125}
126
127/** Runs one search command; the files it printed, or undefined when it failed. */
128const runSearch = async ($: $, argv: string[], cwd: string): Promise<string | undefined> => {
129 try {
130 const ran = await $.process.run(argv, { cwd, timeoutMs: SEARCH_MS })
131 // 0: matches, 1: none; anything else is an error unless it still printed files.
132 if (ran.exitCode === 0 || ran.exitCode === 1 || ran.stdout.trim() !== '') return ran.exitCode === 1 ? '' : ran.stdout
133
134 return undefined
135 } catch {
136 return undefined
137 }
138}
139
140/** Finds the files that reference one edited file's module, rg first, grep when rg fails. */
141const searchOne = async ($: $, path: string): Promise<void> => {
142 const base = await read($, root)
143 const pattern = dependentPattern(moduleRefs(path))
144 let hits: string[] | undefined = []
145
146 if (pattern !== undefined && base !== '') {
147 const out = (await runSearch($, rgArgv(pattern, base), base)) ?? (await runSearch($, grepArgv(pattern, base), base))
148 hits = out === undefined ? undefined : parseHits(out, base, path)
149 }
150
151 let found: Blast | undefined
152 await update($, files, list =>
153 list.map(file => {
154 if (file.path !== path) return file
155 found =
156 hits === undefined
157 ? { ...file, status: 'failed' }
158 : { ...file, status: 'done', count: hits.length, dependents: hits.slice(0, MAX_KEPT) }
159
160 return found
161 }),
162 )
163 await update($, queue, list => list.filter(one => one !== path))
164
165 if (found !== undefined && found.status === 'done' && found.count >= WIDE) {
166 let isNew = false
167 await update($, toasted, list => {
168 isNew = !list.includes(path)
169
170 return isNew ? [...list, path] : list
171 })
172 if (isNew) $.ui.toast(`Wide blast: ${baseName(path)} is used by ${plural(found.count, 'file')}`)
173 }
174 await refreshStatus($)
175}
176
177/** Works through the search queue, one search after another, off any hook's answer. */
178const drain = async ($: $): Promise<void> => {
179 if (isBusy || !isQueued) return
180 isBusy = true
181 try {
182 for (let guard = 0; guard < 500; guard += 1) {
183 isQueued = false
184 const [head] = await read($, queue)
185 if (head === undefined) return
186 await searchOne($, head)
187 }
188 isQueued = true
189 } catch {
190 // Show only: a failed search leaves the row as it was; the next tick tries the queue again.
191 isQueued = true
192 } finally {
193 isBusy = false
194 }
195}
196
197export const register: Register = on => {
198 on('session.start', async ($, e, next) => {
199 await update($, root, () => e.cwd)
200 // The queue's timer: searches run here, never inside a tool call's answer.
201 $.clock.every(TICK_MS, () => {
202 void drain($)
203 })
204 await refreshStatus($)
205 await $.command.register({
206 name: 'blast-radius',
207 description: 'Show the files that depend on what Claude edited, and risky shell commands',
208 argumentHint: '[session|turn|clear]',
209 immediate: true,
210 })
211
212 return next(e)
213 })
214
215 on('turn.start', async ($, e, next) => {
216 await update($, turn, n => n + 1)
217 await update($, turnPaths, () => [])
218 await update($, toasted, () => [])
219 $.ui.status(undefined)
220
221 return next(e)
222 })
223
224 on('tool.call', async ($, e, next) => {
225 const ran = await next(e)
226
227 try {
228 if (e.agentId !== undefined || ran.deny !== undefined) return ran
229 const tool = String(e.tool)
230 const input = e as unknown as Record<string, unknown>
231
232 if (EDIT_TOOLS.has(tool)) {
233 const path = str(input, 'file_path') || str(input, 'notebook_path')
234 if (ran.isError !== true && path !== '') await noteEdit($, path)
235 } else if (tool === 'Bash') {
236 const command = str(input, 'command')
237 const reason = classify(command)
238 if (reason !== undefined) await noteRisky($, command, reason)
239 }
240 } catch {
241 // Show only: bookkeeping never touches the tool's result.
242 }
243
244 return ran
245 }).catch(($, e, next) => next(e))
246
247 on('command.run', { command: 'blast-radius' }, async ($, e) => {
248 const words = e.args.trim().toLowerCase().split(/\s+/).filter(word => word !== '')
249 const word = words.length === 0 ? 'turn' : words.length === 1 ? words[0] : undefined
250
251 if (word === 'clear') {
252 await update($, files, () => [])
253 await update($, turnPaths, () => [])
254 await update($, risky, () => [])
255 await update($, queue, () => [])
256 await update($, toasted, () => [])
257 await update($, shown, () => [])
258 $.ui.status(undefined)
259
260 return { text: 'Blast radius cleared: no edited files or risky commands recorded.' }
261 }
262 if (word !== 'turn' && word !== 'session') return { text: USAGE }
263
264 const mode: View = word
265 await update($, view, () => mode)
266 const opened = await $.ui.open({ id: PANE, title: TITLE }).catch((error: unknown) => ({ isPlaced: false as const, reason: String(error) }))
267 const all = await read($, files)
268 const current = await read($, turn)
269 const commands = await read($, risky)
270 const list = mode === 'turn' ? turnFiles(all, await read($, turnPaths)) : all
271 const ran = mode === 'turn' ? commands.filter(one => one.turn === current) : commands
272 const where = opened.isPlaced ? '' : ` (the pane waits: ${opened.reason})`
273
274 return { text: `Blast radius, ${mode === 'turn' ? 'this turn' : 'this session'}: ${headline(list, ran)}${where}` }
275 })
276
277 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
278 const { Box, Text, Button } = $.ui.resolve(e)
279 const width = Math.max(24, e.props.bodyColumns)
280 const mode = await read($, view)
281 const all = await read($, files)
282 const paths = await read($, turnPaths)
283 const current = await read($, turn)
284 const commands = await read($, risky)
285 const open = await read($, shown)
286 const list = mode === 'turn' ? turnFiles(all, paths) : all
287 const ran = mode === 'turn' ? commands.filter(one => one.turn === current) : commands
288 const label = mode === 'turn' ? 'This turn' : 'This session'
289 const rows = []
290
291 rows.push(
292 <Box key="header" flexDirection="column">
293 <Text bold wrap="wrap">{`${label}: ${headline(list, ran)}`}</Text>
294 <Text dimColor>{clip(mode === 'turn' ? '/blast-radius session shows every edit this session' : '/blast-radius shows this turn only', width)}</Text>
295 </Box>,
296 )
297
298 if (list.length === 0) {
299 rows.push(
300 <Box key="none" flexDirection="column">
301 <Text dimColor>{mode === 'turn' ? 'No files edited this turn.' : 'No files edited this session.'}</Text>
302 </Box>,
303 )
304 }
305
306 for (const file of list) {
307 const isOpen = open.includes(file.path)
308 const canShow = file.status === 'done' && file.count > 0
309 const what =
310 file.status === 'pending'
311 ? 'searching…'
312 : file.status === 'failed'
313 ? 'search failed'
314 : file.status === 'outside'
315 ? 'outside project'
316 : plural(file.count, 'dependent')
317 const color =
318 file.status !== 'done' ? undefined : file.count >= HUGE ? 'error' : file.count >= WIDE ? 'warning' : undefined
319 // The terminal draws the Button as "[ Show ]": 8 cells, after one cell of space.
320 const room = Math.max(4, width - what.length - 1 - (canShow ? 9 : 0))
321
322 rows.push(
323 <Box key={`row:${file.rel}`} flexDirection="row">
324 <Text>{clipPath(file.rel, room).padEnd(room)} </Text>
325 {color === undefined ? (
326 <Text dimColor={file.status !== 'done'}>{what}</Text>
327 ) : (
328 <Text color={color} bold={color === 'error'}>
329 {what}
330 </Text>
331 )}
332 {canShow && <Text> </Text>}
333 {canShow && (
334 <Button
335 key={`show:${file.rel}`}
336 label={isOpen ? 'Hide' : 'Show'}
337 onPress={() => {
338 void update($, shown, was => (was.includes(file.path) ? was.filter(one => one !== file.path) : [...was, file.path]))
339 }}
340 />
341 )}
342 </Box>,
343 )
344
345 if (isOpen && canShow) {
346 const listed = file.dependents.slice(0, LISTED)
347 const more = file.count - listed.length
348
349 rows.push(
350 <Box key={`deps:${file.rel}`} flexDirection="column" paddingLeft={2}>
351 {listed.map(one => (
352 <Text dimColor>{clipPath(one, width - 2)}</Text>
353 ))}
354 {more > 0 && <Text dimColor>{`… ${more} more`}</Text>}
355 </Box>,
356 )
357 }
358 }
359
360 rows.push(
361 <Box key="risky" flexDirection="column" marginTop={1}>
362 <Text bold>Risky commands</Text>
363 {ran.length === 0 && <Text dimColor>{mode === 'turn' ? 'None this turn.' : 'None this session.'}</Text>}
364 </Box>,
365 )
366
367 ran.forEach((one, i) => {
368 const time = clock(one.at)
369 const room = Math.max(4, width - one.reason.length - time.length - 5)
370
371 rows.push(
372 <Box key={`risky:${i}`} flexDirection="row">
373 <Text color="warning">{one.reason}</Text>
374 <Text dimColor> · </Text>
375 <Text>{clip(one.command, room)}</Text>
376 <Text dimColor> {time}</Text>
377 </Box>,
378 )
379 })
380
381 return <Box flexDirection="column">{rows}</Box>
382 })
383}
384hooks/blast.ts 312 lines1import type { Blast, Risky } from '../types'
2
3/** Tools whose successful call changes a file. */
4export const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
5
6/** A file with at least this many dependents is a wide blast (toast, warning color). */
7export const WIDE = 15
8/** At least this many is drawn in the error color. */
9export const HUGE = 50
10/** Dependents kept per file (the count stays exact). */
11export const MAX_KEPT = 1000
12/** Dependents listed under an expanded row. */
13export const LISTED = 30
14/** Risky commands kept for the session. */
15export const MAX_RISKY = 100
16
17export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
18
19export const clip = (text: string, max: number): string => {
20 const line = text.replace(/\s+/g, ' ').trim()
21
22 if (max <= 1) return line.slice(0, Math.max(0, max))
23
24 return line.length > max ? `${line.slice(0, max - 1)}…` : line
25}
26
27/** Clips a path from the left, so its file name stays: "…/src/lib/utils.ts". */
28export const clipPath = (path: string, max: number): string => {
29 if (path.length <= max) return path
30 if (max <= 1) return path.slice(-Math.max(0, max))
31
32 return `…${path.slice(path.length - max + 1)}`
33}
34
35export const baseName = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
36
37const trimSlash = (path: string): string => (path.length > 1 ? path.replace(/\/+$/, '') : path)
38
39/** The path made absolute against the root. */
40export const absolute = (root: string, path: string): string =>
41 path.startsWith('/') || root === '' ? path : `${trimSlash(root)}/${path.replace(/^\.\//, '')}`
42
43/** The path relative to the root, or undefined when it lies outside it. */
44export const inside = (root: string, path: string): string | undefined => {
45 if (root === '') return undefined
46 const base = trimSlash(root)
47 const prefix = base === '/' ? '/' : `${base}/`
48
49 return path.startsWith(prefix) ? path.slice(prefix.length) : undefined
50}
51
52const stripExt = (name: string): string => {
53 const typed = /^(.+)\.d\.[cm]?ts$/.exec(name)
54 if (typed?.[1] !== undefined) return typed[1]
55 const dot = name.lastIndexOf('.')
56
57 return dot > 0 ? name.slice(0, dot) : name
58}
59
60/**
61 * How other files reach this file's module: by its base name with no extension
62 * (`names`), or, for a folder's entry file (index.*, __init__.py, mod.rs), by the
63 * folder's name (`folders`), and for index.* also by an explicit `/index` path.
64 * An `__init__` or `mod` alone is no useful name.
65 */
66export type ModuleRefs = { names: string[]; folders: string[]; isIndex: boolean }
67
68export const moduleRefs = (path: string): ModuleRefs => {
69 const parts = path.split('/').filter(Boolean)
70 const file = parts[parts.length - 1] ?? ''
71 const folder = parts[parts.length - 2]
72 const stem = stripExt(file)
73 const isIndex = stem === 'index'
74
75 if (isIndex || file === '__init__.py' || file === 'mod.rs') {
76 return { names: [], folders: folder === undefined || folder === '' ? [] : [folder], isIndex }
77 }
78
79 return { names: stem === '' ? [] : [stem], folders: [], isIndex: false }
80}
81
82/** Every name a search looks for, for display and tests: "utils", or "widgets" and "index". */
83export const moduleNames = (path: string): string[] => {
84 const refs = moduleRefs(path)
85
86 return [...refs.folders, ...refs.names, ...(refs.isIndex ? ['index'] : [])]
87}
88
89const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
90
91const BEFORE = "[/.:'\"<`[:space:]]"
92const AFTER = "([./'\"`>;:,)[:space:]]|$)"
93// A folder named in a path ends the path ("./widgets"), or goes on as a module path
94// ("pkg.core.x", "crate::net::x"); "./widgets/button" reaches another file, not the entry.
95const AFTER_FOLDER = "(['\"`>;:,).[:space:]]|$)"
96
97/**
98 * One pattern, read alike by rg and by grep -E: a line holding an
99 * import / require / from / include / use / mod word, then later one of the
100 * names standing as a module path piece ("./utils", "lib.utils", "crate::utils",
101 * "utils.h"). Undefined when the file has no name to look for.
102 */
103export const dependentPattern = (refs: ModuleRefs): string | undefined => {
104 const parts: string[] = []
105
106 if (refs.names.length > 0) parts.push(`${BEFORE}(${refs.names.map(escape).join('|')})${AFTER}`)
107 if (refs.folders.length > 0) parts.push(`${BEFORE}(${refs.folders.map(escape).join('|')})${AFTER_FOLDER}`)
108 if (refs.isIndex) parts.push(`/index${AFTER}`)
109 if (parts.length === 0) return undefined
110
111 return '(^|[^A-Za-z0-9_$])(import|require|require_relative|from|include|use|mod)([^A-Za-z0-9_$].*)?' + `(${parts.join('|')})`
112}
113
114export const rgArgv = (pattern: string, root: string): string[] => [
115 'rg',
116 '-l',
117 '--hidden',
118 '-g',
119 '!.git',
120 '-g',
121 '!node_modules',
122 '-g',
123 '!dist',
124 '-g',
125 '!build',
126 '-e',
127 pattern,
128 root,
129]
130
131export const grepArgv = (pattern: string, root: string): string[] => [
132 'grep',
133 '-rIlE',
134 '--exclude-dir=.git',
135 '--exclude-dir=node_modules',
136 '--exclude-dir=dist',
137 '--exclude-dir=build',
138 '-e',
139 pattern,
140 root,
141]
142
143/** The files a search printed, relative to the root, the edited file left out. */
144export const parseHits = (stdout: string, root: string, self: string): string[] => {
145 const seen = new Set<string>()
146
147 for (const raw of stdout.split('\n')) {
148 const line = raw.trim()
149 if (line === '') continue
150 const full = absolute(root, line.replace(/^\.\//, ''))
151 if (full === self) continue
152 seen.add(inside(root, full) ?? full)
153 }
154
155 return [...seen].sort()
156}
157
158type Rule = { reason: string; test: (command: string, segments: readonly string[][]) => boolean }
159
160/** The command split into simple commands (on ; & | newlines, subshell and backtick marks), each into words. */
161const segmentsOf = (command: string): string[][] =>
162 command
163 .split(/[;&|\n()`]+/)
164 .map(part => part.trim().split(/\s+/).filter(word => word !== ''))
165 .filter(words => words.length > 0)
166
167const isWord = (word: string, name: string): boolean => word === name || word.endsWith(`/${name}`)
168
169/** The flags after the first `name` word of a segment (every word that starts with "-"), or undefined. */
170const flagsOf = (words: readonly string[], name: string): string[] | undefined => {
171 const at = words.findIndex(word => isWord(word, name))
172 if (at < 0) return undefined
173
174 return words.slice(at + 1).filter(word => word.startsWith('-') && word !== '-' && word !== '--')
175}
176
177const hasShort = (flags: readonly string[], letter: string): boolean =>
178 flags.some(flag => /^-[A-Za-z]+$/.test(flag) && flag.includes(letter))
179
180const hasLong = (flags: readonly string[], long: string): boolean => flags.some(flag => flag === long || flag.startsWith(`${long}=`))
181
182/** Whether some `rm` in the command is recursive, and whether one is recursive and forced. */
183const rmFlags = (segments: readonly string[][]): { recursive: boolean; force: boolean } => {
184 let recursive = false
185
186 for (const words of segments) {
187 const flags = flagsOf(words, 'rm')
188 if (flags === undefined) continue
189 const r = hasShort(flags, 'r') || hasShort(flags, 'R') || hasLong(flags, '--recursive')
190 const f = hasShort(flags, 'f') || hasLong(flags, '--force')
191 if (r && f) return { recursive: true, force: true }
192 recursive ||= r
193 }
194
195 return { recursive, force: false }
196}
197
198const recursiveOn = (segments: readonly string[][], name: string): boolean =>
199 segments.some(words => {
200 const flags = flagsOf(words, name)
201
202 return flags !== undefined && (hasShort(flags, 'R') || hasLong(flags, '--recursive'))
203 })
204
205/** A segment that runs `git <sub>` (global options before the subcommand allowed). */
206const gitSub = (words: readonly string[], sub: string): string[] | undefined => {
207 const at = words.findIndex(word => isWord(word, 'git'))
208 if (at < 0) return undefined
209 const rest = words.slice(at + 1)
210 const subAt = rest.findIndex(word => !word.startsWith('-') && !/^[^=]+=/.test(word) && word !== 'git')
211 if (subAt < 0 || rest[subAt] !== sub) return undefined
212
213 return rest.slice(subAt + 1)
214}
215
216const git = (segments: readonly string[][], sub: string, test: (args: string[]) => boolean): boolean =>
217 segments.some(words => {
218 const args = gitSub(words, sub)
219
220 return args !== undefined && test(args)
221 })
222
223const runs = (segments: readonly string[][], name: string, test: (args: string[]) => boolean = () => true): boolean =>
224 segments.some(words => {
225 const at = words.findIndex(word => isWord(word, name))
226
227 return at >= 0 && test(words.slice(at + 1))
228 })
229
230const RULES: Rule[] = [
231 { reason: 'rm -rf', test: (c, s) => rmFlags(s).force },
232 { reason: 'rm -r', test: (c, s) => rmFlags(s).recursive },
233 {
234 reason: 'git push --force',
235 test: (c, s) =>
236 git(s, 'push', args => hasShort(args, 'f') || hasLong(args, '--force') || hasLong(args, '--force-with-lease')),
237 },
238 { reason: 'git reset --hard', test: (c, s) => git(s, 'reset', args => args.includes('--hard')) },
239 { reason: 'git clean -f', test: (c, s) => git(s, 'clean', args => hasShort(args, 'f') || hasLong(args, '--force')) },
240 {
241 reason: 'git checkout -- .',
242 test: (c, s) =>
243 git(s, 'checkout', args => {
244 const dash = args.indexOf('--')
245
246 return dash >= 0 && args.slice(dash + 1).some(arg => arg === '.' || arg === './' || arg === '*')
247 }),
248 },
249 { reason: 'DROP TABLE', test: c => /\bDROP\s+TABLE\b/i.test(c) },
250 { reason: 'DROP DATABASE', test: c => /\bDROP\s+(DATABASE|SCHEMA)\b/i.test(c) },
251 { reason: 'TRUNCATE', test: c => /\bTRUNCATE\b/i.test(c) },
252 { reason: 'kubectl delete', test: (c, s) => runs(s, 'kubectl', args => args.includes('delete')) },
253 {
254 reason: 'terraform destroy',
255 test: (c, s) => runs(s, 'terraform', args => args.includes('destroy') || (args.includes('apply') && args.includes('-destroy'))),
256 },
257 { reason: 'chmod -R', test: (c, s) => recursiveOn(s, 'chmod') },
258 { reason: 'chown -R', test: (c, s) => recursiveOn(s, 'chown') },
259 { reason: 'sudo', test: (c, s) => s.some(words => isWord(words[0] ?? '', 'sudo')) },
260 { reason: 'mkfs', test: (c, s) => s.some(words => words.some(word => /^(.*\/)?mkfs(\.[A-Za-z0-9]+)?$/.test(word))) },
261 { reason: 'dd if=', test: (c, s) => runs(s, 'dd', args => args.some(arg => arg.startsWith('if='))) },
262]
263
264/** Why a shell command reaches wide, or undefined when it does not. */
265export const classify = (command: string): string | undefined => {
266 const segments = segmentsOf(command)
267
268 return RULES.find(rule => rule.test(command, segments))?.reason
269}
270
271/** What a set of edited files comes to: how many, how many distinct dependents, how many still searching. */
272export type Totals = { files: number; dependents: number; pending: number }
273
274export const totals = (files: readonly Blast[]): Totals => {
275 const reached = new Set<string>()
276 let extra = 0
277 let pending = 0
278
279 for (const file of files) {
280 if (file.status === 'pending') pending += 1
281 if (file.status !== 'done') continue
282 for (const one of file.dependents) reached.add(one)
283 extra += Math.max(0, file.count - file.dependents.length)
284 }
285
286 return { files: files.length, dependents: reached.size + extra, pending }
287}
288
289/** The status line for this turn's edits, or undefined when nothing was edited. */
290export const statusLine = (files: readonly Blast[]): string | undefined => {
291 if (files.length === 0) return undefined
292 const sum = totals(files)
293 const tail = sum.pending > 0 ? ' · searching…' : ''
294
295 return `blast: ${plural(sum.files, 'file')} → ${plural(sum.dependents, 'dependent')}${tail}`
296}
297
298/** "3 files edited → 27 dependents · 1 risky command". */
299export const headline = (files: readonly Blast[], risky: readonly Risky[]): string => {
300 const sum = totals(files)
301 const tail = sum.pending > 0 ? ' (searching…)' : ''
302
303 return `${plural(sum.files, 'file')} edited → ${plural(sum.dependents, 'dependent')}${tail} · ${plural(risky.length, 'risky command')}`
304}
305
306export const clock = (ms: number): string => {
307 const d = new Date(ms)
308 const two = (n: number) => String(n).padStart(2, '0')
309
310 return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
311}
312types/index.d.ts 49 lines1/** Where the dependent search for one edited file stands. */
2export type SearchStatus = 'pending' | 'done' | 'failed' | 'outside'
3
4/** One file Claude edited this session, and the files found to depend on it. */
5export type Blast = {
6 /** The path as the tool call named it, absolute. */
7 path: string
8 /** The path relative to the project root, for display. */
9 rel: string
10 status: SearchStatus
11 /** How many files reference this file's module (exact). */
12 count: number
13 /** Those files, relative to the root, sorted; at most MAX_KEPT of them. */
14 dependents: string[]
15 /** The turn the result (or the search under way) belongs to: the cache key. */
16 searchedTurn: number
17 /** How many successful edits this session. */
18 edits: number
19 editedAt: number
20}
21
22/** A wide-reaching shell command that ran. */
23export type Risky = {
24 /** The command, clipped. */
25 command: string
26 /** The rule it matched, e.g. "git reset --hard". */
27 reason: string
28 at: number
29 turn: number
30}
31
32export type View = 'turn' | 'session'
33
34declare module 'claude-code' {
35 interface PluginState {
36 'blast-radius': {
37 root: string
38 turn: number
39 files: Blast[]
40 turnPaths: string[]
41 risky: Risky[]
42 queue: string[]
43 toasted: string[]
44 view: View
45 shown: string[]
46 }
47 }
48}
49