---
title: "手搓 CC 多网关 ANTHROPIC_ 变量切换器"
description: "给 Claude Code 切换 Anthropic 兼容网关，本质是改 ANTHROPIC_BASE_URL、密钥和模型名三个值。它们住在一个被 Claude Code 共享、启动时快照一次、跨作用域逐 key 合并的 JSON 里——这篇讲这四条约束各自逼出了什么，五个方案里为什么是重写 settings.json，以及一次代码审查发现测试写穿了真实配置之后修了哪些东西。"
pubDate: 2026-06-15
tags: ["Claude Code", "工程复盘", "开源", "AI编程", "Shell"]
---

ccs 是一个 Claude Code 的网关切换器，用 POSIX sh 写的，2285 行。它做的事只有一件：把当前网关的接口地址、密钥和模型名写进 `~/.claude/settings.json`。

2026 年 5 月开始，6 月发出第五个版本之后我放弃了它。

这篇讲手搓这样一个东西实际要处理什么——为什么它不能是一个二十行的 shell 函数。

## 一、要改的不是三个变量，是一个共享文件

做这个工具的起点很具体：我需要在几个中转站的端点之间切换。

从需求看，切换网关就是改三个东西：

```text
ANTHROPIC_BASE_URL              接口地址
ANTHROPIC_API_KEY / _AUTH_TOKEN 密钥，二选一
ANTHROPIC_MODEL                 模型名
```

密钥那行是二选一：有的网关认 API key，有的认 Bearer token，写错一个就是认证失败。

如果这三个值住在一个专属文件里，二十行 shell 函数确实够了。它们不在。Claude Code 读的是 `~/.claude/settings.json`，而这个文件同时还放着 `permissions`、`hooks`、`statusLine`，以及将来版本会往里加的任何东西。

项目的设计文档把四条约束写在最前面，并注明它们是 Claude Code 的既成事实、经验验证得到，不是这个工具选的：

**一、启动时快照一次。** Claude Code 在会话启动时读一遍设置链和进程环境，之后不再读。没有任何外部工具能让一个正在运行的会话改指向，诚实的契约只能是「重启生效」。

**二、进程环境压过配置文件。** shell 里 export 过的 `ANTHROPIC_API_KEY` 会静默盖掉工具刚写进去的值。所以 `ccs use` 和 `ccs doctor` 都要检查当前 shell 有没有导出这两个密钥变量。

**三、跨作用域逐 key 合并。** 项目级的 `settings.local.json` 是**按 key** 覆盖全局文件的，没写的 key 继承全局。

**四、`settings.json` 是共享地盘。** 工具必须改掉自己管的那些 key，并且可证明地不碰其余部分。

第三条是最贵的。它意味着给某个项目钉一个网关，不能只写这个网关自己的 key——全局那份定义了、而被钉的网关没有的每一个 key，都必须显式写成空串（Claude Code 把空串当作未设置），否则会从全局渗过来。设计文档的说法是，仅这一条事实就塑造了整个 `--project` 实现。

## 二、做出来的东西

最后的形态是一个 2285 行的 POSIX sh 脚本，75 个函数，13 个子命令：

```text
init  set  preset  doctor  use  pin  unpin  verify  ls  show  current  slim  rm
```

它管理的不是三个环境变量，是 20 个：除了地址、密钥、模型，还有 opus、sonnet、haiku 三档的模型别名，每档各自的显示名、描述和能力声明，自定义模型选项的同一组四个字段，以及 subagent 模型和推理强度。

测试用 Python 写，2150 行、114 个测试函数，pytest 实收 132 项。这个依赖只在开发时存在，不随工具分发。

安装有两条路：Homebrew，以及 `curl | sh`。后者承诺除 POSIX userland 之外零依赖，这个承诺后面会变成一笔账。

## 三、五个方案，为什么是重写 settings.json

设计文档列了五个方案，四个被否决：

| 方案 | 否决理由 |
|---|---|
| shell 函数里 export 环境变量 | 只对当前这个 shell 生效，新开一个终端就悄悄回退。**试过，已删除** |
| 本地代理按请求路由 | 要常驻进程，而且把信任边界搬进工具自己。永久排除 |
| 每个网关一份完整 `settings.json`，切换时换符号链接或复制 | 会把非网关设置分叉——在一份里改了 `permissions`，其余几份就丢了 |
| `apiKeyHelper` | 只覆盖密钥，不覆盖地址和模型映射 |
| **重写 `settings.json` 里自己管的那些 key** | 唯一一个 Claude Code 原生就读、且地址、密钥、模型映射三样都放得下的持久位置 |

第一行那个「试过，已删除」是这个项目的前一代。

赢的方案有代价，设计文档自己写明了：必须用 POSIX sh 可靠地读写 JSON。为此手写了一个 awk 的 JSON 扫描器，文档估它约 200 行，并称它是项目主要的复杂度，也是主要的风险面。

代价不止于此。既然要在别人的文件里改自己那几行，就得保证不碰其余部分。设计文档把这件事拆成五条不变量：

- **不半切。** 那组 key 作为一个整体移动，顺序是 verify、写 settings、更新 active 标记。任何一步失败都让上一个网关完整保留；写完 settings 之后的失败必须大声报出来，不能吞掉。
- **不碰不属于自己的。** 非托管的顶层字段和 env key 在每次重写后语义上逐字节存活。保证不了的时候——文件解析不动——工具拒绝执行，而不是猜。
- **诚实地失败。** `ccs use` 默认对真实端点发一次探测，不声称自己没观察到的成功。
- **密钥有四个泄漏面，各有对应防御。** 进程列表（verify 用私有 curl 配置文件传认证，不走 argv；`--key -` 从标准输入读，避免进 shell 历史）、git（`--project` 拒绝把密钥写进没被 gitignore 的文件）、文件权限（umask 077，全程 0600/0700）、网络（明文 HTTP 告警，回环地址豁免）。
- **原子提交。** 每个被替换的文件先在目标文件系统上写临时文件，再用 rename 提交。

这五条管的都是写入路径。下一节那次事故不在写入路径上。

## 四、四天 Python 之后的重开

这个项目最开始不是 shell 写的。

仓库 5 月 21 日创建，git 里最早的提交是 5 月 25 日凌晨。中间那四天的代码是 Python：一个 `ccs/cli.py`、配套的 pytest，还有一个 `migrate` 命令和一段清理 `.zshrc` 的逻辑。

5 月 24 日我给代理下了一条指令，原话是「重建仓库历史为两个 orphan root 分支：`main` 只放 shell 版，`py` 只放 Python 版」。执行它的代理回复：「已完成，而且是按『重开历史』的方式做的。」

两个 root 提交相隔十六秒，一个叫 Start the shell provider switcher branch，一个叫 Start the Python provider switcher branch。前四天的开发史不在它们任何一个下面。

`py` 分支到今天仍然只有那一个 root 提交，后面的开发全部发生在 shell 版上。

## 五、一次代码审查发现它写穿了真实配置

6 月 7 日只有一个提交，标题是 Harden ccs against data loss, injection, and test pollution。它的正文第一句写着：一次多维度的代码审查发现了一个关键的测试隔离缺口——**它已经污染了一个真实的 `~/.claude/settings.json`**。

机制是这样的。测试要验证工具怎么改配置文件，就得让它去改一份临时的，控制去向的是 `CCS_DIR` 和 `CLAUDE_SETTINGS_FILE` 两个环境变量。测试夹具没有把它们从继承来的环境里清掉，于是一个外部传进来的值可以把测试的写入重新指向真实配置。

修法分两层：夹具里 `delenv` 掉这两个变量，然后在运行器和 PTY 测试里再各剥一次，作为纵深防御。

同一次审查还翻出四类别的问题，全在写入路径上：

- `json_quote` 用 awk 的记录分割处理输入，内嵌换行在分割时就没了——处理换行的那个分支成了死代码，换行不是被转义而是被静默丢掉。改成累积整个输入再处理。
- 三个写文件的函数在提交前不检查 awk 的退出码，一次失败的重写可以用截断的临时文件覆盖掉原文件。改成先看退出码再提交。
- 地址、密钥和 `-e` 传进来的值没有拦控制字符，换行和制表符可以注入进配置文件、以及那份用制表符分隔的 env。加了写入边界上的拒绝。
- 一个被转义成 `\uXXXX` 的托管 key 名识别不出来，重写时它会被当成别人的东西留下，陈旧的密钥跟着留下。解码 ASCII 范围的转义后解决。

这次提交改了 `bin/ccs` 四百多行，同时加了 177 行测试。四天后又补了一轮，针对部分读取、跨文件系统 rename 和信号中断。

## 六、最后的样子

v0.8.0 是最后一个版本，6 月 15 日发的，也是整个项目里最大的一次改动：脚本从 1567 行长到 2285 行，测试从 1098 行长到 2150 行、51 个测试函数长到 114 个。加进去的是按项目钉网关、`slim` 子命令、一批网关 preset，以及把 `init` 变成隐式的。

6 月 16 日最后一次推送之后，这个仓库没有再收到提交。我已经放弃了它。

## 七、两处没有关闭的取舍

设计文档末尾有一节叫「刻意保留的取舍」，写了两处项目自己知道没做对、但当时没有改的地方。

**active 标记是存下来的，不是推出来的。** Claude Code 真正读的是 `settings.json`，而 `~/.config/ccs/active` 是这个事实的第二份拷贝，两者会漂移——工具的做法是大声报告漂移，而不是藏起来。项目钉网关那一侧已经证明了更好的模型：`current` 和 `doctor` 把文件里那组托管 key 反向匹配回网关配置，**推导**出当前是谁，没有会过期的标记。把全局这侧也改成推导，能删掉一整类不一致。没改的理由是那个标记还承担另一件事——它能命名那些已经验证不通过的网关——而且这次迁移要动每一个命令。

**手写 awk 扫描器，还是直接要求装 jq。** 硬依赖 jq 能删掉这个项目里最险的代码，而且 Homebrew 那条路可以免费带上它。扫描器留下来的唯一理由，是 `curl | sh` 那条路承诺了 POSIX userland 之外零依赖。折中办法是：jq 在的时候一律优先用它，而扫描器的失败模式被改成了「拒绝并保持文件原样」，不再是「猜」。

还有一处不在文档里。那四天的 Python 代码没有留下来：`py` 分支只有一个空壳 root，被重开掉的历史不在任何分支下面。

---

代码在 [Ike-li/ccs](https://github.com/Ike-li/ccs)，GPL-3.0。
