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.

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.
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.
| Command | What the pane shows |
|---|---|
rm -r, rm -f, rm -rf | The files it would delete, with the count and total size. Globs and ~ are expanded. |
git reset --hard | The files with uncommitted changes, from git status --porcelain, and git diff --shortstat. |
git checkout -- ., git restore . | The files with unstaged changes. |
git clean | The 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 migrate | The pending migrations, from the tool's own status command. |
any other migrate | A 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:
tool.call until the user answers, and returning { deny } with a reason Claude can act on.Pane, or in the AbovePrompt band when the terminal is too narrow for a pane.$.process.run, passing paths as arguments so nothing in them runs as shell.rm -rf build held, with the files it would delete:

After Cancel, Claude reports that nothing was deleted:

The second try, after Proceed:

git reset --hard in a 120-column terminal, where the report is drawn in the band above the prompt:

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