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.

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 │
╰───────────────────────────────────────────────────────────────╯
| Command | The pane shows |
|---|---|
rm -r, rm -f, rm -rf | The files it would delete: count, total size, the first 10 paths. Globs and ~ are expanded. |
git reset --hard | The files with uncommitted changes (git status --porcelain) and git diff --shortstat. |
git checkout -- ., git restore . | The files with unstaged changes. |
git clean | The untracked paths it would remove, from git clean -n with the same flags. |
git push --force (also -f, --force-with-lease, +ref) | The commits on the remote branch that your HEAD does not have, which the push would drop. |
manage.py migrate, db:migrate, alembic upgrade, prisma migrate | The pending migrations, from the tool's own status command. |
any other migrate | A note that it 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.
It works as soon as it is installed. No commands.
1 for Proceed or 2 for Cancel. Prefer the digits.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.| Option | Default | Meaning |
|---|---|---|
language | auto | Language 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.
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.$(...), 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.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.python3 manage.py showmigrations), which loads your project's code before you choose.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.
bash, git, find and du on your PATH; for migrations, the project's own tool.cd - and a folder that does not exist cannot be measured; the pane says so and still holds the command.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.origin.; rm -rf, or a heredoc that writes a file containing that text.claude-code/mods/blast-radius, commit 569c5283 (2026-10-01), by Claude Code DevRel. Copyright 2026 Anthropic PBC.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.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.
hooks/blast-radius.mjs 554 lines1// 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}
554hooks/i18n.mjs 204 lines1// 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