1 · 入口层与启动流程

这一章讲:你在终端敲下 claude 到界面出现之间,程序做了什么。

1.1 四种启动形态

同一个可执行文件,根据参数进入四种完全不同的运行模式:

形态怎么触发用途与特征
交互模式
REPL
claude
(不带任何参数)
启动一个持续对话的终端界面。有输入框、有滚动的消息列表、有快捷键。这是绝大多数人使用的形态。
REPL = Read-Eval-Print Loop,「读取-求值-打印 循环」,是交互式命令行界面的通用叫法。
无头模式
headless / print
claude -p "帮我改这个文件" 不显示界面。给一个问题、执行完、把结果打印到标准输出、退出。用于写在脚本里自动化调用。
配合 --output-format json 可以输出机器可读的结构化结果。
软件开发工具包模式
Agent SDK
被别的程序作为库调用 输入输出都走标准输入输出的流式 JSON 协议(--input-format stream-json)。让其他软件可以把 Claude Code 当成一个智能体引擎嵌进去。
特殊子进程 --daemon-worker
--claude-in-chrome-mcp
由主进程派生的辅助进程。比如浏览器扩展的本地宿主、后台守护工作进程、远程桥接服务。

1.2 启动的第一个设计:快路径分派

entrypoints/cli.tsx 是真正的程序入口,只有 302 行。它的注释开门见山:

「Bootstrap entrypoint - checks for special flags before loading the full CLI. All imports are dynamic to minimize module evaluation for fast paths. Fast-path for --version has zero imports beyond this file.」

译:引导入口 —— 在加载完整命令行程序之前先检查特殊参数。所有导入都是动态的,以便让快路径尽可能少地执行模块代码。--version 这条快路径除了本文件之外零导入。

为什么这很重要

先解释一个背景概念:JavaScript 程序在「导入」一个模块时,那个模块的顶层代码会立刻执行。如果一个程序静态导入了几百个模块,那么光是启动就要把这几百个模块全部执行一遍,即使这次运行根本用不到它们。

Claude Code 打包后是一个 300 MB 的单文件程序,模块数量极其庞大。如果每次运行都全量加载,claude --version 这种只想看一眼版本号的命令也要等好几秒。

所以入口文件用的是动态导入 —— 只有真的走到某条分支时,才去加载那条分支需要的模块:

async function main(): Promise<void> {
  const args = process.argv.slice(2);          // 取命令行参数

  // 快路径 1:--version,零模块加载
  if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
    console.log(`${MACRO.VERSION} (Claude Code)`);   // 版本号在编译期就被写死进来了
    return;                                     // 直接返回,什么都没加载
  }

  // 其余路径才加载启动性能分析器
  const { profileCheckpoint } = await import('../utils/startupProfiler.js');
  profileCheckpoint('cli_entry');               // 打一个时间戳

  // 快路径 2:--dump-system-prompt(导出系统提示词,用于评测)
  if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') {
    const { enableConfigs } = await import('../utils/config.js');
    ...
    return;
  }

  // 快路径 3:浏览器扩展的本地宿主进程
  if (process.argv[2] === '--claude-in-chrome-mcp') { ... return; }

  // 快路径 4:守护工作进程(由主进程派生,对性能敏感)
  if (feature('DAEMON') && args[0] === '--daemon-worker') {
    const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
    await runDaemonWorker(args[1]);
    return;
  }
  ...
  // 全部快路径都不匹配 → 加载完整的命令行程序
}

claude-code/src/entrypoints/cli.tsx

MACRO.VERSION 里的 MACRO编译期宏 —— 打包时被替换成字面量字符串。所以拿版本号连读配置文件都不需要。

可以搬走的做法

如果你的命令行程序有「启动很慢」的问题,先看有没有高频的轻量命令被重量级的启动流程拖累了

典型的例子:--version--help、shell 补全脚本(这个尤其重要 —— 用户每敲一次 Tab 键就会调用一次)、以及被主进程高频派生的子进程。

把这些做成「在加载任何东西之前就分派掉」的快路径,收益立竿见影。

1.3 命令行参数:60 多个选项

完整的命令行接口定义在 main.tsx 里,用的是 Commander.js 这个库。选项数量非常多,下面按用途分组梳理:

模式与输入输出

选项作用
-p, --print无头模式:输出结果后退出。注意:这个模式会跳过「工作目录信任」确认对话框,所以只应在你信任的目录里用。
--output-format <格式>text(默认)/ json(单个结果对象)/ stream-json(实时流式)
--input-format <格式>text(默认)/ stream-json(从标准输入实时读取)
--json-schema <模式>要求输出符合指定的 JSON 结构。程序会用一个特殊的「结构化输出工具」强制模型产出合规结果,不合规就重试(最多 5 次)。
--include-partial-messages把模型流式返回的每一个片段都吐出来,而不只是完整消息

权限与安全

选项作用
--permission-mode <模式>设定权限模式。可选值见第 7 章。
--dangerously-skip-permissions跳过所有权限确认。官方描述:「仅推荐在没有互联网访问的沙箱环境中使用」。注意即使开了它,仍有一层检查绕不过 —— 第 7 章详述。
--allow-dangerously-skip-permissions只是允许使用上面那个模式,但不默认开启。给管理员做策略配置用。
--allowed-tools / --disallowed-tools允许 / 禁止的工具清单。支持带参数的写法,比如 Bash(git:*) 表示「只允许 git 开头的 bash 命令」。
--tools直接指定可用的内建工具集合。传空字符串就是禁用所有工具。
--add-dir <目录...>额外授权访问的目录(默认只能访问当前工作目录)

模型与预算

选项作用
--model <模型>可以传别名(sonnetopus)或完整型号名
--fallback-model <模型>主模型过载时自动降级到这个。只在无头模式下生效(交互模式下会直接问用户)
--effort <级别>思考力度:low / medium / high / max
--thinking <模式>enabled(等同 adaptive 自适应)/ disabled
--max-turns <次数>最多进行几轮。超过就提前退出。只在无头模式生效。
--max-budget-usd <金额>花费上限(美元)。超过就停。只在无头模式生效。

会话与恢复

选项作用
-c, --continue继续当前目录下最近的那次对话
-r, --resume [值]按会话 ID 恢复,或打开一个交互式选择器
--fork-session恢复时创建新的会话 ID,而不是复用原来的。相当于「从这个存档点分叉出一条新线」
--resume-session-at <消息 ID>只恢复到指定消息为止,后面的丢弃
--rewind-files <消息 ID>把文件恢复到某条消息时的状态然后退出。这是一个「撤销」功能 —— 第 11 章会讲文件历史怎么实现的。
--no-session-persistence不落盘。这次对话结束就没了,无法恢复。

扩展与集成

选项作用
--mcp-config <配置...>加载 MCP 外部工具服务(可以传文件路径或 JSON 字符串)
--strict-mcp-config只用命令行指定的 MCP 服务,忽略所有其他来源的配置
--plugin-dir <路径>从指定目录加载插件(可以重复传多个)
--agents <JSON>用 JSON 直接定义自定义子智能体
--settings <文件或 JSON>额外的配置来源
--setting-sources <来源>指定从哪几个来源读配置:user(用户级)/ project(项目级)/ local(本地覆盖)
--ide启动时自动连接编程软件(如果恰好只有一个可连的)
-w, --worktree [名字]为这次会话创建一个新的 git 工作树(隔离的代码副本)

1.4 --bare:一个值得单独讲的极简模式

这个选项的官方描述很长,值得逐条拆开看,因为它等于列出了「一次正常启动到底做了多少额外的事」

「Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery.」

译:极简模式:跳过钩子、语言服务协议、插件同步、提交署名、自动记忆、后台预取、系统钥匙串读取,以及 CLAUDE.md 的自动发现。

反过来读这句话,正常启动时会做这些事:

正常启动会做的事为什么它慢 / 有副作用
执行钩子用户配置的启动脚本,可能是任意程序,耗时不可控
启动语言服务协议
LSP
为了让模型能做「跳转到定义」这类代码导航,需要启动一个语言服务器进程 —— 这在大项目上可能要几秒
同步插件可能触发网络请求去拉取插件的最新版本
提交署名往 git 提交里追加署名信息,需要读 git 配置
自动记忆加载长期记忆目录
后台预取提前拉取可能用得上的数据
读系统钥匙串在 macOS 上读钥匙串会弹出系统授权对话框,在自动化脚本里是致命的
自动发现 CLAUDE.md沿着目录树向上逐级查找项目规范文件

--bare 模式还有一个关键的行为改变,描述里写得很明确:

「Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read).」

译:认证严格限定为环境变量 ANTHROPIC_API_KEY 或通过 --settings 指定的密钥获取脚本(OAuth 登录态和系统钥匙串永远不会被读取)。

这是为自动化场景专门设计的:认证来源必须是完全确定、不需要任何交互的。一个跑在持续集成流水线里的任务,绝不能因为「弹出了一个钥匙串授权框」而卡死。

1.5 启动时序

走完快路径分派之后,完整启动大致是这个顺序:

① 进程级环境准备 · 关闭 corepack 自动固定版本(一个会污染用户 package.json 的行为) · 如果是远程环境,把 Node 堆内存上限提到 8 GB · 特性开关的消融基线(内部实验用) ↓ ② 快路径分派(见 1.2) ↓ ③ enableConfigs() 加载配置 配置有多个来源,按优先级合并: 命令行参数 > 项目本地配置 > 项目配置 > 用户全局配置 ↓ ④ 认证解析 优先级:--settings 指定的密钥脚本 → 环境变量 → OAuth 登录态 → 系统钥匙串 ↓ ⑤ 并行加载三类扩展(互不依赖,同时进行) ├─ 技能(扫描技能目录,只读每个文件的头部元数据) ├─ 插件(从多个来源发现并加载) └─ MCP 外部工具服务(建立连接,拉取工具清单) ↓ ⑥ 组装工具清单 内建工具 → 按权限规则过滤 → 和 MCP 工具合并(分区排序,见第 4 章) ↓ ⑦ 构造系统提示词 fetchSystemPromptParts() 返回三部分: · defaultSystemPrompt 默认系统提示词 · userContext 用户上下文(拼在消息前面) · systemContext 系统上下文(拼在系统提示词后面) ↓ ⑧ 分支: ├─ 无头模式 → 直接创建 QueryEngine,跑一轮,输出,退出 └─ 交互模式 → 启动 React Ink 渲染循环,进入 REPL 主界面

1.6 系统提示词的三段结构

第 ⑦ 步返回的三个部分不是随意划分的,它们对应三个不同的缓存稳定性等级

部分放在哪里稳定性
defaultSystemPrompt
默认系统提示词
上下文最开头 最稳定。同一个版本的程序、同一个模型,所有用户都一样 —— 所以它能跨用户共享缓存
systemContext
系统上下文
追加在系统提示词之后 较稳定。包含当前工作目录、操作系统、git 状态等环境信息。同一个用户的同一场会话内基本不变。
userContext
用户上下文
拼在消息序列前面 最不稳定。包含用户的项目规范文件内容等。它被放在消息区而不是系统提示词区,正是为了不污染前面两段的缓存

对应的代码在主循环里是这样调用的:

// query.ts
const fullSystemPrompt = asSystemPrompt(
  appendSystemContext(systemPrompt, systemContext)     // 系统提示词 + 系统上下文
)
...
messages: prependUserContext(messagesForQuery, userContext)   // 用户上下文 + 消息
这个三段划分的价值

把「所有人都一样的部分」「这个用户不变的部分」「随时可能变的部分」按稳定性从高到低依次排列,是缓存友好的通用做法。

因为提示词缓存是前缀匹配的 —— 从最开头逐字比对,一旦对不上,后面全部失效。所以最不容易变的东西必须放在最前面。

如果把用户的项目规范文件(随时可能被编辑)放在系统提示词开头,那么用户每改一次 CLAUDE.md,整段缓存就全废。放到消息区之后,改动只影响它自己那一小段。