SLOPSHOPPER

blast-radius

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.

newpaneguardtoastprocess
v0.1.0no licenseupdated 2026-10-20ZH-L1N/agent-window-mods/blast-radius
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ Blast Radius ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ⚠ Blast Radius · rm -rf │ ⏺ Read(src/auth.ts) │ ┃ │ Command rm -rf build && git push │ ⎿ Read 6 lines │ ┃ │ --force origin main │ ⏺ Update(src/auth.ts) │ ┃ │ Would 2 risky commands: [1] delete │ ⎿ Added 2 lines, removed 1 line │ ┃ │ nothing: no file matches build; [2] │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ force-push to origin/main: drops 2 │ ⎿ Denied by blast-radius: Blast Radius held this command an │ ┃ │ commits │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ [ Cancel ] [ Proceed ] │ │ ┃ │ Enter or 2 cancels. Tab then Enter │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ proceeds. (If the keys went back to the │ │ ┃ │ prompt: ctrl+x tab refocuses this pane.) │ │ ┃ │ Refused after 2 min. │ │ ┃ │ │ │ ┃ │ [2] a1b2c3d fix: refresh expired toke… │ │ ┃ │ [2] 9f8e7d6 feat: add audit log (3 ho… │ │ ┃ │ [1] The paths don't exist, so rm has │ │ ┃ │ nothing to remove. [2] Commits on │ │ ┃ │ origin/main that main doesn't have, as │ │ ┃ │ of the last fetch. │ │ ┃ ╰──────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ Blast Radius · rm -rf │ │ Command rm -rf build && git push --force origin main │ │ Would 2 risky commands: [1] delete nothing: no file │ │ matches build; [2] force-push to origin/main: drops 2 │ │ commits │ │ │ │ [ Cancel ] [ Proceed ] │ │ 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. │ │ │ │ [2] a1b2c3d fix: refresh expired tokens (2 minutes ag… │ │ [2] 9f8e7d6 feat: add audit log (3 hours ago) │ │ [1] The paths don't exist, so rm has nothing to remove. │ │ [2] Commits on origin/main that main doesn't have, as of │ │ the last fetch. │ ╰──────────────────────────────────────────────────────────╯
README

Blast Radius

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.

What this shows

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.

CommandWhat the pane shows
rm -r, rm -f, rm -rfThe files it would delete, with the count and total size. Globs and ~ are expanded.
git reset --hardThe files with uncommitted changes, from git status --porcelain, and git diff --shortstat.
git checkout -- ., git restore .The files with unstaged changes.
git cleanThe 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 migrateThe pending migrations, from the tool's own status command.
any other migrateA 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:

  • Holding a tool.call until the user answers, and returning { deny } with a reason Claude can act on.
  • Drawing the same report in a Pane, or in the AbovePrompt band when the terminal is too narrow for a pane.
  • Measuring with $.process.run, passing paths as arguments so nothing in them runs as shell.

Demo

rm -rf build held, with the files it would delete:

Blast Radius holding rm -rf build in a pane

After Cancel, Claude reports that nothing was deleted:

Claude reports the command was cancelled

The second try, after Proceed:

Claude reports the folder was deleted after Proceed

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

Blast Radius showing git reset --hard in the band above the prompt

How it was built

  • Model: built with Claude in Claude Code. The test runs and screenshots used Claude Sonnet 5. The mod itself doesn't call a model.
  • Prompt(s): the mod started as one of ten ideas Claude wrote for mods. This is the idea as written:

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 uses tool.call to hold the call, and ui.render on Pane, with Button elements. 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.

  • Transcript: not shared. The build ran in an internal workspace.
  • Iterations:
  • A hook has a 10-second budget for its own code, which is far too short to wait for a person. Time spent inside a $ 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.
  • A pane needs room. In a narrow terminal Claude Code doesn't place it, so the mod checks isPlaced and draws the report in the AbovePrompt band instead.
  • Cancel has the focus when the pane opens, so pressing Enter refuses the command rather than running it.
  • An independent review before release found two problems in the measuring step, both fixed. A file named like a 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.
  • Tested in Claude Code, before those fixes: 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.

Run it

Requirements:

  • Claude Code 2.1.287 or later, where mods load by default. The mod was built and tested on 2.1.280, and 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).
  • For the side pane, a terminal about 144 columns wide or more. Narrower terminals get the band above the prompt.

No environment variables or configuration.

Steps:

  1. Clone this repository and go to this folder's parent:
   git clone https://github.com/anthropics/claude-code-playground.git
   cd claude-code-playground/claude-code/mods
  1. Check the plugin:
   claude plugin validate ./blast-radius
  1. Try it for one session:
   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.

  1. Ask Claude to run something risky in a throwaway repo, for example "delete the build folder with rm -rf build".

Using the pane:

  • Press 1 for Proceed or 2 for Cancel. You can also click a button, or Tab to it and press Enter.
  • In the band above the prompt, 1 or 2 works while the input is empty.
  • If nobody answers within 10 minutes, the command is refused.
  • If you interrupt the turn (Esc), the command is refused.

Notes / limitations

  • It reads the command text. It doesn't parse shell fully: $(...), 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.
  • Only the first risky part of a command line is measured, and the pane shows the command on one line, cut off if it's long. Proceed runs the whole line as written.
  • It follows 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.
  • In a narrow terminal the report is drawn in the band above the prompt, which only one mod can use at a time. If another mod that draws there (such as Replay Theater or Token Weather in this folder) takes the band, the Proceed and Cancel buttons may not show, and the command is refused after 10 minutes. Use a wider terminal, or turn the other mod off, when you rely on Blast Radius.
  • One command is held at a time. A second risky call, from a subagent for example, waits until the first is answered.
  • It only watches the Bash tool. File edits and other tools aren't held.
  • The 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.
  • The force-push list uses your last fetch of the remote branch. Without one, it can't list the dropped commits, and it says so. When the push names no remote, it assumes origin.
  • To list pending migrations, it runs the tool's own status command (for example 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.
  • A few harmless commands are held too, such as a commit whose message contains ; rm -rf.
  • It checks one command at a time. It holds a call, but it doesn't sandbox it: after you press Proceed, the command runs as written.
  • This is a safety net, not a permission system. Use permission rules for a hard block.

Dependencies

NameVersionLicense (SPDX)Source
None

The mod has no packages to install. It calls tools that are already on the machine (listed under Requirements).

Third-party notices

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.

Source 1 files
hooks/blast-radius.mjs 675 lines
1// 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