提到逆向,很多人第一反应是Android APK、游戏外挂、Jar包反编译。但如果你把Claude Code从npm上拉下来,会发现它本身就是一坨Node.js代码,源码就躺在你本地。我抱着“官方文档没写清楚那就自己扒”的心态,把这个工具的克里实际实现从头到尾捋了一遍。这篇文章不涉及任何破解、绕过授权之类的东西,纯粹是记录一次读源码的过程:怎么定位关键逻辑、怎么在压缩代码里最快找到自己关心的东西,以及最后那几处让我没想到的设计。
这篇内容适合已经装过Claude Code、好奇它内部到底怎么跑的人,也适合正在做AI Agent、终端CLI或者想对Claude Code做二次开发的工程师。如果你连它怎么安装都还不熟,建议先装一遍、跑通最简单的对话再回来看,体验会完全不一样。
1. 为什么我要对Claude Code做逆向?因为文档讲故事,代码讲真相
1.1 官方文档留白的地方,恰好是我最想知道的
Claude Code的官方文档写得很工整:怎么安装、怎么配置环境变量、怎么让它读代码库、怎么允许它执行命令,一条一条很清楚。但实际用起来,有几个问题文档始终没给我答案。
我遇到的第一件怪事是:明明在终端里导出了ANTHROPIC_API_KEY,CLI 启动后还是弹出登录提示,要求我用 Claude 账号走 OAuth。当时我以为是环境变量名写错了,反复检查了好几遍,都在。最后我只能连系统环境变量、当前 shell 的环境、还有配置文件里的字段全查了一遍,仍然没搞明白它为什么不用我给的 Key。
第二件事是权限确认。Claude Code 在读取文件、写文件、执行 Bash 命令之前,会蹦出允许/拒绝的提示。允许过一次之后,下次同一类命令它就不再问了。这个“同一类”到底是怎么定义的?是按命令前缀,还是按整个命令行,还是按目录?文档说的是“它有自己的安全机制”,一句话就带过去了。
第三件事是会话历史。我很好奇它把多轮对话存在哪里、用的什么格式、能不能直接导出。文档也没有提。
这些问题的共同点是:只有读源码才能给出确定答案。所以与其继续翻文档,不如把包拉下来自己看。
1.2 “逆向”在这里不是破解,而是读交付物
我没去搞什么内存 dump、动态调试那一套,因为Claude Code是通过npm分发的,它本质上就是一个JavaScript包。你在node_modules或 npm tarball 里看到的东西,虽然经过了转译、压缩,变量名变成了t、e、n这种,但它的字符串常量、JSON配置、模块结构都还留在那里。这比逆向一个编译型二进制要温柔得多。
这也算一种“逆向”:从可执行交付物反推设计决策。很多从业者习惯把这个过程叫“源码分析”而不是“逆向”,但本质上都一样——你不再接受黑盒的说法,选择自己把盒子撬开,看里面齿轮是怎么咬合的。
我这里采用的方式是直接从npm包入手,而不是从GitHub仓库。原因后面会细说。
2. 先从npm包下手,比从GitHub下手靠谱得多
2.1 用 npm pack 把线上包固定拉下来
很多人一听说分析源码,第一反应就是去GitHub找仓库。Claude Code相关的代码确实部分能在开源仓库里看到,但npm上分发的那个包才是用户实际运行的东西。仓库里看到的和实际跑的,可能已经不是同一份代码。所以我选择用npm pack拉一枚完整的tarball,锁定当时的版本。
npm view @anthropic-ai/claude-code version npm pack @anthropic-ai/claude-code mkdir -p decode-cc && tar -xzf anthropic-ai-claude-code-*.tgz -C decode-cc cd decode-cc/package用npm pack而不是直接npm install,是因为pack拿到的是一个干净的交付物,不会因为本机已经装了别的依赖而混入其他文件。而且tarball自带版本号,分析完知道自己在看的是哪个版本,后续想重现结论也容易。
拿到包之后,第一件事不是急着解压缩完就去看代码,而是先看这次的版本号。版本差异这个问题,后面我会单独讲。
2.2 package.json 才是第一块拼图:先看 bin 和 dependencies
解开tarball后,里面是package目录,最显眼的是package.json。我习惯先把这个文件格式化后单独输出关键字段:
node -e " const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package/package.json', 'utf8')); console.log(JSON.stringify({ version: pkg.version, bin: pkg.bin, dependencies: pkg.dependencies, engines: pkg.engines }, null, 2)); "bin字段告诉我们,用户在终端敲claude命令时,实际执行的是cli.js。dependencies字段里出现@anthropic-ai/sdk这种字样,说明API调用部分被拆到了SDK包里,CLI层主要负责交互、参数组装和本地文件管理。
这几个字段一出来,整个代码的粗粒度结构就有了:
| 文件/依赖 | 作用猜测 |
|---|---|
cli.js | 可执行入口,命令解析、交互循环入口 |
@anthropic-ai/sdk | 和API服务端通信的客户端SDK |
| 其他运行时依赖 | 终端UI渲染、文件监听、配置解析、命令行交互 |
有了这张地图,接下来就不会在几百个文件里迷路。
2.3 打开 cli.js 时的第一印象:压缩、转译,但还没到不可读
cli.js这个文件需要先看下体量:
wc -l cli.js ls -la | grep -E "sourcemap|\.map"我当时这个版本的文件接近上万行。如果你期待看到一个风格良好的源码工程,那会失望。它的形态是转译后的CommonJS,函数名被缩写成单字母,缩进被压平,肉眼几乎没法直接阅读。
但有个好消息:没有做真正意义上的混淆。字符串常量都还在,类名、方法名尽管被缩短,但API消息里的字段名、配置文件名、环境变量名这些是不能乱改的,否则程序就跑不起来了。所以逆向的关键策略就变成了:放弃读变量名,改为抠字符串、追调用点。
另外我检查了包里有没有.map文件。Sourcemap 如果存在,可以直接还原出接近原始 TS 的代码。这次我拿到的包里没带 map,所以只能走字符串搜索这条路。如果以后你拿到的包带了 map,用source-map这个 npm 包就能把产物转化成可读性高得多的代码。
3. 顺着字符串搜索,我挖到的几个关键实现细节
3.1 API密钥与登录态,源码里有一套明确的读取优先级
我搜的第一个关键词是ANTHROPIC_API_KEY。这个搜索基本一秒就定位到了环境变量读取逻辑。顺着调用点往上翻,可以看到一个非常清晰的优先级链:
- 优先读取
process.env.CLAUDE_CODE_ANTHROPIC_API_KEY。 - 如果没拿到,再读
process.env.ANTHROPIC_API_KEY。 - 如果还是没有,继续读本地的配置文件。
- 最后才转向已经登录保存的 OAuth 凭证。
这里就有意思了:在用户同时设置了环境变量和已经登录 OAuth 的情况下,环境变量里的 Key 优先级要高于 OAuth 登录态。但我前面遇到的“明明设置了 Key,还弹登录”这个问题,源码给出的答案是:配置文件名和字段名可能不对。
我去翻配置文件读取逻辑时,看到它有倒装读取多个配置来源的过程。如果只写了ANTHROPIC_API_KEY环境变量,但拼写成了ANTHROPIC_API_KEY_(多了一个下划线),或者写进了错误位置的配置文件,CLI 会认为没有 Key,于是回落到了 OAuth 流程。
这个问题从文档上很难快速定位,但源码里一眼就能看清:
function resolveApiKey() { return process.env.ANTHROPIC_API_KEY || getSettings().apiKey || getAccountToken(); }这里想提醒一句:如果你也用Claude Code做过二次封装或脚本化调用,最好先确认自己的Key来源走的是哪一条通道,否则可能在日志里看到一堆诡异的权限报错,实际启动变量根本没生效。
3.2 Agent Loop 真的是一个循环:消息数组不断追加 tool_result
Claude Code 这类工具的核心不是“一问一答”,而是一个消息循环。我搜索tool_use和tool_result,很快就找到了主循环逻辑。
简化后的流程是这样的:
async function runAgent(client, messages, tools) { let shouldStop = false; while (!shouldStop) { const response = await client.messages.create({ model: "claude-sonnet-4-20250514", messages, tools, system: systemPrompt, }); messages.push(...response.content); const toolUses = response.content.filter( (block) => block.type === "tool_use" ); if (toolUses.length === 0) { shouldStop = true; break; } for (const block of toolUses) { const result = runTool(block.name, block.input); messages.push({ type: "tool_result", tool_use_id: block.id, content: result, }); } } }这个循环的关键在于:工具执行结果不是存到什么额外状态里,而是作为普通消息追加回对话历史。这样模型下一轮读取时,看到的就是一份包含了工具结果的完整上下文。
也就是说,Claude Code 并没有维护一个特殊的“工作流状态机”,它维护的是一份永不停更的消息数组。工具调用只是往数组里塞了两类标准消息:tool_use和tool_result。这个模式我在做自己的 Agent 时直接照搬过,稳定且好调试。
3.3 工具调用前的权限判断,不是写死的,而是一条可插拔的检查链
Claude Code 在执行 Bash 命令前会问用户是否允许。源码里这块设计得比我想象中要清晰。
搜索permission、allowlist、denylist之后,可以看到工具执行前会经过权限管理器。它的核心逻辑概括起来就三层:
| 层级 | 判断依据 | 结果 |
|---|---|---|
| 第一层 | 命令是否命中用户明确允许的 pattern | 直接放行 |
| 第二层 | 命令是否命中用户明确拒绝的 pattern | 直接拒绝 |
| 第三层 | 是否在非交互环境下运行 | 非交互模式走默认策略,可能是全拒绝或全允许 |
这个设计让我想起 CI 系统里的安全沙箱:交互模式下把决策权交给用户,非交互模式下用默认策略兜底。我后来在给自己写的自动化工具加权限模块时,也复刻了这个三层结构,确实能减少误伤。
值得注意的一个细节是:允许/拒绝的判断依据并非“命令前缀”,而更像“完整命令模板”。用户允许过一次npm test,下一次npm test -- --watch可能仍然会触发询问,因为模板不完全一致。这一点在文档里没有细讲,但源码里能看出来。如果你在用Claude Code做自动化执行,可以主动把常用命令加入允许规则,减少不必要的打断。
3.4 本地会话数据:settings.json 和其他周边文件到底存了啥
关于会话历史,我在代码里搜索~/.claude、settings.json这类路径,找到了不少本地落盘逻辑。它把配置和状态拆成几个文件,职责划分很清晰:
| 文件路径 | 大致内容 |
|---|---|
~/.claude/settings.json | 用户级配置,比如权限规则、模型偏好 |
~/.claude.json | 按项目维度保存的会话状态和工具调用记录 |
| 会话历史相关目录 | 多轮对话的消息记录、时间戳、执行结果 |
这个拆分的思路是:全局设置和项目级状态分开,不会因为 A 项目的配置污染 B 项目。我的直观感受是,它把“配置”和“状态”做了比较彻底的隔离,这点比很多同类工具做得干净。
需要提醒的是,这些文件里如果包含 OAuth 凭证,属于敏感信息。做逆向分析时可以看结构、看字段名,但别把别人的.claude.json到处贴,也尽量别在自己机器上关掉权限就为了看明文。安全底线还是要有的。
4. 在压缩代码里捞针:我踩过的三个坑和方法
4.1 别一开始就试图读全部代码,先抠字符串
拿着一个上万行的压缩文件,如果从第一行开始读,大概半小时就会想放弃。我的做法是先提取所有字符串常量,通过高频字符串反推模块边界。
grep -oE '"[^"]{4,}"' cli.fmt.js | sort | uniq -c | sort -rn | head -50跑完这条命令,你会得到一段出现频率极高的词云:tools、model、permission、ANTHROPIC_API_KEY、tool_result、settings.json。这些词就是代码的骨架。想找认证,就顺着ANTHROPIC_API_KEY搜;想找循环,就顺tool_use搜;想找本地缓存,就顺settings.json搜。
这一步做完,基本就不用再看其他无关代码了。
4.2 注意区分 CLI 层和 SDK 层,别把脏水泼错地方
Claude Code 这个 npm 包虽然入口是cli.js,但它真正和API服务端通信的逻辑在@anthropic-ai/sdk里。也就是说,如果你发现请求头不对、超时逻辑诡异,先想一下这个问题是出在CLI拼装参数的过程,还是出在SDK发送请求的过程。
我一开始就在cli.js里找 HTTP 请求超时配置,找了半天一无所获。后来打开node_modules/@anthropic-ai/sdk才发现超时和一些重试逻辑在 SDK 的client.ts(编译后)里。这个教训是:逆向一个包之前,先把它的依赖树理清楚,尤其是包含别人SDK的情况,否则很容易把时间耗在错误的地方。
4.3 版本漂移问题:这次逆向的结论要标上版本号
Claude Code 的迭代节奏很快,一周可能更新好几个版本。我分析的是当时最新版,但等下个月再回头看,可能认证逻辑已经变了。所以我的习惯是:
- 用
npm pack固定副本,把 tarball 留在分析目录。 - 在笔记开头记录
npm view拿到的版本号。 - 以后若要复现结论,就用同一个 tarball,而不是最新线上包。
这样的分析才有可复现性。如果有人跟你说“Claude Code 源码我看到什么什么”,你至少要问他版本号,否则很难判断说的是不是同一个东西。
5. 这篇逆向分析之后,我实际拿来做了什么
5.1 它帮我解决了一个困扰我一周的认证问题
回到文章开头说的那个问题:配置了环境变量,CLI 仍然尝试 OAuth 登录。看了源码后我才发现,它读取 Key 的优先级是先读CLAUDE_CODE_ANTHROPIC_API_KEY再读ANTHROPIC_API_KEY。我当时在全局配置里定义了一个冗长的变量名,结果变量名和源码里要找的不一致,导致它始终认为自己没有 Key。
定位到原因后,改环境变量名,重启 shell,问题立刻消失。如果没有源码排查,我大概还会在配置文件名、权限、防火墙这些方向继续转圈。
5.2 我把它的消息循环架构用到了自己的工具里
向源码学到一个最实用的点,就是前面那个消息数组循环。我后来照着这个模式写了个简化版Node脚本,用来在自己项目里跑简单的Agent任务:
const result = await runAgent(client, [ { role: "user", content: "看看 src 下的代码,给我总结一下" }, ]); async function runAgent(client, initialMessages) { const messages = [...initialMessages]; while (true) { const resp = await client.messages.create({ model: "claude-sonnet-4-20250514", messages, tools: [{ type: "tool_use", name: "read_file" }], }); messages.push(...resp.content); const toolUses = resp.content.filter((b) => b.type === "tool_use"); if (toolUses.length === 0) break; for (const tool of toolUses) { const content = tool.name === "read_file" ? readLocalFile(tool.input.path) : "unknown tool"; messages.push({ type: "tool_result", tool_use_id: tool.id, content, }); } } return messages; }这套模式的好处是:所有的中间过程都留存在消息列表里,出问题时可以完整回放,相当于自带日志。目前我用它处理一些需要自动读文件、判断、再操作的场景,跑得相当稳。
5.3 逆向可以做和不可以做的边界
源码分析这种方向,边界其实很清楚:你可以看代码、理解实现、学习架构,也可以基于这些知识做合法的二次开发。但拿着逆向结论去破解订阅、绕过授权、批量请求、盗用凭证,这些越界行为我不能支持,也不建议任何人做。Claude Code 本身允许用户在本地读取 npm 包内容,这是它的分发方式决定的,但尊重服务的条款和付费边界,是每个从业者该有的底线。
我个人觉得,这次逆向深扒最大的收获,不是某个具体函数怎么写的,而是它让我养成了“先看代码再下结论”的习惯。文档和技术博客写得再详细,也是别人转述的实现结果,源码才是离真相最近的地方。下次你再碰到一个神秘的CLI工具,不用急着发帖问“它为什么会这样”,拉下来自己搜几个字符串,答案往往比你想的要简单。