ai-code-switcher 0.1.4__tar.gz → 0.1.5__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.4 → ai_code_switcher-0.1.5}/PKG-INFO +1 -1
  2. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/pyproject.toml +1 -1
  3. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/src/ai_code_switcher.egg-info/PKG-INFO +1 -1
  4. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/src/ai_code_switcher.egg-info/SOURCES.txt +5 -1
  5. ai_code_switcher-0.1.5/src/code_ai/__init__.py +1 -0
  6. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/src/code_ai/cli.py +10 -3
  7. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/src/code_ai/config.py +10 -0
  8. ai_code_switcher-0.1.5/src/code_ai/launcher.py +93 -0
  9. ai_code_switcher-0.1.5/src/code_ai/models.py +66 -0
  10. ai_code_switcher-0.1.5/src/code_ai/profiles.py +150 -0
  11. ai_code_switcher-0.1.5/tests/test_integration.py +220 -0
  12. ai_code_switcher-0.1.5/tests/test_launcher.py +74 -0
  13. ai_code_switcher-0.1.5/tests/test_models.py +115 -0
  14. ai_code_switcher-0.1.4/src/code_ai/__init__.py +0 -1
  15. ai_code_switcher-0.1.4/src/code_ai/launcher.py +0 -58
  16. ai_code_switcher-0.1.4/src/code_ai/profiles.py +0 -80
  17. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/README.md +0 -0
  18. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/setup.cfg +0 -0
  19. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/src/ai_code_switcher.egg-info/dependency_links.txt +0 -0
  20. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/src/ai_code_switcher.egg-info/entry_points.txt +0 -0
  21. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/src/ai_code_switcher.egg-info/requires.txt +0 -0
  22. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.5}/src/ai_code_switcher.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-code-switcher
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.8
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-code-switcher"
7
- version = "0.1.4"
7
+ version = "0.1.5"
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.4
3
+ Version: 0.1.5
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.8
@@ -10,4 +10,8 @@ src/code_ai/__init__.py
10
10
  src/code_ai/cli.py
11
11
  src/code_ai/config.py
12
12
  src/code_ai/launcher.py
13
- src/code_ai/profiles.py
13
+ src/code_ai/models.py
14
+ src/code_ai/profiles.py
15
+ tests/test_integration.py
16
+ tests/test_launcher.py
17
+ tests/test_models.py
@@ -0,0 +1 @@
1
+ __version__ = "0.1.5"
@@ -110,9 +110,16 @@ UPGRADE_PACKAGES = [
110
110
 
111
111
  def upgrade():
112
112
  typer.echo("Upgrading claude, codex, gemini CLI...")
113
- result = subprocess.run(
114
- ["npm", "install", "-g"] + UPGRADE_PACKAGES,
115
- )
113
+
114
+ # On Windows, npm is a .cmd file, need to use shell=True or npm.cmd
115
+ if sys.platform == "win32":
116
+ # Use shell=True on Windows for better compatibility
117
+ cmd = "npm install -g " + " ".join(UPGRADE_PACKAGES)
118
+ result = subprocess.run(cmd, shell=True)
119
+ else:
120
+ result = subprocess.run(
121
+ ["npm", "install", "-g"] + UPGRADE_PACKAGES,
122
+ )
116
123
  sys.exit(result.returncode)
117
124
 
118
125
 
@@ -4,6 +4,8 @@ from pathlib import Path
4
4
 
5
5
  import yaml
6
6
 
7
+ from .models import profile_from_dict, profile_to_dict, BaseProfile
8
+
7
9
  CONFIG_DIR = Path.home() / ".code-ai"
8
10
  CONFIG_FILE = CONFIG_DIR / "config.yaml"
9
11
 
@@ -26,3 +28,11 @@ def save_config(data):
26
28
 
27
29
  def init_config():
28
30
  save_config({"profiles": {}})
31
+
32
+
33
+ def get_profile_object(config, name: str) -> BaseProfile:
34
+ """Get a profile object from config by name."""
35
+ if name not in config.get("profiles", {}):
36
+ raise ValueError(f"Profile '{name}' not found in config")
37
+ profile_dict = config["profiles"][name]
38
+ return profile_from_dict(profile_dict)
@@ -0,0 +1,93 @@
1
+ import os
2
+ import sys
3
+ import shutil
4
+ import subprocess
5
+ from .models import profile_from_dict, ApiProfile, LoginProfile
6
+
7
+ ENV_MAP = {
8
+ "claude": {
9
+ "env": {"ANTHROPIC_BASE_URL": "base_url", "ANTHROPIC_AUTH_TOKEN": "token"},
10
+ "cmd": "claude",
11
+ },
12
+ "gemini": {
13
+ "env": {"GOOGLE_GEMINI_BASE_URL": "base_url", "GEMINI_API_KEY": "api_key"},
14
+ "cmd": "gemini",
15
+ },
16
+ "codex": {
17
+ "env": {"OPENAI_BASE_URL": "base_url", "OPENAI_API_KEY": "api_key"},
18
+ "cmd": "codex",
19
+ },
20
+ }
21
+
22
+
23
+ def prepare_environment(profile):
24
+ """Prepare environment variables based on profile type and mode"""
25
+ env = os.environ.copy()
26
+ ptype = profile.type
27
+
28
+ if ptype not in ENV_MAP:
29
+ raise ValueError(f"Unknown profile type '{ptype}'")
30
+
31
+ spec = ENV_MAP[ptype]
32
+
33
+ # Handle authentication based on profile type
34
+ if isinstance(profile, LoginProfile):
35
+ # Login mode: clear API environment variables and set CLAUDE_CONFIG_DIR
36
+ for env_var in spec["env"]:
37
+ env.pop(env_var, None)
38
+ # Expand ~ to home directory
39
+ credentials_path = os.path.expanduser(profile.credentials_path)
40
+ env["CLAUDE_CONFIG_DIR"] = credentials_path
41
+ elif isinstance(profile, ApiProfile):
42
+ # API mode: set API environment variables
43
+ for env_var, config_key in spec["env"].items():
44
+ value = getattr(profile, config_key, None)
45
+ if value:
46
+ env[env_var] = value
47
+
48
+ # Handle proxy (all modes)
49
+ if profile.proxy:
50
+ env["HTTP_PROXY"] = profile.proxy
51
+ env["HTTPS_PROXY"] = profile.proxy
52
+
53
+ return env
54
+
55
+
56
+ def launch(profile_dict, extra_args):
57
+ # Convert dict to dataclass
58
+ profile = profile_from_dict(profile_dict)
59
+ ptype = profile.type
60
+
61
+ if ptype not in ENV_MAP:
62
+ print(f"Error: unknown profile type '{ptype}'.")
63
+ sys.exit(1)
64
+
65
+ spec = ENV_MAP[ptype]
66
+ cmd = spec["cmd"]
67
+
68
+ # On Windows, npm global commands are .cmd files
69
+ if sys.platform == "win32":
70
+ cmd_path = shutil.which(f"{cmd}.cmd") or shutil.which(cmd)
71
+ else:
72
+ cmd_path = shutil.which(cmd)
73
+
74
+ if not cmd_path:
75
+ print(f"Error: '{cmd}' not found in PATH. Install it first.")
76
+ sys.exit(1)
77
+
78
+ # Prepare environment
79
+ env = prepare_environment(profile)
80
+
81
+ full_cmd = [cmd_path] + extra_args
82
+
83
+ if sys.platform == "win32":
84
+ # On Windows, use subprocess with full environment
85
+ try:
86
+ result = subprocess.run(full_cmd, env=env, shell=False)
87
+ sys.exit(result.returncode)
88
+ except KeyboardInterrupt:
89
+ sys.exit(130)
90
+ else:
91
+ # On Unix, update os.environ and use execvp
92
+ os.environ.update(env)
93
+ os.execvp(cmd, [cmd] + extra_args)
@@ -0,0 +1,66 @@
1
+ from dataclasses import dataclass, asdict, field
2
+ from typing import Optional
3
+
4
+ VALID_TYPES = ("claude", "gemini", "codex")
5
+
6
+
7
+ @dataclass
8
+ class BaseProfile:
9
+ """Common fields for all profiles"""
10
+ name: str
11
+ type: str # "claude" | "gemini" | "codex"
12
+ proxy: Optional[str] = None # e.g., "http://127.0.0.1:7890"
13
+
14
+
15
+ @dataclass
16
+ class ApiProfile(BaseProfile):
17
+ """API mode: authenticate via base_url + token/api_key"""
18
+ base_url: str = ""
19
+ token: Optional[str] = None # Claude only
20
+ api_key: Optional[str] = None # Gemini/Codex only
21
+
22
+
23
+ @dataclass
24
+ class LoginProfile(BaseProfile):
25
+ """Login mode: authenticate via OAuth credentials directory (Claude only)"""
26
+ credentials_path: str = "" # Path to existing OAuth credentials
27
+
28
+
29
+ def profile_from_dict(data: dict) -> BaseProfile:
30
+ """Convert dict to appropriate profile dataclass"""
31
+ name = data.get("name", "")
32
+ ptype = data.get("type", "")
33
+ mode = data.get("mode", "api") # Default to api for backward compatibility
34
+ proxy = data.get("proxy")
35
+
36
+ if ptype == "claude" and mode == "login":
37
+ return LoginProfile(
38
+ name=name,
39
+ type=ptype,
40
+ credentials_path=data.get("credentials_path", ""),
41
+ proxy=proxy
42
+ )
43
+ else:
44
+ # API mode (default for all types)
45
+ return ApiProfile(
46
+ name=name,
47
+ type=ptype,
48
+ base_url=data.get("base_url", ""),
49
+ token=data.get("token"),
50
+ api_key=data.get("api_key"),
51
+ proxy=proxy
52
+ )
53
+
54
+
55
+ def profile_to_dict(profile: BaseProfile) -> dict:
56
+ """Convert profile dataclass to dict"""
57
+ data = asdict(profile)
58
+
59
+ # Add mode field
60
+ if isinstance(profile, LoginProfile):
61
+ data["mode"] = "login"
62
+ else:
63
+ data["mode"] = "api"
64
+
65
+ # Remove None values for cleaner YAML
66
+ return {k: v for k, v in data.items() if v is not None}
@@ -0,0 +1,150 @@
1
+ import sys
2
+
3
+ from .models import VALID_TYPES, profile_from_dict
4
+
5
+
6
+ def list_profiles(config):
7
+ from .config import save_config
8
+
9
+ profiles = config.get("profiles", {})
10
+
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
+ if not profiles:
22
+ print("No profiles configured.")
23
+ return
24
+
25
+ print(f"{'Name':<20} {'Type':<10} {'Mode':<8} {'Base URL/Credentials':<42} {'Proxy':<20}")
26
+ print("-" * 100)
27
+
28
+ for name, p in profiles.items():
29
+ profile = profile_from_dict(p)
30
+ ptype = profile.type
31
+ mode = p.get("mode", "api")
32
+
33
+ # Determine what to display in the Base URL/Credentials column
34
+ if mode == "login":
35
+ url_or_creds = p.get("credentials_path", "")
36
+ else: # api mode
37
+ url_or_creds = p.get("base_url", "")
38
+
39
+ # Truncate long strings
40
+ if len(url_or_creds) > 40:
41
+ url_or_creds = url_or_creds[:37] + "..."
42
+
43
+ # Get proxy or display "-"
44
+ proxy_display = profile.proxy if profile.proxy else "-"
45
+ if len(proxy_display) > 20:
46
+ proxy_display = proxy_display[:17] + "..."
47
+
48
+ print(f"{name:<20} {ptype:<10} {mode:<8} {url_or_creds:<42} {proxy_display:<20}")
49
+
50
+
51
+ def add_profile(config):
52
+ name = input("Profile name: ").strip()
53
+ if not name:
54
+ print("Error: name cannot be empty.")
55
+ sys.exit(1)
56
+ if name in config.get("profiles", {}):
57
+ print(f"Error: profile '{name}' already exists.")
58
+ sys.exit(1)
59
+
60
+ ptype = input(f"Type ({'/'.join(VALID_TYPES)}): ").strip().lower()
61
+ if ptype not in VALID_TYPES:
62
+ print(f"Error: type must be one of {VALID_TYPES}.")
63
+ sys.exit(1)
64
+
65
+ profile_data = {
66
+ "type": ptype,
67
+ }
68
+
69
+ # Handle mode for Claude
70
+ if ptype == "claude":
71
+ mode = input("Mode (api/login) [api]: ").strip().lower() or "api"
72
+ if mode not in ("api", "login"):
73
+ print("Error: mode must be 'api' or 'login'.")
74
+ sys.exit(1)
75
+ profile_data["mode"] = mode
76
+
77
+ if mode == "login":
78
+ credentials_path = input("Credentials path (optional, auto-generate if empty): ").strip()
79
+ if not credentials_path:
80
+ credentials_path = f"~/.claude-profiles/{name}"
81
+ profile_data["credentials_path"] = credentials_path
82
+ else: # api mode
83
+ base_url = input("Base URL: ").strip()
84
+ if not base_url:
85
+ print("Error: base URL cannot be empty.")
86
+ sys.exit(1)
87
+ token = input("Auth token: ").strip()
88
+ profile_data["base_url"] = base_url
89
+ profile_data["token"] = token
90
+ else: # gemini or codex - API mode only
91
+ base_url = input("Base URL: ").strip()
92
+ if not base_url:
93
+ print("Error: base URL cannot be empty.")
94
+ sys.exit(1)
95
+ api_key = input("API key: ").strip()
96
+ profile_data["base_url"] = base_url
97
+ profile_data["api_key"] = api_key
98
+
99
+ # Optional proxy for all types
100
+ proxy = input("Proxy (optional, e.g., http://127.0.0.1:7890): ").strip()
101
+ if proxy:
102
+ profile_data["proxy"] = proxy
103
+
104
+ config.setdefault("profiles", {})[name] = profile_data
105
+ return config
106
+
107
+
108
+ def show_profile(config, name):
109
+ profiles = config.get("profiles", {})
110
+ if name not in profiles:
111
+ print(f"Error: profile '{name}' not found.")
112
+ sys.exit(1)
113
+
114
+ profile_dict = profiles[name]
115
+ profile = profile_from_dict(profile_dict)
116
+
117
+ print(f"Profile: {name}")
118
+ print(f"Type: {profile.type}")
119
+
120
+ # Display mode for Claude type
121
+ if profile.type == "claude":
122
+ mode = profile_dict.get("mode", "api")
123
+ print(f"Mode: {mode}")
124
+
125
+ if mode == "login":
126
+ credentials_path = profile_dict.get("credentials_path", "N/A")
127
+ print(f"Credentials Path: {credentials_path}")
128
+ else: # api mode
129
+ base_url = profile_dict.get("base_url", "N/A")
130
+ token = profile_dict.get("token", "N/A")
131
+ print(f"Base URL: {base_url}")
132
+ print(f"Token: {token}")
133
+ else: # gemini or codex
134
+ base_url = profile_dict.get("base_url", "N/A")
135
+ api_key = profile_dict.get("api_key", "N/A")
136
+ print(f"Base URL: {base_url}")
137
+ print(f"API Key: {api_key}")
138
+
139
+ # Display proxy if set
140
+ if profile.proxy:
141
+ print(f"Proxy: {profile.proxy}")
142
+
143
+
144
+ def remove_profile(config, name):
145
+ if name not in config.get("profiles", {}):
146
+ print(f"Error: profile '{name}' not found.")
147
+ sys.exit(1)
148
+ del config["profiles"][name]
149
+ print(f"Removed profile '{name}'.")
150
+ return config
@@ -0,0 +1,220 @@
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"
@@ -0,0 +1,74 @@
1
+ import os
2
+ import pytest
3
+ from unittest.mock import patch, MagicMock
4
+ from src.code_ai.models import ApiProfile, LoginProfile
5
+ from src.code_ai.launcher import prepare_environment
6
+
7
+
8
+ def test_prepare_env_api_mode():
9
+ """Test environment preparation for API mode"""
10
+ profile = ApiProfile(
11
+ name="test-api",
12
+ type="claude",
13
+ base_url="https://api.anthropic.com",
14
+ token="sk-ant-test"
15
+ )
16
+
17
+ env = prepare_environment(profile)
18
+
19
+ assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com"
20
+ assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-ant-test"
21
+
22
+
23
+ def test_prepare_env_login_mode():
24
+ """Test environment preparation for login mode"""
25
+ profile = LoginProfile(
26
+ name="test-login",
27
+ type="claude",
28
+ credentials_path="~/.claude-profiles/account-a"
29
+ )
30
+
31
+ env = prepare_environment(profile)
32
+
33
+ # Should NOT have API environment variables
34
+ assert "ANTHROPIC_BASE_URL" not in env
35
+ assert "ANTHROPIC_AUTH_TOKEN" not in env
36
+ # Should have CLAUDE_CONFIG_DIR with expanded path
37
+ expected_path = os.path.expanduser("~/.claude-profiles/account-a")
38
+ assert env["CLAUDE_CONFIG_DIR"] == expected_path
39
+
40
+
41
+ def test_prepare_env_with_proxy():
42
+ """Test environment preparation with proxy"""
43
+ profile = ApiProfile(
44
+ name="test-proxy",
45
+ type="gemini",
46
+ base_url="https://generativelanguage.googleapis.com",
47
+ api_key="AIza-test",
48
+ proxy="http://127.0.0.1:7890"
49
+ )
50
+
51
+ env = prepare_environment(profile)
52
+
53
+ assert env["HTTP_PROXY"] == "http://127.0.0.1:7890"
54
+ assert env["HTTPS_PROXY"] == "http://127.0.0.1:7890"
55
+
56
+
57
+ def test_prepare_env_login_clears_api_vars():
58
+ """Test that login mode clears API environment variables"""
59
+ # Set up environment with existing API variables
60
+ with patch.dict(os.environ, {
61
+ "ANTHROPIC_BASE_URL": "https://old.url",
62
+ "ANTHROPIC_AUTH_TOKEN": "old-token"
63
+ }):
64
+ profile = LoginProfile(
65
+ name="test-login",
66
+ type="claude",
67
+ credentials_path="~/.claude-profiles/account-a"
68
+ )
69
+
70
+ env = prepare_environment(profile)
71
+
72
+ # Should be cleared
73
+ assert "ANTHROPIC_BASE_URL" not in env
74
+ assert "ANTHROPIC_AUTH_TOKEN" not in env
@@ -0,0 +1,115 @@
1
+ import pytest
2
+ from src.code_ai.models import (
3
+ ApiProfile, LoginProfile, profile_from_dict, profile_to_dict, VALID_TYPES
4
+ )
5
+
6
+ def test_api_profile_creation():
7
+ """Test creating an API profile"""
8
+ profile = ApiProfile(
9
+ name="test-api",
10
+ type="claude",
11
+ base_url="https://api.anthropic.com",
12
+ token="sk-ant-test"
13
+ )
14
+ assert profile.name == "test-api"
15
+ assert profile.type == "claude"
16
+ assert profile.base_url == "https://api.anthropic.com"
17
+ assert profile.token == "sk-ant-test"
18
+ assert profile.proxy is None
19
+
20
+ def test_login_profile_creation():
21
+ """Test creating a login profile"""
22
+ profile = LoginProfile(
23
+ name="test-login",
24
+ type="claude",
25
+ credentials_path="~/.claude-profiles/account-a"
26
+ )
27
+ assert profile.name == "test-login"
28
+ assert profile.type == "claude"
29
+ assert profile.credentials_path == "~/.claude-profiles/account-a"
30
+ assert profile.proxy is None
31
+
32
+ def test_profile_with_proxy():
33
+ """Test profile with proxy"""
34
+ profile = ApiProfile(
35
+ name="test-proxy",
36
+ type="gemini",
37
+ base_url="https://generativelanguage.googleapis.com",
38
+ api_key="AIza-test",
39
+ proxy="http://127.0.0.1:7890"
40
+ )
41
+ assert profile.proxy == "http://127.0.0.1:7890"
42
+
43
+ def test_profile_from_dict_api_mode():
44
+ """Test converting dict to API profile"""
45
+ data = {
46
+ "name": "my-api",
47
+ "type": "claude",
48
+ "mode": "api",
49
+ "base_url": "https://api.anthropic.com",
50
+ "token": "sk-ant-test",
51
+ "proxy": None
52
+ }
53
+ profile = profile_from_dict(data)
54
+ assert isinstance(profile, ApiProfile)
55
+ assert profile.name == "my-api"
56
+ assert profile.base_url == "https://api.anthropic.com"
57
+
58
+ def test_profile_from_dict_login_mode():
59
+ """Test converting dict to login profile"""
60
+ data = {
61
+ "name": "my-login",
62
+ "type": "claude",
63
+ "mode": "login",
64
+ "credentials_path": "~/.claude-profiles/account-a",
65
+ "proxy": "http://127.0.0.1:7890"
66
+ }
67
+ profile = profile_from_dict(data)
68
+ assert isinstance(profile, LoginProfile)
69
+ assert profile.credentials_path == "~/.claude-profiles/account-a"
70
+ assert profile.proxy == "http://127.0.0.1:7890"
71
+
72
+ def test_profile_from_dict_defaults_to_api():
73
+ """Test that missing mode defaults to api"""
74
+ data = {
75
+ "name": "legacy",
76
+ "type": "claude",
77
+ "base_url": "https://api.anthropic.com",
78
+ "token": "sk-ant-test"
79
+ }
80
+ profile = profile_from_dict(data)
81
+ assert isinstance(profile, ApiProfile)
82
+
83
+ def test_profile_to_dict_api():
84
+ """Test converting API profile to dict"""
85
+ profile = ApiProfile(
86
+ name="my-api",
87
+ type="claude",
88
+ base_url="https://api.anthropic.com",
89
+ token="sk-ant-test"
90
+ )
91
+ data = profile_to_dict(profile)
92
+ assert data["name"] == "my-api"
93
+ assert data["type"] == "claude"
94
+ assert data["mode"] == "api"
95
+ assert data["base_url"] == "https://api.anthropic.com"
96
+ assert data["token"] == "sk-ant-test"
97
+
98
+ def test_profile_to_dict_login():
99
+ """Test converting login profile to dict"""
100
+ profile = LoginProfile(
101
+ name="my-login",
102
+ type="claude",
103
+ credentials_path="~/.claude-profiles/account-a",
104
+ proxy="http://127.0.0.1:7890"
105
+ )
106
+ data = profile_to_dict(profile)
107
+ assert data["name"] == "my-login"
108
+ assert data["type"] == "claude"
109
+ assert data["mode"] == "login"
110
+ assert data["credentials_path"] == "~/.claude-profiles/account-a"
111
+ assert data["proxy"] == "http://127.0.0.1:7890"
112
+
113
+ def test_valid_types():
114
+ """Test VALID_TYPES constant"""
115
+ assert VALID_TYPES == ("claude", "gemini", "codex")
@@ -1 +0,0 @@
1
- __version__ = "0.1.3"
@@ -1,58 +0,0 @@
1
- import os
2
- import sys
3
- import shutil
4
- import subprocess
5
-
6
- ENV_MAP = {
7
- "claude": {
8
- "env": {"ANTHROPIC_BASE_URL": "base_url", "ANTHROPIC_AUTH_TOKEN": "token"},
9
- "cmd": "claude",
10
- },
11
- "gemini": {
12
- "env": {"GOOGLE_GEMINI_BASE_URL": "base_url", "GEMINI_API_KEY": "api_key"},
13
- "cmd": "gemini",
14
- },
15
- "codex": {
16
- "env": {"OPENAI_BASE_URL": "base_url", "OPENAI_API_KEY": "api_key"},
17
- "cmd": "codex",
18
- },
19
- }
20
-
21
-
22
- def launch(profile, extra_args):
23
- ptype = profile["type"]
24
- if ptype not in ENV_MAP:
25
- print(f"Error: unknown profile type '{ptype}'.")
26
- sys.exit(1)
27
-
28
- spec = ENV_MAP[ptype]
29
- cmd = spec["cmd"]
30
-
31
- # On Windows, npm global commands are .cmd files
32
- if sys.platform == "win32":
33
- cmd_path = shutil.which(f"{cmd}.cmd") or shutil.which(cmd)
34
- else:
35
- cmd_path = shutil.which(cmd)
36
-
37
- if not cmd_path:
38
- print(f"Error: '{cmd}' not found in PATH. Install it first.")
39
- sys.exit(1)
40
-
41
- env = os.environ.copy()
42
- for env_var, config_key in spec["env"].items():
43
- value = profile.get(config_key)
44
- if value:
45
- env[env_var] = value
46
-
47
- full_cmd = [cmd_path] + extra_args
48
-
49
- if sys.platform == "win32":
50
- # On Windows, use subprocess with shell=False for better compatibility
51
- try:
52
- result = subprocess.run(full_cmd, env=env, shell=False)
53
- sys.exit(result.returncode)
54
- except KeyboardInterrupt:
55
- sys.exit(130)
56
- else:
57
- os.environ.update(env)
58
- os.execvp(cmd, [cmd] + extra_args)
@@ -1,80 +0,0 @@
1
- import sys
2
-
3
-
4
- VALID_TYPES = ("claude", "gemini", "codex")
5
-
6
-
7
- def list_profiles(config):
8
- profiles = config.get("profiles", {})
9
- if not profiles:
10
- print("No profiles configured.")
11
- return
12
- print(f"{'Name':<20} {'Type':<10} {'Base URL'}")
13
- print("-" * 60)
14
- for name, p in profiles.items():
15
- print(f"{name:<20} {p['type']:<10} {p.get('base_url', '')}")
16
-
17
-
18
- def add_profile(config):
19
- name = input("Profile name: ").strip()
20
- if not name:
21
- print("Error: name cannot be empty.")
22
- sys.exit(1)
23
- if name in config.get("profiles", {}):
24
- print(f"Error: profile '{name}' already exists.")
25
- sys.exit(1)
26
-
27
- ptype = input(f"Type ({'/'.join(VALID_TYPES)}): ").strip().lower()
28
- if ptype not in VALID_TYPES:
29
- print(f"Error: type must be one of {VALID_TYPES}.")
30
- sys.exit(1)
31
-
32
- base_url = input("Base URL: ").strip()
33
- if not base_url:
34
- print("Error: base URL cannot be empty.")
35
- sys.exit(1)
36
-
37
- if ptype == "claude":
38
- token = input("Auth token: ").strip()
39
- config.setdefault("profiles", {})[name] = {
40
- "type": ptype,
41
- "base_url": base_url,
42
- "token": token,
43
- }
44
- else:
45
- api_key = input("API key: ").strip()
46
- config.setdefault("profiles", {})[name] = {
47
- "type": ptype,
48
- "base_url": base_url,
49
- "api_key": api_key,
50
- }
51
-
52
- return config
53
-
54
-
55
- def show_profile(config, name):
56
- profiles = config.get("profiles", {})
57
- if name not in profiles:
58
- print(f"Error: profile '{name}' not found.")
59
- sys.exit(1)
60
-
61
- profile = profiles[name]
62
- print(f"Profile: {name}")
63
- print(f"Type: {profile['type']}")
64
- print(f"Base URL: {profile.get('base_url', 'N/A')}")
65
-
66
- if profile['type'] == 'claude':
67
- token = profile.get('token', 'N/A')
68
- print(f"Token: {token}")
69
- else:
70
- api_key = profile.get('api_key', 'N/A')
71
- print(f"API Key: {api_key}")
72
-
73
-
74
- def remove_profile(config, name):
75
- if name not in config.get("profiles", {}):
76
- print(f"Error: profile '{name}' not found.")
77
- sys.exit(1)
78
- del config["profiles"][name]
79
- print(f"Removed profile '{name}'.")
80
- return config