code-aide 1.10.1__tar.gz → 1.11.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 (50) hide show
  1. {code_aide-1.10.1 → code_aide-1.11.0}/PKG-INFO +2 -1
  2. {code_aide-1.10.1 → code_aide-1.11.0}/pyproject.toml +1 -0
  3. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/__init__.py +1 -1
  4. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/commands_tools.py +29 -0
  5. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/constants.py +7 -0
  6. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/data/tools.json +8 -4
  7. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/detection.py +77 -0
  8. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/install.py +23 -1
  9. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/install_types.py +3 -0
  10. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/operations.py +23 -0
  11. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/prereqs.py +7 -1
  12. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/status.py +44 -0
  13. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_detection.py +126 -0
  14. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_install.py +54 -0
  15. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_install_types.py +9 -0
  16. {code_aide-1.10.1 → code_aide-1.11.0}/.github/workflows/ci.yml +0 -0
  17. {code_aide-1.10.1 → code_aide-1.11.0}/.github/workflows/publish.yml +0 -0
  18. {code_aide-1.10.1 → code_aide-1.11.0}/.gitignore +0 -0
  19. {code_aide-1.10.1 → code_aide-1.11.0}/.gitlab-ci.yml +0 -0
  20. {code_aide-1.10.1 → code_aide-1.11.0}/.pre-commit-config.yaml +0 -0
  21. {code_aide-1.10.1 → code_aide-1.11.0}/AGENTS.md +0 -0
  22. {code_aide-1.10.1 → code_aide-1.11.0}/CLAUDE.md +0 -0
  23. {code_aide-1.10.1 → code_aide-1.11.0}/LICENSE +0 -0
  24. {code_aide-1.10.1 → code_aide-1.11.0}/README.md +0 -0
  25. {code_aide-1.10.1 → code_aide-1.11.0}/TODO.md +0 -0
  26. {code_aide-1.10.1 → code_aide-1.11.0}/script-archive/README.md +0 -0
  27. {code_aide-1.10.1 → code_aide-1.11.0}/script-archive/amp-install.sh +0 -0
  28. {code_aide-1.10.1 → code_aide-1.11.0}/script-archive/claude-install.sh +0 -0
  29. {code_aide-1.10.1 → code_aide-1.11.0}/script-archive/cursor-install.sh +0 -0
  30. {code_aide-1.10.1 → code_aide-1.11.0}/specs/auto-migrate-deprecated-installs.md +0 -0
  31. {code_aide-1.10.1 → code_aide-1.11.0}/specs/claude-native-installer-migration.md +0 -0
  32. {code_aide-1.10.1 → code_aide-1.11.0}/specs/missing-coding-llm-cli-tools.md +0 -0
  33. {code_aide-1.10.1 → code_aide-1.11.0}/specs/pre-commit-uv-setup.md +0 -0
  34. {code_aide-1.10.1 → code_aide-1.11.0}/specs/remove-bundled-version-baseline.md +0 -0
  35. {code_aide-1.10.1 → code_aide-1.11.0}/specs/unify-upgrade-eligibility-with-shared-evaluator.md +0 -0
  36. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/__main__.py +0 -0
  37. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/commands_actions.py +0 -0
  38. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/config.py +0 -0
  39. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/console.py +0 -0
  40. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/entry.py +0 -0
  41. {code_aide-1.10.1 → code_aide-1.11.0}/src/code_aide/versions.py +0 -0
  42. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_commands_actions.py +0 -0
  43. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_commands_tools.py +0 -0
  44. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_config.py +0 -0
  45. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_console.py +0 -0
  46. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_constants.py +0 -0
  47. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_operations.py +0 -0
  48. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_status.py +0 -0
  49. {code_aide-1.10.1 → code_aide-1.11.0}/tests/test_versions.py +0 -0
  50. {code_aide-1.10.1 → code_aide-1.11.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-aide
3
- Version: 1.10.1
3
+ Version: 1.11.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
@@ -14,6 +14,7 @@ Classifier: Environment :: Console
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: Apache Software License
16
16
  Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: BSD :: FreeBSD
17
18
  Classifier: Operating System :: POSIX :: Linux
18
19
  Classifier: Programming Language :: Python :: 3
19
20
  Classifier: Programming Language :: Python :: 3.11
@@ -16,6 +16,7 @@ classifiers = [
16
16
  "License :: OSI Approved :: Apache Software License",
17
17
  "Operating System :: MacOS",
18
18
  "Operating System :: POSIX :: Linux",
19
+ "Operating System :: POSIX :: BSD :: FreeBSD",
19
20
  "Programming Language :: Python :: 3",
20
21
  "Programming Language :: Python :: 3.11",
21
22
  "Programming Language :: Python :: 3.12",
@@ -1,3 +1,3 @@
1
1
  """code-aide - Manage AI coding CLI tools."""
2
2
 
3
- __version__ = "1.10.1"
3
+ __version__ = "1.11.0"
@@ -20,8 +20,10 @@ from code_aide.install_types import (
20
20
  parse_install_type,
21
21
  )
22
22
  from code_aide.prereqs import detect_package_manager, is_tool_installed
23
+ from code_aide.detection import is_freebsd
23
24
  from code_aide.status import (
24
25
  print_brew_version_status,
26
+ print_pkg_version_status,
25
27
  print_system_version_status,
26
28
  ToolUpgradeEvaluator,
27
29
  UpgradeDecision,
@@ -68,6 +70,9 @@ def cmd_list(args: argparse.Namespace) -> None:
68
70
  if tool_config.get("min_node_version"):
69
71
  print(f" Requires: Node.js v{tool_config['min_node_version']}+")
70
72
 
73
+ if is_freebsd() and not tool_config.get("freebsd_port"):
74
+ print(" Note: Not available on FreeBSD")
75
+
71
76
  if not tool_config.get("default_install", True):
72
77
  print(
73
78
  " Note: Opt-in only "
@@ -116,6 +121,7 @@ def _short_install_method(method: str | None) -> str:
116
121
  InstallMethod.NPM: "npm",
117
122
  InstallMethod.BREW_NPM: "brew-npm",
118
123
  InstallMethod.SYSTEM: "system",
124
+ InstallMethod.PKG: "pkg",
119
125
  InstallMethod.SCRIPT: "script",
120
126
  InstallMethod.DIRECT_DOWNLOAD: "download",
121
127
  }
@@ -243,6 +249,29 @@ def cmd_status(args: argparse.Namespace) -> None:
243
249
  assessment.latest_version,
244
250
  assessment.package_info,
245
251
  )
252
+ elif assessment.install_method == InstallMethod.PKG:
253
+ if assessment.package_info and assessment.package_info.get(
254
+ "available_version"
255
+ ):
256
+ print_pkg_version_status(
257
+ status["version"],
258
+ assessment.latest_version,
259
+ assessment.package_info,
260
+ )
261
+ elif assessment.latest_version:
262
+ if assessment.version_state == VersionDisplayState.UP_TO_DATE:
263
+ print(
264
+ f" Version: {status['version']} "
265
+ f"{Colors.GREEN}(up to date){Colors.NC}"
266
+ )
267
+ else:
268
+ print(
269
+ _generic_version_annotation(
270
+ status["version"], assessment.latest_version
271
+ )
272
+ )
273
+ else:
274
+ print(f" Version: {status['version']}")
246
275
  elif assessment.install_method in (
247
276
  InstallMethod.BREW_FORMULA,
248
277
  InstallMethod.BREW_CASK,
@@ -57,6 +57,13 @@ PACKAGE_MANAGERS: Dict[str, Dict[str, Any]] = {
57
57
  "install_command": ["sudo", "emerge", "--quiet-build"],
58
58
  "description": "Gentoo",
59
59
  },
60
+ "pkg": {
61
+ "detect_command": "pkg",
62
+ "packages": ["node22", "npm-node22"],
63
+ "pre_install": [],
64
+ "install_command": ["sudo", "pkg", "install", "-y"],
65
+ "description": "FreeBSD",
66
+ },
60
67
  }
61
68
 
62
69
 
@@ -36,7 +36,8 @@
36
36
  "--version"
37
37
  ],
38
38
  "docs_url": "https://code.claude.com/docs/en/setup",
39
- "default_install": true
39
+ "default_install": true,
40
+ "freebsd_port": "claude-code"
40
41
  },
41
42
  "gemini": {
42
43
  "name": "Gemini CLI",
@@ -52,7 +53,8 @@
52
53
  "--version"
53
54
  ],
54
55
  "docs_url": "https://github.com/google-gemini/gemini-cli",
55
- "default_install": true
56
+ "default_install": true,
57
+ "freebsd_port": "gemini-cli"
56
58
  },
57
59
  "amp": {
58
60
  "name": "Amp (Sourcegraph)",
@@ -84,7 +86,8 @@
84
86
  "--version"
85
87
  ],
86
88
  "docs_url": "https://github.com/openai/codex",
87
- "default_install": false
89
+ "default_install": false,
90
+ "freebsd_port": "codex"
88
91
  },
89
92
  "copilot": {
90
93
  "name": "Copilot CLI",
@@ -100,7 +103,8 @@
100
103
  "--version"
101
104
  ],
102
105
  "docs_url": "https://github.com/github/copilot-cli",
103
- "default_install": false
106
+ "default_install": false,
107
+ "freebsd_port": "github-copilot-cli"
104
108
  },
105
109
  "opencode": {
106
110
  "name": "OpenCode",
@@ -3,6 +3,7 @@
3
3
  import glob as globmod
4
4
  import json
5
5
  import os
6
+ import platform
6
7
  import re
7
8
  import shutil
8
9
  import subprocess
@@ -40,6 +41,29 @@ class PackageVersionInfo(TypedDict, total=False):
40
41
  outdated: bool | None
41
42
 
42
43
 
44
+ def is_freebsd() -> bool:
45
+ """Return True when running on FreeBSD."""
46
+ return platform.system() == "FreeBSD"
47
+
48
+
49
+ def _pkg_owns_file(path: str) -> bool:
50
+ """Return True when FreeBSD pkg owns the given file path."""
51
+ if not command_exists("pkg"):
52
+ return False
53
+ try:
54
+ proc = subprocess.run(
55
+ ["pkg", "which", "-q", path],
56
+ capture_output=True,
57
+ text=True,
58
+ timeout=5,
59
+ check=False,
60
+ stdin=subprocess.DEVNULL,
61
+ )
62
+ return proc.returncode == 0 and bool(proc.stdout.strip())
63
+ except Exception:
64
+ return False
65
+
66
+
43
67
  def _empty_install_info() -> DetectedInstallInfo:
44
68
  """Return the default empty install-info shape."""
45
69
  return {"method": None, "detail": None}
@@ -78,6 +102,9 @@ def detect_install_method(tool_name: str) -> DetectedInstallInfo:
78
102
 
79
103
  system_prefixes = ("/opt/", "/usr/bin/", "/usr/sbin/", "/usr/local/bin/")
80
104
  if any(real_path.startswith(prefix) for prefix in system_prefixes):
105
+ freebsd_port = tool_config.get("freebsd_port")
106
+ if is_freebsd() and freebsd_port and _pkg_owns_file(real_path):
107
+ return {"method": InstallMethod.PKG, "detail": freebsd_port}
81
108
  return {"method": InstallMethod.SYSTEM, "detail": real_path}
82
109
 
83
110
  return {
@@ -263,6 +290,53 @@ def get_brew_package_info(
263
290
  return result
264
291
 
265
292
 
293
+ def get_pkg_package_info(package_name: str) -> PackageVersionInfo:
294
+ """Get package version info for a FreeBSD pkg-installed tool."""
295
+ result: PackageVersionInfo = {
296
+ "package": package_name,
297
+ "installed_version": None,
298
+ "available_version": None,
299
+ "available_date": None,
300
+ "outdated": None,
301
+ }
302
+
303
+ if not command_exists("pkg"):
304
+ return result
305
+
306
+ try:
307
+ proc = subprocess.run(
308
+ ["pkg", "query", "%v", package_name],
309
+ capture_output=True,
310
+ text=True,
311
+ timeout=5,
312
+ check=False,
313
+ stdin=subprocess.DEVNULL,
314
+ )
315
+ if proc.returncode == 0 and proc.stdout.strip():
316
+ result["installed_version"] = proc.stdout.strip().split("\n")[0]
317
+ except Exception:
318
+ pass
319
+
320
+ try:
321
+ proc = subprocess.run(
322
+ ["pkg", "rquery", "%v", package_name],
323
+ capture_output=True,
324
+ text=True,
325
+ timeout=10,
326
+ check=False,
327
+ stdin=subprocess.DEVNULL,
328
+ )
329
+ if proc.returncode == 0 and proc.stdout.strip():
330
+ result["available_version"] = proc.stdout.strip().split("\n")[0]
331
+ except Exception:
332
+ pass
333
+
334
+ if result["installed_version"] and result["available_version"]:
335
+ result["outdated"] = result["installed_version"] != result["available_version"]
336
+
337
+ return result
338
+
339
+
266
340
  def format_install_method(method: InstallMethodInput, detail: Optional[str]) -> str:
267
341
  """Format detected local install method for display."""
268
342
  install_method = parse_install_method(method)
@@ -279,6 +353,8 @@ def format_install_method(method: InstallMethodInput, detail: Optional[str]) ->
279
353
  if detail
280
354
  else "Homebrew prefix npm-global"
281
355
  )
356
+ if install_method == InstallMethod.PKG:
357
+ return f"FreeBSD pkg ({detail})" if detail else "FreeBSD pkg"
282
358
  if install_method == InstallMethod.SYSTEM:
283
359
  return f"system package ({detail})" if detail else "system package"
284
360
  if install_method == InstallMethod.SCRIPT or install_type == InstallType.SCRIPT:
@@ -299,6 +375,7 @@ _USER_MANAGED_METHODS = frozenset(
299
375
  InstallMethod.BREW_FORMULA,
300
376
  InstallMethod.BREW_CASK,
301
377
  InstallMethod.SYSTEM,
378
+ InstallMethod.PKG,
302
379
  }
303
380
  )
304
381
 
@@ -88,7 +88,7 @@ ARCH_MAP = {
88
88
  def detect_os_arch() -> tuple:
89
89
  """Detect OS and architecture for direct download URLs."""
90
90
  os_name = platform.system().lower()
91
- if os_name not in ("linux", "darwin"):
91
+ if os_name not in ("linux", "darwin", "freebsd"):
92
92
  raise RuntimeError(f"Unsupported OS: {os_name}")
93
93
 
94
94
  machine = platform.machine()
@@ -255,6 +255,28 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
255
255
  f"{tool_path}"
256
256
  )
257
257
 
258
+ if platform.system() == "FreeBSD":
259
+ freebsd_port = tool_config.get("freebsd_port")
260
+ if not freebsd_port:
261
+ error(f"{tool_config['name']} is not available on FreeBSD (no port exists)")
262
+ return False
263
+ if dryrun:
264
+ info(f"[DRYRUN] Would install FreeBSD port: pkg install {freebsd_port}")
265
+ return True
266
+ try:
267
+ run_command(["sudo", "pkg", "install", "-y", freebsd_port], check=True)
268
+ success(f"{tool_config['name']} installed successfully via FreeBSD pkg")
269
+ info(tool_config["next_steps"])
270
+ if "docs_url" in tool_config:
271
+ info(f"Documentation: {tool_config['docs_url']}")
272
+ return True
273
+ except subprocess.CalledProcessError as exc:
274
+ error(f"Failed to install {tool_config['name']}: {exc.stderr}")
275
+ return False
276
+ except Exception as exc:
277
+ error(f"Failed to install {tool_config['name']}: {exc}")
278
+ return False
279
+
258
280
  try:
259
281
  install_type = get_tool_install_type(tool_config)
260
282
 
@@ -17,6 +17,7 @@ class InstallType(_ValueEnum):
17
17
  NPM = "npm"
18
18
  SCRIPT = "script"
19
19
  DIRECT_DOWNLOAD = "direct_download"
20
+ PKG = "pkg"
20
21
 
21
22
 
22
23
  class InstallMethod(_ValueEnum):
@@ -29,6 +30,7 @@ class InstallMethod(_ValueEnum):
29
30
  SYSTEM = "system"
30
31
  SCRIPT = "script"
31
32
  DIRECT_DOWNLOAD = "direct_download"
33
+ PKG = "pkg"
32
34
 
33
35
 
34
36
  InstallTypeInput: TypeAlias = InstallType | str
@@ -40,6 +42,7 @@ _INSTALL_METHOD_BY_TYPE = {
40
42
  InstallType.NPM: InstallMethod.NPM,
41
43
  InstallType.SCRIPT: InstallMethod.SCRIPT,
42
44
  InstallType.DIRECT_DOWNLOAD: InstallMethod.DIRECT_DOWNLOAD,
45
+ InstallType.PKG: InstallMethod.PKG,
43
46
  }
44
47
 
45
48
 
@@ -190,6 +190,17 @@ def upgrade_tool(tool_name: str) -> UpgradeResult:
190
190
  if not install_direct_download(tool_name, tool_config):
191
191
  return UpgradeResult.FAILED
192
192
 
193
+ elif method == InstallMethod.PKG:
194
+ pkg_name = detail or tool_config.get("freebsd_port")
195
+ if not pkg_name:
196
+ error(f"No FreeBSD port configured for {tool_config['name']}")
197
+ return UpgradeResult.FAILED
198
+ run_command(
199
+ ["sudo", "pkg", "upgrade", "-y", pkg_name],
200
+ check=True,
201
+ capture=False,
202
+ )
203
+
193
204
  elif method == InstallMethod.SYSTEM:
194
205
  error(
195
206
  f"{tool_config['name']} is managed by the system package manager. "
@@ -327,6 +338,18 @@ def remove_tool(tool_name: str) -> bool:
327
338
 
328
339
  success(f"{tool_config['name']} removed successfully")
329
340
 
341
+ elif method == InstallMethod.PKG:
342
+ pkg_name = detail or tool_config.get("freebsd_port")
343
+ if not pkg_name:
344
+ error(f"No FreeBSD port configured for {tool_config['name']}")
345
+ return False
346
+ run_command(
347
+ ["sudo", "pkg", "delete", "-y", pkg_name],
348
+ check=True,
349
+ capture=False,
350
+ )
351
+ success(f"{tool_config['name']} removed successfully")
352
+
330
353
  elif method == InstallMethod.SYSTEM:
331
354
  error(
332
355
  f"{tool_config['name']} is managed by the system package manager. "
@@ -19,7 +19,7 @@ from code_aide.console import (
19
19
 
20
20
  def detect_package_manager() -> Optional[str]:
21
21
  """Detect the Linux distribution and return package manager name."""
22
- if platform.system() != "Linux":
22
+ if platform.system() not in ("Linux", "FreeBSD"):
23
23
  return None
24
24
 
25
25
  for pkg_mgr_name, config in PACKAGE_MANAGERS.items():
@@ -78,11 +78,17 @@ def check_prerequisites(
78
78
  needed_prereqs = set()
79
79
  tools_needing_node = []
80
80
 
81
+ on_freebsd = platform.system() == "FreeBSD"
82
+
81
83
  for tool_name in tools_to_install:
82
84
  tool_config = TOOLS.get(tool_name)
83
85
  if not tool_config:
84
86
  continue
85
87
 
88
+ # FreeBSD pkg handles all dependencies for ported tools
89
+ if on_freebsd and tool_config.get("freebsd_port"):
90
+ continue
91
+
86
92
  needed_prereqs.update(tool_config.get("prerequisites", []))
87
93
  if tool_config.get("min_node_version"):
88
94
  tools_needing_node.append((tool_name, tool_config["min_node_version"]))
@@ -12,6 +12,7 @@ from code_aide.detection import (
12
12
  PackageVersionInfo,
13
13
  detect_install_method,
14
14
  get_brew_package_info,
15
+ get_pkg_package_info,
15
16
  get_system_package_info,
16
17
  is_install_method_deprecated,
17
18
  )
@@ -125,6 +126,9 @@ class ToolUpgradeEvaluator:
125
126
  if method in (InstallMethod.BREW_FORMULA, InstallMethod.BREW_CASK):
126
127
  return self._evaluate_brew(status, install_info, package_info)
127
128
 
129
+ if method == InstallMethod.PKG:
130
+ return self._evaluate_brew(status, install_info, package_info)
131
+
128
132
  if method == InstallMethod.SYSTEM:
129
133
  return self._result(
130
134
  decision=UpgradeDecision.PACKAGE_MANAGED,
@@ -243,6 +247,11 @@ class ToolUpgradeEvaluator:
243
247
  self._package_info = get_brew_package_info(
244
248
  method, install_info.get("detail")
245
249
  )
250
+ elif method == InstallMethod.PKG:
251
+ pkg_name = install_info.get("detail") or self.tool_config.get(
252
+ "freebsd_port"
253
+ )
254
+ self._package_info = get_pkg_package_info(pkg_name) if pkg_name else None
246
255
  elif method == InstallMethod.SYSTEM:
247
256
  tool_path = self._tool_path or shutil.which(self.tool_config["command"])
248
257
  self._package_info = (
@@ -383,6 +392,41 @@ def print_brew_version_status(
383
392
  print(f" Packaged: {avail_ver} ({pkg_name})")
384
393
 
385
394
 
395
+ def print_pkg_version_status(
396
+ cli_version: str,
397
+ latest_version: Optional[str],
398
+ pkg_info: PackageInfo,
399
+ ) -> None:
400
+ """Print version status for a FreeBSD pkg-managed tool."""
401
+ avail_ver = pkg_info.get("available_version")
402
+ outdated = pkg_info.get("outdated")
403
+
404
+ if outdated:
405
+ print(
406
+ f" Version: {cli_version} {Colors.YELLOW}(pkg has {avail_ver})"
407
+ f"{Colors.NC}"
408
+ )
409
+ else:
410
+ print(f" Version: {cli_version} {Colors.GREEN}(up to date){Colors.NC}")
411
+
412
+ if avail_ver:
413
+ pkg_name = pkg_info.get("package") or "FreeBSD pkg"
414
+ show_upstream = (
415
+ latest_version
416
+ and not status_version_matches_latest(avail_ver, latest_version)
417
+ and version_is_newer(
418
+ normalize_version(latest_version), normalize_version(avail_ver)
419
+ )
420
+ )
421
+ if show_upstream:
422
+ print(
423
+ f" Packaged: {avail_ver} ({pkg_name}) "
424
+ f"{Colors.YELLOW}(upstream: {latest_version}){Colors.NC}"
425
+ )
426
+ else:
427
+ print(f" Packaged: {avail_ver} ({pkg_name})")
428
+
429
+
386
430
  def get_tool_status(tool_name: str, tool_config: Dict[str, Any]) -> ToolStatus:
387
431
  """Get status information for a specific tool."""
388
432
  status_info = {
@@ -92,6 +92,132 @@ class TestDetectInstallMethod(unittest.TestCase):
92
92
  {"method": "system", "detail": "/usr/bin/claude"},
93
93
  )
94
94
 
95
+ @mock.patch.object(cli_detection, "is_freebsd", return_value=True)
96
+ @mock.patch.object(cli_detection, "_pkg_owns_file", return_value=True)
97
+ @mock.patch.object(cli_detection.os.path, "realpath")
98
+ @mock.patch.object(cli_detection.shutil, "which")
99
+ def test_detects_freebsd_pkg(self, mock_which, mock_realpath, mock_pkg, mock_fbsd):
100
+ mock_which.return_value = "/usr/local/bin/claude"
101
+ mock_realpath.return_value = "/usr/local/bin/claude"
102
+
103
+ self.assertEqual(
104
+ cli_detection.detect_install_method("claude"),
105
+ {"method": "pkg", "detail": "claude-code"},
106
+ )
107
+
108
+ @mock.patch.object(cli_detection, "is_freebsd", return_value=True)
109
+ @mock.patch.object(cli_detection, "_pkg_owns_file", return_value=False)
110
+ @mock.patch.object(cli_detection.os.path, "realpath")
111
+ @mock.patch.object(cli_detection.shutil, "which")
112
+ def test_freebsd_not_pkg_owned_falls_back_to_system(
113
+ self, mock_which, mock_realpath, mock_pkg, mock_fbsd
114
+ ):
115
+ mock_which.return_value = "/usr/local/bin/claude"
116
+ mock_realpath.return_value = "/usr/local/bin/claude"
117
+
118
+ self.assertEqual(
119
+ cli_detection.detect_install_method("claude"),
120
+ {"method": "system", "detail": "/usr/local/bin/claude"},
121
+ )
122
+
123
+ @mock.patch.object(cli_detection, "is_freebsd", return_value=False)
124
+ @mock.patch.object(cli_detection.os.path, "realpath")
125
+ @mock.patch.object(cli_detection.shutil, "which")
126
+ def test_not_freebsd_ignores_port(self, mock_which, mock_realpath, mock_fbsd):
127
+ mock_which.return_value = "/usr/local/bin/claude"
128
+ mock_realpath.return_value = "/usr/local/bin/claude"
129
+
130
+ self.assertEqual(
131
+ cli_detection.detect_install_method("claude"),
132
+ {"method": "system", "detail": "/usr/local/bin/claude"},
133
+ )
134
+
135
+
136
+ class TestFormatInstallMethodPkg(unittest.TestCase):
137
+ """Tests for format_install_method with pkg method."""
138
+
139
+ def test_pkg_with_detail(self):
140
+ self.assertEqual(
141
+ cli_detection.format_install_method("pkg", "claude-code"),
142
+ "FreeBSD pkg (claude-code)",
143
+ )
144
+
145
+ def test_pkg_without_detail(self):
146
+ self.assertEqual(
147
+ cli_detection.format_install_method("pkg", None),
148
+ "FreeBSD pkg",
149
+ )
150
+
151
+
152
+ class TestPkgNeverDeprecated(unittest.TestCase):
153
+ """Tests that pkg install method is never deprecated."""
154
+
155
+ def test_pkg_never_deprecated(self):
156
+ tool_config = {
157
+ "name": "Test Tool",
158
+ "command": "test-tool",
159
+ "install_type": "script",
160
+ }
161
+ with (
162
+ mock.patch.dict(cli_detection.TOOLS, {"test": tool_config}, clear=True),
163
+ mock.patch.object(
164
+ cli_detection,
165
+ "detect_install_method",
166
+ return_value={"method": "pkg", "detail": "test-tool"},
167
+ ),
168
+ ):
169
+ self.assertFalse(cli_detection.is_deprecated_install("test"))
170
+
171
+
172
+ class TestGetPkgPackageInfo(unittest.TestCase):
173
+ """Tests for get_pkg_package_info."""
174
+
175
+ @mock.patch.object(cli_detection, "command_exists", return_value=False)
176
+ def test_no_pkg_command(self, mock_cmd):
177
+ result = cli_detection.get_pkg_package_info("claude-code")
178
+ self.assertEqual(result["package"], "claude-code")
179
+ self.assertIsNone(result["installed_version"])
180
+ self.assertIsNone(result["available_version"])
181
+
182
+ @mock.patch.object(cli_detection, "command_exists", return_value=True)
183
+ @mock.patch.object(cli_detection.subprocess, "run")
184
+ def test_parses_pkg_output(self, mock_run, mock_cmd):
185
+ def side_effect(cmd, **kwargs):
186
+ result = mock.Mock()
187
+ if cmd[1] == "query":
188
+ result.returncode = 0
189
+ result.stdout = "2.1.62\n"
190
+ elif cmd[1] == "rquery":
191
+ result.returncode = 0
192
+ result.stdout = "2.1.63\n"
193
+ else:
194
+ result.returncode = 1
195
+ result.stdout = ""
196
+ return result
197
+
198
+ mock_run.side_effect = side_effect
199
+
200
+ result = cli_detection.get_pkg_package_info("claude-code")
201
+ self.assertEqual(result["installed_version"], "2.1.62")
202
+ self.assertEqual(result["available_version"], "2.1.63")
203
+ self.assertTrue(result["outdated"])
204
+
205
+ @mock.patch.object(cli_detection, "command_exists", return_value=True)
206
+ @mock.patch.object(cli_detection.subprocess, "run")
207
+ def test_up_to_date(self, mock_run, mock_cmd):
208
+ def side_effect(cmd, **kwargs):
209
+ result = mock.Mock()
210
+ result.returncode = 0
211
+ result.stdout = "2.1.63\n"
212
+ return result
213
+
214
+ mock_run.side_effect = side_effect
215
+
216
+ result = cli_detection.get_pkg_package_info("claude-code")
217
+ self.assertEqual(result["installed_version"], "2.1.63")
218
+ self.assertEqual(result["available_version"], "2.1.63")
219
+ self.assertFalse(result["outdated"])
220
+
95
221
 
96
222
  class TestFormatInstallMethodSystem(unittest.TestCase):
97
223
  """Tests for format_install_method with system method."""
@@ -45,6 +45,60 @@ class TestDetectOsArch(unittest.TestCase):
45
45
  with self.assertRaises(RuntimeError):
46
46
  cli_install.detect_os_arch()
47
47
 
48
+ @mock.patch.object(cli_install.platform, "machine", return_value="amd64")
49
+ @mock.patch.object(cli_install.platform, "system", return_value="FreeBSD")
50
+ def test_freebsd_amd64(self, mock_sys, mock_mach):
51
+ self.assertEqual(cli_install.detect_os_arch(), ("freebsd", "x64"))
52
+
53
+ @mock.patch.object(cli_install.platform, "machine", return_value="arm64")
54
+ @mock.patch.object(cli_install.platform, "system", return_value="FreeBSD")
55
+ def test_freebsd_arm64(self, mock_sys, mock_mach):
56
+ self.assertEqual(cli_install.detect_os_arch(), ("freebsd", "arm64"))
57
+
58
+
59
+ class TestInstallToolFreeBSD(unittest.TestCase):
60
+ """Tests for install_tool on FreeBSD."""
61
+
62
+ def _make_tool_config(self, **overrides):
63
+ base = {
64
+ "name": "Test Tool",
65
+ "command": "test-tool",
66
+ "install_type": "npm",
67
+ "npm_package": "test-tool",
68
+ "next_steps": "Run test-tool",
69
+ "docs_url": "https://example.com",
70
+ }
71
+ base.update(overrides)
72
+ return base
73
+
74
+ @mock.patch.object(cli_install.platform, "system", return_value="FreeBSD")
75
+ @mock.patch.object(cli_install, "command_exists", return_value=False)
76
+ def test_freebsd_no_port_returns_false(self, mock_cmd, mock_sys):
77
+ tool_config = self._make_tool_config()
78
+ with mock.patch.dict(cli_install.TOOLS, {"test": tool_config}, clear=True):
79
+ result = cli_install.install_tool("test")
80
+ self.assertFalse(result)
81
+
82
+ @mock.patch.object(cli_install.platform, "system", return_value="FreeBSD")
83
+ @mock.patch.object(cli_install, "command_exists", return_value=False)
84
+ def test_freebsd_with_port_dryrun(self, mock_cmd, mock_sys):
85
+ tool_config = self._make_tool_config(freebsd_port="test-tool-port")
86
+ with mock.patch.dict(cli_install.TOOLS, {"test": tool_config}, clear=True):
87
+ result = cli_install.install_tool("test", dryrun=True)
88
+ self.assertTrue(result)
89
+
90
+ @mock.patch.object(cli_install.platform, "system", return_value="FreeBSD")
91
+ @mock.patch.object(cli_install, "command_exists", return_value=False)
92
+ @mock.patch.object(cli_install, "run_command")
93
+ def test_freebsd_with_port_installs_via_pkg(self, mock_run, mock_cmd, mock_sys):
94
+ tool_config = self._make_tool_config(freebsd_port="test-tool-port")
95
+ with mock.patch.dict(cli_install.TOOLS, {"test": tool_config}, clear=True):
96
+ result = cli_install.install_tool("test")
97
+ self.assertTrue(result)
98
+ mock_run.assert_called_once_with(
99
+ ["sudo", "pkg", "install", "-y", "test-tool-port"], check=True
100
+ )
101
+
48
102
 
49
103
  class TestExtractTarMember(unittest.TestCase):
50
104
  """Tests for extract_tar_member."""
@@ -44,6 +44,15 @@ class TestInstallMethodParsing(unittest.TestCase):
44
44
  def test_install_method_from_type_returns_matching_method(self):
45
45
  self.assertEqual(install_method_from_type(InstallType.NPM), InstallMethod.NPM)
46
46
 
47
+ def test_parse_pkg_install_type(self):
48
+ self.assertEqual(parse_install_type("pkg"), InstallType.PKG)
49
+
50
+ def test_parse_pkg_install_method(self):
51
+ self.assertEqual(parse_install_method("pkg"), InstallMethod.PKG)
52
+
53
+ def test_install_method_from_pkg_type(self):
54
+ self.assertEqual(install_method_from_type(InstallType.PKG), InstallMethod.PKG)
55
+
47
56
 
48
57
  class TestDetectionTypedMethods(unittest.TestCase):
49
58
  """Tests for enum-returning detect_install_method behavior."""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes