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

五个 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、最近 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
Claude 调用下面这些 Bash 命令时,mod 会先拦住,空跑一遍算出影响范围,再打开一个面板:
| 命令 | 怎么算影响范围 |
|---|---|
rm -r / rm -rf | find + du:会删哪些文件、总大小 |
git reset --hard | git status --porcelain:会丢掉哪些未提交改动 |
git clean | git 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的脚本都拦不住。它是安全网,不是权限系统;要硬性禁止请用权限规则。
一轮对话里,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 的分类明细做成输入框上方的一张卡片:
◆ context,右边是已用 / 窗口、自动压缩阈值和一个百分比徽章(<50% 薄荷、<80% 柠檬杏、其余珊瑚红)╭──────────────────────────────────────────────────────────────╮
│ ◆ ▾ 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 在侧边打开面板,列出项目 .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-radiushooks/blast-radius.mjs 219 lines1// 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}
219types/index.d.ts 15 lines1export 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