ccs 是一个 Claude Code 的网关切换器,用 POSIX sh 写的,2285 行。它做的事只有一件:把当前网关的接口地址、密钥和模型名写进 ~/.claude/settings.json。
2026 年 5 月开始,6 月发出第五个版本之后我放弃了它。
这篇讲手搓这样一个东西实际要处理什么——为什么它不能是一个二十行的 shell 函数。
一、要改的不是三个变量,是一个共享文件
做这个工具的起点很具体:我需要在几个中转站的端点之间切换。
从需求看,切换网关就是改三个东西:
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 个子命令:
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,GPL-3.0。