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 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
@@ -0,0 +1,6 @@
1
+ """cc-switch CLI entry point (``python -m cc_switch``)."""
2
+
3
+ from cc_switch.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.26.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cc-switch = cc_switch.cli:main