SLOPSHOPPER

blast-radius

Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed and Cancel buttons.

newpanebandguardtoastprocess
v0.1.0no licenseupdated 2026-10-10mlopian/claude-plugins/blast-radius
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ Blast Radius ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⚠ Blast Radius · rm -rf ⏺ Read(src/auth.ts) │ ┃ │ Command rm -rf build && git push --for… ⎿ Read 6 lines │ ┃ │ Would delete nothing: no file matches ⏺ Update(src/auth.ts) │ ┃ │ build ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ 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. │ ┃ │ 1: Proceed 2: Cancel Claude is waiting o │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ Blast Radius · rm -rf │ │ 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. │ │ │ │ 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.
find ... -delete, find ... -exec rm (also -execdir, -ok, unlink)The paths it would delete, from the same find with its actions swapped for -print. Other actions such as -fprint or -exec chmod don't run while measuring.
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.

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 same report in a Pane, or in the AbovePrompt band 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

git reset --hard in a 120-column terminal, where the report is drawn in the band above the prompt:

Blast Radius showing git reset --hard in the band above the prompt

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. Narrower terminals get the band above the prompt.

No environment variables or configuration.

Steps:

  1. Install it from this marketplace:
   /plugin install blast-radius --marketplace mlopian/claude-plugins

Or try it for one session from a local clone: claude --plugin-dir ./blast-radius.

  1. Start a new Claude Code session.
  1. Check it from the repository root: claude plugin validate blast-radius and claude plugin test 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 the band above the prompt, 1 or 2 works while the input is empty.
  • 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, bash -c "...", xargs rm, find -exec sh -c 'rm ...', scripts that call rm, and wrappers such as timeout 5 rm, doas rm, time -p rm or env -i rm aren't caught.
  • Only the first risky part of a command line is measured, and 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.
  • In a narrow terminal the report is drawn in the band above the prompt, which only one mod can use at a time. If another mod that draws there (such as Replay Theater or Token Weather in this folder) takes the band, the Proceed and Cancel buttons may not show, and the command is refused after 10 minutes. Use a wider terminal, or turn the other mod off, when you rely on Blast Radius.
  • 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 a commit whose message contains ; rm -rf.
  • 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.


Based on the Blast Radius example from anthropics/claude-code-playground (Apache-2.0), changed to also hold find -delete and find -exec rm.

Source 1 files
hooks/blast-radius.mjs 589 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Blast Radius: holds a risky Bash command and shows what it would change.
5//
6// tool.call (Bash): if the command is risky, work out its blast radius, open a
7// pane with Proceed and Cancel, and hold the call until one is pressed.
8// ui.render (Pane): draws the report. If the surface won't place the pane (a
9// narrow terminal), the same report is drawn in the AbovePrompt band instead.
10//
11// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
12// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
13// a button's onPress sets the decision.
14//
15// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
16// spelled literally, and helpers that take `$` are top-level functions.
17
18const PANE_ID = "blast-radius";
19const POLL_SECONDS = "0.25";
20const HOLD_LIMIT_MS = 10 * 60 * 1000;
21const LIST_MAX = 10;
22
23// The call being held, or null. One at a time: Bash calls in a turn run in order.
24let held = null;
25
26export function register(on) {
27  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
28    const risk = classify(String(e.command ?? ""));
29    if (risk === null) {
30      return next(e);
31    }
32    // One hold at a time. If another risky call is already held (a subagent's,
33    // say), wait until it is answered. `held` is claimed with no await between
34    // the check and the claim, so two waiting calls can't both get through.
35    while (held !== null) {
36      if (next.signal.aborted) {
37        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." };
38      }
39      await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
40    }
41    const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
42    held = mine;
43
44    let opened = { isPlaced: false };
45    let decision;
46    let summary = risk.label;
47    try {
48      // Measure where the command will run: the session folder, moved by any
49      // `cd dir &&` or `git -C dir` earlier in the same command line.
50      const sessionCwd = await $.session.cwd();
51      const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
52      mine.report = cwd === null
53        ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
54        : await measure($, risk, cwd);
55      summary = mine.report.summary;
56
57      opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
58      if (!opened.isPlaced) {
59        mine.where = "band";
60      }
61      $.ui.invalidate("ui.render");
62
63      const startedAt = await $.clock.now();
64      while (mine.decision === null) {
65        if (next.signal.aborted) {
66          mine.decision = "interrupted";
67          break;
68        }
69        if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
70          mine.decision = "timeout";
71          break;
72        }
73        await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
74      }
75    } catch {
76      mine.decision = "error"; // anything unexpected refuses the command
77    } finally {
78      decision = mine.decision;
79      // Close this call's pane before releasing the hold, so the next call's
80      // pane can't be the one that gets closed.
81      try {
82        if (opened.isPlaced) {
83          await $.ui.close({ id: PANE_ID });
84        }
85      } catch {
86        // the pane is already gone
87      }
88      if (held === mine) {
89        held = null;
90      }
91      $.ui.invalidate("ui.render");
92    }
93
94    if (decision === "proceed") {
95      $.ui.toast("Blast Radius: running it");
96      return next(e);
97    }
98    const why = {
99      cancel: "the user pressed Cancel",
100      timeout: "no answer within 10 minutes",
101      interrupted: "the turn was interrupted",
102      error: "Blast Radius hit an error while holding it",
103    }[decision] ?? "no answer was recorded";
104    return {
105      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.`,
106    };
107  });
108
109  on("ui.render", { component: "Pane" }, ($, e, next) => {
110    if (e.requestId !== PANE_ID || held === null || held.report === null) {
111      return next(e);
112    }
113    return draw($.ui.resolve(e), held);
114  });
115
116  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
117    if (held === null || held.report === null || held.where !== "band") {
118      return next(e);
119    }
120    return draw($.ui.resolve(e), held);
121  });
122}
123
124// ---- What counts as risky -------------------------------------------------
125
126// sudo options that take a value, so the value isn't read as the command.
127const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
128// Commands that only read, so a bare word "migrate" in them isn't a migration.
129const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
130
131/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
132function joinDir(dir, arg) {
133  if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
134    return arg ?? "~";
135  }
136  return dir ? `${dir}/${arg}` : arg;
137}
138
139/** The first risky segment of a shell command, or null. */
140export function classify(command) {
141  let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
142  const scopes = []; // dir to restore when a ( subshell ) closes
143  const pushed = []; // pushd stack, for popd
144  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
145    const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
146    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
147    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
148    const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
149    for (let k = 0; k < opens; k += 1) {
150      scopes.push(dir);
151    }
152    const risk = classifySegment(raw, dir, pushed);
153    if (risk !== null && risk.cd === undefined) {
154      return risk;
155    }
156    if (risk !== null) {
157      dir = risk.cd; // a cd, pushd or popd moved the folder
158    }
159    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
160      dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
161    }
162  }
163  return null;
164}
165
166// Words that can come before the real command without changing what it does.
167const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
168
169/** One segment: a risk, { cd } for a folder change, or null. */
170function classifySegment(segment, dir, pushed) {
171  {
172    const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
173    while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
174      words.shift(); // leading VAR=value
175    }
176    if (words[0] === "sudo") {
177      words.shift();
178      while (words.length > 0 && words[0].startsWith("-")) {
179        const option = words.shift();
180        if (SUDO_VALUE_OPTIONS.has(option)) {
181          words.shift();
182        }
183      }
184    }
185    while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
186      words.shift();
187    }
188    if (words[0] === "nice") {
189      words.shift();
190      if (words[0] === "-n") {
191        words.splice(0, 2);
192      } else if (/^-\d+$/.test(words[0] ?? "")) {
193        words.shift();
194      }
195    }
196    const [first, ...args] = words;
197    if (first === undefined) {
198      return null;
199    }
200    const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
201    if (cmd === "cd") {
202      return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
203    }
204    if (cmd === "pushd") {
205      pushed.push(dir);
206      return { cd: joinDir(dir, args[0]) };
207    }
208    if (cmd === "popd") {
209      return { cd: pushed.length > 0 ? pushed.pop() : "-" };
210    }
211    if (cmd === "rm" || cmd.endsWith("/rm")) {
212      const flags = args.filter((a) => a.startsWith("-"));
213      const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
214      const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
215      if (recursive || force) {
216        const targets = args.filter((a) => !a.startsWith("-") || a === "-");
217        return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
218      }
219    }
220    if (cmd === "find" || cmd.endsWith("/find")) {
221      const dryRun = findDryRun(args);
222      if (dryRun !== null) {
223        return { kind: "find", label: dryRun.label, args: dryRun.args, dir };
224      }
225    }
226    if (cmd === "git") {
227      // Git's own options come before the subcommand; -C moves where it runs.
228      let gitDir = dir;
229      let i = 0;
230      while (i < args.length && args[i].startsWith("-")) {
231        if (args[i] === "-C" && i + 1 < args.length) {
232          gitDir = joinDir(gitDir, args[i + 1]);
233          i += 2;
234        } else if (args[i] === "-c" && i + 1 < args.length) {
235          i += 2;
236        } else {
237          i += 1;
238        }
239      }
240      const sub = args[i];
241      const rest = args.slice(i + 1);
242      if (sub === "reset" && rest.includes("--hard")) {
243        return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
244      }
245      if (sub === "clean") {
246        return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
247      }
248      if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
249        return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
250      }
251      const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
252      if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
253        return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
254      }
255    }
256    const joined = words.join(" ");
257    if (/\balembic\s+upgrade\b/.test(joined)) {
258      return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
259    }
260    if (/\bdb:migrate(?!:status\b)/.test(joined)) {
261      return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
262    }
263    if (/\bprisma\s+migrate\b/.test(joined)) {
264      return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
265    }
266    if (/\bmanage\.py\s+migrate\b/.test(joined)) {
267      return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
268    }
269    if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
270      return { kind: "migrate", tool: "unknown", label: "migrate", dir };
271    }
272  }
273  return null;
274}
275
276const FIND_RUNS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
277const FIND_OUTPUTS = new Map([["-print", 0], ["-print0", 0], ["-ls", 0], ["-printf", 1], ["-fprint", 1], ["-fprint0", 1], ["-fls", 1], ["-fprintf", 2]]);
278
279function isRemover(word) {
280  const tool = (word ?? "").replace(/^\\/, "");
281  return tool === "rm" || tool.endsWith("/rm") || tool === "unlink" || tool.endsWith("/unlink");
282}
283
284export function findDryRun(args) {
285  const out = [];
286  const actions = new Set();
287  for (let i = 0; i < args.length; i += 1) {
288    const a = args[i];
289    if (a === "-delete") {
290      actions.add("-delete");
291      out.push("-print");
292    } else if (FIND_RUNS.has(a)) {
293      let end = i + 1;
294      while (end < args.length && !/^\\?;$/.test(args[end]) && args[end] !== "+") {
295        end += 1;
296      }
297      const removes = isRemover(args[i + 1]);
298      if (removes) {
299        actions.add(`${a} rm`);
300      }
301      out.push(removes ? "-print" : "-true");
302      i = end;
303    } else if (FIND_OUTPUTS.has(a)) {
304      out.push("-true");
305      i += FIND_OUTPUTS.get(a);
306    } else {
307      out.push(a);
308    }
309  }
310  return actions.size === 0 ? null : { label: `find ${[...actions].join(" ")}`, args: out };
311}
312
313// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
314// The target is passed as an argument, never as source.
315const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
316
317async function resolveDir($, sessionCwd, dir) {
318  if (dir === "-") {
319    return null; // `cd -` depends on the shell's history
320  }
321  const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
322  const out = run.stdout.trim();
323  return run.exitCode === 0 && out !== "" ? out : null;
324}
325
326/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
327function tokenize(text) {
328  const words = [];
329  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
330  let m;
331  while ((m = re.exec(text)) !== null) {
332    words.push(m[1] ?? m[2] ?? m[3]);
333  }
334  return words;
335}
336
337// ---- Measuring the blast radius -------------------------------------------
338
339/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
340async function measure($, risk, cwd) {
341  try {
342    if (risk.kind === "rm") {
343      return await measureRm($, risk, cwd);
344    }
345    if (risk.kind === "migrate") {
346      return await measureMigrations($, risk, cwd);
347    }
348    if (risk.kind === "find") {
349      return await measureFind($, risk, cwd);
350    }
351    return await measureGit($, risk, cwd);
352  } catch (error) {
353    return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
354  }
355}
356
357// The paths are passed to bash as arguments, never as source, so nothing in
358// them runs. compgen -G expands a glob without command substitution.
359const RM_SCRIPT = `
360shopt -s nullglob dotglob
361paths=()
362for p in "$@"; do
363  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
364  if [[ "$p" == *[*?[]* ]]; then
365    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
366  elif [[ -e "$p" || -L "$p" ]]; then
367    paths+=("$p")
368  fi
369done
370if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
371# A relative path gets ./ in front, so find never reads a name like -delete as an action.
372for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
373files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
374kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
375echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
376find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
377`;
378
379async function measureRm($, risk, cwd) {
380  if (risk.targets.length === 0) {
381    return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
382  }
383  const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
384  const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
385  const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
386  if (!found) {
387    return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
388  }
389  if (!files) {
390    return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
391  }
392  return {
393    summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
394    lines: rest.map((l) => l.replace(/^\.\//, "")),
395    more: Math.max(0, files - rest.length),
396    note: `Paths: ${risk.targets.join(" ")}`,
397  };
398}
399
400async function measureFind($, risk, cwd) {
401  const run = await $.process.run(["find", ...risk.args], { cwd, timeoutMs: 15000 });
402  const paths = run.stdout.split("\n").filter((l) => l !== "");
403  if (run.exitCode !== 0 && paths.length === 0) {
404    return { summary: `${risk.label} (could not dry-run it)`, lines: [], note: run.stderr.trim().slice(0, 200) };
405  }
406  return {
407    summary: paths.length === 0 ? "delete nothing: find matches no paths" : `delete ${paths.length}${run.isStdoutTruncated ? "+" : ""} ${paths.length === 1 ? "path" : "paths"}`,
408    lines: paths.slice(0, LIST_MAX).map((l) => l.replace(/^\.\//, "")),
409    more: Math.max(0, paths.length - LIST_MAX),
410    note: "From the same find with its actions swapped for -print. Deleted files can't be recovered.",
411  };
412}
413
414async function measureGit($, risk, cwd) {
415  if (risk.kind === "git-push-force") {
416    return await measurePush($, risk, cwd);
417  }
418  if (risk.kind === "git-clean") {
419    const flags = [];
420    const paths = [];
421    for (let i = 0; i < risk.args.length; i += 1) {
422      const a = risk.args[i];
423      if (a === "--") {
424        paths.push(...risk.args.slice(i + 1));
425        break;
426      }
427      if (a === "-e" || a === "--exclude") {
428        flags.push(a, risk.args[i + 1] ?? "");
429        i += 1;
430      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
431        flags.push(a);
432      } else if (/^-[a-zA-Z]+$/.test(a)) {
433        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
434        if (kept !== "-") {
435          flags.push(kept);
436        }
437      } else if (!a.startsWith("-")) {
438        paths.push(a);
439      }
440    }
441    const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
442    if (run.exitCode !== 0) {
443      return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
444    }
445    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
446    return {
447      summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
448      lines: gone.slice(0, LIST_MAX),
449      more: Math.max(0, gone.length - LIST_MAX),
450      note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
451    };
452  }
453  const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
454  if (status.exitCode !== 0) {
455    return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
456  }
457  const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
458  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
459  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
460  const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
461  return {
462    summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
463    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
464    more: Math.max(0, lost.length - LIST_MAX),
465    note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
466  };
467}
468
469async function measurePush($, risk, cwd) {
470  const positional = risk.args.filter((a) => !a.startsWith("-"));
471  const remote = positional[0] ?? "origin";
472  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
473  const spec = (positional[1] ?? "").replace(/^\+/, "");
474  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
475  branch = (branch ?? "").replace(/^refs\/heads\//, "");
476  if (!branch) {
477    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
478    branch = head.stdout.trim();
479    source = "HEAD";
480  } else if (branch === "HEAD") {
481    // `git push origin HEAD` pushes the current branch to its namesake.
482    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
483    branch = head.stdout.trim();
484    source = "HEAD";
485  }
486  source = source || "HEAD";
487  const ref = `${remote}/${branch}`;
488  const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
489  if (known.exitCode !== 0) {
490    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.` };
491  }
492  const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
493  const dropped = log.stdout.split("\n").filter((l) => l !== "");
494  return {
495    summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
496    lines: dropped.slice(0, LIST_MAX),
497    more: Math.max(0, dropped.length - LIST_MAX),
498    note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
499  };
500}
501
502const MIGRATION_LISTERS = {
503  django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
504  alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
505  rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
506  prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
507};
508
509async function measureMigrations($, risk, cwd) {
510  const lister = MIGRATION_LISTERS[risk.tool];
511  if (lister === undefined) {
512    return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
513  }
514  let run;
515  try {
516    run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
517  } catch (error) {
518    run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
519  }
520  if (run.exitCode !== 0) {
521    return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
522  }
523  const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
524  return {
525    summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
526    lines: pending.slice(0, LIST_MAX),
527    more: Math.max(0, pending.length - LIST_MAX),
528    note: `From ${lister.argv.join(" ")}.`,
529  };
530}
531
532function size(bytes) {
533  if (!Number.isFinite(bytes) || bytes < 1024) {
534    return `${bytes || 0} B`;
535  }
536  const units = ["KB", "MB", "GB", "TB"];
537  let n = bytes;
538  let i = -1;
539  while (n >= 1024 && i < units.length - 1) {
540    n /= 1024;
541    i += 1;
542  }
543  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
544}
545
546// ---- Drawing --------------------------------------------------------------
547
548function paneRows(report) {
549  return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
550}
551
552function draw(t, state) {
553  const { Box, Text, Button } = t;
554  const { report } = state;
555  const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: `  ${line}`, wrap: "truncate-end" }));
556  if (report.more) {
557    list.push(Text({ key: "more", dimColor: true, children: `  + ${report.more} more` }));
558  }
559  // The buttons answer the call this pane was drawn for, never whichever one is held now.
560  const decide = (choice) => () => {
561    if (state.decision === null) {
562      state.decision = choice;
563    }
564  };
565  return Box({
566    flexDirection: "column",
567    borderStyle: "round",
568    borderColor: "yellow",
569    paddingX: 1,
570    children: [
571      Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
572      Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command  " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
573      Text({ key: "sum", children: [Text({ dimColor: true, children: "Would    " }), Text({ color: "red", bold: true, children: report.summary })] }),
574      Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
575      report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
576      Box({
577        key: "buttons",
578        marginTop: 1,
579        gap: 2,
580        children: [
581          Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
582          Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
583          Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
584        ],
585      }),
586    ],
587  });
588}
589