本章目录In this chapter
- 13 · Build and Distribution
- 13.1 The Bun Single-File Executable
- 13.2 Compile-Time Feature Flags: 89 of Them
- 13.3 Dead-Code Elimination: Why It's Not Just an “if”
- 13.4 Compile-Time Macros
- 13.5 Runtime Feature Flags: A Separate System
- 13.6 Versions and Updates
- 13.7 Architectural Constraints Inferred from the Build
13 · 构建与分发
最后一章讲:51 万行 TypeScript 是怎么变成一个能双击运行的文件的,以及这个构建过程本身如何反过来塑造了代码的写法。
13.1 Bun 单文件可执行程序
先看事实:
$ ls -la ~/.local/share/claude/versions/
-rwxr-xr-x 272553824 2.1.223 ← 260 MB
-rwxr-xr-x 279661952 2.1.226 ← 267 MB
-rwxr-xr-x 310740672 2.1.234 ← 296 MB
一个文件,296 MB,直接可执行。不需要装 Node.js,不需要 npm install,不需要任何运行时依赖。
这是 Bun 的一个能力:它可以把「JavaScript 运行时 + 你的全部代码 + 全部依赖包 + 所有静态资源」打包进一个二进制文件。
| 对比 | 传统 Node.js 命令行程序 | Bun 单文件 |
|---|---|---|
| 用户要装什么 | Node.js(版本还要对)+ npm 包 | 什么都不用 |
| 体积 | 几 MB(但依赖几百 MB) | 296 MB(自包含) |
| 启动速度 | 要解析和加载几千个模块文件 | 模块已经内联,更快 |
| 版本冲突 | 用户的 Node 版本可能不兼容 | 不存在 |
| 能内嵌原生程序 | 困难 | 可以(见下) |
内嵌原生程序
第 4.4 节提到过一个条件判断:
// Ant-native builds have bfs/ugrep embedded in the bun binary (same ARGV0
// trick as ripgrep). When available, find/grep in Claude's shell are aliased
// to these fast tools, so the dedicated Glob/Grep tools are unnecessary.
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
译:内部原生构建版把 bfs / ugrep 内嵌进了 bun 可执行文件(用的是和 ripgrep 一样的 ARGV0 技巧)。当它们可用时,Claude 的 shell 里的 find/grep 被别名指向这些快速工具,所以独立的 Glob/Grep 工具就不必要了。
Unix 程序启动时能知道「自己是用什么名字被调用的」(这个值叫 argv[0])。
所以一个可执行文件可以这样写:如果我被以 grep 这个名字调用,我就表现得像 grep;如果被以 claude 调用,我就是 Claude Code。
这样一个二进制文件就能扮演多个程序。BusyBox 就是用这个技巧把几百个 Unix 命令塞进一个文件的。
对 Claude Code 的意义:模型执行 grep -r "foo" . 时,实际跑的是内嵌的高性能搜索程序,而不是系统自带的 grep。速度快很多,而且行为在所有平台上一致。连带的好处是不再需要独立的 Grep 工具 —— 少一个工具就少一份说明文字常驻上下文(第 4.5 节)。
13.2 编译期特性开关:89 个
源码里到处是这样的写法:
import { feature } from 'bun:bundle'
const reactiveCompact = feature('REACTIVE_COMPACT')
? (require('./services/compact/reactiveCompact.js') as typeof import('...'))
: null
if (feature('CONTEXT_COLLAPSE')) {
collapseOwnsIt = (contextCollapse?.isContextCollapseEnabled() ?? false) && isAutoCompactEnabled()
}
统计下来共有 89 个不同的编译期开关。部分列表:
13.3 死代码消除:为什么这不只是「if 判断」
feature() 和普通的运行时判断有本质区别:它在打包时被替换成字面量 true 或 false,然后打包器会把不可达的分支整段删除。
这带来三个后果:
| 后果 | 说明 |
|---|---|
| 体积 | 外部版本不携带内部功能的代码,可执行文件更小 |
| 安全 | 内部功能的代码物理上不存在于外部产物里,无法被逆向分析出来 |
| 字符串消除 | 连字符串常量都被删除 —— 这一点催生了一种特殊的编码风格,见下 |
「排除字符串」检查催生的编码风格
源码里有多处这样的注释:
// Entire block gated behind feature() so the excluded string
// is eliminated from external builds.
if (feature('CACHED_MICROCOMPACT') && pendingCacheEdits) { ... }
// The subtype check lives inside the injected callback so feature-gated
// strings stay out of this file (excluded-strings check).
snipReplay?: (yieldedSystemMsg, store) => { messages, executed } | undefined
第二段尤其能说明问题。为了让某个内部功能的字符串不出现在外部产物里,他们把一段逻辑改成了「由外部注入的回调函数」 —— 这样那个字符串就只存在于注入方(内部构建才编译的模块)里。
这是一个真实的架构约束反过来影响代码结构的例子。
正常的写法是在 QueryEngine 里直接判断 message.subtype === 'snip_boundary'。但那个字符串会出现在外部产物里,泄露内部功能的存在。
所以改成:QueryEngine 接受一个 snipReplay 回调,自己完全不知道判断条件是什么。代码变复杂了,但满足了「外部产物不含内部字符串」的硬约束。
源码注释还提到这个改动的一个副作用是好的:「keeps QueryEngine free of excluded strings and testable despite feature() returning false under bun test」 —— 在测试环境下 feature() 返回 false,但通过注入回调,这段逻辑仍然可测。
另一处:ESLint 规则也参与了
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
/* eslint-disable custom-rules/no-top-level-side-effects */
可以看到多条自定义的 lint 规则:
custom-rules/no-process-env-top-level—— 禁止在模块顶层读环境变量(因为顶层代码在导入时就执行,会破坏快路径的「零加载」)custom-rules/no-top-level-side-effects—— 禁止顶层副作用(同上)custom-rules/require-tool-match-name—— 要求用统一的工具名匹配函数(因为工具有别名,直接比较字符串会漏)- 「ANT-ONLY 导入标记不能被重排序」—— 自动整理导入的工具会打乱那些标记,导致死代码消除失效
这些规则是构建约束的自动化守卫。不是靠代码评审时人肉检查,而是让违规的代码直接过不了检查。
13.4 编译期宏
// MACRO.VERSION is inlined at build time
console.log(`${MACRO.VERSION} (Claude Code)`)
MACRO 是构建时被替换成字面量的宏。所以 --version 这条快路径连读一个配置文件都不需要(第 1.2 节)。
13.5 运行时特性开关:另一套系统
除了编译期开关,还有一套运行时开关,用的是 GrowthBook(一个 A/B 实验平台):
const capEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_otk_slot_v1', false)
注意这个函数名:getFeatureValue_CACHED_MAY_BE_STALE(获取特性值_已缓存_可能是过期的)。
把「这个值可能是过期的」直接写进函数名,是一个很好的 API 设计。
为什么重要?回顾第 8.3 节那个分叉子智能体的坑:「Reconstructing by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and bust the prompt cache」 —— 配置从冷缓存变成热缓存,导致两次生成的系统提示词字节不同。
如果这个函数叫 getFeatureValue(),调用者很容易假设它每次返回相同的值。而名字里带上 MAY_BE_STALE,你在写代码时就会想一下「如果这个值在两次调用之间变了会怎样」。
两套开关的分工
编译期 feature() | 运行时 GrowthBook | |
|---|---|---|
| 什么时候决定 | 打包时 | 程序运行时从服务器拉取 |
| 能否按用户区分 | 不能(同一份产物所有人一样) | 能(可以给 5% 的用户开启) |
| 代码是否存在 | 关掉的代码完全不存在 | 代码存在,只是不执行 |
| 能否紧急关闭 | 不能(要重新发版) | 能(改一下配置,所有用户立即生效) |
| 典型用途 | 内部 / 外部版本差异、产品线区分 | 灰度发布、A/B 实验、紧急止血 |
两者经常叠加使用:编译期开关决定「这段代码在不在」,运行时开关决定「在的话要不要执行」。第 6.3 节的缓存微压缩就是这样:
if (feature('CACHED_MICROCOMPACT')) { // 编译期:外部版本没有这段代码
const mod = await getCachedMCModule()
if (mod.isCachedMicrocompactEnabled() && // 运行时:可以随时关掉
mod.isModelSupportedForCacheEditing(model) &&
isMainThreadSource(querySource)) {
return await cachedMicrocompactPath(messages, querySource)
}
}
13.6 版本与更新
安装目录的结构说明了更新策略:
~/.local/share/claude/
├── ClaudeCode.app/ 桌面应用
└── versions/
├── 2.1.223 ← 旧版本保留
├── 2.1.226 ← 旧版本保留
└── 2.1.234 ← 当前版本
~/.local/bin/claude → 符号链接指向 versions/2.1.234
多个版本并存,通过符号链接切换当前版本。这样:
- 更新是「下载新版本 + 改符号链接」,原子操作,不会出现「更新到一半程序坏了」
- 出问题可以秒回滚(改回符号链接)
- 正在运行的旧版本进程不受影响(它已经把文件加载进内存了)
代价是磁盘占用 —— 三个版本就是 800 MB。所以 utils/nativeInstaller/installer.ts(53 KB)里应该有清理旧版本的逻辑。
13.7 从构建方式反推的架构约束
这一章的内容其实在前面每一章都留下了痕迹。汇总一下「构建方式如何塑造了代码」:
| 构建约束 | 对代码的影响 | 出现在 |
|---|---|---|
| 单文件、零依赖 | 可以内嵌原生搜索程序 → 少两个工具 → 系统提示词更短 | 第 4.4 节 |
| 快路径要零加载 | 入口全部用动态导入;禁止顶层副作用和顶层读环境变量(自定义 lint 规则强制) | 第 1.2 节 |
| 死代码消除 | feature() 必须写在 if / 三元表达式里,不能组合成变量再判断 |
第 3 章多处 |
| 排除字符串检查 | 把逻辑改成注入回调,让内部字符串不进入外部产物 | 第 2 章 snipReplay |
| 导入顺序不能重排 | 禁用自动整理导入的工具 | tools.ts 顶部 |
| 测试环境下 feature() 返回 false | 被开关保护的逻辑必须能通过注入的方式单独测试 | 第 2 章 |
架构讨论通常止步于「模块怎么划分」。但在一个真实的产品里,「怎么构建、怎么分发、怎么灰度、怎么回滚」这些工程约束,会实实在在地反过来改变代码的写法。
Claude Code 里那些看起来奇怪的写法 —— 三元表达式里的 feature()、注入式回调、动态导入、禁用 lint 规则的注释 —— 单独看每一个都像坏味道。放到构建约束的语境里看,它们都是必要的。
这也是读源码相比读架构文章的价值所在:架构文章讲的是「应该怎样」,源码里留着的是「实际付了什么代价」。
十四章走完了从进程启动到消息落盘的完整链路。如果要用一句话概括这个系统的设计立场:
它把「提示词缓存命中率」当成一等公民约束,然后围绕这个约束重新设计了工具装配、子智能体派生、上下文压缩、甚至日志字段的补全方式。凡是和这个目标冲突的整洁性,都被牺牲掉了。
文档里任何看不懂或想深挖的地方,选中那段文字点「提问」就行。
13 · Build and Distribution
The last chapter covers how 512,000 lines of TypeScript become a single file you can double-click to run, and how that build process in turn shapes the way the code is written.
13.1 The Bun Single-File Executable
The facts first:
$ ls -la ~/.local/share/claude/versions/
-rwxr-xr-x 272553824 2.1.223 ← 260 MB
-rwxr-xr-x 279661952 2.1.226 ← 267 MB
-rwxr-xr-x 310740672 2.1.234 ← 296 MB
One file, 296 MB, directly executable. No Node.js to install, no npm install, no runtime dependencies of any kind.
This is a Bun capability: it can package “the JavaScript runtime + all your code + every dependency + all static assets” into one binary.
| Comparison | Traditional Node.js CLI | Bun single file |
|---|---|---|
| What the user installs | Node.js (and the right version) + npm packages | Nothing |
| Size | A few MB (but hundreds of MB of dependencies) | 296 MB (self-contained) |
| Startup speed | Has to parse and load thousands of module files | Modules already inlined; faster |
| Version conflicts | The user's Node version may be incompatible | None |
| Can embed native programs | Hard | Yes (see below) |
Embedding native programs
Section 4.4 mentioned a conditional:
// Ant-native builds have bfs/ugrep embedded in the bun binary (same ARGV0
// trick as ripgrep). When available, find/grep in Claude's shell are aliased
// to these fast tools, so the dedicated Glob/Grep tools are unnecessary.
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
Put plainly: internal native builds embed bfs / ugrep in the bun executable (using the same ARGV0 trick as ripgrep). When they're available, find/grep in Claude's shell are aliased to these fast tools, so the standalone Glob/Grep tools become unnecessary.
When a Unix program starts, it can tell “what name it was invoked under” (that value is called argv[0]).
So an executable can be written like this: if I'm invoked as grep, I behave like grep; if I'm invoked as claude, I'm Claude Code.
One binary can thus play several programs. BusyBox uses this trick to stuff hundreds of Unix commands into one file.
What it means for Claude Code: when the model runs grep -r "foo" ., what actually executes is the embedded high-performance search program, not the system grep. It's much faster, and it behaves identically on every platform. The side benefit is that a standalone Grep tool is no longer needed — one less tool means one less description resident in the context (section 4.5).
13.2 Compile-Time Feature Flags: 89 of Them
This pattern is everywhere in the source:
import { feature } from 'bun:bundle'
const reactiveCompact = feature('REACTIVE_COMPACT')
? (require('./services/compact/reactiveCompact.js') as typeof import('...'))
: null
if (feature('CONTEXT_COLLAPSE')) {
collapseOwnsIt = (contextCollapse?.isContextCollapseEnabled() ?? false) && isAutoCompactEnabled()
}
The count comes to 89 distinct compile-time flags. A partial list:
13.3 Dead-Code Elimination: Why It's Not Just an “if”
feature() is fundamentally different from an ordinary runtime check: at bundle time it's replaced with the literal true or false, and the bundler then deletes the unreachable branch wholesale.
That has three consequences:
| Consequence | Description |
|---|---|
| Size | External builds don't carry the code for internal features; the executable is smaller |
| Security | Internal feature code physically doesn't exist in the external artifact, so it can't be reverse-engineered out |
| String elimination | Even string constants get deleted — which gave rise to a peculiar coding style, see below |
The coding style born of the “excluded strings” check
The source has several comments like this:
// Entire block gated behind feature() so the excluded string
// is eliminated from external builds.
if (feature('CACHED_MICROCOMPACT') && pendingCacheEdits) { ... }
// The subtype check lives inside the injected callback so feature-gated
// strings stay out of this file (excluded-strings check).
snipReplay?: (yieldedSystemMsg, store) => { messages, executed } | undefined
The second one is especially telling. To keep an internal feature's string out of the external artifact, they turned a piece of logic into “a callback injected from outside” — so the string exists only on the injecting side (a module compiled only in internal builds).
This is a real example of an architectural constraint reaching back to shape code structure.
The normal way would be to check message.subtype === 'snip_boundary' right in QueryEngine. But that string would appear in the external artifact and leak the existence of an internal feature.
So instead: QueryEngine accepts a snipReplay callback and has no idea what the condition is. The code got more complex, but it satisfies the hard constraint “no internal strings in external artifacts.”
The source comment also notes a side effect of this change that's a good one: “keeps QueryEngine free of excluded strings and testable despite feature() returning false under bun test” — in the test environment feature() returns false, but through the injected callback this logic stays testable.
Another spot: the ESLint rules join in
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
/* eslint-disable custom-rules/no-top-level-side-effects */
You can see several custom lint rules:
custom-rules/no-process-env-top-level— forbids reading environment variables at module top level (top-level code runs at import time, which would break the fast path's “zero loading”)custom-rules/no-top-level-side-effects— forbids top-level side effects (same reason)custom-rules/require-tool-match-name— requires the unified tool-name matching function (tools have aliases, so comparing strings directly would miss some)- “ANT-ONLY import markers must not be reordered” — import-sorting tools would scramble those markers and defeat dead-code elimination
These rules are automated guardians of the build constraints. Not humans eyeballing it in code review, but violating code simply failing the check.
13.4 Compile-Time Macros
// MACRO.VERSION is inlined at build time
console.log(`${MACRO.VERSION} (Claude Code)`)
MACRO is a macro replaced with a literal at build time. So the --version fast path doesn't even need to read a config file (section 1.2).
13.5 Runtime Feature Flags: A Separate System
Besides compile-time flags, there's a set of runtime flags, using GrowthBook (an A/B experimentation platform):
const capEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_otk_slot_v1', false)
Note the function name: getFeatureValue_CACHED_MAY_BE_STALE (get feature value — cached — may be stale).
Writing “this value may be stale” right into the function name is good API design.
Why does it matter? Recall the forked-subagent trap from section 8.3: “Reconstructing by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and bust the prompt cache” — the config went from cold cache to warm cache, so two generations of the system prompt produced different bytes.
If this function were called getFeatureValue(), callers would easily assume it returns the same value every time. With MAY_BE_STALE in the name, you pause while writing code to think “what if this value changes between two calls.”
How the two flag systems divide the work
Compile-time feature() | Runtime GrowthBook | |
|---|---|---|
| When it's decided | At bundle time | Fetched from the server while the program runs |
| Can it vary per user | No (same artifact, same for everyone) | Yes (can be turned on for 5% of users) |
| Does the code exist | Disabled code doesn't exist at all | The code exists; it just doesn't run |
| Can it be killed in an emergency | No (needs a new release) | Yes (change a config, and it takes effect for all users immediately) |
| Typical use | Internal / external build differences, product-line separation | Gradual rollouts, A/B experiments, emergency stop-the-bleeding |
The two are often stacked: the compile-time flag decides “is this code present,” and the runtime flag decides “if present, should it run.” The cached microcompaction in section 6.3 works this way:
if (feature('CACHED_MICROCOMPACT')) { // compile-time: external builds don't have this code
const mod = await getCachedMCModule()
if (mod.isCachedMicrocompactEnabled() && // runtime: can be switched off at any time
mod.isModelSupportedForCacheEditing(model) &&
isMainThreadSource(querySource)) {
return await cachedMicrocompactPath(messages, querySource)
}
}
13.6 Versions and Updates
The layout of the install directory reveals the update strategy:
~/.local/share/claude/
├── ClaudeCode.app/ the desktop app
└── versions/
├── 2.1.223 ← old version, kept
├── 2.1.226 ← old version, kept
└── 2.1.234 ← current version
~/.local/bin/claude → symlink pointing at versions/2.1.234
Multiple versions coexist; a symlink switches the current one. That way:
- An update is “download the new version + repoint the symlink,” an atomic operation, so there's no “program broke halfway through an update”
- If something goes wrong, rollback takes a second (repoint the symlink)
- Running processes on the old version are unaffected (they've already loaded the file into memory)
The cost is disk usage — three versions is 800 MB. So utils/nativeInstaller/installer.ts (53 KB) presumably has logic for cleaning up old versions.
13.7 Architectural Constraints Inferred from the Build
This chapter's content has actually left traces in every preceding chapter. To sum up “how the build shaped the code”:
| Build constraint | Effect on the code | Where it shows up |
|---|---|---|
| Single file, zero dependencies | Can embed native search programs → two fewer tools → shorter system prompt | Section 4.4 |
| Fast paths must load nothing | The entry point uses dynamic imports throughout; top-level side effects and top-level env reads are forbidden (enforced by custom lint rules) | Section 1.2 |
| Dead-code elimination | feature() must be written inside an if / ternary, never combined into a variable and tested later |
Several places in chapter 3 |
| Excluded-strings check | Logic turned into an injected callback so internal strings stay out of external artifacts | Chapter 2, snipReplay |
| Import order can't be rearranged | Import-sorting tools disabled | Top of tools.ts |
| feature() returns false under test | Flag-guarded logic must be testable in isolation via injection | Chapter 2 |
Architecture discussions usually stop at “how the modules are divided.” But in a real product, engineering constraints — how it's built, distributed, rolled out gradually, rolled back — reach back and genuinely change how the code is written.
The odd-looking constructs in Claude Code — feature() inside ternaries, injected callbacks, dynamic imports, lint-disabling comments — each looks like a code smell on its own. Seen in the context of the build constraints, every one of them is necessary.
That's also the value of reading source over reading architecture articles: architecture articles say “how it should be”; the source keeps a record of “what price was actually paid.”
Fourteen chapters have walked the complete path from process launch to messages on disk. If the system's design stance had to be summed up in one sentence:
It treats “prompt cache hit rate” as a first-class constraint, then redesigns tool assembly, subagent spawning, context compaction, and even the way log fields get filled in around that constraint. Any cleanliness that conflicts with that goal was sacrificed.
Anywhere in this document you don't understand or want to dig deeper, just select the text and click “Ask.”