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
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 delete nothing: no file matches ⏺ Update(src/auth.ts) │ ┃ │ build ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ The paths don't exist, so rm has nothing ⎿ Denied by blast-radius: Blast Radius held this command an │ ┃ │ to remove. │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 1: Proceed 2: Cancel Claude is waiting o │ ┃ ╰─────────────────────────────────────────── ✻ 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 delete nothing: no file matches build │ │ │ │ The paths don't exist, so rm has nothing to remove. │ │ │ │ 1: Proceed 2: Cancel Claude is waiting on your answer │ ╰──────────────────────────────────────────────────────────╯
README

blast-radius

Holds a risky shell command before Claude runs it, works out what it would change, and opens a pane with Proceed and Cancel. Cancel refuses the command and tells Claude why, so it knows nothing ran.

This mod comes from Anthropic's claude-code-playground (claude-code/mods/blast-radius), Apache-2.0. The copy here adds a language option (English, Traditional Chinese, Japanese) and leaves the logic untouched; see Origin and license below. The upstream README, with screenshots and the story of how it was built, is kept as README.upstream.md.

╭─ ⚠ Blast Radius · rm -rf ─────────────────────────────────────╮
│ Command  rm -rf build                                         │
│ Would    delete 128 files (about 4.2 MB)                      │
│                                                               │
│   build/index.js                                              │
│   build/assets/app.css                                        │
│   + 126 more                                                  │
│ Paths: build                                                  │
│                                                               │
│ 1: Proceed   2: Cancel   Claude is waiting on your answer     │
╰───────────────────────────────────────────────────────────────╯

What it holds, and what the pane shows

CommandThe pane shows
rm -r, rm -f, rm -rfThe files it would delete: count, total size, the first 10 paths. Globs and ~ are expanded.
git reset --hardThe files with uncommitted changes (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 does not 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 cannot 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, the measurement runs in that folder. A cd inside ( ... ) only applies inside the parentheses, as in the shell.

Usage

It works as soon as it is installed. No commands.

  • When the pane appears, press 1 for Proceed or 2 for Cancel. Prefer the digits.
  • Arrow keys, Tab and Enter only work while the pane actually holds the keyboard. The pane requests focus when it opens, and Claude Code grants it only over an empty composer. If the composer has text, or the terminal is too narrow and the report is drawn in the band above the prompt instead, the keys stay with the composer. An Enter there is read by Claude Code as "background this turn once the current tool finishes": the session is continued under a new session id and the transcript shows Backgrounding after the current tool finishes…. To use the arrows, click the pane first, or press ctrl+x then tab to hand it the keyboard.
  • Cancel has the focus when the pane does hold the keyboard, so Enter there refuses the command.
  • No answer within 10 minutes refuses the command. Interrupting the turn (Esc) refuses it too.
  • One command is held at a time. A second risky call (from a subagent, say) waits until the first is answered.

Settings

OptionDefaultMeaning
languageautoLanguage of the pane, the toast and the refusal text: auto (from LC_ALL, then LC_MESSAGES, then LANG: zh* gives Traditional Chinese, ja* Japanese, anything else English), en, zh-TW or ja.

Set it with /plugin configure blast-radius@cockpit, or --config language=ja at install. Command names in the pane (rm -rf, git reset --hard, …) are never translated. Every refusal ends with the same English line, (blast-radius: the user did not approve this command; do not retry unless asked.), so Claude reads a Japanese or Chinese refusal as a refusal, not as a transient error.

Beside the other mods in this repo

  • While it holds a command it takes over the band above the prompt (its AbovePrompt hook does not pass the band on). The lines of opsx-board, auto-handover and cache-keeper disappear for that time and return once you answer. That is deliberate: the buttons must be on top.
  • It only watches the Bash tool. opsx-board's checkbox edits and auto-handover's note files go through the mod file API, not Bash, so they are never held.

Safety boundary

  • It reads the command text; it is not a full shell parser. $(...), aliases, eval, bash -c "...", xargs rm, find -delete, scripts that call rm, and wrappers such as timeout 5 rm or doas rm are not caught.
  • Paths are passed to bash, find, du and git as arguments, never as shell source; a relative path gets ./ in front so find never reads a file named like -delete as an action.
  • Listing migrations runs the tool's own status command (for example python3 manage.py showmigrations), which loads your project's code before you choose.
  • It is a safety net, not a permission system. After Proceed the command runs as written, with no sandbox. For a hard block use permission rules.

What it does, before you install it

claude plugin validate ./plugins/blast-radius

Result (this copy, on Claude Code 2.1.289):

hooks: session.start, tool.call{tool=Bash}, ui.render{component=Pane}, ui.render{component=AbovePrompt}
calls: $.clock.now, $.env.get, $.process.run, $.session.cwd, $.ui.close, $.ui.invalidate,
       $.ui.open, $.ui.resolve, $.ui.toast
env reads: LANG, LC_ALL, LC_MESSAGES
env writes: nothing

$.process.run is used for measuring (bash -c to expand paths, find, du -k, git status/diff/clean -n/log/rev-parse, the migration tool's status command) and for the hold loop, which waits on sleep 0.25 until a button is pressed. $.env.get reads the three locale variables once, at session start. No file reads or writes, no network, no model calls.

Requirements and limits

  • bash, git, find and du on your PATH; for migrations, the project's own tool.
  • Only the first risky part of a command line is measured, and the pane shows the command on one line, cut off if long. Proceed runs the whole line as written.
  • cd - and a folder that does not exist cannot be measured; the pane says so and still holds the command.
  • The rm count is approximate: a path matched twice is counted twice, a file name with a line break is not counted, and sizes come from du -k (space on disk). A very large tree can take a few seconds.
  • The force-push list uses your last fetch of the remote branch. Without one it cannot list the dropped commits, and says so. With no remote named, it assumes origin.
  • A few harmless commands are held too, such as a commit whose message contains ; rm -rf, or a heredoc that writes a file containing that text.
  • A terminal narrower than about 144 columns gets the report in the band above the prompt instead of a side pane.

Origin and license

  • Upstream: anthropics/claude-code-playground, claude-code/mods/blast-radius, commit 569c5283 (2026-10-01), by Claude Code DevRel. Copyright 2026 Anthropic PBC.
  • License: Apache License 2.0, full text in LICENSE in this folder. The other mods in this repo are MIT; the two do not affect each other.
  • Modifications: the user-facing strings moved into hooks/i18n.mjs and a language option was added; classification, measuring and holding logic are unchanged. The exact list is in NOTICE and in the header of hooks/blast-radius.mjs. hooks/hooks.json, screenshots/ and README.upstream.md are the upstream files byte for byte.
  • Upstream's note applies: shared as-is as part of claude-code-playground, not an official Anthropic product, no support or maintenance implied.

Development

claude plugin validate --strict ./plugins/blast-radius
claude plugin test ./plugins/blast-radius     # resolveLang, the dictionaries, classify
claude --plugin-dir ./plugins/blast-radius    # load it for one session

Upstream ships no tests (its README mentions tests run in an internal workspace). The tests here cover the language pick, the completeness of the three dictionaries and the command classifier; the measuring step and the hold are exercised in a live session only. If you change the code, add the change to NOTICE, as Apache-2.0 requires.

One trap while developing: a Bash command whose text contains rm -rf (a heredoc writing a test file, a grep for it) is itself held by the installed mod. Build such strings from pieces, or write the file with a tool other than Bash.

Source 2 files
hooks/blast-radius.mjs 554 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Modified by davidho27941 (2026-10-04): added a "language" userConfig option and
5// moved the user-facing strings into hooks/i18n.mjs (English, Traditional Chinese,
6// Japanese). Logic unchanged. `classify` is exported for the tests.
7//
8// Modified by davidho27941 (2026-10-05): renamed draw()'s first parameter from `t`
9// to `ui`; it shadowed the imported translation function, so every pane rendered
10// empty and every hold ended in the error refusal.
11//
12// Blast Radius: holds a risky Bash command and shows what it would change.
13//
14// tool.call (Bash): if the command is risky, work out its blast radius, open a
15// pane with Proceed and Cancel, and hold the call until one is pressed.
16// ui.render (Pane): draws the report. If the surface won't place the pane (a
17// narrow terminal), the same report is drawn in the AbovePrompt band instead.
18//
19// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
20// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
21// a button's onPress sets the decision.
22//
23// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
24// spelled literally, and helpers that take `$` are top-level functions.
25
26import { resolveLang, t } from "./i18n.mjs";
27
28const PANE_ID = "blast-radius";
29const POLL_SECONDS = "0.25";
30const HOLD_LIMIT_MS = 10 * 60 * 1000;
31const LIST_MAX = 10;
32
33// The call being held, or null. One at a time: Bash calls in a turn run in order.
34let held = null;
35// The language the pane and the refusals use: the option, else the locale, else English.
36let lang = resolveLang(undefined, {});
37
38export function register(on, options) {
39  const languageOption = options?.language;
40  lang = resolveLang(languageOption, {});
41  on("session.start", async ($, e, next) => {
42    const out = await next(e);
43    try {
44      const env = { LC_ALL: await $.env.get("LC_ALL"), LC_MESSAGES: await $.env.get("LC_MESSAGES"), LANG: await $.env.get("LANG") };
45      lang = resolveLang(languageOption, env);
46    } catch {
47      // the locale is unreadable: keep the option or English
48    }
49    return out;
50  });
51
52  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
53    const risk = classify(String(e.command ?? ""));
54    if (risk === null) {
55      return next(e);
56    }
57    // One hold at a time. If another risky call is already held (a subagent's,
58    // say), wait until it is answered. `held` is claimed with no await between
59    // the check and the claim, so two waiting calls can't both get through.
60    while (held !== null) {
61      if (next.signal.aborted) {
62        return { deny: t(lang, "denyInterrupted") };
63      }
64      await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
65    }
66    const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
67    held = mine;
68
69    let opened = { isPlaced: false };
70    let decision;
71    let summary = risk.label;
72    try {
73      // Measure where the command will run: the session folder, moved by any
74      // `cd dir &&` or `git -C dir` earlier in the same command line.
75      const sessionCwd = await $.session.cwd();
76      const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
77      mine.report = cwd === null
78        ? { summary: t(lang, "inDir", { label: risk.label, dir: risk.dir }), lines: [], note: t(lang, "noDir", { dir: risk.dir }) }
79        : await measure($, risk, cwd);
80      summary = mine.report.summary;
81
82      opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
83      if (!opened.isPlaced) {
84        mine.where = "band";
85      }
86      $.ui.invalidate("ui.render");
87
88      const startedAt = await $.clock.now();
89      while (mine.decision === null) {
90        if (next.signal.aborted) {
91          mine.decision = "interrupted";
92          break;
93        }
94        if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
95          mine.decision = "timeout";
96          break;
97        }
98        await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
99      }
100    } catch {
101      mine.decision = "error"; // anything unexpected refuses the command
102    } finally {
103      decision = mine.decision;
104      // Close this call's pane before releasing the hold, so the next call's
105      // pane can't be the one that gets closed.
106      try {
107        if (opened.isPlaced) {
108          await $.ui.close({ id: PANE_ID });
109        }
110      } catch {
111        // the pane is already gone
112      }
113      if (held === mine) {
114        held = null;
115      }
116      $.ui.invalidate("ui.render");
117    }
118
119    if (decision === "proceed") {
120      $.ui.toast(t(lang, "running"));
121      return next(e);
122    }
123    const why = {
124      cancel: t(lang, "whyCancel"),
125      timeout: t(lang, "whyTimeout"),
126      interrupted: t(lang, "whyInterrupted"),
127      error: t(lang, "whyError"),
128    }[decision] ?? t(lang, "whyUnknown");
129    return {
130      deny: t(lang, "deny", { why, summary }),
131    };
132  });
133
134  on("ui.render", { component: "Pane" }, ($, e, next) => {
135    if (e.requestId !== PANE_ID || held === null || held.report === null) {
136      return next(e);
137    }
138    return draw($.ui.resolve(e), held);
139  });
140
141  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
142    if (held === null || held.report === null || held.where !== "band") {
143      return next(e);
144    }
145    return draw($.ui.resolve(e), held);
146  });
147}
148
149// ---- What counts as risky -------------------------------------------------
150
151// sudo options that take a value, so the value isn't read as the command.
152const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
153// Commands that only read, so a bare word "migrate" in them isn't a migration.
154const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
155
156/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
157function joinDir(dir, arg) {
158  if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
159    return arg ?? "~";
160  }
161  return dir ? `${dir}/${arg}` : arg;
162}
163
164/** The first risky segment of a shell command, or null. */
165export function classify(command) {
166  let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
167  const scopes = []; // dir to restore when a ( subshell ) closes
168  const pushed = []; // pushd stack, for popd
169  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
170    const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
171    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
172    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
173    const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
174    for (let k = 0; k < opens; k += 1) {
175      scopes.push(dir);
176    }
177    const risk = classifySegment(raw, dir, pushed);
178    if (risk !== null && risk.cd === undefined) {
179      return risk;
180    }
181    if (risk !== null) {
182      dir = risk.cd; // a cd, pushd or popd moved the folder
183    }
184    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
185      dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
186    }
187  }
188  return null;
189}
190
191// Words that can come before the real command without changing what it does.
192const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
193
194/** One segment: a risk, { cd } for a folder change, or null. */
195function classifySegment(segment, dir, pushed) {
196  {
197    const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
198    while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
199      words.shift(); // leading VAR=value
200    }
201    if (words[0] === "sudo") {
202      words.shift();
203      while (words.length > 0 && words[0].startsWith("-")) {
204        const option = words.shift();
205        if (SUDO_VALUE_OPTIONS.has(option)) {
206          words.shift();
207        }
208      }
209    }
210    while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
211      words.shift();
212    }
213    if (words[0] === "nice") {
214      words.shift();
215      if (words[0] === "-n") {
216        words.splice(0, 2);
217      } else if (/^-\d+$/.test(words[0] ?? "")) {
218        words.shift();
219      }
220    }
221    const [first, ...args] = words;
222    if (first === undefined) {
223      return null;
224    }
225    const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
226    if (cmd === "cd") {
227      return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
228    }
229    if (cmd === "pushd") {
230      pushed.push(dir);
231      return { cd: joinDir(dir, args[0]) };
232    }
233    if (cmd === "popd") {
234      return { cd: pushed.length > 0 ? pushed.pop() : "-" };
235    }
236    if (cmd === "rm" || cmd.endsWith("/rm")) {
237      const flags = args.filter((a) => a.startsWith("-"));
238      const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
239      const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
240      if (recursive || force) {
241        const targets = args.filter((a) => !a.startsWith("-") || a === "-");
242        return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
243      }
244    }
245    if (cmd === "git") {
246      // Git's own options come before the subcommand; -C moves where it runs.
247      let gitDir = dir;
248      let i = 0;
249      while (i < args.length && args[i].startsWith("-")) {
250        if (args[i] === "-C" && i + 1 < args.length) {
251          gitDir = joinDir(gitDir, args[i + 1]);
252          i += 2;
253        } else if (args[i] === "-c" && i + 1 < args.length) {
254          i += 2;
255        } else {
256          i += 1;
257        }
258      }
259      const sub = args[i];
260      const rest = args.slice(i + 1);
261      if (sub === "reset" && rest.includes("--hard")) {
262        return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
263      }
264      if (sub === "clean") {
265        return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
266      }
267      if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
268        return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
269      }
270      const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
271      if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
272        return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
273      }
274    }
275    const joined = words.join(" ");
276    if (/\balembic\s+upgrade\b/.test(joined)) {
277      return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
278    }
279    if (/\bdb:migrate(?!:status\b)/.test(joined)) {
280      return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
281    }
282    if (/\bprisma\s+migrate\b/.test(joined)) {
283      return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
284    }
285    if (/\bmanage\.py\s+migrate\b/.test(joined)) {
286      return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
287    }
288    if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
289      return { kind: "migrate", tool: "unknown", label: "migrate", dir };
290    }
291  }
292  return null;
293}
294
295// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
296// The target is passed as an argument, never as source.
297const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
298
299async function resolveDir($, sessionCwd, dir) {
300  if (dir === "-") {
301    return null; // `cd -` depends on the shell's history
302  }
303  const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
304  const out = run.stdout.trim();
305  return run.exitCode === 0 && out !== "" ? out : null;
306}
307
308/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
309function tokenize(text) {
310  const words = [];
311  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
312  let m;
313  while ((m = re.exec(text)) !== null) {
314    words.push(m[1] ?? m[2] ?? m[3]);
315  }
316  return words;
317}
318
319// ---- Measuring the blast radius -------------------------------------------
320
321/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
322async function measure($, risk, cwd) {
323  try {
324    if (risk.kind === "rm") {
325      return await measureRm($, risk, cwd);
326    }
327    if (risk.kind === "migrate") {
328      return await measureMigrations($, risk, cwd);
329    }
330    return await measureGit($, risk, cwd);
331  } catch (error) {
332    return { summary: t(lang, "couldNotMeasure", { label: risk.label }), lines: [], note: t(lang, "couldNotMeasureNote", { error: String(error?.message ?? error).slice(0, 200) }) };
333  }
334}
335
336// The paths are passed to bash as arguments, never as source, so nothing in
337// them runs. compgen -G expands a glob without command substitution.
338const RM_SCRIPT = `
339shopt -s nullglob dotglob
340paths=()
341for p in "$@"; do
342  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
343  if [[ "$p" == *[*?[]* ]]; then
344    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
345  elif [[ -e "$p" || -L "$p" ]]; then
346    paths+=("$p")
347  fi
348done
349if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
350# A relative path gets ./ in front, so find never reads a name like -delete as an action.
351for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
352files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
353kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
354echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
355find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
356`;
357
358async function measureRm($, risk, cwd) {
359  if (risk.targets.length === 0) {
360    return { summary: t(lang, "rmNoPaths"), lines: [], note: t(lang, "rmNoPathsNote") };
361  }
362  const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
363  const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
364  const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
365  if (!found) {
366    return { summary: t(lang, "rmNothing", { targets: risk.targets.join(" ") }), lines: [], note: t(lang, "rmNothingNote") };
367  }
368  if (!files) {
369    return { summary: t(lang, "rmEmpty", { found }), lines: [], note: t(lang, "rmPaths", { targets: risk.targets.join(" ") }) };
370  }
371  return {
372    summary: t(lang, "rmFiles", { files, size: size(bytes) }),
373    lines: rest.map((l) => l.replace(/^\.\//, "")),
374    more: Math.max(0, files - rest.length),
375    note: t(lang, "rmPaths", { targets: risk.targets.join(" ") }),
376  };
377}
378
379async function measureGit($, risk, cwd) {
380  if (risk.kind === "git-push-force") {
381    return await measurePush($, risk, cwd);
382  }
383  if (risk.kind === "git-clean") {
384    const flags = [];
385    const paths = [];
386    for (let i = 0; i < risk.args.length; i += 1) {
387      const a = risk.args[i];
388      if (a === "--") {
389        paths.push(...risk.args.slice(i + 1));
390        break;
391      }
392      if (a === "-e" || a === "--exclude") {
393        flags.push(a, risk.args[i + 1] ?? "");
394        i += 1;
395      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
396        flags.push(a);
397      } else if (/^-[a-zA-Z]+$/.test(a)) {
398        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
399        if (kept !== "-") {
400          flags.push(kept);
401        }
402      } else if (!a.startsWith("-")) {
403        paths.push(a);
404      }
405    }
406    const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
407    if (run.exitCode !== 0) {
408      return { summary: t(lang, "cleanFailed"), lines: [], note: run.stderr.trim().slice(0, 200) };
409    }
410    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
411    return {
412      summary: gone.length === 0 ? t(lang, "cleanNothing") : t(lang, "cleanRemove", { n: gone.length }),
413      lines: gone.slice(0, LIST_MAX),
414      more: Math.max(0, gone.length - LIST_MAX),
415      note: t(lang, "cleanNote"),
416    };
417  }
418  const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
419  if (status.exitCode !== 0) {
420    return { summary: t(lang, "notRepo", { label: risk.label }), lines: [], note: status.stderr.trim().slice(0, 200) };
421  }
422  const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
423  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
424  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
425  const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
426  return {
427    summary: lost.length === 0 ? t(lang, "discardNothing") : t(lang, "discard", { n: lost.length }),
428    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
429    more: Math.max(0, lost.length - LIST_MAX),
430    note: stat.stdout.trim() !== "" ? t(lang, "discardStat", { stat: stat.stdout.trim() }) : t(lang, "discardNote"),
431  };
432}
433
434async function measurePush($, risk, cwd) {
435  const positional = risk.args.filter((a) => !a.startsWith("-"));
436  const remote = positional[0] ?? "origin";
437  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
438  const spec = (positional[1] ?? "").replace(/^\+/, "");
439  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
440  branch = (branch ?? "").replace(/^refs\/heads\//, "");
441  if (!branch) {
442    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
443    branch = head.stdout.trim();
444    source = "HEAD";
445  } else if (branch === "HEAD") {
446    // `git push origin HEAD` pushes the current branch to its namesake.
447    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
448    branch = head.stdout.trim();
449    source = "HEAD";
450  }
451  source = source || "HEAD";
452  const ref = `${remote}/${branch}`;
453  const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
454  if (known.exitCode !== 0) {
455    return { summary: t(lang, "pushTo", { ref }), lines: [], note: t(lang, "pushUnknown", { ref }) };
456  }
457  const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
458  const dropped = log.stdout.split("\n").filter((l) => l !== "");
459  return {
460    summary: dropped.length === 0 ? t(lang, "pushNothing", { ref }) : t(lang, "pushDrops", { ref, n: dropped.length }),
461    lines: dropped.slice(0, LIST_MAX),
462    more: Math.max(0, dropped.length - LIST_MAX),
463    note: t(lang, "pushNote", { ref, source }),
464  };
465}
466
467const MIGRATION_LISTERS = {
468  django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
469  alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
470  rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
471  prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
472};
473
474async function measureMigrations($, risk, cwd) {
475  const lister = MIGRATION_LISTERS[risk.tool];
476  if (lister === undefined) {
477    return { summary: t(lang, "migrateUnknown"), lines: [], note: t(lang, "migrateUnknownNote") };
478  }
479  let run;
480  try {
481    run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
482  } catch (error) {
483    run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
484  }
485  if (run.exitCode !== 0) {
486    return { summary: t(lang, "migrateRun", { label: risk.label }), lines: [], note: t(lang, "migrateListFailed", { argv: lister.argv.join(" ") }) };
487  }
488  const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
489  return {
490    summary: pending.length === 0 ? t(lang, "migrateNothing", { label: risk.label }) : t(lang, "migrateApply", { n: pending.length }),
491    lines: pending.slice(0, LIST_MAX),
492    more: Math.max(0, pending.length - LIST_MAX),
493    note: t(lang, "migrateNote", { argv: lister.argv.join(" ") }),
494  };
495}
496
497function size(bytes) {
498  if (!Number.isFinite(bytes) || bytes < 1024) {
499    return `${bytes || 0} B`;
500  }
501  const units = ["KB", "MB", "GB", "TB"];
502  let n = bytes;
503  let i = -1;
504  while (n >= 1024 && i < units.length - 1) {
505    n /= 1024;
506    i += 1;
507  }
508  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
509}
510
511// ---- Drawing --------------------------------------------------------------
512
513function paneRows(report) {
514  return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
515}
516
517function draw(ui, state) {
518  const { Box, Text, Button } = ui;
519  const { report } = state;
520  const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: `  ${line}`, wrap: "truncate-end" }));
521  if (report.more) {
522    list.push(Text({ key: "more", dimColor: true, children: t(lang, "more", { n: report.more }) }));
523  }
524  // The buttons answer the call this pane was drawn for, never whichever one is held now.
525  const decide = (choice) => () => {
526    if (state.decision === null) {
527      state.decision = choice;
528    }
529  };
530  return Box({
531    flexDirection: "column",
532    borderStyle: "round",
533    borderColor: "yellow",
534    paddingX: 1,
535    children: [
536      Text({ key: "title", bold: true, color: "yellow", children: t(lang, "title", { label: state.risk.label }) }),
537      Text({ key: "cmd", children: [Text({ dimColor: true, children: t(lang, "command") }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
538      Text({ key: "sum", children: [Text({ dimColor: true, children: t(lang, "would") }), Text({ color: "red", bold: true, children: report.summary })] }),
539      Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
540      report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
541      Box({
542        key: "buttons",
543        marginTop: 1,
544        gap: 2,
545        children: [
546          Button({ key: "proceed", label: t(lang, "proceed"), hotkey: "1", plain: true, onPress: decide("proceed") }),
547          Button({ key: "cancel", label: t(lang, "cancel"), hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
548          Text({ key: "hint", dimColor: true, children: t(lang, "waiting") }),
549        ],
550      }),
551    ],
552  });
553}
554
hooks/i18n.mjs 204 lines
1// Copyright 2026 davidho27941
2// SPDX-License-Identifier: Apache-2.0
3//
4// User-facing strings of Blast Radius in English, Traditional Chinese and
5// Japanese, and the language pick: the `language` option when it names one,
6// else the locale (LC_ALL, LC_MESSAGES, LANG), else English.
7//
8// Every key exists in every language; `t` falls back to English for a key a
9// dictionary lacks, so a missing translation shows English, never a blank.
10// Command names (rm -rf, git reset --hard, ...) are not translated.
11
12export const LANGS = ["en", "zh-TW", "ja"];
13export const DEFAULT_LANG = "en";
14
15/** The language to draw in: the option when it names one, else from the locale. */
16export function resolveLang(option, env) {
17  const picked = typeof option === "string" ? option.trim() : "";
18  if (LANGS.includes(picked)) {
19    return picked;
20  }
21  const e = env ?? {};
22  const locale = [e.LC_ALL, e.LC_MESSAGES, e.LANG].find((v) => typeof v === "string" && v.trim() !== "") ?? "";
23  const lower = locale.trim().toLowerCase();
24  if (lower === "" || lower === "c" || lower === "posix" || lower.startsWith("c.") || lower.startsWith("posix.")) {
25    return DEFAULT_LANG;
26  }
27  if (lower.startsWith("zh")) {
28    return "zh-TW";
29  }
30  if (lower.startsWith("ja")) {
31    return "ja";
32  }
33  return DEFAULT_LANG;
34}
35
36// An English tail every refusal carries, whatever the language, so the model
37// never reads a Japanese or Chinese refusal as a transient error.
38const DENY_TAIL = "(blast-radius: the user did not approve this command; do not retry unless asked.)";
39
40const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
41
42export const MESSAGES = {
43  en: {
44    // pane
45    title: ({ label }) => `⚠ Blast Radius · ${label}`,
46    command: "Command  ",
47    would: "Would    ",
48    more: ({ n }) => `  + ${n} more`,
49    proceed: "Proceed",
50    cancel: "Cancel",
51    waiting: "Claude is waiting on your answer",
52    running: "Blast Radius: running it",
53    // refusals
54    denyInterrupted: `Blast Radius held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to. ${DENY_TAIL}`,
55    deny: ({ why, summary }) => `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. ${DENY_TAIL}`,
56    whyCancel: "the user pressed Cancel",
57    whyTimeout: "no answer within 10 minutes",
58    whyInterrupted: "the turn was interrupted",
59    whyError: "Blast Radius hit an error while holding it",
60    whyUnknown: "no answer was recorded",
61    // measuring
62    inDir: ({ label, dir }) => `${label} in ${dir}`,
63    noDir: ({ dir }) => `Couldn't find the folder ${dir}, so I couldn't measure what this would change.`,
64    couldNotMeasure: ({ label }) => `${label} (could not measure it)`,
65    couldNotMeasureNote: ({ error }) => `Could not measure: ${error}`,
66    rmNoPaths: "rm with no paths",
67    rmNoPathsNote: "No paths to expand.",
68    rmNothing: ({ targets }) => `delete nothing: no file matches ${targets}`,
69    rmNothingNote: "The paths don't exist, so rm has nothing to remove.",
70    rmEmpty: ({ found }) => `delete ${plural(found, "path", "paths")} with no files in ${found === 1 ? "it" : "them"}`,
71    rmPaths: ({ targets }) => `Paths: ${targets}`,
72    rmFiles: ({ files, size }) => `delete ${plural(files, "file", "files")} (about ${size})`,
73    cleanFailed: "git clean (could not dry-run it)",
74    cleanNothing: "remove nothing: no untracked files match",
75    cleanRemove: ({ n }) => `remove ${n} untracked ${n === 1 ? "path" : "paths"}`,
76    cleanNote: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
77    notRepo: ({ label }) => `${label} (not a git repo here?)`,
78    discardNothing: "discard nothing: no uncommitted changes",
79    discard: ({ n }) => `discard uncommitted changes in ${plural(n, "file", "files")}`,
80    discardStat: ({ stat }) => `${stat}. Uncommitted changes can't be recovered.`,
81    discardNote: "From git status --porcelain.",
82    pushTo: ({ ref }) => `force-push to ${ref}`,
83    pushUnknown: ({ ref }) => `No local copy of ${ref}, so I can't tell which commits the push would drop. Run git fetch first.`,
84    pushNothing: ({ ref }) => `force-push to ${ref}: drops no commits`,
85    pushDrops: ({ ref, n }) => `force-push to ${ref}: drops ${plural(n, "commit", "commits")}`,
86    pushNote: ({ ref, source }) => `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
87    migrateUnknown: "run migrations",
88    migrateUnknownNote: "I can't list the pending migrations for this tool, so the list is not shown.",
89    migrateRun: ({ label }) => `run ${label}`,
90    migrateListFailed: ({ argv }) => `Couldn't list pending migrations (${argv} failed).`,
91    migrateNothing: ({ label }) => `run ${label}: nothing pending`,
92    migrateApply: ({ n }) => `apply ${n} pending ${n === 1 ? "migration" : "migrations"}`,
93    migrateNote: ({ argv }) => `From ${argv}.`,
94  },
95  "zh-TW": {
96    title: ({ label }) => `⚠ Blast Radius · ${label}`,
97    command: "指令  ",
98    would: "會    ",
99    more: ({ n }) => `  還有 ${n} 個`,
100    proceed: "執行",
101    cancel: "取消",
102    waiting: "Claude 在等你的回答",
103    running: "Blast Radius:執行中",
104    denyInterrupted: `Blast Radius 攔住了這個指令,沒有執行:這一輪被中斷了。除非使用者要求,否則不要重試。 ${DENY_TAIL}`,
105    deny: ({ why, summary }) => `Blast Radius 攔住了這個指令,沒有執行:${why}。它原本會:${summary}。除非使用者要求,否則不要重試。 ${DENY_TAIL}`,
106    whyCancel: "使用者按了取消",
107    whyTimeout: "10 分鐘內沒有回答",
108    whyInterrupted: "這一輪被中斷了",
109    whyError: "Blast Radius 在攔住期間發生錯誤",
110    whyUnknown: "沒有記錄到任何回答",
111    inDir: ({ label, dir }) => `在 ${dir} 執行 ${label}`,
112    noDir: ({ dir }) => `找不到資料夾 ${dir},無法量測這個指令會改變什麼。`,
113    couldNotMeasure: ({ label }) => `${label}(無法量測)`,
114    couldNotMeasureNote: ({ error }) => `無法量測:${error}`,
115    rmNoPaths: "rm 沒有指定路徑",
116    rmNoPathsNote: "沒有可展開的路徑。",
117    rmNothing: ({ targets }) => `不會刪任何東西:沒有檔案符合 ${targets}`,
118    rmNothingNote: "這些路徑不存在,rm 沒有東西可刪。",
119    rmEmpty: ({ found }) => `刪除 ${found} 個路徑,裡面沒有檔案`,
120    rmPaths: ({ targets }) => `路徑:${targets}`,
121    rmFiles: ({ files, size }) => `刪除 ${files} 個檔案(約 ${size})`,
122    cleanFailed: "git clean(無法試跑)",
123    cleanNothing: "不會移除任何東西:沒有符合的未追蹤檔案",
124    cleanRemove: ({ n }) => `移除 ${n} 個未追蹤的路徑`,
125    cleanNote: "來自 git clean -n。未追蹤的檔案不在 git 裡,刪了就找不回來。",
126    notRepo: ({ label }) => `${label}(這裡不是 git repo?)`,
127    discardNothing: "不會丟掉任何東西:沒有未提交的修改",
128    discard: ({ n }) => `丟掉 ${n} 個檔案的未提交修改`,
129    discardStat: ({ stat }) => `${stat}。未提交的修改找不回來。`,
130    discardNote: "來自 git status --porcelain。",
131    pushTo: ({ ref }) => `強制推送到 ${ref}`,
132    pushUnknown: ({ ref }) => `本機沒有 ${ref} 的副本,看不出這次推送會蓋掉哪些 commit。請先 git fetch。`,
133    pushNothing: ({ ref }) => `強制推送到 ${ref}:不會蓋掉任何 commit`,
134    pushDrops: ({ ref, n }) => `強制推送到 ${ref}:會蓋掉 ${n} 個 commit`,
135    pushNote: ({ ref, source }) => `${ref} 上有、${source} 沒有的 commit,以上次 fetch 為準。`,
136    migrateUnknown: "執行 migration",
137    migrateUnknownNote: "無法列出這個工具的待執行 migration,所以不顯示清單。",
138    migrateRun: ({ label }) => `執行 ${label}`,
139    migrateListFailed: ({ argv }) => `無法列出待執行的 migration(${argv} 失敗)。`,
140    migrateNothing: ({ label }) => `執行 ${label}:沒有待執行的項目`,
141    migrateApply: ({ n }) => `套用 ${n} 個待執行的 migration`,
142    migrateNote: ({ argv }) => `來自 ${argv}。`,
143  },
144  ja: {
145    title: ({ label }) => `⚠ Blast Radius · ${label}`,
146    command: "コマンド  ",
147    would: "影響      ",
148    more: ({ n }) => `  他 ${n} 件`,
149    proceed: "実行",
150    cancel: "キャンセル",
151    waiting: "Claude はあなたの回答を待っています",
152    running: "Blast Radius:実行します",
153    denyInterrupted: `Blast Radius がこのコマンドを保留し、実行しませんでした:ターンが中断されました。ユーザーの指示がない限り再試行しないでください。 ${DENY_TAIL}`,
154    deny: ({ why, summary }) => `Blast Radius がこのコマンドを保留し、実行しませんでした:${why}。実行していれば:${summary}。ユーザーの指示がない限り再試行しないでください。 ${DENY_TAIL}`,
155    whyCancel: "ユーザーがキャンセルを押しました",
156    whyTimeout: "10 分以内に回答がありませんでした",
157    whyInterrupted: "ターンが中断されました",
158    whyError: "Blast Radius が保留中にエラーになりました",
159    whyUnknown: "回答が記録されていません",
160    inDir: ({ label, dir }) => `${dir} で ${label}`,
161    noDir: ({ dir }) => `フォルダ ${dir} が見つからないため、影響を測定できませんでした。`,
162    couldNotMeasure: ({ label }) => `${label}(測定できませんでした)`,
163    couldNotMeasureNote: ({ error }) => `測定できませんでした:${error}`,
164    rmNoPaths: "rm にパスがありません",
165    rmNoPathsNote: "展開するパスがありません。",
166    rmNothing: ({ targets }) => `何も削除しません:${targets} に一致するファイルがありません`,
167    rmNothingNote: "パスが存在しないため、rm が削除するものはありません。",
168    rmEmpty: ({ found }) => `${found} 個のパスを削除(中にファイルはありません)`,
169    rmPaths: ({ targets }) => `パス:${targets}`,
170    rmFiles: ({ files, size }) => `${files} 個のファイルを削除(約 ${size})`,
171    cleanFailed: "git clean(ドライランできませんでした)",
172    cleanNothing: "何も削除しません:一致する未追跡ファイルがありません",
173    cleanRemove: ({ n }) => `${n} 個の未追跡パスを削除`,
174    cleanNote: "git clean -n の結果。未追跡ファイルは git に無いため、復元できません。",
175    notRepo: ({ label }) => `${label}(ここは git リポジトリではない?)`,
176    discardNothing: "何も破棄しません:未コミットの変更がありません",
177    discard: ({ n }) => `${n} 個のファイルの未コミット変更を破棄`,
178    discardStat: ({ stat }) => `${stat}。未コミットの変更は復元できません。`,
179    discardNote: "git status --porcelain の結果。",
180    pushTo: ({ ref }) => `${ref} へ強制プッシュ`,
181    pushUnknown: ({ ref }) => `${ref} のローカルコピーが無いため、どのコミットが失われるか分かりません。先に git fetch してください。`,
182    pushNothing: ({ ref }) => `${ref} へ強制プッシュ:失われるコミットはありません`,
183    pushDrops: ({ ref, n }) => `${ref} へ強制プッシュ:${n} 件のコミットが失われます`,
184    pushNote: ({ ref, source }) => `${ref} にあって ${source} に無いコミット(最後の fetch 時点)。`,
185    migrateUnknown: "マイグレーションを実行",
186    migrateUnknownNote: "このツールの未適用マイグレーションを一覧できないため、リストは表示しません。",
187    migrateRun: ({ label }) => `${label} を実行`,
188    migrateListFailed: ({ argv }) => `未適用マイグレーションを一覧できませんでした(${argv} が失敗)。`,
189    migrateNothing: ({ label }) => `${label} を実行:未適用はありません`,
190    migrateApply: ({ n }) => `${n} 件の未適用マイグレーションを適用`,
191    migrateNote: ({ argv }) => `${argv} の結果。`,
192  },
193};
194
195/** The string for `key` in `lang` (English when the dictionary lacks it), with `params` applied. */
196export function t(lang, key, params = {}) {
197  const dict = MESSAGES[lang] ?? MESSAGES[DEFAULT_LANG];
198  const entry = dict[key] ?? MESSAGES[DEFAULT_LANG][key];
199  if (entry === undefined) {
200    return key;
201  }
202  return typeof entry === "function" ? entry(params) : entry;
203}
204