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.1Apache-2.0updated 2026-10-12akaz01/claude-mods/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.
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.

출처

anthropics/claude-code-playground의 claude-code/mods/blast-radius를 수정한 버전입니다. Copyright 2026 Anthropic PBC, Apache License 2.0. 변경 내역은 저장소 루트의 NOTICE를 참고하세요. Anthropic 공식 배포물이 아닙니다.

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