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-13fhva29/claude-code-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 ⏺ 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.
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. Clone this repository and go to this folder's parent:
   git clone https://github.com/anthropics/claude-code-playground.git
   cd claude-code-playground/claude-code/mods
  1. Check the plugin:
   claude plugin validate ./blast-radius
  1. Try it for one session:
   claude --plugin-dir ./blast-radius

Or install it, with the other mods here, from the local marketplace in this folder (see the mods README):

   claude plugin marketplace add ./
   claude plugin install blast-radius@claude-code-playground-mods --scope user

Remove it with claude plugin uninstall blast-radius@claude-code-playground-mods --scope user.

  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 -delete, 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.


Shared as-is as part of claude-code-playground. Not an official Anthropic product; no support or maintenance is implied. See the root README and LICENSE.

Source 1 files
hooks/blast-radius.mjs 531 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" }, async ($, e, next) => {
117    const below = await next(e);
118    if (held === null || held.report === null || held.where !== "band") {
119      return below;
120    }
121    const ui = $.ui.resolve(e);
122    return ui.Box({ flexDirection: "column", children: [below, draw(ui, held)] });
123  });
124}
125
126// ---- What counts as risky -------------------------------------------------
127
128// sudo options that take a value, so the value isn't read as the command.
129const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
130// Commands that only read, so a bare word "migrate" in them isn't a migration.
131const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
132
133/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
134function joinDir(dir, arg) {
135  if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
136    return arg ?? "~";
137  }
138  return dir ? `${dir}/${arg}` : arg;
139}
140
141/** The first risky segment of a shell command, or null. */
142function classify(command) {
143  let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
144  const scopes = []; // dir to restore when a ( subshell ) closes
145  const pushed = []; // pushd stack, for popd
146  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
147    const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
148    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
149    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
150    const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
151    for (let k = 0; k < opens; k += 1) {
152      scopes.push(dir);
153    }
154    const risk = classifySegment(raw, dir, pushed);
155    if (risk !== null && risk.cd === undefined) {
156      return risk;
157    }
158    if (risk !== null) {
159      dir = risk.cd; // a cd, pushd or popd moved the folder
160    }
161    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
162      dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
163    }
164  }
165  return null;
166}
167
168// Words that can come before the real command without changing what it does.
169const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
170
171/** One segment: a risk, { cd } for a folder change, or null. */
172function classifySegment(segment, dir, pushed) {
173  {
174    const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
175    while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
176      words.shift(); // leading VAR=value
177    }
178    if (words[0] === "sudo") {
179      words.shift();
180      while (words.length > 0 && words[0].startsWith("-")) {
181        const option = words.shift();
182        if (SUDO_VALUE_OPTIONS.has(option)) {
183          words.shift();
184        }
185      }
186    }
187    while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
188      words.shift();
189    }
190    if (words[0] === "nice") {
191      words.shift();
192      if (words[0] === "-n") {
193        words.splice(0, 2);
194      } else if (/^-\d+$/.test(words[0] ?? "")) {
195        words.shift();
196      }
197    }
198    const [first, ...args] = words;
199    if (first === undefined) {
200      return null;
201    }
202    const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
203    if (cmd === "cd") {
204      return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
205    }
206    if (cmd === "pushd") {
207      pushed.push(dir);
208      return { cd: joinDir(dir, args[0]) };
209    }
210    if (cmd === "popd") {
211      return { cd: pushed.length > 0 ? pushed.pop() : "-" };
212    }
213    if (cmd === "rm" || cmd.endsWith("/rm")) {
214      const flags = args.filter((a) => a.startsWith("-"));
215      const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
216      const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
217      if (recursive || force) {
218        const targets = args.filter((a) => !a.startsWith("-") || a === "-");
219        return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
220      }
221    }
222    if (cmd === "git") {
223      // Git's own options come before the subcommand; -C moves where it runs.
224      let gitDir = dir;
225      let i = 0;
226      while (i < args.length && args[i].startsWith("-")) {
227        if (args[i] === "-C" && i + 1 < args.length) {
228          gitDir = joinDir(gitDir, args[i + 1]);
229          i += 2;
230        } else if (args[i] === "-c" && i + 1 < args.length) {
231          i += 2;
232        } else {
233          i += 1;
234        }
235      }
236      const sub = args[i];
237      const rest = args.slice(i + 1);
238      if (sub === "reset" && rest.includes("--hard")) {
239        return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
240      }
241      if (sub === "clean") {
242        return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
243      }
244      if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
245        return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
246      }
247      const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
248      if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
249        return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
250      }
251    }
252    const joined = words.join(" ");
253    if (/\balembic\s+upgrade\b/.test(joined)) {
254      return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
255    }
256    if (/\bdb:migrate(?!:status\b)/.test(joined)) {
257      return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
258    }
259    if (/\bprisma\s+migrate\b/.test(joined)) {
260      return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
261    }
262    if (/\bmanage\.py\s+migrate\b/.test(joined)) {
263      return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
264    }
265    if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
266      return { kind: "migrate", tool: "unknown", label: "migrate", dir };
267    }
268  }
269  return null;
270}
271
272// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
273// The target is passed as an argument, never as source.
274const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
275
276async function resolveDir($, sessionCwd, dir) {
277  if (dir === "-") {
278    return null; // `cd -` depends on the shell's history
279  }
280  const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
281  const out = run.stdout.trim();
282  return run.exitCode === 0 && out !== "" ? out : null;
283}
284
285/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
286function tokenize(text) {
287  const words = [];
288  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
289  let m;
290  while ((m = re.exec(text)) !== null) {
291    words.push(m[1] ?? m[2] ?? m[3]);
292  }
293  return words;
294}
295
296// ---- Measuring the blast radius -------------------------------------------
297
298/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
299async function measure($, risk, cwd) {
300  try {
301    if (risk.kind === "rm") {
302      return await measureRm($, risk, cwd);
303    }
304    if (risk.kind === "migrate") {
305      return await measureMigrations($, risk, cwd);
306    }
307    return await measureGit($, risk, cwd);
308  } catch (error) {
309    return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
310  }
311}
312
313// The paths are passed to bash as arguments, never as source, so nothing in
314// them runs. compgen -G expands a glob without command substitution.
315const RM_SCRIPT = `
316shopt -s nullglob dotglob
317paths=()
318for p in "$@"; do
319  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
320  if [[ "$p" == *[*?[]* ]]; then
321    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
322  elif [[ -e "$p" || -L "$p" ]]; then
323    paths+=("$p")
324  fi
325done
326if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
327# A relative path gets ./ in front, so find never reads a name like -delete as an action.
328for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
329files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
330kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
331echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
332find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
333`;
334
335async function measureRm($, risk, cwd) {
336  if (risk.targets.length === 0) {
337    return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
338  }
339  const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
340  const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
341  const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
342  if (!found) {
343    return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
344  }
345  if (!files) {
346    return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
347  }
348  return {
349    summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
350    lines: rest.map((l) => l.replace(/^\.\//, "")),
351    more: Math.max(0, files - rest.length),
352    note: `Paths: ${risk.targets.join(" ")}`,
353  };
354}
355
356async function measureGit($, risk, cwd) {
357  if (risk.kind === "git-push-force") {
358    return await measurePush($, risk, cwd);
359  }
360  if (risk.kind === "git-clean") {
361    const flags = [];
362    const paths = [];
363    for (let i = 0; i < risk.args.length; i += 1) {
364      const a = risk.args[i];
365      if (a === "--") {
366        paths.push(...risk.args.slice(i + 1));
367        break;
368      }
369      if (a === "-e" || a === "--exclude") {
370        flags.push(a, risk.args[i + 1] ?? "");
371        i += 1;
372      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
373        flags.push(a);
374      } else if (/^-[a-zA-Z]+$/.test(a)) {
375        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
376        if (kept !== "-") {
377          flags.push(kept);
378        }
379      } else if (!a.startsWith("-")) {
380        paths.push(a);
381      }
382    }
383    const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
384    if (run.exitCode !== 0) {
385      return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
386    }
387    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
388    return {
389      summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
390      lines: gone.slice(0, LIST_MAX),
391      more: Math.max(0, gone.length - LIST_MAX),
392      note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
393    };
394  }
395  const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
396  if (status.exitCode !== 0) {
397    return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
398  }
399  const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
400  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
401  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
402  const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
403  return {
404    summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
405    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
406    more: Math.max(0, lost.length - LIST_MAX),
407    note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
408  };
409}
410
411async function measurePush($, risk, cwd) {
412  const positional = risk.args.filter((a) => !a.startsWith("-"));
413  const remote = positional[0] ?? "origin";
414  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
415  const spec = (positional[1] ?? "").replace(/^\+/, "");
416  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
417  branch = (branch ?? "").replace(/^refs\/heads\//, "");
418  if (!branch) {
419    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
420    branch = head.stdout.trim();
421    source = "HEAD";
422  } else if (branch === "HEAD") {
423    // `git push origin HEAD` pushes the current branch to its namesake.
424    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
425    branch = head.stdout.trim();
426    source = "HEAD";
427  }
428  source = source || "HEAD";
429  const ref = `${remote}/${branch}`;
430  const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
431  if (known.exitCode !== 0) {
432    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.` };
433  }
434  const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
435  const dropped = log.stdout.split("\n").filter((l) => l !== "");
436  return {
437    summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
438    lines: dropped.slice(0, LIST_MAX),
439    more: Math.max(0, dropped.length - LIST_MAX),
440    note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
441  };
442}
443
444const MIGRATION_LISTERS = {
445  django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
446  alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
447  rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
448  prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
449};
450
451async function measureMigrations($, risk, cwd) {
452  const lister = MIGRATION_LISTERS[risk.tool];
453  if (lister === undefined) {
454    return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
455  }
456  let run;
457  try {
458    run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
459  } catch (error) {
460    run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
461  }
462  if (run.exitCode !== 0) {
463    return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
464  }
465  const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
466  return {
467    summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
468    lines: pending.slice(0, LIST_MAX),
469    more: Math.max(0, pending.length - LIST_MAX),
470    note: `From ${lister.argv.join(" ")}.`,
471  };
472}
473
474function size(bytes) {
475  if (!Number.isFinite(bytes) || bytes < 1024) {
476    return `${bytes || 0} B`;
477  }
478  const units = ["KB", "MB", "GB", "TB"];
479  let n = bytes;
480  let i = -1;
481  while (n >= 1024 && i < units.length - 1) {
482    n /= 1024;
483    i += 1;
484  }
485  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
486}
487
488// ---- Drawing --------------------------------------------------------------
489
490function paneRows(report) {
491  return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
492}
493
494function draw(t, state) {
495  const { Box, Text, Button } = t;
496  const { report } = state;
497  const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: `  ${line}`, wrap: "truncate-end" }));
498  if (report.more) {
499    list.push(Text({ key: "more", dimColor: true, children: `  + ${report.more} more` }));
500  }
501  // The buttons answer the call this pane was drawn for, never whichever one is held now.
502  const decide = (choice) => () => {
503    if (state.decision === null) {
504      state.decision = choice;
505    }
506  };
507  return Box({
508    flexDirection: "column",
509    borderStyle: "round",
510    borderColor: "yellow",
511    paddingX: 1,
512    children: [
513      Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
514      Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command  " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
515      Text({ key: "sum", children: [Text({ dimColor: true, children: "Would    " }), Text({ color: "red", bold: true, children: report.summary })] }),
516      Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
517      report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
518      Box({
519        key: "buttons",
520        marginTop: 1,
521        gap: 2,
522        children: [
523          Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
524          Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
525          Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
526        ],
527      }),
528    ],
529  });
530}
531