SLOPSHOPPER

blast-radius

Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would hit.

newpanebandguardtoastprocess
v0.6.4Apache-2.0updated 2026-10-14LupusMalusDeviant/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

Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would hit.

Fork. Based on anthropics/claude-code-playground by Anthropic PBC (Apache-2.0); modified for the Mod Box: it works on Windows (PowerShell wait loop, Git Bash, Windows cd targets). Each changed file says so at the top.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install blast-radius --marketplace LupusMalusDeviant/claude-mods

License

Apache-2.0, see LICENSE.

Source 2 files
hooks/entry.ts 9 lines
1// Entry of the standalone Blast Radius: the Mod Box code, registered on its own.
2import type { Register } from 'claude-code'
3
4import { register as inner } from '../mods/blast-radius/hooks/blast-radius.mjs'
5
6export const register: Register = (on, options) => {
7  ;(inner as any)(on, options)
8}
9
mods/blast-radius/hooks/blast-radius.mjs 567 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified for mod-box (https://github.com/LupusMalusDeviant/mod-box): Windows support (PowerShell wait loop, Git Bash, Windows cd targets), state/matchers/paths rewritten for the single plugin.
4// Original: https://github.com/anthropics/claude-code-playground/tree/main/claude-code/mods/blast-radius (commit 569c528).
5//
6// Blast Radius: holds a risky Bash command and shows what it would change.
7//
8// tool.call (Bash): if the command is risky, work out its blast radius, open a
9// pane with Proceed and Cancel, and hold the call until one is pressed.
10// ui.render (Pane): draws the report. If the surface won't place the pane (a
11// narrow terminal), the same report is drawn in the AbovePrompt band instead.
12//
13// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
14// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
15// a button's onPress sets the decision.
16//
17// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
18// spelled literally, and helpers that take `$` are top-level functions.
19
20const PANE_ID = "blast-radius";
21const POLL_SECONDS = "0.25";
22const HOLD_LIMIT_MS = 10 * 60 * 1000;
23const LIST_MAX = 10;
24
25// The call being held, or null. One at a time: Bash calls in a turn run in order.
26let held = null;
27
28export function register(on) {
29  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
30    const risk = classify(String(e.command ?? ""));
31    if (risk === null) {
32      return next(e);
33    }
34    // One hold at a time. If another risky call is already held (a subagent's,
35    // say), wait until it is answered. `held` is claimed with no await between
36    // the check and the claim, so two waiting calls can't both get through.
37    while (held !== null) {
38      if (next.signal.aborted) {
39        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." };
40      }
41      await pause($);
42    }
43    const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
44    held = mine;
45
46    let opened = { isPlaced: false };
47    let decision;
48    let summary = risk.label;
49    try {
50      // Measure where the command will run: the session folder, moved by any
51      // `cd dir &&` or `git -C dir` earlier in the same command line.
52      const sessionCwd = await $.session.cwd();
53      const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
54      mine.report = cwd === null
55        ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
56        : await measure($, risk, cwd);
57      summary = mine.report.summary;
58
59      opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
60      if (!opened.isPlaced) {
61        mine.where = "band";
62      }
63      $.ui.invalidate("ui.render");
64
65      const startedAt = await $.clock.now();
66      while (mine.decision === null) {
67        if (next.signal.aborted) {
68          mine.decision = "interrupted";
69          break;
70        }
71        if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
72          mine.decision = "timeout";
73          break;
74        }
75        await pause($);
76      }
77    } catch {
78      mine.decision = "error"; // anything unexpected refuses the command
79    } finally {
80      decision = mine.decision;
81      // Close this call's pane before releasing the hold, so the next call's
82      // pane can't be the one that gets closed.
83      try {
84        if (opened.isPlaced) {
85          await $.ui.close({ id: PANE_ID });
86        }
87      } catch {
88        // the pane is already gone
89      }
90      if (held === mine) {
91        held = null;
92      }
93      $.ui.invalidate("ui.render");
94    }
95
96    if (decision === "proceed") {
97      $.ui.toast("Blast Radius: running it");
98      return next(e);
99    }
100    const why = {
101      cancel: "the user pressed Cancel",
102      timeout: "no answer within 10 minutes",
103      interrupted: "the turn was interrupted",
104      error: "Blast Radius hit an error while holding it",
105    }[decision] ?? "no answer was recorded";
106    return {
107      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.`,
108    };
109  });
110
111  on("ui.render", { component: "Pane" }, ($, e, next) => {
112    if (e.requestId !== PANE_ID || held === null || held.report === null) {
113      return next(e);
114    }
115    return draw($.ui.resolve(e), held);
116  });
117
118  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
119    if (held === null || held.report === null || held.where !== "band") {
120      return next(e);
121    }
122    return draw($.ui.resolve(e), 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 -W 2>/dev/null || pwd -P; }`;
275// Mod Box: under Git Bash (Windows) `pwd -W` prints the Windows path (X:/…) that $.process.run
276// accepts as working folder; elsewhere it fails and `pwd -P` answers as in the original.
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([await bashExe($), "-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  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
322  if [[ "$p" == *[*?[]* ]]; then
323    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
324  elif [[ -e "$p" || -L "$p" ]]; then
325    paths+=("$p")
326  fi
327done
328if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
329# A relative path gets ./ in front, so find never reads a name like -delete as an action.
330for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
331files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
332kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
333echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
334find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
335`;
336
337async function measureRm($, risk, cwd) {
338  if (risk.targets.length === 0) {
339    return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
340  }
341  const run = await $.process.run([await bashExe($), "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
342  const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
343  const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
344  if (!found) {
345    return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
346  }
347  if (!files) {
348    return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
349  }
350  return {
351    summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
352    lines: rest.map((l) => l.replace(/^\.\//, "")),
353    more: Math.max(0, files - rest.length),
354    note: `Paths: ${risk.targets.join(" ")}`,
355  };
356}
357
358async function measureGit($, risk, cwd) {
359  if (risk.kind === "git-push-force") {
360    return await measurePush($, risk, cwd);
361  }
362  if (risk.kind === "git-clean") {
363    const flags = [];
364    const paths = [];
365    for (let i = 0; i < risk.args.length; i += 1) {
366      const a = risk.args[i];
367      if (a === "--") {
368        paths.push(...risk.args.slice(i + 1));
369        break;
370      }
371      if (a === "-e" || a === "--exclude") {
372        flags.push(a, risk.args[i + 1] ?? "");
373        i += 1;
374      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
375        flags.push(a);
376      } else if (/^-[a-zA-Z]+$/.test(a)) {
377        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
378        if (kept !== "-") {
379          flags.push(kept);
380        }
381      } else if (!a.startsWith("-")) {
382        paths.push(a);
383      }
384    }
385    const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
386    if (run.exitCode !== 0) {
387      return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
388    }
389    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
390    return {
391      summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
392      lines: gone.slice(0, LIST_MAX),
393      more: Math.max(0, gone.length - LIST_MAX),
394      note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
395    };
396  }
397  const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
398  if (status.exitCode !== 0) {
399    return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
400  }
401  const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
402  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
403  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
404  const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
405  return {
406    summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
407    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
408    more: Math.max(0, lost.length - LIST_MAX),
409    note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
410  };
411}
412
413async function measurePush($, risk, cwd) {
414  const positional = risk.args.filter((a) => !a.startsWith("-"));
415  const remote = positional[0] ?? "origin";
416  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
417  const spec = (positional[1] ?? "").replace(/^\+/, "");
418  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
419  branch = (branch ?? "").replace(/^refs\/heads\//, "");
420  if (!branch) {
421    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
422    branch = head.stdout.trim();
423    source = "HEAD";
424  } else if (branch === "HEAD") {
425    // `git push origin HEAD` pushes the current branch to its namesake.
426    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
427    branch = head.stdout.trim();
428    source = "HEAD";
429  }
430  source = source || "HEAD";
431  const ref = `${remote}/${branch}`;
432  const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
433  if (known.exitCode !== 0) {
434    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.` };
435  }
436  const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
437  const dropped = log.stdout.split("\n").filter((l) => l !== "");
438  return {
439    summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
440    lines: dropped.slice(0, LIST_MAX),
441    more: Math.max(0, dropped.length - LIST_MAX),
442    note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
443  };
444}
445
446const MIGRATION_LISTERS = {
447  django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
448  alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
449  rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
450  prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
451};
452
453async function measureMigrations($, risk, cwd) {
454  const lister = MIGRATION_LISTERS[risk.tool];
455  if (lister === undefined) {
456    return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
457  }
458  let run;
459  try {
460    run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
461  } catch (error) {
462    run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
463  }
464  if (run.exitCode !== 0) {
465    return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
466  }
467  const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
468  return {
469    summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
470    lines: pending.slice(0, LIST_MAX),
471    more: Math.max(0, pending.length - LIST_MAX),
472    note: `From ${lister.argv.join(" ")}.`,
473  };
474}
475
476function size(bytes) {
477  if (!Number.isFinite(bytes) || bytes < 1024) {
478    return `${bytes || 0} B`;
479  }
480  const units = ["KB", "MB", "GB", "TB"];
481  let n = bytes;
482  let i = -1;
483  while (n >= 1024 && i < units.length - 1) {
484    n /= 1024;
485    i += 1;
486  }
487  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
488}
489
490// ---- Drawing --------------------------------------------------------------
491
492function paneRows(report) {
493  return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
494}
495
496function draw(t, state) {
497  const { Box, Text, Button } = t;
498  const { report } = state;
499  const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: `  ${line}`, wrap: "truncate-end" }));
500  if (report.more) {
501    list.push(Text({ key: "more", dimColor: true, children: `  + ${report.more} more` }));
502  }
503  // The buttons answer the call this pane was drawn for, never whichever one is held now.
504  const decide = (choice) => () => {
505    if (state.decision === null) {
506      state.decision = choice;
507    }
508  };
509  return Box({
510    flexDirection: "column",
511    borderStyle: "round",
512    borderColor: "yellow",
513    paddingX: 1,
514    children: [
515      Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
516      Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command  " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
517      Text({ key: "sum", children: [Text({ dimColor: true, children: "Would    " }), Text({ color: "red", bold: true, children: report.summary })] }),
518      Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
519      report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
520      Box({
521        key: "buttons",
522        marginTop: 1,
523        gap: 2,
524        children: [
525          Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
526          Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
527          Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
528        ],
529      }),
530    ],
531  });
532}
533
534// Mod Box: wait in a process so it doesn't count against the hook's time budget (as in the
535// original). Windows has no "sleep" on PATH; PowerShell does it there.
536let sleepArgv = null;
537async function pause($) {
538  if (sleepArgv === null) {
539    sleepArgv = (await $.env.get("OS")) === "Windows_NT"
540      ? ["powershell", "-NoProfile", "-NonInteractive", "-Command", `Start-Sleep -Milliseconds ${Math.round(Number(POLL_SECONDS) * 1000)}`]
541      : ["sleep", POLL_SECONDS];
542  }
543  await $.process.run(sleepArgv, { timeoutMs: 10000 });
544}
545
546// Mod Box: on Windows, "bash" on PATH is often the WSL launcher, which doesn't know Windows paths.
547// Git Bash ships the GNU tools the scripts above need (find, head, du).
548let bashPath = null;
549async function bashExe($) {
550  if (bashPath === null) {
551    bashPath = "bash";
552    if ((await $.env.get("OS")) === "Windows_NT") {
553      const candidates = [
554        `${(await $.env.get("ProgramFiles")) ?? "C:/Program Files"}/Git/bin/bash.exe`,
555        `${(await $.env.get("LOCALAPPDATA")) ?? ""}/Programs/Git/bin/bash.exe`,
556      ];
557      for (const c of candidates) {
558        if (await $.fs.exists(c).catch(() => false)) {
559          bashPath = c;
560          break;
561        }
562      }
563    }
564  }
565  return bashPath;
566}
567