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. |
find ... -delete, find ... -exec rm (also -execdir, -ok, unlink) | The paths it would delete, from the same find with its actions swapped for -print. Other actions such as -fprint or -exec chmod don't run while measuring. |
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:
/plugin install blast-radius --marketplace mlopian/claude-plugins
Or try it for one session from a local clone: claude --plugin-dir ./blast-radius.
claude plugin validate blast-radius and claude plugin test blast-radius.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 -exec sh -c 'rm ...', 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).
Git is a trademark of Software Freedom Conservancy. Python is a registered trademark of the Python Software Foundation. npm is a trademark of npm, Inc. Django is a registered trademark of the Django Software Foundation. Rails and Ruby on Rails are trademarks of David Heinemeier Hansson. Prisma is a trademark of Prisma Data, Inc. Alembic is an open-source project of the SQLAlchemy authors. Use of these names here is descriptive and implies no endorsement.
Based on the Blast Radius example from anthropics/claude-code-playground (Apache-2.0), changed to also hold find -delete and find -exec rm.
hooks/blast-radius.mjs 589 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. */
140export function 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 === "find" || cmd.endsWith("/find")) {
221 const dryRun = findDryRun(args);
222 if (dryRun !== null) {
223 return { kind: "find", label: dryRun.label, args: dryRun.args, dir };
224 }
225 }
226 if (cmd === "git") {
227 // Git's own options come before the subcommand; -C moves where it runs.
228 let gitDir = dir;
229 let i = 0;
230 while (i < args.length && args[i].startsWith("-")) {
231 if (args[i] === "-C" && i + 1 < args.length) {
232 gitDir = joinDir(gitDir, args[i + 1]);
233 i += 2;
234 } else if (args[i] === "-c" && i + 1 < args.length) {
235 i += 2;
236 } else {
237 i += 1;
238 }
239 }
240 const sub = args[i];
241 const rest = args.slice(i + 1);
242 if (sub === "reset" && rest.includes("--hard")) {
243 return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
244 }
245 if (sub === "clean") {
246 return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
247 }
248 if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
249 return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
250 }
251 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
252 if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
253 return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
254 }
255 }
256 const joined = words.join(" ");
257 if (/\balembic\s+upgrade\b/.test(joined)) {
258 return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
259 }
260 if (/\bdb:migrate(?!:status\b)/.test(joined)) {
261 return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
262 }
263 if (/\bprisma\s+migrate\b/.test(joined)) {
264 return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
265 }
266 if (/\bmanage\.py\s+migrate\b/.test(joined)) {
267 return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
268 }
269 if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
270 return { kind: "migrate", tool: "unknown", label: "migrate", dir };
271 }
272 }
273 return null;
274}
275
276const FIND_RUNS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
277const FIND_OUTPUTS = new Map([["-print", 0], ["-print0", 0], ["-ls", 0], ["-printf", 1], ["-fprint", 1], ["-fprint0", 1], ["-fls", 1], ["-fprintf", 2]]);
278
279function isRemover(word) {
280 const tool = (word ?? "").replace(/^\\/, "");
281 return tool === "rm" || tool.endsWith("/rm") || tool === "unlink" || tool.endsWith("/unlink");
282}
283
284export function findDryRun(args) {
285 const out = [];
286 const actions = new Set();
287 for (let i = 0; i < args.length; i += 1) {
288 const a = args[i];
289 if (a === "-delete") {
290 actions.add("-delete");
291 out.push("-print");
292 } else if (FIND_RUNS.has(a)) {
293 let end = i + 1;
294 while (end < args.length && !/^\\?;$/.test(args[end]) && args[end] !== "+") {
295 end += 1;
296 }
297 const removes = isRemover(args[i + 1]);
298 if (removes) {
299 actions.add(`${a} rm`);
300 }
301 out.push(removes ? "-print" : "-true");
302 i = end;
303 } else if (FIND_OUTPUTS.has(a)) {
304 out.push("-true");
305 i += FIND_OUTPUTS.get(a);
306 } else {
307 out.push(a);
308 }
309 }
310 return actions.size === 0 ? null : { label: `find ${[...actions].join(" ")}`, args: out };
311}
312
313// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
314// The target is passed as an argument, never as source.
315const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
316
317async function resolveDir($, sessionCwd, dir) {
318 if (dir === "-") {
319 return null; // `cd -` depends on the shell's history
320 }
321 const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
322 const out = run.stdout.trim();
323 return run.exitCode === 0 && out !== "" ? out : null;
324}
325
326/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
327function tokenize(text) {
328 const words = [];
329 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
330 let m;
331 while ((m = re.exec(text)) !== null) {
332 words.push(m[1] ?? m[2] ?? m[3]);
333 }
334 return words;
335}
336
337// ---- Measuring the blast radius -------------------------------------------
338
339/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
340async function measure($, risk, cwd) {
341 try {
342 if (risk.kind === "rm") {
343 return await measureRm($, risk, cwd);
344 }
345 if (risk.kind === "migrate") {
346 return await measureMigrations($, risk, cwd);
347 }
348 if (risk.kind === "find") {
349 return await measureFind($, risk, cwd);
350 }
351 return await measureGit($, risk, cwd);
352 } catch (error) {
353 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
354 }
355}
356
357// The paths are passed to bash as arguments, never as source, so nothing in
358// them runs. compgen -G expands a glob without command substitution.
359const RM_SCRIPT = `
360shopt -s nullglob dotglob
361paths=()
362for p in "$@"; do
363 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
364 if [[ "$p" == *[*?[]* ]]; then
365 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
366 elif [[ -e "$p" || -L "$p" ]]; then
367 paths+=("$p")
368 fi
369done
370if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
371# A relative path gets ./ in front, so find never reads a name like -delete as an action.
372for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
373files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
374kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
375echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
376find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
377`;
378
379async function measureRm($, risk, cwd) {
380 if (risk.targets.length === 0) {
381 return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
382 }
383 const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
384 const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
385 const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
386 if (!found) {
387 return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
388 }
389 if (!files) {
390 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
391 }
392 return {
393 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
394 lines: rest.map((l) => l.replace(/^\.\//, "")),
395 more: Math.max(0, files - rest.length),
396 note: `Paths: ${risk.targets.join(" ")}`,
397 };
398}
399
400async function measureFind($, risk, cwd) {
401 const run = await $.process.run(["find", ...risk.args], { cwd, timeoutMs: 15000 });
402 const paths = run.stdout.split("\n").filter((l) => l !== "");
403 if (run.exitCode !== 0 && paths.length === 0) {
404 return { summary: `${risk.label} (could not dry-run it)`, lines: [], note: run.stderr.trim().slice(0, 200) };
405 }
406 return {
407 summary: paths.length === 0 ? "delete nothing: find matches no paths" : `delete ${paths.length}${run.isStdoutTruncated ? "+" : ""} ${paths.length === 1 ? "path" : "paths"}`,
408 lines: paths.slice(0, LIST_MAX).map((l) => l.replace(/^\.\//, "")),
409 more: Math.max(0, paths.length - LIST_MAX),
410 note: "From the same find with its actions swapped for -print. Deleted files can't be recovered.",
411 };
412}
413
414async function measureGit($, risk, cwd) {
415 if (risk.kind === "git-push-force") {
416 return await measurePush($, risk, cwd);
417 }
418 if (risk.kind === "git-clean") {
419 const flags = [];
420 const paths = [];
421 for (let i = 0; i < risk.args.length; i += 1) {
422 const a = risk.args[i];
423 if (a === "--") {
424 paths.push(...risk.args.slice(i + 1));
425 break;
426 }
427 if (a === "-e" || a === "--exclude") {
428 flags.push(a, risk.args[i + 1] ?? "");
429 i += 1;
430 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
431 flags.push(a);
432 } else if (/^-[a-zA-Z]+$/.test(a)) {
433 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
434 if (kept !== "-") {
435 flags.push(kept);
436 }
437 } else if (!a.startsWith("-")) {
438 paths.push(a);
439 }
440 }
441 const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
442 if (run.exitCode !== 0) {
443 return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
444 }
445 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
446 return {
447 summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
448 lines: gone.slice(0, LIST_MAX),
449 more: Math.max(0, gone.length - LIST_MAX),
450 note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
451 };
452 }
453 const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
454 if (status.exitCode !== 0) {
455 return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
456 }
457 const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
458 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
459 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
460 const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
461 return {
462 summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
463 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
464 more: Math.max(0, lost.length - LIST_MAX),
465 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
466 };
467}
468
469async function measurePush($, risk, cwd) {
470 const positional = risk.args.filter((a) => !a.startsWith("-"));
471 const remote = positional[0] ?? "origin";
472 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
473 const spec = (positional[1] ?? "").replace(/^\+/, "");
474 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
475 branch = (branch ?? "").replace(/^refs\/heads\//, "");
476 if (!branch) {
477 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
478 branch = head.stdout.trim();
479 source = "HEAD";
480 } else if (branch === "HEAD") {
481 // `git push origin HEAD` pushes the current branch to its namesake.
482 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
483 branch = head.stdout.trim();
484 source = "HEAD";
485 }
486 source = source || "HEAD";
487 const ref = `${remote}/${branch}`;
488 const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
489 if (known.exitCode !== 0) {
490 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.` };
491 }
492 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
493 const dropped = log.stdout.split("\n").filter((l) => l !== "");
494 return {
495 summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
496 lines: dropped.slice(0, LIST_MAX),
497 more: Math.max(0, dropped.length - LIST_MAX),
498 note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
499 };
500}
501
502const MIGRATION_LISTERS = {
503 django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
504 alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
505 rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
506 prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
507};
508
509async function measureMigrations($, risk, cwd) {
510 const lister = MIGRATION_LISTERS[risk.tool];
511 if (lister === undefined) {
512 return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
513 }
514 let run;
515 try {
516 run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
517 } catch (error) {
518 run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
519 }
520 if (run.exitCode !== 0) {
521 return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
522 }
523 const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
524 return {
525 summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
526 lines: pending.slice(0, LIST_MAX),
527 more: Math.max(0, pending.length - LIST_MAX),
528 note: `From ${lister.argv.join(" ")}.`,
529 };
530}
531
532function size(bytes) {
533 if (!Number.isFinite(bytes) || bytes < 1024) {
534 return `${bytes || 0} B`;
535 }
536 const units = ["KB", "MB", "GB", "TB"];
537 let n = bytes;
538 let i = -1;
539 while (n >= 1024 && i < units.length - 1) {
540 n /= 1024;
541 i += 1;
542 }
543 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
544}
545
546// ---- Drawing --------------------------------------------------------------
547
548function paneRows(report) {
549 return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
550}
551
552function draw(t, state) {
553 const { Box, Text, Button } = t;
554 const { report } = state;
555 const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: ` ${line}`, wrap: "truncate-end" }));
556 if (report.more) {
557 list.push(Text({ key: "more", dimColor: true, children: ` + ${report.more} more` }));
558 }
559 // The buttons answer the call this pane was drawn for, never whichever one is held now.
560 const decide = (choice) => () => {
561 if (state.decision === null) {
562 state.decision = choice;
563 }
564 };
565 return Box({
566 flexDirection: "column",
567 borderStyle: "round",
568 borderColor: "yellow",
569 paddingX: 1,
570 children: [
571 Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
572 Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
573 Text({ key: "sum", children: [Text({ dimColor: true, children: "Would " }), Text({ color: "red", bold: true, children: report.summary })] }),
574 Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
575 report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
576 Box({
577 key: "buttons",
578 marginTop: 1,
579 gap: 2,
580 children: [
581 Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
582 Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
583 Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
584 ],
585 }),
586 ],
587 });
588}
589