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.

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