Holds a risky Bash command, shows what it would change, and lets you proceed or cancel.

基于 Claude Code 分发产物恢复的非官方 TypeScript/TSX 源码工作区,并持续维护本地 CLI、Agent、Workflow、OpenAI/Codex 兼容和调试能力。
本项目不是 Anthropic 官方产品、官方源码分发或官方 Claude Code release,也未获得 Anthropic 背书。公开包只分发 launcher 与对应平台二进制,不包含本仓库源码。
@esonhugh/claude-code本仓库包含从公开 bundle/source map 恢复的上游代码和本地维护改动。上游归属与本地维护者身份应分别理解;完整本地变更以 CHANGELOG.md 为准。
本项目主要用于:
>=18dist/cli.jsbuilt-claude当前发布版本为 2.1.294;发布历史、具体变更和验收边界统一记录在 CHANGELOG.md。
公开包会根据 process.platform 和 process.arch 加载对应的平台二进制 optional dependency:
bun add --global @esonhugh/claude-code
claude --version
公开包仅包含 launcher 和平台二进制,不包含恢复源码。当前 release workflow 的普通平台包覆盖 Linux x64、Linux arm64、macOS arm64 和 Windows x64;不应假设其他 OS/architecture 组合(例如 macOS x64)已有产物。Linux 还会构建 baseline x64 和 musl 验证产物,但 baseline 不作为普通 npm 平台包。若当前平台没有对应 binary package,launcher 会返回缺少平台包的错误。
git clone <repository-url>
cd claude-code
bun install
make build
./built-claude --version
make build 构建平台 binary,并生成根目录下的 built-claude。
只构建 JavaScript CLI:
bun run build
bun ./dist/cli.js --version
bun ./dist/cli.js --help
每项功能的作用、配置写法与可选值、不同配置下的终端效果见对应文档;完整索引见 docs/features/,变更历史见 CHANGELOG.md。设置写入用户级或项目级 settings.json,使用时与现有 JSON 合并。
| 领域 | 功能 | 说明 |
|---|---|---|
| 模型与 Provider | OpenAI / Codex provider | 把 Claude Code 的模型换成 OpenAI(GPT / Codex):用 ChatGPT 账号登录或 API key,工具、界面、命令照常使用 |
| 模型自动发现与 Gateway | 走自己的 API 网关或 OpenAI 兼容服务时,自动把网关提供的模型列进 /model,不用手打模型名 | |
| 请求韧性:重试、回退模型与默认模型 | API 过载或断线时自动重试;主模型一直过载就换备用模型;可以让无人值守任务一直等到额度恢复 | |
| Effort 档位 | 控制 Claude 每次「想多深」:低档快而省,高档慢而仔细;可全局设、按会话切,也可给子代理单独指定 | |
| Codex Apps | 用 ChatGPT 账号登录 OpenAI provider 时,直接使用 ChatGPT 里连好的 App(GitHub、Gmail 等):输入 @codex-app: 点名即可 | |
| Agent 与编排 | Agent 与后台任务 | Claude 把一部分工作交给「子代理」在后台做,你继续聊;可以查看、停止、续跑,也可以自己定义专用子代理 |
| Agent Teams | 让 Claude 组一个小团队:一个负责人带几个队友并行干活,互相发消息、共享任务清单 | |
| Dynamic Workflow | 让 Claude 写一段编排脚本,一次调度多个子 Agent 分阶段、并行地干活;/workflows 看进度、保存成可复用的流程 | |
| 背景会话、daemon 与 agents 视图 | 把任务交给后台跑、终端立刻空出来;claude agents 一屏看谁在等你、谁在干活、谁做完了 | |
| 独立会话通信 | 同一台机器上开着的几个 Claude 会话可以互相发现、互发消息:一个写代码,一个做 review,完成了喊一声 | |
| 权限与安全 | 权限规则、权限模式与 Safe mode | Claude 什么时候要先问你:用规则提前放行或禁止,用模式整体调松或调紧;受保护路径在任何模式下都会问 |
| 自动模式(Auto mode) | 不用一条条点确认:由分类器判断每个操作是否安全,安全的直接做,危险的拦下;可以用一句话加自己的规则 | |
| Hooks 事件、匹配与输出校验 | 在 Claude 做某件事之前或之后自动跑你的脚本:拦住危险命令、改完自动格式化、开局注入上下文 | |
| Bash 沙箱 | 让 Claude 跑的命令只能写项目目录、只能连你允许的域名、读不到你指定的密钥;开了以后命令可以不再逐条确认 | |
| 工具 | Terminal 工具 | 给 Claude 一个能持续交互的终端:开 REPL、操作 TUI、按 Ctrl+C,跑完自动通知 |
| Monitor 工具 | 让 Claude 在后台盯着日志或命令输出,有新内容就自动叫醒它,不用反复 sleep 轮询 | |
| 定时任务:Cron、/loop 与 ScheduleWakeup | 让 Claude 按时间自动做事:「每 5 分钟看一下构建」「工作日早上 9 点汇总 PR」;/loop 一句话设定 | |
| 终端程序状态(OSC 7501) | 支持的终端能在标签页上显示 Claude 正在忙、在等你批准、还是已经做完,不用切回来看 | |
| 上下文与记忆 | 记忆:auto memory 与 SQLite FTS5 检索 | Claude 把跨会话有用的事(项目约定、你的偏好)记到文件里,下次自动带上;可改成 SQLite 全文检索,按需查找 |
| 自动压缩窗口 | 对话太长时把早期内容压成摘要;/autocompact 决定多长时压缩(按模型分别保存),/compact 手动压缩 | |
| Prompt context 与 Tool search | 让每次请求更省 token:提示词分层利于缓存,MCP 工具用到时才加载,大量同类工具只发一行汇总 | |
| 用量、成本与状态 | /usage 看本次会话花了多少钱、用了多少 token;/stats 看长期使用习惯;恢复会话后成本继续累计 | |
| 界面与交互 | 会话命令 | 本 fork 新增或补齐的斜杠命令:/recap 回顾、/cd 换目录、/focus 精简显示、/autocompact 调压缩时机、/usage 用量等 |
| 自主 Goal | 给 Claude 一个「做到才算完」的条件(如「所有测试通过」),每轮结束时自动检查,没达成就继续干 | |
| 快捷键与 Vim 模式 | 自己改快捷键、回合中途排队或立即插话、在输入框里用 Vim 的选择和括号跳转 | |
| 自定义 UI / Branding 与 Status line | 改界面上显示的产品名、换掉 Logo 的 ASCII 图,用自己的脚本定制底部状态栏 | |
| /diff 视图 | 全屏右侧 diff 面板:会话 / 未提交 / 分支三种基准、按轮次回看、把单个文件 diff 附到下一条提示 | |
| 调试日志 | 遇到问题时打开详细日志,看清楚 Claude Code 在后台做了什么、请求发到了哪 | |
| 遥测与反馈 | 默认不向 Anthropic 发送使用数据,/feedback 也随之隐藏;需要时一个环境变量打开 | |
| 远程执行 | Direct Connect | 在一台机器上 claude server,在另一处用 claude cc://... 连过去:工具在 server 上执行,界面和权限确认在你这边 |
| SSH Remote | claude ssh 主机 让 Claude 在远程 Linux 机器上读写文件、跑命令,界面和权限确认留在本机;API 凭据不离开本机 | |
| 插件与扩展 | 插件与 Marketplace | 从插件市场一键安装命令、skill、agent、hook 和 MCP;团队可以自建 marketplace,装完 /reload-plugins 立即生效 |
| MCP 配置与认证 | 接入外部工具服务器(数据库、浏览器、内部 API 等);配置错一条只跳过那一条,/mcp 查看状态、重连、登录 | |
| Skills | 把一套做事步骤写进 SKILL.md,用 /名字 调用,或让 Claude 在合适时自动使用;/skill-doctor 找出从没用过却占提示的 skill | |
| Channels | 让 Telegram、Discord 或你自己写的 MCP server 把消息推进正在运行的会话,还能在手机上远程批准工具调用 | |
| Mods / Function Hooks | 用一个 JS/TS 文件改造 Claude 本身:加命令、拦截或改写工具调用、在界面上加状态条和侧边面板(基线官方 2.1.303) | |
| Mods 远程客户端(stream-json) | 给桌面 / IDE 等外部界面用的协议:通过 stream-json 让 Mod 把界面画到你自己的前端上,并把点击、输入传回来 | |
| Agent SDK 与 stream-json 控制协议 | 在自己的程序里调用 Claude:官方 Agent SDK(TS / Python)可以直接驱动本 fork 的 CLI;补齐了 SDK 0.3 会用到的控制请求 | |
| VS Code 插件 | 让官方 VS Code 插件改用本 fork 运行:面板里的 /status、/usage、/memory 等对话框都能正常打开 | |
| 开发与发布 | 构建、发布与终端验收工具 | 给贡献者:怎么从源码构建出可运行的 binary、怎么和官方版本对照、发布流程做了什么 |
修改 TypeScript 后至少运行:
bunx tsc --noEmit --pretty false
bun run build
bun run lint
bun run audit:missing
git diff --check
涉及 binary、CLI 入口或交互行为时,再运行:
make build
./built-claude --version
./built-claude --help
本项目仍包含恢复阶段的类型边界。修复类型时应优先使用精确 interface、discriminated union、unknown、assertion function 和 type guard,避免为消除局部错误而放宽全局核心类型。
docs/README.md — 文档中心、分类和推荐阅读顺序。docs/features/ — 每项功能的说明、配置与终端效果。docs/guides/build.md — 环境要求、构建、运行、验证和故障排查。docs/guides/secondary-development.md — 恢复源码的二次开发流程与约束。docs/guides/recovery-workspace.md — 源码恢复背景、目录和恢复方法。docs/guides/agent-development.md — Agent、Tool、Hook 和 Plugin 入门。docs/architecture/runtime-internals.md — CLI、REPL、查询循环、工具和 Agent 主链路。docs/architecture/agent.md — AgentTool、runAgent、前后台运行与恢复。docs/architecture/agent-team.md — Team、共享任务、消息和协调者生命周期。docs/architecture/workflow-orchestration.md — Workflow、Agent、Skill、Hook、权限和隔离。docs/architecture/plugin-marketplace.md — Plugin 与 Marketplace 模型。docs/architecture/agent-sdk-exports.md — Agent SDK 导出面和扩展 API。docs/design/ssh-local-ui-coherence.md — SSH transcript、远端补全、settings/Auth 边界与交互一致性设计。docs/research/prompt-context-optimization.md — Prompt context 构成、精简结果、测量方法与剩余风险。docs/research/claude-mods.md — Mods 契约、生命周期、官方证据与本地兼容性边界。mods-test.md — Mods 收口测试方案、验收结果与证据索引。docs/design/workflow-runtime-parity.md — Workflow runtime parity 的行为和证据边界。docs/workflows/ — Workflow 示例、兼容性材料和测试 fixture。docs/research/ — 二进制分析、CCH、Workflow 和 Codex 对比研究。docs/archive/ — 已完成计划、测试计划和历史实施记录。CHANGELOG.md — 权威的本地变更与发布记录。研究和归档文档描述的是特定时间点,不应直接视为当前行为保证;当前使用方法以 README、docs/features/ 和源码为准,历史与验收记录以 CHANGELOG 为准。
本仓库用于授权的研究、调试和二次开发。恢复或修改后的 binary 不应未经独立安全、遥测、更新和权限审查直接用于生产环境。
请勿提交 .env、~/.codex/auth.json、API key、OAuth token、cookie、证书或其他私有配置。涉及外部 provider、插件、MCP、Workflow 和自动化任务时,应先确认权限范围及其对本地或共享环境的影响。
hooks/blast-radius.mjs 158 lines1// Blast Radius: hold a risky Bash command, show what it would change, and let the person decide.
2
3const PANE = "blast-radius";
4const MAX_LINES = 12;
5const SHELL_BREAK = /^(?:;|&&|\|\||\||&|>|>>|<)$/;
6
7// The call being held, if any. Module state is fine here: a hold never outlives the hook call.
8let held = null;
9
10export function register(on) {
11 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
12 const risk = classify(String(e.command ?? ""));
13 if (risk === null) return next(e); // everything else runs as normal
14
15 // One hold at a time; a second risky call waits for the first decision.
16 while (held !== null && !next.signal.aborted) await $.process.run(["sleep", "0.25"]);
17 if (next.signal.aborted) return { deny: "Blast Radius: the turn was interrupted before this command was reviewed." };
18
19 const report = await measure($, risk, await $.session.cwd());
20 const call = { command: String(e.command), risk, report, decision: null, where: "pane" };
21 held = call;
22 try {
23 const opened = await $.ui.open({ id: PANE, title: "Blast Radius", focus: true });
24 if (!opened.isPlaced) call.where = "band"; // too narrow for a pane: draw above the prompt
25 $.ui.invalidate("ui.render");
26 while (call.decision === null && !next.signal.aborted) {
27 await $.process.run(["sleep", "0.25"]); // time inside $ calls doesn't count against the hook's time limit
28 }
29 } finally {
30 held = null;
31 await $.ui.close({ id: PANE });
32 $.ui.invalidate("ui.render");
33 }
34 if (call.decision === "proceed") return next(e); // let it run
35 const why = call.decision === "cancel" ? "the user pressed Cancel" : "the turn was interrupted";
36 return { deny: `Blast Radius held this command: ${why}. It would have: ${report.summary}.` };
37 });
38
39 on("ui.render", { component: "Pane", requestId: PANE }, ($, e, next) => {
40 if (held === null || held.where !== "pane") return next(e);
41 const { Box, Text, Button } = $.ui.resolve(e);
42 return card(Box, Text, Button, held, { columns: e.props.bodyColumns });
43 });
44
45 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
46 if (held === null || held.where !== "band" || e.props.hasSurvey) return next(e);
47 const { Box, Text, Button } = $.ui.resolve(e);
48 return card(Box, Text, Button, held, { columns: e.props.bodyColumns, border: true });
49 });
50}
51
52function decide(call, decision) {
53 return () => {
54 if (call.decision === null) call.decision = decision;
55 };
56}
57
58function card(Box, Text, Button, call, { columns, border = false }) {
59 const width = Math.max(20, (columns ?? 80) - 4);
60 return Box({
61 flexDirection: "column",
62 paddingX: 1,
63 ...(border ? { borderStyle: "round", borderColor: "yellow" } : {}),
64 children: [
65 Text({ color: "yellow", bold: true, children: `⚠ ${call.risk.label}` }),
66 Text({ children: clip(`$ ${call.command}`, width) }),
67 Text({ children: `It would ${call.report.summary}.` }),
68 ...call.report.lines.map((line) => Text({ dimColor: true, children: clip(` ${line}`, width) })),
69 Box({
70 flexDirection: "row",
71 gap: 2,
72 children: [
73 Button({ key: "proceed", label: "Proceed", hotkey: "1", onPress: decide(call, "proceed") }),
74 Button({ key: "cancel", label: "Cancel", hotkey: "2", onPress: decide(call, "cancel") }),
75 ],
76 }),
77 ],
78 });
79}
80
81function classify(command) {
82 const words = command.trim().split(/\s+/);
83 for (let i = 0; i < words.length; i += 1) {
84 const at = i === 0 || SHELL_BREAK.test(words[i - 1]) ? words[i] : null;
85 if (at === "rm") {
86 const args = untilBreak(words, i + 1);
87 const flags = args.filter((a) => a.startsWith("-") && a !== "--");
88 if (flags.some((f) => /^-[a-zA-Z]*[rR]/.test(f) || f === "--recursive")) {
89 return { kind: "remove", label: "rm -r", targets: args.filter((a) => !a.startsWith("-")) };
90 }
91 }
92 if (at === "git") {
93 const args = untilBreak(words, i + 1);
94 const sub = args.find((a) => !a.startsWith("-"));
95 if (sub === "reset" && args.includes("--hard")) return { kind: "reset", label: "git reset --hard", targets: [] };
96 if (sub === "clean") return { kind: "clean", label: "git clean", targets: args.filter((a) => /^-[a-zA-Z]*[dx]/.test(a)) };
97 if (sub === "push" && args.some((a) => a === "-f" || a.startsWith("--force"))) {
98 return { kind: "force-push", label: "git push --force", targets: [] };
99 }
100 }
101 }
102 if (/\b(?:manage\.py\s+migrate|rails\s+db:migrate|rake\s+db:migrate|prisma\s+migrate\s+deploy|alembic\s+upgrade)\b/.test(command)) {
103 return { kind: "migrate", label: "database migration", targets: [] };
104 }
105 return null;
106}
107
108function untilBreak(words, from) {
109 const out = [];
110 for (let i = from; i < words.length && !SHELL_BREAK.test(words[i]); i += 1) out.push(words[i]);
111 return out;
112}
113
114// Each report comes from the tools' own dry runs. Arguments go in as an argv array,
115// so nothing in a path is run as shell code.
116async function measure($, risk, cwd) {
117 const run = (argv) => $.process.run(argv, { cwd });
118 if (risk.kind === "remove") {
119 const files = [];
120 for (const target of risk.targets) {
121 const found = await run(["find", target, "-type", "f"]);
122 if (found.exitCode === 0) files.push(...lines(found.stdout));
123 }
124 return report(`delete ${files.length} file${files.length === 1 ? "" : "s"} under ${risk.targets.join(" ") || "nothing"}`, files);
125 }
126 if (risk.kind === "reset") {
127 const status = await run(["git", "status", "--porcelain"]);
128 const changed = lines(status.stdout).filter((l) => !l.startsWith("??"));
129 return report(`discard uncommitted changes in ${changed.length} file${changed.length === 1 ? "" : "s"}`, changed);
130 }
131 if (risk.kind === "clean") {
132 const flags = risk.targets.join("").replace(/[^dx]/g, "");
133 const dry = await run(["git", "clean", `-n${flags}`]);
134 const paths = lines(dry.stdout).map((l) => l.replace(/^Would remove /, ""));
135 return report(`remove ${paths.length} untracked path${paths.length === 1 ? "" : "s"}`, paths);
136 }
137 if (risk.kind === "force-push") {
138 const log = await run(["git", "log", "--oneline", "HEAD..@{upstream}"]);
139 const commits = log.exitCode === 0 ? lines(log.stdout) : [];
140 return report(`overwrite ${commits.length} commit${commits.length === 1 ? "" : "s"} on the remote`, commits);
141 }
142 return report("run database migrations, which have no dry run here", []);
143}
144
145function report(summary, all) {
146 const shown = all.slice(0, MAX_LINES);
147 if (all.length > shown.length) shown.push(`… and ${all.length - shown.length} more`);
148 return { summary, lines: shown };
149}
150
151function lines(text) {
152 return String(text ?? "").split("\n").map((l) => l.trimEnd()).filter(Boolean);
153}
154
155function clip(text, width) {
156 return text.length > width ? `${text.slice(0, width - 1)}…` : text;
157}
158types/index.d.ts 6 lines1export type BlastRiskKind = "remove" | "reset" | "clean" | "force-push" | "migrate";
2
3export type BlastRisk = { kind: BlastRiskKind; label: string; targets: string[] };
4
5export type BlastReport = { summary: string; lines: string[] };
6