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,而这个文件同时还放着 permissionshooksstatusLine,以及将来版本会往里加的任何东西。

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

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

二、进程环境压过配置文件。 shell 里 export 过的 ANTHROPIC_API_KEY 会静默盖掉工具刚写进去的值。所以 ccs useccs 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_DIRCLAUDE_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 是这个事实的第二份拷贝,两者会漂移——工具的做法是大声报告漂移,而不是藏起来。项目钉网关那一侧已经证明了更好的模型:currentdoctor 把文件里那组托管 key 反向匹配回网关配置,推导出当前是谁,没有会过期的标记。把全局这侧也改成推导,能删掉一整类不一致。没改的理由是那个标记还承担另一件事——它能命名那些已经验证不通过的网关——而且这次迁移要动每一个命令。

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

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


代码在 Ike-li/ccs,GPL-3.0。