SLOPSHOPPER

blast-radius-ko

Anthropic의 blast-radius 샘플을 한글화하고 위험 명령 목록을 넓힌 k-mods 수정판. rm -rf, git reset --hard, force push, prisma/psql/docker의 파괴적인 명령을 멈추고 영향 범위를 먼저 보여줍니다.

newpanebandguardtoastprocess
★ 1v1.2.0Apache-2.0updated 2026-10-08SeongGwangJu/k-mods/mods/blast-radius-ko
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius-ko
│ ┃ Blast Radius ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ⚠ 위험 명령 확인 · rm -rf │ ⏺ Read(src/auth.ts) │ ┃ │ 영향 삭제할 것 없음: build와 맞는 │ ⎿ Read 6 lines │ ┃ │ 파일이 없음 │ ⏺ Update(src/auth.ts) │ ┃ │ 1: 실행 2: 취소 Enter=취소 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ 명령 rm -rf build … --force origin main │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ 경로가 없어서 rm이 지울 것이 없습니다. │ ⎿ Denied by blast-radius-ko: Blast Radius가 이 명령을 멈추고 실행하지 않 │ ┃ ╰──────────────────────────────────────────╯ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ 위험 명령 확인 · rm -rf │ │ 영향 삭제할 것 없음: build와 맞는 파일이 없음 │ │ 1: 실행 2: 취소 Enter=취소 │ │ 명령 rm -rf build && git push --force origin main │ │ 경로가 없어서 rm이 지울 것이 없습니다. │ ╰──────────────────────────────────────────────────────────╯
README

Blast Radius (한글판)

위험한 명령(rm -rf, git reset --hard, 강제 푸시, DB를 지우는 명령 등)을 Claude가 실행하기 전에 멈추고, 무엇이 사라지는지 먼저 보여줘요. 실행 또는 취소를 누르기 전까지는 아무것도 바뀌지 않아요.

설치

/plugin marketplace add SeongGwangJu/k-mods
/plugin install blast-radius-ko@k-mods

쓰는 법

따로 켤 것이 없어요. 설치하면 바로 켜져서, Claude가 아래 같은 명령을 Bash로 실행하려 할 때마다 확인창을 띄워요.

  • 실행 (단축키 1). 명령을 그대로 진행해요.
  • 취소 (단축키 2, 기본 선택). 명령을 거절해요. Claude는 거절 사유를 읽고 다른 방법을 먼저 물어봐요.
  • 10분 안에 아무 응답이 없으면 자동으로 취소돼요.

설정 (/config 또는 /plugin configure blast-radius-ko@k-mods)

설정기본값설명
palettedark확인창 색. dark는 어두운 터미널용 파스텔, theme은 Claude Code 테마 키(밝은 터미널), gray는 회색 배경(#bdbec6) 터미널용 짙은 색이에요.

어떻게 동작하나요

Claude가 Bash 도구를 부를 때마다 명령 한 줄을 먼저 읽고, 아래 목록과 비슷하면 실제로 실행되기 전에 멈춰요. 멈춘 동안 그 명령이 어디서, 무엇을 건드릴지(어떤 파일이 몇 개 지워지는지, 어떤 커밋이 사라지는지 등)를 알아낸 뒤 확인창에 보여줘요. 확인창(패널)을 띄울 자리가 없는 좁은 터미널에서는 같은 내용을 입력창 위 띠에 보여줘요.

멈추는 명령

명령확인창이 보여주는 것
rm -r, rm -f, rm -rf지워질 파일 수와 용량
git reset --hard커밋 안 된 변경 파일 목록
git checkout -- ., git restore .되돌아갈(사라질) 변경 파일 목록
git clean지워질 추적 안 되는 파일 목록
git push --force(-f, --force-with-lease, +ref 포함)강제 푸시로 사라지는 커밋 목록
prisma db push, prisma migrate reset무엇을 하는 명령인지와 왜 위험한지
psql/mysql 안의 DROP TABLE/DATABASE/SCHEMA/INDEX/VIEW, TRUNCATE실행될 SQL
docker volume rm/prune, docker system prune, compose down -v무엇이 지워지는지

git add, git push(강제 아님), prisma migrate deploy 같은 일상적인 명령은 멈추지 않아요. 하나의 명령줄 안에서 cd dir &&, pushd/popd, git -C dir로 폴더를 옮기면 그 폴더 기준으로 측정해요.

Claude Code가 세션마다 쓰는 임시 폴더(/tmp/claude-<uid>/<프로젝트>/<세션>/ 아래, macOS는 /private/tmp/...)만 지우는 rm은 멈추지 않아요. Claude가 작업 중에 만들고 지우는 파일이라 매번 묻지 않아도 돼요. 같은 명령줄에서 cd <임시 폴더> && rm -rf out처럼 들어가서 지우는 경우도 같아요. 대상 중 하나라도 다른 경로이거나, 경로에 변수($VAR)·..가 있거나, 세션 폴더보다 위(claude-<uid>/나 프로젝트 폴더)를 지우면 그대로 멈춰요.

권한 (이 mod가 내 컴퓨터에서 하는 일)

  • 측정을 위해 bash, git, find, du 같은 이미 컴퓨터에 있는 프로그램을 실행해요 (명령을 실행하는 것이 아니라 "실행하면 무엇이 바뀌는지"만 미리 알아봐요. 예: git status, find, git log). 네트워크 호출이나 외부 전송은 없어요.
  • 실행을 누르면 그제서야 원래 명령이 그대로 실행돼요.

한계

  • 셸을 완전히 파싱하지 않아요. $(...), eval, bash -c "...", xargs rm, find -delete처럼 명령을 감싸거나 대신 실행하는 경우는 못 잡아요.
  • 한 번에 하나만 잡아요. 서브에이전트 등에서 두 번째 위험한 호출이 오면 첫 번째가 끝날 때까지 대기열에서 기다려요.
  • Bash 도구만 지켜봐요. 파일 편집 등 다른 도구는 대상이 아니에요.
  • 이것은 안전망이지 권한 시스템이 아니에요. 반드시 막아야 한다면 권한 규칙을 쓰세요.
  • 더 자세한 한계(강제 푸시 목록은 마지막 git fetch 기준, rm 개수는 근사치 등)는 원본 (아래 출처)의 설명과 같습니다. 이 수정판은 분류 목록과 문구·색만 바꿨습니다.

출처·라이선스

anthropics/claude-code-playground의 claude-code/mods/blast-radius 샘플(Apache-2.0, Anthropic PBC)을 바탕으로 한 k-mods 수정판입니다. Anthropic의 저작권 표기와 라이선스는 LICENSE와 hooks/register.mjs 상단에 그대로 남겨 뒀습니다. 무엇을 고쳤는지는 CHANGES-KO.md에 전부 적어 뒀어요.

테스트한 Claude Code 버전: 2.1.291

Source 1 files
hooks/register.mjs 582 lines
1// 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// k-mods 수정본 (anthropics/claude-code-playground 샘플 기반, Apache-2.0). 그 바탕 위에 k-mods가
7// 다시 고친 점은 CHANGES-KO.md에 전부 있습니다. 요약:
8// - 파괴적인 명령만 잡습니다. 일반 마이그레이션(alembic·rails·django·migrate)은 뺐습니다.
9// - 추가: prisma db push / migrate reset, psql·mysql의 DROP·TRUNCATE, docker 볼륨·system prune, compose down -v
10// - 문구를 한글화했습니다.
11// - 색은 테마 키(warning/error)가 기본입니다. userConfig `palette`를 "gray"로 두면 회색 배경
12//   (#bdbec6)에 맞춰 짙게 조정한 고정 색(이 수정본을 처음 만든 사람의 개인 설정)을 씁니다.
13// - 실패했을 때의 처리(`.catch`)를 이름 붙은 함수(handleToolCallFailure)로 빼서 바로 단위
14//   테스트합니다. 분류 로직(classify)도 내보내 따로 테스트합니다.
15//
16// tool.call (Bash): if the command is risky, work out its blast radius, open a
17// pane with Proceed and Cancel, and hold the call until one is pressed.
18// ui.render (Pane): draws the report. If the surface won't place the pane (a
19// narrow terminal), the same report is drawn in the AbovePrompt band instead.
20//
21// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
22// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
23// a button's onPress sets the decision.
24//
25// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
26// spelled literally, and helpers that take `$` are top-level functions.
27
28const PANE_ID = "blast-radius";
29const POLL_SECONDS = "0.25";
30const HOLD_LIMIT_MS = 10 * 60 * 1000;
31const LIST_MAX = 10;
32
33// k-mods: colours by userConfig `palette`. "theme" (the default) uses Claude Code's own
34// theme keys, which follow the user's light/dark theme; "gray" reproduces the fixed hex
35// this mod's first author tuned for one grey terminal background (#bdbec6).
36const PALETTES = {
37  theme: { title: "warning", summary: "error" },
38  dark: { title: "#F0C987", summary: "#F28B9B" },
39  gray: { title: "#7c4a03", summary: "#9f1239" },
40};
41
42function paletteOf(options) {
43  return PALETTES[options.palette] ?? PALETTES.dark;
44}
45
46// The call being held, or null. One at a time: Bash calls in a turn run in order.
47let held = null;
48
49export function register(on, options) {
50  const colors = paletteOf(options);
51
52  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
53    const risk = classify(String(e.command ?? ""));
54    if (risk === null) {
55      return next(e);
56    }
57    // One hold at a time. If another risky call is already held (a subagent's,
58    // say), wait until it is answered. `held` is claimed with no await between
59    // the check and the claim, so two waiting calls can't both get through.
60    while (held !== null) {
61      if (next.signal.aborted) {
62        return { deny: "Blast Radius가 이 명령을 멈추고 실행하지 않았습니다: 턴이 중단됐습니다. 사용자가 요청하지 않으면 다시 시도하지 마세요." };
63      }
64      await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
65    }
66    const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
67    held = mine;
68
69    let opened = { isPlaced: false };
70    let decision;
71    let summary = risk.label;
72    try {
73      // Measure where the command will run: the session folder, moved by any
74      // `cd dir &&` or `git -C dir` earlier in the same command line.
75      const sessionCwd = await $.session.cwd();
76      const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
77      mine.report = cwd === null
78        ? { summary: `${risk.dir}에서 ${risk.label}`, lines: [], note: `${risk.dir} 폴더를 찾지 못해 영향 범위를 잴 수 없습니다.` }
79        : await measure($, risk, cwd);
80      summary = mine.report.summary;
81
82      opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
83      if (!opened.isPlaced) {
84        mine.where = "band";
85      }
86      $.ui.invalidate("ui.render");
87
88      const startedAt = await $.clock.now();
89      while (mine.decision === null) {
90        if (next.signal.aborted) {
91          mine.decision = "interrupted";
92          break;
93        }
94        if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
95          mine.decision = "timeout";
96          break;
97        }
98        await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
99      }
100    } catch {
101      mine.decision = "error"; // anything unexpected refuses the command
102    } finally {
103      decision = mine.decision;
104      // Close this call's pane before releasing the hold, so the next call's
105      // pane can't be the one that gets closed.
106      try {
107        if (opened.isPlaced) {
108          await $.ui.close({ id: PANE_ID });
109        }
110      } catch {
111        // the pane is already gone
112      }
113      if (held === mine) {
114        held = null;
115      }
116      $.ui.invalidate("ui.render");
117    }
118
119    if (decision === "proceed") {
120      $.ui.toast("Blast Radius: 실행합니다");
121      return next(e);
122    }
123    return { deny: denyText(decision, summary) };
124  }).catch(handleToolCallFailure);
125
126  on("ui.render", { component: "Pane" }, ($, e, next) => {
127    if (e.requestId !== PANE_ID || held === null || held.report === null) {
128      return next(e);
129    }
130    return draw($.ui.resolve(e), held, colors);
131  });
132
133  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
134    if (held === null || held.report === null || held.where !== "band") {
135      return next(e);
136    }
137    return draw($.ui.resolve(e), held, colors);
138  });
139}
140
141// k-mods: what a settled decision other than "proceed" tells Claude, pulled out to a
142// pure, exported function so the text for every decision (cancel, timeout, interrupted,
143// error, and an unrecognised one) is tested directly, with no hold, no pane, no $.
144export function denyText(decision, summary) {
145  const why = {
146    cancel: "사용자가 취소를 눌렀습니다",
147    timeout: "10분 안에 응답이 없었습니다",
148    interrupted: "턴이 중단됐습니다",
149    error: "Blast Radius가 확인 중 오류를 만났습니다",
150  }[decision] ?? "응답이 기록되지 않았습니다";
151
152  return `Blast Radius가 이 명령을 멈추고 실행하지 않았습니다: ${why}. 실행했다면: ${summary}. 사용자가 요청하지 않으면 다시 시도하지 마세요.`;
153}
154
155// k-mods: the tool.call hook's own fail-closed .catch, pulled out to a named function so
156// it can be unit-tested directly (construct a fake `next` with `.called`), rather than
157// only through a full hold. CONTRIBUTING.md: a hook that can block a call must fail closed
158// here. `next.called` is true once the main hook reached `return next(e)` — proceeding,
159// or the early pass-through for a non-risky command — so a failure there means `next`
160// itself didn't settle (it threw or timed out); the safest thing is to try it again
161// rather than deny a call that may already be running. A failure anywhere before that
162// (classifying, queueing behind another held call, measuring, or waiting for a press)
163// never reached `next`, so it denies.
164export function handleToolCallFailure($, e, next) {
165  return next.called
166    ? next(e)
167    : { deny: "Blast Radius가 위험 명령 확인 중 오류를 만나 실행하지 않았습니다. 사용자에게 확인을 요청하세요." };
168}
169
170// ---- What counts as risky -------------------------------------------------
171
172// sudo options that take a value, so the value isn't read as the command.
173const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
174
175/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
176function joinDir(dir, arg) {
177  if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
178    return arg ?? "~";
179  }
180  return dir ? `${dir}/${arg}` : arg;
181}
182
183// k-mods: Claude Code keeps per-session temp files under /tmp/claude-<uid>/<project>/<session>/
184// (/private/tmp/... on macOS). A literal path at least that deep belongs to one session,
185// so rm there skips the prompt. Variables, globs before the session folder, `.` and `..`
186// don't count: the real target can't be read from the command line.
187const CLAUDE_SCRATCH = /^\/(?:private\/)?tmp\/claude-\d+\/[^/*?[]+\/[^/*?[]+(?:\/|$)/;
188
189/** True when an rm target is a literal path inside one Claude session's temp folder. */
190export function isClaudeScratch(target) {
191  return CLAUDE_SCRATCH.test(target) && !/[$`~]/.test(target) && !target.split("/").some((part) => part === "." || part === "..");
192}
193
194/** The first risky segment of a shell command, or null. Exported for direct unit tests. */
195export function classify(command) {
196  let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
197  const scopes = []; // dir to restore when a ( subshell ) closes
198  const pushed = []; // pushd stack, for popd
199  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
200    const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
201    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
202    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
203    const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
204    for (let k = 0; k < opens; k += 1) {
205      scopes.push(dir);
206    }
207    const risk = classifySegment(raw, dir, pushed);
208    if (risk !== null && risk.cd === undefined) {
209      return risk;
210    }
211    if (risk !== null) {
212      dir = risk.cd; // a cd, pushd or popd moved the folder
213    }
214    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
215      dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
216    }
217  }
218  return null;
219}
220
221// Words that can come before the real command without changing what it does.
222const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
223
224/** One segment: a risk, { cd } for a folder change, or null. */
225function classifySegment(segment, dir, pushed) {
226  {
227    const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
228    while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
229      words.shift(); // leading VAR=value
230    }
231    if (words[0] === "sudo") {
232      words.shift();
233      while (words.length > 0 && words[0].startsWith("-")) {
234        const option = words.shift();
235        if (SUDO_VALUE_OPTIONS.has(option)) {
236          words.shift();
237        }
238      }
239    }
240    while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
241      words.shift();
242    }
243    if (words[0] === "nice") {
244      words.shift();
245      if (words[0] === "-n") {
246        words.splice(0, 2);
247      } else if (/^-\d+$/.test(words[0] ?? "")) {
248        words.shift();
249      }
250    }
251    const [first, ...args] = words;
252    if (first === undefined) {
253      return null;
254    }
255    const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
256    if (cmd === "cd") {
257      return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
258    }
259    if (cmd === "pushd") {
260      pushed.push(dir);
261      return { cd: joinDir(dir, args[0]) };
262    }
263    if (cmd === "popd") {
264      return { cd: pushed.length > 0 ? pushed.pop() : "-" };
265    }
266    if (cmd === "rm" || cmd.endsWith("/rm")) {
267      const flags = args.filter((a) => a.startsWith("-"));
268      const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
269      const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
270      if (recursive || force) {
271        const targets = args.filter((a) => !a.startsWith("-") || a === "-");
272        // k-mods: cleaning up Claude's own session temp folders isn't worth a prompt.
273        // A relative target counts only after a literal `cd /abs/...` on the same line.
274        const resolve = (t) => (t.startsWith("/") ? t : dir?.startsWith("/") ? `${dir}/${t}` : t);
275        if (targets.length > 0 && targets.map(resolve).every(isClaudeScratch)) {
276          return null;
277        }
278        return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
279      }
280    }
281    if (cmd === "git") {
282      // Git's own options come before the subcommand; -C moves where it runs.
283      let gitDir = dir;
284      let i = 0;
285      while (i < args.length && args[i].startsWith("-")) {
286        if (args[i] === "-C" && i + 1 < args.length) {
287          gitDir = joinDir(gitDir, args[i + 1]);
288          i += 2;
289        } else if (args[i] === "-c" && i + 1 < args.length) {
290          i += 2;
291        } else {
292          i += 1;
293        }
294      }
295      const sub = args[i];
296      const rest = args.slice(i + 1);
297      if (sub === "reset" && rest.includes("--hard")) {
298        return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
299      }
300      if (sub === "clean") {
301        return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
302      }
303      if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
304        return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
305      }
306      const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
307      if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
308        return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
309      }
310    }
311    const joined = words.join(" ");
312    if (/\bprisma\s+db\s+push\b/.test(joined)) {
313      return { kind: "note", label: "prisma db push", summary: "스키마를 DB에 직접 반영 (마이그레이션 기록 없이, 데이터 손실 가능)", note: "인덱스 이름이 다르면 다시 만들면서 잠금이 걸릴 수 있습니다. 공유 DB라면 migrate deploy를 쓰세요.", dir };
314    }
315    if (/\bprisma\s+migrate\s+reset\b/.test(joined)) {
316      return { kind: "note", label: "prisma migrate reset", summary: "DB를 지우고 마이그레이션을 처음부터 다시 적용 (모든 데이터 삭제)", note: "연결된 DATABASE_URL의 데이터가 전부 사라집니다.", dir };
317    }
318    if (/^(psql|mysql|sqlite3|mongosh)$/.test(cmd) || /\b(psql|mysql)\b/.test(joined)) {
319      const m = joined.match(/\b(DROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW)|TRUNCATE)\b[^;"']*/i);
320      if (m) {
321        return { kind: "note", label: m[1].toUpperCase().replace(/\s+/g, " "), summary: `SQL 실행: ${m[0].trim().slice(0, 120)}`, note: "되돌릴 수 없는 SQL입니다. 접속 대상 DB를 확인하세요.", dir };
322      }
323    }
324    if (cmd === "docker" || cmd === "docker-compose") {
325      if (/\bvolume\s+(rm|prune)\b/.test(joined) || /\bsystem\s+prune\b/.test(joined)) {
326        return { kind: "note", label: "docker 볼륨 삭제", summary: "도커 볼륨·데이터 삭제", note: "볼륨에 있던 DB 데이터 등은 복구할 수 없습니다.", dir };
327      }
328      if (/\bdown\b/.test(joined) && /(^|\s)(-v|--volumes)(\s|$)/.test(joined)) {
329        return { kind: "note", label: "compose down -v", summary: "컨테이너와 함께 볼륨 삭제", note: "볼륨에 있던 DB 데이터 등은 복구할 수 없습니다.", dir };
330      }
331    }
332  }
333  return null;
334}
335
336// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
337// The target is passed as an argument, never as source.
338const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
339
340async function resolveDir($, sessionCwd, dir) {
341  if (dir === "-") {
342    return null; // `cd -` depends on the shell's history
343  }
344  const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
345  const out = run.stdout.trim();
346  return run.exitCode === 0 && out !== "" ? out : null;
347}
348
349/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
350function tokenize(text) {
351  const words = [];
352  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
353  let m;
354  while ((m = re.exec(text)) !== null) {
355    words.push(m[1] ?? m[2] ?? m[3]);
356  }
357  return words;
358}
359
360// ---- Measuring the blast radius -------------------------------------------
361
362/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
363async function measure($, risk, cwd) {
364  try {
365    if (risk.kind === "rm") {
366      return await measureRm($, risk, cwd);
367    }
368    if (risk.kind === "note") {
369      return { summary: risk.summary, lines: [], note: risk.note };
370    }
371    return await measureGit($, risk, cwd);
372  } catch (error) {
373    return { summary: `${risk.label} (영향 범위를 재지 못함)`, lines: [], note: `재지 못함: ${String(error?.message ?? error).slice(0, 200)}` };
374  }
375}
376
377// The paths are passed to bash as arguments, never as source, so nothing in
378// them runs. compgen -G expands a glob without command substitution.
379const RM_SCRIPT = `
380shopt -s nullglob dotglob
381paths=()
382for p in "$@"; do
383  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
384  if [[ "$p" == *[*?[]* ]]; then
385    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
386  elif [[ -e "$p" || -L "$p" ]]; then
387    paths+=("$p")
388  fi
389done
390if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
391# A relative path gets ./ in front, so find never reads a name like -delete as an action.
392for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
393files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
394kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
395echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
396find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
397`;
398
399async function measureRm($, risk, cwd) {
400  if (risk.targets.length === 0) {
401    return { summary: "경로 없는 rm", lines: [], note: "펼칠 경로가 없습니다." };
402  }
403  const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
404  const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
405  const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
406  if (!found) {
407    return { summary: `삭제할 것 없음: ${risk.targets.join(" ")}와 맞는 파일이 없음`, lines: [], note: "경로가 없어서 rm이 지울 것이 없습니다." };
408  }
409  if (!files) {
410    return { summary: `빈 경로 ${found}개 삭제`, lines: [], note: `경로: ${risk.targets.join(" ")}` };
411  }
412  return {
413    summary: `파일 ${files}개 삭제 (약 ${size(bytes)})`,
414    lines: rest.map((l) => l.replace(/^\.\//, "")),
415    more: Math.max(0, files - rest.length),
416    note: `경로: ${risk.targets.join(" ")}`,
417  };
418}
419
420async function measureGit($, risk, cwd) {
421  if (risk.kind === "git-push-force") {
422    return await measurePush($, risk, cwd);
423  }
424  if (risk.kind === "git-clean") {
425    const flags = [];
426    const paths = [];
427    for (let i = 0; i < risk.args.length; i += 1) {
428      const a = risk.args[i];
429      if (a === "--") {
430        paths.push(...risk.args.slice(i + 1));
431        break;
432      }
433      if (a === "-e" || a === "--exclude") {
434        flags.push(a, risk.args[i + 1] ?? "");
435        i += 1;
436      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
437        flags.push(a);
438      } else if (/^-[a-zA-Z]+$/.test(a)) {
439        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
440        if (kept !== "-") {
441          flags.push(kept);
442        }
443      } else if (!a.startsWith("-")) {
444        paths.push(a);
445      }
446    }
447    const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
448    if (run.exitCode !== 0) {
449      return { summary: "git clean (미리 실행해보지 못함)", lines: [], note: run.stderr.trim().slice(0, 200) };
450    }
451    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
452    return {
453      summary: gone.length === 0 ? "지울 것 없음: 추적 안 되는 파일 없음" : `추적 안 되는 경로 ${gone.length}개 삭제`,
454      lines: gone.slice(0, LIST_MAX),
455      more: Math.max(0, gone.length - LIST_MAX),
456      note: "git clean -n 결과. 추적 안 되는 파일은 git에 없어서 복구할 수 없습니다.",
457    };
458  }
459  const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
460  if (status.exitCode !== 0) {
461    return { summary: `${risk.label} (git 저장소가 아닌 듯)`, lines: [], note: status.stderr.trim().slice(0, 200) };
462  }
463  const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
464  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
465  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
466  const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
467  return {
468    summary: lost.length === 0 ? "버릴 것 없음: 커밋 안 된 변경 없음" : `파일 ${lost.length}개의 커밋 안 된 변경을 버림`,
469    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
470    more: Math.max(0, lost.length - LIST_MAX),
471    note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. 커밋 안 된 변경은 복구할 수 없습니다.` : "git status --porcelain 결과.",
472  };
473}
474
475async function measurePush($, risk, cwd) {
476  const positional = risk.args.filter((a) => !a.startsWith("-"));
477  const remote = positional[0] ?? "origin";
478  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
479  const spec = (positional[1] ?? "").replace(/^\+/, "");
480  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
481  branch = (branch ?? "").replace(/^refs\/heads\//, "");
482  if (!branch) {
483    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
484    branch = head.stdout.trim();
485    source = "HEAD";
486  } else if (branch === "HEAD") {
487    // `git push origin HEAD` pushes the current branch to its namesake.
488    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
489    branch = head.stdout.trim();
490    source = "HEAD";
491  }
492  source = source || "HEAD";
493  const ref = `${remote}/${branch}`;
494  const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
495  if (known.exitCode !== 0) {
496    return { summary: `${ref}에 강제 푸시`, lines: [], note: `${ref}의 로컬 사본이 없어 사라질 커밋을 알 수 없습니다. 먼저 git fetch 하세요.` };
497  }
498  const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
499  const dropped = log.stdout.split("\n").filter((l) => l !== "");
500  return {
501    summary: dropped.length === 0 ? `${ref}에 강제 푸시: 사라지는 커밋 없음` : `${ref}에 강제 푸시: 커밋 ${dropped.length}개가 사라짐`,
502    lines: dropped.slice(0, LIST_MAX),
503    more: Math.max(0, dropped.length - LIST_MAX),
504    note: `마지막 fetch 기준, ${ref}에는 있고 ${source}에는 없는 커밋.`,
505  };
506}
507
508function 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)} ${units[i]}`;
520}
521
522// ---- Drawing --------------------------------------------------------------
523
524function paneRows(report) {
525  return Math.min(24, 8 + report.lines.length + (report.more ? 1 : 0));
526}
527
528// 버튼은 요약 바로 아래에 둔다. 좁은 화면(band)에서는 아래쪽이 잘려 버튼이 안 보이던 문제가 있었다.
529// k-mods: `colors`는 register()가 userConfig `palette`로 고른 두 값(title, summary)을 그대로
530// 받는다 — 기여 가이드대로 테마 키가 기본이고, "gray" 설정일 때만 고정 16진수 색을 쓴다.
531//
532// Exported for tests: a test draws this directly (through a small harness plugin that
533// just calls draw(t, state, colors) from its own ui.render hook) instead of through a
534// real held tool.call, which runs this same function from inside a hold loop that's
535// also polling $.process.run — the test kit resolves a concurrent $.ui.mount there only
536// after that loop's many stubbed calls settle, which is slow and timing-sensitive, not
537// a reliable way to test a pure drawing function and its button wiring.
538export function draw(t, state, colors) {
539  const { Box, Text, Button } = t;
540  const { report } = state;
541  const cap = state.where === "band" ? 3 : LIST_MAX;
542  const shown = report.lines.slice(0, cap);
543  const more = (report.more ?? 0) + (report.lines.length - shown.length);
544  const list = shown.map((line, i) => Text({ key: `l${i}`, children: `  ${tilde(line)}`, wrap: "truncate-start" }));
545  if (more > 0) {
546    list.push(Text({ key: "more", dimColor: true, children: `  외 ${more}개` }));
547  }
548  // 버튼은 그려진 호출에만 답한다. 지금 잡혀 있는 다른 호출이 아니라.
549  const decide = (choice) => () => {
550    if (state.decision === null) {
551      state.decision = choice;
552    }
553  };
554  return Box({
555    flexDirection: "column",
556    borderStyle: "round",
557    borderColor: colors.title,
558    paddingX: 1,
559    children: [
560      Text({ key: "title", bold: true, color: colors.title, children: `⚠ 위험 명령 확인 · ${state.risk.label}` }),
561      Text({ key: "sum", children: [Text({ dimColor: true, children: "영향  " }), Text({ color: colors.summary, bold: true, children: report.summary })] }),
562      Box({
563        key: "buttons",
564        gap: 2,
565        children: [
566          Button({ key: "proceed", label: "실행", hotkey: "1", plain: true, onPress: decide("proceed") }),
567          Button({ key: "cancel", label: "취소", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
568          Text({ key: "hint", dimColor: true, children: "Enter=취소", wrap: "truncate-end" }),
569        ],
570      }),
571      Text({ key: "cmd", children: [Text({ dimColor: true, children: "명령  " }), Text({ bold: true, children: tilde(state.command) })], wrap: "truncate-middle" }),
572      Box({ key: "list", flexDirection: "column", children: list }),
573      report.note ? Text({ key: "note", dimColor: true, italic: true, children: tilde(report.note), wrap: state.where === "band" ? "truncate-end" : "wrap" }) : null,
574    ],
575  });
576}
577
578// 홈 경로를 ~로 줄인다. 정확한 사용자명은 몰라도 /Users/<이름>/ 형태면 줄인다.
579function tilde(text) {
580  return String(text).replace(/\/(?:Users|home)\/[^/\s]+/g, "~");
581}
582