ai-code-switcher 0.1.16__tar.gz → 0.1.18__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.
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/PKG-INFO +1 -1
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/README.md +10 -5
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/pyproject.toml +1 -1
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/PKG-INFO +1 -1
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/SOURCES.txt +1 -0
- ai_code_switcher-0.1.18/src/code_ai/__init__.py +1 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/cli.py +4 -4
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/app.py +0 -16
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/bridge.py +4 -2
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/windows.py +26 -1
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/app.js +22 -16
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/style.css +9 -1
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/launcher.py +8 -4
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/models.py +6 -6
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/profiles.py +18 -21
- ai_code_switcher-0.1.18/tests/test_cli_upgrade.py +6 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_app_import.py +42 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_bridge.py +8 -4
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_windows.py +46 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_integration.py +59 -27
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_launcher.py +61 -3
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_models.py +19 -7
- ai_code_switcher-0.1.16/src/code_ai/__init__.py +0 -1
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/setup.cfg +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/dependency_links.txt +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/entry_points.txt +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/requires.txt +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/top_level.txt +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/buddy.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/config.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/__init__.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/apps.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/config.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/env.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/__init__.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/base.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/macos.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/icon.icns +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/icon.ico +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/icon.png +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/index.html +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_apps.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_backend.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_base.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_cli.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_config.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_env.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_icons.py +0 -0
- {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_macos.py +0 -0
|
@@ -4,7 +4,7 @@ Switch AI coding tool profiles and launch the correct CLI.
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- Manage multiple AI coding tool profiles for Claude, Codex, and
|
|
7
|
+
- Manage multiple AI coding tool profiles for Claude, Codex, and Grok
|
|
8
8
|
- Switch between API-mode and login-mode profiles
|
|
9
9
|
- Launch the matching CLI through one command entrypoint
|
|
10
10
|
- Per-profile default launch arguments (e.g., always pass `--model ...`)
|
|
@@ -39,7 +39,7 @@ code-ai show <profile-name>
|
|
|
39
39
|
Launch a profile:
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
code-ai run fox-
|
|
42
|
+
code-ai run fox-grok
|
|
43
43
|
code-ai run 4399
|
|
44
44
|
code-ai run fox-claude -p "hi"
|
|
45
45
|
code-ai run --no-default-args fox-claude --model sonnet # bypass profile defaults
|
|
@@ -102,7 +102,7 @@ This upgrades:
|
|
|
102
102
|
|
|
103
103
|
- `@anthropic-ai/claude-code`
|
|
104
104
|
- `@openai/codex`
|
|
105
|
-
- `@
|
|
105
|
+
- `@xai-official/grok`
|
|
106
106
|
|
|
107
107
|
Version:
|
|
108
108
|
|
|
@@ -120,6 +120,10 @@ code-ai --help
|
|
|
120
120
|
|
|
121
121
|
Profiles are stored under `~/.code-ai/config.yaml`.
|
|
122
122
|
|
|
123
|
+
Grok profiles support both modes. API mode sets `XAI_API_KEY` and optionally
|
|
124
|
+
`GROK_CLI_CHAT_PROXY_BASE_URL`; login mode isolates browser credentials and
|
|
125
|
+
configuration through `GROK_HOME`.
|
|
126
|
+
|
|
123
127
|
## Project Layout
|
|
124
128
|
|
|
125
129
|
```text
|
|
@@ -143,8 +147,9 @@ MIT
|
|
|
143
147
|
## Desktop launcher (`code-ai desktop`)
|
|
144
148
|
|
|
145
149
|
A double-clickable GUI (Windows + macOS) that starts and Steam-style stops the
|
|
146
|
-
Claude, ChatGPT, and Codex desktop apps. Each app
|
|
147
|
-
|
|
150
|
+
Claude, ChatGPT, and Codex desktop apps. Each detected app shows separate launch
|
|
151
|
+
and stop actions; launch first stops any running instance so the new environment
|
|
152
|
+
is applied cleanly.
|
|
148
153
|
|
|
149
154
|
```bash
|
|
150
155
|
pip install ai-code-switcher[desktop] # one-time: install the GUI deps
|
{ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/SOURCES.txt
RENAMED
|
@@ -29,6 +29,7 @@ src/code_ai/desktop/ui/icon.ico
|
|
|
29
29
|
src/code_ai/desktop/ui/icon.png
|
|
30
30
|
src/code_ai/desktop/ui/index.html
|
|
31
31
|
src/code_ai/desktop/ui/style.css
|
|
32
|
+
tests/test_cli_upgrade.py
|
|
32
33
|
tests/test_desktop_app_import.py
|
|
33
34
|
tests/test_desktop_apps.py
|
|
34
35
|
tests/test_desktop_backend.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.18"
|
|
@@ -128,7 +128,7 @@ def roll_claude_buddy_command(
|
|
|
128
128
|
|
|
129
129
|
@app.command(name="upgrade")
|
|
130
130
|
def upgrade_command():
|
|
131
|
-
"""Upgrade claude, codex, and
|
|
131
|
+
"""Upgrade claude, codex, and grok CLI via npm"""
|
|
132
132
|
upgrade()
|
|
133
133
|
|
|
134
134
|
|
|
@@ -277,7 +277,7 @@ def main(
|
|
|
277
277
|
Examples:
|
|
278
278
|
code-ai list List all profiles
|
|
279
279
|
code-ai show fox-claude Show profile details
|
|
280
|
-
code-ai run fox-
|
|
280
|
+
code-ai run fox-grok Launch Grok CLI with fox profile
|
|
281
281
|
code-ai run 4399 Launch Claude CLI with 4399 profile
|
|
282
282
|
code-ai run fox-claude -p "hi" Pass extra args to Claude CLI
|
|
283
283
|
"""
|
|
@@ -287,12 +287,12 @@ def main(
|
|
|
287
287
|
UPGRADE_PACKAGES = [
|
|
288
288
|
"@anthropic-ai/claude-code",
|
|
289
289
|
"@openai/codex",
|
|
290
|
-
"@
|
|
290
|
+
"@xai-official/grok",
|
|
291
291
|
]
|
|
292
292
|
|
|
293
293
|
|
|
294
294
|
def upgrade():
|
|
295
|
-
typer.echo("Upgrading claude, codex,
|
|
295
|
+
typer.echo("Upgrading claude, codex, grok CLI...")
|
|
296
296
|
|
|
297
297
|
# On Windows, npm is a .cmd file, need to use shell=True or npm.cmd
|
|
298
298
|
if sys.platform == "win32":
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
import json
|
|
2
1
|
import pathlib
|
|
3
2
|
import sys
|
|
4
|
-
import threading
|
|
5
|
-
import time
|
|
6
3
|
|
|
7
4
|
from .apps import APP_REGISTRY
|
|
8
5
|
from .bridge import LauncherBridge
|
|
9
6
|
from .platforms import get_backend
|
|
10
7
|
|
|
11
|
-
POLL_SECONDS = 1.5
|
|
12
|
-
|
|
13
8
|
|
|
14
9
|
def _ui_asset_path(name: str) -> pathlib.Path:
|
|
15
10
|
return pathlib.Path(__file__).resolve().parent / "ui" / name
|
|
@@ -46,15 +41,4 @@ def run_gui():
|
|
|
46
41
|
"AI Launcher", url=_ui_url(), js_api=bridge, width=760, height=560,
|
|
47
42
|
)
|
|
48
43
|
bridge._attach_window(window, webview.OPEN_DIALOG)
|
|
49
|
-
|
|
50
|
-
def poll():
|
|
51
|
-
while True:
|
|
52
|
-
time.sleep(POLL_SECONDS)
|
|
53
|
-
try:
|
|
54
|
-
payload = json.dumps(bridge._statuses())
|
|
55
|
-
window.evaluate_js(f"window.updateStatus({payload})")
|
|
56
|
-
except Exception:
|
|
57
|
-
break
|
|
58
|
-
|
|
59
|
-
threading.Thread(target=poll, daemon=True).start()
|
|
60
44
|
webview.start(icon=_window_icon_path())
|
|
@@ -63,8 +63,10 @@ class LauncherBridge:
|
|
|
63
63
|
self._status[app_id] = status
|
|
64
64
|
if not status.found:
|
|
65
65
|
return {"ok": False, "error": "未检测到应用,请先配置路径"}
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
try:
|
|
67
|
+
self._backend.stop(status)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
return {"ok": False, "error": f"中止失败: {exc}"}
|
|
68
70
|
env = merge_env(os.environ,
|
|
69
71
|
cfg.get_common_env(data),
|
|
70
72
|
cfg.get_app_env(data, app_id))
|
{ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/windows.py
RENAMED
|
@@ -26,6 +26,8 @@ _IGNORED_PROCESS_NAMES = {
|
|
|
26
26
|
"claude": ("cowork-svc.exe",),
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
_DESKTOP_PYTHON_ENV = "CODE_AI_DESKTOP_PYTHON"
|
|
30
|
+
|
|
29
31
|
|
|
30
32
|
class WindowsBackend:
|
|
31
33
|
# ---- detection ----
|
|
@@ -128,6 +130,15 @@ class WindowsBackend:
|
|
|
128
130
|
subprocess.Popen([status.launch_target], env=env,
|
|
129
131
|
creationflags=_NO_WINDOW)
|
|
130
132
|
return
|
|
133
|
+
if status.app_id == "codex":
|
|
134
|
+
# Codex's Windows app starts its own sandbox helpers from the MSIX
|
|
135
|
+
# package. Launching through the OS broker preserves package
|
|
136
|
+
# activation context and avoids direct WindowsApps process quirks.
|
|
137
|
+
subprocess.Popen(
|
|
138
|
+
["explorer.exe", f"shell:AppsFolder\\{status.launch_target}"],
|
|
139
|
+
env=env, creationflags=_NO_WINDOW,
|
|
140
|
+
)
|
|
141
|
+
return
|
|
131
142
|
# Brokered MSIX: launch the real binary directly when resolvable, so the
|
|
132
143
|
# injected env actually reaches the app. Shell activation (explorer
|
|
133
144
|
# shell:AppsFolder) runs it under an OS broker that strips our custom
|
|
@@ -182,10 +193,24 @@ class WindowsBackend:
|
|
|
182
193
|
return ""
|
|
183
194
|
return str(icon)
|
|
184
195
|
|
|
185
|
-
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _shortcut_python():
|
|
198
|
+
override = os.environ.get(_DESKTOP_PYTHON_ENV)
|
|
199
|
+
if override:
|
|
200
|
+
candidate = os.path.expandvars(os.path.expanduser(override.strip('"')))
|
|
201
|
+
if os.path.basename(candidate).lower() == "python.exe":
|
|
202
|
+
pythonw = os.path.join(os.path.dirname(candidate), "pythonw.exe")
|
|
203
|
+
if os.path.exists(pythonw):
|
|
204
|
+
return pythonw
|
|
205
|
+
if os.path.exists(candidate):
|
|
206
|
+
return candidate
|
|
186
207
|
pythonw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
|
|
187
208
|
if not os.path.exists(pythonw):
|
|
188
209
|
pythonw = sys.executable
|
|
210
|
+
return pythonw
|
|
211
|
+
|
|
212
|
+
def create_shortcut(self) -> str:
|
|
213
|
+
pythonw = self._shortcut_python()
|
|
189
214
|
icon = self._icon_path()
|
|
190
215
|
paths = self._shortcut_paths()
|
|
191
216
|
# Idempotent: if any shortcut already exists, do nothing (no shell-out).
|
|
@@ -29,10 +29,16 @@ function renderApps() {
|
|
|
29
29
|
`<button class="cfg" title="专有设置">⚙</button>` +
|
|
30
30
|
`<div class="title">${esc(a.display)}</div>` +
|
|
31
31
|
`<div class="status" id="status-${esc(a.id)}"></div>` +
|
|
32
|
-
`<
|
|
32
|
+
`<div class="card-actions">` +
|
|
33
|
+
`<button class="action" id="btn-launch-${esc(a.id)}" data-mode="launch">启动</button>` +
|
|
34
|
+
`<button class="action" id="btn-stop-${esc(a.id)}" data-mode="stop">中止</button>` +
|
|
35
|
+
`<button class="action" id="btn-config-${esc(a.id)}" data-mode="config">配置路径</button>` +
|
|
36
|
+
`</div>`;
|
|
33
37
|
grid.appendChild(card);
|
|
34
38
|
updateCard(a.id, a.found, a.running);
|
|
35
|
-
document.getElementById(`btn-${a.id}`).onclick = () => onAction(a.id);
|
|
39
|
+
document.getElementById(`btn-launch-${a.id}`).onclick = () => onAction(a.id, "launch");
|
|
40
|
+
document.getElementById(`btn-stop-${a.id}`).onclick = () => onAction(a.id, "stop");
|
|
41
|
+
document.getElementById(`btn-config-${a.id}`).onclick = () => onAction(a.id, "config");
|
|
36
42
|
card.querySelector(".cfg").onclick = () => openAppSettings(a.id);
|
|
37
43
|
}
|
|
38
44
|
}
|
|
@@ -41,22 +47,29 @@ function updateCard(id, found, running) {
|
|
|
41
47
|
const a = findApp(id);
|
|
42
48
|
if (a) { a.found = found; a.running = running; }
|
|
43
49
|
const s = document.getElementById(`status-${id}`);
|
|
44
|
-
const
|
|
45
|
-
|
|
50
|
+
const launch = document.getElementById(`btn-launch-${id}`);
|
|
51
|
+
const stop = document.getElementById(`btn-stop-${id}`);
|
|
52
|
+
const config = document.getElementById(`btn-config-${id}`);
|
|
53
|
+
if (!s || !launch || !stop || !config) return;
|
|
46
54
|
if (!found) {
|
|
47
55
|
s.textContent = "⚠ 未检测到"; s.className = "status warn";
|
|
48
|
-
|
|
56
|
+
launch.disabled = true;
|
|
57
|
+
stop.disabled = true;
|
|
58
|
+
config.hidden = false;
|
|
49
59
|
} else if (running) {
|
|
50
60
|
s.textContent = "● 运行中"; s.className = "status on";
|
|
51
|
-
|
|
61
|
+
launch.disabled = false;
|
|
62
|
+
stop.disabled = false;
|
|
63
|
+
config.hidden = true;
|
|
52
64
|
} else {
|
|
53
65
|
s.textContent = "○ 已停止"; s.className = "status off";
|
|
54
|
-
|
|
66
|
+
launch.disabled = false;
|
|
67
|
+
stop.disabled = false;
|
|
68
|
+
config.hidden = true;
|
|
55
69
|
}
|
|
56
70
|
}
|
|
57
71
|
|
|
58
|
-
async function onAction(id) {
|
|
59
|
-
const mode = document.getElementById(`btn-${id}`).dataset.mode;
|
|
72
|
+
async function onAction(id, mode) {
|
|
60
73
|
if (mode === "config") {
|
|
61
74
|
const r = await api().pick_app_path(id);
|
|
62
75
|
if (r && r.ok) await refresh();
|
|
@@ -77,13 +90,6 @@ async function refresh() {
|
|
|
77
90
|
for (const a of APPS) updateCard(a.id, a.found, a.running);
|
|
78
91
|
}
|
|
79
92
|
|
|
80
|
-
window.updateStatus = (map) => {
|
|
81
|
-
for (const id in map) {
|
|
82
|
-
const a = findApp(id);
|
|
83
|
-
if (a) updateCard(id, a.found, map[id]);
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
|
|
87
93
|
function toast(msg) {
|
|
88
94
|
const t = document.getElementById("toast");
|
|
89
95
|
t.textContent = msg;
|
|
@@ -28,6 +28,12 @@ header h1 { font-size: 16px; margin: 0; letter-spacing: .5px; }
|
|
|
28
28
|
.status.on { color: #46d27e; }
|
|
29
29
|
.status.off { color: #8a909a; }
|
|
30
30
|
.status.warn { color: #e0a93b; }
|
|
31
|
+
.card-actions {
|
|
32
|
+
width: 100%;
|
|
33
|
+
display: grid;
|
|
34
|
+
grid-template-columns: 1fr 1fr;
|
|
35
|
+
gap: 8px;
|
|
36
|
+
}
|
|
31
37
|
.action {
|
|
32
38
|
width: 100%; padding: 9px 0; border-radius: 9px; border: none;
|
|
33
39
|
cursor: pointer; font-size: 14px; font-weight: 600;
|
|
@@ -35,7 +41,9 @@ header h1 { font-size: 16px; margin: 0; letter-spacing: .5px; }
|
|
|
35
41
|
}
|
|
36
42
|
.action:hover { filter: brightness(1.08); }
|
|
37
43
|
.action[data-mode="stop"] { background: #b5453d; }
|
|
38
|
-
.action[data-mode="config"] { background: #4a5160; }
|
|
44
|
+
.action[data-mode="config"] { background: #4a5160; grid-column: 1 / -1; }
|
|
45
|
+
.action:disabled { opacity: .45; cursor: default; filter: none; }
|
|
46
|
+
.action[hidden] { display: none; }
|
|
39
47
|
.panel {
|
|
40
48
|
padding: 18px 20px; border-top: 1px solid #262a31;
|
|
41
49
|
}
|
|
@@ -12,9 +12,12 @@ ENV_MAP = {
|
|
|
12
12
|
"env": {"ANTHROPIC_BASE_URL": "base_url", "ANTHROPIC_AUTH_TOKEN": "token"},
|
|
13
13
|
"cmd": "claude",
|
|
14
14
|
},
|
|
15
|
-
"
|
|
16
|
-
"env": {
|
|
17
|
-
|
|
15
|
+
"grok": {
|
|
16
|
+
"env": {
|
|
17
|
+
"GROK_CLI_CHAT_PROXY_BASE_URL": "base_url",
|
|
18
|
+
"XAI_API_KEY": "api_key",
|
|
19
|
+
},
|
|
20
|
+
"cmd": "grok",
|
|
18
21
|
},
|
|
19
22
|
"codex": {
|
|
20
23
|
"env": {"OPENAI_BASE_URL": "base_url", "OPENAI_API_KEY": "api_key"},
|
|
@@ -24,6 +27,7 @@ ENV_MAP = {
|
|
|
24
27
|
|
|
25
28
|
CONFIG_DIR_ENV_VARS = {
|
|
26
29
|
"claude": "CLAUDE_CONFIG_DIR",
|
|
30
|
+
"grok": "GROK_HOME",
|
|
27
31
|
"codex": "CODEX_HOME",
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -63,7 +67,7 @@ def prepare_environment(profile):
|
|
|
63
67
|
# Expand ~ to home directory
|
|
64
68
|
credentials_path = os.path.expanduser(profile.credentials_path)
|
|
65
69
|
# Set the appropriate config dir env var based on profile type
|
|
66
|
-
#
|
|
70
|
+
# Each supported CLI exposes an environment variable for its home dir.
|
|
67
71
|
config_dir_var = CONFIG_DIR_ENV_VARS.get(ptype)
|
|
68
72
|
if config_dir_var:
|
|
69
73
|
if not credentials_path:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from dataclasses import dataclass, asdict, field
|
|
2
2
|
from typing import List, Optional, Union
|
|
3
3
|
|
|
4
|
-
VALID_TYPES = ("claude", "
|
|
4
|
+
VALID_TYPES = ("claude", "grok", "codex")
|
|
5
5
|
|
|
6
6
|
# default_args is stored in either form the user wrote it: a YAML list, or a
|
|
7
7
|
# single command-line string parsed at use-site via shlex.split.
|
|
@@ -12,22 +12,22 @@ DefaultArgs = Optional[Union[List[str], str]]
|
|
|
12
12
|
class BaseProfile:
|
|
13
13
|
"""Common fields for all profiles"""
|
|
14
14
|
name: str
|
|
15
|
-
type: str # "claude" | "
|
|
15
|
+
type: str # "claude" | "grok" | "codex"
|
|
16
16
|
proxy: Optional[str] = None # e.g., "http://127.0.0.1:7890"
|
|
17
17
|
default_args: DefaultArgs = None # appended after CLI extra_args at launch
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
@dataclass
|
|
21
21
|
class ApiProfile(BaseProfile):
|
|
22
|
-
"""API mode: authenticate
|
|
22
|
+
"""API mode: authenticate with a token/key and an optional endpoint."""
|
|
23
23
|
base_url: str = ""
|
|
24
24
|
token: Optional[str] = None # Claude only
|
|
25
|
-
api_key: Optional[str] = None #
|
|
25
|
+
api_key: Optional[str] = None # Grok/Codex only
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
@dataclass
|
|
29
29
|
class LoginProfile(BaseProfile):
|
|
30
|
-
"""Login mode: authenticate via
|
|
30
|
+
"""Login mode: authenticate via an isolated credentials directory."""
|
|
31
31
|
credentials_path: str = "" # Path to existing OAuth credentials
|
|
32
32
|
|
|
33
33
|
|
|
@@ -39,7 +39,7 @@ def profile_from_dict(data: dict) -> BaseProfile:
|
|
|
39
39
|
proxy = data.get("proxy")
|
|
40
40
|
default_args = data.get("default_args")
|
|
41
41
|
|
|
42
|
-
if
|
|
42
|
+
if ptype in VALID_TYPES and mode == "login":
|
|
43
43
|
return LoginProfile(
|
|
44
44
|
name=name,
|
|
45
45
|
type=ptype,
|
|
@@ -55,8 +55,8 @@ def add_profile(config):
|
|
|
55
55
|
"type": ptype,
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
#
|
|
59
|
-
if ptype in
|
|
58
|
+
# All supported CLIs can use either API credentials or isolated login state.
|
|
59
|
+
if ptype in VALID_TYPES:
|
|
60
60
|
mode = input("Mode (api/login) [api]: ").strip().lower() or "api"
|
|
61
61
|
if mode not in ("api", "login"):
|
|
62
62
|
print("Error: mode must be 'api' or 'login'.")
|
|
@@ -69,26 +69,28 @@ def add_profile(config):
|
|
|
69
69
|
credentials_path = f"~/.{ptype}-profiles/{name}"
|
|
70
70
|
profile_data["credentials_path"] = credentials_path
|
|
71
71
|
else: # api mode
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
prompt = (
|
|
73
|
+
"Base URL (optional, leave blank for xAI default): "
|
|
74
|
+
if ptype == "grok"
|
|
75
|
+
else "Base URL: "
|
|
76
|
+
)
|
|
77
|
+
base_url = input(prompt).strip()
|
|
78
|
+
if not base_url and ptype != "grok":
|
|
74
79
|
print("Error: base URL cannot be empty.")
|
|
75
80
|
sys.exit(1)
|
|
76
81
|
if ptype == "claude":
|
|
77
82
|
token = input("Auth token: ").strip()
|
|
78
83
|
profile_data["base_url"] = base_url
|
|
79
84
|
profile_data["token"] = token
|
|
80
|
-
|
|
85
|
+
elif ptype == "codex":
|
|
81
86
|
api_key = input("API key: ").strip()
|
|
82
87
|
profile_data["base_url"] = base_url
|
|
83
88
|
profile_data["api_key"] = api_key
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
api_key = input("API key: ").strip()
|
|
90
|
-
profile_data["base_url"] = base_url
|
|
91
|
-
profile_data["api_key"] = api_key
|
|
89
|
+
else: # grok
|
|
90
|
+
api_key = input("API key: ").strip()
|
|
91
|
+
if base_url:
|
|
92
|
+
profile_data["base_url"] = base_url
|
|
93
|
+
profile_data["api_key"] = api_key
|
|
92
94
|
|
|
93
95
|
# Optional proxy for all types
|
|
94
96
|
proxy = input("Proxy (optional, e.g., http://127.0.0.1:7890): ").strip()
|
|
@@ -119,8 +121,8 @@ def show_profile(config, name):
|
|
|
119
121
|
print(f"Profile: {name}")
|
|
120
122
|
print(f"Type: {profile.type}")
|
|
121
123
|
|
|
122
|
-
# Display mode
|
|
123
|
-
if profile.type in
|
|
124
|
+
# Display authentication mode and its relevant fields.
|
|
125
|
+
if profile.type in VALID_TYPES:
|
|
124
126
|
mode = profile_dict.get("mode", "api")
|
|
125
127
|
print(f"Mode: {mode}")
|
|
126
128
|
|
|
@@ -133,15 +135,10 @@ def show_profile(config, name):
|
|
|
133
135
|
token = profile_dict.get("token", "N/A")
|
|
134
136
|
print(f"Base URL: {base_url}")
|
|
135
137
|
print(f"Token: {token}")
|
|
136
|
-
else: # codex
|
|
138
|
+
else: # codex or grok
|
|
137
139
|
api_key = profile_dict.get("api_key", "N/A")
|
|
138
140
|
print(f"Base URL: {base_url}")
|
|
139
141
|
print(f"API Key: {api_key}")
|
|
140
|
-
else: # gemini
|
|
141
|
-
base_url = profile_dict.get("base_url", "N/A")
|
|
142
|
-
api_key = profile_dict.get("api_key", "N/A")
|
|
143
|
-
print(f"Base URL: {base_url}")
|
|
144
|
-
print(f"API Key: {api_key}")
|
|
145
142
|
|
|
146
143
|
# Display proxy if set
|
|
147
144
|
if profile.proxy:
|
|
@@ -22,6 +22,15 @@ def test_ui_assets_are_packaged():
|
|
|
22
22
|
assert (ui / "app.js").is_file()
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def test_ui_lists_launch_and_stop_actions_without_status_push_hook():
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
script = (Path(app_mod.__file__).parent / "ui" / "app.js").read_text(encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
assert 'onAction(a.id, "launch")' in script
|
|
30
|
+
assert 'onAction(a.id, "stop")' in script
|
|
31
|
+
assert "window.updateStatus" not in script
|
|
32
|
+
|
|
33
|
+
|
|
25
34
|
def test_ui_url_is_file_uri():
|
|
26
35
|
# pywebview must receive a file:// URI (not a bare OS path) so the window
|
|
27
36
|
# actually renders and relative asset links resolve.
|
|
@@ -135,3 +144,36 @@ def test_run_gui_exposes_only_js_api_methods_to_pywebview(monkeypatch, tmp_path)
|
|
|
135
144
|
"save_settings",
|
|
136
145
|
"stop_app",
|
|
137
146
|
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_run_gui_does_not_start_background_status_poll(monkeypatch, tmp_path):
|
|
150
|
+
captured = {"threads": []}
|
|
151
|
+
fake_window = _FakeWindow()
|
|
152
|
+
|
|
153
|
+
def create_window(_title, url, js_api, width, height):
|
|
154
|
+
return fake_window
|
|
155
|
+
|
|
156
|
+
class Thread:
|
|
157
|
+
def __init__(self, *args, **kwargs):
|
|
158
|
+
captured["threads"].append((args, kwargs))
|
|
159
|
+
|
|
160
|
+
def start(self):
|
|
161
|
+
raise AssertionError("run_gui must not start a background poll thread")
|
|
162
|
+
|
|
163
|
+
fake_webview = types.SimpleNamespace(
|
|
164
|
+
OPEN_DIALOG=20,
|
|
165
|
+
create_window=create_window,
|
|
166
|
+
start=lambda **kwargs: captured.update(start_kwargs=kwargs),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
monkeypatch.setitem(sys.modules, "webview", fake_webview)
|
|
170
|
+
monkeypatch.setattr(app_mod, "get_backend", lambda: _FakeBackend())
|
|
171
|
+
monkeypatch.setattr(app_mod, "threading", types.SimpleNamespace(Thread=Thread), raising=False)
|
|
172
|
+
monkeypatch.setattr(
|
|
173
|
+
"src.code_ai.desktop.config.DESKTOP_CONFIG_FILE",
|
|
174
|
+
tmp_path / "desktop.yaml",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
app_mod.run_gui()
|
|
178
|
+
|
|
179
|
+
assert captured["threads"] == []
|
|
@@ -26,18 +26,21 @@ class FakeBackend:
|
|
|
26
26
|
self.running = running
|
|
27
27
|
self.launched = None
|
|
28
28
|
self.stopped = None
|
|
29
|
+
self.calls = []
|
|
29
30
|
|
|
30
31
|
def detect(self, app, override):
|
|
31
32
|
return AppStatus(app.id, found=self.found, direct=False,
|
|
32
33
|
launch_target="t", match_root="r")
|
|
33
34
|
|
|
34
35
|
def launch(self, status, env):
|
|
36
|
+
self.calls.append("launch")
|
|
35
37
|
self.launched = (status, env)
|
|
36
38
|
|
|
37
39
|
def is_running(self, status):
|
|
38
40
|
return self.running
|
|
39
41
|
|
|
40
42
|
def stop(self, status):
|
|
43
|
+
self.calls.append("stop")
|
|
41
44
|
self.stopped = status
|
|
42
45
|
|
|
43
46
|
def proxy_enabled(self):
|
|
@@ -103,15 +106,16 @@ def test_launch_merges_env_with_per_app_winning():
|
|
|
103
106
|
assert env["A_ONLY"] == "2"
|
|
104
107
|
|
|
105
108
|
|
|
106
|
-
def
|
|
109
|
+
def test_launch_stops_running_app_before_starting():
|
|
107
110
|
with temp_desktop_config() as f:
|
|
108
111
|
with patch("src.code_ai.desktop.config.DESKTOP_CONFIG_FILE", f):
|
|
109
112
|
be = FakeBackend(found=True, proxy=True, running=True)
|
|
110
113
|
b = LauncherBridge(be, APP_REGISTRY)
|
|
111
114
|
result = b.launch_app("claude")
|
|
112
|
-
assert result["ok"] is
|
|
113
|
-
assert
|
|
114
|
-
assert be.
|
|
115
|
+
assert result["ok"] is True
|
|
116
|
+
assert be.calls == ["stop", "launch"]
|
|
117
|
+
assert be.stopped is not None
|
|
118
|
+
assert be.launched is not None
|
|
115
119
|
|
|
116
120
|
|
|
117
121
|
def test_stop_app_not_found_returns_error():
|
|
@@ -218,6 +218,33 @@ def test_create_shortcut_quotes_paths_for_powershell(monkeypatch, tmp_path):
|
|
|
218
218
|
assert captured["creationflags"] == windows._NO_WINDOW
|
|
219
219
|
|
|
220
220
|
|
|
221
|
+
def test_create_shortcut_uses_desktop_python_env(monkeypatch, tmp_path):
|
|
222
|
+
home = tmp_path / "home"
|
|
223
|
+
home.mkdir()
|
|
224
|
+
pyhome = tmp_path / "py312"
|
|
225
|
+
pyhome.mkdir()
|
|
226
|
+
python = pyhome / "python.exe"
|
|
227
|
+
pythonw = pyhome / "pythonw.exe"
|
|
228
|
+
python.write_text("x")
|
|
229
|
+
pythonw.write_text("x")
|
|
230
|
+
monkeypatch.setenv("CODE_AI_DESKTOP_PYTHON", str(python))
|
|
231
|
+
monkeypatch.setattr(windows.os.path, "expanduser",
|
|
232
|
+
lambda p: str(home) if p == "~" else p)
|
|
233
|
+
monkeypatch.delenv("APPDATA", raising=False)
|
|
234
|
+
monkeypatch.setattr(windows.WindowsBackend, "_icon_path", lambda self: r"C:\nope\icon.ico")
|
|
235
|
+
|
|
236
|
+
captured = {}
|
|
237
|
+
monkeypatch.setattr(windows.subprocess, "run",
|
|
238
|
+
lambda argv, check=False, creationflags=0: captured.update(
|
|
239
|
+
argv=argv, creationflags=creationflags))
|
|
240
|
+
|
|
241
|
+
be = windows.WindowsBackend()
|
|
242
|
+
be.create_shortcut()
|
|
243
|
+
|
|
244
|
+
script = captured["argv"][-1]
|
|
245
|
+
assert f"$s.TargetPath = '{pythonw}'" in script
|
|
246
|
+
|
|
247
|
+
|
|
221
248
|
def _make_fake_msix(tmp_path, app_id="ChatGPT", exe_rel="app\\ChatGPT.exe"):
|
|
222
249
|
"""Create a fake MSIX InstallLocation: an AppxManifest.xml + on-disk exe.
|
|
223
250
|
|
|
@@ -326,6 +353,25 @@ def test_launch_brokered_prefers_real_exe_so_env_propagates(monkeypatch, tmp_pat
|
|
|
326
353
|
assert calls["creationflags"] == windows._NO_WINDOW
|
|
327
354
|
|
|
328
355
|
|
|
356
|
+
def test_launch_codex_uses_broker_to_preserve_msix_context(monkeypatch, tmp_path):
|
|
357
|
+
_make_fake_msix(tmp_path, app_id="App", exe_rel="app\\Codex.exe")
|
|
358
|
+
calls = {}
|
|
359
|
+
monkeypatch.setattr(windows.subprocess, "Popen",
|
|
360
|
+
lambda argv, env=None, creationflags=0: calls.update(
|
|
361
|
+
argv=argv, env=env, creationflags=creationflags))
|
|
362
|
+
be = windows.WindowsBackend()
|
|
363
|
+
st = AppStatus("codex", found=True, direct=False,
|
|
364
|
+
launch_target="OpenAI.Codex_2p2nqsd0c76g0!App",
|
|
365
|
+
match_root=str(tmp_path))
|
|
366
|
+
be.launch(st, {"INJECTED": "1"})
|
|
367
|
+
assert calls["argv"] == [
|
|
368
|
+
"explorer.exe",
|
|
369
|
+
"shell:AppsFolder\\OpenAI.Codex_2p2nqsd0c76g0!App",
|
|
370
|
+
]
|
|
371
|
+
assert calls["env"] == {"INJECTED": "1"}
|
|
372
|
+
assert calls["creationflags"] == windows._NO_WINDOW
|
|
373
|
+
|
|
374
|
+
|
|
329
375
|
def test_launch_brokered_falls_back_to_explorer_on_popen_oserror(monkeypatch, tmp_path):
|
|
330
376
|
# The exe resolves, but launching it raises OSError (e.g. a TOCTOU delete
|
|
331
377
|
# or permission denial between the isfile check and Popen). launch must
|
|
@@ -175,33 +175,6 @@ class TestBackwardCompatibility:
|
|
|
175
175
|
list_profiles(config)
|
|
176
176
|
assert mock_print.called
|
|
177
177
|
|
|
178
|
-
gemini_config = {
|
|
179
|
-
"profiles": {
|
|
180
|
-
"legacy-gemini": {
|
|
181
|
-
"type": "gemini",
|
|
182
|
-
"base_url": "https://generativelanguage.googleapis.com",
|
|
183
|
-
"api_key": "AIza-test-key",
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
save_config(gemini_config)
|
|
188
|
-
|
|
189
|
-
config = load_config()
|
|
190
|
-
profile_dict = config["profiles"]["legacy-gemini"]
|
|
191
|
-
assert profile_dict["name"] == "legacy-gemini"
|
|
192
|
-
assert profile_dict["mode"] == "api"
|
|
193
|
-
|
|
194
|
-
profile = profile_from_dict(profile_dict)
|
|
195
|
-
assert isinstance(profile, ApiProfile)
|
|
196
|
-
assert profile.name == "legacy-gemini"
|
|
197
|
-
assert profile.type == "gemini"
|
|
198
|
-
assert profile.api_key == "AIza-test-key"
|
|
199
|
-
|
|
200
|
-
env = prepare_environment(profile)
|
|
201
|
-
assert env["GOOGLE_GEMINI_BASE_URL"] == "https://generativelanguage.googleapis.com"
|
|
202
|
-
assert env["GEMINI_API_KEY"] == "AIza-test-key"
|
|
203
|
-
|
|
204
|
-
|
|
205
178
|
class TestCodexProfiles:
|
|
206
179
|
"""Test codex profile workflows"""
|
|
207
180
|
|
|
@@ -305,3 +278,62 @@ class TestCodexProfiles:
|
|
|
305
278
|
env = prepare_environment(profile)
|
|
306
279
|
assert "OPENAI_API_KEY" not in env
|
|
307
280
|
assert env["CODEX_HOME"] == os.path.expanduser("~/.codex-profiles/account-a")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class TestGrokProfiles:
|
|
284
|
+
"""Test official Grok CLI profile workflows."""
|
|
285
|
+
|
|
286
|
+
def test_grok_api_profile(self):
|
|
287
|
+
with temp_config_file() as config_file:
|
|
288
|
+
with patch("src.code_ai.config.CONFIG_FILE", config_file):
|
|
289
|
+
save_config({"profiles": {}})
|
|
290
|
+
inputs = [
|
|
291
|
+
"my-grok-api",
|
|
292
|
+
"grok",
|
|
293
|
+
"api",
|
|
294
|
+
"", # use the official xAI endpoint
|
|
295
|
+
"xai-test-key",
|
|
296
|
+
"", # proxy
|
|
297
|
+
"", # default_args
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
with patch("builtins.input", side_effect=inputs):
|
|
301
|
+
config = add_profile(load_config())
|
|
302
|
+
save_config(config)
|
|
303
|
+
|
|
304
|
+
profile_dict = load_config()["profiles"]["my-grok-api"]
|
|
305
|
+
assert profile_dict["type"] == "grok"
|
|
306
|
+
assert profile_dict["mode"] == "api"
|
|
307
|
+
assert "base_url" not in profile_dict
|
|
308
|
+
assert profile_dict["api_key"] == "xai-test-key"
|
|
309
|
+
|
|
310
|
+
env = prepare_environment(profile_from_dict(profile_dict))
|
|
311
|
+
assert env["XAI_API_KEY"] == "xai-test-key"
|
|
312
|
+
assert "GROK_CLI_CHAT_PROXY_BASE_URL" not in env
|
|
313
|
+
|
|
314
|
+
def test_grok_login_profile(self):
|
|
315
|
+
with temp_config_file() as config_file:
|
|
316
|
+
with patch("src.code_ai.config.CONFIG_FILE", config_file):
|
|
317
|
+
save_config({"profiles": {}})
|
|
318
|
+
inputs = [
|
|
319
|
+
"my-grok-login",
|
|
320
|
+
"grok",
|
|
321
|
+
"login",
|
|
322
|
+
"~/.grok-profiles/account-a",
|
|
323
|
+
"", # proxy
|
|
324
|
+
"", # default_args
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
with patch("builtins.input", side_effect=inputs):
|
|
328
|
+
config = add_profile(load_config())
|
|
329
|
+
save_config(config)
|
|
330
|
+
|
|
331
|
+
profile_dict = load_config()["profiles"]["my-grok-login"]
|
|
332
|
+
assert profile_dict["type"] == "grok"
|
|
333
|
+
assert profile_dict["mode"] == "login"
|
|
334
|
+
|
|
335
|
+
env = prepare_environment(profile_from_dict(profile_dict))
|
|
336
|
+
assert env["GROK_HOME"] == os.path.expanduser(
|
|
337
|
+
"~/.grok-profiles/account-a"
|
|
338
|
+
)
|
|
339
|
+
assert "XAI_API_KEY" not in env
|
|
@@ -3,6 +3,7 @@ import pytest
|
|
|
3
3
|
from unittest.mock import patch, MagicMock
|
|
4
4
|
from src.code_ai.models import ApiProfile, LoginProfile
|
|
5
5
|
from src.code_ai.launcher import (
|
|
6
|
+
ENV_MAP,
|
|
6
7
|
prepare_environment,
|
|
7
8
|
resolve_default_args,
|
|
8
9
|
merge_launch_args,
|
|
@@ -31,6 +32,9 @@ def test_prepare_env_api_mode_clears_stale_managed_vars():
|
|
|
31
32
|
"ANTHROPIC_AUTH_TOKEN": "old-token",
|
|
32
33
|
"CLAUDE_CONFIG_DIR": "/tmp/old-claude",
|
|
33
34
|
"OPENAI_API_KEY": "old-openai-key",
|
|
35
|
+
"XAI_API_KEY": "old-xai-key",
|
|
36
|
+
"GROK_CLI_CHAT_PROXY_BASE_URL": "https://old-grok.url",
|
|
37
|
+
"GROK_HOME": "/tmp/old-grok",
|
|
34
38
|
"HTTP_PROXY": "http://127.0.0.1:9999",
|
|
35
39
|
"HTTPS_PROXY": "http://127.0.0.1:9999",
|
|
36
40
|
}):
|
|
@@ -47,6 +51,9 @@ def test_prepare_env_api_mode_clears_stale_managed_vars():
|
|
|
47
51
|
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-ant-test"
|
|
48
52
|
assert "CLAUDE_CONFIG_DIR" not in env
|
|
49
53
|
assert "OPENAI_API_KEY" not in env
|
|
54
|
+
assert "XAI_API_KEY" not in env
|
|
55
|
+
assert "GROK_CLI_CHAT_PROXY_BASE_URL" not in env
|
|
56
|
+
assert "GROK_HOME" not in env
|
|
50
57
|
assert "HTTP_PROXY" not in env
|
|
51
58
|
assert "HTTPS_PROXY" not in env
|
|
52
59
|
|
|
@@ -73,9 +80,9 @@ def test_prepare_env_with_proxy():
|
|
|
73
80
|
"""Test environment preparation with proxy"""
|
|
74
81
|
profile = ApiProfile(
|
|
75
82
|
name="test-proxy",
|
|
76
|
-
type="
|
|
77
|
-
base_url="https://
|
|
78
|
-
api_key="
|
|
83
|
+
type="grok",
|
|
84
|
+
base_url="https://api.x.ai/v1",
|
|
85
|
+
api_key="xai-test",
|
|
79
86
|
proxy="http://127.0.0.1:7890"
|
|
80
87
|
)
|
|
81
88
|
|
|
@@ -85,6 +92,57 @@ def test_prepare_env_with_proxy():
|
|
|
85
92
|
assert env["HTTPS_PROXY"] == "http://127.0.0.1:7890"
|
|
86
93
|
|
|
87
94
|
|
|
95
|
+
def test_gemini_is_not_a_supported_launcher():
|
|
96
|
+
assert "gemini" not in ENV_MAP
|
|
97
|
+
assert ENV_MAP["grok"]["cmd"] == "grok"
|
|
98
|
+
|
|
99
|
+
with pytest.raises(ValueError, match="Unknown profile type 'gemini'"):
|
|
100
|
+
prepare_environment(
|
|
101
|
+
ApiProfile(name="legacy-gemini", type="gemini", api_key="old-key")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_prepare_env_grok_api_mode():
|
|
106
|
+
profile = ApiProfile(
|
|
107
|
+
name="test-grok-api",
|
|
108
|
+
type="grok",
|
|
109
|
+
base_url="https://grok-proxy.example/v1",
|
|
110
|
+
api_key="xai-test",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
env = prepare_environment(profile)
|
|
114
|
+
|
|
115
|
+
assert env["XAI_API_KEY"] == "xai-test"
|
|
116
|
+
assert env["GROK_CLI_CHAT_PROXY_BASE_URL"] == "https://grok-proxy.example/v1"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_prepare_env_grok_api_mode_allows_default_endpoint():
|
|
120
|
+
profile = ApiProfile(
|
|
121
|
+
name="test-grok-api",
|
|
122
|
+
type="grok",
|
|
123
|
+
api_key="xai-test",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
env = prepare_environment(profile)
|
|
127
|
+
|
|
128
|
+
assert env["XAI_API_KEY"] == "xai-test"
|
|
129
|
+
assert "GROK_CLI_CHAT_PROXY_BASE_URL" not in env
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_prepare_env_grok_login_mode():
|
|
133
|
+
profile = LoginProfile(
|
|
134
|
+
name="test-grok-login",
|
|
135
|
+
type="grok",
|
|
136
|
+
credentials_path="~/.grok-profiles/account-a",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
env = prepare_environment(profile)
|
|
140
|
+
|
|
141
|
+
assert "XAI_API_KEY" not in env
|
|
142
|
+
assert "GROK_CLI_CHAT_PROXY_BASE_URL" not in env
|
|
143
|
+
assert env["GROK_HOME"] == os.path.expanduser("~/.grok-profiles/account-a")
|
|
144
|
+
|
|
145
|
+
|
|
88
146
|
def test_prepare_env_login_clears_api_vars():
|
|
89
147
|
"""Test that login mode clears API environment variables"""
|
|
90
148
|
# Set up environment with existing API variables
|
|
@@ -33,9 +33,9 @@ def test_profile_with_proxy():
|
|
|
33
33
|
"""Test profile with proxy"""
|
|
34
34
|
profile = ApiProfile(
|
|
35
35
|
name="test-proxy",
|
|
36
|
-
type="
|
|
37
|
-
base_url="https://
|
|
38
|
-
api_key="
|
|
36
|
+
type="grok",
|
|
37
|
+
base_url="https://api.x.ai/v1",
|
|
38
|
+
api_key="xai-test",
|
|
39
39
|
proxy="http://127.0.0.1:7890"
|
|
40
40
|
)
|
|
41
41
|
assert profile.proxy == "http://127.0.0.1:7890"
|
|
@@ -69,6 +69,19 @@ def test_profile_from_dict_login_mode():
|
|
|
69
69
|
assert profile.credentials_path == "~/.claude-profiles/account-a"
|
|
70
70
|
assert profile.proxy == "http://127.0.0.1:7890"
|
|
71
71
|
|
|
72
|
+
|
|
73
|
+
def test_profile_from_dict_grok_login_mode():
|
|
74
|
+
"""Grok login profiles isolate browser credentials with GROK_HOME."""
|
|
75
|
+
data = {
|
|
76
|
+
"name": "my-grok-login",
|
|
77
|
+
"type": "grok",
|
|
78
|
+
"mode": "login",
|
|
79
|
+
"credentials_path": "~/.grok-profiles/account-a",
|
|
80
|
+
}
|
|
81
|
+
profile = profile_from_dict(data)
|
|
82
|
+
assert isinstance(profile, LoginProfile)
|
|
83
|
+
assert profile.credentials_path == "~/.grok-profiles/account-a"
|
|
84
|
+
|
|
72
85
|
def test_profile_from_dict_defaults_to_api():
|
|
73
86
|
"""Test that missing mode defaults to api"""
|
|
74
87
|
data = {
|
|
@@ -112,7 +125,7 @@ def test_profile_to_dict_login():
|
|
|
112
125
|
|
|
113
126
|
def test_valid_types():
|
|
114
127
|
"""Test VALID_TYPES constant"""
|
|
115
|
-
assert VALID_TYPES == ("claude", "
|
|
128
|
+
assert VALID_TYPES == ("claude", "grok", "codex")
|
|
116
129
|
|
|
117
130
|
|
|
118
131
|
def test_profile_from_dict_default_args_list():
|
|
@@ -146,9 +159,8 @@ def test_profile_from_dict_default_args_missing_is_none():
|
|
|
146
159
|
"""Profiles without default_args default to None (backward-compat)."""
|
|
147
160
|
data = {
|
|
148
161
|
"name": "p",
|
|
149
|
-
"type": "
|
|
150
|
-
"
|
|
151
|
-
"api_key": "AIza-x",
|
|
162
|
+
"type": "grok",
|
|
163
|
+
"api_key": "xai-x",
|
|
152
164
|
}
|
|
153
165
|
profile = profile_from_dict(data)
|
|
154
166
|
assert profile.default_args is None
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.16"
|
|
File without changes
|
|
File without changes
|
{ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/requires.txt
RENAMED
|
File without changes
|
{ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|