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