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:
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.$(...), 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).
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 675 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";
20// Local change (zehui-mods): 2 minutes, not 10. A hold nobody answers should
21// not stall a pane for long; the start of a hold is also announced with a
22// native notification, so a hold in an unwatched pane surfaces.
23const HOLD_LIMIT_MS = 2 * 60 * 1000;
24const LIST_MAX = 10;
25// The call being held, or null. One at a time: Bash calls in a turn run in order.
26let held = null;
27
28export function register(on) {
29 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
30 // Local change (zehui-mods): BLAST_RADIUS_OFF=1 turns the hold off for a
31 // session. herdr spokes run in auto mode already; an unattended hold there
32 // only stalls. The name is spelled literally so the validator can list it.
33 if ((await $.env.get("BLAST_RADIUS_OFF")) === "1") {
34 return next(e);
35 }
36 const text = String(e.command ?? "");
37 // Local change (zehui-mods): every risky segment is collected, not only
38 // the first, so a line like `rm -rf a; rm -rf b` is measured whole. Two
39 // extra kinds are held without measuring: a command that names the
40 // opt-out variable (it could switch the guard off for a nested session),
41 // and one too long to classify safely inside the hook budget.
42 const risks = text.includes("BLAST_RADIUS_OFF")
43 ? [{ kind: "guard-off", label: "mentions BLAST_RADIUS_OFF", dir: null }]
44 : text.length > MAX_CLASSIFY_LENGTH
45 ? [{ kind: "too-long", label: `command of ${text.length} characters, not classified`, dir: null }]
46 : classifyAll(text);
47 if (risks.length === 0) {
48 return next(e);
49 }
50 const risk = risks[0];
51 // One hold at a time. If another risky call is already held (a subagent's,
52 // say), wait until it is answered. `held` is claimed with no await between
53 // the check and the claim, so two waiting calls can't both get through.
54 while (held !== null) {
55 if (next.signal.aborted) {
56 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." };
57 }
58 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
59 }
60 // The command is shown with control characters blanked, so it cannot fake
61 // a report line or send escape sequences to the notification channel.
62 const mine = { command: plain(text), risk, risks, report: null, decision: null, where: "pane" };
63 held = mine;
64
65 let opened = { isPlaced: false };
66 let decision;
67 let summary = risks.map((r) => r.label).join("; ");
68 try {
69 // Measure where each risky segment will run: the session folder, moved
70 // by any `cd dir &&` or `git -C dir` earlier in the same command line.
71 const sessionCwd = await $.session.cwd();
72 const reports = [];
73 for (const r of risks) {
74 const cwd = r.dir ? await resolveDir($, sessionCwd, r.dir) : sessionCwd;
75 reports.push(cwd === null
76 ? { summary: `${r.label} in ${r.dir}`, lines: [], note: `Couldn't find the folder ${r.dir}, so I couldn't measure what this would change.` }
77 : await measure($, r, cwd));
78 }
79 mine.report = mergeReports(reports);
80 summary = mine.report.summary;
81
82 // Local change (zehui-mods): opened focused so the keys reach it at once
83 // (mouse clicks do not pass through a herdr pane). Cancel holds the focus
84 // and Proceed has no hotkey, so Enter, 2 or any stray digit cancels; only
85 // Tab then Enter, or a click, proceeds.
86 opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
87 $.ui.invalidate("ui.render");
88 try {
89 await $.ui.notify(`Would ${summary}\nCommand (as typed): ${mine.command.slice(0, 120)}`, { title: "Blast Radius: holding a command" });
90 } catch {
91 // no notification channel; the pane or dialog still shows the hold
92 }
93
94 if (opened.isPlaced) {
95 const startedAt = await $.clock.now();
96 while (mine.decision === null) {
97 if (next.signal.aborted) {
98 mine.decision = "interrupted";
99 break;
100 }
101 if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
102 mine.decision = "timeout";
103 break;
104 }
105 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
106 }
107 } else {
108 // Local change (zehui-mods): where the pane is not placed (a narrow
109 // herdr split), ask through the engine's own dialog instead of the
110 // band above the prompt. Upstream drew the buttons in that band, which
111 // another band mod or a survey card can occupy, leaving the hold with
112 // no visible buttons until it times out. The dialog always reaches the
113 // person; dismissing it, or Esc, refuses the command.
114 mine.where = "ask";
115 const preview = mine.report.lines.slice(0, 6).map((l) => ` ${l}`).join("\n");
116 const more = mine.report.more ? `\n + ${mine.report.more} more` : "";
117 const question = `Blast Radius: this command would ${summary}.\nCommand (as typed): ${mine.command.slice(0, 300)}${preview ? `\n${preview}${more}` : ""}\nRun it?`;
118 try {
119 const answer = await $.ui.ask(question, { options: ["Cancel", "Proceed"], header: "Blast Radius" });
120 mine.decision = answer === "Proceed" ? "proceed" : "cancel";
121 } catch {
122 mine.decision = next.signal.aborted ? "interrupted" : "cancel";
123 }
124 }
125 } catch {
126 mine.decision = "error"; // anything unexpected refuses the command
127 } finally {
128 decision = mine.decision;
129 // Close this call's pane before releasing the hold, so the next call's
130 // pane can't be the one that gets closed.
131 try {
132 if (opened.isPlaced) {
133 await $.ui.close({ id: PANE_ID });
134 }
135 } catch {
136 // the pane is already gone
137 }
138 if (held === mine) {
139 held = null;
140 }
141 $.ui.invalidate("ui.render");
142 }
143
144 if (decision === "proceed") {
145 $.ui.toast("Blast Radius: running it");
146 return next(e);
147 }
148 const why = {
149 cancel: "the user chose Cancel (or dismissed the dialog)",
150 timeout: "no answer within 2 minutes",
151 interrupted: "the turn was interrupted",
152 error: "Blast Radius hit an error while holding it",
153 }[decision] ?? "no answer was recorded";
154 return {
155 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.`,
156 };
157 }).catch(($, e, next) => {
158 // Local change (zehui-mods): fail closed. Without this handler a guard
159 // that throws, overruns its budget or answers a wrong shape is skipped
160 // and the command runs unguarded.
161 if (next.called) {
162 return next(e);
163 }
164 return { deny: "Blast Radius failed while checking this command and refused it. Do not retry it unless the user asks you to." };
165 });
166
167 on("ui.render", { component: "Pane" }, ($, e, next) => {
168 if (e.requestId !== PANE_ID || held === null || held.report === null) {
169 return next(e);
170 }
171 return draw($.ui.resolve(e), held);
172 });
173
174 // Local change (zehui-mods): no AbovePrompt hook. The narrow-terminal
175 // fallback is the engine's ask dialog (see the hold above), so this mod
176 // never competes for the band with token-weather or a survey card.
177}
178
179// ---- What counts as risky -------------------------------------------------
180
181// sudo options that take a value, so the value isn't read as the command.
182const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
183// Commands that only read, so a bare word "migrate" in them isn't a migration.
184const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
185// Local change (zehui-mods): the subset that runs nothing it is given (no
186// find, whose -exec runs commands), and the git subcommands that take text.
187const PURE_READ = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "less", "head", "tail"]);
188const GIT_TEXT_SUBS = new Set(["commit", "log", "show", "grep", "tag", "diff", "status", "blame"]);
189
190/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
191function joinDir(dir, arg) {
192 if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
193 return arg ?? "~";
194 }
195 return dir ? `${dir}/${arg}` : arg;
196}
197
198/** The first risky segment of a shell command, or null. Kept for the tests. */
199export function classify(command) {
200 return classifyAll(command)[0] ?? null;
201}
202
203/** Every risky segment of a shell command, in order. Exported for the tests. */
204export function classifyAll(command) {
205 const risks = [];
206 let dir = null; // 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 for (const raw of command.split(/&&|\|\||;|\||\n/)) {
210 const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
211 // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
212 // Local change (zehui-mods): stripped in a loop of anchored single matches
213 // instead of one nested-quantifier regex, which could backtrack
214 // catastrophically on a long adversarial segment and fail the guard open.
215 const tail = stripTrailingRedirects(raw);
216 const closes = (tail.match(/\)+$/)?.[0].length) ?? 0;
217 for (let k = 0; k < opens; k += 1) {
218 scopes.push(dir);
219 }
220 const risk = classifySegment(raw, dir, pushed);
221 if (risk !== null && risk.cd === undefined) {
222 risks.push(risk);
223 } else if (risk !== null) {
224 dir = risk.cd; // a cd, pushd or popd moved the folder
225 }
226 for (let k = 0; k < closes && scopes.length > 0; k += 1) {
227 dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
228 }
229 }
230 return risks;
231}
232
233// Only the tail of a segment can hold its trailing redirects; bounding the
234// work to it keeps the stripping linear whatever the segment's length.
235const REDIRECT_TAIL = 1024;
236
237/** `raw` without its trailing redirects (`> log`, `2>>err`, `2>&1`, `< in`) and a trailing `&`. */
238export function stripTrailingRedirects(raw) {
239 const trimmed = raw.trimEnd();
240 const head = trimmed.slice(0, Math.max(0, trimmed.length - REDIRECT_TAIL));
241 let s = trimmed.slice(head.length);
242 for (;;) {
243 const before = s;
244 s = s.replace(/\s*&$/, "");
245 s = s.replace(/\s*(?:\d*>>?|&>>?|\d*<)\s*(?:&\d+|&-|[^\s<>&]+)$/, "");
246 s = s.trimEnd();
247 if (s === before) {
248 return head + s;
249 }
250 }
251}
252
253const MAX_CLASSIFY_LENGTH = 16000;
254
255/** Text with control characters blanked, for the pane and the notification. */
256function plain(text) {
257 return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, " ").replace(/\t/g, " ");
258}
259
260/** One report for the pane from the reports of every risky segment. */
261function mergeReports(reports) {
262 if (reports.length === 1) {
263 return reports[0];
264 }
265 const lines = [];
266 let more = 0;
267 for (const [i, r] of reports.entries()) {
268 const own = r.lines.map((l) => `[${i + 1}] ${l}`);
269 const room = Math.max(0, LIST_MAX - lines.length);
270 lines.push(...own.slice(0, room));
271 more += Math.max(0, own.length - room) + (r.more ?? 0);
272 }
273 return {
274 summary: `${reports.length} risky commands: ${reports.map((r, i) => `[${i + 1}] ${r.summary}`).join("; ")}`,
275 lines,
276 more,
277 note: reports.map((r, i) => (r.note ? `[${i + 1}] ${r.note}` : "")).filter((n) => n !== "").join(" "),
278 };
279}
280
281// Words that can come before the real command without changing what it does.
282const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
283
284/** One segment: a risk, { cd } for a folder change, or null. */
285function classifySegment(segment, dir, pushed) {
286 {
287 // Local change (zehui-mods): trailing `)` stripped after trimEnd, not with
288 // `\s*[)}]+$`, which backtracks quadratically over a long run of spaces.
289 const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").trimEnd().replace(/[)}]+$/, "").trimEnd());
290 while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
291 words.shift(); // leading VAR=value
292 }
293 if (words[0] === "sudo") {
294 words.shift();
295 while (words.length > 0 && words[0].startsWith("-")) {
296 const option = words.shift();
297 if (SUDO_VALUE_OPTIONS.has(option)) {
298 words.shift();
299 }
300 }
301 }
302 while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
303 words.shift();
304 }
305 if (words[0] === "nice") {
306 words.shift();
307 if (words[0] === "-n") {
308 words.splice(0, 2);
309 } else if (/^-\d+$/.test(words[0] ?? "")) {
310 words.shift();
311 }
312 }
313 const [first, ...args] = words;
314 if (first === undefined) {
315 return null;
316 }
317 const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
318 if (cmd === "cd") {
319 return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
320 }
321 if (cmd === "pushd") {
322 pushed.push(dir);
323 return { cd: joinDir(dir, args[0]) };
324 }
325 if (cmd === "popd") {
326 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
327 }
328 if (cmd === "rm" || cmd.endsWith("/rm")) {
329 const flags = args.filter((a) => a.startsWith("-"));
330 const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
331 const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
332 if (recursive || force) {
333 const targets = args.filter((a) => !a.startsWith("-") || a === "-");
334 return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
335 }
336 }
337 if (cmd === "git") {
338 // Git's own options come before the subcommand; -C moves where it runs.
339 let gitDir = dir;
340 let i = 0;
341 while (i < args.length && args[i].startsWith("-")) {
342 if (args[i] === "-C" && i + 1 < args.length) {
343 gitDir = joinDir(gitDir, args[i + 1]);
344 i += 2;
345 } else if (args[i] === "-c" && i + 1 < args.length) {
346 i += 2;
347 } else {
348 i += 1;
349 }
350 }
351 const sub = args[i];
352 const rest = args.slice(i + 1);
353 if (sub === "reset" && rest.includes("--hard")) {
354 return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
355 }
356 if (sub === "clean") {
357 return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
358 }
359 if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
360 return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
361 }
362 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
363 if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
364 return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
365 }
366 }
367 // Local change (zehui-mods): a command that only reads and merely mentions
368 // a migration tool (grep, cat, echo ...), or a git command that carries
369 // text (a commit message, a log search), is not a migration. A segment
370 // with a command substitution or process substitution still is, since
371 // that inner command runs. Upstream only consulted READ_ONLY for the bare
372 // word "migrate". `find` and other git subcommands are not exempt: -exec,
373 // `submodule foreach` and `rebase -x` run what they are given.
374 const runsInner = /\$\(|`|<\(/.test(segment);
375 if (!runsInner && (PURE_READ.has(cmd) || (cmd === "git" && GIT_TEXT_SUBS.has(args[0])))) {
376 return null;
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.
400const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
401
402async function resolveDir($, sessionCwd, dir) {
403 if (dir === "-") {
404 return null; // `cd -` depends on the shell's history
405 }
406 const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
407 const out = run.stdout.trim();
408 return run.exitCode === 0 && out !== "" ? out : null;
409}
410
411/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
412function tokenize(text) {
413 const words = [];
414 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
415 let m;
416 while ((m = re.exec(text)) !== null) {
417 words.push(m[1] ?? m[2] ?? m[3]);
418 }
419 return words;
420}
421
422// ---- Measuring the blast radius -------------------------------------------
423
424/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
425async function measure($, risk, cwd) {
426 try {
427 if (risk.kind === "guard-off") {
428 return { summary: "run a command that names BLAST_RADIUS_OFF", lines: [], note: "That variable switches this guard off for a session it is exported into, so the command is shown to you first." };
429 }
430 if (risk.kind === "too-long") {
431 return { summary: risk.label, lines: [], note: `Commands over ${MAX_CLASSIFY_LENGTH} characters are not parsed inside the hook; read it before you proceed.` };
432 }
433 if (risk.kind === "rm") {
434 return await measureRm($, risk, cwd);
435 }
436 if (risk.kind === "migrate") {
437 return await measureMigrations($, risk, cwd);
438 }
439 return await measureGit($, risk, cwd);
440 } catch (error) {
441 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
442 }
443}
444
445// The paths are passed to bash as arguments, never as source, so nothing in
446// them runs. compgen -G expands a glob without command substitution.
447const RM_SCRIPT = `
448shopt -s nullglob dotglob
449paths=()
450for p in "$@"; do
451 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
452 if [[ "$p" == *[*?[]* ]]; then
453 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
454 elif [[ -e "$p" || -L "$p" ]]; then
455 paths+=("$p")
456 fi
457done
458if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
459# A relative path gets ./ in front, so find never reads a name like -delete as an action.
460for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
461files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
462kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
463echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
464find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
465`;
466
467async function measureRm($, risk, cwd) {
468 if (risk.targets.length === 0) {
469 return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
470 }
471 const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
472 const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
473 const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
474 if (!found) {
475 return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
476 }
477 if (!files) {
478 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
479 }
480 return {
481 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
482 lines: rest.map((l) => l.replace(/^\.\//, "")),
483 more: Math.max(0, files - rest.length),
484 note: `Paths: ${risk.targets.join(" ")}`,
485 };
486}
487
488async function measureGit($, risk, cwd) {
489 if (risk.kind === "git-push-force") {
490 return await measurePush($, risk, cwd);
491 }
492 if (risk.kind === "git-clean") {
493 const flags = [];
494 const paths = [];
495 for (let i = 0; i < risk.args.length; i += 1) {
496 const a = risk.args[i];
497 if (a === "--") {
498 paths.push(...risk.args.slice(i + 1));
499 break;
500 }
501 if (a === "-e" || a === "--exclude") {
502 flags.push(a, risk.args[i + 1] ?? "");
503 i += 1;
504 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
505 flags.push(a);
506 } else if (/^-[a-zA-Z]+$/.test(a)) {
507 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
508 if (kept !== "-") {
509 flags.push(kept);
510 }
511 } else if (!a.startsWith("-")) {
512 paths.push(a);
513 }
514 }
515 const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
516 if (run.exitCode !== 0) {
517 return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
518 }
519 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
520 return {
521 summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
522 lines: gone.slice(0, LIST_MAX),
523 more: Math.max(0, gone.length - LIST_MAX),
524 note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
525 };
526 }
527 const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
528 if (status.exitCode !== 0) {
529 return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
530 }
531 const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
532 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
533 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
534 const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
535 return {
536 summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
537 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
538 more: Math.max(0, lost.length - LIST_MAX),
539 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
540 };
541}
542
543async function measurePush($, risk, cwd) {
544 const positional = risk.args.filter((a) => !a.startsWith("-"));
545 const remote = positional[0] ?? "origin";
546 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
547 const spec = (positional[1] ?? "").replace(/^\+/, "");
548 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
549 branch = (branch ?? "").replace(/^refs\/heads\//, "");
550 if (!branch) {
551 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
552 branch = head.stdout.trim();
553 source = "HEAD";
554 } else if (branch === "HEAD") {
555 // `git push origin HEAD` pushes the current branch to its namesake.
556 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
557 branch = head.stdout.trim();
558 source = "HEAD";
559 }
560 source = source || "HEAD";
561 const ref = `${remote}/${branch}`;
562 const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
563 if (known.exitCode !== 0) {
564 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.` };
565 }
566 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
567 const dropped = log.stdout.split("\n").filter((l) => l !== "");
568 return {
569 summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
570 lines: dropped.slice(0, LIST_MAX),
571 more: Math.max(0, dropped.length - LIST_MAX),
572 note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
573 };
574}
575
576const MIGRATION_LISTERS = {
577 django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
578 alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
579 rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
580 prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
581};
582
583async function measureMigrations($, risk, cwd) {
584 const lister = MIGRATION_LISTERS[risk.tool];
585 if (lister === undefined) {
586 return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
587 }
588 let run;
589 try {
590 run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
591 } catch (error) {
592 run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
593 }
594 if (run.exitCode !== 0) {
595 return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
596 }
597 const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
598 return {
599 summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
600 lines: pending.slice(0, LIST_MAX),
601 more: Math.max(0, pending.length - LIST_MAX),
602 note: `From ${lister.argv.join(" ")}.`,
603 };
604}
605
606function size(bytes) {
607 if (!Number.isFinite(bytes) || bytes < 1024) {
608 return `${bytes || 0} B`;
609 }
610 const units = ["KB", "MB", "GB", "TB"];
611 let n = bytes;
612 let i = -1;
613 while (n >= 1024 && i < units.length - 1) {
614 n /= 1024;
615 i += 1;
616 }
617 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
618}
619
620// ---- Drawing --------------------------------------------------------------
621
622function paneRows(report) {
623 return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
624}
625
626function draw(t, state) {
627 const { Box, Text, Button } = t;
628 const { report } = state;
629 const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: ` ${line}`, wrap: "truncate-end" }));
630 if (report.more) {
631 list.push(Text({ key: "more", dimColor: true, children: ` + ${report.more} more` }));
632 }
633 // The buttons answer the call this pane was drawn for, never whichever one is held now.
634 const decide = (choice) => () => {
635 if (state.decision === null) {
636 state.decision = choice;
637 }
638 };
639 return Box({
640 flexDirection: "column",
641 borderStyle: "round",
642 borderColor: "yellow",
643 paddingX: 1,
644 children: [
645 Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
646 // Local change (zehui-mods): the whole command, wrapped, so a long line
647 // cannot hide a later destructive segment behind an ellipsis.
648 Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command " }), Text({ bold: true, children: state.command.slice(0, 2000) })], wrap: "wrap" }),
649 Text({ key: "sum", children: [Text({ dimColor: true, children: "Would " }), Text({ color: "red", bold: true, children: report.summary })] }),
650 // Local change (zehui-mods): framed buttons (not `plain`), placed right
651 // under the summary so they are inside the pane's visible window even
652 // when the file list is long or the pane is short. No hotkey on Proceed:
653 // a bare digit typed into an empty prompt answers a band Button, so one
654 // stray keystroke must never approve a delete.
655 Box({
656 key: "buttons",
657 marginTop: 1,
658 gap: 2,
659 children: [
660 Button({ key: "cancel", label: "Cancel", hotkey: "2", autoFocus: true, onPress: decide("cancel") }),
661 Button({ key: "proceed", label: "Proceed", onPress: decide("proceed") }),
662 ],
663 }),
664 Text({
665 key: "hint",
666 dimColor: true,
667 wrap: "wrap",
668 children: "Enter or 2 cancels. Tab then Enter proceeds. (If the keys went back to the prompt: ctrl+x tab refocuses this pane.) Refused after 2 min.",
669 }),
670 Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
671 report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
672 ],
673 });
674}
675