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

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.
In a terminal Claude Code session (2.1.287 or newer):
/plugin install blast-radius --marketplace LupusMalusDeviant/claude-mods
Apache-2.0, see LICENSE.
hooks/entry.ts 9 lines1// 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}
9mods/blast-radius/hooks/blast-radius.mjs 567 lines1// 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