[mod] Holds risky shell commands (rm -rf, git reset --hard, force push, git clean, migrations, find -delete, xargs rm, also inside bash -c / eval) and shows…

japan-da-man の Claude Code プラグイン(mods を含む)を管理するマーケットプレイス。
| 名前 | 内容 | | :- | :- | | japan-da-man-mods | まとめてインストール用。下のプラグインを全部入れる | | todo-mod | /todo で TODO パネルを開く。チェックを入れたタスクを Claude に依頼する | | token-weather | コンテキストの埋まり具合を天気でプロンプトの上に表示する(☀ Clear → ☁ Cloudy → ☂ Showers → ☇ Storm → ↯ Compact soon)。直近12ターンのグラフと、直前のターンの増減(▲ +98.3k last turn)付き。/weather で表示切り替え | | usage-stats | /stats で利用統計のパネルを開く。セッション数・メッセージ数・トークン数・利用日数・連続日数・よく使うモデル・日ごとのヒートマップ | | blast-radius | 危険なシェルコマンドを実行前に止め、何が消えるか・変わるかをペインに出して Proceed / Cancel で選ばせる(10分答えがなければ止める)。rm -r/-f、git reset --hard、force push、git clean、変更の破棄、git branch -D、git stash drop/clear、マイグレーション、find -delete、xargs rm。bash -c / eval の中も調べる。/blast-radius で履歴 | | replay-theater | ターンの中で Claude が行ったファイル編集を記録する。ターンが終わるとプロンプトの上に ▶ Replay: N edits が出て、r か /replay でペインを開き、編集を 1 件ずつ差分で見られる(Prev / Next / Close) | | spec-flow | 「〇〇な機能が欲しい」と話しかけるか /spec-flow:spec <要望> で、要求整理 → 影響画面確認 → 要件定義 → 機能案・UI案 を 1 段階ずつ承認しながら進める。曖昧な点はヒアリングし、影響調査と案出しはサブエージェントが行う。成果物は docs/specs/<id>/(UI 案の HTML モックは Desktop なら Artifact にも公開。--local で公開しない)。/specs でチェックリストを開き、チェックで承認・修正依頼もできる | | dev-flow | 汎用の開発フロー。skills plan(小さなタスクに分けて承認をもらってから着手)・tdd(RED → GREEN → REFACTOR)・debug(再現 → 仮説 → 検証で原因を確かめてから直す)・review(重要度つきの差分レビュー)。サブエージェント code-explorer(横断調査)・test-runner(テスト結果の要約)・reviewer(別コンテキストでレビュー、修正はしない)が長い出力をメインに持ち込まない | | dev-extras | 他の OSS から選んで取り込んだ skill(すべて MIT、出典は各 README と licenses/)。ponytail(最小の実装に絞る)・ponytail-review(作り込みすぎだけを探す)・grill-me(質問攻めで計画を詰める)・handoff(バックグラウンドエージェントに引き継ぐ)・verification-before-completion(検証してから完了と言う)・security-audit(Cloudflare 製のセキュリティ監査) | | mod-dev | skill add-mod。「〇〇する mod を作って」で雛形・テスト・登録まで行う。marketplace リポジトリでは marketplace.json / bundle / README も更新し、それ以外では単体の mod を作る |
claude plugin marketplace add japan-da-man/claude-code
claude plugin install japan-da-man-mods@japan-da-man
1つずつ入れる場合は claude plugin install <名前>@japan-da-man。
更新:
claude plugin marketplace update japan-da-man
claude plugin update <名前>@japan-da-man
bundle に増えたプラグインを入れるときは、update → install の順で実行する(update だけだと増えた分は入らず、bundle が failed to load になる):
claude plugin marketplace update japan-da-man
claude plugin update japan-da-man-mods@japan-da-man
claude plugin install japan-da-man-mods@japan-da-man
セッション内なら update のあと /reload-plugins でもよい。
インストールせずに作業中のディレクトリを読み込む(保存するとホットリロードされる):
claude --plugin-dir ./plugins/todo-mod
チェック:
claude plugin validate .
claude plugin validate ./plugins/todo-mod
テスト(tests/*.test.ts があるプラグイン):
cd plugins/<name> && claude plugin test
mod-dev を入れた状態でこのリポジトリを開き「〇〇する mod を追加して」と頼むと、skill add-mod が下の手順(雛形・テスト・marketplace / bundle / README 更新・validate)をまとめてやる。手でやる場合:
plugins/<name>/ に .claude-plugin/plugin.json と中身を置く.claude-plugin/marketplace.json の plugins にエントリを追加する(name は plugin.json と同じにする)plugins/japan-da-man-mods/.claude-plugin/plugin.json の dependencies にも名前を足すplugin.json に version を書いていないので、コミットごとに新しいバージョンとして扱われる。
mods は Claude Code v2.1.287 以降が必要。
token-weather・blast-radius・replay-theater は、Anthropic の claude-code-playground にある同名の mod(Apache-2.0)をもとに手を加えたもの。各プラグインに LICENSE を同梱し、各 hooks/register.js の先頭に元のコードの著作権表示と、こちらで変えた点を書いている。
hooks/register.js 773 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//
18// Based on mods/blast-radius in anthropics/claude-code-playground (Apache-2.0, see LICENSE).
19//
20// japan-da-man による変更:
21// - コマンドをクォートを考慮して区切る('a && b' のような引数の中の && で切らない)
22// - bash -c / sh -c / zsh -c / eval の中身も同じルールで調べる
23// - find -delete / find -exec rm(消える一覧は削除なしの find で下見)、xargs rm を捕まえる
24// - git branch -D、git stash drop / clear、パスを指定した git checkout -- / git restore を捕まえる
25// - rm の対象が変数のときや、ルート・ホーム・フォルダ全体のときは注意書きを出す
26// - 答える人がいない(claude -p など)ときは待たずに止める
27// - フック自体が失敗・時間切れになったときも止める(.catch)
28// - /blast-radius で、最近止めたコマンドとその結果を一覧する
29
30const PANE_ID = "blast-radius";
31const POLL_SECONDS = "0.25";
32const HOLD_LIMIT_MS = 10 * 60 * 1000;
33const LIST_MAX = 10;
34const HISTORY_MAX = 10;
35const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
36
37// 最近捕まえたコマンド(新しい順): { command, summary, outcome }
38let history = [];
39
40// The call being held, or null. One at a time: Bash calls in a turn run in order.
41let held = null;
42
43export function register(on) {
44 on("session.start", async ($, e, next) => {
45 const result = await next(e);
46 await $.command.register({ name: "blast-radius", description: "Blast Radius: commands it held recently, and what happened", immediate: true });
47 return result;
48 });
49
50 on("command.run", { command: "blast-radius" }, async () => {
51 if (history.length === 0) {
52 return { text: "Blast Radius hasn't held any command in this session." };
53 }
54 return { text: history.map((h) => `${h.outcome.padEnd(9)} ${h.command}\n ${h.summary}`).join("\n") };
55 });
56
57 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
58 const risk = classify(String(e.command ?? ""));
59 if (risk === null) {
60 return next(e);
61 }
62 // 答える人がいない(claude -p など)ときは、10 分待たずにすぐ止める
63 if ((await $.session.surfaces()).length === 0) {
64 remember(String(e.command), risk.label, "refused");
65 return { deny: `Blast Radius held this command and did not run it: there is no one to answer (a non-interactive session). It is a risky command (${risk.label}). Do not retry it unless the user asks you to.` };
66 }
67 // One hold at a time. If another risky call is already held (a subagent's,
68 // say), wait until it is answered. `held` is claimed with no await between
69 // the check and the claim, so two waiting calls can't both get through.
70 while (held !== null) {
71 if (next.signal.aborted) {
72 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." };
73 }
74 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
75 }
76 const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
77 held = mine;
78
79 let opened = { isPlaced: false };
80 let decision;
81 let summary = risk.label;
82 try {
83 // Measure where the command will run: the session folder, moved by any
84 // `cd dir &&` or `git -C dir` earlier in the same command line.
85 const sessionCwd = await $.session.cwd();
86 const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
87 mine.report = cwd === null
88 ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
89 : await measure($, risk, cwd);
90 summary = mine.report.summary;
91
92 opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
93 if (!opened.isPlaced) {
94 mine.where = "band";
95 }
96 $.ui.invalidate("ui.render");
97
98 const startedAt = await $.clock.now();
99 while (mine.decision === null) {
100 if (next.signal.aborted) {
101 mine.decision = "interrupted";
102 break;
103 }
104 if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
105 mine.decision = "timeout";
106 break;
107 }
108 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
109 }
110 } catch {
111 mine.decision = "error"; // anything unexpected refuses the command
112 } finally {
113 decision = mine.decision;
114 // Close this call's pane before releasing the hold, so the next call's
115 // pane can't be the one that gets closed.
116 try {
117 if (opened.isPlaced) {
118 await $.ui.close({ id: PANE_ID });
119 }
120 } catch {
121 // the pane is already gone
122 }
123 if (held === mine) {
124 held = null;
125 }
126 $.ui.invalidate("ui.render");
127 }
128
129 remember(mine.command, summary, decision === "proceed" ? "ran" : decision);
130 if (decision === "proceed") {
131 $.ui.toast("Blast Radius: running it");
132 return next(e);
133 }
134 const why = {
135 cancel: "the user pressed Cancel",
136 timeout: "no answer within 10 minutes",
137 interrupted: "the turn was interrupted",
138 error: "Blast Radius hit an error while holding it",
139 }[decision] ?? "no answer was recorded";
140 return {
141 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.`,
142 };
143 })
144 // このフック自体が失敗したり 10 秒の持ち時間を超えたりすると、Claude Code はフックを飛ばしてコマンドを実行してしまう。
145 // 安全装置なので、そのときも止める側に倒す
146 .catch(($, e, next) => ({
147 deny: next.called
148 ? `Blast Radius failed after the command had started (${next.error.message}). The command may have run.`
149 : `Blast Radius couldn't check this command (${next.error.message}), so it did not run it. Ask the user before retrying.`,
150 }));
151
152 on("ui.render", { component: "Pane" }, ($, e, next) => {
153 if (e.requestId !== PANE_ID || held === null || held.report === null) {
154 return next(e);
155 }
156 return draw($.ui.resolve(e), held);
157 });
158
159 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
160 if (held === null || held.report === null || held.where !== "band") {
161 return next(e);
162 }
163 return draw($.ui.resolve(e), held);
164 });
165}
166
167function remember(command, summary, outcome) {
168 history = [{ command, summary, outcome }, ...history].slice(0, HISTORY_MAX);
169}
170
171// ---- What counts as risky -------------------------------------------------
172
173// sudo options that take a value, so the value isn't read as the command.
174const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
175// Commands that only read, so a bare word "migrate" in them isn't a migration.
176const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
177
178/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
179function joinDir(dir, arg) {
180 if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
181 return arg ?? "~";
182 }
183 return dir ? `${dir}/${arg}` : arg;
184}
185
186/** Splits a command line on && || ; | & and newlines, outside quotes. Each segment keeps its own text. */
187export function splitSegments(command) {
188 const out = [];
189 let cur = "";
190 let quote = null;
191 for (let i = 0; i < command.length; i += 1) {
192 const c = command[i];
193 if (quote) {
194 cur += c;
195 if (c === "\\" && quote === '"' && i + 1 < command.length) {
196 cur += command[i + 1];
197 i += 1;
198 } else if (c === quote) {
199 quote = null;
200 }
201 continue;
202 }
203 if (c === "'" || c === '"') {
204 quote = c;
205 cur += c;
206 } else if (c === "\\" && i + 1 < command.length) {
207 cur += c + command[i + 1];
208 i += 1;
209 } else if (c === ";" || c === "\n" || c === "|" || (c === "&" && command[i + 1] === "&")) {
210 out.push(cur);
211 cur = "";
212 if ((c === "|" && command[i + 1] === "|") || c === "&") {
213 i += 1;
214 }
215 } else {
216 cur += c;
217 }
218 }
219 out.push(cur);
220 return out;
221}
222
223/** The first risky segment of a shell command, or null. startDir: the folder an enclosing bash -c already moved to. */
224export function classify(command, startDir = null) {
225 let dir = startDir; // where a `cd` earlier on the line moved to; null means the session folder
226 const scopes = []; // dir to restore when a ( subshell ) closes
227 const pushed = []; // pushd stack, for popd
228 for (const raw of splitSegments(command)) {
229 const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
230 // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
231 const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
232 const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
233 for (let k = 0; k < opens; k += 1) {
234 scopes.push(dir);
235 }
236 const risk = classifySegment(raw, dir, pushed);
237 if (risk !== null && risk.cd === undefined) {
238 return risk;
239 }
240 if (risk !== null) {
241 dir = risk.cd; // a cd, pushd or popd moved the folder
242 }
243 for (let k = 0; k < closes && scopes.length > 0; k += 1) {
244 dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
245 }
246 }
247 return null;
248}
249
250// Words that can come before the real command without changing what it does.
251const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
252
253/** One segment: a risk, { cd } for a folder change, or null. */
254function classifySegment(segment, dir, pushed) {
255 {
256 const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
257 while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
258 words.shift(); // leading VAR=value
259 }
260 if (words[0] === "sudo") {
261 words.shift();
262 while (words.length > 0 && words[0].startsWith("-")) {
263 const option = words.shift();
264 if (SUDO_VALUE_OPTIONS.has(option)) {
265 words.shift();
266 }
267 }
268 }
269 while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
270 words.shift();
271 }
272 if (words[0] === "nice") {
273 words.shift();
274 if (words[0] === "-n") {
275 words.splice(0, 2);
276 } else if (/^-\d+$/.test(words[0] ?? "")) {
277 words.shift();
278 }
279 }
280 const [first, ...args] = words;
281 if (first === undefined) {
282 return null;
283 }
284 const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
285 if (cmd === "cd") {
286 return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
287 }
288 if (cmd === "pushd") {
289 pushed.push(dir);
290 return { cd: joinDir(dir, args[0]) };
291 }
292 if (cmd === "popd") {
293 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
294 }
295 // bash -c '...' と eval '...' は中身を同じルールで調べる
296 if (SHELLS.has(cmd) || [...SHELLS].some((sh) => cmd.endsWith(`/${sh}`))) {
297 const c = args.findIndex((a) => /^-[A-Za-z]*c[A-Za-z]*$/.test(a));
298 return c !== -1 && args[c + 1] !== undefined ? classify(args[c + 1], dir) : null;
299 }
300 if (cmd === "eval") {
301 return args.length > 0 ? classify(args.join(" "), dir) : null;
302 }
303 if (cmd === "find") {
304 const found = parseFindDelete(args);
305 if (found !== null) {
306 return { kind: "find-delete", label: `find ${found.via}`, args: found.dryRun, isUnfiltered: found.isUnfiltered, dir };
307 }
308 return null;
309 }
310 if (cmd === "xargs") {
311 let k = 0;
312 while (k < args.length && args[k].startsWith("-")) {
313 k += XARGS_OPTS_WITH_VALUE.has(args[k]) ? 2 : 1;
314 }
315 if ((args[k] ?? "").split("/").pop() === "rm") {
316 return { kind: "xargs-rm", label: "xargs rm", dir };
317 }
318 return null;
319 }
320 if (cmd === "rm" || cmd.endsWith("/rm")) {
321 const flags = args.filter((a) => a.startsWith("-"));
322 const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
323 const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
324 if (recursive || force) {
325 const targets = args.filter((a) => !a.startsWith("-") || a === "-");
326 return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
327 }
328 }
329 if (cmd === "git") {
330 // Git's own options come before the subcommand; -C moves where it runs.
331 let gitDir = dir;
332 let i = 0;
333 while (i < args.length && args[i].startsWith("-")) {
334 if (args[i] === "-C" && i + 1 < args.length) {
335 gitDir = joinDir(gitDir, args[i + 1]);
336 i += 2;
337 } else if (args[i] === "-c" && i + 1 < args.length) {
338 i += 2;
339 } else {
340 i += 1;
341 }
342 }
343 const sub = args[i];
344 const rest = args.slice(i + 1);
345 if (sub === "reset" && rest.includes("--hard")) {
346 return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
347 }
348 if (sub === "clean") {
349 return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
350 }
351 if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
352 return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
353 }
354 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
355 // checkout は . か -- のあとのパス、restore はパスがあれば作業ツリーの変更を捨てる
356 const dash = rest.indexOf("--");
357 const paths = dash !== -1 ? rest.slice(dash + 1) : rest.filter((a) => !a.startsWith("-"));
358 const discards = sub === "restore" ? paths.length > 0 : rest.includes(".") || (dash !== -1 && paths.length > 0);
359 if ((sub === "checkout" || sub === "restore") && discards && !stagedOnly) {
360 const label = paths.length === 1 && paths[0] === "." ? `git ${sub} -- .` : `git ${sub} -- ${paths.join(" ")}`;
361 return { kind: "git-checkout", label, args: rest, paths, dir: gitDir };
362 }
363 if (sub === "branch" && (rest.includes("-D") || (rest.includes("--delete") && rest.includes("--force")))) {
364 const branches = rest.filter((a) => !a.startsWith("-"));
365 if (branches.length > 0) {
366 return { kind: "git-branch-delete", label: `git branch -D ${branches.join(" ")}`, branches, dir: gitDir };
367 }
368 }
369 if (sub === "stash" && (rest[0] === "drop" || rest[0] === "clear")) {
370 return { kind: "git-stash", label: `git stash ${rest[0]}`, action: rest[0], ref: rest[1] ?? null, dir: gitDir };
371 }
372 }
373 const joined = words.join(" ");
374 if (/\balembic\s+upgrade\b/.test(joined)) {
375 return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
376 }
377 if (/\bdb:migrate(?!:status\b)/.test(joined)) {
378 return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
379 }
380 if (/\bprisma\s+migrate\b/.test(joined)) {
381 return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
382 }
383 if (/\bmanage\.py\s+migrate\b/.test(joined)) {
384 return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
385 }
386 if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
387 return { kind: "migrate", tool: "unknown", label: "migrate", dir };
388 }
389 }
390 return null;
391}
392
393const EXEC_ACTIONS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
394// 引数を 1 つとる find のグローバルオプションと、引数なしのもの。ファイルに書き出す動作は下見では外す
395const FIND_GLOBAL_OPTS = new Set(["-maxdepth", "-mindepth"]);
396const FIND_GLOBAL_FLAGS = new Set(["-depth", "-d", "-xdev", "-mount", "-follow", "-noleaf"]);
397const FIND_WRITE_ACTIONS = new Set(["-fprint", "-fprint0", "-fls", "-fprintf"]);
398// xargs のオプションのうち、引数を 1 つとるもの
399const XARGS_OPTS_WITH_VALUE = new Set(["-n", "-I", "-L", "-P", "-d", "-s", "-E", "-a"]);
400
401/** find の引数から -delete / -exec rm を見つけ、消えるものを下見する引数を作る。削除しない find は null */
402export function parseFindDelete(args) {
403 const del = args.indexOf("-delete");
404 const execRm = args.findIndex((a, i) => EXEC_ACTIONS.has(a) && (args[i + 1] ?? "").split("/").pop() === "rm");
405 if (del === -1 && execRm === -1) {
406 return null;
407 }
408 const dryRun = [];
409 for (let k = 0; k < args.length; k += 1) {
410 const a = args[k];
411 if (a === "-delete") {
412 continue;
413 }
414 if (EXEC_ACTIONS.has(a)) {
415 while (k < args.length && args[k] !== ";" && args[k] !== "+") {
416 k += 1;
417 }
418 continue;
419 }
420 if (FIND_WRITE_ACTIONS.has(a)) {
421 k += a === "-fprintf" ? 2 : 1;
422 continue;
423 }
424 dryRun.push(a);
425 }
426 // -delete より前に絞り込みの条件がない(find . -delete -name x など)と、たどったものが全部消える
427 let isUnfiltered = false;
428 if (del !== -1) {
429 let k = args.findIndex((a) => a.startsWith("-") || a === "(" || a === "!");
430 isUnfiltered = true;
431 while (k !== -1 && k < del) {
432 if (FIND_GLOBAL_OPTS.has(args[k])) {
433 k += 2;
434 } else if (FIND_GLOBAL_FLAGS.has(args[k])) {
435 k += 1;
436 } else {
437 isUnfiltered = false;
438 break;
439 }
440 }
441 }
442 return { dryRun, isUnfiltered, via: del !== -1 ? "-delete" : "-exec rm" };
443}
444
445// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
446// The target is passed as an argument, never as source.
447const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
448
449async function resolveDir($, sessionCwd, dir) {
450 if (dir === "-") {
451 return null; // `cd -` depends on the shell's history
452 }
453 const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
454 const out = run.stdout.trim();
455 return run.exitCode === 0 && out !== "" ? out : null;
456}
457
458/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
459function tokenize(text) {
460 const words = [];
461 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
462 let m;
463 while ((m = re.exec(text)) !== null) {
464 words.push(m[1] ?? m[2] ?? m[3]);
465 }
466 return words;
467}
468
469// ---- Measuring the blast radius -------------------------------------------
470
471/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
472async function measure($, risk, cwd) {
473 try {
474 if (risk.kind === "rm") {
475 return await measureRm($, risk, cwd);
476 }
477 if (risk.kind === "migrate") {
478 return await measureMigrations($, risk, cwd);
479 }
480 if (risk.kind === "find-delete") {
481 return await measureFind($, risk, cwd);
482 }
483 if (risk.kind === "xargs-rm") {
484 return { summary: "delete the files piped into it", lines: [], note: "xargs gets its file list when the command runs, so I can't list them beforehand." };
485 }
486 return await measureGit($, risk, cwd);
487 } catch (error) {
488 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
489 }
490}
491
492// The paths are passed to bash as arguments, never as source, so nothing in
493// them runs. compgen -G expands a glob without command substitution.
494const RM_SCRIPT = `
495shopt -s nullglob dotglob
496paths=()
497for p in "$@"; do
498 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
499 if [[ "$p" == *[*?[]* ]]; then
500 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
501 elif [[ -e "$p" || -L "$p" ]]; then
502 paths+=("$p")
503 fi
504done
505if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
506# A relative path gets ./ in front, so find never reads a name like -delete as an action.
507for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
508files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
509kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
510echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
511find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
512`;
513
514const WHOLE_TREES = new Set(["/", "/*", "~", "~/", "~/*", ".", "./", "./*", "..", "../", "*"]);
515
516async function measureRm($, risk, cwd) {
517 const report = await measureRmPaths($, risk, cwd);
518 const warnings = [];
519 const variables = risk.targets.filter((t) => /[$`]/.test(t));
520 if (variables.length > 0) {
521 warnings.push(`${variables.join(" ")} ${variables.length === 1 ? "is a variable" : "are variables"}: what ${variables.length === 1 ? "it holds" : "they hold"} is only known when the command runs, so ${variables.length === 1 ? "it isn't" : "they aren't"} counted.`);
522 }
523 const wide = risk.targets.filter((t) => WHOLE_TREES.has(t));
524 if (wide.length > 0) {
525 warnings.push(`⚠ ${wide.join(" ")} is a whole folder tree.`);
526 }
527 if (warnings.length === 0) {
528 return report;
529 }
530 return { ...report, note: [...warnings, report.note].filter(Boolean).join(" ") };
531}
532
533async function measureRmPaths($, risk, cwd) {
534 if (risk.targets.length === 0) {
535 return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
536 }
537 const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
538 const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
539 const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
540 if (!found) {
541 return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
542 }
543 if (!files) {
544 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
545 }
546 return {
547 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
548 lines: rest.map((l) => l.replace(/^\.\//, "")),
549 more: Math.max(0, files - rest.length),
550 note: `Paths: ${risk.targets.join(" ")}`,
551 };
552}
553
554async function measureGit($, risk, cwd) {
555 if (risk.kind === "git-push-force") {
556 return await measurePush($, risk, cwd);
557 }
558 if (risk.kind === "git-branch-delete") {
559 const lines = [];
560 let total = 0;
561 for (const branch of risk.branches) {
562 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `HEAD..${branch}`], { cwd, timeoutMs: 15000 });
563 const commits = log.exitCode === 0 ? log.stdout.split("\n").filter((l) => l !== "") : [];
564 total += commits.length;
565 lines.push(`${branch}: ${log.exitCode !== 0 ? "no such branch" : commits.length === 0 ? "merged into HEAD" : `${commits.length} unmerged ${commits.length === 1 ? "commit" : "commits"}`}`);
566 lines.push(...commits.slice(0, LIST_MAX).map((c) => ` ${c}`));
567 }
568 return {
569 summary: total === 0 ? "delete branches with no unmerged commits" : `delete ${total} ${total === 1 ? "commit" : "commits"} that are not in HEAD`,
570 lines: lines.slice(0, LIST_MAX),
571 more: Math.max(0, lines.length - LIST_MAX),
572 note: "From git log HEAD..branch. Unmerged commits can be hard to find again once the branch is gone.",
573 };
574 }
575 if (risk.kind === "git-stash") {
576 const list = await $.process.run(["git", "stash", "list"], { cwd, timeoutMs: 15000 });
577 const all = list.stdout.split("\n").filter((l) => l !== "");
578 const gone = risk.action === "clear" ? all : all.filter((l) => l.startsWith(`${risk.ref ?? "stash@{0}"}:`));
579 return {
580 summary: gone.length === 0 ? "drop no stash entries" : `drop ${gone.length} stash ${gone.length === 1 ? "entry" : "entries"}`,
581 lines: gone.slice(0, LIST_MAX),
582 more: Math.max(0, gone.length - LIST_MAX),
583 note: "From git stash list. A dropped stash is hard to get back.",
584 };
585 }
586 if (risk.kind === "git-clean") {
587 const flags = [];
588 const paths = [];
589 for (let i = 0; i < risk.args.length; i += 1) {
590 const a = risk.args[i];
591 if (a === "--") {
592 paths.push(...risk.args.slice(i + 1));
593 break;
594 }
595 if (a === "-e" || a === "--exclude") {
596 flags.push(a, risk.args[i + 1] ?? "");
597 i += 1;
598 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
599 flags.push(a);
600 } else if (/^-[a-zA-Z]+$/.test(a)) {
601 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
602 if (kept !== "-") {
603 flags.push(kept);
604 }
605 } else if (!a.startsWith("-")) {
606 paths.push(a);
607 }
608 }
609 const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
610 if (run.exitCode !== 0) {
611 return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
612 }
613 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
614 return {
615 summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
616 lines: gone.slice(0, LIST_MAX),
617 more: Math.max(0, gone.length - LIST_MAX),
618 note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
619 };
620 }
621 const scope = risk.kind === "git-checkout" && risk.paths?.length ? ["--", ...risk.paths] : [];
622 const status = await $.process.run(["git", "status", "--porcelain", ...scope], { cwd, timeoutMs: 15000 });
623 if (status.exitCode !== 0) {
624 return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
625 }
626 const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
627 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
628 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
629 const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
630 return {
631 summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
632 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
633 more: Math.max(0, lost.length - LIST_MAX),
634 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
635 };
636}
637
638// 削除の動作を外した find を、引数のまま(シェルを通さずに)実行して一覧にする
639async function measureFind($, risk, cwd) {
640 const run = await $.process.run(["find", ...risk.args], { cwd, timeoutMs: 15000 });
641 const found = run.stdout.split("\n").filter((l) => l !== "").map((l) => l.replace(/^\.\//, ""));
642 const note = risk.isUnfiltered
643 ? "⚠ -delete comes before any test, so everything find walks is deleted. From the same find without -delete."
644 : "From the same find without -delete or -exec rm.";
645 return {
646 summary: found.length === 0 ? "delete nothing: find matches no files" : `delete ${found.length} ${found.length === 1 ? "path" : "paths"}`,
647 lines: found.slice(0, LIST_MAX),
648 more: Math.max(0, found.length - LIST_MAX),
649 note,
650 };
651}
652
653async function measurePush($, risk, cwd) {
654 const positional = risk.args.filter((a) => !a.startsWith("-"));
655 const remote = positional[0] ?? "origin";
656 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
657 const spec = (positional[1] ?? "").replace(/^\+/, "");
658 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
659 branch = (branch ?? "").replace(/^refs\/heads\//, "");
660 if (!branch) {
661 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
662 branch = head.stdout.trim();
663 source = "HEAD";
664 } else if (branch === "HEAD") {
665 // `git push origin HEAD` pushes the current branch to its namesake.
666 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
667 branch = head.stdout.trim();
668 source = "HEAD";
669 }
670 source = source || "HEAD";
671 const ref = `${remote}/${branch}`;
672 const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
673 if (known.exitCode !== 0) {
674 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.` };
675 }
676 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
677 const dropped = log.stdout.split("\n").filter((l) => l !== "");
678 return {
679 summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
680 lines: dropped.slice(0, LIST_MAX),
681 more: Math.max(0, dropped.length - LIST_MAX),
682 note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
683 };
684}
685
686const MIGRATION_LISTERS = {
687 django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
688 alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
689 rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
690 prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
691};
692
693async function measureMigrations($, risk, cwd) {
694 const lister = MIGRATION_LISTERS[risk.tool];
695 if (lister === undefined) {
696 return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
697 }
698 let run;
699 try {
700 run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
701 } catch (error) {
702 run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
703 }
704 if (run.exitCode !== 0) {
705 return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
706 }
707 const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
708 return {
709 summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
710 lines: pending.slice(0, LIST_MAX),
711 more: Math.max(0, pending.length - LIST_MAX),
712 note: `From ${lister.argv.join(" ")}.`,
713 };
714}
715
716function size(bytes) {
717 if (!Number.isFinite(bytes) || bytes < 1024) {
718 return `${bytes || 0} B`;
719 }
720 const units = ["KB", "MB", "GB", "TB"];
721 let n = bytes;
722 let i = -1;
723 while (n >= 1024 && i < units.length - 1) {
724 n /= 1024;
725 i += 1;
726 }
727 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
728}
729
730// ---- Drawing --------------------------------------------------------------
731
732function paneRows(report) {
733 return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
734}
735
736function draw(t, state) {
737 const { Box, Text, Button } = t;
738 const { report } = state;
739 const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: ` ${line}`, wrap: "truncate-end" }));
740 if (report.more) {
741 list.push(Text({ key: "more", dimColor: true, children: ` + ${report.more} more` }));
742 }
743 // The buttons answer the call this pane was drawn for, never whichever one is held now.
744 const decide = (choice) => () => {
745 if (state.decision === null) {
746 state.decision = choice;
747 }
748 };
749 return Box({
750 flexDirection: "column",
751 borderStyle: "round",
752 borderColor: "yellow",
753 paddingX: 1,
754 children: [
755 Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
756 Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
757 Text({ key: "sum", children: [Text({ dimColor: true, children: "Would " }), Text({ color: "red", bold: true, children: report.summary })] }),
758 Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
759 report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
760 Box({
761 key: "buttons",
762 marginTop: 1,
763 gap: 2,
764 children: [
765 Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
766 Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
767 Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
768 ],
769 }),
770 ],
771 });
772}
773