code-aide 1.15.6__tar.gz → 1.15.7__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 (61) hide show
  1. {code_aide-1.15.6 → code_aide-1.15.7}/PKG-INFO +1 -1
  2. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/__init__.py +1 -1
  3. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/commands_actions.py +5 -2
  4. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/install.py +49 -13
  5. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/prereqs.py +9 -14
  6. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_commands_actions.py +34 -1
  7. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_install.py +16 -0
  8. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_prereqs.py +3 -9
  9. {code_aide-1.15.6 → code_aide-1.15.7}/.github/workflows/ci.yml +0 -0
  10. {code_aide-1.15.6 → code_aide-1.15.7}/.github/workflows/publish.yml +0 -0
  11. {code_aide-1.15.6 → code_aide-1.15.7}/.gitignore +0 -0
  12. {code_aide-1.15.6 → code_aide-1.15.7}/.gitlab-ci.yml +0 -0
  13. {code_aide-1.15.6 → code_aide-1.15.7}/.pre-commit-config.yaml +0 -0
  14. {code_aide-1.15.6 → code_aide-1.15.7}/AGENTS.md +0 -0
  15. {code_aide-1.15.6 → code_aide-1.15.7}/CLAUDE.md +0 -0
  16. {code_aide-1.15.6 → code_aide-1.15.7}/LICENSE +0 -0
  17. {code_aide-1.15.6 → code_aide-1.15.7}/README.md +0 -0
  18. {code_aide-1.15.6 → code_aide-1.15.7}/TODO.md +0 -0
  19. {code_aide-1.15.6 → code_aide-1.15.7}/pyproject.toml +0 -0
  20. {code_aide-1.15.6 → code_aide-1.15.7}/script-archive/README.md +0 -0
  21. {code_aide-1.15.6 → code_aide-1.15.7}/script-archive/amp-install.sh +0 -0
  22. {code_aide-1.15.6 → code_aide-1.15.7}/script-archive/claude-install.sh +0 -0
  23. {code_aide-1.15.6 → code_aide-1.15.7}/script-archive/cursor-install.sh +0 -0
  24. {code_aide-1.15.6 → code_aide-1.15.7}/specs/2026-05-03-qwen3.6-code-quality-analysis-original.md +0 -0
  25. {code_aide-1.15.6 → code_aide-1.15.7}/specs/2026-05-03-qwen3.6-code-quality-analysis.md +0 -0
  26. {code_aide-1.15.6 → code_aide-1.15.7}/specs/auto-migrate-deprecated-installs.md +0 -0
  27. {code_aide-1.15.6 → code_aide-1.15.7}/specs/claude-native-installer-migration.md +0 -0
  28. {code_aide-1.15.6 → code_aide-1.15.7}/specs/missing-coding-llm-cli-tools.md +0 -0
  29. {code_aide-1.15.6 → code_aide-1.15.7}/specs/pre-commit-uv-setup.md +0 -0
  30. {code_aide-1.15.6 → code_aide-1.15.7}/specs/remove-bundled-version-baseline.md +0 -0
  31. {code_aide-1.15.6 → code_aide-1.15.7}/specs/unify-upgrade-eligibility-with-shared-evaluator.md +0 -0
  32. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/__main__.py +0 -0
  33. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/commands_tools.py +0 -0
  34. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/config.py +0 -0
  35. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/console.py +0 -0
  36. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/constants.py +0 -0
  37. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/data/tools.json +0 -0
  38. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/detection.py +0 -0
  39. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/detection_types.py +0 -0
  40. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/entry.py +0 -0
  41. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/install_types.py +0 -0
  42. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/operation_handlers.py +0 -0
  43. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/operations.py +0 -0
  44. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/package_info.py +0 -0
  45. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/package_managers.py +0 -0
  46. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/status.py +0 -0
  47. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/status_print.py +0 -0
  48. {code_aide-1.15.6 → code_aide-1.15.7}/src/code_aide/versions.py +0 -0
  49. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_commands_tools.py +0 -0
  50. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_config.py +0 -0
  51. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_console.py +0 -0
  52. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_constants.py +0 -0
  53. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_detection.py +0 -0
  54. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_entry.py +0 -0
  55. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_error_paths.py +0 -0
  56. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_install_types.py +0 -0
  57. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_operations.py +0 -0
  58. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_package_managers.py +0 -0
  59. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_status.py +0 -0
  60. {code_aide-1.15.6 → code_aide-1.15.7}/tests/test_versions.py +0 -0
  61. {code_aide-1.15.6 → code_aide-1.15.7}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-aide
3
- Version: 1.15.6
3
+ Version: 1.15.7
4
4
  Summary: Manage AI coding CLI tools (Claude, Copilot, Cursor, Gemini, Amp, Codex)
5
5
  Project-URL: Homepage, https://github.com/dajobe/code-aide
6
6
  Project-URL: Repository, https://github.com/dajobe/code-aide
@@ -1,3 +1,3 @@
1
1
  """code-aide - Manage AI coding CLI tools."""
2
2
 
3
- __version__ = "1.15.6"
3
+ __version__ = "1.15.7"
@@ -73,6 +73,7 @@ def cmd_install(args: argparse.Namespace) -> None:
73
73
  )
74
74
 
75
75
  installed = []
76
+ installed_bin_dirs = []
76
77
  failed = []
77
78
 
78
79
  for tool in tools_to_install:
@@ -82,8 +83,10 @@ def cmd_install(args: argparse.Namespace) -> None:
82
83
  else:
83
84
  info(f"=== Installing {tool} ===")
84
85
 
85
- if install_tool(tool, dryrun=dryrun):
86
+ outcome = install_tool(tool, dryrun=dryrun)
87
+ if outcome:
86
88
  installed.append(tool)
89
+ installed_bin_dirs.extend(outcome.bin_dirs)
87
90
  else:
88
91
  failed.append(tool)
89
92
 
@@ -120,7 +123,7 @@ def cmd_install(args: argparse.Namespace) -> None:
120
123
  print(f" {tool_config['next_steps']}")
121
124
 
122
125
  print()
123
- check_path_directories(installed)
126
+ check_path_directories(installed_bin_dirs)
124
127
  success("All installations completed successfully!")
125
128
 
126
129
 
@@ -8,6 +8,7 @@ import shutil
8
8
  import subprocess
9
9
  import tarfile
10
10
  import tempfile
11
+ from dataclasses import dataclass
11
12
  from typing import Any, Dict, Optional
12
13
 
13
14
  from code_aide.constants import TOOLS
@@ -18,6 +19,29 @@ from code_aide.versions import check_script_tool, fetch_url
18
19
  DOWNLOAD_TIMEOUT_SECONDS = 120
19
20
 
20
21
 
22
+ @dataclass(frozen=True)
23
+ class InstallOutcome:
24
+ """Result of an install, including directories that may need PATH setup."""
25
+
26
+ succeeded: bool
27
+ bin_dirs: tuple[str, ...] = ()
28
+
29
+ def __bool__(self) -> bool:
30
+ return self.succeeded
31
+
32
+
33
+ def get_npm_global_bin_dir() -> Optional[str]:
34
+ """Return the directory where the active npm installs global executables."""
35
+ try:
36
+ prefix = run_command(["npm", "prefix", "--global"]).stdout.strip()
37
+ except (OSError, subprocess.CalledProcessError):
38
+ return None
39
+
40
+ if not prefix:
41
+ return None
42
+ return os.path.join(prefix, "bin")
43
+
44
+
21
45
  def run_pkg_command(
22
46
  base_cmd: list,
23
47
  pkg_name: str,
@@ -265,12 +289,14 @@ def install_direct_download(
265
289
  return False
266
290
 
267
291
 
268
- def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> bool:
292
+ def install_tool(
293
+ tool_name: str, dryrun: bool = False, force: bool = False
294
+ ) -> InstallOutcome:
269
295
  """Install a tool based on its configuration."""
270
296
  tool_config = TOOLS.get(tool_name)
271
297
  if not tool_config:
272
298
  error(f"Unknown tool: {tool_name}")
273
- return False
299
+ return InstallOutcome(False)
274
300
 
275
301
  if dryrun:
276
302
  info(f"[DRYRUN] Checking {tool_config['name']}...")
@@ -283,7 +309,7 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
283
309
  info(f"{tool_config['command']} already installed at {tool_path}")
284
310
  else:
285
311
  warning(f"{tool_config['command']} already installed at {tool_path}")
286
- return True
312
+ return InstallOutcome(True)
287
313
  if command_exists(tool_config["command"]) and force:
288
314
  tool_path = shutil.which(tool_config["command"])
289
315
  if dryrun:
@@ -301,7 +327,7 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
301
327
  freebsd_port = tool_config.get("freebsd_port")
302
328
  if not freebsd_port:
303
329
  error(f"{tool_config['name']} is not available on FreeBSD (no port exists)")
304
- return False
330
+ return InstallOutcome(False)
305
331
  pkg_repo = tool_config.get("freebsd_pkg_repo")
306
332
  if dryrun:
307
333
  repo_flag = f" -r {pkg_repo}" if pkg_repo else ""
@@ -309,7 +335,7 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
309
335
  f"[DRYRUN] Would install FreeBSD port: "
310
336
  f"pkg install{repo_flag} {freebsd_port}"
311
337
  )
312
- return True
338
+ return InstallOutcome(True)
313
339
  try:
314
340
  run_pkg_command(
315
341
  ["sudo", "pkg", "install", "-y"],
@@ -321,27 +347,32 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
321
347
  info(tool_config["next_steps"])
322
348
  if "docs_url" in tool_config:
323
349
  info(f"Documentation: {tool_config['docs_url']}")
324
- return True
350
+ return InstallOutcome(True)
325
351
  except subprocess.CalledProcessError as exc:
326
352
  error(f"Failed to install {tool_config['name']}: {exc.stderr}")
327
- return False
353
+ return InstallOutcome(False)
328
354
  except Exception as exc:
329
355
  error(f"Failed to install {tool_config['name']}: {exc}")
330
- return False
356
+ return InstallOutcome(False)
331
357
 
332
358
  try:
333
359
  install_type = get_tool_install_type(tool_config)
334
360
 
335
361
  if install_type == InstallType.NPM:
336
362
  npm_package = tool_config["npm_package"]
363
+ bin_dirs = ()
337
364
  if dryrun:
338
365
  info(f"[DRYRUN] Would install npm package: {npm_package}")
339
366
  else:
340
367
  run_command(["npm", "install", "-g", npm_package], check=True)
368
+ npm_bin_dir = get_npm_global_bin_dir()
369
+ if npm_bin_dir:
370
+ bin_dirs = (npm_bin_dir,)
341
371
  success(f"{tool_config['name']} installed successfully")
342
372
  info(tool_config["next_steps"])
343
373
  if "docs_url" in tool_config:
344
374
  info(f"Documentation: {tool_config['docs_url']}")
375
+ return InstallOutcome(True, bin_dirs)
345
376
 
346
377
  elif install_type == InstallType.SCRIPT:
347
378
  install_url = tool_config["install_url"]
@@ -361,7 +392,7 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
361
392
  if "docs_url" in tool_config:
362
393
  info(f"Documentation: {tool_config['docs_url']}")
363
394
  else:
364
- return False
395
+ return InstallOutcome(False)
365
396
 
366
397
  elif install_type == InstallType.DIRECT_DOWNLOAD:
367
398
  if install_direct_download(tool_name, tool_config, dryrun):
@@ -373,13 +404,18 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
373
404
  if "docs_url" in tool_config:
374
405
  info(f"Documentation: {tool_config['docs_url']}")
375
406
  else:
376
- return False
407
+ return InstallOutcome(False)
377
408
 
378
- return True
409
+ if not dryrun and tool_config.get("bin_dir"):
410
+ return InstallOutcome(
411
+ True, (os.path.expanduser(tool_config["bin_dir"]),)
412
+ )
413
+
414
+ return InstallOutcome(True)
379
415
 
380
416
  except subprocess.CalledProcessError as exc:
381
417
  error(f"Failed to install {tool_config['name']}: {exc.stderr}")
382
- return False
418
+ return InstallOutcome(False)
383
419
  except Exception as exc:
384
420
  error(f"Failed to install {tool_config['name']}: {exc}")
385
- return False
421
+ return InstallOutcome(False)
@@ -149,28 +149,23 @@ def is_tool_installed(tool_name: str) -> bool:
149
149
  return command_exists(tool_config["command"])
150
150
 
151
151
 
152
- def check_path_directories(tools_installed: Optional[List[str]] = None) -> None:
152
+ def check_path_directories(directories: Optional[List[str]] = None) -> None:
153
153
  """Check if binary installation directories are in PATH and warn if not.
154
154
 
155
- When *tools_installed* is given, only directories relevant to those
156
- tools are checked. Otherwise a small set of common directories is used.
155
+ When *directories* is given, only those directories are checked. Otherwise
156
+ a small set of common directories is used.
157
157
  """
158
158
  current_path = os.environ.get("PATH", "")
159
159
  path_entries = current_path.split(os.pathsep)
160
160
 
161
- if tools_installed:
161
+ if directories is not None:
162
162
  seen: set = set()
163
163
  dirs_to_check: List[str] = []
164
- for tool_name in tools_installed:
165
- tool_config = TOOLS.get(tool_name)
166
- if not tool_config:
167
- continue
168
- bin_dir = tool_config.get("bin_dir")
169
- if bin_dir:
170
- expanded = os.path.expanduser(bin_dir)
171
- if expanded not in seen:
172
- seen.add(expanded)
173
- dirs_to_check.append(expanded)
164
+ for directory in directories:
165
+ expanded = os.path.expanduser(directory)
166
+ if expanded not in seen:
167
+ seen.add(expanded)
168
+ dirs_to_check.append(expanded)
174
169
  else:
175
170
  dirs_to_check = [
176
171
  os.path.expanduser("~/.local/bin"),
@@ -8,6 +8,7 @@ from unittest import mock
8
8
 
9
9
  from code_aide import constants, commands_actions
10
10
  from code_aide import entry
11
+ from code_aide.install import InstallOutcome
11
12
  from code_aide.operations import UpgradeResult
12
13
 
13
14
 
@@ -39,7 +40,7 @@ class TestCmdInstall(unittest.TestCase):
39
40
  mock.patch.dict(constants._TOOLS_DATA, tools, clear=True),
40
41
  mock.patch.object(commands_actions, "validate_tools"),
41
42
  mock.patch.object(
42
- commands_actions, "install_tool", return_value=True
43
+ commands_actions, "install_tool", return_value=InstallOutcome(True)
43
44
  ) as mock_install_tool,
44
45
  mock.patch.object(commands_actions, "check_prerequisites") as mock_prereqs,
45
46
  ):
@@ -50,6 +51,38 @@ class TestCmdInstall(unittest.TestCase):
50
51
  mock_install_tool.assert_called_once_with("default_tool", dryrun=True)
51
52
  mock_prereqs.assert_not_called()
52
53
 
54
+ def test_checks_bin_directories_reported_by_installers(self):
55
+ tools = {
56
+ "test": {
57
+ "name": "Test Tool",
58
+ "command": "test",
59
+ "install_type": "npm",
60
+ "next_steps": "run test",
61
+ }
62
+ }
63
+ args = type(
64
+ "Args",
65
+ (),
66
+ {"tools": ["test"], "dryrun": False, "install_prerequisites": False},
67
+ )()
68
+
69
+ with (
70
+ mock.patch.dict(constants._TOOLS_DATA, tools, clear=True),
71
+ mock.patch.object(commands_actions, "validate_tools"),
72
+ mock.patch.object(commands_actions, "check_prerequisites"),
73
+ mock.patch.object(
74
+ commands_actions,
75
+ "install_tool",
76
+ return_value=InstallOutcome(True, ("/home/test/.npm-packages/bin",)),
77
+ ),
78
+ mock.patch.object(
79
+ commands_actions, "check_path_directories"
80
+ ) as mock_check_path,
81
+ ):
82
+ commands_actions.cmd_install(args)
83
+
84
+ mock_check_path.assert_called_once_with(["/home/test/.npm-packages/bin"])
85
+
53
86
 
54
87
  class TestCmdUpdateVersions(unittest.TestCase):
55
88
  """Tests for cmd_update_versions."""
@@ -286,6 +286,16 @@ class TestInstallDirectDownload(unittest.TestCase):
286
286
  class TestInstallTool(unittest.TestCase):
287
287
  """Tests for install_tool behavior."""
288
288
 
289
+ def test_get_npm_global_bin_dir_uses_active_prefix(self):
290
+ npm_prefix = mock.Mock(stdout="/home/test/.npm-packages\n")
291
+ with mock.patch.object(
292
+ cli_install, "run_command", return_value=npm_prefix
293
+ ) as mock_run:
294
+ result = cli_install.get_npm_global_bin_dir()
295
+
296
+ self.assertEqual(result, "/home/test/.npm-packages/bin")
297
+ mock_run.assert_called_once_with(["npm", "prefix", "--global"])
298
+
289
299
  def test_force_reinstalls_even_when_binary_exists(self):
290
300
  tool_config = {
291
301
  "name": "Test Tool",
@@ -301,11 +311,17 @@ class TestInstallTool(unittest.TestCase):
301
311
  mock.patch.object(
302
312
  cli_install.shutil, "which", return_value="/usr/local/bin/test-tool"
303
313
  ),
314
+ mock.patch.object(
315
+ cli_install,
316
+ "get_npm_global_bin_dir",
317
+ return_value="/home/test/.npm-packages/bin",
318
+ ),
304
319
  mock.patch.object(cli_install, "run_command") as mock_run,
305
320
  ):
306
321
  result = cli_install.install_tool("test", force=True)
307
322
 
308
323
  self.assertTrue(result)
324
+ self.assertEqual(result.bin_dirs, ("/home/test/.npm-packages/bin",))
309
325
  mock_run.assert_called_once_with(
310
326
  ["npm", "install", "-g", "test-tool"], check=True
311
327
  )
@@ -210,14 +210,12 @@ class TestCheckPathDirectories(unittest.TestCase):
210
210
  """Tests for check_path_directories."""
211
211
 
212
212
  def test_warns_when_existing_bin_dir_not_in_path(self):
213
- tool_config = {"name": "Test", "command": "test", "bin_dir": "/custom/bin"}
214
213
  with (
215
- mock.patch.dict(constants._TOOLS_DATA, {"test": tool_config}, clear=True),
216
214
  mock.patch.dict("os.environ", {"PATH": "/usr/bin"}, clear=False),
217
215
  mock.patch.object(cli_prereqs.os.path, "isdir", return_value=True),
218
216
  mock.patch("builtins.print") as mock_print,
219
217
  ):
220
- cli_prereqs.check_path_directories(["test"])
218
+ cli_prereqs.check_path_directories(["/custom/bin"])
221
219
  # Should have printed at least the bin dir as a warning detail
222
220
  printed = " ".join(
223
221
  str(call.args[0]) for call in mock_print.call_args_list if call.args
@@ -225,27 +223,23 @@ class TestCheckPathDirectories(unittest.TestCase):
225
223
  self.assertIn("/custom/bin", printed)
226
224
 
227
225
  def test_silent_when_dir_already_in_path(self):
228
- tool_config = {"name": "Test", "command": "test", "bin_dir": "/custom/bin"}
229
226
  with (
230
- mock.patch.dict(constants._TOOLS_DATA, {"test": tool_config}, clear=True),
231
227
  mock.patch.dict(
232
228
  "os.environ", {"PATH": "/custom/bin:/usr/bin"}, clear=False
233
229
  ),
234
230
  mock.patch.object(cli_prereqs.os.path, "isdir", return_value=True),
235
231
  mock.patch("builtins.print") as mock_print,
236
232
  ):
237
- cli_prereqs.check_path_directories(["test"])
233
+ cli_prereqs.check_path_directories(["/custom/bin"])
238
234
  self.assertFalse(mock_print.called)
239
235
 
240
236
  def test_silent_when_bin_dir_does_not_exist(self):
241
- tool_config = {"name": "Test", "command": "test", "bin_dir": "/missing/bin"}
242
237
  with (
243
- mock.patch.dict(constants._TOOLS_DATA, {"test": tool_config}, clear=True),
244
238
  mock.patch.dict("os.environ", {"PATH": "/usr/bin"}, clear=False),
245
239
  mock.patch.object(cli_prereqs.os.path, "isdir", return_value=False),
246
240
  mock.patch("builtins.print") as mock_print,
247
241
  ):
248
- cli_prereqs.check_path_directories(["test"])
242
+ cli_prereqs.check_path_directories(["/missing/bin"])
249
243
  self.assertFalse(mock_print.called)
250
244
 
251
245
 
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