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.
Files changed (49) hide show
  1. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/PKG-INFO +1 -1
  2. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/README.md +10 -5
  3. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/pyproject.toml +1 -1
  4. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/PKG-INFO +1 -1
  5. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/SOURCES.txt +1 -0
  6. ai_code_switcher-0.1.18/src/code_ai/__init__.py +1 -0
  7. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/cli.py +4 -4
  8. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/app.py +0 -16
  9. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/bridge.py +4 -2
  10. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/windows.py +26 -1
  11. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/app.js +22 -16
  12. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/style.css +9 -1
  13. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/launcher.py +8 -4
  14. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/models.py +6 -6
  15. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/profiles.py +18 -21
  16. ai_code_switcher-0.1.18/tests/test_cli_upgrade.py +6 -0
  17. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_app_import.py +42 -0
  18. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_bridge.py +8 -4
  19. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_windows.py +46 -0
  20. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_integration.py +59 -27
  21. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_launcher.py +61 -3
  22. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_models.py +19 -7
  23. ai_code_switcher-0.1.16/src/code_ai/__init__.py +0 -1
  24. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/setup.cfg +0 -0
  25. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/dependency_links.txt +0 -0
  26. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/entry_points.txt +0 -0
  27. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/requires.txt +0 -0
  28. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/ai_code_switcher.egg-info/top_level.txt +0 -0
  29. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/buddy.py +0 -0
  30. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/config.py +0 -0
  31. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/__init__.py +0 -0
  32. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/apps.py +0 -0
  33. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/config.py +0 -0
  34. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/env.py +0 -0
  35. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/__init__.py +0 -0
  36. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/base.py +0 -0
  37. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/platforms/macos.py +0 -0
  38. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/icon.icns +0 -0
  39. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/icon.ico +0 -0
  40. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/icon.png +0 -0
  41. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/src/code_ai/desktop/ui/index.html +0 -0
  42. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_apps.py +0 -0
  43. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_backend.py +0 -0
  44. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_base.py +0 -0
  45. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_cli.py +0 -0
  46. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_config.py +0 -0
  47. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_env.py +0 -0
  48. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_icons.py +0 -0
  49. {ai_code_switcher-0.1.16 → ai_code_switcher-0.1.18}/tests/test_desktop_macos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-code-switcher
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -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 Gemini
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-gemini
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
- - `@google/gemini-cli`
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 has one button that toggles
147
- between **启动** (launch) and **中止** (stop), with a live running indicator.
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-code-switcher"
7
- version = "0.1.16"
7
+ version = "0.1.18"
8
8
  description = "Switch AI coding tool profiles and launch the correct CLI"
9
9
  license = {text = "MIT"}
10
10
  requires-python = ">=3.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-code-switcher
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -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 gemini CLI via npm"""
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-gemini Launch Gemini CLI with fox profile
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
- "@google/gemini-cli",
290
+ "@xai-official/grok",
291
291
  ]
292
292
 
293
293
 
294
294
  def upgrade():
295
- typer.echo("Upgrading claude, codex, gemini CLI...")
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
- if self._backend.is_running(status):
67
- return {"ok": False, "error": "应用已在运行,请先中止后再启动以应用环境变量"}
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))
@@ -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
- def create_shortcut(self) -> str:
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
- `<button class="action" id="btn-${esc(a.id)}"></button>`;
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 b = document.getElementById(`btn-${id}`);
45
- if (!s || !b) return;
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
- b.textContent = "配置路径"; b.dataset.mode = "config";
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
- b.textContent = "中止"; b.dataset.mode = "stop";
61
+ launch.disabled = false;
62
+ stop.disabled = false;
63
+ config.hidden = true;
52
64
  } else {
53
65
  s.textContent = "○ 已停止"; s.className = "status off";
54
- b.textContent = "启动"; b.dataset.mode = "launch";
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
- "gemini": {
16
- "env": {"GOOGLE_GEMINI_BASE_URL": "base_url", "GEMINI_API_KEY": "api_key"},
17
- "cmd": "gemini",
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
- # Claude uses CLAUDE_CONFIG_DIR, Codex uses CODEX_HOME
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", "gemini", "codex")
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" | "gemini" | "codex"
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 via base_url + token/api_key"""
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 # Gemini/Codex only
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 OAuth credentials directory (Claude/Codex)"""
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 (ptype == "claude" or ptype == "codex") and mode == "login":
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
- # Handle mode for Claude and Codex
59
- if ptype in ("claude", "codex"):
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
- base_url = input("Base URL: ").strip()
73
- if not base_url:
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
- else: # codex
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
- else: # gemini - API mode only
85
- base_url = input("Base URL: ").strip()
86
- if not base_url:
87
- print("Error: base URL cannot be empty.")
88
- sys.exit(1)
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 for Claude and Codex types
123
- if profile.type in ("claude", "codex"):
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:
@@ -0,0 +1,6 @@
1
+ from src.code_ai.cli import UPGRADE_PACKAGES
2
+
3
+
4
+ def test_upgrade_packages_use_official_grok_cli_instead_of_gemini():
5
+ assert "@xai-official/grok" in UPGRADE_PACKAGES
6
+ assert "@google/gemini-cli" not in UPGRADE_PACKAGES
@@ -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 test_launch_blocked_when_app_already_running():
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 False
113
- assert "已在运行" in result["error"]
114
- assert be.launched is None
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="gemini",
77
- base_url="https://generativelanguage.googleapis.com",
78
- api_key="AIza-test",
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="gemini",
37
- base_url="https://generativelanguage.googleapis.com",
38
- api_key="AIza-test",
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", "gemini", "codex")
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": "gemini",
150
- "base_url": "https://generativelanguage.googleapis.com",
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"