一、两种不透明

2026 年 5 月,我每天要切五次左右 Claude Code 的 provider。

原因很简单,我用的中转,一家的额度撑不到一天。切换本身也不难,就是改 ~/.claude/settings.json 里三个键,ANTHROPIC_BASE_URL,加上 ANTHROPIC_API_KEY 或者 ANTHROPIC_AUTH_TOKEN,有些 provider 用前者有些用后者。

但一天手改五次 json 不现实,而且两种 auth 会打架,上一家用 api key,下一家用 auth token,忘了删前一个,行为就变得很难解释。

所以我先用的 cc-switch。

它是能用的,用的人也非常多,十三万 star,快九千个 fork,到现在还在更新。我后来不用它跟质量没关系,是两个很主观的原因。一个是对我要做的事来说太重了,我要的是改三个键,它是个跨平台桌面应用。另一个更要紧,我不知道点下切换那一刻它到底动了哪些文件、写了哪些键。这不能算它的问题,GUI 工具本来就不负责把每一步暴露出来,我也没去读过它源码,就是我自己的偏好,我想要一个能读懂每个字节的东西。

于是自己写了一个。

这个想法决定了后面所有设计。ccs 的设计文档里那些看着像洁癖的条款,来源都是同一句“我不知道它做了什么”:解析不了就拒绝改写不去猜,provider 配置用纯 KEY=value 明文,文档里写清楚哪些方案被否决了,commit 信息里带 Constraint / Rejected / Tested / Not-tested。还有几条是反过来的,不属于自己的键逐字节保留、每次改写前备份留十份,是不想让下一个人对我的工具说同一句话。零依赖、不跑守护进程、不占端口,对应的是“那个太重了”。

这些最后变成 2285 行 POSIX shell 和 114 个测试。

第十七天出了个事。

6 月 7 日我跑了一次全面 code review 的工作流,多个 agent 分维度审代码。跑完之后照常用 ccs,发现不对。~/.claude/settings.jsonANTHROPIC_BASE_URL 变成了 https://k.exampleANTHROPIC_API_KEY 变成 sk-X~/.config/ccs/active 被覆盖成 k,providers 目录里多了个我没建过的 k.conf

那些是测试夹具里的值。

问题不在测试代码。tests/conftest.py 里有个 autouse 的 fixture 把每个测试的 HOME 重定向到临时目录,那个隔离是对的,本来就是防这个的。问题在 review 工作流本身,我给 review 用的是能跑 bash 的 agent,prompt 里没有一句话禁止它去执行被测程序。某个 agent 为了动态验证自己的结论,自己手搓了一段:

export HOME=/tmp/ccstest/home CCS_DIR=$HOME/.config/ccs

同一条 export$HOME 当行还没生效,CCS_DIR 就展开成了真实的 ~/.config/ccs

损失不大,provider 配置没丢,settings.json 手动改回来,active 用 ccs use 设回去就行。

但这个事我记到现在。我不知道 cc-switch 改了我什么,所以自己写了一个能看懂的,结果写到第十七天,把我配置改乱的是我自己跑的 review agent。我不放心一个 GUI 应用动我三个键,但给一批看不见中间步骤的 agent 开完整 bash 权限、让它们去审一个专门改这三个键的程序,这事我一次都没想过。换成自己的东西,标准就松了。

这次事故没留原始日志,上面的原因分析来自当时写的复盘记录。恢复花了多久我没记。

二、看起来只是改三个环境变量

只看需求描述,这工具应该二十行 shell 就够了。

最后 bin/ccs 是 2285 行,测试 2150 行。多出来的不是功能,是“改 settings.json 里三个键”这件事本身比听起来复杂。有四个来源,都不是我的设计选择,是 Claude Code 本身的行为,我一条条测出来写进了 docs/design.md

env 是启动时的快照。 Claude Code 在会话启动时把 settings 里的 env 注进程,之后再改文件对正在跑的会话没影响。所以“切换后立即生效”物理上做不到,能给的只有一句:重启会话才生效。

进程环境变量压过 settings.json。 shell 里 export 过 ANTHROPIC_API_KEY 的话,它会盖掉 ccs 写进文件的东西。所以 ccs useccs doctor 要去查当前 shell 里的残留 export,不查的话工具会说“已经切到 A 了”,而 Claude Code 实际在用 B。

项目级配置是逐键合并的,而且没有删除语义。 .claude/settings.local.json 覆盖全局是一个键一个键覆盖,缺的键继承全局值。所以给某个项目钉一个 provider,不能只写自己的键,全局定义了而这个 provider 没定义的托管键,每一个都得显式处理,不然会渗透进来。

settings.json 是共享地盘。 里面同时装着 permissionshooksstatusLine,还有以后版本会加的东西。ccs 只能改自己那几个键,而且要能证明没碰别的。

这四条合起来决定了这工具的形状,它不能是个 shell 函数,不能是代理,也不能是“把整个 settings.json 换掉”。

边界也顺便说清楚。ccs 不做协议转换,上游只会说 OpenAI 格式的得先用网关转;不做路由、重试、限流;不做密钥托管。就一件事,把当前 provider 写进 Claude Code 官方支持的那个文件。

需求是真的,我本机 ~/.config/ccs/providers/ 里有 22 个配置文件。那不是“试过很多家”,是备胎池,今天这家额度用完得有下一家可切。

三、从 Python 重写成 POSIX shell

第一版是 Python 写的。

Python 的好处很实在,json 是原生类型不用自己解析,测试生态成熟,写得快。那版能用,我自己用了几天。

放弃它就一个理由,用 Python 还需要一个 Python 环境。一个改三个键的工具要求用户先装运行时、建 venv、pip install,跟“轻”这个初衷是反的。我想要的是 brew install 一行,装完就是一个可执行文件。

所以第二版重写成 POSIX shell。代价很具体,json 得自己解析。

bin/ccs 里有大概 200 行手写的 awk json scanner,是整个项目最危险的一段代码,复杂度全堆在这。

这里有个到现在也没关掉的权衡。直接依赖 jq 的话这 200 行可以全删,Homebrew 那条路完全可以顺手带上(depends_on "jq" 一行的事)。我没这么做,因为 curl | sh 那条路承诺了除了 POSIX 用户态什么都不需要。这个权衡我原样写进了设计文档的 Deliberate tradeoffs, kept open 那一节。现在回头看,为了守一条几乎没人走的安装路径,留下了全项目最容易出错的代码。

这节说的 Python 版,现在仓库里看不到了。

5 月 24 日 17:49 我下了一条指令,重建仓库历史为两个 orphan root 分支。执行完之后 main 和 py 的第一个 commit 都没有父提交,前三天半的开发史被永久删掉了,里面包括记忆文件记载的一套 233 个 pytest 的 Python 实现。我当时不知道这条指令会丢掉全部历史。

现在还能拿到的只剩三样,GitHub Actions 的运行记录(里面留着 ccs v0.2.0、v0.3.1、v0.3.2、v0.3.3 几次运行,commit sha 在本地已经不可达,v0.3.3 那次是 failure)、py 分支上那个孤零零的快照 commit、以及 AI 工具写的项目记忆文件。有个细节可以佐证,orphan 之前的 commit 信息是中文,之后全是英文。

四、三个真正难的地方

子进程改不了父 shell

期望是运行 ccs use 之后 Claude Code 就用新 provider 了,最直接的做法是在 shell 里 export 环境变量。

v0.3.x 真这么干过,一个 ccs() shell wrapper 函数,配一个 active.env 文件,再往 ~/.zshrc 里塞一段托管代码块,每次调用后自动重新 source。

撞了两个坑。一个是物理上的,子进程改不了父 shell 的环境,wrapper 只能影响当前这个终端,新开的窗口全都退回去。另一个是升级断层,塞进 .zshrc 那段代码跟后来的版本对不上,用户升级完工具就静默失效了。

v0.7.0 把 shell 环境这条路整个放弃了。唯一写入点是 settings.json,唯一能生效的方式是重启会话,ccs use 会直接把这句打出来。--shell 这个选项留着,但只做一件事,打印你需要执行的 unset 命令,帮你清掉 shell 里的残留 export。

v0.3.x 的实现代码随 orphan 重写一起没了,这段来自当时的记忆文件。

改一个装着别人东西的 json

v0.8.0 之前,如果 settings.json 里有 BOM 前缀、有人手加的注释、被截断、或者尾巴上有多余字符,awk scanner 走不到文件末尾,返回的结果是“空”。

而“空”跟“这文件不存在”走的是同一条路径,直接写一个只有 ccs 托管键的新文件。

后果就是用户的 permissionshooksstatusLine 会被一次切换全抹掉,没装 jq 的机器上整个过程一声不响。

同一批还有几个同源的问题。装了 jq 的时候空文件或者纯空白输入会让 jq 什么都不输出,于是 ccs 提交了一个零字节的 settings.json 然后报告切换成功。临时文件建在 TMPDIR 里,如果那是另一个文件系统,mv 就不是原子操作,中途崩了会留下一个被截断的目标文件。HUP/INT/TERM 没处理,中断 ccs set 向导的时候清理逻辑把暂存文件删了但流程还往下走,可能存下一个缺字段的 provider。

这里挺讽刺的,我因为读不懂别的工具在我配置里干什么才写的 ccs,而 ccs 自己在 v0.8.0 之前能静默清空用户全部的 permissions 和 hooks。

修完之后定了五条不变量:托管键整体移动不许半切;不属于自己的东西逐字节保留,做不到就拒绝而不是猜;默认要向真实 endpoint 探测,不谎报成功;密钥的四个泄漏面(进程列表、git、文件权限、网络明文)各有对应防御;每次替换文件都在目标文件系统上暂存再用 rename 提交。再加一条兜底,每次改写前把原文件备份到 ~/.config/ccs/backups/ 留最新十份,这条不是防已知 bug,是给未知的兜底。

逐键合并导致 provider 渗透

项目级钉 provider 卡在一个不起眼的事实上,合并是逐键的,而且没有删除语义,项目层没法取消全局层设过的键,只能覆盖。

所以会出现一种混合状态,项目文件写了 A 家的 base url 和密钥但没写模型映射,全局文件里留着 B 家的模型映射,合并完会话实际跑在 A 家 endpoint 加 B 家模型名上。这个状态我现场抓到过一次。

解法依赖一个官方文档没写的行为,env 的值设成空字符串等于取消设置。

这是整个项目里唯一一个必须自己设计实验去问真实系统的问题。做法是开一个 HOME=$(mktemp -d) 的沙箱,用 env -i 清掉继承的环境,然后把 canary 的 ANTHROPIC_BASE_URL 指向 127.0.0.1:9,一个必定拒绝连接的死端口。对照组报 ConnectionRefused 说明这个值生效了,实验组在项目层写空字符串之后报的是 Not logged in · Please run /login,说明它不是被覆盖成空值,是等同于从来没设过。

所以 ccs pin 里有一段看着很怪的逻辑,凡是全局定义了、而被钉的这个 provider 没定义的托管键,都要显式写成 ""

这个项目里最值钱的三十分钟就花在搭那个死端口对照实验上。它产出的七条合并语义是唯一一个 AI 帮不上忙、文档里也抄不到的东西,只能自己设计实验去问真实系统。

五、AI 参与了什么,我又主动砍掉了什么

43 个 commit 里有 24 个带 AI 的 co-author 记录,全程花了大概 25 刀。

这只能说明 AI 参与了,不能换算成“AI 写了多少代码”。commit trailer 是工具自动加的,它不区分“AI 写了主体我改两行”和“我口述结构 AI 补语法”。拿这个比例当生产力指标的说法,包括我自己的,都得打折。

值得说的是另一件事。5 月的 commit 里有 25 个用了一种四段格式,CONTRIBUTING.md 里管它叫 decision-record style:

Constraint: Installer checksum is pinned to the local v0.7.0 bin/ccs sha256
            before the tag exists, because the release tag will contain
            the same bin/ccs bytes.
Rejected:   Keep curl|sh on main without checksum | mutable installs leave
            the fallback path weaker than the documented release model.
Tested:     pytest -q; sh -n bin/ccs; shellcheck; ruff check; installer smoke;
            jq-failure awk fallback smoke; unsafe-doc grep.
Not-tested: GitHub release workflow execution before pushing the v0.7.0 tag.

Not-tested 是这套格式里最少见的部分,它要求我在提交的时候写下自己知道哪些没验证。上面那条的意思是,安装脚本的 checksum 已经按本地文件算好钉进去了,但 GitHub 的发布流程本身在推 tag 之前我没跑过。这跟设计文档里那张被否决方案的表是同一个习惯,把没做什么、否决了什么也当成交付物。

然后是这一节要说的。5 月 25 到 30 号那 26 个 commit 里有 25 个用了这个格式,6 月 7 号到 15 号的 17 个,一个都没有。

不是换工具丢的,是我有意简化掉的。

而 6 月那 17 个 commit 恰好是整个项目最危险的两周,上一节说的那批数据丢失 bug 是那时候修的,项目级钉 provider 是那时候做的,awk scanner 的严格化也是那时候。最需要写下“我没测什么”的时候,我把这一栏省了。

这不是忏悔,是个能观察到的现象。写这个格式当时要多花时间,好处得过一阵才看得出来,所以累的时候第一个砍的就是它,偏偏那两周是最需要它的。

六、AI 让我付出的三笔看不见的成本

下面的记录来自 AI 工具留在本地的 79 条 turn 日志,是当时的原始输入,不是我事后回忆的。前两笔发生在同一天,5 月 24 日。

密钥的泄漏面换地方了,我还在查旧的。 18:19:19 我把一整段 provider 配置粘进对话框,里面有明文密钥。18:22:31 我问的是“提交,代码有没有泄漏隐私信息”。隔了三分钟。

查 git 没错,但泄漏点不在 git。那条明文密钥到现在还躺在 .omx/logs/turns-2026-05-24.jsonl 里,三个多月了。.gitignore 拦住了它进仓库,也因此让它躲过了所有 secret scanning。一个专门为了不明文存密钥而写的工具,开发过程本身把密钥明文写进了三个地方,对话上下文、本地日志、终端 scrollback。AI 工具的日志目录是个还没人做过威胁建模的新泄漏面。

改主意的成本降到零,决策就不受它约束了。 02:04 我说“这个不要明文”,02:12 我说“好吧,现在抛弃明文的需求”,隔了八分钟。17:49 我说“重建仓库历史为两个 orphan root 分支”。三条都在同一天,这天还夹着上面那次密钥泄漏。

这工具最大的产品弱点,设计文档里那句明文存储 by design,就是那八分钟的产物。第三条的代价第三节说过了。以前重写整个仓库历史是个要想两天的决定,因为执行起来本身就很麻烦,现在一句话就落地。缺的不是能力,是原来由麻烦免费提供的那道闸门。

方向盘悄悄换手了。 5 月 29 号有一段连续记录:

04:46:51  下一步做什么
05:29:21  下一步做什么
05:45:09  下一步做什么
05:56:32  下一步做什么
06:25:41  下一步做什么

同一句问了五次。

那天的产出是三个投给 awesome-list 的 PR,和一篇发在自己仓库里的中文公告帖。三个月后的今天,三个 PR 全部还是 open,唯一的评论来自一个机器人,公告帖 0 评论 0 反应。

开始问“下一步做什么”的时候,说明真实需求已经满足了,剩下的产品判断被工具的合理建议填上了。工具不会说该停了,它永远能生成下一个 TODO。

七、做对的和做错的

做对的几件:

范围守住了,整个开发期里“要不要顺手做个本地代理”这个念头出现过好几次,每次都挡回去了,设计文档里有一张明确的永久不做清单。失败的时候关门而不是猜,解析不了就拒绝改写把文件原样留着。每次改写前备份留十份。原子提交,暂存文件建在目标文件系统上用 rename 落地。测试从 24 个长到 114 个,553 行长到 2150 行。还有一条是把自己的短板写进了文档,docs/compare.md 里有一节专门列 ccs 在哪些方面天生更弱:明文存储、不做协议转换、不控制请求路径、没有集中策略、provider 配方会过期。

做错的:

  1. 同一类风险踩了两次。5 月 23 号测试夹具没隔离 HOME,把 pytest 的临时路径写进了我真实的 ~/.zshrc,临时目录被清理之后那几行 source 静默失败,ccs 在交互 shell 里永久失效而且不报任何错。6 月 7 号又来一次,这回是 review agent。第一天就该把测试 HOME 完全隔离。
  2. orphan 重写不可逆,而我当时不知道。
  3. 明文密钥在 AI 日志里躺了三个多月,当时的隐私检查查的是 git。
  4. 在最危险的两周主动停用了 decision-record 格式。
  5. 5 月 29 号一天连发四个正式版,v0.5.0、v0.6.0、v0.6.1、v0.7.0。当时的想法很简单,更新代码,以为有人在用。
  6. 安装承诺反过来放大了内部复杂度,为了守 curl | sh 的零依赖,留下了全项目最危险的 200 行。
  7. 工程投入严重跑在用户验证前面,第 114 个测试写完的时候外部用户数是 0。

八、结果,还有它其实已经结束了

工程这边的数字:

日历跨度 25.3 天(2026-05-21 建仓到 2026-06-16 最后一次 push)
有日志可证的活动时长 18.3 小时 / 20 个工作块,这是下界,6 月的会话日志已经没了
AI 开销 大概 25 刀
bin/ccs 1,128 行到 2,285 行
测试 553 行 24 个到 2,150 行 114 个(pytest 实收 132)
发布 6 个 tag、5 个 GitHub Release

产品这边的数字,截至 2026-09-02:

star / fork / issue / PR 4 / 1 / 0 / 0
release tarball 下载 累计 21 次
近 14 天访问 5 个独立访客、10 个独立 cloner
awesome-list 投稿 3 个,5 月 29 号提交,到现在还是 open
自己发的公告帖 0 评论 0 反应
真实用户 只有我自己,没收到过任何反馈

那 21 次下载得加个注脚,Homebrew 的 formula 直接指向这个 tarball,所以这数字里混着安装、升级、重复请求和机器人,它不等于 21 个用户。10 个 cloner 同理。

这两组数字要分开看。第一组说明工程体系已经成形,第二组说明需求和市场从来没被验证过。混在一起谈就会得出两种偷懒的结论,“东西挺好只是没人发现”,或者“没人用说明做得不行”。

实际是第三种。

开头那个每天切五次的理由是中转额度不够用。现在我 ~/.claude/settings.json 里 env 块是空的,这台机器上的 Claude Code 不走任何第三方 provider 了。

所以 ccs 不是被更好的工具打败的,也不是因为难用被放弃的,是驱动它的那个约束消失了。不需要在中转之间轮换,一个为轮换而生的工具就没用了。不是工具做得不行,是我不需要它了,而我是唯一的用户。

还有个更小的证据。

~/.config/ccs/active 这个文件里写着某一家中转的名字,最后修改时间是 2026 年 6 月 15 日 18:05,之后再没动过。而同一台机器上的 settings.json 说我没在用任何 provider。这两个状态互相矛盾,本来不该矛盾。

这个矛盾我三个月前就写下来过,docs/design.md 里有一节叫 Deliberate tradeoffs, kept open,第一条是:

The active marker is stored, not derived. settings.json is the source of truth Claude Code reads; ~/.config/ccs/active is a second copy of that fact and can drift.

我知道它会漂。我在文档里写了更好的做法,项目级那一侧已经在用了,不存 marker,直接从文件里的托管键反推当前是哪个 provider,没有第二份副本就没有东西可漂。我也写了为什么暂时不改,迁移要动每一个命令。

然后它在我自己机器上漂了。

我没注意到,因为我已经不用这个工具了。

九、如果重来

先问一句,驱动这个需求的约束能存在多久。这次的答案是“直到我不再用中转为止”。5 月要是问出这句,整条投入曲线会完全不一样。这个问题 AI 不会替我问,它只会帮我把下一个功能做得更好。

写第 60 个测试之前,先去找 10 个真实用户。需求是硬的、每天都在发生的,写个工具是合理决定,不合理的是写到第 114 个测试才想起来看有没有人在用。

直接依赖 jq,把 curl | sh 降成次要路径,删掉全项目最危险的 200 行。为一条几乎没人走的安装路径守零依赖,代价不成比例。

第一天就把测试用的 HOME 完全隔离,再写死一条规矩,review 工作流永远不执行被测程序只读源码。这条规矩的成本是一行 prompt。

给不可逆的操作加一道 24 小时闸门。重写历史、大范围删除、orphan 化,当天不执行。AI 把执行成本降到零了,那道原来由麻烦免费提供的闸门得手动装回去。

把 AI 工具的日志目录纳入密钥巡检,跟 git 一样对待。

decision-record 在最危险的时候最不该砍。它成本是当下的收益是以后的,所以总是第一个被砍,这恰好说明它应该写成硬规则而不是靠自觉。

最后一条不算方法。不信任一个我读不懂的工具,不等于必须自己重写一个。提个 issue,或者花两小时把它源码读一遍,成本都比 25 天、25 刀,加上一个我自己也不用的工具要低。