SLOPSHOPPER

blast-radius

Holds risky Bash commands, shows what they would change, and waits for Proceed or Cancel.

newpanebandguardprocess
v0.1.0no licenseupdated 2026-10-02TLOBillyQ/claude-mods/blast-radius
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ Blast Radius ✕ › fix the failing auth test and add an audit log call │ ┃ ⚠ Recursive delete │ ┃ $ rm -rf build && git push --force origin… ⏺ Read(src/auth.ts) │ ┃ It would delete 3 files (0 KB) ⎿ Read 6 lines │ ┃ package.json ⏺ Update(src/auth.ts) │ ┃ README.md ⎿ Added 2 lines, removed 1 line │ ┃ src ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Proceed ] [ Cancel ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius
⚠ Recursive delete $ rm -rf build && git push --force origin main It would delete 3 files (0 KB) package.json README.md src [ Proceed ] [ Cancel ]
README

claude-mods

五个 Claude Code mod,打包成一个本地 marketplace my-mods。

Mod作用
token-weather在输入框上方用“天气预报”显示上下文窗口用量
blast-radius拦下有风险的 Bash 命令,先显示会影响什么,再等你选 Proceed 或 Cancel
replay-theater记录上一轮的文件编辑,逐个 diff 回放
context-bar在输入框上方用一张卡片显示 /context 的分类明细,可折叠
agents-panel侧边面板列出项目的 .claude/agents,每个带 run 按钮

需要 Claude Code 2.1.287 或更高版本(claude --version)。

安装

git clone https://github.com/TLOBillyQ/claude-mods.git
claude plugin marketplace add ./claude-mods
claude plugin install token-weather@my-mods --scope user
claude plugin install blast-radius@my-mods --scope user
claude plugin install replay-theater@my-mods --scope user
claude plugin install context-bar@my-mods --scope user
claude plugin install agents-panel@my-mods --scope user

装好后新开一个会话才会加载。

停用某一个:

claude plugin disable blast-radius@my-mods

拉取新版本后更新:

claude plugin marketplace update my-mods

只想临时试用、不安装:

claude --plugin-dir ./claude-mods/token-weather --plugin-dir ./claude-mods/blast-radius --plugin-dir ./claude-mods/replay-theater --plugin-dir ./claude-mods/context-bar --plugin-dir ./claude-mods/agents-panel

token-weather

每轮结束后读取上下文用量,在输入框上方画一行:天气图标、百分比、已用/总 token、最近 12 轮的迷你图,以及上一轮增加了多少。

用量预报
< 25%☀ Clear
25–49%☁ Cloudy
50–74%☂ Showers
75–89%☇ Storm
≥ 90%↯ Compact soon
☀  Clear  18% of context  36.1k / 200k   last turns ▁▂▅  ▲ +12.4k last turn

blast-radius

Claude 调用下面这些 Bash 命令时,mod 会先拦住,空跑一遍算出影响范围,再打开一个面板:

命令怎么算影响范围
rm -r / rm -rffind + du:会删哪些文件、总大小
git reset --hardgit status --porcelain:会丢掉哪些未提交改动
git cleangit clean -n:会删哪些未跟踪文件
强推(--force、--force-with-lease、+ref)git log HEAD..<upstream>:远端会丢哪些提交
数据库迁移(Django、Rails、Prisma、Alembic、Knex、Sequelize、Flyway)Django 用 showmigrations --plan 列出待执行迁移,其他只做提示

按 1 Proceed,命令照原样执行;按 2 Cancel,Claude 会收到拒绝和原因。关掉面板或按 Esc 等于 Cancel。终端太窄放不下面板时,同样的内容会画在输入框上方。

它只读命令文本,$(…)、别名、调用 rm 的脚本都拦不住。它是安全网,不是权限系统;要硬性禁止请用权限规则。

replay-theater

一轮对话里,mod 记录每次成功的 Edit、MultiEdit 和 Write(Write 会在写入前读取旧内容,所以 diff 是真实的)。回合结束后,输入框上方会出现提示:

↻ 3 edits across 2 files last turn  r: Replay  or /replay

运行 /replay(或聚焦提示条后按 r)打开回放面板:顶部是步骤条,下面是当前这一步的文件和 diff,p / n / c 对应 Prev / Next / Close,也可以点步骤条上的编号直接跳转。它只观察,不会阻止或修改任何编辑。

context-bar

把 /context 的分类明细做成输入框上方的一张卡片:

  • 标题行:◆ context,右边是已用 / 窗口、自动压缩阈值和一个百分比徽章(<50% 薄荷、<80% 柠檬杏、其余珊瑚红)
  • 一条连续的彩色条,每个类别有一个固定的中间亮度粉彩色,深色和浅色终端背景上都看得清;剩余空间是浅灰色,深墨色竖线标出压缩点。条按格宽用色块画,desktop 上也和卡片等宽
  • 图例:每个类别的 token 数和占比
╭──────────────────────────────────────────────────────────────╮
│ ◆ ▾ context                90k of 1M · compacts at 987k  9%  │
│ ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│  │
│ ■ system prompt 4.2k 0%   ■ tools 17k 2%   ■ messages 0 0%   │
│ ■ free 897k                                                  │
╰──────────────────────────────────────────────────────────────╯

默认折叠,只显示标题行;点 ▸ context 展开,再点 ▾ context 收起。手动选择在当前会话的后续回复和热重载时保持;键盘可以 ctrl+x tab 聚焦后回车。/context-bar 显示或隐藏整张卡片。会话开始和每轮结束后刷新,用 summary 模式在本地估算,不额外发 token 计数请求;按需加载的 deferred 工具不占窗口,不显示。终端窄于 60 列时不画图例。

agents-panel

/agents-panel 在侧边打开面板,列出项目 .claude/agents/*.md 里定义的所有 agent:名字、模型、一行描述,右侧 ▶ run 按钮用该 agent 启动一个子 agent(提示词为“Run your default task on this project.”)。再次运行 /agents-panel 关闭。只读项目目录,不读 ~/.claude/agents;每次打开面板时重新读取。

开发

每个 mod 的结构:

<mod>/
├── .claude-plugin/plugin.json   清单,types 指向类型契约
├── hooks/hooks.json             指向 hooks 模块
├── hooks/<mod>.mjs              导出 register(on)
├── types/index.d.ts             $.state 的类型契约
└── tests/<mod>.test.ts

校验和测试:

claude plugin validate ./blast-radius
claude plugin test ./blast-radius
Source 2 files
hooks/blast-radius.mjs 219 lines
1// Blast Radius: hold a risky Bash command, show what it would change, and wait for a decision.
2
3const PANE = "blast-radius";
4const MAX_LINES = 40;
5
6// What the pane draws. Held by the host, so the drawing survives a hot reload of this file.
7const held = { plugin: "blast-radius", key: "held" };
8
9// The decision the waiting tool.call reads. A module variable, because a hook's
10// own dispatch doesn't see $.state writes made after it started.
11let pending = null;
12
13export function register(on) {
14  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
15    const command = String(e.command ?? "");
16    const risk = classify(command);
17    if (risk === null) return next(e); // everything else runs as normal
18
19    const report = await measure($, risk, await $.session.cwd());
20    const ticket = (pending = { decision: null });
21    await $.state.set(held, { command, risk: risk.label, ...report, where: "pane", decision: null });
22    const isPlaced = await $.ui
23      .open({ id: PANE, title: "Blast Radius", focus: true, closeOnEscape: true })
24      .then(async () => (await $.ui.panes()).some((p) => p.id === PANE && p.isPlaced))
25      .catch(() => false);
26    if (!isPlaced) {
27      // Too narrow for a pane: draw the same report above the prompt.
28      await $.ui.close({ id: PANE }).catch(() => {});
29      await $.state.set(held, { command, risk: risk.label, ...report, where: "band", decision: null });
30    }
31
32    while (ticket.decision === null && !next.signal?.aborted) {
33      await $.process.run(["sleep", "0.25"]); // time inside $ calls doesn't count against the hook's time limit
34    }
35    const { decision } = ticket;
36    if (pending === ticket) pending = null;
37    await $.state.set(held, null);
38    await $.ui.close({ id: PANE }).catch(() => {});
39
40    if (decision === "proceed") return next(e); // let it run
41    if (decision === null) return { deny: "Blast Radius held this command and the user interrupted." };
42    return { deny: `Blast Radius held this command: the user pressed Cancel. It would have: ${report.summary}.` };
43  });
44
45  on("ui.close", { requestId: PANE }, ($, e, next) => {
46    if (e.origin === "person") decide("cancel"); // closing the pane by hand counts as Cancel
47    return next(e);
48  });
49
50  on("ui.render", { component: "Pane" }, async ($, e, next) => {
51    if (e.requestId !== PANE) return next(e);
52    const { value } = await $.state.get(held);
53    if (!value) return next(e);
54    return drawReport($, e, value, false);
55  });
56
57  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
58    const { value } = await $.state.get(held);
59    if (e.props.hasSurvey || !value || value.where !== "band") return next(e);
60    return drawReport($, e, value, true);
61  });
62}
63
64function decide(decision) {
65  if (pending && pending.decision === null) pending.decision = decision;
66}
67
68function drawReport($, e, value, isBand) {
69  const { Box, Text, Button } = $.ui.resolve(e);
70  const children = [
71    Text({ color: "yellow", bold: true, children: `⚠ ${value.risk}` }),
72    Text({ children: `$ ${value.command}`, wrap: "truncate-end" }),
73    Text({ bold: true, children: `It would ${value.summary}` }),
74    ...value.lines.map((line) => Text({ dimColor: true, children: `  ${line}`, wrap: "truncate-end" })),
75    Box({
76      flexDirection: "row",
77      gap: 2,
78      marginTop: 1,
79      children: [
80        Button({ key: "proceed", label: "Proceed", hotkey: "1", onPress: () => decide("proceed") }),
81        Button({ key: "cancel", label: "Cancel", hotkey: "2", onPress: () => decide("cancel") }),
82      ],
83    }),
84  ];
85  return Box(
86    isBand
87      ? { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children }
88      : { flexDirection: "column", paddingX: 1, children },
89  );
90}
91
92// ---------- classifying ----------
93
94export function classify(command) {
95  for (const segment of segments(command)) {
96    const argv = words(segment);
97    const [cmd, ...args] = stripPrefix(argv);
98    if (!cmd) continue;
99    const flags = args.filter((a) => a.startsWith("-"));
100    const operands = args.filter((a) => !a.startsWith("-"));
101    const has = (short, long) =>
102      flags.some((f) => f === long || (!f.startsWith("--") && short && f.slice(1).includes(short)));
103
104    if (cmd === "rm" && has("r", "--recursive") && operands.length) {
105      return { kind: "rm", label: "Recursive delete", targets: operands };
106    }
107    if (cmd === "git") {
108      const [sub, ...rest] = operands;
109      if (sub === "reset" && args.includes("--hard")) return { kind: "reset", label: "git reset --hard" };
110      if (sub === "clean") {
111        const cleanFlags = flags.filter((f) => f !== "-f" && f !== "--force").map((f) => f.replace("f", ""));
112        return { kind: "clean", label: "git clean", flags: cleanFlags.filter((f) => f !== "-"), paths: rest };
113      }
114      if (sub === "push" && flags.some((f) => /^(-f|--force|--force-with-lease.*|--force-if-includes)$/.test(f))) {
115        return { kind: "push", label: "Force push", remote: rest[0], branch: rest[1] };
116      }
117      if (sub === "push" && rest.some((r) => r.startsWith("+"))) {
118        return { kind: "push", label: "Force push", remote: rest[0], branch: rest[1]?.slice(1) };
119      }
120    }
121    if (isMigration(cmd, args)) return { kind: "migrate", label: "Database migration", argv: [cmd, ...args] };
122  }
123  return null;
124}
125
126function isMigration(cmd, args) {
127  const line = [cmd, ...args].join(" ");
128  return (
129    /manage\.py\s+migrate\b/.test(line) ||
130    /\b(rails|rake)\s+db:(migrate|rollback|reset|drop)/.test(line) ||
131    /\bprisma\s+(migrate\s+(deploy|reset|dev)|db\s+push)/.test(line) ||
132    /\balembic\s+(upgrade|downgrade)\b/.test(line) ||
133    /\b(knex|sequelize)\b.*\bmigrate\b/.test(line) ||
134    /\bflyway\s+(migrate|clean)\b/.test(line)
135  );
136}
137
138function segments(command) {
139  return command.split(/&&|\|\||;|\||\n/).map((s) => s.trim()).filter(Boolean);
140}
141
142function stripPrefix(argv) {
143  let i = 0;
144  while (i < argv.length && (/^\w+=/.test(argv[i]) || ["sudo", "command", "time", "nice"].includes(argv[i]))) i++;
145  return argv.slice(i);
146}
147
148function words(segment) {
149  const out = [];
150  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
151  let m;
152  while ((m = re.exec(segment))) out.push(m[1] ?? m[2] ?? m[3]);
153  return out;
154}
155
156// ---------- measuring (dry runs only) ----------
157
158async function measure($, risk, cwd) {
159  const run = async (argv) => {
160    try {
161      return await $.process.run(argv, { cwd, timeoutMs: 15_000 });
162    } catch {
163      return { exitCode: -1, stdout: "", stderr: "" };
164    }
165  };
166  const nonEmpty = (text) => text.split("\n").filter((l) => l.trim());
167  const cap = (lines) =>
168    lines.length > MAX_LINES ? [...lines.slice(0, MAX_LINES), `… and ${lines.length - MAX_LINES} more`] : lines;
169
170  switch (risk.kind) {
171    case "rm": {
172      const files = [];
173      let kb = 0;
174      for (const target of risk.targets) {
175        const found = await run(["find", target, "-type", "f"]);
176        files.push(...nonEmpty(found.stdout));
177        const du = await run(["du", "-sk", target]);
178        kb += parseInt(du.stdout, 10) || 0;
179      }
180      if (!files.length) return { summary: `delete ${risk.targets.join(", ")} (nothing found there)`, lines: [] };
181      return { summary: `delete ${files.length} file${files.length === 1 ? "" : "s"} (${size(kb)})`, lines: cap(files) };
182    }
183    case "reset": {
184      const status = await run(["git", "status", "--porcelain"]);
185      const tracked = nonEmpty(status.stdout).filter((l) => !l.startsWith("??"));
186      if (!tracked.length) return { summary: "discard nothing: no uncommitted changes to tracked files", lines: [] };
187      return { summary: `discard uncommitted changes in ${tracked.length} file${tracked.length === 1 ? "" : "s"}`, lines: cap(tracked) };
188    }
189    case "clean": {
190      const dry = await run(["git", "clean", "-n", ...risk.flags, ...(risk.paths.length ? ["--", ...risk.paths] : [])]);
191      const lines = nonEmpty(dry.stdout).map((l) => l.replace(/^Would remove /, ""));
192      return { summary: lines.length ? `remove ${lines.length} untracked path${lines.length === 1 ? "" : "s"}` : "remove nothing", lines: cap(lines) };
193    }
194    case "push": {
195      const upstream = risk.remote && risk.branch ? `${risk.remote}/${risk.branch.split(":").pop()}` : "@{u}";
196      const lost = await run(["git", "log", "--oneline", `HEAD..${upstream}`]);
197      if (lost.exitCode !== 0) return { summary: `overwrite ${upstream} (could not compare: ${lost.stderr.trim() || "no upstream"})`, lines: [] };
198      const lines = nonEmpty(lost.stdout);
199      return { summary: lines.length ? `drop ${lines.length} commit${lines.length === 1 ? "" : "s"} from ${upstream}` : `overwrite ${upstream} (no commits lost)`, lines: cap(lines) };
200    }
201    case "migrate": {
202      if (/manage\.py/.test(risk.argv.join(" "))) {
203        const python = risk.argv.find((a) => /python/.test(a)) ?? "python";
204        const plan = await run([python, "manage.py", "showmigrations", "--plan"]);
205        const pending = nonEmpty(plan.stdout).filter((l) => l.includes("[ ]")).map((l) => l.replace("[ ]", "").trim());
206        return { summary: pending.length ? `apply ${pending.length} migration${pending.length === 1 ? "" : "s"}` : "apply or roll back migrations", lines: cap(pending) };
207      }
208      return { summary: "change the database schema (no dry run available for this tool)", lines: [] };
209    }
210  }
211  return { summary: "run a risky command", lines: [] };
212}
213
214function size(kb) {
215  if (kb >= 1024 * 1024) return `${+(kb / 1024 / 1024).toFixed(1)} GB`;
216  if (kb >= 1024) return `${+(kb / 1024).toFixed(1)} MB`;
217  return `${kb} KB`;
218}
219
types/index.d.ts 15 lines
1export type BlastRadiusHeld = {
2  command: string;
3  risk: string;
4  summary: string;
5  lines: string[];
6  where: "pane" | "band";
7  decision: "proceed" | "cancel" | null;
8};
9
10declare module "claude-code" {
11  interface PluginState {
12    "blast-radius": { held: BlastRadiusHeld | null };
13  }
14}
15