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.

newpanebandguardtoastprocess
v0.2.0Apache-2.0updated 2026-10-15aecheman27-gif/claude-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 --for… ⎿ Read 6 lines │ ┃ │ Would rm -rf (could not measure it) ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Could not measure: bash exited with 0 ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ ⎿ Denied by blast-radius: Blast Radius held this command an │ ┃ │ 1: Proceed 2: Cancel Claude is waiting o │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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 rm -rf (could not measure it) │ │ │ │ Could not measure: bash exited with 0 │ │ │ │ 1: Proceed 2: Cancel Claude is waiting on your answer │ ╰──────────────────────────────────────────────────────────╯
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 (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.

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.
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:

  • 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. 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).
  • For the side pane, a terminal about 144 columns wide or more. Narrower terminals get the band above the prompt.

No configuration. On Windows it reads CLAUDE_CODE_GIT_BASH_PATH, the variable Claude Code itself uses for Git Bash.

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.
  • If anything goes wrong while holding (the wait or the hook itself fails), the command is refused rather than run.

Notes / limitations

  • It reads the command text. It doesn't parse shell fully: aliases, functions, scripts that call 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.
  • 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 watches the Bash and Monitor tools. PowerShell, file edits and other tools aren't held.
  • If measuring fails (bash missing or erroring), the pane says it couldn't measure; it never reads a failure as "delete nothing".
  • 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 734 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, 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