Skip to content

我写了一个单文件 Python 小工具:用交互方式切换 Claude 模型配置 ​

最近我折腾了一个很小的单文件 Python 脚本。

它不是什么大工程,也没有复杂框架,但我自己写完之后用着挺顺手,所以想把它整理成一篇偏爱好者分享的技术文章,聊聊它到底解决了什么问题、代码为什么这样写、以及这种小工具为什么值得自己动手做一个。

这个脚本只做一件事:

把 ~/.claude/settings.json 里的模型配置,变成交互式选择。

也就是说,不再手动打开配置文件、一行一行改模型名,而是直接拉取可用模型列表,用 fzf 选中后再写回本地配置。

如果你平时会在默认模型、Opus、Sonnet、Haiku 之间切换,或者想顺手决定某个模型是否带上 [1m] 后缀,这种写法会比纯手改 JSON 轻松很多。


它到底做了什么 ​

先用一句话概括这个脚本的功能:

  1. 读取当前 ~/.claude/settings.json
  2. 打印现在的模型配置
  3. 询问你要不要修改
  4. 调用 ANTHROPIC_BASE_URL 对应接口的 /v1/models 拉取模型列表
  5. 用 fzf 让你分别选择默认模型、Opus、Sonnet、Haiku
  6. 额外询问是否给部分模型追加 [1m] 后缀
  7. 把新结果写回 ~/.claude/settings.json

这就是它的全部职责:读取、选择、写回。


完整代码 ​

python
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///

from __future__ import annotations

import json
import shutil
import subprocess
import urllib.request
from pathlib import Path
from typing import cast

SETTINGS_PATH = Path("~/.claude/settings.json").expanduser()
MODEL_KEYS = (
    "ANTHROPIC_MODEL",
    "ANTHROPIC_DEFAULT_OPUS_MODEL",
    "ANTHROPIC_DEFAULT_SONNET_MODEL",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL",
)
MODEL_SELECTIONS = (
    ("ANTHROPIC_MODEL", "默认模型> "),
    ("ANTHROPIC_DEFAULT_OPUS_MODEL", "OPUS 模型> "),
    ("ANTHROPIC_DEFAULT_SONNET_MODEL", "SONNET 模型> "),
    ("ANTHROPIC_DEFAULT_HAIKU_MODEL", "HAIKU 模型> "),
)
ONE_MILLION_SELECTIONS = (
    ("ANTHROPIC_MODEL", "默认模型开启 100 万上下文> "),
    ("ANTHROPIC_DEFAULT_OPUS_MODEL", "OPUS 开启 100 万上下文> "),
    ("ANTHROPIC_DEFAULT_SONNET_MODEL", "SONNET 开启 100 万上下文> "),
)

ModelPayload = dict[str, list[dict[str, str]]]
Settings = dict[str, dict[str, str] | str | bool]
ModelConfig = dict[str, str]


def read_settings() -> Settings:
    return json.loads(SETTINGS_PATH.read_text())


def write_settings(settings: Settings) -> None:
    SETTINGS_PATH.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n")


def fetch_model_names(base_url: str, token: str) -> list[str]:
    request = urllib.request.Request(
        f"{base_url}/v1/models",
        headers={
            "Authorization": f"Bearer {token}",
            "User-Agent": "curl/8.5.0",
        },
        method="GET",
    )
    with urllib.request.urlopen(request) as response:
        payload = json.loads(response.read())
    return sorted(item["id"] for item in payload["data"])


def pick_with_fzf(prompt: str, options: list[str]) -> str:
    return subprocess.run(
        ["fzf", "--prompt", prompt, "--no-sort"],
        input="\n".join(options) + "\n",
        text=True,
        capture_output=True,
        check=True,
    ).stdout.strip()


def confirm_yes_no(prompt: str, *, default: bool = False) -> bool:
    suffix = "[Y/n]" if default else "[y/N]"
    answer = input(f"{prompt} {suffix} ").strip().lower()
    if not answer:
        return default
    return answer in {"y", "yes"}


def append_1m_suffix(model_name: str, enabled: bool) -> str:
    return f"{model_name}[1m]" if enabled else model_name


def read_model_config(env: dict[str, str]) -> ModelConfig:
    return {key: env[key] for key in MODEL_KEYS}


def print_model_config(title: str, config: ModelConfig) -> None:
    print(title)
    for key in MODEL_KEYS:
        print(f"- {key}: {config[key]}")


def select_model_config(models: list[str]) -> ModelConfig:
    config = {
        key: pick_with_fzf(prompt, models)
        for key, prompt in MODEL_SELECTIONS
    }
    for key, prompt in ONE_MILLION_SELECTIONS:
        enabled = confirm_yes_no(prompt, default=False)
        config[key] = append_1m_suffix(config[key], enabled)
    return config


def update_model_config(settings: Settings, config: ModelConfig) -> None:
    cast(dict[str, str], settings["env"]).update(config)


def main() -> int:
    if shutil.which("fzf") is None:
        raise RuntimeError("fzf not found")

    settings = read_settings()
    env = cast(dict[str, str], settings["env"])
    current_config = read_model_config(env)

    print_model_config("当前模型配置:", current_config)
    if not confirm_yes_no("是否更改模型配置?", default=False):
        print("未更改模型配置。")
        return 0

    models = fetch_model_names(env["ANTHROPIC_BASE_URL"], env["ANTHROPIC_AUTH_TOKEN"])
    updated_config = select_model_config(models)

    update_model_config(settings, updated_config)
    write_settings(settings)
    print_model_config("已更新模型配置:", updated_config)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

为什么我会觉得它有分享价值 ​

这种脚本的价值,不在于代码量大,而在于它很像爱好者日常折腾里会自然长出来的东西。

很多时候,我们并不是不会改 JSON,而是:

  • 手动改配置容易出错
  • 改完后不容易一眼确认当前值
  • 一次切 4 个模型项时,重复劳动比较明显
  • 想试不同模型组合时,交互式选择明显更顺手

所以我很喜欢把这种高频但机械的动作,顺手收口成一个小工具。

它未必是一个“正式产品”,但对经常自己折腾终端、脚本和工作流的人来说,这种小东西往往最有分享意义。


先看脚本设计:它是典型的“单文件工具”写法 ​

这个脚本最让我喜欢的一点,就是结构非常收敛。

它没有拆模块,也没有引入额外工程结构,而是把职责压缩在一个文件里:

  • 常量定义
  • 配置读写
  • 远端模型列表获取
  • 终端交互
  • 最终写回

这种写法很适合爱好者自己做的小工具。因为目标不是做成通用 SDK,而是快速、清楚、可维护地解决眼前的问题。


第 1 部分:脚本头部说明了运行方式 ​

python
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///

这几行很短,但信息量很明确:

  • 这是一个脚本化入口,不是完整包工程
  • 运行环境要求 Python 3.12+
  • 没有 Python 第三方依赖

不过要注意一点:

它虽然没有 Python 包依赖,但它依赖系统里有 fzf。

这一点不是通过 dependencies = [] 声明的,而是后面通过 shutil.which("fzf") 在运行时检查出来的。


第 2 部分:常量把目标键位写死,避免到处散落字符串 ​

python
SETTINGS_PATH = Path("~/.claude/settings.json").expanduser()
MODEL_KEYS = (
    "ANTHROPIC_MODEL",
    "ANTHROPIC_DEFAULT_OPUS_MODEL",
    "ANTHROPIC_DEFAULT_SONNET_MODEL",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL",
)

这里做了两件很实用的事。

第一,直接把配置文件路径固定到 ~/.claude/settings.json。

第二,把模型相关 key 全部集中到 MODEL_KEYS 里,后续读取、打印、更新时都复用这一组常量,而不是到处重复硬编码字符串。

这类小工具特别适合这么写:

把会重复使用、而且写错代价高的字符串先收成常量。


第 3 部分:读取与写回配置,逻辑非常干净 ​

python
def read_settings() -> Settings:
    return json.loads(SETTINGS_PATH.read_text())


def write_settings(settings: Settings) -> None:
    SETTINGS_PATH.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n")

这两段几乎没有多余逻辑。

read_settings() ​

  • 直接读文本
  • 直接 json.loads()
  • 不做额外包装

write_settings() ​

  • indent=2:写回后的 JSON 更易读
  • ensure_ascii=False:如果文件里有中文,不会被强制转义成 \uXXXX
  • 最后补一个换行:让文件结尾更规整

这是典型的“本地配置工具”写法:

不追求抽象层次,优先保证读起来一眼就明白。


第 4 部分:模型列表不是手写,而是实时拉取 ​

python
def fetch_model_names(base_url: str, token: str) -> list[str]:
    request = urllib.request.Request(
        f"{base_url}/v1/models",
        headers={
            "Authorization": f"Bearer {token}",
            "User-Agent": "curl/8.5.0",
        },
        method="GET",
    )
    with urllib.request.urlopen(request) as response:
        payload = json.loads(response.read())
    return sorted(item["id"] for item in payload["data"])

这部分是整段脚本里最关键的自动化点。

它不是让你自己维护模型列表,而是直接请求 base_url/v1/models,然后把返回的 payload["data"] 里的 id 全部取出来。

这带来两个好处:

  1. 减少手工维护成本:模型有增减时,不用自己再改脚本里的候选列表
  2. 候选项和服务端保持一致:本地看到什么,取决于接口真实返回了什么

这里还做了一步 sorted(...),所以最终给 fzf 的是按字典序排好的模型 ID 列表。


第 5 部分:fzf 负责交互选择,脚本本身不处理复杂 UI ​

python
def pick_with_fzf(prompt: str, options: list[str]) -> str:
    return subprocess.run(
        ["fzf", "--prompt", prompt, "--no-sort"],
        input="\n".join(options) + "\n",
        text=True,
        capture_output=True,
        check=True,
    ).stdout.strip()

这段我很喜欢,因为思路非常朴素:

  • Python 负责准备候选数据
  • fzf 负责做终端交互
  • Python 再把选择结果接回来

也就是说,它没有自己发明一个选择界面,而是直接借用成熟 CLI 工具。

参数里有两个点值得注意:

  • --prompt:让每一次选择都显示明确提示语
  • --no-sort:告诉 fzf 不要自行重新排序

再结合前面的 sorted(...),整个顺序控制就很清楚:

先由 Python 决定候选顺序,再由 fzf 原样展示。


第 6 部分:是否开启 [1m],用单独确认而不是混在模型选择里 ​

python
def append_1m_suffix(model_name: str, enabled: bool) -> str:
    return f"{model_name}[1m]" if enabled else model_name

后面在 select_model_config() 里,这个函数会配合 confirm_yes_no() 一起用:

python
for key, prompt in ONE_MILLION_SELECTIONS:
    enabled = confirm_yes_no(prompt, default=False)
    config[key] = append_1m_suffix(config[key], enabled)

这里的设计我觉得挺合理。

它没有把“选模型”和“是否追加 [1m]”混成一个超长列表,而是拆成两步:

  1. 先选基础模型名
  2. 再单独确认是否加 [1m]

这样做的好处是交互负担更低,也更容易读懂。

当然,这里也有一个前提:

你的接口体系需要认可 model_name[1m] 这种命名约定。

如果你自己的服务端并不是靠这个后缀表示 100 万上下文,那么这段逻辑就要按你的实际规则调整。


第 7 部分:当前配置先打印,再决定要不要改 ​

python
def print_model_config(title: str, config: ModelConfig) -> None:
    print(title)
    for key in MODEL_KEYS:
        print(f"- {key}: {config[key]}")

再看 main() 里的调用顺序:

python
print_model_config("当前模型配置:", current_config)
if not confirm_yes_no("是否更改模型配置?", default=False):
    print("未更改模型配置。")
    return 0

这个顺序非常符合真实使用习惯:

  • 先把当前状态展示出来
  • 然后再问你要不要继续
  • 如果不改,直接退出

这一步看起来小,但非常重要,因为它避免了“脚本一运行就开始改配置”的冒进感。

对本地工具来说,先展示、再确认、再落盘,通常是比“直接执行”更稳妥的交互顺序。


第 8 部分:真正写回时,只更新 env 里的目标键 ​

python
def update_model_config(settings: Settings, config: ModelConfig) -> None:
    cast(dict[str, str], settings["env"]).update(config)

这行的重点不在 cast(),而在 update(config)。

它的意思很明确:

  • 不重写整个 settings 结构
  • 只更新 settings["env"] 里的目标模型项
  • 其他无关配置保持不动

这就是我很认同的一种“最小修改”思路。

哪怕只是个人脚本,也尽量别做无关覆盖。只碰需要改的那几个 key,风险会小很多。


第 9 部分:main() 把整个流程串起来了 ​

如果把这段脚本抽象成流程图,其实非常简单:

  1. 检查 fzf 是否存在
  2. 读取 settings.json
  3. 取出当前 env
  4. 打印当前模型配置
  5. 用户确认是否继续
  6. 拉取模型列表
  7. 逐项交互选择
  8. 更新内存中的配置对象
  9. 写回磁盘
  10. 打印更新后的结果

这种写法的好处是:

所有流程都在 main() 一眼看完。

你不需要跳很多文件,也不用来回追调用链,非常适合这种一次性就想看懂的小工具。


这个脚本适合什么场景 ​

我觉得它适合下面这些场景:

  • 你本来就通过 ~/.claude/settings.json 管理模型相关环境变量
  • 你经常在多种模型之间切换
  • 你不想每次都手改 JSON
  • 你本机已经装了 fzf
  • 你当前 ANTHROPIC_BASE_URL 对应接口能正常返回 /v1/models

如果这些条件都满足,这个脚本的体验会很顺。


也要说清楚它的边界 ​

分享这种脚本时,我觉得把边界讲清楚比单纯夸“好用”更重要。

这份代码按当前写法,有几个前提:

1. settings["env"] 必须存在 ​

代码里直接写了:

python
env = cast(dict[str, str], settings["env"])

如果你的 settings.json 没有 env 字段,这里会直接报错。

2. 目标模型 key 必须已经存在 ​

read_model_config() 用的是:

python
return {key: env[key] for key in MODEL_KEYS}

这意味着如果缺任何一个 key,就会触发 KeyError。

3. 远端接口返回结构必须符合预期 ​

fetch_model_names() 默认返回 JSON 里有 data,并且每个元素都有 id。

如果你的接口返回格式不同,这段逻辑也需要跟着改。

4. [1m] 后缀是约定,不是通用标准 ​

这不是 Python 语法问题,而是你的模型命名约定问题。

脚本只是机械地拼接字符串,不会替你判断服务端是否真的支持这个名字。


我为什么喜欢这种脚本 ​

因为它非常像“给自己用的好工具”应有的样子:

  • 单文件
  • 标准库优先
  • 逻辑直给
  • 依赖少
  • 修改范围小
  • 用完就能解决问题

它不试图变成一个大而全的配置管理器,也不试图抽象出复杂框架。

它更像是一个爱好者在真实使用里,为了让自己的工作流更顺一点,慢慢磨出来的小工具。

它只是把“模型切换”这件事,做得比手改 JSON 更顺手一点。

很多时候,这种小改进反而最有实际价值。


结语 ​

如果让我给这个脚本下一个简单定义,我会说:

它是一个面向个人工作流的交互式模型配置切换器。

代码不长,但思路很完整:

  • 先读当前配置
  • 再拉远端模型列表
  • 用 fzf 做选择
  • 最后最小范围写回本地设置

它不是什么“标准答案”,也不是要替所有人设计一套通用方案。

但如果你刚好也喜欢折腾终端、喜欢把重复动作变顺手、也愿意为自己的工作流写一些小工具,那这类单文件脚本其实特别值得分享。

它不惊艳,却很实用;不大,却足够解决真实问题。对爱好者来说,这往往就是最有意思的部分。

Nine and AI — Licensed under the MIT License