cc-switch 0.9.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cc_switch/__init__.py +50 -0
- cc_switch/__main__.py +6 -0
- cc_switch/_activate.py +142 -0
- cc_switch/_api.py +105 -0
- cc_switch/_defaults.py +55 -0
- cc_switch/cli.py +288 -0
- cc_switch-0.9.6.dist-info/METADATA +138 -0
- cc_switch-0.9.6.dist-info/RECORD +10 -0
- cc_switch-0.9.6.dist-info/WHEEL +4 -0
- cc_switch-0.9.6.dist-info/entry_points.txt +2 -0
cc_switch/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""cc-switch — generate ~/.claude/settings.json from saved provider/model configs.
|
|
2
|
+
|
|
3
|
+
A standalone CLI (mirroring the credstore pattern) that keeps the
|
|
4
|
+
non-secret *shape* of a Claude Code provider setup in
|
|
5
|
+
``~/.claude/cc-switch.json`` and materialises it into
|
|
6
|
+
``~/.claude/settings.json``. Secrets never touch settings.json — the
|
|
7
|
+
API key is read from credstore at activate time and injected only into
|
|
8
|
+
the system environment as ``ANTHROPIC_AUTH_TOKEN``.
|
|
9
|
+
|
|
10
|
+
Modules::
|
|
11
|
+
|
|
12
|
+
cc_switch Package API (get/save/remove/list/activate)
|
|
13
|
+
cc_switch.cli Command-line interface (entry point ``cc-switch``)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from cc_switch._api import (
|
|
17
|
+
CONFIG_PATH,
|
|
18
|
+
add_provider,
|
|
19
|
+
list_providers,
|
|
20
|
+
remove_provider,
|
|
21
|
+
load_config,
|
|
22
|
+
save_config,
|
|
23
|
+
update_provider,
|
|
24
|
+
)
|
|
25
|
+
from cc_switch._defaults import (
|
|
26
|
+
DEFAULT_ENV,
|
|
27
|
+
DEFAULT_OVERRIDE_KEYS,
|
|
28
|
+
DEFAULT_SETTINGS,
|
|
29
|
+
MAIN_MODEL_SLOT_KEYS,
|
|
30
|
+
default_value,
|
|
31
|
+
list_default_override_keys,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "0.9.6"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CONFIG_PATH",
|
|
38
|
+
"DEFAULT_ENV",
|
|
39
|
+
"DEFAULT_OVERRIDE_KEYS",
|
|
40
|
+
"DEFAULT_SETTINGS",
|
|
41
|
+
"MAIN_MODEL_SLOT_KEYS",
|
|
42
|
+
"add_provider",
|
|
43
|
+
"default_value",
|
|
44
|
+
"list_default_override_keys",
|
|
45
|
+
"list_providers",
|
|
46
|
+
"load_config",
|
|
47
|
+
"remove_provider",
|
|
48
|
+
"save_config",
|
|
49
|
+
"update_provider",
|
|
50
|
+
]
|
cc_switch/__main__.py
ADDED
cc_switch/_activate.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Activation — materialise a provider/model into ``~/.claude/settings.json``.
|
|
2
|
+
|
|
3
|
+
Plain ``activate`` fills every model slot with the stock Claude Code
|
|
4
|
+
defaults from :mod:`cc_switch._defaults`; ``--custom`` lets the user
|
|
5
|
+
override each env key interactively before writing.
|
|
6
|
+
|
|
7
|
+
The secret is read from credstore (by the *api_key_name* referenced in
|
|
8
|
+
the provider config) and injected into the **system environment** as
|
|
9
|
+
``ANTHROPIC_AUTH_TOKEN`` — mirroring ``credstore inject`` (registry on
|
|
10
|
+
Windows, shell profile on Unix). The generated settings.json never
|
|
11
|
+
contains a credential line, and the env injection survives the transient
|
|
12
|
+
cc-switch process so a new Claude Code session picks it up.
|
|
13
|
+
|
|
14
|
+
If the secret is missing from credstore, activation fails loudly instead
|
|
15
|
+
of writing an unusable settings.json.
|
|
16
|
+
|
|
17
|
+
Memory safety: the keyring value is fetched, used, then ``del``-ed
|
|
18
|
+
immediately.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from cc_switch import _defaults
|
|
27
|
+
|
|
28
|
+
# Output path — overridable for tests via monkeypatch.
|
|
29
|
+
SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SecretNotFoundError(RuntimeError):
|
|
33
|
+
"""Raised when a provider's api_key_name is not stored in credstore."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_secret(api_key_name: str) -> str | None:
|
|
37
|
+
"""Read *api_key_name* from credstore (system keyring).
|
|
38
|
+
|
|
39
|
+
Returns the secret, or None when the key is not stored or credstore
|
|
40
|
+
is unavailable. The caller must ``del`` the returned value after
|
|
41
|
+
use. This is the only place cc-switch touches secret material.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
from credstore import get_credential
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
return get_credential(api_key_name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_env(provider: dict, model_name: str, overrides: dict | None = None) -> dict:
|
|
51
|
+
"""Build the env block for settings.json.
|
|
52
|
+
|
|
53
|
+
*overrides* maps env key → value and is used by ``--custom``; a
|
|
54
|
+
missing key falls back to ``default_value`` — the main-model slots
|
|
55
|
+
inherit *model_name* (never empty), the rest keep their static
|
|
56
|
+
default. Always + ``ANTHROPIC_BASE_URL`` + model — never a secret.
|
|
57
|
+
"""
|
|
58
|
+
env: dict[str, str] = {}
|
|
59
|
+
# 1. Every override slot (with the override value where provided)
|
|
60
|
+
for key in _defaults.DEFAULT_OVERRIDE_KEYS:
|
|
61
|
+
if overrides and key in overrides:
|
|
62
|
+
env[key] = str(overrides[key])
|
|
63
|
+
else:
|
|
64
|
+
env[key] = _defaults.default_value(key, model_name)
|
|
65
|
+
# 2. Provider-specific env overrides (applied after the slots)
|
|
66
|
+
env.update(provider.get("extra_env") or {})
|
|
67
|
+
# 3. Provider base URL + chosen model
|
|
68
|
+
env["ANTHROPIC_BASE_URL"] = provider["base_url"]
|
|
69
|
+
env["ANTHROPIC_MODEL"] = model_name
|
|
70
|
+
return env
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_settings(provider: dict, model_name: str, overrides: dict | None = None) -> dict:
|
|
74
|
+
"""Return the full settings.json dict (no secret material)."""
|
|
75
|
+
settings = dict(_defaults.DEFAULT_SETTINGS)
|
|
76
|
+
settings["env"] = build_env(provider, model_name, overrides)
|
|
77
|
+
return settings
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def write_settings(settings: dict) -> None:
|
|
81
|
+
"""Write the settings dict to ``~/.claude/settings.json`` (overrides path)."""
|
|
82
|
+
import json
|
|
83
|
+
|
|
84
|
+
target = SETTINGS_PATH
|
|
85
|
+
parent = os.path.dirname(target)
|
|
86
|
+
if parent:
|
|
87
|
+
os.makedirs(parent, exist_ok=True)
|
|
88
|
+
with open(target, "w", encoding="utf-8") as fh:
|
|
89
|
+
json.dump(settings, fh, ensure_ascii=False, indent=2)
|
|
90
|
+
fh.write("\n")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def inject_token(secret: str, shell: str = "auto", output=None) -> None:
|
|
94
|
+
"""Persist *secret* as ``ANTHROPIC_AUTH_TOKEN`` in the system environment.
|
|
95
|
+
|
|
96
|
+
Mirrors ``credstore inject``: writes the value to the system env via
|
|
97
|
+
``persist_key`` (registry on Windows, shell profile on Unix) so a
|
|
98
|
+
freshly launched Claude Code session inherits it. When *output* is
|
|
99
|
+
a TTY it prints an activation hint (no secret); otherwise it prints
|
|
100
|
+
the export line for the current shell.
|
|
101
|
+
"""
|
|
102
|
+
from credstore._shell import format_export, persist_key
|
|
103
|
+
|
|
104
|
+
persist_key("ANTHROPIC_AUTH_TOKEN", secret, shell)
|
|
105
|
+
|
|
106
|
+
out = output if output is not None else sys.stdout
|
|
107
|
+
if out.isatty():
|
|
108
|
+
print(
|
|
109
|
+
"Injected ANTHROPIC_AUTH_TOKEN into the system environment.",
|
|
110
|
+
file=out,
|
|
111
|
+
)
|
|
112
|
+
print(
|
|
113
|
+
"Restart your shell (or start a new terminal) for it to take effect.",
|
|
114
|
+
file=out,
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
print(format_export("ANTHROPIC_AUTH_TOKEN", secret, shell), file=out)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def activate(provider: dict, model_name: str, overrides: dict | None = None,
|
|
121
|
+
shell: str = "auto", output=None) -> dict:
|
|
122
|
+
"""Generate, write settings.json, and inject the secret into the system env.
|
|
123
|
+
|
|
124
|
+
Writes settings.json first (so the config shape is in place even if
|
|
125
|
+
the token lookup then fails), then resolves the API key from
|
|
126
|
+
credstore. A missing key raises :class:`SecretNotFoundError` —
|
|
127
|
+
never a silent skip. Returns the settings dict that was written.
|
|
128
|
+
"""
|
|
129
|
+
settings = build_settings(provider, model_name, overrides)
|
|
130
|
+
write_settings(settings)
|
|
131
|
+
|
|
132
|
+
secret = resolve_secret(provider["api_key_name"])
|
|
133
|
+
if secret is None:
|
|
134
|
+
raise SecretNotFoundError(
|
|
135
|
+
f"'{provider['api_key_name']}' is not in credstore.\n"
|
|
136
|
+
f"Store it first: credstore set {provider['api_key_name']}"
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
inject_token(secret, shell=shell, output=output)
|
|
140
|
+
finally:
|
|
141
|
+
del secret
|
|
142
|
+
return settings
|
cc_switch/_api.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Persistent provider/model configuration storage.
|
|
2
|
+
|
|
3
|
+
The non-secret *shape* of a Claude Code provider setup lives in
|
|
4
|
+
``~/.claude/cc-switch.json`` (path overridable via ``CC_SWITCH_FILE``).
|
|
5
|
+
Only provider metadata is stored here — never API keys. The secret is
|
|
6
|
+
referenced by *name* (the ``api_key_name`` field) and resolved through
|
|
7
|
+
credstore at activate time.
|
|
8
|
+
|
|
9
|
+
File format::
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"providers": {
|
|
13
|
+
"deepseek": {
|
|
14
|
+
"base_url": "https://api.deepseek.com/anthropic",
|
|
15
|
+
"api_key_name": "DEEPSEEK_API_KEY",
|
|
16
|
+
"models": ["deepseek-chat", "deepseek-reasoner"],
|
|
17
|
+
"extra_env": {}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
CONFIG_PATH = Path(os.environ.get("CC_SWITCH_FILE", str(Path.home() / ".claude" / "cc-switch.json")))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_config() -> dict:
|
|
32
|
+
"""Load provider configs from ``~/.claude/cc-switch.json``.
|
|
33
|
+
|
|
34
|
+
Returns a dict with a ``"providers"`` key (possibly empty). A
|
|
35
|
+
missing or unparseable file yields ``{"providers": {}}``.
|
|
36
|
+
"""
|
|
37
|
+
import json
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
return {"providers": {}}
|
|
43
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("providers"), dict):
|
|
44
|
+
return {"providers": {}}
|
|
45
|
+
return raw
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_config(data: dict) -> None:
|
|
49
|
+
"""Persist the whole config dict to ``~/.claude/cc-switch.json``."""
|
|
50
|
+
import json
|
|
51
|
+
|
|
52
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
CONFIG_PATH.write_text(
|
|
54
|
+
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_providers() -> dict:
|
|
60
|
+
return load_config().get("providers", {})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def add_provider(name: str, base_url: str, api_key_name: str, models: list[str], extra_env: dict | None = None) -> None:
|
|
64
|
+
"""Add a new provider, or replace an existing one with the same name."""
|
|
65
|
+
data = load_config()
|
|
66
|
+
data.setdefault("providers", {})[name] = {
|
|
67
|
+
"base_url": base_url,
|
|
68
|
+
"api_key_name": api_key_name,
|
|
69
|
+
"models": list(models),
|
|
70
|
+
"extra_env": dict(extra_env or {}),
|
|
71
|
+
}
|
|
72
|
+
save_config(data)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def update_provider(name: str, base_url: str | None = None, api_key_name: str | None = None,
|
|
76
|
+
models: list[str] | None = None, extra_env: dict | None = None) -> None:
|
|
77
|
+
"""Merge changes into an existing provider. Raises KeyError if unknown."""
|
|
78
|
+
providers = _get_providers()
|
|
79
|
+
if name not in providers:
|
|
80
|
+
raise KeyError(name)
|
|
81
|
+
provider = providers[name]
|
|
82
|
+
if base_url is not None:
|
|
83
|
+
provider["base_url"] = base_url
|
|
84
|
+
if api_key_name is not None:
|
|
85
|
+
provider["api_key_name"] = api_key_name
|
|
86
|
+
if models is not None:
|
|
87
|
+
provider["models"] = list(models)
|
|
88
|
+
if extra_env is not None:
|
|
89
|
+
provider["extra_env"] = dict(extra_env)
|
|
90
|
+
save_config({"providers": providers})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove_provider(name: str) -> bool:
|
|
94
|
+
"""Delete a provider. Returns True if it existed."""
|
|
95
|
+
providers = _get_providers()
|
|
96
|
+
if name not in providers:
|
|
97
|
+
return False
|
|
98
|
+
del providers[name]
|
|
99
|
+
save_config({"providers": providers})
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def list_providers() -> list[str]:
|
|
104
|
+
"""Return provider names, sorted."""
|
|
105
|
+
return sorted(_get_providers().keys())
|
cc_switch/_defaults.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Default settings template for the generated ``~/.claude/settings.json``.
|
|
2
|
+
|
|
3
|
+
The Claude Code convention: the other model slots
|
|
4
|
+
(``ANTHROPIC_DEFAULT_HAIKU_MODEL`` / ``_SONNET_`` / ``_OPUS_`` /
|
|
5
|
+
``CLAUDE_CODE_SUBAGENT_MODEL``) default to the **main model** picked on
|
|
6
|
+
the command line (``ANTHROPIC_MODEL``), and are never left empty.
|
|
7
|
+
``activate --custom`` starts each of those slots at the main model so the
|
|
8
|
+
user can change them individually.
|
|
9
|
+
|
|
10
|
+
Only the non-model slots have a static default
|
|
11
|
+
(``ANTHROPIC_CLAUDE_CODE_EFFORT_LEVEL: medium``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
# The model slots that default to the main model (ANTHROPIC_MODEL).
|
|
17
|
+
# ANTHROPIC_MODEL itself is NOT here — it is chosen on the command line.
|
|
18
|
+
MAIN_MODEL_SLOT_KEYS = [
|
|
19
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
20
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
21
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
22
|
+
"ANTHROPIC_CLAUDE_CODE_SUBAGENT_MODEL",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Env keys that ``activate --custom`` prompts for, in order.
|
|
26
|
+
DEFAULT_OVERRIDE_KEYS = list(MAIN_MODEL_SLOT_KEYS) + [
|
|
27
|
+
"ANTHROPIC_CLAUDE_CODE_EFFORT_LEVEL",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Static default for the non-model slots.
|
|
31
|
+
DEFAULT_ENV: dict[str, str] = {
|
|
32
|
+
"ANTHROPIC_CLAUDE_CODE_EFFORT_LEVEL": "medium",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Frozen template for the settings.json body — never a credential here.
|
|
36
|
+
DEFAULT_SETTINGS = {
|
|
37
|
+
"env": {},
|
|
38
|
+
"autoUpdatesChannel": "latest",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_value(key: str, main_model: str) -> str:
|
|
43
|
+
"""Return the default for an env key given the main model.
|
|
44
|
+
|
|
45
|
+
Model slots inherit the main model; non-model slots use their static
|
|
46
|
+
default.
|
|
47
|
+
"""
|
|
48
|
+
if key in MAIN_MODEL_SLOT_KEYS:
|
|
49
|
+
return main_model
|
|
50
|
+
return DEFAULT_ENV[key]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_default_override_keys() -> list[str]:
|
|
54
|
+
"""Return the env keys that ``activate --custom`` prompts for, in order."""
|
|
55
|
+
return list(DEFAULT_OVERRIDE_KEYS)
|
cc_switch/cli.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""cc-switch CLI — terminal commands for Claude Code settings generation.
|
|
2
|
+
|
|
3
|
+
Commands::
|
|
4
|
+
|
|
5
|
+
cc-switch set <provider> [--name NAME] [--models M1,M2]
|
|
6
|
+
Create or edit a provider config (base URL + API key name,
|
|
7
|
+
plus optional models / display name). Secrets are never stored.
|
|
8
|
+
cc-switch remove <provider>
|
|
9
|
+
Delete a provider config.
|
|
10
|
+
cc-switch activate <provider>[/<model>]
|
|
11
|
+
Write ~/.claude/settings.json from defaults, inject the API key
|
|
12
|
+
from credstore into ANTHROPIC_AUTH_TOKEN (env only).
|
|
13
|
+
cc-switch activate <provider>[/<model>] --custom
|
|
14
|
+
Interactive override of every model slot, then write.
|
|
15
|
+
cc-switch list
|
|
16
|
+
List providers and their models as provider/model.
|
|
17
|
+
cc-switch list-providers
|
|
18
|
+
Show all providers and their models (list is for provider/model rows).
|
|
19
|
+
|
|
20
|
+
Non-secret interactive values use plain ``input()``. The API key name
|
|
21
|
+
is stored in the config; the key value is read from credstore only at
|
|
22
|
+
activate time and never written to any file.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
from cc_switch import _activate, _api, _defaults
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _err(msg: str) -> None:
|
|
35
|
+
print(f"Error: {msg}", file=sys.stderr)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_provider_model(spec: str) -> tuple[str, str | None]:
|
|
39
|
+
"""Split 'provider/model' into (provider, model-or-None)."""
|
|
40
|
+
if "/" in spec:
|
|
41
|
+
provider, model = spec.split("/", 1)
|
|
42
|
+
return provider.strip(), model.strip() or None
|
|
43
|
+
return spec.strip(), None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _prompt_required(prompt: str, current: str = "") -> str:
|
|
47
|
+
"""Prompt for a required value.
|
|
48
|
+
|
|
49
|
+
When *current* is non-empty (editing), Enter keeps it — blank never
|
|
50
|
+
re-prompts. When adding (no current), blank re-prompts.
|
|
51
|
+
"""
|
|
52
|
+
hint = f" [{current}]" if current else ""
|
|
53
|
+
while True:
|
|
54
|
+
value = input(f"{prompt}{hint}: ").strip()
|
|
55
|
+
if value or current:
|
|
56
|
+
return value or current
|
|
57
|
+
print("(required — cannot be empty)", file=sys.stderr)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _prompt_optional(prompt: str, current: str = "") -> str:
|
|
61
|
+
"""Prompt for an optional value; blank keeps the current / empty."""
|
|
62
|
+
hint = f" [{current}]" if current else " [blank = empty]"
|
|
63
|
+
value = input(f"{prompt}{hint}: ").strip()
|
|
64
|
+
return value or current or ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_models(text: str) -> list[str]:
|
|
68
|
+
"""Split a model list on commas, whitespace, and/or semicolons."""
|
|
69
|
+
for sep in (",", ";"):
|
|
70
|
+
text = text.replace(sep, " ")
|
|
71
|
+
return [m.strip() for m in text.split() if m.strip()]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── set ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cmd_set(args) -> int:
|
|
78
|
+
"""Create or edit a provider. Exists → edit, missing → add."""
|
|
79
|
+
name = args.provider
|
|
80
|
+
providers = _api.load_config().get("providers", {})
|
|
81
|
+
current = providers.get(name)
|
|
82
|
+
is_edit = current is not None
|
|
83
|
+
|
|
84
|
+
if is_edit:
|
|
85
|
+
print(f"Editing provider '{name}' (press Enter to keep current value).")
|
|
86
|
+
else:
|
|
87
|
+
print(f"Adding provider '{name}'.")
|
|
88
|
+
|
|
89
|
+
base_url = _prompt_required("Base URL", current["base_url"] if current else "")
|
|
90
|
+
api_key_name = _prompt_required("API key name (credstore key)", current["api_key_name"] if current else "")
|
|
91
|
+
models_text = _prompt_optional(
|
|
92
|
+
"Supported models (comma, space, or semicolon separated)",
|
|
93
|
+
", ".join(current["models"]) if current else "",
|
|
94
|
+
)
|
|
95
|
+
models = _parse_models(models_text)
|
|
96
|
+
extra_env = current.get("extra_env") if current else None
|
|
97
|
+
_api.add_provider(name, base_url, api_key_name, models, extra_env=extra_env)
|
|
98
|
+
print(f"Provider '{name}' saved.")
|
|
99
|
+
if models:
|
|
100
|
+
print(f" models: {', '.join(models)}")
|
|
101
|
+
print(f" Activate with: cc-switch activate {name}/<model>")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── remove ───────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _cmd_remove(args) -> int:
|
|
109
|
+
if _api.remove_provider(args.provider):
|
|
110
|
+
print(f"Provider '{args.provider}' removed.")
|
|
111
|
+
return 0
|
|
112
|
+
_err(f"provider '{args.provider}' not found.")
|
|
113
|
+
return 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── activate ─────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _collect_overrides(main_model: str) -> dict:
|
|
120
|
+
"""Interactive override pass for ``activate --custom``.
|
|
121
|
+
|
|
122
|
+
Main-model slots default to *main_model* (the CLI-chosen model);
|
|
123
|
+
the effort level defaults to its static value. A blank response
|
|
124
|
+
keeps the default; a value of ``-`` clears the field to an empty
|
|
125
|
+
string.
|
|
126
|
+
"""
|
|
127
|
+
overrides: dict[str, str] = {}
|
|
128
|
+
print("Custom activation — enter a new value, or Enter to keep the default.")
|
|
129
|
+
for key in _defaults.DEFAULT_OVERRIDE_KEYS:
|
|
130
|
+
current = _defaults.default_value(key, main_model)
|
|
131
|
+
hint = ""
|
|
132
|
+
if key == "ANTHROPIC_CLAUDE_CODE_EFFORT_LEVEL":
|
|
133
|
+
hint = " (low|medium|high|xhigh|max)"
|
|
134
|
+
value = input(f"{key} [{current}]{hint}: ").strip()
|
|
135
|
+
if value:
|
|
136
|
+
# '-' clears the field to an empty string; anything else overrides.
|
|
137
|
+
overrides[key] = "" if value == "-" else value
|
|
138
|
+
return overrides
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cmd_activate(args) -> int:
|
|
142
|
+
provider_name, model_name = _parse_provider_model(args.provider_model)
|
|
143
|
+
providers = _api.load_config().get("providers", {})
|
|
144
|
+
provider = providers.get(provider_name)
|
|
145
|
+
if provider is None:
|
|
146
|
+
_err(f"provider '{provider_name}' not found. Add it first: cc-switch set {provider_name}")
|
|
147
|
+
return 1
|
|
148
|
+
|
|
149
|
+
if model_name is None:
|
|
150
|
+
models = provider.get("models") or []
|
|
151
|
+
if len(models) == 1:
|
|
152
|
+
model_name = models[0]
|
|
153
|
+
elif not models:
|
|
154
|
+
model_name = _prompt_required("Model name")
|
|
155
|
+
else:
|
|
156
|
+
print("Available models:")
|
|
157
|
+
for i, m in enumerate(models, 1):
|
|
158
|
+
print(f" {i}. {m}")
|
|
159
|
+
try:
|
|
160
|
+
choice = int(input("Select a model by number: ").strip())
|
|
161
|
+
model_name = models[choice - 1]
|
|
162
|
+
except (ValueError, IndexError):
|
|
163
|
+
_err("invalid selection.")
|
|
164
|
+
return 1
|
|
165
|
+
elif model_name not in (provider.get("models") or []):
|
|
166
|
+
print(f"Note: model '{model_name}' is not in the provider's model list.", file=sys.stderr)
|
|
167
|
+
|
|
168
|
+
overrides = _collect_overrides(model_name) if args.custom else None
|
|
169
|
+
|
|
170
|
+
# --custom never touches the stored config; the overrides are one-shot.
|
|
171
|
+
try:
|
|
172
|
+
settings = _activate.activate(
|
|
173
|
+
provider, model_name, overrides=overrides,
|
|
174
|
+
shell=args.shell,
|
|
175
|
+
)
|
|
176
|
+
except _activate.SecretNotFoundError as exc:
|
|
177
|
+
_err(str(exc))
|
|
178
|
+
_err("settings.json was not updated — store the key, then re-run activate.")
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
print(f"Activated {provider_name}/{model_name}.")
|
|
182
|
+
print(f" Wrote: {_activate.SETTINGS_PATH}")
|
|
183
|
+
print(f" Injected ANTHROPIC_AUTH_TOKEN from credstore '{provider['api_key_name']}'.")
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── list ─────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cmd_list(args) -> int:
|
|
191
|
+
"""List providers and their models as provider/model rows."""
|
|
192
|
+
providers = _api.load_config().get("providers", {})
|
|
193
|
+
if not providers:
|
|
194
|
+
print("No providers configured.")
|
|
195
|
+
print("Add one with: cc-switch set <provider-name>")
|
|
196
|
+
return 0
|
|
197
|
+
for name in sorted(providers):
|
|
198
|
+
provider = providers[name]
|
|
199
|
+
models = provider.get("models") or []
|
|
200
|
+
if not models:
|
|
201
|
+
print(f"{name}/<no models — run 'cc-switch set {name}' to add>")
|
|
202
|
+
continue
|
|
203
|
+
for model in models:
|
|
204
|
+
print(f"{name}/{model}")
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _cmd_list_providers(args) -> int:
|
|
209
|
+
"""Show providers with their metadata (base URL, API key name)."""
|
|
210
|
+
providers = _api.load_config().get("providers", {})
|
|
211
|
+
if not providers:
|
|
212
|
+
print("No providers configured.")
|
|
213
|
+
return 0
|
|
214
|
+
for name in sorted(providers):
|
|
215
|
+
provider = providers[name]
|
|
216
|
+
models = provider.get("models") or []
|
|
217
|
+
print(f"{name}:")
|
|
218
|
+
print(f" base_url: {provider.get('base_url', '')}")
|
|
219
|
+
print(f" api_key_name: {provider.get('api_key_name', '')}")
|
|
220
|
+
print(f" models: {', '.join(models) if models else '(none)'}")
|
|
221
|
+
extra = provider.get("extra_env") or {}
|
|
222
|
+
if extra:
|
|
223
|
+
print(f" extra_env: {json.dumps(extra)}")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── dispatch ─────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
231
|
+
parser = argparse.ArgumentParser(
|
|
232
|
+
prog="cc-switch",
|
|
233
|
+
description="Generate ~/.claude/settings.json from saved provider/model configs.",
|
|
234
|
+
)
|
|
235
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
236
|
+
|
|
237
|
+
set_p = sub.add_parser("set", help="Create or edit a provider config")
|
|
238
|
+
set_p.add_argument("provider", help="Provider name, e.g. deepseek")
|
|
239
|
+
|
|
240
|
+
rem_p = sub.add_parser("remove", help="Delete a provider config")
|
|
241
|
+
rem_p.add_argument("provider", help="Provider name")
|
|
242
|
+
|
|
243
|
+
act_p = sub.add_parser("activate", help="Write ~/.claude/settings.json and inject the API key into the env")
|
|
244
|
+
act_p.add_argument("provider_model", help="Provider/model, e.g. deepseek/deepseek-chat")
|
|
245
|
+
act_p.add_argument("--custom", action="store_true",
|
|
246
|
+
help="Interactively override every model slot before writing")
|
|
247
|
+
act_p.add_argument("--shell", choices=["auto", "bash", "powershell", "cmd"],
|
|
248
|
+
default="auto",
|
|
249
|
+
help="Shell format for the exported env var (default: auto-detect)")
|
|
250
|
+
|
|
251
|
+
sub.add_parser("list", help="List providers and models as provider/model")
|
|
252
|
+
sub.add_parser("list-providers", help="Show providers with their base URL and API key name")
|
|
253
|
+
|
|
254
|
+
return parser
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main(argv: list[str] | None = None) -> int:
|
|
258
|
+
"""CLI entry point. Returns exit code."""
|
|
259
|
+
if argv is None:
|
|
260
|
+
argv = sys.argv[1:]
|
|
261
|
+
|
|
262
|
+
parser = _build_parser()
|
|
263
|
+
args = parser.parse_args(argv)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
if args.command == "set":
|
|
267
|
+
return _cmd_set(args)
|
|
268
|
+
elif args.command == "remove":
|
|
269
|
+
return _cmd_remove(args)
|
|
270
|
+
elif args.command == "activate":
|
|
271
|
+
return _cmd_activate(args)
|
|
272
|
+
elif args.command == "list":
|
|
273
|
+
return _cmd_list(args)
|
|
274
|
+
elif args.command == "list-providers":
|
|
275
|
+
return _cmd_list_providers(args)
|
|
276
|
+
else:
|
|
277
|
+
parser.print_help()
|
|
278
|
+
return 1
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
print("\nCancelled.")
|
|
281
|
+
return 130
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
_err(str(exc))
|
|
284
|
+
return 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cc-switch
|
|
3
|
+
Version: 0.9.6
|
|
4
|
+
Summary: Generate ~/.claude/settings.json from saved provider/model configs
|
|
5
|
+
Project-URL: Homepage, https://github.com/juzcn/slife
|
|
6
|
+
Project-URL: Repository, https://github.com/juzcn/slife
|
|
7
|
+
Author-email: juzcn <zhangjun@cueb.edu.cn>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Terminals
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Requires-Dist: credstore
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# cc-switch
|
|
21
|
+
|
|
22
|
+
Generate `~/.claude/settings.json` from saved provider/model configs —
|
|
23
|
+
a small CLI that mirrors the [credstore](../credstore/README.md) pattern.
|
|
24
|
+
|
|
25
|
+
Non-secret provider *shape* lives in `~/.claude/cc-switch.json`. API
|
|
26
|
+
keys are **never** stored there — the config keeps the key's *name*,
|
|
27
|
+
and the value is read from credstore at activate time and injected into
|
|
28
|
+
the current process environment as `ANTHROPIC_AUTH_TOKEN`. The
|
|
29
|
+
generated `settings.json` contains no credential line.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
cc-switch is a standalone PyPI package — installed independently of
|
|
34
|
+
slife (installing slife does **not** bring cc-switch, and vice versa).
|
|
35
|
+
Both depend on [credstore](../credstore/README.md), which is pulled in
|
|
36
|
+
automatically.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv tool install cc-switch
|
|
40
|
+
# or, in this repo:
|
|
41
|
+
uv sync
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
### `cc-switch set <provider-name>`
|
|
47
|
+
|
|
48
|
+
Create or edit a provider. Prompts for:
|
|
49
|
+
|
|
50
|
+
- **Base URL** (required)
|
|
51
|
+
- **API key name** — the credstore key holding the secret (required)
|
|
52
|
+
- **Supported models** (optional; comma, space, or semicolon separated)
|
|
53
|
+
|
|
54
|
+
If the provider already exists this *edits* it; otherwise it *adds* it.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cc-switch set deepseek
|
|
58
|
+
# Base URL [..]: https://api.deepseek.com/anthropic
|
|
59
|
+
# API key name (credstore key): DEEPSEEK_API_KEY
|
|
60
|
+
# Supported models (comma separated): deepseek-chat,deepseek-reasoner
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
No secret value is ever asked for or written — store it first with:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
credstore set DEEPSEEK_API_KEY
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### `cc-switch activate <provider-name/model-name>`
|
|
70
|
+
|
|
71
|
+
Writes `~/.claude/settings.json` with stock Claude Code defaults, and
|
|
72
|
+
injects the API key from credstore into the system environment as
|
|
73
|
+
`ANTHROPIC_AUTH_TOKEN` (mirroring `credstore inject` — registry on
|
|
74
|
+
Windows, shell profile on Unix), so a freshly launched Claude Code
|
|
75
|
+
session inherits it. The settings file contains **no** credential.
|
|
76
|
+
|
|
77
|
+
If the provider's API key is not in credstore, `activate` fails loudly
|
|
78
|
+
with a `credstore set <key>` hint instead of writing an unusable setup.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cc-switch activate deepseek/deepseek-chat
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If the model is omitted and the provider has exactly one model, it is
|
|
85
|
+
used; if several, you are prompted to pick one.
|
|
86
|
+
|
|
87
|
+
### `cc-switch activate <provider-name/model-name> --custom`
|
|
88
|
+
|
|
89
|
+
Like `activate`, but lets you override every model slot first —
|
|
90
|
+
`ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`,
|
|
91
|
+
`ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_CLAUDE_CODE_SUBAGENT_MODEL`,
|
|
92
|
+
`ANTHROPIC_MODEL`, `ANTHROPIC_CLAUDE_CODE_EFFORT_LEVEL` — one at a time.
|
|
93
|
+
Enter a value to override, or Enter to keep the default. Overrides are
|
|
94
|
+
one-shot: they never touch the stored provider config.
|
|
95
|
+
|
|
96
|
+
### `cc-switch list`
|
|
97
|
+
|
|
98
|
+
Lists every configured provider/model pair, one per line:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
deepseek/deepseek-chat
|
|
102
|
+
deepseek/deepseek-reasoner
|
|
103
|
+
scnet/scnet-1m
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `cc-switch list-providers`
|
|
107
|
+
|
|
108
|
+
Shows provider metadata (base URL, API key name, models) without the
|
|
109
|
+
model-per-line layout of `list`.
|
|
110
|
+
|
|
111
|
+
### `cc-switch remove <provider-name>`
|
|
112
|
+
|
|
113
|
+
Deletes a provider config. Does not touch credstore or settings.json.
|
|
114
|
+
|
|
115
|
+
## Files
|
|
116
|
+
|
|
117
|
+
| Path | Purpose |
|
|
118
|
+
|------|---------|
|
|
119
|
+
| `~/.claude/cc-switch.json` | Provider/model shapes (no secrets) |
|
|
120
|
+
| `~/.claude/settings.json` | Generated by `activate` |
|
|
121
|
+
| credstore (`DEEPSEEK_API_KEY`, …) | The actual API key values |
|
|
122
|
+
|
|
123
|
+
`CC_SWITCH_FILE` overrides the config path; the settings path can be
|
|
124
|
+
overridden for tests.
|
|
125
|
+
|
|
126
|
+
## Security notes
|
|
127
|
+
|
|
128
|
+
- The config file holds only metadata — the API key is referenced by name.
|
|
129
|
+
- `activate` reads the secret from credstore and injects it into the
|
|
130
|
+
system environment as `ANTHROPIC_AUTH_TOKEN` (registry on Windows /
|
|
131
|
+
shell profile on Unix), mirroring `credstore inject`; it is never
|
|
132
|
+
written to settings.json.
|
|
133
|
+
- On a TTY, `activate` prints an activation hint without echoing the
|
|
134
|
+
secret; when stdout is piped it emits the shell export line for `eval`.
|
|
135
|
+
- Restart your shell (or start a new terminal) after activating for the
|
|
136
|
+
env change to take effect in the current session.
|
|
137
|
+
- Secrets are immutable Python `str` — cc-switch follows credstore's
|
|
138
|
+
practice of `del`-ing the reference immediately after use.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cc_switch/__init__.py,sha256=Tj25Bi3ts1Y6z1u1oqOoygTTIzpXfI4pX9MQK4Tn5QY,1283
|
|
2
|
+
cc_switch/__main__.py,sha256=zIgq2dbe9u_cRGENPSYTeMDs1Pw1hZHBVMtwhrMk_hY,148
|
|
3
|
+
cc_switch/_activate.py,sha256=6a0nMSyjtljBQPax1tMJ9_m68Dcz8Uk5PgCXFjiLzNU,5386
|
|
4
|
+
cc_switch/_api.py,sha256=gMHz-DyiOt1VHRgHYl5KduAEPlewp_a3Hhh4aau74Hc,3288
|
|
5
|
+
cc_switch/_defaults.py,sha256=ptaIhRO8WQTWB2SIJS-_9py6oS1TGJFIgIDseAev7L4,1812
|
|
6
|
+
cc_switch/cli.py,sha256=1fZBoYtR7bD--CtyvzmZNjrLGvBV4oFZgmmmjyEuOpA,11092
|
|
7
|
+
cc_switch-0.9.6.dist-info/METADATA,sha256=2xpiZnUF5zmYavhNHwiBAqekY-WLctc-YxzfCCAwRs8,4792
|
|
8
|
+
cc_switch-0.9.6.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
9
|
+
cc_switch-0.9.6.dist-info/entry_points.txt,sha256=g2UCHtGA9vuEbJrB6HK7KY2DfElVzkWh6wZMQ5bmTzI,49
|
|
10
|
+
cc_switch-0.9.6.dist-info/RECORD,,
|