Claude Code 源码学习笔记
个人博客
项目 GitHub
个人博客
项目 GitHub
  • 全部文档

    • 1、从 claude 命令到 Agent 主循环

平常我们在终端里输入:

claude

很快就会进入 Claude Code 的交互界面。但从源码看,这中间其实经过了不少东西:

  • Shell 先找到已经安装好的 claude 可执行文件;
  • cli.tsx 处理 --version 一类的快速命令;
  • main.tsx 判断这次是什么运行模式;
  • Commander 解析参数并选中对应的 .action();
  • .action() 加载权限、Tools、Skills、Agents 和 MCP;
  • 最后才进入 REPL 或 Headless,再调用 queryLoop()。

核心只涉及两个文件 src/entrypoints/cli.tsx和 src/main.tsx 。

下边会结合源码把每一步都串起来。除了弄清代码怎么执行,也会看一下 Claude Code 为什么这么写。

为了不让后边的代码只剩下一堆变量名,全文固定使用下边这条命令作为例子:

claude \
  --model sonnet \
  --permission-mode plan \
  --mcp-config ./mcp.json \
  "检查登录模块"

cli.tsx 先处理简单命令

cli.tsx 没有一上来就加载完整的 Claude Code,而是先看当前参数能不能直接处理。

例如:

// src/entrypoints/cli.tsx
async function main(): Promise<void> {
  const args = process.argv.slice(2)

  if (
    args.length === 1 &&
    ['--version', '-v', '-V'].includes(args[0])
  ) {
    console.log(`${MACRO.VERSION} (Claude Code)`)
    return
  }

  // Chrome、Daemon、Bridge 等特殊入口……

  if (args.includes('--bare')) {
    process.env.CLAUDE_CODE_SIMPLE = '1'
  }

  const {
    main: cliMain,
  } = await import('../main.js')

  await cliMain()
}

大概流程如下:

值得注意的地方是执行主函数的时候没有在文件开头 import,而是这里动态 import:

const {
  main: cliMain,
} = await import('../main.js')

动态加载最直观的好处是启动更快。例如执行 claude --version 时,根本不用加载 React、MCP 和 Tools。

还有个作用是提前处理参数和环境变量,再允许后续模块执行顶层初始化代码。

假设把代码改成静态导入:

import { main } from '../main.js'

if (args.includes('--bare')) {
  process.env.CLAUDE_CODE_SIMPLE = '1'
}

ES Module 会先加载并执行 main.js 以及它依赖的模块,然后才执行当前文件里的普通代码。

如果某个工具模块在顶层读取:

const simpleMode =
  process.env.CLAUDE_CODE_SIMPLE === '1'

此时等入口再设置环境变量就晚了,因为依赖模块已经读取过旧值。

主要的几个作用:

  1. 快速命令不用初始化完整 Agent 系统。
  2. --bare 等早期配置能在模块求值前生效。
  3. Chrome、Daemon、REPL、SDK 等路径互不污染。
  4. 无关模块损坏时,--version、诊断等简单路径仍可能正常工作。

从 main() 到 .action()

进入 main.tsx 后会连续看到 main()、run() 和 .action(),这三个名字很容易混。

先不用看里边的所有代码,可以把它们理解成三层:

main():处理进程级初始化

位置 src/main.tsx:585 ,主要代码:

export async function main() {
  // Windows:避免从当前目录误执行恶意同名程序
  process.env.NoDefaultCurrentDirectoryInExePath = '1'

  initializeWarningHandler()

  process.on('exit', () => {
    resetCursor()
  })

  process.on('SIGINT', () => {
    // Print 模式有自己的优雅退出处理
    if (
      process.argv.includes('-p') ||
      process.argv.includes('--print')
    ) {
      return
    }

    process.exit(0)
  })

  // 提前处理 cc://、Deep Link、Assistant、SSH 等参数……

  const cliArgs = process.argv.slice(2)
  const hasPrintFlag =
    cliArgs.includes('-p') ||
    cliArgs.includes('--print')
  const hasInitOnlyFlag =
    cliArgs.includes('--init-only')
  const hasSdkUrl =
    cliArgs.some(arg => arg.startsWith('--sdk-url'))

  const isNonInteractive =
    hasPrintFlag ||
    hasInitOnlyFlag ||
    hasSdkUrl ||
    !process.stdout.isTTY

  setIsInteractive(!isNonInteractive)
  initializeEntrypoint(isNonInteractive)

  const clientType = (() => {
    if (
      process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-ts'
    ) {
      return 'sdk-typescript'
    }

    if (
      process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-py'
    ) {
      return 'sdk-python'
    }

    // Desktop、Remote、CLI 等其他分支……
    return 'cli'
  })()

  setClientType(clientType)

  eagerLoadSettings()

  await run()
}

可以看到,main() 还没有加载完整工具池,也没有调用模型。它主要做下边这些事情:

  • 建立进程级安全设置;
  • 安装退出和中断处理;
  • 改写特殊启动参数;
  • 提前判断交互、Print、SDK;
  • 标记调用方是 CLI、Python SDK、TypeScript SDK、Desktop 还是 Remote;
  • 在初始化前加载 --settings;
  • 最后把控制权交给 run()。

run():注册并解析命令

位置:src/main.tsx:884。

run() 先创建 Commander 实例:

const program =
  new CommanderCommand()
    .configureHelp(createSortedHelpConfig())
    .enablePositionalOptions()

接着注册根命令的参数:

program
  .name('claude')
  .description(
    'Claude Code - starts an interactive session by default...',
  )
  .argument('[prompt]', 'Your prompt', String)
  .option('-p, --print', 'Print response and exit')
  .option('--model <model>', 'Model for the current session')
  .option('--permission-mode <mode>', 'Permission mode')
  .option('--mcp-config <configs...>', 'Load MCP servers')
  // ...
  .action(async (prompt, options) => {
    // 默认 Claude 会话
  })

然后再注册子命令:

program.command('mcp')
program.command('plugin')
program.command('doctor')
program.command('config')
// ...

所有命令都注册完成以后,才会解析用户这次传入的参数:

await program.parseAsync(process.argv)

Commander 会根据 argv 选择对应的处理函数:

图 5:run(/Users/windliang/others-project/claude-code-analysis/articles/assets/01-claude-code-architecture-and-startup/diagram-05.png):注册并解析命令

在真正执行 action 以前,还会先执行一个公共的 preAction:

program.hook('preAction', async () => {
  await init()
  initSinks()
  // 初始化设置、认证、日志基础设施……
})

所以 run() 不是自己决定执行哪个分支,而是先把所有分支注册好,再由 Commander 根据参数选择:

.action():准备一次 Claude 会话

用户执行默认的 claude 命令后,会进入根命令的 .action()。

它拿到两个主要参数:

prompt
options

例如用户执行:

claude \
  --model sonnet \
  --permission-mode plan \
  --mcp-config ./mcp.json \
  "检查登录模块"

经过 Commander 解析后,可以近似理解成:

prompt = '检查登录模块'

options = {
  model: 'sonnet',
  permissionMode: 'plan',
  mcpConfig: ['./mcp.json'],
  print: undefined,
  inputFormat: undefined,
  outputFormat: undefined,
  sdkUrl: undefined,
}

这里的 options 实际还有很多字段,上边只列出后续主流程会用到的部分。没有传入的可选参数大多是 undefined,之后再和用户设置、项目设置以及默认值合并。

接下来 .action() 会准备五组主要数据:

图 7:.action(/Users/windliang/others-project/claude-code-analysis/articles/assets/01-claude-code-architecture-and-startup/diagram-07.png):准备一次 Claude 会话

这也是 main.tsx 看起来特别长的原因。它不是在实现某一个复杂算法,而是在把输入、配置、权限和各种能力接到一次会话上。

下边分别看一下这些数据是怎么来的。

inputPrompt

const effectivePrompt = prompt || '';
    let inputPrompt = await getInputPrompt(effectivePrompt, (inputFormat ?? 'text') as 'text' | 'stream-json');

默认其实就是终端拿到的 prompt,但也兼容了一些其他情况:

stdin 是进程的标准输入,isTTY 表示它是否直接连接着交互终端:

process.stdin.isTTY === true
// 用户在终端运行 claude,stdin 通常来自键盘

process.stdin.isTTY === false
// stdin 通常来自管道或 SDK 子进程

getInputPrompt() 的主逻辑可以简化成:

if (process.stdin.isTTY || process.argv.includes('mcp')) {
  return prompt
}

if (inputFormat === 'stream-json') {
  return process.stdin
}

let data = '';
const onData = (chunk: string) => {
  data += chunk;
};
process.stdin.on('data', onData);
// If no data arrives in 3s, stop waiting and warn. Stdin is likely an
// inherited pipe from a parent that isn't writing (subprocess spawned
// without explicit stdin handling). 3s covers slow producers like curl,
// jq on large files, python with import overhead. The warning makes
// silent data loss visible for the rare producer that's slower still.
const timedOut = await peekForStdinData(process.stdin, 3000);
process.stdin.off('data', onData);
if (timedOut) {
  process.stderr.write('Warning: no stdin data received in 3s, proceeding without it. ' + 'If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.\n');
}
return [prompt, data].filter(Boolean).join('\n')

对应三种常见情况:

命令prompt 最终值
claude "检查登录模块"'检查登录模块'
cat error.log | claude -p "分析日志"'分析日志\n' + error.log内容
SDK 的 stream-json 模式process.stdin 输入流

源码中额外的三秒等待是为了防止父进程传入一条永远没有数据的 stdin,导致 Claude Code 一直卡在启动阶段。

初始化权限和 Tools

这段代码分成两个阶段:

  • 启动时:选权限模式、合并规则、生成模型可见的工具列表;
  • 运行时:模型真的调用工具时,再判断这一次调用能否执行。

先看启动阶段:

const { mode: permissionMode } =
  initialPermissionModeFromCLI({
    permissionModeCli,
    dangerouslySkipPermissions,
  })

const { toolPermissionContext } =
  await initializeToolPermissionContext({
    allowedToolsCli: allowedTools,
    disallowedToolsCli: disallowedTools,
    baseToolsCli: baseTools,
    permissionMode,
    allowDangerouslySkipPermissions,
    addDirs: addDir,
  })

const tools = getTools(toolPermissionContext)

本文继续使用这个命令:

claude --permission-mode plan "检查登录模块"

运行到这里时,关键值近似为:

permissionModeCli // 'plan'
permissionMode    // 'plan'
allowedTools      // []
disallowedTools   // []
addDir            // []

下面按执行顺序阅读。代码是删去日志、埋点和兼容分支后的主干,... 表示暂时不影响理解的细节。

第一步:initialPermissionModeFromCLI() 选择模式

源码:src/utils/permissions/permissionSetup.ts

function initialPermissionModeFromCLI({
  permissionModeCli,
  dangerouslySkipPermissions,
}) {
  const settings = getSettings_DEPRECATED() ?? {}

  const candidates = [
    dangerouslySkipPermissions
      ? 'bypassPermissions'
      : undefined,
    permissionModeCli
      ? permissionModeFromString(permissionModeCli)
      : undefined,
    settings.permissions?.defaultMode,
  ]

  const mode = candidates.find(Boolean) ?? 'default'
  return { mode }
}

选择顺序就是:

危险跳过权限参数
→ --permission-mode
→ settings.permissions.defaultMode
→ default

示例传了 --permission-mode plan,所以返回 { mode: 'plan' }。真实源码还会跳过被组织策略禁用的模式。这里仅仅选模式,还没有判断任何工具。

对外模式有五种;开启相关功能后还会出现 auto。源码中的 bubble 是内部状态,不能通过 CLI 选择。

模式遇到原本需要确认的调用时
default询问用户
acceptEdits工作目录内的常规文件修改自动允许,其他调用继续判断
plan先探索和生成计划,经批准后再实施
dontAsk不询问,直接拒绝
bypassPermissions普通确认直接允许,但显式 deny、ask 和安全检查仍优先
auto交给分类器判断,需要相应功能开关

plan 改变的是 Agent 的工作流程,并不等于直接从工具列表中删掉 Edit 和 Bash。

第二步:initializeToolPermissionContext() 合并规则

源码:src/utils/permissions/permissionSetup.ts

async function initializeToolPermissionContext(args) {
  const allowRules =
    parseToolListFromCLI(args.allowedToolsCli)
  const denyRules =
    parseToolListFromCLI(args.disallowedToolsCli)

  let context = {
    mode: args.permissionMode,
    alwaysAllowRules: { cliArg: allowRules },
    alwaysDenyRules: { cliArg: denyRules },
    alwaysAskRules: {},
    additionalWorkingDirectories: new Map(),
  }

  context = applyPermissionRulesToPermissionContext(
    context,
    loadAllPermissionRulesFromDisk(),
  )

  for (const dir of args.addDirs) {
    const result =
      await validateDirectoryForWorkspace(dir, context)

    if (result.resultType === 'success') {
      context = applyPermissionUpdate(context, {
        type: 'addDirectories',
        directories: [result.absolutePath],
        destination: 'cliArg',
      })
    }
  }

  return { toolPermissionContext: context }
}

它把 CLI、settings 和组织策略合成一张规则表。--tools 如果限制了基础工具,真实源码还会把其余工具加入 deny。示例得到的核心内容是:

{
  mode: 'plan',
  alwaysAllowRules: { cliArg: [] },
  alwaysDenyRules: { cliArg: [] },
  alwaysAskRules: {},
  additionalWorkingDirectories: Map(0) {},
}

这一步只整理规则,不执行工具。

第三步:getTools() 筛选模型可见的工具

源码:src/tools.ts

function getTools(permissionContext) {
  const allTools = getAllBaseTools()

  const allowedTools = filterToolsByDenyRules(
    allTools,
    permissionContext,
  )

  return allowedTools.filter(tool => tool.isEnabled())
}

它取得候选工具,再去掉整项 deny 和当前环境未启用的工具。真实源码还处理 Simple、REPL、Coordinator 以及需要按条件添加的特殊工具。

getTools() 返回的类型是 readonly Tool[],也就是 Tool 对象数组,不是工具名字符串数组。

一次普通交互会话中,如果没有整项 deny 规则,查看名称可能得到:

const tools = getTools(toolPermissionContext)

tools.map(tool => tool.name)
// [
//   'Agent', 'Bash', 'Glob', 'Grep',
//   'Read', 'Edit', 'Write',
//   'WebFetch', 'WebSearch', 'Skill',
//   ...
// ]

每一项本身近似是下面这样的对象:

tools.find(tool => tool.name === 'Read')
// {
//   name: 'Read',
//   searchHint: 'read files, images, PDFs, notebooks',
//   inputSchema: ZodObject(...),
//   isEnabled: [Function],
//   isReadOnly: [Function],
//   checkPermissions: [AsyncFunction],
//   call: [AsyncFunction],
//   ...
// }

其中 inputSchema 告诉模型参数怎么传,checkPermissions() 检查本次调用,call() 才真正读取文件。具体有哪些工具会随环境变量、Feature Flag、REPL 状态和 deny 规则变化。

关键点只有一句:

出现在 tools 中只代表模型看得见,不代表调用一定能执行。

第四步(运行时):hasPermissionsToUseTool() 检查调用

源码:src/utils/permissions/permissions.ts

query() 把它作为 canUseTool 回调。模型每次调用工具前,都会走一次:

async function hasPermissionsToUseTool(
  tool,
  input,
  context,
) {
  const permissionContext =
    context.getAppState().toolPermissionContext

  if (getDenyRuleForTool(permissionContext, tool)) {
    return { behavior: 'deny' }
  }

  if (getAskRuleForTool(permissionContext, tool)) {
    return { behavior: 'ask' }
  }

  const toolResult =
    await tool.checkPermissions(input, context)

  // deny、显式 ask 和安全检查不能被普通模式覆盖
  if (
    toolResult.behavior === 'deny' ||
    toolResult.decisionReason?.type === 'rule' ||
    toolResult.decisionReason?.type === 'safetyCheck'
  ) {
    return toolResult
  }

  if (permissionContext.mode === 'bypassPermissions') {
    return { behavior: 'allow' }
  }

  if (toolAlwaysAllowedRule(permissionContext, tool)) {
    return { behavior: 'allow' }
  }

  let result = toolResult.behavior === 'passthrough'
    ? { behavior: 'ask' }
    : toolResult

  if (
    permissionContext.mode === 'dontAsk' &&
    result.behavior === 'ask'
  ) {
    return { behavior: 'deny' }
  }

  if (
    permissionContext.mode === 'auto' &&
    result.behavior === 'ask'
  ) {
    return classifyYoloAction(/* ... */)
  }

  return result
}

假设模型要编辑登录文件,传入的不是一个工具名,而是完整调用:

tool.name // 'Edit'
input     // { file_path: '/Users/me/shop/src/login.ts', ... }

函数最终只返回三种结果:

{ behavior: 'allow' } // 执行
{ behavior: 'ask' }   // 询问用户
{ behavior: 'deny' }  // 拒绝

Edit 的路径检查、Bash 的命令匹配以及 acceptEdits 的自动放行,主要在各工具自己的 tool.checkPermissions() 中完成;dontAsk 和 auto 再对普通的 ask 结果做转换。

整条权限链如下:

图 8:初始化权限和 Tools

getTools() 决定模型能看见什么,hasPermissionsToUseTool() 决定这一次能不能做。