ai-switch-cli 0.1.0__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.
ai_switch.py ADDED
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """Switch Codex and Claude Code configuration profiles without a GUI."""
3
+ import argparse, getpass, json, os, re, shutil, sys, tempfile, time
4
+ from pathlib import Path
5
+
6
+ HOME = Path.home()
7
+ CODEX = HOME / ".codex" / "config.toml"
8
+ CLAUDE = HOME / ".claude" / "settings.json"
9
+ CODEX_AUTH = HOME / ".codex" / "auth.json"
10
+ CODEX_MODELS = HOME / ".codex" / "models.json"
11
+ ROOT = Path(os.environ.get("AI_SWITCH_HOME", HOME / ".config" / "ai-switch"))
12
+ PROFILES = ROOT / "profiles"
13
+ STATE = ROOT / "current"
14
+
15
+ def secure(p):
16
+ try: p.chmod(0o700 if p.is_dir() else 0o600)
17
+ except OSError: pass
18
+
19
+ def write_atomic(path, data):
20
+ path.parent.mkdir(parents=True, exist_ok=True); secure(path.parent)
21
+ fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=str(path.parent))
22
+ try:
23
+ with os.fdopen(fd, "w") as f: f.write(data)
24
+ os.chmod(tmp, 0o600); os.replace(tmp, path)
25
+ finally:
26
+ if os.path.exists(tmp): os.unlink(tmp)
27
+
28
+ def profile(name):
29
+ if not name or Path(name).name != name or name in (".", ".."): raise ValueError("invalid profile name")
30
+ return PROFILES / name
31
+
32
+ def init(name):
33
+ d = profile(name); d.mkdir(parents=True, exist_ok=False); secure(d)
34
+ if CODEX.exists(): shutil.copy2(CODEX, d / "codex-config.toml")
35
+ if CLAUDE.exists(): shutil.copy2(CLAUDE, d / "claude-settings.json")
36
+ if CODEX_AUTH.exists(): shutil.copy2(CODEX_AUTH, d / "codex-auth.json")
37
+ if CODEX_MODELS.exists(): shutil.copy2(CODEX_MODELS, d / "codex-models.json")
38
+ for p in (d / "codex-config.toml", d / "claude-settings.json", d / "codex-auth.json", d / "codex-models.json"): secure(p)
39
+ write_atomic(d / "profile.json", json.dumps({"description": getattr(init, "description", ""), "created": time.strftime("%Y-%m-%d %H:%M:%S")}, ensure_ascii=True, indent=2) + "\n")
40
+ print(f"Created profile: {name}")
41
+
42
+ def summary(d):
43
+ meta = {}
44
+ try: meta = json.loads((d / "profile.json").read_text())
45
+ except (OSError, ValueError): pass
46
+ clients=[]; details=[]
47
+ c=d/"codex-config.toml"
48
+ if c.exists():
49
+ clients.append("Codex")
50
+ s=c.read_text(errors="replace")
51
+ model=re.search(r'^model\s*=\s*["\']([^"\']+)',s,re.M)
52
+ base=re.search(r'^base_url\s*=\s*["\']([^"\']+)',s,re.M)
53
+ if model: details.append("Codex model="+model.group(1))
54
+ if base: details.append("Codex endpoint="+base.group(1).split('/')[2] if '://' in base.group(1) else "Codex endpoint configured")
55
+ c=d/"claude-settings.json"
56
+ if c.exists():
57
+ clients.append("Claude")
58
+ try:
59
+ x=json.loads(c.read_text()); env=x.get("env",{}); model=x.get("model")
60
+ if model: details.append("Claude model="+str(model))
61
+ if env.get("ANTHROPIC_BASE_URL"): details.append("Claude endpoint="+str(env["ANTHROPIC_BASE_URL"]).split('/')[2] if '://' in str(env["ANTHROPIC_BASE_URL"]) else "Claude endpoint configured")
62
+ except ValueError: details.append("Claude settings invalid JSON")
63
+ return meta.get("description") or "No description", ",".join(clients) or "No config", "; ".join(details) or "No automatic summary"
64
+
65
+ def list_profiles(_):
66
+ current = STATE.read_text().strip() if STATE.exists() else ""
67
+ for d in sorted(PROFILES.iterdir() if PROFILES.exists() else []):
68
+ if d.is_dir():
69
+ desc, clients, details = summary(d)
70
+ print(f"{'*' if d.name == current else ' '} {d.name:<16} {clients:<12} {desc} [{details}]")
71
+
72
+ def use(name):
73
+ d = profile(name)
74
+ if not d.is_dir(): raise FileNotFoundError(f"profile not found: {name}")
75
+ backup = ROOT / "backups" / time.strftime("%Y%m%d-%H%M%S")
76
+ backup.mkdir(parents=True, exist_ok=True); secure(backup)
77
+ for target, source, label in ((CODEX,d/"codex-config.toml","Codex"),(CLAUDE,d/"claude-settings.json","Claude"),(CODEX_AUTH,d/"codex-auth.json","Codex auth"),(CODEX_MODELS,d/"codex-models.json","Codex models")):
78
+ if source.exists():
79
+ if target.exists(): shutil.copy2(target, backup / target.name); secure(backup / target.name)
80
+ write_atomic(target, source.read_text())
81
+ print(f"Switched {label}")
82
+ if (d/"codex-config.toml").exists() and not (d/"codex-auth.json").exists():
83
+ print("Warning: profile has no Codex auth.json; run 'ai-switch init' again after configuring its API key.", file=sys.stderr)
84
+ write_atomic(STATE, name + "\n")
85
+ print(f"Active profile: {name}\nBackup: {backup}")
86
+
87
+ def current(_): print(STATE.read_text().strip() if STATE.exists() else "(none)")
88
+
89
+ def describe(name, text):
90
+ d=profile(name)
91
+ if not d.is_dir(): raise FileNotFoundError(f"profile not found: {name}")
92
+ write_atomic(d/"profile.json", json.dumps({"description": text, "updated": time.strftime("%Y-%m-%d %H:%M:%S")}, ensure_ascii=True, indent=2)+"\n")
93
+ print(f"Updated description: {name}")
94
+
95
+ def add_interactive(_):
96
+ print("Create a provider profile (input is not echoed for API keys).")
97
+ print("At any prompt, type 'cancel' or press Ctrl-C to exit without saving.\n")
98
+ provider=input("Provider [glm/deepseek/custom] (default: glm): ").strip().lower() or "glm"
99
+ if provider not in ("glm","deepseek","custom"): raise ValueError("provider must be glm, deepseek, or custom")
100
+ if provider == "glm":
101
+ endpoint="https://open.bigmodel.cn/api/v1"; model="glm-5.3"; claude_endpoint="https://open.bigmodel.cn/api/anthropic"
102
+ print("Using GLM preset: Codex Responses API and Claude model mappings will be configured automatically.")
103
+ elif provider == "deepseek":
104
+ endpoint="https://api.deepseek.com/"; model="deepseek-v4-flash"; claude_endpoint="https://api.deepseek.com/anthropic"
105
+ print("Using DeepSeek preset: Codex Responses API, model catalog, and Claude model mappings will be configured automatically.")
106
+ else:
107
+ endpoint=input("API endpoint URL (e.g. https://api.example.com/v1): ").strip()
108
+ model=input("Model name: ").strip(); claude_endpoint=endpoint[:-3].rstrip("/") if endpoint.endswith("/v1") else endpoint.rstrip("/")
109
+ name=input("Profile name: ").strip(); desc=input("Description: ").strip()
110
+ key=getpass.getpass("API key: ")
111
+ clients=input("Configure clients [both/codex/claude] (default: both): ").strip().lower() or "both"
112
+ if clients not in ("both","codex","claude"): raise ValueError("clients must be both, codex, or claude")
113
+ d=profile(name); d.mkdir(parents=True, exist_ok=False); secure(d)
114
+ if clients in ("both","codex"):
115
+ if provider == "glm":
116
+ base='model_provider = "ZAI"\nmodel = "glm-5.3"\nmodel_reasoning_effort = "max"\nmodel_catalog_json = "~/.codex/models.json"\n\n[model_providers.ZAI]\nname = "ZAI"\nbase_url = "https://open.bigmodel.cn/api/v1"\nexperimental_bearer_token = "'+key+'"\nwire_api = "responses"\n'
117
+ elif provider == "deepseek":
118
+ base='model = "deepseek-v4-flash"\nmodel_provider = "deepseek"\npreferred_auth_method = "apikey"\nforced_login_method = "api"\nmodel_reasoning_effort = "high"\nmodel_catalog_json = "~/.codex/models.json"\n\n[model_providers.deepseek]\nname = "deepseek"\nbase_url = "https://api.deepseek.com/"\nwire_api = "responses"\nexperimental_bearer_token = "'+key+'"\n'
119
+ else: base=CODEX.read_text() if CODEX.exists() else 'model_provider = "custom"\nmodel = "MODEL"\n\n[model_providers.custom]\nname = "custom"\nbase_url = "ENDPOINT"\nwire_api = "responses"\nrequires_openai_auth = true\n'
120
+ base=re.sub(r'(?m)^model\s*=\s*["\'][^"\']*["\']', f'model = "{model}"', base, count=1)
121
+ base=re.sub(r'(?m)^base_url\s*=\s*["\'][^"\']*["\']', f'base_url = "{endpoint}"', base, count=1)
122
+ write_atomic(d/"codex-config.toml", base)
123
+ write_atomic(d/"codex-auth.json", json.dumps({"OPENAI_API_KEY":key}, indent=2)+"\n")
124
+ if provider in ("glm", "deepseek"):
125
+ if provider == "deepseek":
126
+ models=[]
127
+ for slug, desc, modalities, priority, context in (("deepseek-v4-flash","Fast general-purpose DeepSeek model",["text"],0,1048576),("deepseek-v4-pro","Deep reasoning DeepSeek model",["text"],1,1048576),("deepseek-v4-flash-vision-exp","DeepSeek vision model",["text","image"],2,1048576)):
128
+ models.append({"slug":slug,"display_name":slug,"description":desc,"default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low","description":"Light reasoning"},{"effort":"high","description":"Enhanced reasoning"},{"effort":"max","description":"Deep reasoning"}],"shell_type":"shell_command","visibility":"list","supported_in_api":True,"priority":priority,"base_instructions":"","supports_reasoning_summaries":True,"default_reasoning_summary":"none","support_verbosity":False,"apply_patch_tool_type":"freeform","truncation_policy":{"mode":"bytes","limit":10000},"context_window":context,"max_context_window":context,"effective_context_window_percent":95,"supports_parallel_tool_calls":True,"experimental_supported_tools":[],"input_modalities":modalities})
129
+ write_atomic(d/"codex-models.json", json.dumps({"models":models}, indent=2)+"\n")
130
+ else:
131
+ catalog={"models":[{"slug":"glm-5.3","display_name":"glm-5.3","description":"Z.ai flagship model","default_reasoning_level":"max","supported_reasoning_levels":[{"effort":"low","description":"Light reasoning"},{"effort":"high","description":"Enhanced reasoning"},{"effort":"max","description":"Deep reasoning"}],"shell_type":"shell_command","visibility":"list","supported_in_api":True,"priority":0,"base_instructions":"","supports_reasoning_summaries":True,"default_reasoning_summary":"none","support_verbosity":False,"apply_patch_tool_type":"freeform","truncation_policy":{"mode":"bytes","limit":10000},"context_window":1048576,"max_context_window":1048576,"effective_context_window_percent":95,"supports_parallel_tool_calls":True,"experimental_supported_tools":[],"input_modalities":["text"]}]}
132
+ write_atomic(d/"codex-models.json", json.dumps(catalog, indent=2)+"\n")
133
+ if clients in ("both","claude"):
134
+ obj=json.loads(CLAUDE.read_text()) if CLAUDE.exists() else {}
135
+ obj.setdefault("env",{}).update({"ANTHROPIC_BASE_URL":claude_endpoint,"ANTHROPIC_AUTH_TOKEN":key})
136
+ if provider == "glm": obj["env"].update({"ANTHROPIC_DEFAULT_HAIKU_MODEL":"glm-5.3-flash[1m]","ANTHROPIC_DEFAULT_SONNET_MODEL":"glm-5.3[1m]","ANTHROPIC_DEFAULT_OPUS_MODEL":"glm-5.3[1m]","CLAUDE_CODE_AUTO_COMPACT_WINDOW":"1000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1,"API_TIMEOUT_MS":"3000000"})
137
+ elif provider == "deepseek": obj["env"].update({"ANTHROPIC_MODEL":"deepseek-v4-pro[1m]","ANTHROPIC_DEFAULT_OPUS_MODEL":"deepseek-v4-pro[1m]","ANTHROPIC_DEFAULT_SONNET_MODEL":"deepseek-v4-pro[1m]","ANTHROPIC_DEFAULT_HAIKU_MODEL":"deepseek-v4-flash","CLAUDE_CODE_SUBAGENT_MODEL":"deepseek-v4-flash","CLAUDE_CODE_EFFORT_LEVEL":"max","CLAUDE_CODE_AUTO_COMPACT_WINDOW":"786432"})
138
+ obj["model"]=model; write_atomic(d/"claude-settings.json", json.dumps(obj, indent=2)+"\n")
139
+ write_atomic(d/"profile.json", json.dumps({"description":desc,"created":time.strftime("%Y-%m-%d %H:%M:%S")}, indent=2)+"\n")
140
+ print(f"Created profile: {name}. Activate it with: ai-switch use {name}")
141
+
142
+ def main():
143
+ description = "Switch Codex and Claude Code API profiles without a GUI."
144
+ epilog = """Examples:
145
+ ai-switch init openai Save the current configuration as 'openai'
146
+ ai-switch list List profiles (* marks the active one)
147
+ ai-switch use glm Activate the 'glm' profile
148
+ ai-switch current Show the active profile
149
+
150
+ Profiles: ~/.config/ai-switch/profiles/
151
+ Backups: ~/.config/ai-switch/backups/
152
+ Set AI_SWITCH_HOME to override the storage directory.
153
+ After switching, restart claude/codex so they reload their configuration."""
154
+ ap=argparse.ArgumentParser(prog="ai-switch", description=description,
155
+ epilog=epilog, formatter_class=argparse.RawDescriptionHelpFormatter)
156
+ sp=ap.add_subparsers(dest="cmd")
157
+ p=sp.add_parser("init", help="save current Codex/Claude config as a new profile", description="Save the current configuration files as a new profile.")
158
+ p.add_argument("name", help="profile name (letters, numbers, . _ -; no path separators)")
159
+ p.add_argument("-d", "--description", default="", help="human-readable purpose, e.g. 'GLM Coding Plan'")
160
+ p.set_defaults(fn=init)
161
+ p=sp.add_parser("list", help="list all profiles", description="List profiles; '*' marks the active profile."); p.set_defaults(fn=list_profiles)
162
+ p=sp.add_parser("use", help="activate a profile", description="Back up current files and atomically activate the selected profile.")
163
+ p.add_argument("name", help="profile name"); p.set_defaults(fn=use)
164
+ p=sp.add_parser("current", help="show active profile", description="Print the active profile name, or '(none)'."); p.set_defaults(fn=current)
165
+ p=sp.add_parser("describe", help="set a profile description", description="Update the human-readable description of an existing profile.")
166
+ p.add_argument("name", help="profile name"); p.add_argument("text", help="description"); p.set_defaults(fn=None)
167
+ p=sp.add_parser("add", help="create a profile interactively", description="Interactively create a profile without editing configuration files."); p.set_defaults(fn=add_interactive)
168
+ a=ap.parse_args()
169
+ if not a.cmd:
170
+ ap.print_help()
171
+ print("\nAvailable profiles:")
172
+ list_profiles(a)
173
+ return 0
174
+ if a.cmd == "init": init.description = a.description
175
+ if a.cmd == "describe": a.fn = lambda n: describe(n, a.text)
176
+ try: a.fn(getattr(a,"name",a))
177
+ except KeyboardInterrupt:
178
+ print("\nCancelled. No profile was created.", file=sys.stderr)
179
+ return 130
180
+ except (OSError, ValueError) as e: print(f"Error: {e}", file=sys.stderr); return 1
181
+ return 0
182
+ if __name__ == "__main__": raise SystemExit(main())
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-switch-cli
3
+ Version: 0.1.0
4
+ Summary: A secure CLI profile switcher for Codex and Claude Code
5
+ Author: ZidongS
6
+ License: MIT
7
+ Keywords: codex,claude-code,api,profile,cli
8
+ Classifier: Environment :: Console
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: POSIX
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+
15
+ # ai-switch
16
+
17
+ A small, secure command-line profile switcher for Codex and Claude Code. It works on headless servers and does not require administrator privileges.
18
+
19
+ ## Install
20
+
21
+ Install the latest development version directly from GitHub:
22
+
23
+ ```bash
24
+ python3 -m pip install --user "git+https://github.com/ZidongS/ai-switch.git"
25
+ ```
26
+
27
+ Or install a local checkout:
28
+
29
+ ```bash
30
+ python3 -m pip install --user .
31
+ ```
32
+
33
+ If `ai-switch` is not found afterwards, add the user script directory to `PATH`:
34
+
35
+ ```bash
36
+ export PATH="$HOME/.local/bin:$PATH"
37
+ ```
38
+
39
+ ## Quick start
40
+
41
+ Save the configuration currently in use:
42
+
43
+ ```bash
44
+ ai-switch init bairuo --description "Bairuo relay for daily work"
45
+ ```
46
+
47
+ Edit `~/.codex/config.toml` and `~/.claude/settings.json` for another provider, then save that configuration as a second profile:
48
+
49
+ ```bash
50
+ ai-switch init glm --description "GLM Coding Plan"
51
+ ai-switch list
52
+ ai-switch use glm
53
+ ai-switch current
54
+ ```
55
+
56
+ Create a profile through an interactive prompt (no editor required; API keys are hidden while typing). Choose the built-in `glm` preset to generate the complete ZAI Codex Responses configuration, Codex model catalog, and Claude Code model/environment mappings automatically. The `deepseek` preset creates the three DeepSeek model entries, including image input metadata for `deepseek-v4-flash-vision-exp`, plus the recommended Claude Code mappings:
57
+
58
+ ```bash
59
+ ai-switch add
60
+ ```
61
+
62
+ Update an existing profile description:
63
+
64
+ ```bash
65
+ ai-switch describe bairuo "Bairuo relay for daily work"
66
+ ```
67
+
68
+ `list` shows the active marker, profile name, configured clients, description, detected models, and endpoint hostnames. API keys are never printed.
69
+
70
+ ## Safety and storage
71
+
72
+ Before activation, the current Codex and Claude files are backed up under `~/.config/ai-switch/backups/`. Profiles are stored under `~/.config/ai-switch/profiles/`; directories use mode 700 and files use mode 600. Restart `claude` or `codex` after switching so the process reloads its configuration.
73
+
74
+ Set `AI_SWITCH_HOME` to use a different profile directory.
75
+
76
+ ## Commands
77
+
78
+ ```text
79
+ ai-switch init NAME [-d DESCRIPTION] Save current files as a new profile
80
+ ai-switch list List profiles and configuration summaries
81
+ ai-switch use NAME Back up and activate a profile
82
+ ai-switch current Print the active profile
83
+ ai-switch describe NAME TEXT Set a profile description
84
+ ai-switch add Create a profile interactively (GLM, DeepSeek, or custom)
85
+ ai-switch --help Show full usage and examples
86
+ ```
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ python3 -m unittest -v
92
+ ```
93
+
94
+ The project uses only the Python standard library.
@@ -0,0 +1,6 @@
1
+ ai_switch.py,sha256=TkkVaPTmCObzmRXDZy93pVTtKlf6S4S46bOfjgp1yTA,13878
2
+ ai_switch_cli-0.1.0.dist-info/METADATA,sha256=gRK2717Xo1FvS8hUs56BUddqFeTOQK70ZDIknB2PEDk,3112
3
+ ai_switch_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ ai_switch_cli-0.1.0.dist-info/entry_points.txt,sha256=dygvfzbH-sh8Ll2K6R0hZT1G93wz5CGs3l9T84VdysE,45
5
+ ai_switch_cli-0.1.0.dist-info/top_level.txt,sha256=1KbjpO07l32H-mrv-bF-nbpTj2wuBXmldINYlCVwwVQ,10
6
+ ai_switch_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ai-switch = ai_switch:main
@@ -0,0 +1 @@
1
+ ai_switch