SLOPSHOPPER

blast-radius

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…

newpaneguardtoastprocess
v0.2.0Apache-2.0updated 2026-10-22whykothari/whystack/mods/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 │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⚠ Blast Radius · rm -rf + git push --fo… ⏺ Read(src/auth.ts) │ ┃ │ Command rm -rf build && git push --for… ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ │ Would delete nothing: no file matches ⎿ Added 2 lines, removed 1 line │ ┃ │ build ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ The paths don't exist, so rm has nothing ⎿ Denied by blast-radius: Blast Radius held this command an │ ┃ │ to remove. │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Would force-push to origin/main: │ ┃ │ drops 2 commits ✻ Worked for 42s · done 4:20 PM │ ┃ │ a1b2c3d fix: refresh expired tokens (… │ ┃ │ 9f8e7d6 feat: add audit log (3 hours … │ ┃ │ Commits on origin/main that main doesn't │ ┃ │ have, as of the last fetch. │ ┃ │ │ ┃ │ 1: Proceed 2: Cancel Claude is waiting o │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ Blast Radius · rm -rf + git push --force │ │ Command rm -rf build && git push --force origin main │ │ │ │ Would delete nothing: no file matches build │ │ The paths don't exist, so rm has nothing to remove. │ │ │ │ Would force-push to origin/main: drops 2 commits │ │ a1b2c3d fix: refresh expired tokens (2 minutes ago) │ │ 9f8e7d6 feat: add audit log (3 hours ago) │ │ Commits on origin/main that main doesn't have, as of the │ │ last fetch. │ │ │ │ 1: Proceed 2: Cancel Claude is waiting on your answer │ ╰──────────────────────────────────────────────────────────╯
README

Blast Radius

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.

What this shows

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.

CommandWhat the pane shows
rm -r, rm -f, rm -rfThe files it would delete, with the count and total size. Globs and ~ are expanded.
git reset --hardThe files with uncommitted changes, from git status --porcelain, and git diff --shortstat.
git checkout -- ., git restore .The files with unstaged changes.
git cleanThe 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 migrateThe pending migrations, from the tool's own status command.
any other migrateA note that it can't list the pending migrations for that tool.
find ... -delete, find ... -exec rmThe 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:

  • Holding a tool.call until the user answers, and returning { deny } with a reason Claude can act on.
  • Drawing the report in a Pane, and asking through Claude Code's own permission dialog (tool.check with decider: "person") when the terminal is too narrow for a pane.
  • Measuring with $.process.run, passing paths as arguments so nothing in them runs as shell.

Demo

rm -rf build held, with the files it would delete:

Blast Radius holding rm -rf build in a pane

After Cancel, Claude reports that nothing was deleted:

Claude reports the command was cancelled

The second try, after Proceed:

Claude reports the folder was deleted after Proceed

How it was built

  • Model: built with Claude in Claude Code. The test runs and screenshots used Claude Sonnet 5. The mod itself doesn't call a model.
  • Prompt(s): the mod started as one of ten ideas Claude wrote for mods. This is the idea as written:

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 uses tool.call to hold the call, and ui.render on Pane, with Button elements. 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.

  • Transcript: not shared. The build ran in an internal workspace.
  • Iterations:
  • A hook has a 10-second budget for its own code, which is far too short to wait for a person. Time spent inside a $ 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.
  • A pane needs room. In a narrow terminal Claude Code doesn't place it, so the mod checks isPlaced and draws the report in the AbovePrompt band instead.
  • Cancel has the focus when the pane opens, so pressing Enter refuses the command rather than running it.
  • An independent review before release found two problems in the measuring step, both fixed. A file named like a 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.
  • Tested in Claude Code, before those fixes: 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.

Run it

Requirements:

  • Claude Code 2.1.287 or later, where mods load by default. The mod was built and tested on 2.1.280, and 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).
  • For the side pane, a terminal about 144 columns wide or more. In a narrower terminal, Claude Code's permission dialog asks instead.

No environment variables or configuration.

Steps:

  1. Install it in a Claude Code terminal session:
   /plugin install blast-radius --marketplace whykothari/whystack

Remove it with claude plugin uninstall blast-radius@whystack --scope user.

  1. To try a local copy for one session instead, run this from the mods folder:
   claude plugin validate blast-radius
   claude --plugin-dir ./blast-radius
  1. Ask Claude to run something risky in a throwaway repo, for example "delete the build folder with rm -rf build".

Using the pane:

  • Press 1 for Proceed or 2 for Cancel. You can also click a button, or Tab to it and press Enter.
  • In a narrow terminal there is no pane. Claude Code's permission dialog asks, and its reason says what the command would change. A rule that already refuses the command still refuses it.
  • If nobody answers within 10 minutes, the command is refused.
  • If you interrupt the turn (Esc), the command is refused.

Notes / limitations

  • It reads the command text. It doesn't parse shell fully: $(...), aliases, eval, and scripts that call rm aren't caught.
  • Each risky part of a command line is measured, up to four. More parts are named but not measured. The pane shows the command on one line, cut off if it's long. Proceed runs the whole line as written.
  • It follows 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.
  • The permission dialog in a narrow terminal shows only a short report: each part's summary and up to three paths. Where nobody can be asked (a headless run), the command is refused.
  • For 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.
  • One command is held at a time. A second risky call, from a subagent for example, waits until the first is answered.
  • It only watches the Bash tool. File edits and other tools aren't held.
  • The 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.
  • The force-push list uses your last fetch of the remote branch. Without one, it can't list the dropped commits, and it says so. When the push names no remote, it assumes origin.
  • To list pending migrations, it runs the tool's own status command (for example 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.
  • A few harmless commands are held too, such as echo done; rm -f empty.txt or ls | xargs rm on a list you meant to delete.
  • It checks one command at a time. It holds a call, but it doesn't sandbox it: after you press Proceed, the command runs as written.
  • This is a safety net, not a permission system. Use permission rules for a hard block.

Dependencies

NameVersionLicense (SPDX)Source
None

The mod has no packages to install. It calls tools that are already on the machine (listed under Requirements).

Third-party notices

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.

Credits and changes

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.

Source 1 files
hooks/blast-radius.mjs 773 lines
1// 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