ai-code-switcher 0.1.22__tar.gz → 0.1.23__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 (52) hide show
  1. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/PKG-INFO +10 -2
  2. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/README.md +9 -1
  3. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/pyproject.toml +1 -1
  4. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/ai_code_switcher.egg-info/PKG-INFO +10 -2
  5. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/ai_code_switcher.egg-info/SOURCES.txt +3 -0
  6. ai_code_switcher-0.1.23/src/code_ai/__init__.py +1 -0
  7. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/cli.py +25 -3
  8. ai_code_switcher-0.1.23/src/code_ai/grok.py +68 -0
  9. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/launcher.py +7 -1
  10. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_cli_upgrade.py +66 -5
  11. ai_code_switcher-0.1.23/tests/test_grok.py +86 -0
  12. ai_code_switcher-0.1.23/tests/test_grok_unix_execution.py +40 -0
  13. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_launcher.py +29 -0
  14. ai_code_switcher-0.1.22/src/code_ai/__init__.py +0 -1
  15. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/setup.cfg +0 -0
  16. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/ai_code_switcher.egg-info/dependency_links.txt +0 -0
  17. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/ai_code_switcher.egg-info/entry_points.txt +0 -0
  18. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/ai_code_switcher.egg-info/requires.txt +0 -0
  19. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/ai_code_switcher.egg-info/top_level.txt +0 -0
  20. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/buddy.py +0 -0
  21. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/config.py +0 -0
  22. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/__init__.py +0 -0
  23. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/app.py +0 -0
  24. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/apps.py +0 -0
  25. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/bridge.py +0 -0
  26. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/config.py +0 -0
  27. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/env.py +0 -0
  28. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/platforms/__init__.py +0 -0
  29. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/platforms/base.py +0 -0
  30. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/platforms/macos.py +0 -0
  31. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/platforms/windows.py +0 -0
  32. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/ui/app.js +0 -0
  33. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/ui/icon.icns +0 -0
  34. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/ui/icon.ico +0 -0
  35. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/ui/icon.png +0 -0
  36. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/ui/index.html +0 -0
  37. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/desktop/ui/style.css +0 -0
  38. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/models.py +0 -0
  39. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/src/code_ai/profiles.py +0 -0
  40. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_app_import.py +0 -0
  41. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_apps.py +0 -0
  42. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_backend.py +0 -0
  43. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_base.py +0 -0
  44. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_bridge.py +0 -0
  45. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_cli.py +0 -0
  46. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_config.py +0 -0
  47. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_env.py +0 -0
  48. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_icons.py +0 -0
  49. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_macos.py +0 -0
  50. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_desktop_windows.py +0 -0
  51. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_integration.py +0 -0
  52. {ai_code_switcher-0.1.22 → ai_code_switcher-0.1.23}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-code-switcher
3
- Version: 0.1.22
3
+ Version: 0.1.23
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -97,7 +97,15 @@ The supported AI CLIs are:
97
97
  `code-ai upgrade` also refreshes the Grok binary in each login profile's
98
98
  credentials directory. Grok stores a separate binary under `GROK_HOME/bin`, so
99
99
  updating only the global npm package can leave a login profile running an old
100
- version. Restart running Grok sessions after upgrading.
100
+ version. The upgrade enables npm install scripts and verifies the actual binary
101
+ version in the default home and every login home; an unchanged or missing binary
102
+ causes the command to fail. Restart running Grok sessions after upgrading.
103
+
104
+ For Grok, `code-ai run` prefers the official package in the active npm global
105
+ prefix, so an older native installation earlier on `PATH` cannot shadow the
106
+ package updated by `code-ai upgrade`. Native installations remain supported
107
+ when the npm package is absent. Login credentials still use the profile's own
108
+ `GROK_HOME`.
101
109
 
102
110
  ## Profiles and configuration
103
111
 
@@ -84,7 +84,15 @@ The supported AI CLIs are:
84
84
  `code-ai upgrade` also refreshes the Grok binary in each login profile's
85
85
  credentials directory. Grok stores a separate binary under `GROK_HOME/bin`, so
86
86
  updating only the global npm package can leave a login profile running an old
87
- version. Restart running Grok sessions after upgrading.
87
+ version. The upgrade enables npm install scripts and verifies the actual binary
88
+ version in the default home and every login home; an unchanged or missing binary
89
+ causes the command to fail. Restart running Grok sessions after upgrading.
90
+
91
+ For Grok, `code-ai run` prefers the official package in the active npm global
92
+ prefix, so an older native installation earlier on `PATH` cannot shadow the
93
+ package updated by `code-ai upgrade`. Native installations remain supported
94
+ when the npm package is absent. Login credentials still use the profile's own
95
+ `GROK_HOME`.
88
96
 
89
97
  ## Profiles and configuration
90
98
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-code-switcher"
7
- version = "0.1.22"
7
+ version = "0.1.23"
8
8
  description = "Switch AI coding tool profiles and launch the correct CLI"
9
9
  readme = {file = "README.md", content-type = "text/markdown"}
10
10
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-code-switcher
3
- Version: 0.1.22
3
+ Version: 0.1.23
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -97,7 +97,15 @@ The supported AI CLIs are:
97
97
  `code-ai upgrade` also refreshes the Grok binary in each login profile's
98
98
  credentials directory. Grok stores a separate binary under `GROK_HOME/bin`, so
99
99
  updating only the global npm package can leave a login profile running an old
100
- version. Restart running Grok sessions after upgrading.
100
+ version. The upgrade enables npm install scripts and verifies the actual binary
101
+ version in the default home and every login home; an unchanged or missing binary
102
+ causes the command to fail. Restart running Grok sessions after upgrading.
103
+
104
+ For Grok, `code-ai run` prefers the official package in the active npm global
105
+ prefix, so an older native installation earlier on `PATH` cannot shadow the
106
+ package updated by `code-ai upgrade`. Native installations remain supported
107
+ when the npm package is absent. Login credentials still use the profile's own
108
+ `GROK_HOME`.
101
109
 
102
110
  ## Profiles and configuration
103
111
 
@@ -10,6 +10,7 @@ src/code_ai/__init__.py
10
10
  src/code_ai/buddy.py
11
11
  src/code_ai/cli.py
12
12
  src/code_ai/config.py
13
+ src/code_ai/grok.py
13
14
  src/code_ai/launcher.py
14
15
  src/code_ai/models.py
15
16
  src/code_ai/profiles.py
@@ -41,6 +42,8 @@ tests/test_desktop_env.py
41
42
  tests/test_desktop_icons.py
42
43
  tests/test_desktop_macos.py
43
44
  tests/test_desktop_windows.py
45
+ tests/test_grok.py
46
+ tests/test_grok_unix_execution.py
44
47
  tests/test_integration.py
45
48
  tests/test_launcher.py
46
49
  tests/test_models.py
@@ -0,0 +1 @@
1
+ __version__ = "0.1.23"
@@ -8,6 +8,7 @@ import typer
8
8
  from .config import load_config, save_config
9
9
  from .profiles import list_profiles, add_profile, remove_profile, show_profile
10
10
  from .launcher import launch
11
+ from .grok import find_npm_installation, home_binary, check_version as check_grok_version
11
12
  from .buddy import (
12
13
  RARITIES, SPECIES, RARITY_COLORS,
13
14
  roll_by_name, bruteforce_user_id,
@@ -299,18 +300,35 @@ def _run_npm(args, env):
299
300
  return subprocess.run(["npm"] + args, env=env)
300
301
 
301
302
 
303
+ def _verify_grok(command, env, version):
304
+ try:
305
+ check_grok_version(command, env, version)
306
+ except RuntimeError as exc:
307
+ typer.echo(str(exc), err=True)
308
+ sys.exit(1)
309
+
310
+
302
311
  def upgrade():
303
312
  typer.echo("Upgrading claude, codex, grok CLI...")
304
313
 
305
314
  env = os.environ.copy()
306
315
  env.pop("GROK_HOME", None)
307
- result = _run_npm(["install", "-g"] + UPGRADE_PACKAGES, env)
316
+ result = _run_npm(["install", "-g", "--ignore-scripts=false"] + UPGRADE_PACKAGES, env)
308
317
  if result.returncode:
309
318
  sys.exit(result.returncode)
310
319
 
320
+ installation = find_npm_installation()
321
+ if not installation:
322
+ typer.echo("Cannot find the updated @xai-official/grok installation in the active npm prefix.", err=True)
323
+ sys.exit(1)
324
+ _verify_grok(installation.command, env, installation.version)
325
+ default_home = os.path.expanduser("~/.grok")
326
+ _verify_grok(home_binary(default_home), env, installation.version)
327
+ typer.echo(f"Grok CLI {installation.version} verified at {installation.command}")
328
+
311
329
  # Grok's npm launcher prefers GROK_HOME/bin even when that binary is old.
312
330
  # Re-run its installer in each login home after updating the global package.
313
- refreshed = {os.path.normcase(os.path.realpath(os.path.expanduser("~/.grok")))}
331
+ refreshed = {os.path.normcase(os.path.realpath(default_home))}
314
332
  for name, profile in load_config().get("profiles", {}).items():
315
333
  if (profile.get("type") != "grok" or profile.get("mode") != "login"
316
334
  or not profile.get("credentials_path")):
@@ -321,10 +339,14 @@ def upgrade():
321
339
  continue
322
340
  typer.echo(f"Refreshing Grok CLI for login profile '{name}'...")
323
341
  profile_env = dict(env, GROK_HOME=home)
324
- result = _run_npm(["rebuild", "-g", "@xai-official/grok"], profile_env)
342
+ result = _run_npm([
343
+ "rebuild", "-g", "--ignore-scripts=false", "--foreground-scripts", "@xai-official/grok",
344
+ ], profile_env)
325
345
  if result.returncode:
326
346
  typer.echo(f"Failed to refresh Grok CLI for profile '{name}'.", err=True)
327
347
  sys.exit(result.returncode)
348
+ _verify_grok(home_binary(home), profile_env, installation.version)
349
+ typer.echo(f"Grok CLI {installation.version} verified for profile '{name}'.")
328
350
  refreshed.add(normalized_home)
329
351
  sys.exit(0)
330
352
 
@@ -0,0 +1,68 @@
1
+ """Locate and verify the Grok installation managed by npm."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class GrokInstallation:
15
+ command: str
16
+ version: str
17
+
18
+
19
+ def find_npm_installation() -> Optional[GrokInstallation]:
20
+ npm = shutil.which("npm.cmd" if sys.platform == "win32" else "npm")
21
+ if not npm:
22
+ return None
23
+ try:
24
+ result = subprocess.run(
25
+ [npm, "prefix", "-g"], capture_output=True, text=True, timeout=10,
26
+ )
27
+ prefix = result.stdout.strip()
28
+ if result.returncode or not prefix:
29
+ return None
30
+ if sys.platform == "win32":
31
+ command = os.path.join(prefix, "grok.cmd")
32
+ modules = os.path.join(prefix, "node_modules")
33
+ else:
34
+ command = os.path.join(prefix, "bin", "grok")
35
+ modules = os.path.join(prefix, "lib", "node_modules")
36
+ if not os.path.isfile(command):
37
+ return None
38
+ package_file = os.path.join(modules, "@xai-official", "grok", "package.json")
39
+ with open(package_file, encoding="utf-8") as package:
40
+ metadata = json.load(package)
41
+ if (not isinstance(metadata, dict)
42
+ or metadata.get("name") != "@xai-official/grok"
43
+ or not isinstance(metadata.get("version"), str)):
44
+ return None
45
+ return GrokInstallation(command, metadata["version"])
46
+ except (OSError, subprocess.SubprocessError, ValueError):
47
+ return None
48
+
49
+
50
+ def home_binary(home: str) -> str:
51
+ name = "grok.exe" if sys.platform == "win32" else "grok"
52
+ return os.path.join(home, "bin", name)
53
+
54
+
55
+ def check_version(command: str, env: dict, expected: str) -> None:
56
+ try:
57
+ result = subprocess.run(
58
+ [command, "--version"], env=env,
59
+ capture_output=True, text=True, timeout=10,
60
+ )
61
+ except (OSError, subprocess.SubprocessError) as exc:
62
+ raise RuntimeError(f"Cannot verify Grok CLI at '{command}': {exc}") from exc
63
+ match = re.search(r"\bgrok\s+(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)", result.stdout)
64
+ if result.returncode or not match:
65
+ raise RuntimeError(f"Cannot verify Grok CLI version at '{command}'.")
66
+ actual = match.group(1)
67
+ if actual != expected:
68
+ raise RuntimeError(f"Grok CLI at '{command}' is {actual}; expected {expected}.")
@@ -6,6 +6,7 @@ import subprocess
6
6
  from typing import List
7
7
 
8
8
  from .models import profile_from_dict, ApiProfile, LoginProfile
9
+ from .grok import find_npm_installation
9
10
 
10
11
  ENV_MAP = {
11
12
  "claude": {
@@ -166,6 +167,11 @@ def launch(profile_dict, extra_args, use_default_args=True):
166
167
  else:
167
168
  cmd_path = shutil.which(cmd)
168
169
 
170
+ if ptype == "grok":
171
+ installation = find_npm_installation()
172
+ if installation:
173
+ cmd_path = installation.command
174
+
169
175
  if not cmd_path:
170
176
  print(f"Error: '{cmd}' not found in PATH. Install it first.")
171
177
  sys.exit(1)
@@ -194,4 +200,4 @@ def launch(profile_dict, extra_args, use_default_args=True):
194
200
  else:
195
201
  # On Unix, update os.environ and use execvp
196
202
  os.environ.update(env)
197
- os.execvp(cmd, [cmd] + final_args)
203
+ os.execvp(cmd_path, full_cmd)
@@ -7,13 +7,23 @@ from src.code_ai import cli
7
7
  from src.code_ai.cli import UPGRADE_PACKAGES
8
8
 
9
9
 
10
+ @pytest.fixture(autouse=True)
11
+ def installed_grok(monkeypatch, tmp_path):
12
+ installation = SimpleNamespace(command=str(tmp_path / "npm-grok"), version="1.0.46")
13
+ monkeypatch.setattr(cli, "find_npm_installation", lambda: installation)
14
+ monkeypatch.setattr(cli, "load_config", lambda: {"profiles": {}})
15
+ checks = []
16
+ monkeypatch.setattr(cli, "check_grok_version", lambda command, env, version: checks.append((command, env, version)))
17
+ return installation, checks
18
+
19
+
10
20
  def test_upgrade_packages_use_official_grok_cli_instead_of_gemini():
11
21
  assert "@xai-official/grok" in UPGRADE_PACKAGES
12
22
  assert "@google/gemini-cli" not in UPGRADE_PACKAGES
13
23
 
14
24
 
15
25
  @pytest.mark.parametrize("platform", ["win32", "linux", "darwin"])
16
- def test_upgrade_refreshes_each_grok_login_home(monkeypatch, tmp_path, platform):
26
+ def test_upgrade_refreshes_each_grok_login_home(monkeypatch, tmp_path, platform, installed_grok):
17
27
  homes = [str(tmp_path / "account-a"), str(tmp_path / "account-b")]
18
28
  monkeypatch.setattr(cli.sys, "platform", platform)
19
29
  monkeypatch.setenv("GROK_HOME", homes[0])
@@ -41,12 +51,21 @@ def test_upgrade_refreshes_each_grok_login_home(monkeypatch, tmp_path, platform)
41
51
  assert "GROK_HOME" not in calls[0][1]["env"]
42
52
  assert [call[1]["env"]["GROK_HOME"] for call in calls[1:]] == homes
43
53
  if platform == "win32":
44
- assert calls[0][0] == "npm install -g " + " ".join(UPGRADE_PACKAGES)
54
+ assert calls[0][0] == "npm install -g --ignore-scripts=false " + " ".join(UPGRADE_PACKAGES)
45
55
  assert all(call[1]["shell"] for call in calls)
46
- assert [call[0] for call in calls[1:]] == ["npm rebuild -g @xai-official/grok"] * 2
56
+ assert [call[0] for call in calls[1:]] == ["npm rebuild -g --ignore-scripts=false --foreground-scripts @xai-official/grok"] * 2
47
57
  else:
48
- assert calls[0][0] == ["npm", "install", "-g"] + UPGRADE_PACKAGES
49
- assert [call[0] for call in calls[1:]] == [["npm", "rebuild", "-g", "@xai-official/grok"]] * 2
58
+ assert calls[0][0] == ["npm", "install", "-g", "--ignore-scripts=false"] + UPGRADE_PACKAGES
59
+ assert [call[0] for call in calls[1:]] == [["npm", "rebuild", "-g", "--ignore-scripts=false", "--foreground-scripts", "@xai-official/grok"]] * 2
60
+ installation, checks = installed_grok
61
+ binary_name = "grok.exe" if platform == "win32" else "grok"
62
+ assert [check[0] for check in checks] == [
63
+ installation.command,
64
+ os.path.join(os.path.expanduser("~/.grok"), "bin", binary_name),
65
+ os.path.join(homes[0], "bin", binary_name),
66
+ os.path.join(homes[1], "bin", binary_name),
67
+ ]
68
+ assert all(check[2] == "1.0.46" for check in checks)
50
69
  assert os.environ["GROK_HOME"] == homes[0]
51
70
 
52
71
 
@@ -121,3 +140,45 @@ def test_upgrade_reports_grok_home_refresh_failure(monkeypatch, tmp_path, capsys
121
140
 
122
141
  assert exc.value.code == 9
123
142
  assert "grok-a" in capsys.readouterr().err
143
+
144
+
145
+ def test_upgrade_rejects_successful_rebuild_that_leaves_profile_binary_old(monkeypatch, tmp_path, capsys):
146
+ home = str(tmp_path / "account-a")
147
+ monkeypatch.setattr(cli, "load_config", lambda: {"profiles": {
148
+ "grok-a": {"type": "grok", "mode": "login", "credentials_path": home},
149
+ }})
150
+ monkeypatch.setattr(cli.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0))
151
+
152
+ def check(command, env, version):
153
+ if env.get("GROK_HOME") == home:
154
+ raise RuntimeError("Grok CLI is still 0.2.118; expected 1.0.46")
155
+
156
+ monkeypatch.setattr(cli, "check_grok_version", check, raising=False)
157
+ with pytest.raises(SystemExit) as exc:
158
+ cli.upgrade()
159
+ assert exc.value.code == 1
160
+ assert "0.2.118" in capsys.readouterr().err
161
+
162
+
163
+ def test_upgrade_rejects_stale_default_binary(monkeypatch, capsys, installed_grok):
164
+ monkeypatch.setattr(cli.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0))
165
+ installation, _ = installed_grok
166
+
167
+ def check(command, env, version):
168
+ if command != installation.command:
169
+ raise RuntimeError("Grok CLI is still 0.2.118; expected 1.0.46")
170
+
171
+ monkeypatch.setattr(cli, "check_grok_version", check, raising=False)
172
+ with pytest.raises(SystemExit) as exc:
173
+ cli.upgrade()
174
+ assert exc.value.code == 1
175
+ assert "0.2.118" in capsys.readouterr().err
176
+
177
+
178
+ def test_upgrade_fails_when_npm_installation_cannot_be_found(monkeypatch, capsys):
179
+ monkeypatch.setattr(cli.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0))
180
+ monkeypatch.setattr(cli, "find_npm_installation", lambda: None, raising=False)
181
+ with pytest.raises(SystemExit) as exc:
182
+ cli.upgrade()
183
+ assert exc.value.code == 1
184
+ assert "npm" in capsys.readouterr().err
@@ -0,0 +1,86 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ from types import SimpleNamespace
5
+
6
+ import pytest
7
+
8
+ from src.code_ai import grok
9
+
10
+
11
+ @pytest.mark.parametrize("platform", ["win32", "darwin", "linux"])
12
+ def test_find_npm_installation_uses_active_prefix(monkeypatch, tmp_path, platform):
13
+ monkeypatch.setattr(grok.sys, "platform", platform)
14
+ command = tmp_path / ("grok.cmd" if platform == "win32" else "bin/grok")
15
+ package = tmp_path / ("node_modules" if platform == "win32" else "lib/node_modules") / "@xai-official/grok"
16
+ command.parent.mkdir(parents=True, exist_ok=True)
17
+ command.write_text("launcher", encoding="utf-8")
18
+ package.mkdir(parents=True)
19
+ (package / "package.json").write_text(json.dumps({"name": "@xai-official/grok", "version": "1.0.46"}))
20
+ monkeypatch.setattr(grok.shutil, "which", lambda name: "npm.cmd" if platform == "win32" else "npm")
21
+ calls = []
22
+
23
+ def run(args, **kwargs):
24
+ calls.append((args, kwargs))
25
+ return SimpleNamespace(returncode=0, stdout=str(tmp_path) + "\n", stderr="")
26
+
27
+ monkeypatch.setattr(grok.subprocess, "run", run)
28
+ installation = grok.find_npm_installation()
29
+
30
+ assert installation.command == str(command)
31
+ assert installation.version == "1.0.46"
32
+ assert calls[0][0][1:] == ["prefix", "-g"]
33
+ assert calls[0][1]["timeout"] == 10
34
+
35
+
36
+ def test_find_npm_installation_returns_none_when_npm_is_missing(monkeypatch):
37
+ monkeypatch.setattr(grok.shutil, "which", lambda name: None)
38
+ assert grok.find_npm_installation() is None
39
+
40
+
41
+ def test_find_npm_installation_returns_none_when_prefix_query_fails(monkeypatch):
42
+ monkeypatch.setattr(grok.shutil, "which", lambda name: "npm")
43
+ monkeypatch.setattr(grok.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout="", stderr="unavailable"))
44
+ assert grok.find_npm_installation() is None
45
+
46
+
47
+ def test_find_npm_installation_does_not_treat_an_empty_prefix_as_cwd(monkeypatch):
48
+ monkeypatch.setattr(grok.shutil, "which", lambda name: "npm")
49
+ monkeypatch.setattr(grok.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="\n", stderr=""))
50
+ assert grok.find_npm_installation() is None
51
+
52
+
53
+ def test_check_version_rejects_stale_binary_despite_successful_exit(monkeypatch):
54
+ monkeypatch.setattr(grok.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="grok 0.2.118 (old)\n", stderr=""))
55
+ with pytest.raises(RuntimeError, match="0.2.118.*1.0.46"):
56
+ grok.check_version("/profile/bin/grok", {}, "1.0.46")
57
+
58
+
59
+ def test_check_version_uses_profile_environment(monkeypatch):
60
+ calls = []
61
+ env = {"GROK_HOME": "/profile"}
62
+
63
+ def run(args, **kwargs):
64
+ calls.append((args, kwargs))
65
+ return SimpleNamespace(returncode=0, stdout="grok 1.0.46 (current)\n", stderr="")
66
+
67
+ monkeypatch.setattr(grok.subprocess, "run", run)
68
+ grok.check_version("/profile/bin/grok", env, "1.0.46")
69
+ assert calls[0][0] == ["/profile/bin/grok", "--version"]
70
+ assert calls[0][1]["env"] is env
71
+
72
+
73
+ def test_check_version_reports_missing_binary(monkeypatch):
74
+ def run(*args, **kwargs):
75
+ raise FileNotFoundError("missing")
76
+
77
+ monkeypatch.setattr(grok.subprocess, "run", run)
78
+ with pytest.raises(RuntimeError, match="/profile/bin/grok"):
79
+ grok.check_version("/profile/bin/grok", {}, "1.0.46")
80
+
81
+
82
+ def test_home_binary_uses_platform_filename(monkeypatch):
83
+ monkeypatch.setattr(grok.sys, "platform", "darwin")
84
+ assert grok.home_binary("/profile") == os.path.join("/profile", "bin", "grok")
85
+ monkeypatch.setattr(grok.sys, "platform", "win32")
86
+ assert grok.home_binary("/profile") == os.path.join("/profile", "bin", "grok.exe")
@@ -0,0 +1,40 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ import shlex
5
+ import subprocess
6
+ import sys
7
+
8
+ import pytest
9
+
10
+
11
+ @pytest.mark.skipif(sys.platform == "win32", reason="Requires Unix executable scripts")
12
+ def test_grok_launch_executes_updated_npm_binary_with_isolated_home(tmp_path):
13
+ old_bin = tmp_path / "old-install"
14
+ prefix = tmp_path / "npm-prefix"
15
+ old_bin.mkdir()
16
+ (prefix / "bin").mkdir(parents=True)
17
+ package = prefix / "lib/node_modules/@xai-official/grok"
18
+ package.mkdir(parents=True)
19
+ (package / "package.json").write_text(json.dumps({"name": "@xai-official/grok", "version": "1.0.46"}))
20
+ scripts = {
21
+ old_bin / "npm": "#!/bin/sh\nprintf '%s\\n' " + shlex.quote(str(prefix)) + "\n",
22
+ old_bin / "grok": "#!/bin/sh\necho 'grok 0.2.118 (old)'\n",
23
+ prefix / "bin/grok": "#!/bin/sh\necho 'grok 1.0.46 (current)'\nprintf 'profile=%s\\n' \"$GROK_HOME\"\n",
24
+ }
25
+ for path, script in scripts.items():
26
+ path.write_text(script, encoding="utf-8")
27
+ path.chmod(0o755)
28
+ home = str(tmp_path / "profile with spaces")
29
+ profile = {"name": "account-a", "type": "grok", "mode": "login", "credentials_path": home}
30
+ env = os.environ.copy()
31
+ env["PATH"] = str(old_bin) + os.pathsep + env.get("PATH", "")
32
+ env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src")
33
+ result = subprocess.run(
34
+ [sys.executable, "-c", "from code_ai.launcher import launch; launch(" + repr(profile) + ", ['--version'])"],
35
+ env=env, capture_output=True, text=True, timeout=15,
36
+ )
37
+ assert result.returncode == 0, result.stderr
38
+ assert "grok 1.0.46" in result.stdout
39
+ assert "0.2.118" not in result.stdout
40
+ assert "profile=" + home in result.stdout
@@ -325,3 +325,32 @@ def test_launch_windows_passes_env_correctly():
325
325
  env = mock_run.call_args[1]["env"]
326
326
  assert env["ANTHROPIC_BASE_URL"] == "https://example.com"
327
327
  assert "ANTHROPIC_AUTH_TOKEN" in env
328
+
329
+
330
+ @pytest.mark.parametrize("platform", ["darwin", "linux"])
331
+ def test_launch_grok_uses_updated_npm_entry_instead_of_shadowing_path(monkeypatch, tmp_path, platform):
332
+ from types import SimpleNamespace
333
+ from src.code_ai import launcher
334
+
335
+ profile_home = str(tmp_path / "grok-account")
336
+ npm_command = str(tmp_path / "npm-prefix" / "bin" / "grok")
337
+ monkeypatch.setattr(launcher.sys, "platform", platform)
338
+ monkeypatch.setattr(launcher.shutil, "which", lambda name: "/old-install/grok")
339
+ monkeypatch.setattr(launcher, "find_npm_installation", lambda: SimpleNamespace(command=npm_command, version="1.0.46"))
340
+ with patch.object(launcher.os, "execvp") as execute:
341
+ with patch.dict(os.environ):
342
+ launcher.launch({"type": "grok", "mode": "login", "name": "grok-account", "credentials_path": profile_home}, ["--version"])
343
+ assert os.environ["GROK_HOME"] == profile_home
344
+ execute.assert_called_once_with(npm_command, [npm_command, "--version"])
345
+
346
+
347
+ def test_launch_grok_without_npm_preserves_native_install(monkeypatch, tmp_path):
348
+ from src.code_ai import launcher
349
+
350
+ monkeypatch.setattr(launcher.sys, "platform", "darwin")
351
+ monkeypatch.setattr(launcher.shutil, "which", lambda name: "/native/bin/grok")
352
+ monkeypatch.setattr(launcher, "find_npm_installation", lambda: None)
353
+ with patch.object(launcher.os, "execvp") as execute:
354
+ with patch.dict(os.environ):
355
+ launcher.launch({"type": "grok", "mode": "login", "credentials_path": str(tmp_path / "native-account")}, ["--version"])
356
+ execute.assert_called_once_with("/native/bin/grok", ["/native/bin/grok", "--version"])
@@ -1 +0,0 @@
1
- __version__ = "0.1.22"