Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed and Cancel buttons.

A Claude Code mod that holds a risky shell command and shows you what it would change before it runs. We're sharing it as a small, complete example of a mod that pauses a tool call and asks you to decide.
When Claude calls Bash with one of the commands below, Blast Radius stops the call, works out what the command would touch, and opens a pane with two buttons: Proceed runs the command, Cancel refuses it. Claude sees the refusal and the reason.
| Command | What the pane shows |
|---|---|
rm -r, rm -f, rm -rf | The files it would delete, with the count and total size. Globs and ~ are expanded. |
git reset --hard | The files with uncommitted changes, from git status --porcelain, and git diff --shortstat. |
git checkout -- ., git restore . | The files with unstaged changes. |
git clean | The untracked paths it would remove, from git clean -n with the same flags. |
git push --force (also -f, --force-with-lease, +ref) | The commits on the remote branch that your HEAD doesn't have, which the push would drop. |
manage.py migrate, db:migrate, alembic upgrade, prisma migrate | The pending migrations, from the tool's own status command. |
any other migrate | A note that it can't list the pending migrations for that tool. |
Every other command runs as normal. If the command line moves first, with cd dir &&, pushd/popd or git -C dir, Blast Radius measures in that folder. A cd inside ( ... ) only applies inside the parentheses, as in the shell.
The patterns it demonstrates:
tool.call until the user answers, and returning { deny } with a reason Claude can act on.Pane, or in the AbovePrompt band when the terminal is too narrow for a pane.$.process.run, passing paths as arguments so nothing in them runs as shell.rm -rf build held, with the files it would delete:

After Cancel, Claude reports that nothing was deleted:

The second try, after Proceed:

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

Blast Radius. See what a command will touch, before it runs. When Claude runs a risky shell command, like
rm,git reset, or a migration, the mod opens a pane. The pane lists the files and branches the command would change. The developer presses Proceed, or Cancel. It usestool.callto hold the call, andui.renderonPane, withButtonelements. It adds a safety check, and it doesn't remove any, so it's safe to show. A bigger project, because each command needs its own dry run.
The build prompt, which picked this idea and two others by number:
implement 1,2,7. give me zips for them. test them in claude code and get me screenshots of what they look like when used.
$ call doesn't count, so the hold is a loop that waits on $.process.run(["sleep", "0.25"]) until a button's onPress sets the decision.isPlaced and draws the report in the AbovePrompt band instead.find action (for example -delete) that a glob matched could be read as an action while measuring, so relative paths now go to find with ./ in front. And a cd earlier on the command line was ignored, so the wrong folder was measured; the mod now follows cd and git -C. The same review led to smaller fixes: overlapping holds are queued, and the buttons always answer the command shown; an error while holding refuses the command; git clean -e, src:dst and HEAD force pushes are measured correctly; and sizes use du -k, which works on macOS as well as Linux.rm -rf build was held for more than 30 seconds, Cancel kept the files and Proceed deleted them; git reset --hard listed the two changed files and Cancel kept them; git clean -fdx was held. Force pushes and migrations were checked against the command classifier only, not run. The fixes are covered by tests of the classifier, the measuring step and the hold queue, run with Node against a stand-in for Claude Code; they haven't been re-run in a live session.Requirements:
claude plugin validate passes on 2.1.285.bash, git, find and du on your PATH. For migrations, the project's own tool (for example python3 manage.py showmigrations).No environment variables or configuration.
Steps:
git clone https://github.com/anthropics/claude-code-playground.git
cd claude-code-playground/claude-code/mods
claude plugin validate ./blast-radius
claude --plugin-dir ./blast-radius
Or install it, with the other mods here, from the local marketplace in this folder (see the mods README):
claude plugin marketplace add ./
claude plugin install blast-radius@claude-code-playground-mods --scope user
Remove it with claude plugin uninstall blast-radius@claude-code-playground-mods --scope user.
Using the pane:
1 for Proceed or 2 for Cancel. You can also click a button, or Tab to it and press Enter.1 or 2 works while the input is empty.$(...), aliases, eval, bash -c "...", xargs rm, find -delete, scripts that call rm, and wrappers such as timeout 5 rm, doas rm, time -p rm or env -i rm aren't caught.cd, pushd, popd and git -C on the same line. cd -, and a folder that doesn't exist, can't be measured; the pane says so and still holds the command. Otherwise it starts from the session's working folder.rm count is approximate: a path matched twice is counted twice, and a file name with a line break is not counted. The list shows the first 10 files. Counts come from find and sizes from du -k, so a size is the space on disk, to the nearest kilobyte. A very large tree can take a few seconds to measure.origin.python3 manage.py showmigrations), which loads your project's code before you choose Proceed or Cancel. If that fails, the pane says it couldn't list them.; rm -rf.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
The mod has no packages to install. It calls tools that are already on the machine (listed under Requirements).
Git is a trademark of Software Freedom Conservancy. Python is a registered trademark of the Python Software Foundation. npm is a trademark of npm, Inc. Django is a registered trademark of the Django Software Foundation. Rails and Ruby on Rails are trademarks of David Heinemeier Hansson. Prisma is a trademark of Prisma Data, Inc. Alembic is an open-source project of the SQLAlchemy authors. Use of these names here is descriptive and implies no endorsement.
Shared as-is as part of claude-code-playground. Not an official Anthropic product; no support or maintenance is implied. See the root README and LICENSE.
anthropics/claude-code-playground의 claude-code/mods/blast-radius를 수정한 버전입니다. Copyright 2026 Anthropic PBC, Apache License 2.0. 변경 내역은 저장소 루트의 NOTICE를 참고하세요. Anthropic 공식 배포물이 아닙니다.
hooks/blast-radius.mjs 536 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified 2026 by suhoon (Windows port / display changes). Original: https://github.com/anthropics/claude-code-playground — see NOTICE.
4//
5// Blast Radius: holds a risky Bash command and shows what it would change.
6//
7// tool.call (Bash): if the command is risky, work out its blast radius, open a
8// pane with Proceed and Cancel, and hold the call until one is pressed.
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.
15//
16// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
17// spelled literally, and helpers that take `$` are top-level functions.
18
19const PANE_ID = "blast-radius";
20const POLL_SECONDS = "0.25";
21// Windows distribution: configured by install.ps1; defaults work on macOS/Linux.
22let bashExecutable = "bash";
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, options = {}) {
30 bashExecutable = options.bashExecutable || "bash";
31 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
32 const risk = classify(String(e.command ?? ""));
33 if (risk === null) {
34 return next(e);
35 }
36 // One hold at a time. If another risky call is already held (a subagent's,
37 // say), wait until it is answered. `held` is claimed with no await between
38 // the check and the claim, so two waiting calls can't both get through.
39 while (held !== null) {
40 if (next.signal.aborted) {
41 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." };
42 }
43 await $.process.run([bashExecutable, "-c", 'sleep "$1"', "blast-radius", POLL_SECONDS], { timeoutMs: 5000 });
44 }
45 const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
46 held = mine;
47
48 let opened = { isPlaced: false };
49 let decision;
50 let summary = risk.label;
51 try {
52 // Measure where the command will run: the session folder, moved by any
53 // `cd dir &&` or `git -C dir` earlier in the same command line.
54 const sessionCwd = await $.session.cwd();
55 const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
56 mine.report = cwd === null
57 ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
58 : await measure($, risk, cwd);
59 summary = mine.report.summary;
60
61 opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
62 if (!opened.isPlaced) {
63 mine.where = "band";
64 }
65 $.ui.invalidate("ui.render");
66
67 const startedAt = await $.clock.now();
68 while (mine.decision === null) {
69 if (next.signal.aborted) {
70 mine.decision = "interrupted";
71 break;
72 }
73 if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
74 mine.decision = "timeout";
75 break;
76 }
77 await $.process.run([bashExecutable, "-c", 'sleep "$1"', "blast-radius", POLL_SECONDS], { timeoutMs: 5000 });
78 }
79 } catch {
80 mine.decision = "error"; // anything unexpected refuses the command
81 } finally {
82 decision = mine.decision;
83 // Close this call's pane before releasing the hold, so the next call's
84 // pane can't be the one that gets closed.
85 try {
86 if (opened.isPlaced) {
87 await $.ui.close({ id: PANE_ID });
88 }
89 } catch {
90 // the pane is already gone
91 }
92 if (held === mine) {
93 held = null;
94 }
95 $.ui.invalidate("ui.render");
96 }
97
98 if (decision === "proceed") {
99 $.ui.toast("Blast Radius: running it");
100 return next(e);
101 }
102 const why = {
103 cancel: "the user pressed Cancel",
104 timeout: "no answer within 10 minutes",
105 interrupted: "the turn was interrupted",
106 error: "Blast Radius hit an error while holding it",
107 }[decision] ?? "no answer was recorded";
108 return {
109 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.`,
110 };
111 });
112
113 on("ui.render", { component: "Pane" }, ($, e, next) => {
114 if (e.requestId !== PANE_ID || held === null || held.report === null) {
115 return next(e);
116 }
117 return draw($.ui.resolve(e), held);
118 });
119
120 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
121 if (held === null || held.report === null || held.where !== "band") {
122 return next(e);
123 }
124 return draw($.ui.resolve(e), held);
125 });
126}
127
128// ---- What counts as risky -------------------------------------------------
129
130// sudo options that take a value, so the value isn't read as the command.
131const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
132// Commands that only read, so a bare word "migrate" in them isn't a migration.
133const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
134
135/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
136function joinDir(dir, arg) {
137 if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
138 return arg ?? "~";
139 }
140 return dir ? `${dir}/${arg}` : arg;
141}
142
143/** The first risky segment of a shell command, or null. */
144function classify(command) {
145 let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
146 const scopes = []; // dir to restore when a ( subshell ) closes
147 const pushed = []; // pushd stack, for popd
148 for (const raw of command.split(/&&|\|\||;|\||\n/)) {
149 const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
150 // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
151 const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
152 const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
153 for (let k = 0; k < opens; k += 1) {
154 scopes.push(dir);
155 }
156 const risk = classifySegment(raw, dir, pushed);
157 if (risk !== null && risk.cd === undefined) {
158 return risk;
159 }
160 if (risk !== null) {
161 dir = risk.cd; // a cd, pushd or popd moved the folder
162 }
163 for (let k = 0; k < closes && scopes.length > 0; k += 1) {
164 dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
165 }
166 }
167 return null;
168}
169
170// Words that can come before the real command without changing what it does.
171const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
172
173/** One segment: a risk, { cd } for a folder change, or null. */
174function classifySegment(segment, dir, pushed) {
175 {
176 const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
177 while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
178 words.shift(); // leading VAR=value
179 }
180 if (words[0] === "sudo") {
181 words.shift();
182 while (words.length > 0 && words[0].startsWith("-")) {
183 const option = words.shift();
184 if (SUDO_VALUE_OPTIONS.has(option)) {
185 words.shift();
186 }
187 }
188 }
189 while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
190 words.shift();
191 }
192 if (words[0] === "nice") {
193 words.shift();
194 if (words[0] === "-n") {
195 words.splice(0, 2);
196 } else if (/^-\d+$/.test(words[0] ?? "")) {
197 words.shift();
198 }
199 }
200 const [first, ...args] = words;
201 if (first === undefined) {
202 return null;
203 }
204 const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
205 if (cmd === "cd") {
206 return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
207 }
208 if (cmd === "pushd") {
209 pushed.push(dir);
210 return { cd: joinDir(dir, args[0]) };
211 }
212 if (cmd === "popd") {
213 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
214 }
215 if (cmd === "rm" || cmd.endsWith("/rm")) {
216 const flags = args.filter((a) => a.startsWith("-"));
217 const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
218 const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
219 if (recursive || force) {
220 const targets = args.filter((a) => !a.startsWith("-") || a === "-");
221 return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
222 }
223 }
224 if (cmd === "git") {
225 // Git's own options come before the subcommand; -C moves where it runs.
226 let gitDir = dir;
227 let i = 0;
228 while (i < args.length && args[i].startsWith("-")) {
229 if (args[i] === "-C" && i + 1 < args.length) {
230 gitDir = joinDir(gitDir, args[i + 1]);
231 i += 2;
232 } else if (args[i] === "-c" && i + 1 < args.length) {
233 i += 2;
234 } else {
235 i += 1;
236 }
237 }
238 const sub = args[i];
239 const rest = args.slice(i + 1);
240 if (sub === "reset" && rest.includes("--hard")) {
241 return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
242 }
243 if (sub === "clean") {
244 return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
245 }
246 if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
247 return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
248 }
249 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
250 if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
251 return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
252 }
253 }
254 const joined = words.join(" ");
255 if (/\balembic\s+upgrade\b/.test(joined)) {
256 return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
257 }
258 if (/\bdb:migrate(?!:status\b)/.test(joined)) {
259 return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
260 }
261 if (/\bprisma\s+migrate\b/.test(joined)) {
262 return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
263 }
264 if (/\bmanage\.py\s+migrate\b/.test(joined)) {
265 return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
266 }
267 if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
268 return { kind: "migrate", tool: "unknown", label: "migrate", dir };
269 }
270 }
271 return null;
272}
273
274// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
275// The target is passed as an argument, never as source.
276const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && { pwd -W 2>/dev/null || pwd -P; }`;
277
278async function resolveDir($, sessionCwd, dir) {
279 if (dir === "-") {
280 return null; // `cd -` depends on the shell's history
281 }
282 const run = await $.process.run([bashExecutable, "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
283 const out = run.stdout.trim();
284 return run.exitCode === 0 && out !== "" ? out : null;
285}
286
287/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
288function tokenize(text) {
289 const words = [];
290 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
291 let m;
292 while ((m = re.exec(text)) !== null) {
293 words.push(m[1] ?? m[2] ?? m[3]);
294 }
295 return words;
296}
297
298// ---- Measuring the blast radius -------------------------------------------
299
300/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
301async function measure($, risk, cwd) {
302 try {
303 if (risk.kind === "rm") {
304 return await measureRm($, risk, cwd);
305 }
306 if (risk.kind === "migrate") {
307 return await measureMigrations($, risk, cwd);
308 }
309 return await measureGit($, risk, cwd);
310 } catch (error) {
311 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
312 }
313}
314
315// The paths are passed to bash as arguments, never as source, so nothing in
316// them runs. compgen -G expands a glob without command substitution.
317const RM_SCRIPT = `
318shopt -s nullglob dotglob
319paths=()
320for p in "$@"; do
321 # Git Bash converts drive-letter and backslash paths before testing or globbing.
322 if command -v cygpath >/dev/null 2>&1; then p=$(cygpath -u -- "$p") || exit 1; fi
323 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
324 if [[ "$p" == *[*?[]* ]]; then
325 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
326 elif [[ -e "$p" || -L "$p" ]]; then
327 paths+=("$p")
328 fi
329done
330if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
331# A relative path gets ./ in front, so find never reads a name like -delete as an action.
332for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
333files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
334kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
335echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
336find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
337`;
338
339async function measureRm($, risk, cwd) {
340 if (risk.targets.length === 0) {
341 return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
342 }
343 const run = await $.process.run([bashExecutable, "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
344 if (run.exitCode !== 0) throw new Error(run.stderr || "Could not enumerate paths");
345 const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
346 const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
347 if (!found) {
348 return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
349 }
350 if (!files) {
351 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
352 }
353 return {
354 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
355 lines: rest.map((l) => l.replace(/^\.\//, "")),
356 more: Math.max(0, files - rest.length),
357 note: `Paths: ${risk.targets.join(" ")}`,
358 };
359}
360
361async function measureGit($, risk, cwd) {
362 if (risk.kind === "git-push-force") {
363 return await measurePush($, risk, cwd);
364 }
365 if (risk.kind === "git-clean") {
366 const flags = [];
367 const paths = [];
368 for (let i = 0; i < risk.args.length; i += 1) {
369 const a = risk.args[i];
370 if (a === "--") {
371 paths.push(...risk.args.slice(i + 1));
372 break;
373 }
374 if (a === "-e" || a === "--exclude") {
375 flags.push(a, risk.args[i + 1] ?? "");
376 i += 1;
377 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
378 flags.push(a);
379 } else if (/^-[a-zA-Z]+$/.test(a)) {
380 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
381 if (kept !== "-") {
382 flags.push(kept);
383 }
384 } else if (!a.startsWith("-")) {
385 paths.push(a);
386 }
387 }
388 const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
389 if (run.exitCode !== 0) {
390 return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
391 }
392 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
393 return {
394 summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
395 lines: gone.slice(0, LIST_MAX),
396 more: Math.max(0, gone.length - LIST_MAX),
397 note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
398 };
399 }
400 const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
401 if (status.exitCode !== 0) {
402 return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
403 }
404 const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
405 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
406 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
407 const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
408 return {
409 summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
410 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
411 more: Math.max(0, lost.length - LIST_MAX),
412 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
413 };
414}
415
416async function measurePush($, risk, cwd) {
417 const positional = risk.args.filter((a) => !a.startsWith("-"));
418 const remote = positional[0] ?? "origin";
419 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
420 const spec = (positional[1] ?? "").replace(/^\+/, "");
421 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
422 branch = (branch ?? "").replace(/^refs\/heads\//, "");
423 if (!branch) {
424 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
425 branch = head.stdout.trim();
426 source = "HEAD";
427 } else if (branch === "HEAD") {
428 // `git push origin HEAD` pushes the current branch to its namesake.
429 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
430 branch = head.stdout.trim();
431 source = "HEAD";
432 }
433 source = source || "HEAD";
434 const ref = `${remote}/${branch}`;
435 const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
436 if (known.exitCode !== 0) {
437 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.` };
438 }
439 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
440 const dropped = log.stdout.split("\n").filter((l) => l !== "");
441 return {
442 summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
443 lines: dropped.slice(0, LIST_MAX),
444 more: Math.max(0, dropped.length - LIST_MAX),
445 note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
446 };
447}
448
449const MIGRATION_LISTERS = {
450 django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
451 alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
452 rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
453 prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
454};
455
456async function measureMigrations($, risk, cwd) {
457 const lister = MIGRATION_LISTERS[risk.tool];
458 if (lister === undefined) {
459 return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
460 }
461 let run;
462 try {
463 run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
464 } catch (error) {
465 run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
466 }
467 if (run.exitCode !== 0) {
468 return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
469 }
470 const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
471 return {
472 summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
473 lines: pending.slice(0, LIST_MAX),
474 more: Math.max(0, pending.length - LIST_MAX),
475 note: `From ${lister.argv.join(" ")}.`,
476 };
477}
478
479function size(bytes) {
480 if (!Number.isFinite(bytes) || bytes < 1024) {
481 return `${bytes || 0} B`;
482 }
483 const units = ["KB", "MB", "GB", "TB"];
484 let n = bytes;
485 let i = -1;
486 while (n >= 1024 && i < units.length - 1) {
487 n /= 1024;
488 i += 1;
489 }
490 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
491}
492
493// ---- Drawing --------------------------------------------------------------
494
495function paneRows(report) {
496 return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
497}
498
499function draw(t, state) {
500 const { Box, Text, Button } = t;
501 const { report } = state;
502 const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: ` ${line}`, wrap: "truncate-end" }));
503 if (report.more) {
504 list.push(Text({ key: "more", dimColor: true, children: ` + ${report.more} more` }));
505 }
506 // The buttons answer the call this pane was drawn for, never whichever one is held now.
507 const decide = (choice) => () => {
508 if (state.decision === null) {
509 state.decision = choice;
510 }
511 };
512 return Box({
513 flexDirection: "column",
514 borderStyle: "round",
515 borderColor: "yellow",
516 paddingX: 1,
517 children: [
518 Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
519 Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
520 Text({ key: "sum", children: [Text({ dimColor: true, children: "Would " }), Text({ color: "red", bold: true, children: report.summary })] }),
521 Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
522 report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
523 Box({
524 key: "buttons",
525 marginTop: 1,
526 gap: 2,
527 children: [
528 Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
529 Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
530 Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
531 ],
532 }),
533 ],
534 });
535}
536