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 one-line band above the Claude Code prompt, a roomy Cockpit panel beside the conversation, and seven community mods, installed as one plugin. The band shows only what you glance at. The panel has everything else: prompt-cache countdown, context, plan limits with their reset times, session cost, your next tasks and project shortcuts. Everything that spends tokens or redraws your screen starts off.
◔ 42m ctx 72.5k Next Write docs/specs/PLAN-2.md for the phase 2 slices Continue Details
The band is drawn with Claude's own text and buttons, so it takes the app's fonts and theme, truncates the task by itself, and cannot flash or resize. Details (or /cockpit) opens the panel as a tab in the right-hand side panel.
<img src="docs/images/band.png" alt="The band above the prompt in the Claude desktop app: working, context 704.8k at 70 percent, the next task, and a Details button" width="720">
<img src="docs/images/panel.png" alt="The Cockpit panel in the side panel: prompt cache with a green bar, context at 70 percent, plan limits with reset times, session cost, next up and shortcuts" width="320">
These are screenshots of the real desktop app, showing the dark theme.
| Plugin | What it does | Default here | Origin |
|---|---|---|---|
| cockpit | The band, the Cockpit panel, next-task list, Continue / What's next / Compact buttons, guards, and a cheat-sheet skill | on | this repo (MIT) |
| cache-tax | Shows the rewrite price before a cold send; opt-in keep-warm for the prompt cache | warns, never drops; keep-warm off | karanb192/cache-tax |
claude-reflect (reflect-mod) | Notices corrections and offers to save them as rules in CLAUDE.md | paused | BayramAnnakov/claude-reflect |
| skins | Redraws the transcript: tool rows, diff cards, table and code cards, spinner | off, its own band off | hellosverre/claude-skins |
| filetree | IDE-style file tree in the sidebar with git status and read/write activity | opens with /filetree (does not dock by itself) | data-goblin/claude-code-filetree |
| replay-theater | Step through the last turn's file edits, one diff at a time | on | anthropics/claude-code-playground |
| blast-radius | Holds rm -r, git reset --hard, git clean, force push and migrations until you press Proceed | on | anthropics/claude-code-playground |
| savvy-progress | Progress bar and agents panel for flows and subagents | idle until something reports | JohnnyVizz/claude-kit |
The seven community mods are vendored here at pinned commits, with small patches (listed in each folder's SOURCE.md) so they start quiet and stack above each other instead of replacing each other's band. Their original licences are kept.
Needs Claude Code 2.1.287 or later.
/plugin marketplace add cGradying/claude-code-cockpit
/plugin install cockpit@janvin-mods
or from a terminal:
claude plugin marketplace add cGradying/claude-code-cockpit
claude plugin install cockpit@janvin-mods --scope user
cockpit lists the seven mods as dependencies, so installing it installs them all. If a dependency does not install by itself, install it by name, for example claude plugin install skins@janvin-mods. Then restart Claude Code (or the desktop app) and open a new session.
To remove everything: /plugin, pick cockpit, uninstall, and do the same for each mod you no longer want. Each one is independent.
The band. One line above the prompt: the cache countdown (◔ 42m, green, then amber, then red), context, and the next task. The 5-hour and weekly limits join the line only once they pass 60%, and 9router down shows only if it was up and stopped. Buttons sit at the right; click them, or focus the band (ctrl+x then tab) and press the key.
| Button | Key | Does |
|---|---|---|
| Continue | c | Sends "continue with the next task" for the first open line of .claude/NEXT.md, and asks Claude to check it off when done. Hidden while a turn runs. |
| Details | d | Opens the Cockpit panel. |
| Compact | k | Appears past 70% context. Runs /compact and tells it to keep the plan, open tasks, PRs, decisions and open bugs. |
The Cockpit panel. One tab in the side panel with room for each number: prompt cache (time left, a bar, what happens if it lapses), context (tokens, percent, window size, Compact), plan limits (each with a bar and when it resets), this session (cost, 9router), your next five tasks (Continue, What's next?), and your project shortcuts. Bars are solid boxes sized in percent and follow the app's light or dark theme. Open it with Details or /cockpit.
Guards that run quietly. A shell command carrying a Claude or Anthropic co-author line is refused. A prompt containing a pasted API key is held once (press Enter again to send it anyway). Prompts about UI or design get a hidden nudge toward the impeccable skill if you have it.
Commands. /cockpit, /next, /keepwarm, /cache-tax, /reflect-pause, /reflect-queue, /skin, /filetree, /replay, /agents-info. Details in docs/COMMANDS.md; inside Claude Code, the cockpit:help skill prints the same cheat sheet.
Three things in the bundle can cost tokens or change your screen, so they start off:
/keepwarm. The cold-send guard only warns by default: it shows the rewrite price and still sends your message./reflect-pause turns it on. remember: <rule> works either way and needs no model./skin noir turns it on.How to change each: docs/CONFIGURATION.md.
NEXT.md, quick.json./filetree (set autoOpen to on to dock it at session start). It needs a wide window, and upstream says it does not load in WSL sessions of the desktop app.The code in cockpit/ is MIT, Copyright (c) 2026 cGradying (see LICENSE). Each vendored mod keeps its own licence file and authorship: the two Anthropic mods are Apache-2.0, the rest are MIT. See THIRD_PARTY_NOTICES.md. Thanks to the authors of all seven.
hooks/blast-radius.mjs 532 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 // cockpit patch: stack above whatever other plugins draw in this slot instead of replacing it.
117 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
118 const below = await next(e);
119 if (held === null || held.report === null || held.where !== "band") {
120 return below;
121 }
122 const { Box } = $.ui.resolve(e);
123 return Box({ flexDirection: "column", children: [draw($.ui.resolve(e), held), below].filter(Boolean) });
124 });
125}
126
127// ---- What counts as risky -------------------------------------------------
128
129// sudo options that take a value, so the value isn't read as the command.
130const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
131// Commands that only read, so a bare word "migrate" in them isn't a migration.
132const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
133
134/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
135function joinDir(dir, arg) {
136 if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
137 return arg ?? "~";
138 }
139 return dir ? `${dir}/${arg}` : arg;
140}
141
142/** The first risky segment of a shell command, or null. */
143function classify(command) {
144 let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
145 const scopes = []; // dir to restore when a ( subshell ) closes
146 const pushed = []; // pushd stack, for popd
147 for (const raw of command.split(/&&|\|\||;|\||\n/)) {
148 const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
149 // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
150 const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
151 const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
152 for (let k = 0; k < opens; k += 1) {
153 scopes.push(dir);
154 }
155 const risk = classifySegment(raw, dir, pushed);
156 if (risk !== null && risk.cd === undefined) {
157 return risk;
158 }
159 if (risk !== null) {
160 dir = risk.cd; // a cd, pushd or popd moved the folder
161 }
162 for (let k = 0; k < closes && scopes.length > 0; k += 1) {
163 dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
164 }
165 }
166 return null;
167}
168
169// Words that can come before the real command without changing what it does.
170const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
171
172/** One segment: a risk, { cd } for a folder change, or null. */
173function classifySegment(segment, dir, pushed) {
174 {
175 const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
176 while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
177 words.shift(); // leading VAR=value
178 }
179 if (words[0] === "sudo") {
180 words.shift();
181 while (words.length > 0 && words[0].startsWith("-")) {
182 const option = words.shift();
183 if (SUDO_VALUE_OPTIONS.has(option)) {
184 words.shift();
185 }
186 }
187 }
188 while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
189 words.shift();
190 }
191 if (words[0] === "nice") {
192 words.shift();
193 if (words[0] === "-n") {
194 words.splice(0, 2);
195 } else if (/^-\d+$/.test(words[0] ?? "")) {
196 words.shift();
197 }
198 }
199 const [first, ...args] = words;
200 if (first === undefined) {
201 return null;
202 }
203 const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
204 if (cmd === "cd") {
205 return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
206 }
207 if (cmd === "pushd") {
208 pushed.push(dir);
209 return { cd: joinDir(dir, args[0]) };
210 }
211 if (cmd === "popd") {
212 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
213 }
214 if (cmd === "rm" || cmd.endsWith("/rm")) {
215 const flags = args.filter((a) => a.startsWith("-"));
216 const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
217 const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
218 if (recursive || force) {
219 const targets = args.filter((a) => !a.startsWith("-") || a === "-");
220 return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
221 }
222 }
223 if (cmd === "git") {
224 // Git's own options come before the subcommand; -C moves where it runs.
225 let gitDir = dir;
226 let i = 0;
227 while (i < args.length && args[i].startsWith("-")) {
228 if (args[i] === "-C" && i + 1 < args.length) {
229 gitDir = joinDir(gitDir, args[i + 1]);
230 i += 2;
231 } else if (args[i] === "-c" && i + 1 < args.length) {
232 i += 2;
233 } else {
234 i += 1;
235 }
236 }
237 const sub = args[i];
238 const rest = args.slice(i + 1);
239 if (sub === "reset" && rest.includes("--hard")) {
240 return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
241 }
242 if (sub === "clean") {
243 return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
244 }
245 if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
246 return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
247 }
248 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
249 if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
250 return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
251 }
252 }
253 const joined = words.join(" ");
254 if (/\balembic\s+upgrade\b/.test(joined)) {
255 return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
256 }
257 if (/\bdb:migrate(?!:status\b)/.test(joined)) {
258 return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
259 }
260 if (/\bprisma\s+migrate\b/.test(joined)) {
261 return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
262 }
263 if (/\bmanage\.py\s+migrate\b/.test(joined)) {
264 return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
265 }
266 if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
267 return { kind: "migrate", tool: "unknown", label: "migrate", dir };
268 }
269 }
270 return null;
271}
272
273// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
274// The target is passed as an argument, never as source.
275const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
276
277async function resolveDir($, sessionCwd, dir) {
278 if (dir === "-") {
279 return null; // `cd -` depends on the shell's history
280 }
281 const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
282 const out = run.stdout.trim();
283 return run.exitCode === 0 && out !== "" ? out : null;
284}
285
286/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
287function tokenize(text) {
288 const words = [];
289 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
290 let m;
291 while ((m = re.exec(text)) !== null) {
292 words.push(m[1] ?? m[2] ?? m[3]);
293 }
294 return words;
295}
296
297// ---- Measuring the blast radius -------------------------------------------
298
299/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
300async function measure($, risk, cwd) {
301 try {
302 if (risk.kind === "rm") {
303 return await measureRm($, risk, cwd);
304 }
305 if (risk.kind === "migrate") {
306 return await measureMigrations($, risk, cwd);
307 }
308 return await measureGit($, risk, cwd);
309 } catch (error) {
310 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
311 }
312}
313
314// The paths are passed to bash as arguments, never as source, so nothing in
315// them runs. compgen -G expands a glob without command substitution.
316const RM_SCRIPT = `
317shopt -s nullglob dotglob
318paths=()
319for p in "$@"; do
320 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
321 if [[ "$p" == *[*?[]* ]]; then
322 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
323 elif [[ -e "$p" || -L "$p" ]]; then
324 paths+=("$p")
325 fi
326done
327if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
328# A relative path gets ./ in front, so find never reads a name like -delete as an action.
329for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
330files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
331kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
332echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
333find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
334`;
335
336async function measureRm($, risk, cwd) {
337 if (risk.targets.length === 0) {
338 return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
339 }
340 const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
341 const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
342 const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
343 if (!found) {
344 return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
345 }
346 if (!files) {
347 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
348 }
349 return {
350 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
351 lines: rest.map((l) => l.replace(/^\.\//, "")),
352 more: Math.max(0, files - rest.length),
353 note: `Paths: ${risk.targets.join(" ")}`,
354 };
355}
356
357async function measureGit($, risk, cwd) {
358 if (risk.kind === "git-push-force") {
359 return await measurePush($, risk, cwd);
360 }
361 if (risk.kind === "git-clean") {
362 const flags = [];
363 const paths = [];
364 for (let i = 0; i < risk.args.length; i += 1) {
365 const a = risk.args[i];
366 if (a === "--") {
367 paths.push(...risk.args.slice(i + 1));
368 break;
369 }
370 if (a === "-e" || a === "--exclude") {
371 flags.push(a, risk.args[i + 1] ?? "");
372 i += 1;
373 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
374 flags.push(a);
375 } else if (/^-[a-zA-Z]+$/.test(a)) {
376 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
377 if (kept !== "-") {
378 flags.push(kept);
379 }
380 } else if (!a.startsWith("-")) {
381 paths.push(a);
382 }
383 }
384 const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
385 if (run.exitCode !== 0) {
386 return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
387 }
388 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
389 return {
390 summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
391 lines: gone.slice(0, LIST_MAX),
392 more: Math.max(0, gone.length - LIST_MAX),
393 note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
394 };
395 }
396 const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
397 if (status.exitCode !== 0) {
398 return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
399 }
400 const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
401 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
402 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
403 const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
404 return {
405 summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
406 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
407 more: Math.max(0, lost.length - LIST_MAX),
408 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
409 };
410}
411
412async function measurePush($, risk, cwd) {
413 const positional = risk.args.filter((a) => !a.startsWith("-"));
414 const remote = positional[0] ?? "origin";
415 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
416 const spec = (positional[1] ?? "").replace(/^\+/, "");
417 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
418 branch = (branch ?? "").replace(/^refs\/heads\//, "");
419 if (!branch) {
420 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
421 branch = head.stdout.trim();
422 source = "HEAD";
423 } else if (branch === "HEAD") {
424 // `git push origin HEAD` pushes the current branch to its namesake.
425 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
426 branch = head.stdout.trim();
427 source = "HEAD";
428 }
429 source = source || "HEAD";
430 const ref = `${remote}/${branch}`;
431 const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
432 if (known.exitCode !== 0) {
433 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.` };
434 }
435 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
436 const dropped = log.stdout.split("\n").filter((l) => l !== "");
437 return {
438 summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
439 lines: dropped.slice(0, LIST_MAX),
440 more: Math.max(0, dropped.length - LIST_MAX),
441 note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
442 };
443}
444
445const MIGRATION_LISTERS = {
446 django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
447 alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
448 rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
449 prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
450};
451
452async function measureMigrations($, risk, cwd) {
453 const lister = MIGRATION_LISTERS[risk.tool];
454 if (lister === undefined) {
455 return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
456 }
457 let run;
458 try {
459 run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
460 } catch (error) {
461 run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
462 }
463 if (run.exitCode !== 0) {
464 return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
465 }
466 const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
467 return {
468 summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
469 lines: pending.slice(0, LIST_MAX),
470 more: Math.max(0, pending.length - LIST_MAX),
471 note: `From ${lister.argv.join(" ")}.`,
472 };
473}
474
475function size(bytes) {
476 if (!Number.isFinite(bytes) || bytes < 1024) {
477 return `${bytes || 0} B`;
478 }
479 const units = ["KB", "MB", "GB", "TB"];
480 let n = bytes;
481 let i = -1;
482 while (n >= 1024 && i < units.length - 1) {
483 n /= 1024;
484 i += 1;
485 }
486 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
487}
488
489// ---- Drawing --------------------------------------------------------------
490
491function paneRows(report) {
492 return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
493}
494
495function draw(t, state) {
496 const { Box, Text, Button } = t;
497 const { report } = state;
498 const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: ` ${line}`, wrap: "truncate-end" }));
499 if (report.more) {
500 list.push(Text({ key: "more", dimColor: true, children: ` + ${report.more} more` }));
501 }
502 // The buttons answer the call this pane was drawn for, never whichever one is held now.
503 const decide = (choice) => () => {
504 if (state.decision === null) {
505 state.decision = choice;
506 }
507 };
508 return Box({
509 flexDirection: "column",
510 borderStyle: "round",
511 borderColor: "yellow",
512 paddingX: 1,
513 children: [
514 Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
515 Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
516 Text({ key: "sum", children: [Text({ dimColor: true, children: "Would " }), Text({ color: "red", bold: true, children: report.summary })] }),
517 Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
518 report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
519 Box({
520 key: "buttons",
521 marginTop: 1,
522 gap: 2,
523 children: [
524 Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
525 Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
526 Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
527 ],
528 }),
529 ],
530 });
531}
532