平常我们在终端里输入:
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'
此时等入口再设置环境变量就晚了,因为依赖模块已经读取过旧值。
主要的几个作用:
- 快速命令不用初始化完整 Agent 系统。
--bare等早期配置能在模块求值前生效。- Chrome、Daemon、REPL、SDK 等路径互不污染。
- 无关模块损坏时,
--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 选择对应的处理函数:

在真正执行 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() 会准备五组主要数据:

这也是 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 结果做转换。
整条权限链如下:

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