ai-code-switcher 0.1.4__tar.gz → 0.1.6__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.6}/PKG-INFO +1 -1
  2. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/pyproject.toml +1 -1
  3. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/src/ai_code_switcher.egg-info/PKG-INFO +1 -1
  4. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/src/ai_code_switcher.egg-info/SOURCES.txt +5 -1
  5. ai_code_switcher-0.1.6/src/code_ai/__init__.py +1 -0
  6. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/src/code_ai/cli.py +10 -3
  7. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/src/code_ai/config.py +10 -0
  8. ai_code_switcher-0.1.6/src/code_ai/launcher.py +95 -0
  9. ai_code_switcher-0.1.6/src/code_ai/models.py +66 -0
  10. ai_code_switcher-0.1.6/src/code_ai/profiles.py +160 -0
  11. ai_code_switcher-0.1.6/tests/test_integration.py +311 -0
  12. ai_code_switcher-0.1.6/tests/test_launcher.py +107 -0
  13. ai_code_switcher-0.1.6/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.6}/README.md +0 -0
  18. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/setup.cfg +0 -0
  19. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/src/ai_code_switcher.egg-info/dependency_links.txt +0 -0
  20. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/src/ai_code_switcher.egg-info/entry_points.txt +0 -0
  21. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/src/ai_code_switcher.egg-info/requires.txt +0 -0
  22. {ai_code_switcher-0.1.4 → ai_code_switcher-0.1.6}/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.6
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.6"
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.6
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.6"
@@ -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,95 @@
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_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 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
+ # Set the appropriate CONFIG_DIR based on profile type
41
+ config_dir_var = f"{ptype.upper()}_CONFIG_DIR"
42
+ env[config_dir_var] = credentials_path
43
+ elif isinstance(profile, ApiProfile):
44
+ # API mode: set API environment variables
45
+ for env_var, config_key in spec["env"].items():
46
+ value = getattr(profile, config_key, None)
47
+ if value:
48
+ env[env_var] = value
49
+
50
+ # Handle proxy (all modes)
51
+ if profile.proxy:
52
+ env["HTTP_PROXY"] = profile.proxy
53
+ env["HTTPS_PROXY"] = profile.proxy
54
+
55
+ return env
56
+
57
+
58
+ def launch(profile_dict, extra_args):
59
+ # Convert dict to dataclass
60
+ profile = profile_from_dict(profile_dict)
61
+ ptype = profile.type
62
+
63
+ if ptype not in ENV_MAP:
64
+ print(f"Error: unknown profile type '{ptype}'.")
65
+ sys.exit(1)
66
+
67
+ spec = ENV_MAP[ptype]
68
+ cmd = spec["cmd"]
69
+
70
+ # On Windows, npm global commands are .cmd files
71
+ if sys.platform == "win32":
72
+ cmd_path = shutil.which(f"{cmd}.cmd") or shutil.which(cmd)
73
+ else:
74
+ cmd_path = shutil.which(cmd)
75
+
76
+ if not cmd_path:
77
+ print(f"Error: '{cmd}' not found in PATH. Install it first.")
78
+ sys.exit(1)
79
+
80
+ # Prepare environment
81
+ env = prepare_environment(profile)
82
+
83
+ full_cmd = [cmd_path] + extra_args
84
+
85
+ if sys.platform == "win32":
86
+ # On Windows, use subprocess with full environment
87
+ try:
88
+ result = subprocess.run(full_cmd, env=env, shell=False)
89
+ sys.exit(result.returncode)
90
+ except KeyboardInterrupt:
91
+ sys.exit(130)
92
+ else:
93
+ # On Unix, update os.environ and use execvp
94
+ os.environ.update(env)
95
+ 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" or ptype == "codex") 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,160 @@
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 and Codex
70
+ if ptype in ("claude", "codex"):
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"~/.{ptype}-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
+ if ptype == "claude":
88
+ token = input("Auth token: ").strip()
89
+ profile_data["base_url"] = base_url
90
+ profile_data["token"] = token
91
+ else: # codex
92
+ api_key = input("API key: ").strip()
93
+ profile_data["base_url"] = base_url
94
+ profile_data["api_key"] = api_key
95
+ else: # gemini - API mode only
96
+ base_url = input("Base URL: ").strip()
97
+ if not base_url:
98
+ print("Error: base URL cannot be empty.")
99
+ sys.exit(1)
100
+ api_key = input("API key: ").strip()
101
+ profile_data["base_url"] = base_url
102
+ profile_data["api_key"] = api_key
103
+
104
+ # Optional proxy for all types
105
+ proxy = input("Proxy (optional, e.g., http://127.0.0.1:7890): ").strip()
106
+ if proxy:
107
+ profile_data["proxy"] = proxy
108
+
109
+ config.setdefault("profiles", {})[name] = profile_data
110
+ return config
111
+
112
+
113
+ def show_profile(config, name):
114
+ profiles = config.get("profiles", {})
115
+ if name not in profiles:
116
+ print(f"Error: profile '{name}' not found.")
117
+ sys.exit(1)
118
+
119
+ profile_dict = profiles[name]
120
+ profile = profile_from_dict(profile_dict)
121
+
122
+ print(f"Profile: {name}")
123
+ print(f"Type: {profile.type}")
124
+
125
+ # Display mode for Claude and Codex types
126
+ if profile.type in ("claude", "codex"):
127
+ mode = profile_dict.get("mode", "api")
128
+ print(f"Mode: {mode}")
129
+
130
+ if mode == "login":
131
+ credentials_path = profile_dict.get("credentials_path", "N/A")
132
+ print(f"Credentials Path: {credentials_path}")
133
+ else: # api mode
134
+ base_url = profile_dict.get("base_url", "N/A")
135
+ if profile.type == "claude":
136
+ token = profile_dict.get("token", "N/A")
137
+ print(f"Base URL: {base_url}")
138
+ print(f"Token: {token}")
139
+ else: # codex
140
+ api_key = profile_dict.get("api_key", "N/A")
141
+ print(f"Base URL: {base_url}")
142
+ print(f"API Key: {api_key}")
143
+ else: # gemini
144
+ base_url = profile_dict.get("base_url", "N/A")
145
+ api_key = profile_dict.get("api_key", "N/A")
146
+ print(f"Base URL: {base_url}")
147
+ print(f"API Key: {api_key}")
148
+
149
+ # Display proxy if set
150
+ if profile.proxy:
151
+ print(f"Proxy: {profile.proxy}")
152
+
153
+
154
+ def remove_profile(config, name):
155
+ if name not in config.get("profiles", {}):
156
+ print(f"Error: profile '{name}' not found.")
157
+ sys.exit(1)
158
+ del config["profiles"][name]
159
+ print(f"Removed profile '{name}'.")
160
+ return config
@@ -0,0 +1,311 @@
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 only have OPENAI_API_KEY, not OPENAI_BASE_URL
265
+ assert env["OPENAI_API_KEY"] == "sk-test-key"
266
+ assert "OPENAI_BASE_URL" not in env
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_CONFIG_DIR with expanded path
309
+ import os
310
+ expected_path = os.path.expanduser("~/.codex-profiles/account-a")
311
+ assert env["CODEX_CONFIG_DIR"] == expected_path
@@ -0,0 +1,107 @@
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
75
+
76
+
77
+ def test_prepare_env_codex_api_mode():
78
+ """Test environment preparation for codex API mode"""
79
+ profile = ApiProfile(
80
+ name="test-codex-api",
81
+ type="codex",
82
+ base_url="https://api.openai.com/v1",
83
+ api_key="sk-test"
84
+ )
85
+
86
+ env = prepare_environment(profile)
87
+
88
+ # Should only have OPENAI_API_KEY, not OPENAI_BASE_URL
89
+ assert env["OPENAI_API_KEY"] == "sk-test"
90
+ assert "OPENAI_BASE_URL" not in env
91
+
92
+
93
+ def test_prepare_env_codex_login_mode():
94
+ """Test environment preparation for codex login mode"""
95
+ profile = LoginProfile(
96
+ name="test-codex-login",
97
+ type="codex",
98
+ credentials_path="~/.codex-profiles/account-a"
99
+ )
100
+
101
+ env = prepare_environment(profile)
102
+
103
+ # Should NOT have API environment variables
104
+ assert "OPENAI_API_KEY" not in env
105
+ # Should have CODEX_CONFIG_DIR with expanded path
106
+ expected_path = os.path.expanduser("~/.codex-profiles/account-a")
107
+ assert env["CODEX_CONFIG_DIR"] == expected_path
@@ -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