ai-code-switcher 0.1.7__tar.gz → 0.1.8__tar.gz

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.
Files changed (22) hide show
  1. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/PKG-INFO +1 -1
  2. ai_code_switcher-0.1.8/README.md +98 -0
  3. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/pyproject.toml +1 -1
  4. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/ai_code_switcher.egg-info/PKG-INFO +1 -1
  5. ai_code_switcher-0.1.8/src/code_ai/__init__.py +1 -0
  6. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/code_ai/config.py +13 -0
  7. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/code_ai/launcher.py +31 -8
  8. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/code_ai/profiles.py +1 -12
  9. ai_code_switcher-0.1.8/tests/test_integration.py +278 -0
  10. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/tests/test_launcher.py +27 -0
  11. ai_code_switcher-0.1.7/README.md +0 -98
  12. ai_code_switcher-0.1.7/src/code_ai/__init__.py +0 -1
  13. ai_code_switcher-0.1.7/tests/test_integration.py +0 -311
  14. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/setup.cfg +0 -0
  15. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/ai_code_switcher.egg-info/SOURCES.txt +0 -0
  16. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/ai_code_switcher.egg-info/dependency_links.txt +0 -0
  17. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/ai_code_switcher.egg-info/entry_points.txt +0 -0
  18. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/ai_code_switcher.egg-info/requires.txt +0 -0
  19. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/ai_code_switcher.egg-info/top_level.txt +0 -0
  20. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/code_ai/cli.py +0 -0
  21. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/src/code_ai/models.py +0 -0
  22. {ai_code_switcher-0.1.7 → ai_code_switcher-0.1.8}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-code-switcher
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.8
@@ -0,0 +1,98 @@
1
+ # ai-code-switcher
2
+
3
+ Switch AI coding tool profiles and launch the correct CLI.
4
+
5
+ ## Features
6
+
7
+ - Manage multiple AI coding tool profiles for Claude, Codex, and Gemini
8
+ - Switch between API-mode and login-mode profiles
9
+ - Launch the matching CLI through one command entrypoint
10
+ - Upgrade supported AI CLIs through npm
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install -e .
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ List profiles:
21
+
22
+ ```bash
23
+ code-ai list
24
+ ```
25
+
26
+ Add a profile:
27
+
28
+ ```bash
29
+ code-ai add
30
+ ```
31
+
32
+ Show one profile:
33
+
34
+ ```bash
35
+ code-ai show <profile-name>
36
+ ```
37
+
38
+ Launch a profile:
39
+
40
+ ```bash
41
+ code-ai run fox-gemini
42
+ code-ai run 4399
43
+ code-ai run fox-claude -p "hi"
44
+ ```
45
+
46
+ Remove a profile:
47
+
48
+ ```bash
49
+ code-ai remove <profile-name>
50
+ ```
51
+
52
+ Upgrade supported CLIs:
53
+
54
+ ```bash
55
+ code-ai upgrade
56
+ ```
57
+
58
+ This upgrades:
59
+
60
+ - `@anthropic-ai/claude-code`
61
+ - `@openai/codex`
62
+ - `@google/gemini-cli`
63
+
64
+ Version:
65
+
66
+ ```bash
67
+ code-ai --version
68
+ ```
69
+
70
+ Help:
71
+
72
+ ```bash
73
+ code-ai --help
74
+ ```
75
+
76
+ ## Configuration
77
+
78
+ Profiles are stored under `~/.code-ai/config.yaml`.
79
+
80
+ ## Project Layout
81
+
82
+ ```text
83
+ src/code_ai/
84
+ |-- __init__.py
85
+ |-- cli.py
86
+ |-- config.py
87
+ |-- launcher.py
88
+ `-- profiles.py
89
+ ```
90
+
91
+ ## Requirements
92
+
93
+ - Python >= 3.8
94
+ - pyyaml >= 5.0
95
+
96
+ ## License
97
+
98
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-code-switcher"
7
- version = "0.1.7"
7
+ version = "0.1.8"
8
8
  description = "Switch AI coding tool profiles and launch the correct CLI"
9
9
  license = "MIT"
10
10
  requires-python = ">=3.8"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-code-switcher
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.8
@@ -0,0 +1 @@
1
+ __version__ = "0.1.8"
@@ -17,6 +17,19 @@ def load_config():
17
17
  data = yaml.safe_load(f)
18
18
  if not data or "profiles" not in data:
19
19
  data = {"profiles": {}}
20
+ migrated = False
21
+ profiles = data.get("profiles", {})
22
+ for name, profile in profiles.items():
23
+ if not isinstance(profile, dict):
24
+ continue
25
+ if "name" not in profile:
26
+ profile["name"] = name
27
+ migrated = True
28
+ if "mode" not in profile:
29
+ profile["mode"] = "api"
30
+ migrated = True
31
+ if migrated:
32
+ save_config(data)
20
33
  return data
21
34
 
22
35
 
@@ -19,10 +19,35 @@ ENV_MAP = {
19
19
  },
20
20
  }
21
21
 
22
+ CONFIG_DIR_ENV_VARS = {
23
+ "claude": "CLAUDE_CONFIG_DIR",
24
+ "codex": "CODEX_HOME",
25
+ }
26
+
27
+ PROXY_ENV_VARS = (
28
+ "HTTP_PROXY",
29
+ "HTTPS_PROXY",
30
+ "http_proxy",
31
+ "https_proxy",
32
+ )
33
+
34
+ MANAGED_ENV_VARS = frozenset(
35
+ env_var
36
+ for spec in ENV_MAP.values()
37
+ for env_var in spec["env"]
38
+ ) | frozenset(CONFIG_DIR_ENV_VARS.values()) | frozenset(PROXY_ENV_VARS)
39
+
40
+
41
+ def clear_managed_environment(env):
42
+ """Remove environment variables managed by this tool."""
43
+ for env_var in MANAGED_ENV_VARS:
44
+ env.pop(env_var, None)
45
+
22
46
 
23
47
  def prepare_environment(profile):
24
48
  """Prepare environment variables based on profile type and mode"""
25
49
  env = os.environ.copy()
50
+ clear_managed_environment(env)
26
51
  ptype = profile.type
27
52
 
28
53
  if ptype not in ENV_MAP:
@@ -32,23 +57,21 @@ def prepare_environment(profile):
32
57
 
33
58
  # Handle authentication based on profile type
34
59
  if isinstance(profile, LoginProfile):
35
- # Login mode: clear API environment variables and set CONFIG_DIR
36
- for env_var in spec["env"]:
37
- env.pop(env_var, None)
38
60
  # Expand ~ to home directory
39
61
  credentials_path = os.path.expanduser(profile.credentials_path)
40
62
  # Set the appropriate config dir env var based on profile type
41
63
  # Claude uses CLAUDE_CONFIG_DIR, Codex uses CODEX_HOME
42
- config_dir_vars = {"claude": "CLAUDE_CONFIG_DIR", "codex": "CODEX_HOME"}
43
- config_dir_var = config_dir_vars.get(ptype)
64
+ config_dir_var = CONFIG_DIR_ENV_VARS.get(ptype)
44
65
  if config_dir_var:
66
+ if not credentials_path:
67
+ raise ValueError(f"Login profile '{profile.name or ptype}' is missing credentials_path")
45
68
  os.makedirs(credentials_path, exist_ok=True)
46
69
  # For codex: ensure config.toml exists with default openai provider
47
70
  # to prevent inheriting custom providers from ~/.codex/config.toml
48
71
  if ptype == "codex":
49
72
  config_toml = os.path.join(credentials_path, "config.toml")
50
73
  if not os.path.exists(config_toml):
51
- with open(config_toml, "w") as f:
74
+ with open(config_toml, "w", encoding="utf-8") as f:
52
75
  f.write('model_provider = "openai"\n')
53
76
  env[config_dir_var] = credentials_path
54
77
  elif isinstance(profile, ApiProfile):
@@ -60,8 +83,8 @@ def prepare_environment(profile):
60
83
 
61
84
  # Handle proxy (all modes)
62
85
  if profile.proxy:
63
- env["HTTP_PROXY"] = profile.proxy
64
- env["HTTPS_PROXY"] = profile.proxy
86
+ for env_var in PROXY_ENV_VARS:
87
+ env[env_var] = profile.proxy
65
88
 
66
89
  return env
67
90
 
@@ -4,20 +4,8 @@ from .models import VALID_TYPES, profile_from_dict
4
4
 
5
5
 
6
6
  def list_profiles(config):
7
- from .config import save_config
8
-
9
7
  profiles = config.get("profiles", {})
10
8
 
11
- # Auto-migrate old profiles without mode field
12
- migrated = False
13
- for name, p in profiles.items():
14
- if "mode" not in p:
15
- p["mode"] = "api"
16
- migrated = True
17
-
18
- if migrated:
19
- save_config(config)
20
-
21
9
  if not profiles:
22
10
  print("No profiles configured.")
23
11
  return
@@ -63,6 +51,7 @@ def add_profile(config):
63
51
  sys.exit(1)
64
52
 
65
53
  profile_data = {
54
+ "name": name,
66
55
  "type": ptype,
67
56
  }
68
57
 
@@ -0,0 +1,278 @@
1
+ from contextlib import contextmanager
2
+ from pathlib import Path
3
+ import shutil
4
+ import os
5
+ from unittest.mock import patch
6
+ from uuid import uuid4
7
+
8
+ from src.code_ai.config import load_config, save_config
9
+ from src.code_ai.profiles import add_profile, list_profiles, show_profile, remove_profile
10
+ from src.code_ai.models import profile_from_dict, ApiProfile, LoginProfile
11
+ from src.code_ai.launcher import prepare_environment
12
+
13
+
14
+ @contextmanager
15
+ def temp_config_file():
16
+ root = Path.cwd() / ".test-artifacts" / str(uuid4())
17
+ root.mkdir(parents=True, exist_ok=True)
18
+ try:
19
+ yield root / "config.yaml"
20
+ finally:
21
+ shutil.rmtree(root, ignore_errors=True)
22
+
23
+
24
+ class TestFullWorkflowApiProfile:
25
+ """Test complete workflow for API profile (Claude)"""
26
+
27
+ def test_full_workflow_api_profile(self):
28
+ """Test full workflow: add API profile -> show -> list -> remove"""
29
+ with temp_config_file() as config_file:
30
+ with patch("src.code_ai.config.CONFIG_FILE", config_file):
31
+ config = {"profiles": {}}
32
+ save_config(config)
33
+
34
+ inputs = [
35
+ "my-claude-api",
36
+ "claude",
37
+ "api",
38
+ "https://api.anthropic.com",
39
+ "sk-ant-test-token",
40
+ "",
41
+ ]
42
+
43
+ with patch("builtins.input", side_effect=inputs):
44
+ config = load_config()
45
+ config = add_profile(config)
46
+ save_config(config)
47
+
48
+ config = load_config()
49
+ assert "my-claude-api" in config["profiles"]
50
+ profile_dict = config["profiles"]["my-claude-api"]
51
+ assert profile_dict["name"] == "my-claude-api"
52
+ assert profile_dict["type"] == "claude"
53
+ assert profile_dict["mode"] == "api"
54
+ assert profile_dict["base_url"] == "https://api.anthropic.com"
55
+ assert profile_dict["token"] == "sk-ant-test-token"
56
+
57
+ profile = profile_from_dict(profile_dict)
58
+ assert isinstance(profile, ApiProfile)
59
+ assert profile.name == "my-claude-api"
60
+ assert profile.type == "claude"
61
+
62
+ env = prepare_environment(profile)
63
+ assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com"
64
+ assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-ant-test-token"
65
+
66
+ with patch("builtins.print") as mock_print:
67
+ show_profile(config, "my-claude-api")
68
+ assert mock_print.called
69
+
70
+ with patch("builtins.print") as mock_print:
71
+ list_profiles(config)
72
+ assert mock_print.called
73
+
74
+ config = remove_profile(config, "my-claude-api")
75
+ assert "my-claude-api" not in config["profiles"]
76
+
77
+
78
+ class TestFullWorkflowLoginProfile:
79
+ """Test complete workflow for login profile (Claude)"""
80
+
81
+ def test_full_workflow_login_profile(self):
82
+ """Test full workflow: add login profile -> show -> list -> remove"""
83
+ with temp_config_file() as config_file:
84
+ with patch("src.code_ai.config.CONFIG_FILE", config_file):
85
+ config = {"profiles": {}}
86
+ save_config(config)
87
+
88
+ inputs = [
89
+ "my-claude-login",
90
+ "claude",
91
+ "login",
92
+ "~/.claude-profiles/account-a",
93
+ "http://127.0.0.1:7890",
94
+ ]
95
+
96
+ with patch("builtins.input", side_effect=inputs):
97
+ config = load_config()
98
+ config = add_profile(config)
99
+ save_config(config)
100
+
101
+ config = load_config()
102
+ assert "my-claude-login" in config["profiles"]
103
+ profile_dict = config["profiles"]["my-claude-login"]
104
+ assert profile_dict["name"] == "my-claude-login"
105
+ assert profile_dict["type"] == "claude"
106
+ assert profile_dict["mode"] == "login"
107
+ assert profile_dict["credentials_path"] == "~/.claude-profiles/account-a"
108
+ assert profile_dict["proxy"] == "http://127.0.0.1:7890"
109
+
110
+ profile = profile_from_dict(profile_dict)
111
+ assert isinstance(profile, LoginProfile)
112
+ assert profile.name == "my-claude-login"
113
+ assert profile.type == "claude"
114
+
115
+ env = prepare_environment(profile)
116
+ assert "ANTHROPIC_BASE_URL" not in env
117
+ assert "ANTHROPIC_AUTH_TOKEN" not in env
118
+ assert env["CLAUDE_CONFIG_DIR"] == os.path.expanduser("~/.claude-profiles/account-a")
119
+ assert env["HTTP_PROXY"] == "http://127.0.0.1:7890"
120
+ assert env["HTTPS_PROXY"] == "http://127.0.0.1:7890"
121
+
122
+ with patch("builtins.print") as mock_print:
123
+ show_profile(config, "my-claude-login")
124
+ assert mock_print.called
125
+
126
+ with patch("builtins.print") as mock_print:
127
+ list_profiles(config)
128
+ assert mock_print.called
129
+
130
+ config = remove_profile(config, "my-claude-login")
131
+ assert "my-claude-login" not in config["profiles"]
132
+
133
+
134
+ class TestBackwardCompatibility:
135
+ """Test backward compatibility with legacy profiles"""
136
+
137
+ def test_backward_compatibility(self):
138
+ """Test that legacy profiles without name/mode still work and are migrated"""
139
+ with temp_config_file() as config_file:
140
+ with patch("src.code_ai.config.CONFIG_FILE", config_file):
141
+ legacy_config = {
142
+ "profiles": {
143
+ "legacy-claude": {
144
+ "type": "claude",
145
+ "base_url": "https://api.anthropic.com",
146
+ "token": "sk-ant-legacy-token",
147
+ }
148
+ }
149
+ }
150
+ save_config(legacy_config)
151
+
152
+ config = load_config()
153
+ assert "legacy-claude" in config["profiles"]
154
+ profile_dict = config["profiles"]["legacy-claude"]
155
+ assert profile_dict["name"] == "legacy-claude"
156
+ assert profile_dict["mode"] == "api"
157
+
158
+ profile = profile_from_dict(profile_dict)
159
+ assert isinstance(profile, ApiProfile)
160
+ assert profile.name == "legacy-claude"
161
+ assert profile.base_url == "https://api.anthropic.com"
162
+ assert profile.token == "sk-ant-legacy-token"
163
+
164
+ env = prepare_environment(profile)
165
+ assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com"
166
+ assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-ant-legacy-token"
167
+
168
+ with patch("builtins.print") as mock_print:
169
+ show_profile(config, "legacy-claude")
170
+ assert mock_print.called
171
+
172
+ with patch("builtins.print") as mock_print:
173
+ list_profiles(config)
174
+ assert mock_print.called
175
+
176
+ gemini_config = {
177
+ "profiles": {
178
+ "legacy-gemini": {
179
+ "type": "gemini",
180
+ "base_url": "https://generativelanguage.googleapis.com",
181
+ "api_key": "AIza-test-key",
182
+ }
183
+ }
184
+ }
185
+ save_config(gemini_config)
186
+
187
+ config = load_config()
188
+ profile_dict = config["profiles"]["legacy-gemini"]
189
+ assert profile_dict["name"] == "legacy-gemini"
190
+ assert profile_dict["mode"] == "api"
191
+
192
+ profile = profile_from_dict(profile_dict)
193
+ assert isinstance(profile, ApiProfile)
194
+ assert profile.name == "legacy-gemini"
195
+ assert profile.type == "gemini"
196
+ assert profile.api_key == "AIza-test-key"
197
+
198
+ env = prepare_environment(profile)
199
+ assert env["GOOGLE_GEMINI_BASE_URL"] == "https://generativelanguage.googleapis.com"
200
+ assert env["GEMINI_API_KEY"] == "AIza-test-key"
201
+
202
+
203
+ class TestCodexProfiles:
204
+ """Test codex profile workflows"""
205
+
206
+ def test_codex_api_profile(self):
207
+ """Test codex API mode profile"""
208
+ with temp_config_file() as config_file:
209
+ with patch("src.code_ai.config.CONFIG_FILE", config_file):
210
+ config = {"profiles": {}}
211
+ save_config(config)
212
+
213
+ inputs = [
214
+ "my-codex-api",
215
+ "codex",
216
+ "api",
217
+ "https://api.openai.com/v1",
218
+ "sk-test-key",
219
+ "",
220
+ ]
221
+
222
+ with patch("builtins.input", side_effect=inputs):
223
+ config = load_config()
224
+ config = add_profile(config)
225
+ save_config(config)
226
+
227
+ config = load_config()
228
+ assert "my-codex-api" in config["profiles"]
229
+ profile_dict = config["profiles"]["my-codex-api"]
230
+ assert profile_dict["name"] == "my-codex-api"
231
+ assert profile_dict["type"] == "codex"
232
+ assert profile_dict["mode"] == "api"
233
+
234
+ profile = profile_from_dict(profile_dict)
235
+ assert isinstance(profile, ApiProfile)
236
+ assert profile.name == "my-codex-api"
237
+ assert profile.type == "codex"
238
+
239
+ env = prepare_environment(profile)
240
+ assert env["OPENAI_API_KEY"] == "sk-test-key"
241
+ assert env["OPENAI_BASE_URL"] == "https://api.openai.com/v1"
242
+
243
+ def test_codex_login_profile(self):
244
+ """Test codex login mode profile"""
245
+ with temp_config_file() as config_file:
246
+ with patch("src.code_ai.config.CONFIG_FILE", config_file):
247
+ config = {"profiles": {}}
248
+ save_config(config)
249
+
250
+ inputs = [
251
+ "my-codex-login",
252
+ "codex",
253
+ "login",
254
+ "~/.codex-profiles/account-a",
255
+ "",
256
+ ]
257
+
258
+ with patch("builtins.input", side_effect=inputs):
259
+ config = load_config()
260
+ config = add_profile(config)
261
+ save_config(config)
262
+
263
+ config = load_config()
264
+ assert "my-codex-login" in config["profiles"]
265
+ profile_dict = config["profiles"]["my-codex-login"]
266
+ assert profile_dict["name"] == "my-codex-login"
267
+ assert profile_dict["type"] == "codex"
268
+ assert profile_dict["mode"] == "login"
269
+ assert profile_dict["credentials_path"] == "~/.codex-profiles/account-a"
270
+
271
+ profile = profile_from_dict(profile_dict)
272
+ assert isinstance(profile, LoginProfile)
273
+ assert profile.name == "my-codex-login"
274
+ assert profile.type == "codex"
275
+
276
+ env = prepare_environment(profile)
277
+ assert "OPENAI_API_KEY" not in env
278
+ assert env["CODEX_HOME"] == os.path.expanduser("~/.codex-profiles/account-a")
@@ -20,6 +20,33 @@ def test_prepare_env_api_mode():
20
20
  assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-ant-test"
21
21
 
22
22
 
23
+ def test_prepare_env_api_mode_clears_stale_managed_vars():
24
+ """API mode should not inherit stale managed vars from the parent shell"""
25
+ with patch.dict(os.environ, {
26
+ "ANTHROPIC_BASE_URL": "https://old.url",
27
+ "ANTHROPIC_AUTH_TOKEN": "old-token",
28
+ "CLAUDE_CONFIG_DIR": "/tmp/old-claude",
29
+ "OPENAI_API_KEY": "old-openai-key",
30
+ "HTTP_PROXY": "http://127.0.0.1:9999",
31
+ "HTTPS_PROXY": "http://127.0.0.1:9999",
32
+ }):
33
+ profile = ApiProfile(
34
+ name="test-api",
35
+ type="claude",
36
+ base_url="https://api.anthropic.com",
37
+ token="sk-ant-test"
38
+ )
39
+
40
+ env = prepare_environment(profile)
41
+
42
+ assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com"
43
+ assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-ant-test"
44
+ assert "CLAUDE_CONFIG_DIR" not in env
45
+ assert "OPENAI_API_KEY" not in env
46
+ assert "HTTP_PROXY" not in env
47
+ assert "HTTPS_PROXY" not in env
48
+
49
+
23
50
  def test_prepare_env_login_mode():
24
51
  """Test environment preparation for login mode"""
25
52
  profile = LoginProfile(
@@ -1,98 +0,0 @@
1
- # ai-code-switcher
2
-
3
- 一个用于切换 AI 编码工具配置文件并启动相应 CLI 的工具。
4
-
5
- ## 功能特性
6
-
7
- - 管理多个 AI 编码工具配置文件(Claude、Codex、Gemini)
8
- - 快速切换不同的配置文件
9
- - 统一的命令行接口
10
- - 支持一键升级所有 AI CLI 工具
11
-
12
- ## 安装
13
-
14
- ```bash
15
- pip install -e .
16
- ```
17
-
18
- ## 使用方法
19
-
20
- ### 列出所有配置文件
21
-
22
- ```bash
23
- code-ai list
24
- ```
25
-
26
- ### 添加新配置文件
27
-
28
- ```bash
29
- code-ai add
30
- ```
31
-
32
- ### 使用指定配置文件启动
33
-
34
- ```bash
35
- # 使用 fox-gemini 配置启动 Gemini CLI
36
- code-ai fox-gemini
37
-
38
- # 使用 4399 配置启动 Claude CLI
39
- code-ai 4399
40
-
41
- # 传递额外参数
42
- code-ai fox-claude -p "hi"
43
- ```
44
-
45
- ### 删除配置文件
46
-
47
- ```bash
48
- code-ai remove <profile-name>
49
- ```
50
-
51
- ### 升级 AI CLI 工具
52
-
53
- ```bash
54
- code-ai upgrade
55
- ```
56
-
57
- 该命令会通过 npm 升级以下工具:
58
- - @anthropic-ai/claude-code
59
- - @openai/codex
60
- - @google/gemini-cli
61
-
62
- ### 查看版本
63
-
64
- ```bash
65
- code-ai --version
66
- ```
67
-
68
- ### 查看帮助
69
-
70
- ```bash
71
- code-ai --help
72
- ```
73
-
74
- ## 配置
75
-
76
- 配置文件存储在用户目录下,包含各个配置文件的设置信息。
77
-
78
- ## 开发
79
-
80
- ### 项目结构
81
-
82
- ```
83
- src/code_ai/
84
- ├── __init__.py # 包初始化
85
- ├── cli.py # 命令行入口
86
- ├── config.py # 配置管理
87
- ├── launcher.py # 启动器
88
- └── profiles.py # 配置文件管理
89
- ```
90
-
91
- ### 依赖
92
-
93
- - Python >= 3.8
94
- - pyyaml >= 5.0
95
-
96
- ## 许可证
97
-
98
- MIT
@@ -1 +0,0 @@
1
- __version__ = "0.1.6"
@@ -1,311 +0,0 @@
1
- import pytest
2
- from unittest.mock import patch, MagicMock
3
- from src.code_ai.config import load_config, save_config
4
- from src.code_ai.profiles import add_profile, list_profiles, show_profile, remove_profile
5
- from src.code_ai.models import profile_from_dict, ApiProfile, LoginProfile
6
- from src.code_ai.launcher import prepare_environment
7
-
8
-
9
- class TestFullWorkflowApiProfile:
10
- """Test complete workflow for API profile (Claude)"""
11
-
12
- def test_full_workflow_api_profile(self, monkeypatch, tmp_path):
13
- """Test full workflow: add API profile -> show -> list -> remove"""
14
- # Mock config file location
15
- config_file = tmp_path / "config.yaml"
16
-
17
- with patch("src.code_ai.config.CONFIG_FILE", config_file):
18
- # Step 1: Initialize config
19
- config = {"profiles": {}}
20
- save_config(config)
21
-
22
- # Step 2: Add API profile interactively
23
- inputs = [
24
- "my-claude-api", # Profile name
25
- "claude", # Type
26
- "api", # Mode
27
- "https://api.anthropic.com", # Base URL
28
- "sk-ant-test-token", # Auth token
29
- "", # No proxy
30
- ]
31
-
32
- with patch("builtins.input", side_effect=inputs):
33
- config = load_config()
34
- config = add_profile(config)
35
- save_config(config)
36
-
37
- # Step 3: Verify profile was added
38
- config = load_config()
39
- assert "my-claude-api" in config["profiles"]
40
- profile_dict = config["profiles"]["my-claude-api"]
41
- assert profile_dict["type"] == "claude"
42
- assert profile_dict["mode"] == "api"
43
- assert profile_dict["base_url"] == "https://api.anthropic.com"
44
- assert profile_dict["token"] == "sk-ant-test-token"
45
-
46
- # Step 4: Convert to profile object and verify
47
- profile = profile_from_dict(profile_dict)
48
- assert isinstance(profile, ApiProfile)
49
- assert profile.type == "claude"
50
-
51
- # Step 5: Test environment preparation
52
- env = prepare_environment(profile)
53
- assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com"
54
- assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-ant-test-token"
55
-
56
- # Step 6: Show profile
57
- with patch("builtins.print") as mock_print:
58
- show_profile(config, "my-claude-api")
59
- # Verify show_profile was called and printed something
60
- assert mock_print.called
61
-
62
- # Step 7: List profiles
63
- with patch("builtins.print") as mock_print:
64
- list_profiles(config)
65
- # Verify list_profiles was called and printed something
66
- assert mock_print.called
67
-
68
- # Step 8: Remove profile
69
- config = remove_profile(config, "my-claude-api")
70
- assert "my-claude-api" not in config["profiles"]
71
-
72
-
73
- class TestFullWorkflowLoginProfile:
74
- """Test complete workflow for login profile (Claude)"""
75
-
76
- def test_full_workflow_login_profile(self, monkeypatch, tmp_path):
77
- """Test full workflow: add login profile -> show -> list -> remove"""
78
- # Mock config file location
79
- config_file = tmp_path / "config.yaml"
80
-
81
- with patch("src.code_ai.config.CONFIG_FILE", config_file):
82
- # Step 1: Initialize config
83
- config = {"profiles": {}}
84
- save_config(config)
85
-
86
- # Step 2: Add login profile interactively
87
- inputs = [
88
- "my-claude-login", # Profile name
89
- "claude", # Type
90
- "login", # Mode
91
- "~/.claude-profiles/account-a", # Credentials path
92
- "http://127.0.0.1:7890", # Proxy
93
- ]
94
-
95
- with patch("builtins.input", side_effect=inputs):
96
- config = load_config()
97
- config = add_profile(config)
98
- save_config(config)
99
-
100
- # Step 3: Verify profile was added
101
- config = load_config()
102
- assert "my-claude-login" in config["profiles"]
103
- profile_dict = config["profiles"]["my-claude-login"]
104
- assert profile_dict["type"] == "claude"
105
- assert profile_dict["mode"] == "login"
106
- assert profile_dict["credentials_path"] == "~/.claude-profiles/account-a"
107
- assert profile_dict["proxy"] == "http://127.0.0.1:7890"
108
-
109
- # Step 4: Convert to profile object and verify
110
- profile = profile_from_dict(profile_dict)
111
- assert isinstance(profile, LoginProfile)
112
- assert profile.type == "claude"
113
-
114
- # Step 5: Test environment preparation
115
- env = prepare_environment(profile)
116
- # Login mode should NOT have API environment variables
117
- assert "ANTHROPIC_BASE_URL" not in env
118
- assert "ANTHROPIC_AUTH_TOKEN" not in env
119
- # Should have CLAUDE_CONFIG_DIR with expanded path
120
- import os
121
- expected_path = os.path.expanduser("~/.claude-profiles/account-a")
122
- assert env["CLAUDE_CONFIG_DIR"] == expected_path
123
- # Should have proxy
124
- assert env["HTTP_PROXY"] == "http://127.0.0.1:7890"
125
- assert env["HTTPS_PROXY"] == "http://127.0.0.1:7890"
126
-
127
- # Step 6: Show profile
128
- with patch("builtins.print") as mock_print:
129
- show_profile(config, "my-claude-login")
130
- # Verify show_profile was called and printed something
131
- assert mock_print.called
132
-
133
- # Step 7: List profiles
134
- with patch("builtins.print") as mock_print:
135
- list_profiles(config)
136
- # Verify list_profiles was called and printed something
137
- assert mock_print.called
138
-
139
- # Step 8: Remove profile
140
- config = remove_profile(config, "my-claude-login")
141
- assert "my-claude-login" not in config["profiles"]
142
-
143
-
144
- class TestBackwardCompatibility:
145
- """Test backward compatibility with legacy profiles"""
146
-
147
- def test_backward_compatibility(self, tmp_path):
148
- """Test that legacy profiles (without mode field) still work"""
149
- # Mock config file location
150
- config_file = tmp_path / "config.yaml"
151
-
152
- with patch("src.code_ai.config.CONFIG_FILE", config_file):
153
- # Step 1: Create a legacy profile (without mode field)
154
- legacy_config = {
155
- "profiles": {
156
- "legacy-claude": {
157
- "name": "legacy-claude",
158
- "type": "claude",
159
- "base_url": "https://api.anthropic.com",
160
- "token": "sk-ant-legacy-token"
161
- # Note: no "mode" field
162
- }
163
- }
164
- }
165
- save_config(legacy_config)
166
-
167
- # Step 2: Load config and verify it works
168
- config = load_config()
169
- assert "legacy-claude" in config["profiles"]
170
- profile_dict = config["profiles"]["legacy-claude"]
171
-
172
- # Step 3: Convert to profile object
173
- # Should default to API mode
174
- profile = profile_from_dict(profile_dict)
175
- assert isinstance(profile, ApiProfile)
176
- assert profile.base_url == "https://api.anthropic.com"
177
- assert profile.token == "sk-ant-legacy-token"
178
-
179
- # Step 4: Test environment preparation
180
- env = prepare_environment(profile)
181
- assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com"
182
- assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-ant-legacy-token"
183
-
184
- # Step 5: Show profile should work
185
- with patch("builtins.print") as mock_print:
186
- show_profile(config, "legacy-claude")
187
- assert mock_print.called
188
-
189
- # Step 6: List profiles should work
190
- with patch("builtins.print") as mock_print:
191
- list_profiles(config)
192
- assert mock_print.called
193
-
194
- # Step 7: Test with other profile types (gemini, codex)
195
- gemini_config = {
196
- "profiles": {
197
- "legacy-gemini": {
198
- "name": "legacy-gemini",
199
- "type": "gemini",
200
- "base_url": "https://generativelanguage.googleapis.com",
201
- "api_key": "AIza-test-key"
202
- # Note: no "mode" field
203
- }
204
- }
205
- }
206
- save_config(gemini_config)
207
-
208
- config = load_config()
209
- profile_dict = config["profiles"]["legacy-gemini"]
210
- profile = profile_from_dict(profile_dict)
211
-
212
- # Should default to API mode
213
- assert isinstance(profile, ApiProfile)
214
- assert profile.type == "gemini"
215
- assert profile.api_key == "AIza-test-key"
216
-
217
- # Test environment preparation
218
- env = prepare_environment(profile)
219
- assert env["GOOGLE_GEMINI_BASE_URL"] == "https://generativelanguage.googleapis.com"
220
- assert env["GEMINI_API_KEY"] == "AIza-test-key"
221
-
222
-
223
- class TestCodexProfiles:
224
- """Test codex profile workflows"""
225
-
226
- def test_codex_api_profile(self, tmp_path):
227
- """Test codex API mode profile"""
228
- config_file = tmp_path / "config.yaml"
229
-
230
- with patch("src.code_ai.config.CONFIG_FILE", config_file):
231
- # Initialize config
232
- config = {"profiles": {}}
233
- save_config(config)
234
-
235
- # Add codex API profile
236
- inputs = [
237
- "my-codex-api", # Profile name
238
- "codex", # Type
239
- "api", # Mode
240
- "https://api.openai.com/v1", # Base URL
241
- "sk-test-key", # API key
242
- "", # No proxy
243
- ]
244
-
245
- with patch("builtins.input", side_effect=inputs):
246
- config = load_config()
247
- config = add_profile(config)
248
- save_config(config)
249
-
250
- # Verify profile was added
251
- config = load_config()
252
- assert "my-codex-api" in config["profiles"]
253
- profile_dict = config["profiles"]["my-codex-api"]
254
- assert profile_dict["type"] == "codex"
255
- assert profile_dict["mode"] == "api"
256
-
257
- # Convert to profile object
258
- profile = profile_from_dict(profile_dict)
259
- assert isinstance(profile, ApiProfile)
260
- assert profile.type == "codex"
261
-
262
- # Test environment preparation
263
- env = prepare_environment(profile)
264
- # Should have both OPENAI_API_KEY and OPENAI_BASE_URL
265
- assert env["OPENAI_API_KEY"] == "sk-test-key"
266
- assert env["OPENAI_BASE_URL"] == "https://api.openai.com/v1"
267
-
268
- def test_codex_login_profile(self, tmp_path):
269
- """Test codex login mode profile"""
270
- config_file = tmp_path / "config.yaml"
271
-
272
- with patch("src.code_ai.config.CONFIG_FILE", config_file):
273
- # Initialize config
274
- config = {"profiles": {}}
275
- save_config(config)
276
-
277
- # Add codex login profile
278
- inputs = [
279
- "my-codex-login", # Profile name
280
- "codex", # Type
281
- "login", # Mode
282
- "~/.codex-profiles/account-a", # Credentials path
283
- "", # No proxy
284
- ]
285
-
286
- with patch("builtins.input", side_effect=inputs):
287
- config = load_config()
288
- config = add_profile(config)
289
- save_config(config)
290
-
291
- # Verify profile was added
292
- config = load_config()
293
- assert "my-codex-login" in config["profiles"]
294
- profile_dict = config["profiles"]["my-codex-login"]
295
- assert profile_dict["type"] == "codex"
296
- assert profile_dict["mode"] == "login"
297
- assert profile_dict["credentials_path"] == "~/.codex-profiles/account-a"
298
-
299
- # Convert to profile object
300
- profile = profile_from_dict(profile_dict)
301
- assert isinstance(profile, LoginProfile)
302
- assert profile.type == "codex"
303
-
304
- # Test environment preparation
305
- env = prepare_environment(profile)
306
- # Login mode should NOT have API environment variables
307
- assert "OPENAI_API_KEY" not in env
308
- # Should have CODEX_HOME with expanded path
309
- import os
310
- expected_path = os.path.expanduser("~/.codex-profiles/account-a")
311
- assert env["CODEX_HOME"] == expected_path