SLOPSHOPPER

blast-radius-plus

Hält riskante Shell-Befehle an (rm -rf, git reset --hard, Force-Push, Migrationen) und zeigt dir vorher, was sie ändern würden.

newpanebandguardtoastprocess
v1.0.0Apache-2.0updated 2026-10-09Verselo-Orbit/claude-mods/plugins/blast-radius-plus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius-plus
│ ┃ Blast Radius ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⚠ Blast Radius · rm -rf ⏺ Read(src/auth.ts) │ ┃ │ Würde löscht nichts: keine Datei passt ⎿ Read 6 lines │ ┃ │ zu build ⏺ Update(src/auth.ts) │ ┃ │ Befehl rm -rf build && git push --forc… ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ Die Pfade gibt es nicht, rm hat nichts ⎿ Denied by blast-radius-plus: Blast Radius held this comma │ ┃ │ zu löschen. │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 1: Ausführen 2: Abbrechen Claude wartet │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius · while holding a tool call
╭─────────────────────────────────────────────────────────── │ ⚠ Blast Radius · rm -rf │ Würde löscht nichts: keine Datei passt zu build │ Befehl rm -rf build && git push --force origin main │ │ Die Pfade gibt es nicht, rm hat nichts zu löschen. │ │ 1: Ausführen 2: Abbrechen Claude wartet auf deine Entsch ╰───────────────────────────────────────────────────────────
README

Blast Radius Plus

Bevor Claude einen riskanten Shell-Befehl ausführt, hält dieser Mod ihn an und zeigt dir, was er ändern würde. Du entscheidest: Ausführen oder Abbrechen.

Was es macht

BefehlWas du siehst
rm -r, rm -f, rm -rfWelche Dateien gelöscht würden, mit Anzahl und Größe
git reset --hardDateien mit nicht committeten Änderungen
git checkout -- ., git restore .Dateien mit ungestagten Änderungen
git cleanUngetrackte Dateien, die verschwinden würden (aus git clean -n)
git push --force (auch -f, --force-with-lease, +ref)Commits auf dem Remote, die der Push verwirft
manage.py migrate, db:migrate, alembic upgrade, prisma migrateOffene Migrationen

Alle anderen Befehle laufen normal durch. Steht vorher cd ordner &&, pushd/popd oder git -C ordner, misst der Mod in diesem Ordner.

Installation

/plugin marketplace add Verselo-Orbit/claude-mods
/plugin install blast-radius-plus@verselo-mods

Bedienung

  • 1 = Ausführen, 2 = Abbrechen. Abbrechen ist vorausgewählt, Enter bricht also ab.
  • In schmalen Terminals erscheint die Anzeige als Leiste über dem Eingabefeld. Dort gehen 1 und 2, solange das Eingabefeld leer ist.
  • Keine Antwort in 10 Minuten oder Esc: der Befehl wird nicht ausgeführt.

Grenzen

  • Der Mod liest den Befehlstext, er parst keine Shell vollständig. $(...), Aliase, eval, bash -c "...", xargs rm, find -delete oder Skripte, die rm aufrufen, erkennt er nicht.
  • Nur der erste riskante Teil einer Befehlszeile wird gemessen. Ausführen startet trotzdem die ganze Zeile.
  • Nur das Bash-Tool wird überwacht, keine Datei-Edits.
  • Die rm-Anzahl ist ungefähr, die Liste zeigt die ersten 10 Dateien.
  • Force-Push nutzt deinen letzten git fetch. Ohne Fetch kann er die Commits nicht auflisten.
  • Für Migrationen startet er das Status-Kommando des Tools (z. B. python3 manage.py showmigrations). Das lädt Projektcode, bevor du entscheidest.
  • Die Leiste über dem Eingabefeld kann immer nur ein Mod nutzen. Belegt ein anderer Mod sie, fehlen die Buttons. Nimm dann ein breiteres Terminal.
  • Braucht bash, git, find, du und sleep im PATH.
  • Ein Sicherheitsnetz, kein Berechtigungssystem. Für harte Sperren nimm Permission-Regeln.

Getestet auf macOS mit Claude Code 2.1.295. Windows: Fix für Windows-Pfade (cd C:\projekt && rm -rf build) eingebaut, auf Windows noch nicht getestet.

Credits

Basiert auf Blast Radius von Anthropic (Apache-2.0). Änderungen siehe NOTICE.md.

Source 1 files
hooks/blast-radius.mjs 567 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified by Verselo Systems SL, 2026: German UI, readability, fixes (see NOTICE.md)
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// ponytail: not `$.clock.sleep` - its wait is the one `$` call that still counts
16// against the 10 s budget (see HookBudget in the types), so a hold would die after
17// 10 s. Needs a `sleep` binary on PATH (Git for Windows ships one).
18//
19// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
20// spelled literally, and helpers that take `$` are top-level functions.
21
22const PANE_ID = "blast-radius";
23const POLL_SECONDS = "0.25";
24const HOLD_LIMIT_MS = 10 * 60 * 1000;
25const LIST_MAX = 10;
26
27// The call being held, or null. One at a time: Bash calls in a turn run in order.
28let held = null;
29
30export function register(on) {
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(["sleep", 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: `Ordner ${risk.dir} nicht gefunden. Darum konnte ich nicht messen, was der Befehl ändert.` }
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(["sleep", 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: wird ausgeführt");
100      return next(e);
101    }
102    const why = {
103      cancel: "the user pressed Cancel (Abbrechen)",
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}. What it would have done (as shown to the user, in German): "${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 Windows absolute path: `C:\x`, `C:/x`, `C:` or `\\server\share`. Git-Bash `/c/x` is already POSIX-absolute. */
136export function isWindowsAbsolute(path) {
137  return /^[A-Za-z]:([\\/]|$)/.test(path) || path.startsWith("\\\\");
138}
139
140/** `C:\x\y` -> `/c/x/y`, `\\srv\share` -> `//srv/share`, the way cygpath -u does on Git Bash. */
141export function toPosixPath(path) {
142  const slashed = path.replace(/\\/g, "/");
143  const drive = /^([A-Za-z]):(\/.*)?$/.exec(slashed);
144  return drive ? `/${drive[1].toLowerCase()}${drive[2] ?? "/"}` : slashed;
145}
146
147/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
148export function joinDir(dir, arg) {
149  if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/") || isWindowsAbsolute(arg)) {
150    return arg ?? "~";
151  }
152  return dir ? `${dir}/${arg}` : arg;
153}
154
155/** The first risky segment of a shell command, or null. */
156export function classify(command) {
157  let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
158  const scopes = []; // dir to restore when a ( subshell ) closes
159  const pushed = []; // pushd stack, for popd
160  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
161    const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
162    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
163    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
164    const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
165    for (let k = 0; k < opens; k += 1) {
166      scopes.push(dir);
167    }
168    const risk = classifySegment(raw, dir, pushed);
169    if (risk !== null && risk.cd === undefined) {
170      return risk;
171    }
172    if (risk !== null) {
173      dir = risk.cd; // a cd, pushd or popd moved the folder
174    }
175    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
176      dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
177    }
178  }
179  return null;
180}
181
182// Words that can come before the real command without changing what it does.
183const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
184
185/** One segment: a risk, { cd } for a folder change, or null. */
186function classifySegment(segment, dir, pushed) {
187  {
188    const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
189    while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
190      words.shift(); // leading VAR=value
191    }
192    if (words[0] === "sudo") {
193      words.shift();
194      while (words.length > 0 && words[0].startsWith("-")) {
195        const option = words.shift();
196        if (SUDO_VALUE_OPTIONS.has(option)) {
197          words.shift();
198        }
199      }
200    }
201    while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
202      words.shift();
203    }
204    if (words[0] === "nice") {
205      words.shift();
206      if (words[0] === "-n") {
207        words.splice(0, 2);
208      } else if (/^-\d+$/.test(words[0] ?? "")) {
209        words.shift();
210      }
211    }
212    const [first, ...args] = words;
213    if (first === undefined) {
214      return null;
215    }
216    const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
217    if (cmd === "cd") {
218      return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
219    }
220    if (cmd === "pushd") {
221      pushed.push(dir);
222      return { cd: joinDir(dir, args[0]) };
223    }
224    if (cmd === "popd") {
225      return { cd: pushed.length > 0 ? pushed.pop() : "-" };
226    }
227    if (cmd === "rm" || cmd.endsWith("/rm")) {
228      const flags = args.filter((a) => a.startsWith("-"));
229      const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
230      const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
231      if (recursive || force) {
232        const targets = args.filter((a) => !a.startsWith("-") || a === "-");
233        return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
234      }
235    }
236    if (cmd === "git") {
237      // Git's own options come before the subcommand; -C moves where it runs.
238      let gitDir = dir;
239      let i = 0;
240      while (i < args.length && args[i].startsWith("-")) {
241        if (args[i] === "-C" && i + 1 < args.length) {
242          gitDir = joinDir(gitDir, args[i + 1]);
243          i += 2;
244        } else if (args[i] === "-c" && i + 1 < args.length) {
245          i += 2;
246        } else {
247          i += 1;
248        }
249      }
250      const sub = args[i];
251      const rest = args.slice(i + 1);
252      if (sub === "reset" && rest.includes("--hard")) {
253        return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
254      }
255      if (sub === "clean") {
256        return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
257      }
258      if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
259        return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
260      }
261      const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
262      if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
263        return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
264      }
265    }
266    const joined = words.join(" ");
267    if (/\balembic\s+upgrade\b/.test(joined)) {
268      return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
269    }
270    if (/\bdb:migrate(?!:status\b)/.test(joined)) {
271      return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
272    }
273    if (/\bprisma\s+migrate\b/.test(joined)) {
274      return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
275    }
276    if (/\bmanage\.py\s+migrate\b/.test(joined)) {
277      return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
278    }
279    if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
280      return { kind: "migrate", tool: "unknown", label: "migrate", dir };
281    }
282  }
283  return null;
284}
285
286// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
287// The target is passed as an argument, never as source.
288// `pwd -W` (Git Bash / MSYS only) prints `C:/x`, a folder Windows can spawn in;
289// `/c/x` from `pwd -P` is not, and every measuring step after it would fail.
290// Elsewhere `pwd -W` is an invalid option, so `pwd -P` answers.
291const 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; }`;
292
293async function resolveDir($, sessionCwd, dir) {
294  if (dir === "-") {
295    return null; // `cd -` depends on the shell's history
296  }
297  if (isWindowsAbsolute(dir)) {
298    dir = await windowsToPosix($, sessionCwd, dir);
299  }
300  const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
301  const out = run.stdout.trim();
302  return run.exitCode === 0 && out !== "" ? out : null;
303}
304
305// Git Bash's own cygpath knows mount points; without it, convert by hand.
306async function windowsToPosix($, sessionCwd, dir) {
307  try {
308    const run = await $.process.run(["cygpath", "-u", dir], { cwd: sessionCwd, timeoutMs: 5000 });
309    const out = run.stdout.trim();
310    if (run.exitCode === 0 && out !== "") {
311      return out;
312    }
313  } catch {
314    // no cygpath on this machine
315  }
316  return toPosixPath(dir);
317}
318
319/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
320function tokenize(text) {
321  const words = [];
322  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
323  let m;
324  while ((m = re.exec(text)) !== null) {
325    words.push(m[1] ?? m[2] ?? m[3]);
326  }
327  return words;
328}
329
330// ---- Measuring the blast radius -------------------------------------------
331
332/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
333async function measure($, risk, cwd) {
334  try {
335    if (risk.kind === "rm") {
336      return await measureRm($, risk, cwd);
337    }
338    if (risk.kind === "migrate") {
339      return await measureMigrations($, risk, cwd);
340    }
341    return await measureGit($, risk, cwd);
342  } catch (error) {
343    return { summary: `${risk.label} (konnte ich nicht messen)`, lines: [], note: `Messen fehlgeschlagen: ${String(error?.message ?? error).slice(0, 200)}` };
344  }
345}
346
347// The paths are passed to bash as arguments, never as source, so nothing in
348// them runs. compgen -G expands a glob without command substitution.
349const RM_SCRIPT = `
350shopt -s nullglob dotglob
351paths=()
352for p in "$@"; do
353  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
354  if [[ "$p" == *[*?[]* ]]; then
355    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
356  elif [[ -e "$p" || -L "$p" ]]; then
357    paths+=("$p")
358  fi
359done
360if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
361# A relative path gets ./ in front, so find never reads a name like -delete as an action.
362for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
363files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
364kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
365echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
366find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
367`;
368
369async function measureRm($, risk, cwd) {
370  if (risk.targets.length === 0) {
371    return { summary: "rm ohne Pfade", lines: [], note: "Keine Pfade angegeben." };
372  }
373  const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
374  const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
375  const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
376  if (!found) {
377    return { summary: `löscht nichts: keine Datei passt zu ${risk.targets.join(" ")}`, lines: [], note: "Die Pfade gibt es nicht, rm hat nichts zu löschen." };
378  }
379  if (!files) {
380    return { summary: `löscht ${found} ${found === 1 ? "leeren Pfad" : "leere Pfade"} (keine Dateien darin)`, lines: [], note: `Pfade: ${risk.targets.join(" ")}` };
381  }
382  return {
383    summary: `löscht ${files} ${files === 1 ? "Datei" : "Dateien"} (ca. ${size(bytes)})`,
384    lines: rest.map((l) => l.replace(/^\.\//, "")),
385    more: Math.max(0, files - rest.length),
386    note: `Pfade: ${risk.targets.join(" ")}`,
387  };
388}
389
390async function measureGit($, risk, cwd) {
391  if (risk.kind === "git-push-force") {
392    return await measurePush($, risk, cwd);
393  }
394  if (risk.kind === "git-clean") {
395    const flags = [];
396    const paths = [];
397    for (let i = 0; i < risk.args.length; i += 1) {
398      const a = risk.args[i];
399      if (a === "--") {
400        paths.push(...risk.args.slice(i + 1));
401        break;
402      }
403      if (a === "-e" || a === "--exclude") {
404        flags.push(a, risk.args[i + 1] ?? "");
405        i += 1;
406      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
407        flags.push(a);
408      } else if (/^-[a-zA-Z]+$/.test(a)) {
409        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
410        if (kept !== "-") {
411          flags.push(kept);
412        }
413      } else if (!a.startsWith("-")) {
414        paths.push(a);
415      }
416    }
417    const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
418    if (run.exitCode !== 0) {
419      return { summary: "git clean (Probelauf fehlgeschlagen)", lines: [], note: run.stderr.trim().slice(0, 200) };
420    }
421    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
422    return {
423      summary: gone.length === 0 ? "entfernt nichts: keine ungetrackten Dateien betroffen" : `entfernt ${gone.length} ungetrackte ${gone.length === 1 ? "Datei/Ordner" : "Dateien/Ordner"}`,
424      lines: gone.slice(0, LIST_MAX),
425      more: Math.max(0, gone.length - LIST_MAX),
426      note: "Aus git clean -n. Ungetrackte Dateien sind nicht in git und lassen sich nicht wiederherstellen.",
427    };
428  }
429  const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
430  if (status.exitCode !== 0) {
431    return { summary: `${risk.label} (hier kein git-Repo?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
432  }
433  const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
434  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
435  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
436  const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
437  return {
438    summary: lost.length === 0 ? "verwirft nichts: keine offenen Änderungen" : `verwirft Änderungen in ${lost.length} ${lost.length === 1 ? "Datei" : "Dateien"}`,
439    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
440    more: Math.max(0, lost.length - LIST_MAX),
441    note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Nicht committete Änderungen lassen sich nicht wiederherstellen.` : "Aus git status --porcelain.",
442  };
443}
444
445async function measurePush($, risk, cwd) {
446  const positional = risk.args.filter((a) => !a.startsWith("-"));
447  const remote = positional[0] ?? "origin";
448  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
449  const spec = (positional[1] ?? "").replace(/^\+/, "");
450  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
451  branch = (branch ?? "").replace(/^refs\/heads\//, "");
452  if (!branch) {
453    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
454    branch = head.stdout.trim();
455    source = "HEAD";
456  } else if (branch === "HEAD") {
457    // `git push origin HEAD` pushes the current branch to its namesake.
458    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
459    branch = head.stdout.trim();
460    source = "HEAD";
461  }
462  source = source || "HEAD";
463  const ref = `${remote}/${branch}`;
464  const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
465  if (known.exitCode !== 0) {
466    return { summary: `Force-Push auf ${ref}`, lines: [], note: `Keine lokale Kopie von ${ref}, darum ist unklar, welche Commits der Push verwirft. Vorher git fetch ausführen.` };
467  }
468  const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
469  const dropped = log.stdout.split("\n").filter((l) => l !== "");
470  return {
471    summary: dropped.length === 0 ? `Force-Push auf ${ref}: verwirft keine Commits` : `Force-Push auf ${ref}: verwirft ${dropped.length} ${dropped.length === 1 ? "Commit" : "Commits"}`,
472    lines: dropped.slice(0, LIST_MAX),
473    more: Math.max(0, dropped.length - LIST_MAX),
474    note: `Commits auf ${ref}, die ${source} nicht hat (Stand: letzter Fetch).`,
475  };
476}
477
478const MIGRATION_LISTERS = {
479  django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
480  alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
481  rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
482  prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
483};
484
485async function measureMigrations($, risk, cwd) {
486  const lister = MIGRATION_LISTERS[risk.tool];
487  if (lister === undefined) {
488    return { summary: "führt Migrationen aus", lines: [], note: "Für dieses Tool kann ich offene Migrationen nicht auflisten." };
489  }
490  let run;
491  try {
492    run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
493  } catch (error) {
494    run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
495  }
496  if (run.exitCode !== 0) {
497    return { summary: `führt ${risk.label} aus`, lines: [], note: `Offene Migrationen nicht lesbar (${lister.argv.join(" ")} fehlgeschlagen).` };
498  }
499  const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
500  return {
501    summary: pending.length === 0 ? `führt ${risk.label} aus: nichts offen` : `wendet ${pending.length} offene ${pending.length === 1 ? "Migration" : "Migrationen"} an`,
502    lines: pending.slice(0, LIST_MAX),
503    more: Math.max(0, pending.length - LIST_MAX),
504    note: `Aus ${lister.argv.join(" ")}.`,
505  };
506}
507
508export function size(bytes) {
509  if (!Number.isFinite(bytes) || bytes < 1024) {
510    return `${bytes || 0} B`;
511  }
512  const units = ["KB", "MB", "GB", "TB"];
513  let n = bytes;
514  let i = -1;
515  while (n >= 1024 && i < units.length - 1) {
516    n /= 1024;
517    i += 1;
518  }
519  return `${n.toFixed(n < 10 ? 1 : 0).replace(".", ",")} ${units[i]}`;
520}
521
522// ---- Drawing --------------------------------------------------------------
523
524function paneRows(report) {
525  return Math.min(24, 11 + report.lines.length + (report.more ? 1 : 0));
526}
527
528// Layout: title, then what happens (the one line to read, bold red), then the
529// command dim, then the list, the note, and the buttons with their hotkeys.
530export function draw(t, state) {
531  const { Box, Text, Button } = t;
532  const { report } = state;
533  const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: `  • ${line}`, wrap: "truncate-end" }));
534  if (report.more) {
535    list.push(Text({ key: "more", dimColor: true, children: `  + ${report.more} weitere` }));
536  }
537  // The buttons answer the call this pane was drawn for, never whichever one is held now.
538  const decide = (choice) => () => {
539    if (state.decision === null) {
540      state.decision = choice;
541    }
542  };
543  return Box({
544    flexDirection: "column",
545    borderStyle: "round",
546    borderColor: "yellow",
547    paddingX: 1,
548    children: [
549      Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}`, wrap: "truncate-end" }),
550      Text({ key: "sum", children: [Text({ dimColor: true, children: "Würde   " }), Text({ color: "red", bold: true, children: report.summary })], wrap: "wrap" }),
551      Text({ key: "cmd", children: [Text({ dimColor: true, children: "Befehl  " }), Text({ children: state.command })], wrap: "truncate-end" }),
552      list.length > 0 ? Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }) : null,
553      report.note ? Box({ key: "note", marginTop: 1, children: [Text({ dimColor: true, italic: true, children: report.note, wrap: "wrap" })] }) : null,
554      Box({
555        key: "buttons",
556        marginTop: 1,
557        gap: 2,
558        children: [
559          Button({ key: "proceed", label: "Ausführen", hotkey: "1", plain: true, onPress: decide("proceed") }),
560          Button({ key: "cancel", label: "Abbrechen", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
561          Text({ key: "hint", dimColor: true, children: "Claude wartet auf deine Entscheidung" }),
562        ],
563      }),
564    ],
565  });
566}
567