SLOPSHOPPER

blast-radius

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

newpanebandguardtoastprocess
v0.1.0no licenseupdated 2026-10-08dillonmohr8777/claude-skills-repo/mods/blast-radius
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ Blast Radius ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⚠ Blast Radius · rm -rf ⏺ Read(src/auth.ts) │ ┃ │ Command rm -rf build && git push --for… ⎿ Read 6 lines │ ┃ │ Would delete nothing: no file matches ⏺ Update(src/auth.ts) │ ┃ │ build ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ The paths don't exist, so rm has nothing ⎿ Denied by blast-radius: Blast Radius held this command an │ ┃ │ to remove. │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 1: Proceed 2: Cancel Claude is waiting o │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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