别直接往 settings.json 里写:一个更稳的 API 档位切换器
之前写过一个 43 行的版本,用序号切换 Claude Code 的 API 配置。够用,但有个隐患:它直接 write_text 覆盖 ~/.claude/settings.json。
覆盖本身没错,问题是覆盖到一半的时候。磁盘满了、进程被 kill 了、Ctrl-C 按得不是时候,settings.json 就变成一个截断的 JSON。下次 Claude Code 启动,读配置失败,整个工具打不开。
这个文件不是草稿,是启动配置。它坏一次,我得手动去修,而且修的时候还没有 Claude Code 可用。
所以我把那个 43 行的脚本重写了。功能没变,还是选一个配置,合并进 env;变的是写文件的方式和出错的方式。
配置长这样
$SECRETS49/claudecode_api.yaml:
deepseek:
ANTHROPIC_BASE_URL: https://api.deepseek.com/anthropic
ANTHROPIC_AUTH_TOKEN: sk-xxx
ANTHROPIC_MODEL: deepseek-chat
xiaomimimo:
ANTHROPIC_BASE_URL: https://api.xiaomimimo.com/anthropic
ANTHROPIC_AUTH_TOKEN: sk-yyy
ANTHROPIC_MODEL: mimo-7b一个配置档就是一组必须互相匹配的环境变量。换端点要换 token,换 token 往往还要换模型名。改一个字段不够,得整组一起换。
脚本把选中的那一整套合并进 settings.json 的 env:
def merge_env(settings: dict[str, Any], config: dict[str, str]) -> dict[str, Any]:
env = settings.get("env", {})
if not isinstance(env, dict):
raise ConfigError("配置错误: settings.json 的 env 必须是对象")
return {**settings, "env": {**env, **config}}这里用合并。settings.json 里可能还有 PATH、EDITOR 这类跟 API 无关的变量,替换会把它们全冲掉。
完整代码
完整文件在下面,细节留到后面说。
# /// script
# requires-python = ">=3.11"
# dependencies = ["PyYAML>=6.0.2,<7"]
# ///
"""把 $SECRETS49/claudecode_api.yaml 中选中的配置合并进 ~/.claude/settings.json。"""
import json
import os
import sys
from pathlib import Path
from typing import Any
import yaml # pyright: ignore[reportMissingImports]
SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
CONFIG_FILENAME = "claudecode_api.yaml"
class ConfigError(Exception):
"""可预期的配置或输入错误,消息直接展示给用户。"""
# ---------------------------------------------------------------------------
# 读取配置
# ---------------------------------------------------------------------------
def load_configs() -> dict[str, dict[str, str]]:
secrets_dir = os.environ.get("SECRETS49")
if not secrets_dir:
raise ConfigError("配置错误: 未设置环境变量 SECRETS49")
config_path = Path(secrets_dir) / CONFIG_FILENAME
try:
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except OSError as exc:
raise ConfigError(f"配置错误: 无法读取 {config_path}: {exc}") from exc
except yaml.YAMLError as exc:
raise ConfigError(f"配置错误: {config_path} 不是合法 YAML: {exc}") from exc
if not isinstance(raw, dict) or not raw:
raise ConfigError(f"配置错误: {config_path} 必须是非空映射")
return {str(name): string_map(name, values) for name, values in raw.items()}
def string_map(name: str, values: object) -> dict[str, str]:
if not isinstance(values, dict):
raise ConfigError(f"配置错误: {name!r} 必须是键值映射")
result: dict[str, str] = {}
for key, value in values.items():
if not isinstance(key, str) or not isinstance(value, str):
raise ConfigError(f"配置错误: {name!r} 的 {key!r} 必须是字符串")
result[key] = value
return result
def choose_config(configs: dict[str, dict[str, str]]) -> tuple[str, dict[str, str]]:
names = list(configs)
print("可用配置:")
for index, name in enumerate(names, start=1):
print(f" {index}. {name}")
while True:
try:
choice = input("请选择配置编号:").strip()
except EOFError as exc:
raise ConfigError("输入错误: 没有读到选择") from exc
if choice.isdigit() and 1 <= (number := int(choice)) <= len(names):
name = names[number - 1]
return name, configs[name]
print(f"请输入 1 到 {len(names)} 之间的整数。", file=sys.stderr)
# ---------------------------------------------------------------------------
# 写入 settings
# ---------------------------------------------------------------------------
def read_settings(path: Path) -> dict[str, Any]:
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return {}
except OSError as exc:
raise ConfigError(f"配置错误: 无法读取 {path}: {exc}") from exc
try:
settings = json.loads(text)
except ValueError as exc:
raise ConfigError(f"配置错误: {path} 不是合法 JSON: {exc}") from exc
if not isinstance(settings, dict):
raise ConfigError(f"配置错误: {path} 的根节点必须是对象")
return settings
def merge_env(settings: dict[str, Any], config: dict[str, str]) -> dict[str, Any]:
env = settings.get("env", {})
if not isinstance(env, dict):
raise ConfigError("配置错误: settings.json 的 env 必须是对象")
return {**settings, "env": {**env, **config}}
def write_if_changed(path: Path, settings: dict[str, Any]) -> bool:
"""内容有变化才写入,并用临时文件原子替换。"""
text = json.dumps(settings, ensure_ascii=False, indent=2) + "\n"
try:
if path.read_text(encoding="utf-8") == text:
return False
mode = path.stat().st_mode & 0o777
except FileNotFoundError:
mode = 0o644
except OSError as exc:
raise ConfigError(f"配置错误: 无法读取 {path}: {exc}") from exc
path.parent.mkdir(parents=True, exist_ok=True)
staging = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
staging.write_text(text, encoding="utf-8")
staging.chmod(mode)
os.replace(staging, path)
except OSError as exc:
raise ConfigError(f"配置错误: 无法写入 {path}: {exc}") from exc
finally:
staging.unlink(missing_ok=True)
return True
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
def main() -> int:
try:
configs = load_configs()
name, config = choose_config(configs)
settings = merge_env(read_settings(SETTINGS_PATH), config)
changed = write_if_changed(SETTINGS_PATH, settings)
except ConfigError as exc:
print(exc, file=sys.stderr)
return 1
except KeyboardInterrupt:
print(file=sys.stderr)
return 130
action = "已更新" if changed else "无变化"
print(f"{action}: 配置 {name!r} 已合并到 {SETTINGS_PATH} 的 env。")
return 0
if __name__ == "__main__":
raise SystemExit(main())写文件只做三件事
def write_if_changed(path: Path, settings: dict[str, Any]) -> bool:
"""内容有变化才写入,并用临时文件原子替换。"""
text = json.dumps(settings, ensure_ascii=False, indent=2) + "\n"
try:
if path.read_text(encoding="utf-8") == text:
return False
mode = path.stat().st_mode & 0o777
except FileNotFoundError:
mode = 0o644
...
staging = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
staging.write_text(text, encoding="utf-8")
staging.chmod(mode)
os.replace(staging, path)
except OSError as exc:
raise ConfigError(f"配置错误: 无法写入 {path}: {exc}") from exc
finally:
staging.unlink(missing_ok=True)
return True内容没变就不写
先读一遍现有文件,转成同样的 JSON 文本,逐字符比较。相同就直接返回 False。
省一次写入只是顺带。settings.json 被其他进程监视着,写一次就触发一次重载。切到当前已经生效的配置,本来应该是个无操作,不该让 Claude Code 重载一遍。
主流程会把这个结果说出来:
已更新: 配置 'deepseek' 已合并到 /home/me/.claude/settings.json 的 env。或者:
无变化: 配置 'deepseek' 已合并到 /home/me/.claude/settings.json 的 env。原子替换
临时文件写在目标同目录,写完 chmod,再 os.replace 到正式路径。
os.replace 在同一个文件系统上是原子的。别的进程要么看到旧文件,要么看到新文件,不会看到写了一半的内容。
临时文件必须和目标同目录。写到 /tmp 再移动,跨文件系统时 os.replace 会退化成复制,原子性就没了。
保留权限
mode = path.stat().st_mode & 0o777settings.json 可能被我手动 chmod 600 过,里面有 API key。如果写完变成 644,密钥就对同机其他用户可读了。
先读原权限,临时文件设置成同样的权限,再替换。原文件不存在才用 644。
配置本身也要校验
旧版直接 yaml.safe_load 然后当字典用。YAML 写错一个缩进,配置变成了字符串;某个值忘了加引号,变成了数字。这些错误会在合并时以 KeyError 或 AttributeError 的形式炸出来,报错信息完全看不出是哪一行配置的问题。
新版在校验上花了点篇幅:
def string_map(name: str, values: object) -> dict[str, str]:
if not isinstance(values, dict):
raise ConfigError(f"配置错误: {name!r} 必须是键值映射")
result: dict[str, str] = {}
for key, value in values.items():
if not isinstance(key, str) or not isinstance(value, str):
raise ConfigError(f"配置错误: {name!r} 的 {key!r} 必须是字符串")
result[key] = value
return result错误信息里带着配置名和键名。看到 配置错误: 'deepseek' 的 'ANTHROPIC_MODEL' 必须是字符串,我就知道去 YAML 里找哪一行。
settings.json 也有同样的校验:不是合法 JSON、根节点不是对象、env 不是对象,三种情况分别报出来。不会拿着一个坏字典继续往下走。
Ctrl-C 退出码 130
except ConfigError as exc:
print(exc, file=sys.stderr)
return 1
except KeyboardInterrupt:
print(file=sys.stderr)
return 130配置错误返回 1,用户中断返回 130。130 是 shell 对 SIGINT 的约定退出码,&& 链和 set -e 脚本能正确识别「这是用户主动停的」,而不是「脚本出错了」。
多写一百行图什么
功能上一模一样,输入输出没变。多出来的是一百多行校验和错误处理。
但 settings.json 是那种坏一次就很麻烦的文件。它决定 Claude Code 能不能启动,而我又天天用它。为它多写一百行,让写入变成原子的、让错误变成人话,我觉得划算。
切配置不用先备份 settings.json 了。切错了看报错,切到一半断了,文件还是完整的。