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 (or Monitor, whose command is a shell command too) 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. |
find -delete, find -exec rm, xargs rm -rf, and rm of a path from $VAR or $(...) | Held, with a note that it couldn't measure: the paths are only known when the command runs. |
It reads past wrappers (sudo, env, nice, timeout, nohup, xargs), into bash -c "...", sh -c '...' and eval "...", into $(...) and backticks, and across ;, &&, ||, | and a single &. git push -fu counts as a force push.
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. On Windows, Git for Windows instead: the measuring and the hold's wait run in its Git Bash, found by path (CLAUDE_CODE_GIT_BASH_PATH, then %ProgramFiles%\Git\bin\bash.exe, then next to the git on PATH). The bash on PATH, often WSL's, is never used: it can't see Windows paths. If no Git Bash is found, rm isn't measured and a hold can wait only about 10 seconds for your answer before the command is refused. For migrations, the project's own tool (for example python3 manage.py showmigrations).No configuration. On Windows it reads CLAUDE_CODE_GIT_BASH_PATH, the variable Claude Code itself uses for Git Bash.
Steps:
git clone https://github.com/anthropics/claude-code-playground.git
cd claude-code-playground/claude-code/mods
claude plugin validate ./blast-radius
claude --plugin-dir ./blast-radius
Or install it, with the other mods here, from the local marketplace in this folder (see the mods README):
claude plugin marketplace add ./
claude plugin install blast-radius@claude-code-playground-mods --scope user
Remove it with claude plugin uninstall blast-radius@claude-code-playground-mods --scope user.
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.rm, and wrappers it doesn't know (doas rm, time -p rm) aren't caught. It errs toward holding: an &, ; or $( inside quotes is read as if it weren't quoted.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.
Shared as-is as part of claude-code-playground. Not an official Anthropic product; no support or maintenance is implied. See the root README and LICENSE.
hooks/blast-radius.mjs 734 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, Monitor): if the command is risky, work out its blast
7// radius, open a pane with Proceed and Cancel, and hold the call until one is
8// pressed. Anything that goes wrong while holding refuses the call.
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. ($.clock.sleep is only the fallback: its
15// wait counts against the 10 s.) On Windows the sleep and the measuring run in
16// Git Bash, found by path, since neither `sleep` nor a usable `bash` is on PATH.
17//
18// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
19// spelled literally, and helpers that take `$` are top-level functions.
20
21const PANE_ID = "blast-radius";
22const POLL_SECONDS = "0.25";
23const HOLD_LIMIT_MS = 10 * 60 * 1000;
24const LIST_MAX = 10;
25
26// The call being held, or null. One at a time: Bash calls in a turn run in order.
27let held = null;
28
29export function register(on) {
30 // Modified for my-claude-mods: Monitor's `command` is a shell command too.
31 on("tool.call", { tool: ["Bash", "Monitor"] }, async ($, e, next) => {
32 const risk = classify(String(e.command ?? ""));
33 if (risk === null) {
34 return next(e);
35 }
36 const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
37 let opened = { isPlaced: false };
38 let decision;
39 let summary = risk.label;
40 try {
41 // One hold at a time. If another risky call is already held (a subagent's,
42 // say), wait until it is answered. `held` is claimed with no await between
43 // the check and the claim, so two waiting calls can't both get through.
44 // The wait is inside the try, so a wait that fails refuses this call too.
45 while (held !== null) {
46 if (next.signal.aborted) {
47 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." };
48 }
49 await pause($);
50 }
51 held = mine;
52
53 // Measure where the command will run: the session folder, moved by any
54 // `cd dir &&` or `git -C dir` earlier in the same command line.
55 const sessionCwd = await $.session.cwd();
56 const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
57 mine.report = cwd === null
58 ? { summary: `${risk.label} in ${shown(risk.dir)}`, lines: [], note: `Couldn't find the folder ${shown(risk.dir)}, so I couldn't measure what this would change.` }
59 : await measure($, risk, cwd);
60 summary = mine.report.summary;
61
62 opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
63 if (!opened.isPlaced) {
64 mine.where = "band";
65 }
66 $.ui.invalidate("ui.render");
67
68 const startedAt = await $.clock.now();
69 while (mine.decision === null) {
70 if (next.signal.aborted) {
71 mine.decision = "interrupted";
72 break;
73 }
74 if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
75 mine.decision = "timeout";
76 break;
77 }
78 await pause($);
79 }
80 } catch {
81 mine.decision = "error"; // anything unexpected refuses the command
82 } finally {
83 decision = mine.decision;
84 // Close this call's pane before releasing the hold, so the next call's
85 // pane can't be the one that gets closed.
86 try {
87 if (opened.isPlaced) {
88 await $.ui.close({ id: PANE_ID });
89 }
90 } catch {
91 // the pane is already gone
92 }
93 if (held === mine) {
94 held = null;
95 }
96 $.ui.invalidate("ui.render");
97 }
98
99 if (decision === "proceed") {
100 $.ui.toast("Blast Radius: running it");
101 return next(e);
102 }
103 const why = {
104 cancel: "the user pressed Cancel",
105 timeout: "no answer within 10 minutes",
106 interrupted: "the turn was interrupted",
107 error: "Blast Radius hit an error while holding it",
108 }[decision] ?? "no answer was recorded";
109 return {
110 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.`,
111 };
112 }).catch(($, e, next) => {
113 // Modified for my-claude-mods: fail closed. Without this, a hook that throws
114 // or overruns is skipped and the engine runs the command unheld. Asked in
115 // the hook's place (a re-entry), it judges the command text alone.
116 if (next.error.kind === "re-entry" ? classify(String(e.command ?? "")) === null : next.called) {
117 return next(e);
118 }
119 return { deny: "Blast Radius could not hold this command, so it did not run. Do not retry it unless the user asks you to." };
120 });
121
122 on("ui.render", { component: "Pane" }, ($, e, next) => {
123 if (e.requestId !== PANE_ID || held === null || held.report === null) {
124 return next(e);
125 }
126 return draw($.ui.resolve(e), held);
127 });
128
129 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
130 if (held === null || held.report === null || held.where !== "band") {
131 return next(e);
132 }
133 return draw($.ui.resolve(e), held);
134 });
135}
136
137// ---- What counts as risky -------------------------------------------------
138
139// sudo options that take a value, so the value isn't read as the command.
140const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
141// Modified for my-claude-mods: the other wrappers' options that take a value.
142const ENV_VALUE_OPTIONS = new Set(["-u", "--unset", "-S", "--split-string"]); // -C moves the folder
143const NICE_VALUE_OPTIONS = new Set(["-n", "--adjustment"]);
144const TIMEOUT_VALUE_OPTIONS = new Set(["-s", "--signal", "-k", "--kill-after"]);
145const XARGS_VALUE_OPTIONS = new Set(["-I", "-n", "-P", "-L", "-s", "-d", "-E", "-a", "--max-args", "--max-procs", "--max-lines", "--max-chars", "--delimiter", "--eof", "--arg-file"]);
146// Shells whose -c script is a command line of its own.
147const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
148// Commands that only read, so a bare word "migrate" in them isn't a migration.
149const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
150// Separators between commands: && || ; | newline, and a lone & (not &&, &>, >& or <&).
151const SEPARATORS = /&&|\|\||;|\||\n|(?<![<>&])&(?!>)/;
152// Stands in a command line for a $( ), <( ), >( ) or `...` that is read on its own.
153const SUB = "\u0001";
154
155/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
156function joinDir(dir, arg) {
157 if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/") || /^[A-Za-z]:[\\/]/.test(arg)) {
158 return arg ?? "~";
159 }
160 return dir ? `${dir}/${arg}` : arg;
161}
162
163/** Text for the pane, with each lifted substitution shown as $(…). */
164function shown(text) {
165 return String(text).split(SUB).join("$(…)");
166}
167
168/**
169 * The command line with each $( ), <( ), >( ) and `...` replaced by SUB, and
170 * the commands inside them in order. They run before the command around them.
171 */
172function lift(command) {
173 const inner = [];
174 let outer = "";
175 for (let i = 0; i < command.length; i += 1) {
176 const c = command[i];
177 if ((c === "$" || c === "<" || c === ">") && command[i + 1] === "(") {
178 let depth = 1;
179 let j = i + 2;
180 for (; j < command.length && depth > 0; j += 1) {
181 if (command[j] === "(") {
182 depth += 1;
183 } else if (command[j] === ")") {
184 depth -= 1;
185 }
186 }
187 inner.push(command.slice(i + 2, depth === 0 ? j - 1 : j));
188 outer += SUB;
189 i = j - 1;
190 } else if (c === "`") {
191 const end = command.indexOf("`", i + 1);
192 const stop = end === -1 ? command.length : end;
193 inner.push(command.slice(i + 1, stop));
194 outer += SUB;
195 i = stop;
196 } else {
197 outer += c;
198 }
199 }
200 return { outer, inner };
201}
202
203/** The first risky segment of a shell command, or null. `start` is where the line starts (null = the session folder). */
204function classify(command, start = null) {
205 const { outer, inner } = lift(command);
206 let dir = start; // where a `cd` earlier on the line moved to; null means the session folder
207 const scopes = []; // dir to restore when a ( subshell ) closes
208 const pushed = []; // pushd stack, for popd
209 let lifted = 0; // how many of `inner` have been read
210 for (const raw of outer.split(SEPARATORS)) {
211 const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
212 // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
213 const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
214 const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
215 for (let k = 0; k < opens; k += 1) {
216 scopes.push(dir);
217 }
218 // A $( ) or `...` in this segment runs first, in the same folder.
219 for (let n = raw.split(SUB).length - 1; n > 0; n -= 1) {
220 const risk = classify(inner[lifted], dir);
221 lifted += 1;
222 if (risk !== null) {
223 return risk;
224 }
225 }
226 const risk = classifySegment(raw, dir, pushed);
227 if (risk !== null && risk.cd === undefined) {
228 return risk;
229 }
230 if (risk !== null) {
231 dir = risk.cd; // a cd, pushd or popd moved the folder
232 }
233 for (let k = 0; k < closes && scopes.length > 0; k += 1) {
234 dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
235 }
236 }
237 return null;
238}
239
240// Words that can come before the real command without changing what it does.
241const PREFIXES = new Set(["command", "exec", "nohup", "time", "then", "do", "else", "!"]);
242
243/** Drops leading options, and the value after each one in `takesValue`; stops after `--`. */
244function dropOptions(words, takesValue) {
245 while (words.length > 0 && words[0].startsWith("-") && words[0] !== "-") {
246 const option = words.shift();
247 if (option === "--") {
248 break;
249 }
250 if (takesValue.has(option)) {
251 words.shift();
252 }
253 }
254}
255
256/** One segment: a risk, { cd } for a folder change, or null. */
257function classifySegment(segment, dir, pushed) {
258 {
259 const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
260 // Modified for my-claude-mods: wrappers in any order and number (VAR=value,
261 // sudo, env, nice, timeout, nohup, xargs) are stripped down to the command
262 // they run. xargs hands that command paths from its input.
263 let fromInput = false;
264 for (;;) {
265 const word = (words[0] ?? "").replace(/^\\/, "");
266 const base = word.slice(word.lastIndexOf("/") + 1);
267 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || PREFIXES.has(word)) {
268 words.shift(); // leading VAR=value, or a word that changes nothing
269 } else if (base === "sudo") {
270 words.shift();
271 dropOptions(words, SUDO_VALUE_OPTIONS);
272 } else if (base === "env") {
273 words.shift();
274 while (words.length > 0 && words[0].startsWith("-") && words[0] !== "-") {
275 const option = words.shift();
276 if (option === "--") {
277 break;
278 }
279 if (option === "-C" || option === "--chdir") {
280 dir = joinDir(dir, words.shift());
281 } else if (ENV_VALUE_OPTIONS.has(option)) {
282 words.shift();
283 }
284 }
285 } else if (base === "nice") {
286 words.shift();
287 dropOptions(words, NICE_VALUE_OPTIONS);
288 } else if (base === "timeout") {
289 words.shift();
290 dropOptions(words, TIMEOUT_VALUE_OPTIONS);
291 words.shift(); // the duration
292 } else if (base === "xargs") {
293 words.shift();
294 dropOptions(words, XARGS_VALUE_OPTIONS);
295 fromInput = true;
296 } else {
297 break;
298 }
299 }
300 const [first, ...args] = words;
301 if (first === undefined) {
302 return null;
303 }
304 const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
305 const base = cmd.slice(cmd.lastIndexOf("/") + 1);
306 if (SHELLS.has(base)) {
307 // bash -c "script", sh -c '...': read the script as a command line.
308 const at = args.findIndex((a) => /^-[A-Za-z]*c[A-Za-z]*$/.test(a));
309 if (at !== -1 && args[at + 1] !== undefined) {
310 return classify(args[at + 1], dir);
311 }
312 }
313 if (base === "eval") {
314 return classify(args.join(" "), dir);
315 }
316 if (base === "find") {
317 // find -delete and find -exec rm delete what find matches as it runs.
318 const exec = args.findIndex((a) => /^-(exec|execdir|ok|okdir)$/.test(a));
319 const execRm = exec !== -1 && /(^|\/)rm$/.test((args[exec + 1] ?? "").replace(/^\\/, ""));
320 if (args.includes("-delete") || execRm) {
321 return { kind: "find", label: args.includes("-delete") ? "find -delete" : "find -exec rm", dir, unmeasured: "find chooses the files as it runs" };
322 }
323 }
324 if (cmd === "cd") {
325 return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
326 }
327 if (cmd === "pushd") {
328 pushed.push(dir);
329 return { cd: joinDir(dir, args[0]) };
330 }
331 if (cmd === "popd") {
332 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
333 }
334 if (cmd === "rm" || cmd.endsWith("/rm")) {
335 const flags = args.filter((a) => a.startsWith("-"));
336 const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
337 const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
338 if (recursive || force) {
339 const targets = args.filter((a) => !a.startsWith("-") || a === "-");
340 if (fromInput) {
341 return { kind: "rm", label: `xargs rm ${flags.join(" ")}`.trim(), targets: [], dir, unmeasured: "xargs passes rm the paths from its input" };
342 }
343 return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
344 }
345 }
346 if (cmd === "git") {
347 // Git's own options come before the subcommand; -C moves where it runs.
348 let gitDir = dir;
349 let i = 0;
350 while (i < args.length && args[i].startsWith("-")) {
351 if (args[i] === "-C" && i + 1 < args.length) {
352 gitDir = joinDir(gitDir, args[i + 1]);
353 i += 2;
354 } else if (args[i] === "-c" && i + 1 < args.length) {
355 i += 2;
356 } else {
357 i += 1;
358 }
359 }
360 const sub = args[i];
361 const rest = args.slice(i + 1);
362 if (sub === "reset" && rest.includes("--hard")) {
363 return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
364 }
365 if (sub === "clean") {
366 return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
367 }
368 // -f alone or in a bundle (-fu); the letters after an o are -o's value.
369 const forces = (a) => a === "--force" || (/^-[^-]/.test(a) && a.slice(1).split("o")[0].includes("f")) || a.startsWith("--force-with-lease") || /^\+/.test(a);
370 if (sub === "push" && rest.some(forces)) {
371 return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
372 }
373 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
374 if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
375 return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
376 }
377 }
378 const joined = words.join(" ");
379 if (/\balembic\s+upgrade\b/.test(joined)) {
380 return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
381 }
382 if (/\bdb:migrate(?!:status\b)/.test(joined)) {
383 return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
384 }
385 if (/\bprisma\s+migrate\b/.test(joined)) {
386 return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
387 }
388 if (/\bmanage\.py\s+migrate\b/.test(joined)) {
389 return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
390 }
391 if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
392 return { kind: "migrate", tool: "unknown", label: "migrate", dir };
393 }
394 }
395 return null;
396}
397
398// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
399// The target is passed as an argument, never as source. Git Bash's cygpath
400// turns /c/... into C:/..., a folder Windows can start a process in.
401const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && { cygpath -m "$(pwd -P)" 2>/dev/null || pwd -P; }`;
402
403async function resolveDir($, sessionCwd, dir) {
404 if (dir === "-") {
405 return null; // `cd -` depends on the shell's history
406 }
407 const bash = await findBash($);
408 if (bash === null) {
409 return null;
410 }
411 const run = await $.process.run([bash, "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
412 const out = run.stdout.trim();
413 return run.exitCode === 0 && out !== "" ? out : null;
414}
415
416// Modified for my-claude-mods: on Windows the first bash on PATH is usually
417// WSL's (C:\Windows\System32\bash.exe), where C:/ and /c/ paths don't exist, so
418// it measured "nothing to delete". Measure in Git Bash, found by path. `bash`
419// elsewhere; null on Windows with no Git Bash found.
420let bashPath;
421
422async function findBash($) {
423 if (bashPath === undefined) {
424 bashPath = await lookUpBash($);
425 }
426 return bashPath;
427}
428
429async function lookUpBash($) {
430 if ((await $.env.get("OS")) !== "Windows_NT") {
431 return "bash";
432 }
433 const set = await $.env.get("CLAUDE_CODE_GIT_BASH_PATH");
434 if (set && (await $.fs.exists(set))) {
435 return set;
436 }
437 const programFiles = await $.env.get("ProgramFiles");
438 if (programFiles && (await $.fs.exists(`${programFiles}\\Git\\bin\\bash.exe`))) {
439 return `${programFiles}\\Git\\bin\\bash.exe`;
440 }
441 // Elsewhere, from the git on PATH: its exec path is <Git>/mingw64/libexec/git-core.
442 const git = await $.process.run(["git", "--exec-path"], { timeoutMs: 5000 }).catch(() => null);
443 const root = git?.exitCode === 0 ? git.stdout.trim().replace(/[\\/][^\\/]+[\\/]libexec[\\/]git-core$/i, "") : "";
444 return root && (await $.fs.exists(`${root}/bin/bash.exe`)) ? `${root}/bin/bash.exe` : null;
445}
446
447/** One poll of a hold: a `$` call, so the wait doesn't spend the hook's own 10 s. */
448async function pause($) {
449 const bash = await findBash($);
450 if (bash === null) {
451 // Windows with no Git Bash: a clock wait does spend the 10 s, so an
452 // unanswered hold ends (refused) after about that, rather than at once.
453 await $.clock.sleep(Number(POLL_SECONDS) * 1000);
454 return;
455 }
456 await $.process.run(bash === "bash" ? ["sleep", POLL_SECONDS] : [bash, "-c", `sleep ${POLL_SECONDS}`], { timeoutMs: 5000 });
457}
458
459/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
460function tokenize(text) {
461 const words = [];
462 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
463 let m;
464 while ((m = re.exec(text)) !== null) {
465 words.push(m[1] ?? m[2] ?? m[3]);
466 }
467 return words;
468}
469
470// ---- Measuring the blast radius -------------------------------------------
471
472/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
473async function measure($, risk, cwd) {
474 try {
475 if (risk.unmeasured) {
476 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${risk.unmeasured}.` };
477 }
478 if (risk.kind === "rm") {
479 return await measureRm($, risk, cwd);
480 }
481 if (risk.kind === "migrate") {
482 return await measureMigrations($, risk, cwd);
483 }
484 return await measureGit($, risk, cwd);
485 } catch (error) {
486 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
487 }
488}
489
490// The paths are passed to bash as arguments, never as source, so nothing in
491// them runs. compgen -G expands a glob without command substitution.
492const RM_SCRIPT = `
493shopt -s nullglob dotglob
494paths=()
495for p in "$@"; do
496 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
497 if [[ "$p" == *[*?[]* ]]; then
498 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
499 elif [[ -e "$p" || -L "$p" ]]; then
500 paths+=("$p")
501 fi
502done
503if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
504# A relative path gets ./ in front, so find never reads a name like -delete as an action.
505for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*|[A-Za-z]:*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
506files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
507kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
508echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
509find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
510`;
511
512async function measureRm($, risk, cwd) {
513 if (risk.targets.length === 0) {
514 return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
515 }
516 // Modified for my-claude-mods: a path from $VAR, $( ) or `...` is known only
517 // when the shell runs, so it isn't measured as the literal text.
518 if (risk.targets.some((t) => t.includes("$") || t.includes(SUB))) {
519 return { summary: `${risk.label} (could not measure it)`, lines: [], note: "Could not measure: a path comes from a variable or a command's output, which only the shell knows." };
520 }
521 const bash = await findBash($);
522 if (bash === null) {
523 return { summary: `${risk.label} (could not measure it)`, lines: [], note: "Could not measure: Git Bash was not found. Set CLAUDE_CODE_GIT_BASH_PATH to its bash.exe." };
524 }
525 const run = await $.process.run([bash, "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
526 const [head, ...rest] = run.stdout.split(/\r?\n/).filter((l) => l !== "");
527 // Modified for my-claude-mods: only a clean "files bytes paths" line is a
528 // measurement. A bash that failed, or printed an error, must not read as
529 // "delete nothing".
530 if (run.exitCode !== 0 || !/^\d+ \d+ \d+$/.test(head ?? "")) {
531 const why = run.stderr.trim() || head || `bash exited with ${run.exitCode}`;
532 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${why.slice(0, 200)}` };
533 }
534 const [files, bytes, found] = head.split(" ").map(Number);
535 if (!found) {
536 return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
537 }
538 if (!files) {
539 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
540 }
541 return {
542 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
543 lines: rest.map((l) => l.replace(/^\.\//, "")),
544 more: Math.max(0, files - rest.length),
545 note: `Paths: ${risk.targets.join(" ")}`,
546 };
547}
548
549async function measureGit($, risk, cwd) {
550 if (risk.kind === "git-push-force") {
551 return await measurePush($, risk, cwd);
552 }
553 if (risk.kind === "git-clean") {
554 const flags = [];
555 const paths = [];
556 for (let i = 0; i < risk.args.length; i += 1) {
557 const a = risk.args[i];
558 if (a === "--") {
559 paths.push(...risk.args.slice(i + 1));
560 break;
561 }
562 if (a === "-e" || a === "--exclude") {
563 flags.push(a, risk.args[i + 1] ?? "");
564 i += 1;
565 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
566 flags.push(a);
567 } else if (/^-[a-zA-Z]+$/.test(a)) {
568 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
569 if (kept !== "-") {
570 flags.push(kept);
571 }
572 } else if (!a.startsWith("-")) {
573 paths.push(a);
574 }
575 }
576 const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
577 if (run.exitCode !== 0) {
578 return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
579 }
580 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
581 return {
582 summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
583 lines: gone.slice(0, LIST_MAX),
584 more: Math.max(0, gone.length - LIST_MAX),
585 note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
586 };
587 }
588 const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
589 if (status.exitCode !== 0) {
590 return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
591 }
592 const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
593 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
594 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
595 const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
596 return {
597 summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
598 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
599 more: Math.max(0, lost.length - LIST_MAX),
600 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
601 };
602}
603
604async function measurePush($, risk, cwd) {
605 const positional = risk.args.filter((a) => !a.startsWith("-"));
606 const remote = positional[0] ?? "origin";
607 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
608 const spec = (positional[1] ?? "").replace(/^\+/, "");
609 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
610 branch = (branch ?? "").replace(/^refs\/heads\//, "");
611 if (!branch) {
612 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
613 branch = head.stdout.trim();
614 source = "HEAD";
615 } else if (branch === "HEAD") {
616 // `git push origin HEAD` pushes the current branch to its namesake.
617 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
618 branch = head.stdout.trim();
619 source = "HEAD";
620 }
621 source = source || "HEAD";
622 const ref = `${remote}/${branch}`;
623 const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
624 if (known.exitCode !== 0) {
625 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.` };
626 }
627 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
628 const dropped = log.stdout.split("\n").filter((l) => l !== "");
629 return {
630 summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
631 lines: dropped.slice(0, LIST_MAX),
632 more: Math.max(0, dropped.length - LIST_MAX),
633 note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
634 };
635}
636
637const MIGRATION_LISTERS = {
638 django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
639 alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
640 rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
641 prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
642};
643
644// Modified for my-claude-mods: the listers above run the project's own code
645// (manage.py, bin/rails, alembic's env.py, node_modules' prisma), and this hook
646// runs before the permission check, so a hold could execute repository code the
647// person or auto mode would then refuse. The hold now names the command without
648// listing; ALLOW_PROJECT_LISTERS stays false.
649const ALLOW_PROJECT_LISTERS = false;
650
651async function measureMigrations($, risk, cwd) {
652 if (!ALLOW_PROJECT_LISTERS) {
653 return { summary: `run ${risk.label}`, lines: [], note: "Pending migrations are not listed: listing them would run the project's own code before you decide." };
654 }
655 const lister = MIGRATION_LISTERS[risk.tool];
656 if (lister === undefined) {
657 return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
658 }
659 let run;
660 try {
661 run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
662 } catch (error) {
663 run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
664 }
665 if (run.exitCode !== 0) {
666 return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
667 }
668 const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
669 return {
670 summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
671 lines: pending.slice(0, LIST_MAX),
672 more: Math.max(0, pending.length - LIST_MAX),
673 note: `From ${lister.argv.join(" ")}.`,
674 };
675}
676
677function size(bytes) {
678 if (!Number.isFinite(bytes) || bytes < 1024) {
679 return `${bytes || 0} B`;
680 }
681 const units = ["KB", "MB", "GB", "TB"];
682 let n = bytes;
683 let i = -1;
684 while (n >= 1024 && i < units.length - 1) {
685 n /= 1024;
686 i += 1;
687 }
688 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
689}
690
691// ---- Drawing --------------------------------------------------------------
692
693function paneRows(report) {
694 return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
695}
696
697function draw(t, state) {
698 const { Box, Text, Button } = t;
699 const { report } = state;
700 const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: ` ${line}`, wrap: "truncate-end" }));
701 if (report.more) {
702 list.push(Text({ key: "more", dimColor: true, children: ` + ${report.more} more` }));
703 }
704 // The buttons answer the call this pane was drawn for, never whichever one is held now.
705 const decide = (choice) => () => {
706 if (state.decision === null) {
707 state.decision = choice;
708 }
709 };
710 return Box({
711 flexDirection: "column",
712 borderStyle: "round",
713 borderColor: "yellow",
714 paddingX: 1,
715 children: [
716 Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
717 Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
718 Text({ key: "sum", children: [Text({ dimColor: true, children: "Would " }), Text({ color: "red", bold: true, children: report.summary })] }),
719 Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
720 report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
721 Box({
722 key: "buttons",
723 marginTop: 1,
724 gap: 2,
725 children: [
726 Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
727 Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
728 Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
729 ],
730 }),
731 ],
732 });
733}
734