design.md 11 KB

Context

用户在多家 Claude Code 兼容的 coding plan 间切换,本质是"换一组环境变量再启动 claude"。当前痛点:

  • 不同 provider 用到的 env 变量集合会有差异(例如有的用 ANTHROPIC_AUTH_TOKEN,有的用 ANTHROPIC_API_KEY + ANTHROPIC_BASE_URL,有的还设 ANTHROPIC_MODEL / ANTHROPIC_SMALL_FAST_MODEL),漏清理会造成"以为切了但没切"的串扰;
  • 在 shell 里写一堆 alias/函数维护成本高,也不利于多机同步。

该工具目标是做成一个薄封装:配置集中、切换确定性强、启动 claude 后行为与直接在 shell 里运行一致(stdio、信号、退出码都透传)。

Goals / Non-Goals

Goals:

  • 单个 Go 二进制,零外部运行时依赖即可使用。
  • 配置文件人类可读可手改,支持多 provider,每 provider 任意条 env 变量。
  • 切换流程确定性:清理 → 注入 → 启动;不污染用户当前 shell。
  • 子进程退出码、stdio、信号(SIGINT/SIGTERM/SIGHUP)与裸跑 claude 等价。
  • 非交互 (cc-switch use <name>) 与交互 (cc-switch 裸跑或 cc-switch use 无参) 两种入口。

Non-Goals:

  • 不做 API Key 加密存储(v1 依赖 0600 文件权限;后续可评估接入 OS keychain)。
  • 不管理 shell rc 文件、不写 profile、不 export 到父 shell。
  • 不内置 provider 模板/预设(v1 要求用户自填 env 键值)。
  • 不做多租户/团队共享配置同步(留给用户自己用 dotfiles/Git 同步)。
  • 不代理或改写 claude 行为,只负责"换环境再启动"。

Decisions

Decision: 配置格式与位置

采用 YAML,路径遵循 XDG:默认 $XDG_CONFIG_HOME/cc-switch/config.yaml(未设置时回落到 ~/.config/cc-switch/config.yaml);CC_SWITCH_CONFIG 环境变量可覆盖。

  • Why: YAML 对多行、嵌套 map 友好;env 变量很适合 key: value 展示。用户可能同时维护多个 provider,手改体验优于 TOML/JSON。
  • Alternatives considered: TOML(层次表达稍弱);JSON(无注释);split file per provider(增加 IO 复杂度,收益不明显)。

Decision: 环境变量"清理"的实现方式

构造子进程的 Env 切片,而非修改 cc-switch 自身的 os.Environ

  1. 读取 os.Environ() 作为基础;
  2. 从中剔除 key ∈ union(all providers' env keys)
  3. 追加选中 provider 的 key=value 对;
  4. 赋给 exec.Cmd.Env,启动 claude。 这样"清理 → 注入"只作用于子进程环境,cc-switch 自身进程和父 shell 完全不受影响;"退出后清理"由进程隔离天然保证(子进程环境随其生命周期结束)。对外我们保留"退出后再清理一次"的语义承诺,在 cc-switch 退出前打印一条 trace(可选 --verbose)确认,但不做实际 mutation。
  5. Why: 避免对 os.Environ 做有状态 mutation 带来的测试复杂度和泄漏风险;读写集中在一处易于单测。
  6. Alternatives considered: 用 os.Setenv / os.Unsetenv 再 fork 子进程——需要在 defer 中回滚,panic 路径容易漏;对多 goroutine 场景不友好。

Decision: claude 可执行文件解析

优先使用配置里的 claude_path(绝对或相对路径,支持 ~);未配置时 exec.LookPath("claude");都找不到则以明确错误退出(退出码保留给用户,不伪装成 claude 自身错误)。

  • Why: 不少用户把 claude 装在 ~/.claude/local/claude 这类非 PATH 路径,需要显式覆盖;同时多数用户 PATH 里就有,不应强制配置。

Decision: 运行方式——subprocess 而非 exec-replace

使用 os/exec 启动 claudecc-switch 作为父进程守护:

  • 透传 stdin/stdout/stderr;
  • 注册信号处理器,把 SIGINT/SIGTERM/SIGHUP 转发给子进程;
  • cmd.Wait() 完成后提取 ExitCode() 作为 cc-switch 自身退出码。 不使用 syscall.Exec(替换当前进程映像)。
  • Why: 保留子进程退出后做记账/清理/提示的机会;跨平台兼容(Windows 无 exec 语义);便于未来加 --dry-run--print-env 等 hook。
  • Trade-off: 多一层进程,但 claude 本身就是长交互进程,进程链深一层对用户无感。

Decision: CLI 框架

采用 github.com/spf13/cobra 做子命令装配,gopkg.in/yaml.v3 做 YAML 序列化。

  • Why: cobra 是 Go 生态事实标准,子命令/help/flag 等基础设施完善,减少样板;yaml.v3 能保留 key 顺序,用户手改 diff 友好。
  • Alternatives considered: 标准库 flag(子命令层级要自己搭)、urfave/cli(也可行但生态小一号)。

Decision: Provider 选择的交互 UI

优先使用轻量方案:在 tty 下用简单数字菜单(stdin 读一行),不引入完整 TUI 库。若未来体验不足再评估 survey/bubbletea

  • Why: 二进制体积、依赖面、CI 环境兼容性(非 tty 时自动退化为报错提示用户给出 <name>)。

Decision: 配置文件权限

创建配置文件与其父目录时使用 0700(目录)、0600(文件)。读写配置前若发现权限过宽(group/other 可读),打印一条 warning 但不中止——降低误操作阻塞成本。

  • Why: 配置里存 API key,POSIX 权限是第一道防线;warning 留一条可见线索。

Decision: 原子写入

保存配置使用 write-to-temp-then-rename:先写 config.yaml.tmp,fsync,再 os.Rename 为目标文件。

  • Why: 防止编辑/并发导致半写坏文件丢掉所有 provider 配置。

Decision: Env 值的间接引用语法

支持值形如 env:VAR_NAME 从启动时的父进程环境中读取实际值(正则约束 ^env:[A-Za-z_][A-Za-z0-9_]*$)。解析时机:启动阶段,在"并集清理"之前先对 os.Environ() 取快照,针对选中 provider 的 env 值逐一解析引用;引用的 VAR_NAME 未在快照中出现则非零退出并打印哪一个 key 引用的哪个 VAR 缺失。引用的变量值本身仍被视为最终 env value,写入子进程环境前不会再做任何 shell 扩展。

  • Why: 让用户可以把 API key 放在系统 keychain / 1Password CLI / direnv 暴露的 env 中,配置文件里只存"指针",降低明文泄露面。
  • 为什么先取快照再清理: 被引用的 VAR 本身可能恰好落在并集里(例如 provider A 用 key ANTHROPIC_API_KEY: env:ANTHROPIC_API_KEY),如果清理后再解析就读不到了。先快照绕过这层次序耦合。
  • Alternatives considered: ${VAR} 风格(与"value 字面传递"的承诺冲突、视觉上像 shell 扩展易误解);YAML 显式结构 {from_env: VAR}(schema 复杂、增删改 UX 差);!env YAML 标签(不易被通用 YAML 工具处理)。
  • 局限: 值字面量恰好以 env: 开头且并非引用的情况极少,v1 不支持转义;文档明示"若确实需要字面 env:xxx,请改用不同写法或等 v2 转义方案"。

Decision: Provider 模板

在二进制中内嵌(go:embed 一份 templates.yaml)一组常见 provider 的 env key 骨架,作为 cc-switch add --from-template <tpl> 的起点。v1 的种子模板:anthropic-officialopenrouterdeepseekmoonshot(Kimi)、zhipu(GLM)、custom-base(仅 BASE_URL + AUTH_TOKEN,给未列出的 OpenAI-兼容服务用)。每个模板包含:descriptionenv key 列表(仅键名 + 每项可选的 hint 提示文案和可选的 default 示例 value,例如 ANTHROPIC_BASE_URL 默认填官方 URL)。

  • 交互流程add <name> --from-template openrouter 进入向导,逐个 key 提示输入(显示 hint;允许直接输入 value 或 env:VAR 引用;有 default 的回车接受)。提供 --non-interactive 时只带出 key 骨架写入(value 为空占位),要求用户后续 edit 补齐。
  • 扩展途径:v1 不支持用户自定义模板目录(non-goal);v2 可加 $XDG_CONFIG_HOME/cc-switch/templates/。README 会附贡献新模板到 upstream 的 PR 指引。
  • Why: 让首次上手不用翻各家文档即可拼出 env;限制在"仅键名骨架"避免工具把"该用哪个 model id"这种快速过时的信息硬编码。
  • Alternatives considered: 纯文档 snippet(用户仍需手抄);远程拉取模板(新增网络依赖、安全面、离线不可用)。

Risks / Trade-offs

  • [Risk] 明文存储 API key → 以 0600 权限 + 文档中明示;后续版本评估 keychain 集成;提供 env:VAR_NAME 语法支持从外部 env 引用(v1 可先设计字段,实现留到后续)——v1 不实现该语法,但在 schema 中预留扩展位。
  • [Risk] 用户在 shell 里已经 export 了某些 ANTHROPIC_* 变量,和 provider 冲突 → 因为我们对 union(all providers' keys) 做清理,即便这些变量来自父 shell 也会被覆盖;但如果用户 export 的某个 key 未出现在任何 provider 配置里,则会被原样继承(这是预期行为)。文档中明确说明"union"范围,并建议用户把所有相关 key 都登记到至少一个 provider 里以便统一清理。
  • [Risk] 信号转发在 Windows 与 Unix 语义差异 → v1 明确只支持 macOS/Linux(goreleaser/发布时不打 Windows 包);Windows 用户可走 WSL。
  • [Risk] 交互菜单在非 tty(CI、管道)下卡死 → 检测 stdin.IsTerminal(),非 tty 时要求必须显式给 <name>,否则非零退出并打印提示。
  • [Trade-off] 不做 exec-replace → 多一层父进程,但换来跨平台、可测试性、未来扩展空间,值得。
  • [Trade-off] 无内置 provider 模板 → 用户第一次上手要自己查各家文档;作为补偿,cc-switch add 提供交互式向导,把常见 key 列出来让用户勾选填值。

Migration Plan

这是全新仓库、无历史用户:

  1. 先落地 module 骨架与基础命令(add/list/use);
  2. 实现 env 计算与子进程启动;
  3. 补齐 edit/remove/config
  4. 加交互选择;
  5. 发布 v0.1.0(Homebrew tap + go install 双通道)。 无回滚策略需求(新项目)。

Open Questions

  • 是否在 add 时提供常见 provider 的 env key preset(Anthropic 官方、DeepSeek、Kimi、Moonshot、OpenRouter 等兼容层)作为可选引导?—— 倾向 v1 不做,由 README 给出 snippet;v2 观察用户反馈再加。
  • 是否支持"一次性覆盖":cc-switch use foo --set ANTHROPIC_MODEL=claude-opus-4-7?—— v1 不做,保持配置单一事实来源;v2 可加 --set/--unset 作为临时 override。
  • 是否需要 cc-switch doctor 做健康检查(claude 路径存在、配置权限正确、tty 检测)?—— v1 暂不做,但在 list -v 中附带展示 claude 路径解析结果。