Holds risky shell commands (rm -rf, find -delete, xargs rm, git reset --hard, git clean, force push, migrations, also inside bash -c and sudo) and shows what…

A Claude Code mod that holds a risky shell command and shows you what it would change before it runs. We're sharing it as a small, complete example of a mod that pauses a tool call and asks you to decide.
When Claude calls Bash with one of the commands below, Blast Radius stops the call, works out what the command would touch, and opens a pane with two buttons: Proceed runs the command, Cancel refuses it. Claude sees the refusal and the reason.
| Command | What the pane shows | |
|---|---|---|
rm -r, rm -f, rm -rf | The files it would delete, with the count and total size. Globs and ~ are expanded. | |
git reset --hard | The files with uncommitted changes, from git status --porcelain, and git diff --shortstat. | |
git checkout -- ., git restore . | The files with unstaged changes. | |
git clean | The untracked paths it would remove, from git clean -n with the same flags. | |
git push --force (also -f, --force-with-lease, +ref) | The commits on the remote branch that your HEAD doesn't have, which the push would drop. | |
manage.py migrate, db:migrate, alembic upgrade, prisma migrate | The pending migrations, from the tool's own status command. | |
any other migrate | A note that it can't list the pending migrations for that tool. | |
find ... -delete, find ... -exec rm | The paths it would delete, from the same find with its actions taken out. | |
| `... \ | xargs rm` | A note that the list comes from the command before the pipe. It is held even without -r or -f. |
It also finds these commands behind sudo, doas, env, time, timeout, nice, nohup and exec, and inside bash -c, sh -c or zsh -c. When a command line has more than one risky part, the pane shows each part, up to four.
Every other command runs as normal. If the command line moves first, with cd dir &&, pushd/popd or git -C dir, Blast Radius measures in that folder. A cd inside ( ... ) only applies inside the parentheses, as in the shell.
The patterns it demonstrates:
tool.call until the user answers, and returning { deny } with a reason Claude can act on.Pane, and asking through Claude Code's own permission dialog (tool.check with decider: "person") when the terminal is too narrow for a pane.$.process.run, passing paths as arguments so nothing in them runs as shell.rm -rf build held, with the files it would delete:

After Cancel, Claude reports that nothing was deleted:

The second try, after Proceed:

Blast Radius. See what a command will touch, before it runs. When Claude runs a risky shell command, like
rm,git reset, or a migration, the mod opens a pane. The pane lists the files and branches the command would change. The developer presses Proceed, or Cancel. It usestool.callto hold the call, andui.renderonPane, withButtonelements. It adds a safety check, and it doesn't remove any, so it's safe to show. A bigger project, because each command needs its own dry run.
The build prompt, which picked this idea and two others by number:
implement 1,2,7. give me zips for them. test them in claude code and get me screenshots of what they look like when used.
$ call doesn't count, so the hold is a loop that waits on $.process.run(["sleep", "0.25"]) until a button's onPress sets the decision.isPlaced and draws the report in the AbovePrompt band instead.find action (for example -delete) that a glob matched could be read as an action while measuring, so relative paths now go to find with ./ in front. And a cd earlier on the command line was ignored, so the wrong folder was measured; the mod now follows cd and git -C. The same review led to smaller fixes: overlapping holds are queued, and the buttons always answer the command shown; an error while holding refuses the command; git clean -e, src:dst and HEAD force pushes are measured correctly; and sizes use du -k, which works on macOS as well as Linux.rm -rf build was held for more than 30 seconds, Cancel kept the files and Proceed deleted them; git reset --hard listed the two changed files and Cancel kept them; git clean -fdx was held. Force pushes and migrations were checked against the command classifier only, not run. The fixes are covered by tests of the classifier, the measuring step and the hold queue, run with Node against a stand-in for Claude Code; they haven't been re-run in a live session.Requirements:
claude plugin validate passes on 2.1.285.bash, git, find and du on your PATH. For migrations, the project's own tool (for example python3 manage.py showmigrations).No environment variables or configuration.
Steps:
/plugin install blast-radius --marketplace whykothari/whystack
Remove it with claude plugin uninstall blast-radius@whystack --scope user.
mods folder: claude plugin validate blast-radius
claude --plugin-dir ./blast-radius
Using the pane:
1 for Proceed or 2 for Cancel. You can also click a button, or Tab to it and press Enter.$(...), aliases, eval, and scripts that call rm aren't caught.cd, pushd, popd and git -C on the same line. cd -, and a folder that doesn't exist, can't be measured; the pane says so and still holds the command. Otherwise it starts from the session's working folder.find, it runs the same find with every action taken out (-delete, -exec, -ok, -fprint and the like) to list the paths. If removing an action leaves an expression that find can't read, the pane says it couldn't list them.rm count is approximate: a path matched twice is counted twice, and a file name with a line break is not counted. The list shows the first 10 files. Counts come from find and sizes from du -k, so a size is the space on disk, to the nearest kilobyte. A very large tree can take a few seconds to measure.origin.python3 manage.py showmigrations), which loads your project's code before you choose Proceed or Cancel. If that fails, the pane says it couldn't list them.echo done; rm -f empty.txt or ls | xargs rm on a list you meant to delete.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
The mod has no packages to install. It calls tools that are already on the machine (listed under Requirements).
Git is a trademark of Software Freedom Conservancy. Python is a registered trademark of the Python Software Foundation. npm is a trademark of npm, Inc. Django is a registered trademark of the Django Software Foundation. Rails and Ruby on Rails are trademarks of David Heinemeier Hansson. Prisma is a trademark of Prisma Data, Inc. Alembic is an open-source project of the SQLAlchemy authors. Use of these names here is descriptive and implies no endorsement.
Based on Blast Radius from anthropics/claude-code-playground, Copyright 2026 Anthropic PBC, under the Apache License 2.0 (see LICENSE). It is not an official Anthropic product.
Changes from the original, by Yash Kothari:
hooks/blast-radius.mjs: finds risky commands behind wrappers, in bash -c, in xargs rm and in find -delete; splits the command line outside quotes only; measures every risky part; asks through Claude Code's permission dialog in place of the AbovePrompt band; adds a .catch to each guard so an error refuses the command..claude-plugin/plugin.json: author, repository, license and version fields.README.md: install steps for whystack, the changes above, and this section.LICENSE: copied from the original repository root.hooks/hooks.json is unchanged.
hooks/blast-radius.mjs 773 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Modified by Yash Kothari, 2026: wrapped commands, every risky part measured,
5// and Claude Code's permission dialog in place of the AbovePrompt band.
6//
7// Blast Radius: holds a risky Bash command and shows what it would change.
8//
9// tool.call (Bash): if the command is risky, work out its blast radius, open a
10// pane with Proceed and Cancel, and hold the call until one is pressed.
11// ui.render (Pane): draws the report.
12// tool.check (Bash): if the surface won't place the pane (a narrow terminal),
13// Claude Code's own permission dialog asks instead, with the report as reason.
14// The AbovePrompt band is not used: other mods draw there and can hide it.
15//
16// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
17// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
18// a button's onPress sets the decision.
19//
20// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
21// spelled literally, and helpers that take `$` are top-level functions.
22
23const PANE_ID = "blast-radius";
24const POLL_SECONDS = "0.25";
25const HOLD_LIMIT_MS = 10 * 60 * 1000;
26const LIST_MAX = 10;
27const PARTS_MAX = 4;
28
29// The call being held, or null. One at a time: Bash calls in a turn run in order.
30let held = null;
31
32// Reasons for the permission dialog, by call, for calls with no room for a pane.
33const asks = new Map();
34
35export function register(on) {
36 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
37 const risks = classify(String(e.command ?? ""));
38 if (risks.length === 0) {
39 return next(e);
40 }
41 // One hold at a time. If another risky call is already held (a subagent's,
42 // say), wait until it is answered. `held` is claimed with no await between
43 // the check and the claim, so two waiting calls can't both get through.
44 while (held !== null) {
45 if (next.signal.aborted) {
46 return { deny: "Blast Radius held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to." };
47 }
48 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
49 }
50 const mine = { command: String(e.command), risks, report: null, decision: null };
51 held = mine;
52
53 let opened = { isPlaced: false };
54 let decision;
55 let summary = risks.map((r) => r.label).join("; ");
56 try {
57 mine.report = await measureAll($, risks, await $.session.cwd());
58 summary = mine.report.summary;
59
60 opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
61 if (!opened.isPlaced) {
62 mine.decision = "ask";
63 }
64 $.ui.invalidate("ui.render");
65
66 const startedAt = await $.clock.now();
67 while (mine.decision === null) {
68 if (next.signal.aborted) {
69 mine.decision = "interrupted";
70 break;
71 }
72 if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
73 mine.decision = "timeout";
74 break;
75 }
76 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
77 }
78 } catch {
79 mine.decision = "error"; // anything unexpected refuses the command
80 } finally {
81 decision = mine.decision;
82 // Close this call's pane before releasing the hold, so the next call's
83 // pane can't be the one that gets closed.
84 try {
85 if (opened.isPlaced) {
86 await $.ui.close({ id: PANE_ID });
87 }
88 } catch {
89 // the pane is already gone
90 }
91 if (held === mine) {
92 held = null;
93 }
94 $.ui.invalidate("ui.render");
95 }
96
97 if (decision === "proceed") {
98 $.ui.toast("Blast Radius: running it");
99 return next(e);
100 }
101 if (decision === "ask") {
102 asks.set(askKey(e.tool_use_id, e.command), askReason(mine.report));
103 return next(e);
104 }
105 const why = {
106 cancel: "the user pressed Cancel",
107 timeout: "no answer within 10 minutes",
108 interrupted: "the turn was interrupted",
109 error: "Blast Radius hit an error while holding it",
110 }[decision] ?? "no answer was recorded";
111 return {
112 deny: `Blast Radius held this command and did not run it: ${why}. It would have: ${summary}. Do not retry it unless the user asks you to.`,
113 };
114 }).catch(($, e, next) =>
115 next.called ? next(e) : { deny: "Blast Radius failed while it checked this command, so it did not run it. Do not retry it unless the user asks you to." },
116 );
117
118 // A rule that already refuses the call stands. Any other verdict, an allow
119 // from a rule or a mode included, becomes a question to the person.
120 on("tool.check", { tool: "Bash" }, async ($, e, next) => {
121 const key = askKey(e.tool_use_id, e.input?.command);
122 const reason = asks.get(key);
123 if (reason === undefined) {
124 return next(e);
125 }
126 asks.delete(key);
127 const verdict = await next(e);
128 if (verdict.decision === "deny") {
129 return verdict;
130 }
131 return { decision: "ask", reason, decider: "person" };
132 }).catch(() => ({ decision: "deny", reason: "Blast Radius failed while it asked about this command, so it did not run it." }));
133
134 on("ui.render", { component: "Pane" }, ($, e, next) => {
135 if (e.requestId !== PANE_ID || held === null || held.report === null) {
136 return next(e);
137 }
138 return draw($.ui.resolve(e), held);
139 });
140}
141
142function askKey(toolUseId, command) {
143 return toolUseId ?? `command:${command}`;
144}
145
146/** One sentence for the permission dialog: what each part would change, with a few paths. */
147function askReason(report) {
148 const parts = report.parts.map((part) => {
149 const shown = part.lines.slice(0, 3);
150 const rest = part.lines.length - shown.length + (part.more ?? 0);
151 const sample = shown.length === 0 ? "" : ` (${shown.join(", ")}${rest > 0 ? `, + ${rest} more` : ""})`;
152 return `${part.summary}${sample}`;
153 });
154 return `Blast Radius: this command would ${parts.join("; and ")}.`;
155}
156
157// ---- What counts as risky -------------------------------------------------
158
159// sudo options that take a value, so the value isn't read as the command.
160const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
161// Commands that only read, so a bare word "migrate" in them isn't a migration.
162const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
163
164/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
165function joinDir(dir, arg) {
166 if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
167 return arg ?? "~";
168 }
169 return dir ? `${dir}/${arg}` : arg;
170}
171
172/**
173 * Splits a command line on && || ; | and line breaks outside quotes, so the
174 * script of `bash -c "a && b"` and the `\;` of `find -exec` stay whole.
175 */
176function splitCommand(command) {
177 const segments = [];
178 let current = "";
179 let quote = null;
180 for (let i = 0; i < command.length; i += 1) {
181 const c = command[i];
182 if (c === "\\" && quote !== "'" && i + 1 < command.length) {
183 current += c + command[i + 1];
184 i += 1;
185 } else if (quote !== null) {
186 current += c;
187 if (c === quote) {
188 quote = null;
189 }
190 } else if (c === "'" || c === '"') {
191 quote = c;
192 current += c;
193 } else if (command.startsWith("&&", i) || command.startsWith("||", i)) {
194 segments.push(current);
195 current = "";
196 i += 1;
197 } else if (c === ";" || c === "|" || c === "\n") {
198 segments.push(current);
199 current = "";
200 } else {
201 current += c;
202 }
203 }
204 segments.push(current);
205 return segments;
206}
207
208/** Every risky segment of a shell command, in order; empty if none. */
209function classify(command, startDir = null, depth = 0) {
210 const risks = [];
211 let dir = startDir; // where a `cd` earlier on the line moved to; null means the session folder
212 const scopes = []; // dir to restore when a ( subshell ) closes
213 const pushed = []; // pushd stack, for popd
214 for (const raw of splitCommand(command)) {
215 const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
216 // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
217 const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
218 const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
219 for (let k = 0; k < opens; k += 1) {
220 scopes.push(dir);
221 }
222 const found = classifySegment(raw, dir, pushed, depth);
223 if (found.cd !== undefined) {
224 dir = found.cd; // a cd, pushd or popd moved the folder
225 } else {
226 risks.push(...found);
227 }
228 for (let k = 0; k < closes && scopes.length > 0; k += 1) {
229 dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
230 }
231 }
232 return risks;
233}
234
235// Words that can come before the real command without changing what it does.
236const PREFIXES = new Set(["command", "exec", "nohup", "then", "do", "else", "!"]);
237const DOAS_VALUE_OPTIONS = new Set(["-u", "-C"]);
238const ENV_VALUE_OPTIONS = new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]);
239const TIMEOUT_VALUE_OPTIONS = new Set(["-s", "--signal", "-k", "--kill-after"]);
240const XARGS_VALUE_OPTIONS = new Set(["-n", "-L", "-P", "-s", "-d", "-E", "-I", "-a", "-J", "-R", "-S"]);
241const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
242const FIND_EXEC = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
243
244/** The command's name without a leading \ (which skips aliases) or a folder. */
245function baseName(word) {
246 return word.replace(/^\\/, "").split("/").pop();
247}
248
249/** Drops leading options from words, and the value after each option in valueOptions. */
250function dropOptions(words, valueOptions) {
251 while (words.length > 0 && words[0].startsWith("-") && words[0] !== "-" && words[0] !== "--") {
252 if (valueOptions.has(words.shift())) {
253 words.shift();
254 }
255 }
256 if (words[0] === "--") {
257 words.shift();
258 }
259}
260
261/**
262 * Removes the words before the real command: VAR=value, sudo, doas, env,
263 * time, timeout, nice and the PREFIXES, each with its own options.
264 */
265function unwrap(words) {
266 for (;;) {
267 const word = words[0];
268 if (word === undefined) {
269 return words;
270 }
271 const name = baseName(word);
272 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || PREFIXES.has(word)) {
273 words.shift();
274 } else if (name === "sudo" || name === "doas") {
275 words.shift();
276 dropOptions(words, name === "sudo" ? SUDO_VALUE_OPTIONS : DOAS_VALUE_OPTIONS);
277 } else if (name === "env") {
278 words.shift();
279 dropOptions(words, ENV_VALUE_OPTIONS);
280 } else if (name === "time") {
281 words.shift();
282 dropOptions(words, new Set());
283 } else if (name === "timeout") {
284 words.shift();
285 dropOptions(words, TIMEOUT_VALUE_OPTIONS);
286 words.shift(); // the duration
287 } else if (name === "nice") {
288 words.shift();
289 if (words[0] === "-n") {
290 words.splice(0, 2);
291 } else if (/^-n?-?\d+$/.test(words[0] ?? "")) {
292 words.shift();
293 }
294 } else {
295 return words;
296 }
297 }
298}
299
300/** The risk of an rm with these arguments, or null for an rm that is neither -r nor -f. */
301function rmRisk(args, dir) {
302 const flags = args.filter((a) => a.startsWith("-"));
303 const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
304 const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
305 if (!recursive && !force) {
306 return null;
307 }
308 const targets = args.filter((a) => !a.startsWith("-") || a === "-");
309 return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
310}
311
312/** One segment: { cd } for a folder change, else its risks (empty if none). */
313function classifySegment(segment, dir, pushed, depth) {
314 {
315 const words = unwrap(tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, "")));
316 const [first, ...args] = words;
317 if (first === undefined) {
318 return [];
319 }
320 const cmd = baseName(first);
321 if (cmd === "cd") {
322 return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
323 }
324 if (cmd === "pushd") {
325 pushed.push(dir);
326 return { cd: joinDir(dir, args[0]) };
327 }
328 if (cmd === "popd") {
329 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
330 }
331 if (SHELLS.has(cmd)) {
332 // `bash -c 'script'`: the script runs in a child shell, so a cd in it stays there.
333 const at = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));
334 return at !== -1 && args[at + 1] !== undefined && depth < 3 ? classify(args[at + 1], dir, depth + 1) : [];
335 }
336 if (cmd === "rm") {
337 const risk = rmRisk(args, dir);
338 return risk === null ? [] : [risk];
339 }
340 if (cmd === "xargs") {
341 // xargs gets its paths from the command before the pipe, so even a plain rm is held.
342 const rest = [...args];
343 dropOptions(rest, XARGS_VALUE_OPTIONS);
344 const inner = unwrap(rest);
345 return inner[0] !== undefined && baseName(inner[0]) === "rm"
346 ? [{ kind: "rm-stdin", label: `xargs ${inner.join(" ")}`, dir }]
347 : [];
348 }
349 if (cmd === "find") {
350 const execRm = args.some((a, i) => FIND_EXEC.has(a) && args[i + 1] !== undefined && baseName(args[i + 1]) === "rm");
351 if (args.includes("-delete") || execRm) {
352 return [{ kind: "find-delete", label: args.includes("-delete") ? "find -delete" : "find -exec rm", args, dir }];
353 }
354 }
355 if (cmd === "git") {
356 // Git's own options come before the subcommand; -C moves where it runs.
357 let gitDir = dir;
358 let i = 0;
359 while (i < args.length && args[i].startsWith("-")) {
360 if (args[i] === "-C" && i + 1 < args.length) {
361 gitDir = joinDir(gitDir, args[i + 1]);
362 i += 2;
363 } else if (args[i] === "-c" && i + 1 < args.length) {
364 i += 2;
365 } else {
366 i += 1;
367 }
368 }
369 const sub = args[i];
370 const rest = args.slice(i + 1);
371 if (sub === "reset" && rest.includes("--hard")) {
372 return [{ kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir }];
373 }
374 if (sub === "clean") {
375 return [{ kind: "git-clean", label: "git clean", args: rest, dir: gitDir }];
376 }
377 if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
378 return [{ kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir }];
379 }
380 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
381 if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
382 return [{ kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir }];
383 }
384 }
385 const joined = words.join(" ");
386 if (/\balembic\s+upgrade\b/.test(joined)) {
387 return [{ kind: "migrate", tool: "alembic", label: "alembic upgrade", dir }];
388 }
389 if (/\bdb:migrate(?!:status\b)/.test(joined)) {
390 return [{ kind: "migrate", tool: "rails", label: "db:migrate", dir }];
391 }
392 if (/\bprisma\s+migrate\b/.test(joined)) {
393 return [{ kind: "migrate", tool: "prisma", label: "prisma migrate", dir }];
394 }
395 if (/\bmanage\.py\s+migrate\b/.test(joined)) {
396 return [{ kind: "migrate", tool: "django", label: "manage.py migrate", dir }];
397 }
398 if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
399 return [{ kind: "migrate", tool: "unknown", label: "migrate", dir }];
400 }
401 }
402 return [];
403}
404
405// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
406// The target is passed as an argument, never as source.
407const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
408
409async function resolveDir($, sessionCwd, dir) {
410 if (dir === "-") {
411 return null; // `cd -` depends on the shell's history
412 }
413 const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
414 const out = run.stdout.trim();
415 return run.exitCode === 0 && out !== "" ? out : null;
416}
417
418/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
419function tokenize(text) {
420 const words = [];
421 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
422 let m;
423 while ((m = re.exec(text)) !== null) {
424 words.push(m[1] ?? m[2] ?? m[3]);
425 }
426 return words;
427}
428
429// ---- Measuring the blast radius -------------------------------------------
430
431/**
432 * { summary, parts } for the pane, one part per risky segment, each measured
433 * where it will run: the session folder, moved by any `cd dir &&` or
434 * `git -C dir` earlier in the same command line. Proceed runs every part, so
435 * the pane must show every part.
436 */
437async function measureAll($, risks, sessionCwd) {
438 const parts = [];
439 for (const risk of risks.slice(0, PARTS_MAX)) {
440 const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
441 const report = cwd === null
442 ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
443 : await measure($, risk, cwd);
444 parts.push({ label: risk.label, ...report });
445 }
446 if (risks.length > PARTS_MAX) {
447 const rest = risks.slice(PARTS_MAX);
448 parts.push({ label: "more", summary: `run ${rest.length} more risky ${rest.length === 1 ? "part" : "parts"}`, lines: [], note: `Not measured: ${rest.map((r) => r.label).join(", ")}.` });
449 }
450 return { summary: parts.map((p) => p.summary).join("; and "), parts };
451}
452
453/** { summary, lines, note } for one part. Never throws: a failed read is said, not hidden. */
454async function measure($, risk, cwd) {
455 try {
456 if (risk.kind === "rm") {
457 return await measureRm($, risk, cwd);
458 }
459 if (risk.kind === "rm-stdin") {
460 return { summary: "delete the paths piped into xargs", lines: [], note: "The command before the pipe makes the list, so I can't measure it without running that command." };
461 }
462 if (risk.kind === "find-delete") {
463 return await measureFind($, risk, cwd);
464 }
465 if (risk.kind === "migrate") {
466 return await measureMigrations($, risk, cwd);
467 }
468 return await measureGit($, risk, cwd);
469 } catch (error) {
470 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
471 }
472}
473
474// The paths are passed to bash as arguments, never as source, so nothing in
475// them runs. compgen -G expands a glob without command substitution.
476const RM_SCRIPT = `
477shopt -s nullglob dotglob
478paths=()
479for p in "$@"; do
480 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
481 if [[ "$p" == *[*?[]* ]]; then
482 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
483 elif [[ -e "$p" || -L "$p" ]]; then
484 paths+=("$p")
485 fi
486done
487if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
488# A relative path gets ./ in front, so find never reads a name like -delete as an action.
489for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
490files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
491kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
492echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
493find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
494`;
495
496async function measureRm($, risk, cwd) {
497 if (risk.targets.length === 0) {
498 return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
499 }
500 const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
501 const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
502 const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
503 if (!found) {
504 return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
505 }
506 if (!files) {
507 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
508 }
509 return {
510 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
511 lines: rest.map((l) => l.replace(/^\.\//, "")),
512 more: Math.max(0, files - rest.length),
513 note: `Paths: ${risk.targets.join(" ")}`,
514 };
515}
516
517// The output is cut in awk, so a large tree doesn't fill the hook's memory.
518const FIND_SCRIPT = `find "$@" 2>/dev/null | awk -v max=${LIST_MAX} 'NR <= max { print } END { print NR }'; exit "\${PIPESTATUS[0]}"`;
519const FIND_ONE_VALUE_ACTIONS = new Set(["-printf", "-fprint", "-fprint0", "-fls"]);
520const FIND_PLAIN_ACTIONS = new Set(["-delete", "-print", "-print0", "-ls", "-quit"]);
521
522/**
523 * The same find with every action taken out, so it only lists: no -delete,
524 * no -exec, and no -fprint that would write a file. The tests go in ( ),
525 * so -print applies to the whole of them.
526 */
527function findDryRun(args) {
528 const words = args.map((a) => a.replace(/^\\([();!])$/, "$1"));
529 const head = [];
530 let i = 0;
531 while (i < words.length && /^-[HLP]$/.test(words[i])) {
532 head.push(words[i]);
533 i += 1;
534 }
535 while (i < words.length && !words[i].startsWith("-") && words[i] !== "(" && words[i] !== "!") {
536 head.push(words[i]);
537 i += 1;
538 }
539 const tests = [];
540 for (; i < words.length; i += 1) {
541 const w = words[i];
542 if (FIND_EXEC.has(w)) {
543 while (i + 1 < words.length && words[i + 1] !== ";" && words[i + 1] !== "+") {
544 i += 1;
545 }
546 i += 1; // the ; or +
547 } else if (FIND_ONE_VALUE_ACTIONS.has(w)) {
548 i += 1;
549 } else if (w === "-fprintf") {
550 i += 2;
551 } else if (!FIND_PLAIN_ACTIONS.has(w)) {
552 tests.push(w);
553 }
554 }
555 return tests.length === 0 ? [...head, "-print"] : [...head, "(", ...tests, ")", "-print"];
556}
557
558async function measureFind($, risk, cwd) {
559 const run = await $.process.run(["bash", "-c", FIND_SCRIPT, "blast-radius", ...findDryRun(risk.args)], { cwd, timeoutMs: 15000 });
560 const out = run.stdout.split("\n").filter((l) => l !== "");
561 const count = Number(out.pop() ?? 0);
562 if (run.exitCode !== 0 && count === 0) {
563 return { summary: `${risk.label} (could not dry-run it)`, lines: [], note: "find failed without its actions, so I couldn't list what it would delete." };
564 }
565 return {
566 summary: count === 0 ? "delete nothing: find matches no paths" : `delete ${count} ${count === 1 ? "path" : "paths"} that find matches`,
567 lines: out.map((l) => l.replace(/^\.\//, "")),
568 more: Math.max(0, count - out.length),
569 note: "From the same find with its actions taken out.",
570 };
571}
572
573async function measureGit($, risk, cwd) {
574 if (risk.kind === "git-push-force") {
575 return await measurePush($, risk, cwd);
576 }
577 if (risk.kind === "git-clean") {
578 const flags = [];
579 const paths = [];
580 for (let i = 0; i < risk.args.length; i += 1) {
581 const a = risk.args[i];
582 if (a === "--") {
583 paths.push(...risk.args.slice(i + 1));
584 break;
585 }
586 if (a === "-e" || a === "--exclude") {
587 flags.push(a, risk.args[i + 1] ?? "");
588 i += 1;
589 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
590 flags.push(a);
591 } else if (/^-[a-zA-Z]+$/.test(a)) {
592 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
593 if (kept !== "-") {
594 flags.push(kept);
595 }
596 } else if (!a.startsWith("-")) {
597 paths.push(a);
598 }
599 }
600 const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
601 if (run.exitCode !== 0) {
602 return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
603 }
604 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
605 return {
606 summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
607 lines: gone.slice(0, LIST_MAX),
608 more: Math.max(0, gone.length - LIST_MAX),
609 note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
610 };
611 }
612 const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
613 if (status.exitCode !== 0) {
614 return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
615 }
616 const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
617 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
618 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
619 const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
620 return {
621 summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
622 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
623 more: Math.max(0, lost.length - LIST_MAX),
624 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
625 };
626}
627
628async function measurePush($, risk, cwd) {
629 const positional = risk.args.filter((a) => !a.startsWith("-"));
630 const remote = positional[0] ?? "origin";
631 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
632 const spec = (positional[1] ?? "").replace(/^\+/, "");
633 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
634 branch = (branch ?? "").replace(/^refs\/heads\//, "");
635 if (!branch) {
636 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
637 branch = head.stdout.trim();
638 source = "HEAD";
639 } else if (branch === "HEAD") {
640 // `git push origin HEAD` pushes the current branch to its namesake.
641 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
642 branch = head.stdout.trim();
643 source = "HEAD";
644 }
645 source = source || "HEAD";
646 const ref = `${remote}/${branch}`;
647 const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
648 if (known.exitCode !== 0) {
649 return { summary: `force-push to ${ref}`, lines: [], note: `No local copy of ${ref}, so I can't tell which commits the push would drop. Run git fetch first.` };
650 }
651 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
652 const dropped = log.stdout.split("\n").filter((l) => l !== "");
653 return {
654 summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
655 lines: dropped.slice(0, LIST_MAX),
656 more: Math.max(0, dropped.length - LIST_MAX),
657 note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
658 };
659}
660
661const MIGRATION_LISTERS = {
662 django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
663 alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
664 rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
665 prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
666};
667
668async function measureMigrations($, risk, cwd) {
669 const lister = MIGRATION_LISTERS[risk.tool];
670 if (lister === undefined) {
671 return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
672 }
673 let run;
674 try {
675 run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
676 } catch (error) {
677 run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
678 }
679 if (run.exitCode !== 0) {
680 return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
681 }
682 const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
683 return {
684 summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
685 lines: pending.slice(0, LIST_MAX),
686 more: Math.max(0, pending.length - LIST_MAX),
687 note: `From ${lister.argv.join(" ")}.`,
688 };
689}
690
691function size(bytes) {
692 if (!Number.isFinite(bytes) || bytes < 1024) {
693 return `${bytes || 0} B`;
694 }
695 const units = ["KB", "MB", "GB", "TB"];
696 let n = bytes;
697 let i = -1;
698 while (n >= 1024 && i < units.length - 1) {
699 n /= 1024;
700 i += 1;
701 }
702 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
703}
704
705// ---- Drawing --------------------------------------------------------------
706
707/** How many paths each part lists, so several parts share the pane's rows. */
708function listCap(report) {
709 return report.parts.length === 1 ? LIST_MAX : Math.max(2, Math.floor(LIST_MAX / report.parts.length));
710}
711
712function paneRows(report) {
713 const cap = listCap(report);
714 const partRows = report.parts.reduce((sum, p) => {
715 const hidden = Math.max(0, p.lines.length - cap) + (p.more ?? 0);
716 return sum + 3 + Math.min(cap, p.lines.length) + (hidden > 0 ? 1 : 0);
717 }, 0);
718 return Math.min(24, 6 + partRows);
719}
720
721function drawPart(t, part, i, cap) {
722 const { Box, Text } = t;
723 const shown = part.lines.slice(0, cap);
724 const hidden = part.lines.length - shown.length + (part.more ?? 0);
725 const list = shown.map((line, k) => Text({ key: `l${k}`, children: ` ${line}`, wrap: "truncate-end" }));
726 if (hidden > 0) {
727 list.push(Text({ key: "more", dimColor: true, children: ` + ${hidden} more` }));
728 }
729 return Box({
730 key: `part${i}`,
731 flexDirection: "column",
732 marginTop: 1,
733 children: [
734 Text({ key: "sum", children: [Text({ dimColor: true, children: "Would " }), Text({ color: "red", bold: true, children: part.summary })] }),
735 Box({ key: "list", flexDirection: "column", children: list }),
736 part.note ? Text({ key: "note", dimColor: true, italic: true, children: part.note, wrap: "wrap" }) : null,
737 ],
738 });
739}
740
741function draw(t, state) {
742 const { Box, Text, Button } = t;
743 const { report } = state;
744 const cap = listCap(report);
745 // The buttons answer the call this pane was drawn for, never whichever one is held now.
746 const decide = (choice) => () => {
747 if (state.decision === null) {
748 state.decision = choice;
749 }
750 };
751 return Box({
752 flexDirection: "column",
753 borderStyle: "round",
754 borderColor: "yellow",
755 paddingX: 1,
756 children: [
757 Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risks.map((r) => r.label).join(" + ")}`, wrap: "truncate-end" }),
758 Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
759 ...report.parts.map((part, i) => drawPart(t, part, i, cap)),
760 Box({
761 key: "buttons",
762 marginTop: 1,
763 gap: 2,
764 children: [
765 Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
766 Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
767 Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
768 ],
769 }),
770 ],
771 });
772}
773