code-aide 1.12.2__tar.gz → 1.13.0__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 (53) hide show
  1. {code_aide-1.12.2 → code_aide-1.13.0}/PKG-INFO +1 -1
  2. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/__init__.py +1 -1
  3. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/commands_tools.py +7 -6
  4. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/config.py +17 -5
  5. code_aide-1.13.0/src/code_aide/constants.py +51 -0
  6. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/operations.py +40 -0
  7. code_aide-1.13.0/src/code_aide/package_managers.py +188 -0
  8. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/prereqs.py +19 -28
  9. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_commands_tools.py +2 -2
  10. code_aide-1.13.0/tests/test_package_managers.py +277 -0
  11. code_aide-1.12.2/src/code_aide/constants.py +0 -104
  12. {code_aide-1.12.2 → code_aide-1.13.0}/.github/workflows/ci.yml +0 -0
  13. {code_aide-1.12.2 → code_aide-1.13.0}/.github/workflows/publish.yml +0 -0
  14. {code_aide-1.12.2 → code_aide-1.13.0}/.gitignore +0 -0
  15. {code_aide-1.12.2 → code_aide-1.13.0}/.gitlab-ci.yml +0 -0
  16. {code_aide-1.12.2 → code_aide-1.13.0}/.pre-commit-config.yaml +0 -0
  17. {code_aide-1.12.2 → code_aide-1.13.0}/AGENTS.md +0 -0
  18. {code_aide-1.12.2 → code_aide-1.13.0}/CLAUDE.md +0 -0
  19. {code_aide-1.12.2 → code_aide-1.13.0}/LICENSE +0 -0
  20. {code_aide-1.12.2 → code_aide-1.13.0}/README.md +0 -0
  21. {code_aide-1.12.2 → code_aide-1.13.0}/TODO.md +0 -0
  22. {code_aide-1.12.2 → code_aide-1.13.0}/pyproject.toml +0 -0
  23. {code_aide-1.12.2 → code_aide-1.13.0}/script-archive/README.md +0 -0
  24. {code_aide-1.12.2 → code_aide-1.13.0}/script-archive/amp-install.sh +0 -0
  25. {code_aide-1.12.2 → code_aide-1.13.0}/script-archive/claude-install.sh +0 -0
  26. {code_aide-1.12.2 → code_aide-1.13.0}/script-archive/cursor-install.sh +0 -0
  27. {code_aide-1.12.2 → code_aide-1.13.0}/specs/auto-migrate-deprecated-installs.md +0 -0
  28. {code_aide-1.12.2 → code_aide-1.13.0}/specs/claude-native-installer-migration.md +0 -0
  29. {code_aide-1.12.2 → code_aide-1.13.0}/specs/missing-coding-llm-cli-tools.md +0 -0
  30. {code_aide-1.12.2 → code_aide-1.13.0}/specs/pre-commit-uv-setup.md +0 -0
  31. {code_aide-1.12.2 → code_aide-1.13.0}/specs/remove-bundled-version-baseline.md +0 -0
  32. {code_aide-1.12.2 → code_aide-1.13.0}/specs/unify-upgrade-eligibility-with-shared-evaluator.md +0 -0
  33. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/__main__.py +0 -0
  34. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/commands_actions.py +0 -0
  35. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/console.py +0 -0
  36. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/data/tools.json +0 -0
  37. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/detection.py +0 -0
  38. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/entry.py +0 -0
  39. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/install.py +0 -0
  40. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/install_types.py +0 -0
  41. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/status.py +0 -0
  42. {code_aide-1.12.2 → code_aide-1.13.0}/src/code_aide/versions.py +0 -0
  43. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_commands_actions.py +0 -0
  44. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_config.py +0 -0
  45. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_console.py +0 -0
  46. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_constants.py +0 -0
  47. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_detection.py +0 -0
  48. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_install.py +0 -0
  49. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_install_types.py +0 -0
  50. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_operations.py +0 -0
  51. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_status.py +0 -0
  52. {code_aide-1.12.2 → code_aide-1.13.0}/tests/test_versions.py +0 -0
  53. {code_aide-1.12.2 → code_aide-1.13.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-aide
3
- Version: 1.12.2
3
+ Version: 1.13.0
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.12.2"
3
+ __version__ = "1.13.0"
@@ -8,7 +8,7 @@ import shutil
8
8
  from typing import List
9
9
 
10
10
  from code_aide.config import ensure_versions_cache
11
- from code_aide.constants import Colors, PACKAGE_MANAGERS, TOOLS
11
+ from code_aide.constants import Colors, TOOLS
12
12
  from code_aide.detection import (
13
13
  format_install_method,
14
14
  format_migration_warning,
@@ -20,7 +20,10 @@ from code_aide.install_types import (
20
20
  parse_install_method,
21
21
  parse_install_type,
22
22
  )
23
- from code_aide.prereqs import detect_package_manager, is_tool_installed
23
+ from code_aide.package_managers import (
24
+ detect_package_manager as _detect_package_manager,
25
+ )
26
+ from code_aide.prereqs import is_tool_installed
24
27
  from code_aide.detection import is_freebsd
25
28
  from code_aide.status import (
26
29
  print_brew_version_status,
@@ -107,11 +110,9 @@ def cmd_list(args: argparse.Namespace) -> None:
107
110
  except Exception:
108
111
  pass
109
112
 
110
- pkg_mgr = detect_package_manager()
113
+ pkg_mgr = _detect_package_manager()
111
114
  if pkg_mgr:
112
- print(
113
- f" Package manager: {PACKAGE_MANAGERS[pkg_mgr]['description']} ({pkg_mgr})"
114
- )
115
+ print(f" Package manager: {pkg_mgr.description} ({pkg_mgr.detect_command})")
115
116
 
116
117
 
117
118
  def _short_install_method(method: str | None) -> str:
@@ -135,15 +135,27 @@ def save_updated_versions(tools: dict) -> None:
135
135
  save_versions_cache(cache_data)
136
136
 
137
137
 
138
- def versions_cache_is_fresh() -> bool:
139
- """Return True if the versions cache exists and is less than 24h old."""
138
+ def versions_cache_is_fresh(tools: dict) -> bool:
139
+ """Return True if the versions cache exists, is recent, and complete."""
140
140
  cache_path = get_versions_cache_path()
141
141
  try:
142
142
  age = time.time() - os.path.getmtime(cache_path)
143
- return age < CACHE_MAX_AGE_SECONDS
143
+ if age >= CACHE_MAX_AGE_SECONDS:
144
+ return False
144
145
  except OSError:
145
146
  return False
146
147
 
148
+ # Check that every versionable tool has latest_version populated.
149
+ for tool_config in tools.values():
150
+ install_type = parse_install_type(tool_config.get("install_type"))
151
+ if install_type == InstallType.NPM:
152
+ if not tool_config.get("latest_version"):
153
+ return False
154
+ elif install_type in (InstallType.SCRIPT, InstallType.DIRECT_DOWNLOAD):
155
+ if tool_config.get("version_url") and not tool_config.get("latest_version"):
156
+ return False
157
+ return True
158
+
147
159
 
148
160
  def refresh_versions_cache(tools: dict) -> None:
149
161
  """Fetch latest versions from upstream and update tools dict in-place.
@@ -187,6 +199,6 @@ def refresh_versions_cache(tools: dict) -> None:
187
199
 
188
200
 
189
201
  def ensure_versions_cache(tools: dict) -> None:
190
- """Refresh versions cache if missing or stale, updating tools in-place."""
191
- if not versions_cache_is_fresh():
202
+ """Refresh versions cache if missing, stale, or incomplete."""
203
+ if not versions_cache_is_fresh(tools):
192
204
  refresh_versions_cache(tools)
@@ -0,0 +1,51 @@
1
+ """Shared constants and mutable tool configuration for CLI modules."""
2
+
3
+ import os
4
+ import sys
5
+ from typing import Any, Dict
6
+
7
+ from code_aide.config import load_tools_config
8
+
9
+
10
+ def _use_color() -> bool:
11
+ """Determine whether to emit ANSI color codes.
12
+
13
+ Checks (in priority order):
14
+ NO_COLOR — if set (any value), no color (https://no-color.org/)
15
+ FORCE_COLOR — if set (any value), force color
16
+ CLICOLOR_FORCE — if non-empty, force color
17
+ TERM=dumb — no color
18
+ CLICOLOR=0 — no color
19
+ stdout is not a TTY — no color
20
+ """
21
+ if os.environ.get("NO_COLOR") is not None:
22
+ return False
23
+ if os.environ.get("FORCE_COLOR") is not None:
24
+ return True
25
+ if os.environ.get("CLICOLOR_FORCE", ""):
26
+ return True
27
+ if os.environ.get("TERM") == "dumb":
28
+ return False
29
+ if os.environ.get("CLICOLOR") == "0":
30
+ return False
31
+ return sys.stdout.isatty()
32
+
33
+
34
+ class Colors:
35
+ if _use_color():
36
+ RED = "\033[0;31m"
37
+ GREEN = "\033[0;32m"
38
+ YELLOW = "\033[1;33m"
39
+ BLUE = "\033[0;34m"
40
+ BOLD = "\033[1m"
41
+ NC = "\033[0m"
42
+ else:
43
+ RED = ""
44
+ GREEN = ""
45
+ YELLOW = ""
46
+ BLUE = ""
47
+ BOLD = ""
48
+ NC = ""
49
+
50
+
51
+ TOOLS: Dict[str, Dict[str, Any]] = load_tools_config()
@@ -14,6 +14,7 @@ from code_aide.detection import (
14
14
  format_install_method,
15
15
  is_deprecated_install,
16
16
  )
17
+ from code_aide.package_managers import query_package_owner
17
18
  from code_aide.install import (
18
19
  install_direct_download,
19
20
  install_tool,
@@ -83,6 +84,44 @@ def _upgrade_result_from_snapshots(
83
84
  return UpgradeResult.CHANGED
84
85
 
85
86
 
87
+ def _warn_duplicate_system_install(tool_name: str) -> None:
88
+ """Warn if a duplicate system-packaged binary shadows or coexists."""
89
+ tool_config = TOOLS[tool_name]
90
+ command = tool_config["command"]
91
+
92
+ # Find all instances of the command in PATH
93
+ seen = set()
94
+ paths = []
95
+ for directory in os.environ.get("PATH", "").split(os.pathsep):
96
+ candidate = os.path.join(directory, command)
97
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
98
+ real = os.path.realpath(candidate)
99
+ if real not in seen:
100
+ seen.add(real)
101
+ paths.append(real)
102
+
103
+ if len(paths) < 2:
104
+ return
105
+
106
+ system_prefixes = ("/opt/", "/usr/bin/", "/usr/sbin/", "/usr/local/bin/")
107
+ for path in paths:
108
+ if not any(path.startswith(p) for p in system_prefixes):
109
+ continue
110
+ package, remove_cmd = query_package_owner(path)
111
+ if package and remove_cmd:
112
+ warning(
113
+ f"A system-packaged {command} is also installed at {path} "
114
+ f"(package: {package})."
115
+ )
116
+ info(f"To remove it, run: {remove_cmd}")
117
+ else:
118
+ warning(
119
+ f"A system-packaged {command} is also installed at {path}. "
120
+ "You may want to remove it with your package manager."
121
+ )
122
+ return
123
+
124
+
86
125
  def _migrate_install_method(tool_name: str) -> UpgradeResult:
87
126
  """Migrate a tool from a deprecated install method to the configured one.
88
127
 
@@ -129,6 +168,7 @@ def _migrate_install_method(tool_name: str) -> UpgradeResult:
129
168
  return UpgradeResult.FAILED
130
169
 
131
170
  success(f"{tool_config['name']} migrated from {old_label} to {new_label}")
171
+ _warn_duplicate_system_install(tool_name)
132
172
  return UpgradeResult.CHANGED
133
173
 
134
174
 
@@ -0,0 +1,188 @@
1
+ """System package manager definitions and helpers."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from typing import List, Optional
10
+
11
+
12
+ class PackageManager(Enum):
13
+ """Known system package managers."""
14
+
15
+ APT = "apt-get"
16
+ DNF = "dnf"
17
+ YUM = "yum"
18
+ PACMAN = "pacman"
19
+ ZYPPER = "zypper"
20
+ EMERGE = "emerge"
21
+ PKG = "pkg"
22
+ HOMEBREW = "brew"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class PackageManagerInfo:
27
+ """Configuration for a system package manager."""
28
+
29
+ manager: PackageManager
30
+ description: str
31
+ detect_command: str
32
+ packages: List[str]
33
+ install_command: List[str]
34
+ pre_install: List[List[str]] = field(default_factory=list)
35
+ query_owner_command: List[str] = field(default_factory=list)
36
+ remove_command: List[str] = field(default_factory=list)
37
+
38
+
39
+ _MANAGERS: List[PackageManagerInfo] = [
40
+ PackageManagerInfo(
41
+ manager=PackageManager.APT,
42
+ description="Debian/Ubuntu",
43
+ detect_command="apt-get",
44
+ packages=["nodejs", "npm"],
45
+ pre_install=[["sudo", "apt-get", "update"]],
46
+ install_command=["sudo", "apt-get", "install", "-y"],
47
+ query_owner_command=["dpkg", "-S"],
48
+ remove_command=["sudo", "apt-get", "remove"],
49
+ ),
50
+ PackageManagerInfo(
51
+ manager=PackageManager.DNF,
52
+ description="Fedora/RHEL 8+",
53
+ detect_command="dnf",
54
+ packages=["nodejs", "npm"],
55
+ install_command=["sudo", "dnf", "install", "-y"],
56
+ query_owner_command=["rpm", "-qf"],
57
+ remove_command=["sudo", "dnf", "remove"],
58
+ ),
59
+ PackageManagerInfo(
60
+ manager=PackageManager.YUM,
61
+ description="RHEL/CentOS 7",
62
+ detect_command="yum",
63
+ packages=["nodejs", "npm"],
64
+ install_command=["sudo", "yum", "install", "-y"],
65
+ query_owner_command=["rpm", "-qf"],
66
+ remove_command=["sudo", "yum", "remove"],
67
+ ),
68
+ PackageManagerInfo(
69
+ manager=PackageManager.PACMAN,
70
+ description="Arch Linux",
71
+ detect_command="pacman",
72
+ packages=["nodejs", "npm"],
73
+ install_command=["sudo", "pacman", "-S", "--noconfirm"],
74
+ query_owner_command=["pacman", "-Qo"],
75
+ remove_command=["sudo", "pacman", "-R"],
76
+ ),
77
+ PackageManagerInfo(
78
+ manager=PackageManager.ZYPPER,
79
+ description="openSUSE",
80
+ detect_command="zypper",
81
+ packages=["nodejs", "npm"],
82
+ install_command=["sudo", "zypper", "install", "-y"],
83
+ query_owner_command=["rpm", "-qf"],
84
+ remove_command=["sudo", "zypper", "remove"],
85
+ ),
86
+ PackageManagerInfo(
87
+ manager=PackageManager.EMERGE,
88
+ description="Gentoo",
89
+ detect_command="emerge",
90
+ packages=["net-libs/nodejs"],
91
+ install_command=["sudo", "emerge", "--quiet-build"],
92
+ query_owner_command=["qfile", "-qC"],
93
+ remove_command=["sudo", "emerge", "--unmerge"],
94
+ ),
95
+ PackageManagerInfo(
96
+ manager=PackageManager.PKG,
97
+ description="FreeBSD",
98
+ detect_command="pkg",
99
+ packages=["node22", "npm-node22"],
100
+ install_command=["sudo", "pkg", "install", "-y"],
101
+ query_owner_command=["pkg", "which", "-q"],
102
+ remove_command=["sudo", "pkg", "delete"],
103
+ ),
104
+ PackageManagerInfo(
105
+ manager=PackageManager.HOMEBREW,
106
+ description="macOS/Linux (Homebrew)",
107
+ detect_command="brew",
108
+ packages=["node"],
109
+ install_command=["brew", "install"],
110
+ query_owner_command=["brew", "which-formula"],
111
+ remove_command=["brew", "uninstall"],
112
+ ),
113
+ ]
114
+
115
+ PACKAGE_MANAGER_BY_ENUM = {info.manager: info for info in _MANAGERS}
116
+
117
+
118
+ def detect_package_manager() -> Optional[PackageManagerInfo]:
119
+ """Detect the system package manager, or None on unsupported platforms."""
120
+ if platform.system() not in ("Linux", "FreeBSD", "Darwin"):
121
+ return None
122
+
123
+ for info in _MANAGERS:
124
+ if shutil.which(info.detect_command):
125
+ return info
126
+
127
+ return None
128
+
129
+
130
+ def _parse_package_name(manager: PackageManager, query_output: str) -> Optional[str]:
131
+ """Extract a package name from query-owner output."""
132
+ line = query_output.strip().split("\n")[0]
133
+ if not line:
134
+ return None
135
+
136
+ if manager == PackageManager.APT:
137
+ # dpkg -S output: "package: /path/to/file"
138
+ return line.split(":")[0].strip() or None
139
+ if manager == PackageManager.PACMAN:
140
+ # pacman -Qo output: "/path is owned by package version"
141
+ parts = line.split("is owned by")
142
+ if len(parts) > 1:
143
+ return parts[1].strip().rsplit(" ", 1)[0] or None
144
+ return None
145
+ # rpm -qf (dnf/yum/zypper): "package-version-release.arch"
146
+ # qfile -qC (emerge): "category/package"
147
+ # pkg which -q (FreeBSD): "package-name"
148
+ return line.strip() or None
149
+
150
+
151
+ def query_package_owner(
152
+ binary_path: str,
153
+ ) -> tuple:
154
+ """Identify the system package that owns a binary path.
155
+
156
+ Returns (package_name, remove_command_str) or (None, None).
157
+ """
158
+ mgr = detect_package_manager()
159
+ if not mgr or not mgr.query_owner_command or not mgr.remove_command:
160
+ return None, None
161
+
162
+ # brew which-formula takes the command name, not the full path.
163
+ query_arg = (
164
+ os.path.basename(binary_path)
165
+ if mgr.manager == PackageManager.HOMEBREW
166
+ else binary_path
167
+ )
168
+
169
+ try:
170
+ proc = subprocess.run(
171
+ [*mgr.query_owner_command, query_arg],
172
+ capture_output=True,
173
+ text=True,
174
+ timeout=10,
175
+ check=False,
176
+ stdin=subprocess.DEVNULL,
177
+ )
178
+ if proc.returncode != 0 or not proc.stdout.strip():
179
+ return None, None
180
+ except Exception:
181
+ return None, None
182
+
183
+ package = _parse_package_name(mgr.manager, proc.stdout)
184
+ if not package:
185
+ return None, None
186
+
187
+ remove_cmd = " ".join([*mgr.remove_command, package])
188
+ return package, remove_cmd
@@ -6,7 +6,7 @@ import subprocess
6
6
  import sys
7
7
  from typing import List, Optional
8
8
 
9
- from code_aide.constants import PACKAGE_MANAGERS, TOOLS
9
+ from code_aide.constants import TOOLS
10
10
  from code_aide.console import (
11
11
  command_exists,
12
12
  error,
@@ -15,42 +15,38 @@ from code_aide.console import (
15
15
  success,
16
16
  warning,
17
17
  )
18
+ from code_aide.package_managers import (
19
+ _MANAGERS,
20
+ detect_package_manager,
21
+ )
18
22
 
19
23
 
20
- def detect_package_manager() -> Optional[str]:
21
- """Detect the Linux distribution and return package manager name."""
22
- if platform.system() not in ("Linux", "FreeBSD"):
23
- return None
24
-
25
- for pkg_mgr_name, config in PACKAGE_MANAGERS.items():
26
- if command_exists(config["detect_command"]):
27
- return pkg_mgr_name
28
-
29
- return None
24
+ def _print_all_install_hints() -> None:
25
+ """Print install commands for all known package managers."""
26
+ for mgr in _MANAGERS:
27
+ install_cmd = " ".join(mgr.install_command + mgr.packages)
28
+ print(f" {mgr.description}: {install_cmd}")
29
+ print(" Or visit: https://nodejs.org/")
30
30
 
31
31
 
32
32
  def install_nodejs_npm() -> bool:
33
33
  """Install Node.js and npm using the system package manager."""
34
- pkg_mgr_name = detect_package_manager()
34
+ mgr = detect_package_manager()
35
35
 
36
- if not pkg_mgr_name:
36
+ if not mgr:
37
37
  error("Could not detect package manager. Please install Node.js manually:")
38
- for manager_name, config in PACKAGE_MANAGERS.items():
39
- install_cmd = " ".join(config["install_command"] + config["packages"])
40
- print(f" {config['description']}: {install_cmd}")
41
- print(" Or visit: https://nodejs.org/")
38
+ _print_all_install_hints()
42
39
  return False
43
40
 
44
- config = PACKAGE_MANAGERS[pkg_mgr_name]
45
- info(f"Detected package manager: {pkg_mgr_name} ({config['description']})")
41
+ info(f"Detected package manager: {mgr.detect_command} ({mgr.description})")
46
42
  info("Installing Node.js and npm...")
47
43
 
48
44
  try:
49
- for pre_cmd in config["pre_install"]:
45
+ for pre_cmd in mgr.pre_install:
50
46
  info(f"Running: {' '.join(pre_cmd)}")
51
- run_command(pre_cmd, check=True, capture=False)
47
+ run_command(list(pre_cmd), check=True, capture=False)
52
48
 
53
- install_cmd = config["install_command"] + config["packages"]
49
+ install_cmd = list(mgr.install_command) + list(mgr.packages)
54
50
  run_command(install_cmd, check=True, capture=False)
55
51
 
56
52
  if not command_exists("npm"):
@@ -102,12 +98,7 @@ def check_prerequisites(
102
98
  else:
103
99
  error("npm is required but not installed.")
104
100
  error("Please install Node.js and npm first:")
105
- for manager_name, config in PACKAGE_MANAGERS.items():
106
- install_cmd = " ".join(
107
- config["install_command"] + config["packages"]
108
- )
109
- print(f" {config['description']}: {install_cmd}")
110
- print(" Or visit: https://nodejs.org/")
101
+ _print_all_install_hints()
111
102
  print("\nOr use -p/--install-prerequisites to install automatically")
112
103
  sys.exit(1)
113
104
 
@@ -26,7 +26,7 @@ class TestCmdList(unittest.TestCase):
26
26
  mock.patch.object(commands_tools, "is_tool_installed", return_value=False),
27
27
  mock.patch.object(commands_tools, "command_exists", return_value=False),
28
28
  mock.patch.object(
29
- commands_tools, "detect_package_manager", return_value=None
29
+ commands_tools, "_detect_package_manager", return_value=None
30
30
  ),
31
31
  ):
32
32
  buf = io.StringIO()
@@ -314,7 +314,7 @@ class TestCmdStatus(unittest.TestCase):
314
314
  ),
315
315
  mock.patch.object(commands_tools, "command_exists", return_value=False),
316
316
  mock.patch.object(
317
- commands_tools, "detect_package_manager", return_value=None
317
+ commands_tools, "_detect_package_manager", return_value=None
318
318
  ),
319
319
  ):
320
320
  buf = io.StringIO()
@@ -0,0 +1,277 @@
1
+ """Unit tests for the package_managers module."""
2
+
3
+ import subprocess
4
+ import unittest
5
+ from unittest import mock
6
+
7
+ from code_aide.package_managers import (
8
+ PACKAGE_MANAGER_BY_ENUM,
9
+ PackageManager,
10
+ _MANAGERS,
11
+ _parse_package_name,
12
+ detect_package_manager,
13
+ query_package_owner,
14
+ )
15
+
16
+
17
+ class TestPackageManagerEnum(unittest.TestCase):
18
+ """Tests for the PackageManager enum."""
19
+
20
+ def test_all_managers_have_entries(self):
21
+ for mgr in PackageManager:
22
+ self.assertIn(mgr, PACKAGE_MANAGER_BY_ENUM)
23
+
24
+ def test_manager_list_matches_enum(self):
25
+ managers_in_list = {info.manager for info in _MANAGERS}
26
+ self.assertEqual(managers_in_list, set(PackageManager))
27
+
28
+ def test_no_duplicate_detect_commands(self):
29
+ commands = [info.detect_command for info in _MANAGERS]
30
+ self.assertEqual(len(commands), len(set(commands)))
31
+
32
+
33
+ class TestPackageManagerInfo(unittest.TestCase):
34
+ """Tests for PackageManagerInfo completeness."""
35
+
36
+ def test_all_managers_have_install_command(self):
37
+ for info in _MANAGERS:
38
+ self.assertTrue(
39
+ len(info.install_command) > 0,
40
+ f"{info.manager.name} missing install_command",
41
+ )
42
+
43
+ def test_all_managers_have_query_owner_command(self):
44
+ for info in _MANAGERS:
45
+ self.assertTrue(
46
+ len(info.query_owner_command) > 0,
47
+ f"{info.manager.name} missing query_owner_command",
48
+ )
49
+
50
+ def test_all_managers_have_remove_command(self):
51
+ for info in _MANAGERS:
52
+ self.assertTrue(
53
+ len(info.remove_command) > 0,
54
+ f"{info.manager.name} missing remove_command",
55
+ )
56
+
57
+ def test_all_managers_have_description(self):
58
+ for info in _MANAGERS:
59
+ self.assertTrue(
60
+ len(info.description) > 0,
61
+ f"{info.manager.name} missing description",
62
+ )
63
+
64
+ def test_all_managers_have_packages(self):
65
+ for info in _MANAGERS:
66
+ self.assertTrue(
67
+ len(info.packages) > 0,
68
+ f"{info.manager.name} missing packages",
69
+ )
70
+
71
+ def test_info_is_frozen(self):
72
+ info = PACKAGE_MANAGER_BY_ENUM[PackageManager.APT]
73
+ with self.assertRaises(AttributeError):
74
+ info.description = "changed"
75
+
76
+
77
+ class TestDetectPackageManager(unittest.TestCase):
78
+ """Tests for detect_package_manager."""
79
+
80
+ @mock.patch("code_aide.package_managers.platform.system", return_value="Windows")
81
+ def test_returns_none_on_windows(self, _mock_sys):
82
+ self.assertIsNone(detect_package_manager())
83
+
84
+ @mock.patch("code_aide.package_managers.shutil.which", return_value=None)
85
+ @mock.patch("code_aide.package_managers.platform.system", return_value="Linux")
86
+ def test_returns_none_when_no_manager_found(self, _mock_sys, _mock_which):
87
+ self.assertIsNone(detect_package_manager())
88
+
89
+ @mock.patch("code_aide.package_managers.shutil.which")
90
+ @mock.patch("code_aide.package_managers.platform.system", return_value="Linux")
91
+ def test_detects_apt(self, _mock_sys, mock_which):
92
+ mock_which.side_effect = lambda cmd: (
93
+ "/usr/bin/apt-get" if cmd == "apt-get" else None
94
+ )
95
+ result = detect_package_manager()
96
+ self.assertIsNotNone(result)
97
+ self.assertEqual(result.manager, PackageManager.APT)
98
+
99
+ @mock.patch("code_aide.package_managers.shutil.which")
100
+ @mock.patch("code_aide.package_managers.platform.system", return_value="Linux")
101
+ def test_detects_emerge(self, _mock_sys, mock_which):
102
+ mock_which.side_effect = lambda cmd: (
103
+ "/usr/bin/emerge" if cmd == "emerge" else None
104
+ )
105
+ result = detect_package_manager()
106
+ self.assertIsNotNone(result)
107
+ self.assertEqual(result.manager, PackageManager.EMERGE)
108
+
109
+ @mock.patch("code_aide.package_managers.shutil.which")
110
+ @mock.patch("code_aide.package_managers.platform.system", return_value="FreeBSD")
111
+ def test_detects_pkg_on_freebsd(self, _mock_sys, mock_which):
112
+ mock_which.side_effect = lambda cmd: "/usr/sbin/pkg" if cmd == "pkg" else None
113
+ result = detect_package_manager()
114
+ self.assertIsNotNone(result)
115
+ self.assertEqual(result.manager, PackageManager.PKG)
116
+
117
+ @mock.patch("code_aide.package_managers.shutil.which")
118
+ @mock.patch("code_aide.package_managers.platform.system", return_value="Darwin")
119
+ def test_detects_brew_on_macos(self, _mock_sys, mock_which):
120
+ mock_which.side_effect = lambda cmd: (
121
+ "/opt/homebrew/bin/brew" if cmd == "brew" else None
122
+ )
123
+ result = detect_package_manager()
124
+ self.assertIsNotNone(result)
125
+ self.assertEqual(result.manager, PackageManager.HOMEBREW)
126
+
127
+ @mock.patch("code_aide.package_managers.shutil.which")
128
+ @mock.patch("code_aide.package_managers.platform.system", return_value="Linux")
129
+ def test_returns_first_match(self, _mock_sys, mock_which):
130
+ # apt-get is first in the list
131
+ mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}"
132
+ result = detect_package_manager()
133
+ self.assertEqual(result.manager, PackageManager.APT)
134
+
135
+
136
+ class TestParsePackageName(unittest.TestCase):
137
+ """Tests for _parse_package_name output parsing."""
138
+
139
+ def test_apt_dpkg_output(self):
140
+ self.assertEqual(
141
+ _parse_package_name(PackageManager.APT, "libfoo:amd64: /usr/lib/libfoo.so"),
142
+ "libfoo",
143
+ )
144
+
145
+ def test_apt_simple_output(self):
146
+ self.assertEqual(
147
+ _parse_package_name(PackageManager.APT, "claude-code: /usr/bin/claude"),
148
+ "claude-code",
149
+ )
150
+
151
+ def test_dnf_rpm_output(self):
152
+ self.assertEqual(
153
+ _parse_package_name(PackageManager.DNF, "claude-code-2.1.50-1.x86_64"),
154
+ "claude-code-2.1.50-1.x86_64",
155
+ )
156
+
157
+ def test_pacman_output(self):
158
+ self.assertEqual(
159
+ _parse_package_name(
160
+ PackageManager.PACMAN,
161
+ "/usr/bin/claude is owned by claude-code 2.1.50",
162
+ ),
163
+ "claude-code",
164
+ )
165
+
166
+ def test_pacman_no_match(self):
167
+ self.assertIsNone(
168
+ _parse_package_name(PackageManager.PACMAN, "error: no package"),
169
+ )
170
+
171
+ def test_emerge_output(self):
172
+ self.assertEqual(
173
+ _parse_package_name(PackageManager.EMERGE, "dev-util/claude-code"),
174
+ "dev-util/claude-code",
175
+ )
176
+
177
+ def test_pkg_output(self):
178
+ self.assertEqual(
179
+ _parse_package_name(PackageManager.PKG, "claude-code-2.1.50"),
180
+ "claude-code-2.1.50",
181
+ )
182
+
183
+ def test_brew_output(self):
184
+ self.assertEqual(
185
+ _parse_package_name(PackageManager.HOMEBREW, "gemini-cli"),
186
+ "gemini-cli",
187
+ )
188
+
189
+ def test_empty_output(self):
190
+ self.assertIsNone(_parse_package_name(PackageManager.APT, ""))
191
+ self.assertIsNone(_parse_package_name(PackageManager.APT, "\n"))
192
+
193
+ def test_multiline_takes_first(self):
194
+ self.assertEqual(
195
+ _parse_package_name(PackageManager.EMERGE, "dev-util/foo\ndev-util/bar"),
196
+ "dev-util/foo",
197
+ )
198
+
199
+
200
+ class TestQueryPackageOwner(unittest.TestCase):
201
+ """Tests for query_package_owner."""
202
+
203
+ @mock.patch("code_aide.package_managers.detect_package_manager", return_value=None)
204
+ def test_returns_none_when_no_manager(self, _mock):
205
+ pkg, cmd = query_package_owner("/usr/bin/example")
206
+ self.assertIsNone(pkg)
207
+ self.assertIsNone(cmd)
208
+
209
+ @mock.patch("code_aide.package_managers.subprocess.run")
210
+ @mock.patch("code_aide.package_managers.detect_package_manager")
211
+ def test_emerge_query(self, mock_detect, mock_run):
212
+ mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.EMERGE]
213
+ mock_run.return_value = subprocess.CompletedProcess(
214
+ args=[], returncode=0, stdout="dev-util/claude-code\n"
215
+ )
216
+ pkg, cmd = query_package_owner("/opt/bin/claude")
217
+ self.assertEqual(pkg, "dev-util/claude-code")
218
+ self.assertEqual(cmd, "sudo emerge --unmerge dev-util/claude-code")
219
+ mock_run.assert_called_once_with(
220
+ ["qfile", "-qC", "/opt/bin/claude"],
221
+ capture_output=True,
222
+ text=True,
223
+ timeout=10,
224
+ check=False,
225
+ stdin=subprocess.DEVNULL,
226
+ )
227
+
228
+ @mock.patch("code_aide.package_managers.subprocess.run")
229
+ @mock.patch("code_aide.package_managers.detect_package_manager")
230
+ def test_apt_query(self, mock_detect, mock_run):
231
+ mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.APT]
232
+ mock_run.return_value = subprocess.CompletedProcess(
233
+ args=[], returncode=0, stdout="claude-code: /usr/bin/claude\n"
234
+ )
235
+ pkg, cmd = query_package_owner("/usr/bin/claude")
236
+ self.assertEqual(pkg, "claude-code")
237
+ self.assertEqual(cmd, "sudo apt-get remove claude-code")
238
+
239
+ @mock.patch("code_aide.package_managers.subprocess.run")
240
+ @mock.patch("code_aide.package_managers.detect_package_manager")
241
+ def test_brew_uses_basename(self, mock_detect, mock_run):
242
+ mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.HOMEBREW]
243
+ mock_run.return_value = subprocess.CompletedProcess(
244
+ args=[], returncode=0, stdout="gemini-cli\n"
245
+ )
246
+ pkg, cmd = query_package_owner("/opt/homebrew/bin/gemini")
247
+ self.assertEqual(pkg, "gemini-cli")
248
+ self.assertEqual(cmd, "brew uninstall gemini-cli")
249
+ # Verify basename was passed, not full path
250
+ mock_run.assert_called_once_with(
251
+ ["brew", "which-formula", "gemini"],
252
+ capture_output=True,
253
+ text=True,
254
+ timeout=10,
255
+ check=False,
256
+ stdin=subprocess.DEVNULL,
257
+ )
258
+
259
+ @mock.patch("code_aide.package_managers.subprocess.run")
260
+ @mock.patch("code_aide.package_managers.detect_package_manager")
261
+ def test_returns_none_on_query_failure(self, mock_detect, mock_run):
262
+ mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.APT]
263
+ mock_run.return_value = subprocess.CompletedProcess(
264
+ args=[], returncode=1, stdout=""
265
+ )
266
+ pkg, cmd = query_package_owner("/usr/bin/example")
267
+ self.assertIsNone(pkg)
268
+ self.assertIsNone(cmd)
269
+
270
+ @mock.patch("code_aide.package_managers.subprocess.run")
271
+ @mock.patch("code_aide.package_managers.detect_package_manager")
272
+ def test_returns_none_on_exception(self, mock_detect, mock_run):
273
+ mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.APT]
274
+ mock_run.side_effect = OSError("timeout")
275
+ pkg, cmd = query_package_owner("/usr/bin/example")
276
+ self.assertIsNone(pkg)
277
+ self.assertIsNone(cmd)
@@ -1,104 +0,0 @@
1
- """Shared constants and mutable tool configuration for CLI modules."""
2
-
3
- import os
4
- import sys
5
- from typing import Any, Dict
6
-
7
- from code_aide.config import load_tools_config
8
-
9
-
10
- def _use_color() -> bool:
11
- """Determine whether to emit ANSI color codes.
12
-
13
- Checks (in priority order):
14
- NO_COLOR — if set (any value), no color (https://no-color.org/)
15
- FORCE_COLOR — if set (any value), force color
16
- CLICOLOR_FORCE — if non-empty, force color
17
- TERM=dumb — no color
18
- CLICOLOR=0 — no color
19
- stdout is not a TTY — no color
20
- """
21
- if os.environ.get("NO_COLOR") is not None:
22
- return False
23
- if os.environ.get("FORCE_COLOR") is not None:
24
- return True
25
- if os.environ.get("CLICOLOR_FORCE", ""):
26
- return True
27
- if os.environ.get("TERM") == "dumb":
28
- return False
29
- if os.environ.get("CLICOLOR") == "0":
30
- return False
31
- return sys.stdout.isatty()
32
-
33
-
34
- class Colors:
35
- if _use_color():
36
- RED = "\033[0;31m"
37
- GREEN = "\033[0;32m"
38
- YELLOW = "\033[1;33m"
39
- BLUE = "\033[0;34m"
40
- BOLD = "\033[1m"
41
- NC = "\033[0m"
42
- else:
43
- RED = ""
44
- GREEN = ""
45
- YELLOW = ""
46
- BLUE = ""
47
- BOLD = ""
48
- NC = ""
49
-
50
-
51
- PACKAGE_MANAGERS: Dict[str, Dict[str, Any]] = {
52
- "apt-get": {
53
- "detect_command": "apt-get",
54
- "packages": ["nodejs", "npm"],
55
- "pre_install": [["sudo", "apt-get", "update"]],
56
- "install_command": ["sudo", "apt-get", "install", "-y"],
57
- "description": "Debian/Ubuntu",
58
- },
59
- "dnf": {
60
- "detect_command": "dnf",
61
- "packages": ["nodejs", "npm"],
62
- "pre_install": [],
63
- "install_command": ["sudo", "dnf", "install", "-y"],
64
- "description": "Fedora/RHEL 8+",
65
- },
66
- "yum": {
67
- "detect_command": "yum",
68
- "packages": ["nodejs", "npm"],
69
- "pre_install": [],
70
- "install_command": ["sudo", "yum", "install", "-y"],
71
- "description": "RHEL/CentOS 7",
72
- },
73
- "pacman": {
74
- "detect_command": "pacman",
75
- "packages": ["nodejs", "npm"],
76
- "pre_install": [],
77
- "install_command": ["sudo", "pacman", "-S", "--noconfirm"],
78
- "description": "Arch Linux",
79
- },
80
- "zypper": {
81
- "detect_command": "zypper",
82
- "packages": ["nodejs", "npm"],
83
- "pre_install": [],
84
- "install_command": ["sudo", "zypper", "install", "-y"],
85
- "description": "openSUSE",
86
- },
87
- "emerge": {
88
- "detect_command": "emerge",
89
- "packages": ["net-libs/nodejs"],
90
- "pre_install": [],
91
- "install_command": ["sudo", "emerge", "--quiet-build"],
92
- "description": "Gentoo",
93
- },
94
- "pkg": {
95
- "detect_command": "pkg",
96
- "packages": ["node22", "npm-node22"],
97
- "pre_install": [],
98
- "install_command": ["sudo", "pkg", "install", "-y"],
99
- "description": "FreeBSD",
100
- },
101
- }
102
-
103
-
104
- TOOLS: Dict[str, Dict[str, Any]] = load_tools_config()
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