环境搭建与启动链
从 ccb 命令到终端里出现交互界面,这一路代码怎么跑?今天贴真实源码追一遍完整启动链,并把项目在本地跑起来。昨天(Day 01)建立了模块地图,今天顺着地图走一遍"从敲命令到界面亮起",为 Day 03 讲循环搭好舞台。
ccb,就像店长早上开店营业:① 先按总电闸(那行必须最先跑的 performance shim);② 前台看一眼顾客要啥——只想看营业时间牌子的(--version)根本不用开整个店,指一下就走(fast-path);③ 真要进店消费才逐个通电(init/setup 一次性初始化);④ 最后拉开卷帘门、亮起招牌(Ink 渲染出 REPL 界面)迎客。记住"开店四步:合电闸→看需求→通电→开门迎客",今天全通。今天的目标
Day 01 我们知道了真正的入口是 src/entrypoints/cli.tsx。今天沿着这条线一路追到"REPL 界面渲染出来",你会看清:怎么装 Bun/build/dev;cli.tsx 那个"按优先级短路"的快速分发器;main() → init() → setup() → launchRepl() 主链;以及为什么构建要拆成几百个 chunk。全程贴真实代码。
装 Bun 与跑起来
项目跑在 Bun 上(.tool-versions 锁 bun 1.3.13,package.json:24 要求 bun >= 1.3.0)。四步:
curl -fsSL https://bun.sh/install | bash # 1. 装 Bun(没有的话)
cd claude-code && bun install # 2. 装依赖(monorepo 一次装全部)
bun run build # 3. 构建 → 产出 dist/cli.js + bin
./dist/cli-node.js # 4. 跑(或 node dist/cli-node.js)
Bun.build、bun:ffi)。但它构建时会"打补丁"让产物node/bun 都能跑(L08 讲),所以 ccb(node)和 ccb-bun(bun)两个入口都可用(Day 01 讲的 bin 映射)。dev 模式:不构建直接跑源码
读源码调试时不用每次 build。bun run dev(scripts/dev.ts)用 Bun 的 -d flag 注入编译期常量后直接跑 src/entrypoints/cli.tsx:
bun run dev # 直接跑源码,全部 feature 默认开,版本号显示 888
bun run dev --version # 试试:会打印 888 而不是 2.8.3
bun run dev:inspect # 带调试器
其它常用脚本:bun test(测试)、biome check(lint+格式)、bun run typecheck(类型检查)、bun run precheck(提交前三合一)。
入口 cli.tsx 顶部:最先跑的几行有讲究
打开 src/entrypoints/cli.tsx,最顶上几行(cli.tsx:5 起):
- 第一行必须是 performance shim(
cli.tsx:5):替换globalThis.performance,防止 Bun 底层 JSC 引擎里某个 C++ Vector 无限增长(内存泄漏)。顺序敏感,必须最先执行。 - MACRO 运行时兜底(
cli.tsx:11):dev 模式没有构建注入的VERSION等常量,这里给兜底。 - 设若干环境变量(
cli.tsx:40)。
fast-path 分发器:能短路就不加载(真实代码)
ccb 都无脑加载 5640 行的 main.tsx + 整个交互 UI,那你只是想 ccb --version 看个版本号,也得等它把"整个店"通电开门——又慢又费内存。能不能"看牌子的顾客不用进店"?cli.tsx 的 main()(cli.tsx:76)是一个"按优先级短路"的巨型分发器。看开头的真实代码——--version 是怎么做到"零额外加载"的:
async function main(): Promise<void> {
const args = process.argv.slice(2); // 取命令行参数
// Fast-path for --version/-v: zero module loading needed
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
console.log(`${MACRO.VERSION} (Claude Code)`); // MACRO.VERSION 构建时内联
return; // ← 直接返回,什么都不加载
}
// 其它路径才加载启动分析器
const { profileCheckpoint } = await import('../utils/startupProfiler.js');
// ... 十几个子命令分支,每个都用 await import() 按需加载
}
process.argv.slice(2) = "命令行里你敲的参数"(跳过前两个固定项)。MACRO.VERSION 是构建时写死进代码的版本号常量(不是运行时读文件)——所以打印它连文件都不用读。关键:--version 分支里没有一个 import,处理完直接 return。后面十几个分支(--dump-system-prompt / --claude-in-chrome-mcp / daemon / --bg …)几乎每个都用动态 import(await import('...'))——用到才加载。默认路径(普通启动)最后才加载重量级的 main.tsx:
// 默认路径(cli.tsx 末尾):才真正加载 5640 行的 main.tsx
const { main: cliMain } = await import('../main.jsx');
await cliMain();
ccb --version → 命中 fast-path → 打印 2.8.3 (Claude Code) → 立即 return,一个模块都没 import(前台指一下牌子)。你敲
ccb(普通启动) → 走到末尾 → await import('../main.jsx') 才加载 5640 行 → 通电开门、亮起 REPL 界面(顾客进店消费)。main.tsx 有 5640 行,加载它很"重"。但用户可能只想看版本号、或跑个后台子命令——没必要为此加载整个交互式 UI 的代码。动态 import + fast-path 短路让常见轻量操作几乎零冷启动开销。动态 import(await import('x'))= "现在才去加载 x 这个模块",相对于顶部的静态 import(一启动就全加载)。这是 CLI 冷启动优化的经典手法,全项目到处用(Day 11 命令懒加载、Day 13 条件技能同理)。main() → init() → setup():一次性初始化
默认路径进入 src/main.tsx 的 main()(main.tsx:743)。它用 Commander 构建命令行程序,并在真正执行命令前跑一个 preAction 钩子做初始化:
进程级防护
设 Windows PATH 劫持防护、warning handler、注册 process.on('exit')(含 workflow 关闭)和 SIGINT(Ctrl+C)。
await init()
调 src/entrypoints/init.ts:66 的 init(用 memoize 包着,只跑一次):启用配置、注册 Ink 主题、telemetry/Sentry、优雅关闭、证书/代理、仓库检测……全是一次性初始化。
setup()
校验 Node ≥18、设置工作目录、抓取 hooks 配置快照、初始化 SessionMemory / SkillLearning、发启动 beacon。
init() 被 memoize 包着,保证无论被调几次,那些一次性初始化只真正跑一次。Commander 是解析命令行参数/子命令的库(ccb daemon、ccb --resume 这些子命令/旗标就是它解析的)。preAction 钩子 = "在真正执行命令前先跑这段"。REPL 挂载:Ink 接管终端(真实代码)
初始化完,交互式会话会挂载 React/Ink 应用。核心是 src/replLauncher.tsx——短短 30 行,是"把 React 应用挂到终端"的胶水。真实全文(节选):
export async function launchRepl(root, appProps, replProps, renderAndRun) {
const { App } = await import('./components/App.js');
const { SentryErrorBoundary } = await import('./components/SentryErrorBoundary.js');
const { REPL } = await import('./screens/REPL.js');
await renderAndRun(
root,
<SentryErrorBoundary name="RootREPLBoundary">
<App {...appProps}>
<REPL {...replProps} /> {/* ← 真正的交互界面 */}
</App>
</SentryErrorBoundary>,
);
}
renderAndRun 渲染。这就是根组件树 SentryErrorBoundary > App > REPL(Day 04 会展开)。{...appProps} 是"把 appProps 对象里的字段都当属性传进去"。渲染在 interactiveHelpers.tsx 的 renderAndRun:root.render(element) 之后 await root.waitUntilExit()——进程就挂在交互界面,直到你退出。
root.render(...) 之后 Ink 接管终端,你看到的所有消息、输入框、进度都是这个 React 树在渲染(Day 04 讲 Ink)。完整启动链一句话:ccb → shim → cli.tsx(fast-path 分发)→ main.tsx main()(Commander + init + setup)→ createRoot + launchRepl → Ink 渲染 REPL → 等待交互。👶 小白:为什么不干脆一启动就把所有模块都 import 好,用起来不是更省事?
👨🏫 老师:因为"开门"很贵——5640 行的 main.tsx 加载慢、吃内存。绝大多数命令用不到整个店。所以用动态 import(await import)+ fast-path:需要哪个模块才现去拿,能在门口解决的绝不进店。这套"用到才加载"的懒加载手法,Day 11(命令)、Day 13(技能)你还会反复见到。
构建双管线:为什么必须拆几百个 chunk
两条并行构建管线(package.json:44):
Bun.build(默认)
bun run build → build.ts。Bun.build({ splitting: true, ... }) 打包 cli.tsx、拆分代码,产物后处理成 node/bun 双兼容,最后生成两个 shim。Vite / Rollup
bun run build:vite。发布用;内联依赖、minify、拆 chunk 到 chunks/。两个 shim 的真实生成代码(build.ts:95)——正好印证 Day 01 讲的 bin 映射:
await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n') // ccb-bun
await writeFile(cliNode, '#!/usr/bin/env node\nimport "./cli.js"\n') // ccb / claude-code-best
chmodSync(cliBun, 0o755); chmodSync(cliNode, 0o755) // 给可执行权限
import "./cli.js" 的极薄壳(第一行 #!/usr/bin/env node 或 bun 指定用谁跑)。真正的产物是 cli.js。chmod 0o755 是给文件加"可执行"权限。splitting: true)后,Bun 按需加载,--version 的内存从 966MB 降到 35MB。(Node 的 V8 因懒解析没这问题,但为兼容 Bun 必须拆。)这是"打包策略直接影响运行时内存"的真实案例。版本号、feature flag 都是构建时用"宏(MACRO)"注入的编译期常量(scripts/defines.ts),关掉某 feature 相关代码会被死代码消除。今日小结 + 动手
🧠 今天你应该能回答
- 怎么装 Bun/build/dev?(dev 版本号 888)
- cli.tsx 的 fast-path 为什么用动态 import?(冷启动优化,--version 零额外加载)
- 启动主链哪几步?(cli.tsx → main() → init()/setup() → launchRepl → Ink 渲染)
- 两个 bin(cli-node/cli-bun)为什么只是一行 import 的壳?
- 构建为什么拆几百个 chunk?(Bun/JSC 急切解析导致大 bundle 内存爆炸)
✋ 动手:对着真实代码读一遍
# 1. dev 模式跑(看到版本 888 就对了)
bun install && bun run dev --version
# 2. 读 fast-path 分发器(L05,注意 --version 分支零 import)
sed -n '76,120p' src/entrypoints/cli.tsx
# 3. 读 REPL 挂载胶水(L07,就 30 行)
cat src/replLauncher.tsx
# 4. 读构建生成的两个 shim(L08)
sed -n '95,106p' build.ts
# 5. 读启动主链
sed -n '743,760p' src/main.tsx