code-aide 1.10.1__tar.gz → 1.11.1__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.1}/PKG-INFO +2 -1
  2. {code_aide-1.10.1 → code_aide-1.11.1}/pyproject.toml +1 -0
  3. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/__init__.py +1 -1
  4. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/commands_tools.py +30 -0
  5. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/constants.py +7 -0
  6. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/data/tools.json +12 -4
  7. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/detection.py +83 -0
  8. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/install.py +32 -1
  9. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/install_types.py +3 -0
  10. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/operations.py +25 -0
  11. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/prereqs.py +7 -1
  12. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/status.py +49 -0
  13. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_detection.py +150 -0
  14. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_install.py +70 -0
  15. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_install_types.py +9 -0
  16. {code_aide-1.10.1 → code_aide-1.11.1}/.github/workflows/ci.yml +0 -0
  17. {code_aide-1.10.1 → code_aide-1.11.1}/.github/workflows/publish.yml +0 -0
  18. {code_aide-1.10.1 → code_aide-1.11.1}/.gitignore +0 -0
  19. {code_aide-1.10.1 → code_aide-1.11.1}/.gitlab-ci.yml +0 -0
  20. {code_aide-1.10.1 → code_aide-1.11.1}/.pre-commit-config.yaml +0 -0
  21. {code_aide-1.10.1 → code_aide-1.11.1}/AGENTS.md +0 -0
  22. {code_aide-1.10.1 → code_aide-1.11.1}/CLAUDE.md +0 -0
  23. {code_aide-1.10.1 → code_aide-1.11.1}/LICENSE +0 -0
  24. {code_aide-1.10.1 → code_aide-1.11.1}/README.md +0 -0
  25. {code_aide-1.10.1 → code_aide-1.11.1}/TODO.md +0 -0
  26. {code_aide-1.10.1 → code_aide-1.11.1}/script-archive/README.md +0 -0
  27. {code_aide-1.10.1 → code_aide-1.11.1}/script-archive/amp-install.sh +0 -0
  28. {code_aide-1.10.1 → code_aide-1.11.1}/script-archive/claude-install.sh +0 -0
  29. {code_aide-1.10.1 → code_aide-1.11.1}/script-archive/cursor-install.sh +0 -0
  30. {code_aide-1.10.1 → code_aide-1.11.1}/specs/auto-migrate-deprecated-installs.md +0 -0
  31. {code_aide-1.10.1 → code_aide-1.11.1}/specs/claude-native-installer-migration.md +0 -0
  32. {code_aide-1.10.1 → code_aide-1.11.1}/specs/missing-coding-llm-cli-tools.md +0 -0
  33. {code_aide-1.10.1 → code_aide-1.11.1}/specs/pre-commit-uv-setup.md +0 -0
  34. {code_aide-1.10.1 → code_aide-1.11.1}/specs/remove-bundled-version-baseline.md +0 -0
  35. {code_aide-1.10.1 → code_aide-1.11.1}/specs/unify-upgrade-eligibility-with-shared-evaluator.md +0 -0
  36. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/__main__.py +0 -0
  37. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/commands_actions.py +0 -0
  38. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/config.py +0 -0
  39. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/console.py +0 -0
  40. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/entry.py +0 -0
  41. {code_aide-1.10.1 → code_aide-1.11.1}/src/code_aide/versions.py +0 -0
  42. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_commands_actions.py +0 -0
  43. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_commands_tools.py +0 -0
  44. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_config.py +0 -0
  45. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_console.py +0 -0
  46. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_constants.py +0 -0
  47. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_operations.py +0 -0
  48. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_status.py +0 -0
  49. {code_aide-1.10.1 → code_aide-1.11.1}/tests/test_versions.py +0 -0
  50. {code_aide-1.10.1 → code_aide-1.11.1}/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.1
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.1"
@@ -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,30 @@ 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
+ repo=tool_config.get("freebsd_pkg_repo"),
261
+ )
262
+ elif assessment.latest_version:
263
+ if assessment.version_state == VersionDisplayState.UP_TO_DATE:
264
+ print(
265
+ f" Version: {status['version']} "
266
+ f"{Colors.GREEN}(up to date){Colors.NC}"
267
+ )
268
+ else:
269
+ print(
270
+ _generic_version_annotation(
271
+ status["version"], assessment.latest_version
272
+ )
273
+ )
274
+ else:
275
+ print(f" Version: {status['version']}")
246
276
  elif assessment.install_method in (
247
277
  InstallMethod.BREW_FORMULA,
248
278
  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,9 @@
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",
41
+ "freebsd_pkg_repo": "FreeBSD-latest"
40
42
  },
41
43
  "gemini": {
42
44
  "name": "Gemini CLI",
@@ -52,7 +54,9 @@
52
54
  "--version"
53
55
  ],
54
56
  "docs_url": "https://github.com/google-gemini/gemini-cli",
55
- "default_install": true
57
+ "default_install": true,
58
+ "freebsd_port": "gemini-cli",
59
+ "freebsd_pkg_repo": "FreeBSD-latest"
56
60
  },
57
61
  "amp": {
58
62
  "name": "Amp (Sourcegraph)",
@@ -84,7 +88,9 @@
84
88
  "--version"
85
89
  ],
86
90
  "docs_url": "https://github.com/openai/codex",
87
- "default_install": false
91
+ "default_install": false,
92
+ "freebsd_port": "codex",
93
+ "freebsd_pkg_repo": "FreeBSD-latest"
88
94
  },
89
95
  "copilot": {
90
96
  "name": "Copilot CLI",
@@ -100,7 +106,9 @@
100
106
  "--version"
101
107
  ],
102
108
  "docs_url": "https://github.com/github/copilot-cli",
103
- "default_install": false
109
+ "default_install": false,
110
+ "freebsd_port": "github-copilot-cli",
111
+ "freebsd_pkg_repo": "FreeBSD-latest"
104
112
  },
105
113
  "opencode": {
106
114
  "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,59 @@ def get_brew_package_info(
263
290
  return result
264
291
 
265
292
 
293
+ def get_pkg_package_info(
294
+ package_name: str, repo: Optional[str] = None
295
+ ) -> PackageVersionInfo:
296
+ """Get package version info for a FreeBSD pkg-installed tool."""
297
+ result: PackageVersionInfo = {
298
+ "package": package_name,
299
+ "installed_version": None,
300
+ "available_version": None,
301
+ "available_date": None,
302
+ "outdated": None,
303
+ }
304
+
305
+ if not command_exists("pkg"):
306
+ return result
307
+
308
+ try:
309
+ proc = subprocess.run(
310
+ ["pkg", "query", "%v", package_name],
311
+ capture_output=True,
312
+ text=True,
313
+ timeout=5,
314
+ check=False,
315
+ stdin=subprocess.DEVNULL,
316
+ )
317
+ if proc.returncode == 0 and proc.stdout.strip():
318
+ result["installed_version"] = proc.stdout.strip().split("\n")[0]
319
+ except Exception:
320
+ pass
321
+
322
+ try:
323
+ rquery_cmd = ["pkg", "rquery"]
324
+ if repo:
325
+ rquery_cmd.extend(["-r", repo])
326
+ rquery_cmd.extend(["%v", package_name])
327
+ proc = subprocess.run(
328
+ rquery_cmd,
329
+ capture_output=True,
330
+ text=True,
331
+ timeout=10,
332
+ check=False,
333
+ stdin=subprocess.DEVNULL,
334
+ )
335
+ if proc.returncode == 0 and proc.stdout.strip():
336
+ result["available_version"] = proc.stdout.strip().split("\n")[0]
337
+ except Exception:
338
+ pass
339
+
340
+ if result["installed_version"] and result["available_version"]:
341
+ result["outdated"] = result["installed_version"] != result["available_version"]
342
+
343
+ return result
344
+
345
+
266
346
  def format_install_method(method: InstallMethodInput, detail: Optional[str]) -> str:
267
347
  """Format detected local install method for display."""
268
348
  install_method = parse_install_method(method)
@@ -279,6 +359,8 @@ def format_install_method(method: InstallMethodInput, detail: Optional[str]) ->
279
359
  if detail
280
360
  else "Homebrew prefix npm-global"
281
361
  )
362
+ if install_method == InstallMethod.PKG:
363
+ return f"FreeBSD pkg ({detail})" if detail else "FreeBSD pkg"
282
364
  if install_method == InstallMethod.SYSTEM:
283
365
  return f"system package ({detail})" if detail else "system package"
284
366
  if install_method == InstallMethod.SCRIPT or install_type == InstallType.SCRIPT:
@@ -299,6 +381,7 @@ _USER_MANAGED_METHODS = frozenset(
299
381
  InstallMethod.BREW_FORMULA,
300
382
  InstallMethod.BREW_CASK,
301
383
  InstallMethod.SYSTEM,
384
+ InstallMethod.PKG,
302
385
  }
303
386
  )
304
387
 
@@ -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,37 @@ 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
+ pkg_repo = tool_config.get("freebsd_pkg_repo")
264
+ if dryrun:
265
+ repo_flag = f" -r {pkg_repo}" if pkg_repo else ""
266
+ info(
267
+ f"[DRYRUN] Would install FreeBSD port: "
268
+ f"pkg install{repo_flag} {freebsd_port}"
269
+ )
270
+ return True
271
+ try:
272
+ cmd = ["sudo", "pkg", "install", "-y"]
273
+ if pkg_repo:
274
+ cmd.extend(["-r", pkg_repo])
275
+ cmd.append(freebsd_port)
276
+ run_command(cmd, check=True)
277
+ success(f"{tool_config['name']} installed successfully via FreeBSD pkg")
278
+ info(tool_config["next_steps"])
279
+ if "docs_url" in tool_config:
280
+ info(f"Documentation: {tool_config['docs_url']}")
281
+ return True
282
+ except subprocess.CalledProcessError as exc:
283
+ error(f"Failed to install {tool_config['name']}: {exc.stderr}")
284
+ return False
285
+ except Exception as exc:
286
+ error(f"Failed to install {tool_config['name']}: {exc}")
287
+ return False
288
+
258
289
  try:
259
290
  install_type = get_tool_install_type(tool_config)
260
291
 
@@ -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,18 @@ 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
+ cmd = ["sudo", "pkg", "upgrade", "-y"]
199
+ pkg_repo = tool_config.get("freebsd_pkg_repo")
200
+ if pkg_repo:
201
+ cmd.extend(["-r", pkg_repo])
202
+ cmd.append(pkg_name)
203
+ run_command(cmd, check=True, capture=False)
204
+
193
205
  elif method == InstallMethod.SYSTEM:
194
206
  error(
195
207
  f"{tool_config['name']} is managed by the system package manager. "
@@ -327,6 +339,19 @@ def remove_tool(tool_name: str) -> bool:
327
339
 
328
340
  success(f"{tool_config['name']} removed successfully")
329
341
 
342
+ elif method == InstallMethod.PKG:
343
+ pkg_name = detail or tool_config.get("freebsd_port")
344
+ if not pkg_name:
345
+ error(f"No FreeBSD port configured for {tool_config['name']}")
346
+ return False
347
+ cmd = ["sudo", "pkg", "delete", "-y"]
348
+ pkg_repo = tool_config.get("freebsd_pkg_repo")
349
+ if pkg_repo:
350
+ cmd.extend(["-r", pkg_repo])
351
+ cmd.append(pkg_name)
352
+ run_command(cmd, check=True, capture=False)
353
+ success(f"{tool_config['name']} removed successfully")
354
+
330
355
  elif method == InstallMethod.SYSTEM:
331
356
  error(
332
357
  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,14 @@ 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
+ pkg_repo = self.tool_config.get("freebsd_pkg_repo")
255
+ self._package_info = (
256
+ get_pkg_package_info(pkg_name, repo=pkg_repo) if pkg_name else None
257
+ )
246
258
  elif method == InstallMethod.SYSTEM:
247
259
  tool_path = self._tool_path or shutil.which(self.tool_config["command"])
248
260
  self._package_info = (
@@ -383,6 +395,43 @@ def print_brew_version_status(
383
395
  print(f" Packaged: {avail_ver} ({pkg_name})")
384
396
 
385
397
 
398
+ def print_pkg_version_status(
399
+ cli_version: str,
400
+ latest_version: Optional[str],
401
+ pkg_info: PackageInfo,
402
+ repo: Optional[str] = None,
403
+ ) -> None:
404
+ """Print version status for a FreeBSD pkg-managed tool."""
405
+ avail_ver = pkg_info.get("available_version")
406
+ outdated = pkg_info.get("outdated")
407
+
408
+ if outdated:
409
+ print(
410
+ f" Version: {cli_version} {Colors.YELLOW}(pkg has {avail_ver})"
411
+ f"{Colors.NC}"
412
+ )
413
+ else:
414
+ print(f" Version: {cli_version} {Colors.GREEN}(up to date){Colors.NC}")
415
+
416
+ if avail_ver:
417
+ pkg_name = pkg_info.get("package") or "FreeBSD pkg"
418
+ repo_suffix = f", {repo}" if repo else ""
419
+ show_upstream = (
420
+ latest_version
421
+ and not status_version_matches_latest(avail_ver, latest_version)
422
+ and version_is_newer(
423
+ normalize_version(latest_version), normalize_version(avail_ver)
424
+ )
425
+ )
426
+ if show_upstream:
427
+ print(
428
+ f" Packaged: {avail_ver} ({pkg_name}{repo_suffix}) "
429
+ f"{Colors.YELLOW}(upstream: {latest_version}){Colors.NC}"
430
+ )
431
+ else:
432
+ print(f" Packaged: {avail_ver} ({pkg_name}{repo_suffix})")
433
+
434
+
386
435
  def get_tool_status(tool_name: str, tool_config: Dict[str, Any]) -> ToolStatus:
387
436
  """Get status information for a specific tool."""
388
437
  status_info = {
@@ -92,6 +92,156 @@ 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
+
221
+ @mock.patch.object(cli_detection, "command_exists", return_value=True)
222
+ @mock.patch.object(cli_detection.subprocess, "run")
223
+ def test_repo_passes_r_flag_to_rquery(self, mock_run, mock_cmd):
224
+ calls = []
225
+
226
+ def side_effect(cmd, **kwargs):
227
+ calls.append(list(cmd))
228
+ result = mock.Mock()
229
+ result.returncode = 0
230
+ result.stdout = "2.1.63\n"
231
+ return result
232
+
233
+ mock_run.side_effect = side_effect
234
+
235
+ cli_detection.get_pkg_package_info("claude-code", repo="FreeBSD-latest")
236
+
237
+ # pkg query (local) should NOT have -r
238
+ self.assertEqual(calls[0], ["pkg", "query", "%v", "claude-code"])
239
+ # pkg rquery (remote) should have -r
240
+ self.assertEqual(
241
+ calls[1],
242
+ ["pkg", "rquery", "-r", "FreeBSD-latest", "%v", "claude-code"],
243
+ )
244
+
95
245
 
96
246
  class TestFormatInstallMethodSystem(unittest.TestCase):
97
247
  """Tests for format_install_method with system method."""
@@ -45,6 +45,76 @@ 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
+
102
+ @mock.patch.object(cli_install.platform, "system", return_value="FreeBSD")
103
+ @mock.patch.object(cli_install, "command_exists", return_value=False)
104
+ @mock.patch.object(cli_install, "run_command")
105
+ def test_freebsd_with_repo_passes_r_flag(self, mock_run, mock_cmd, mock_sys):
106
+ tool_config = self._make_tool_config(
107
+ freebsd_port="test-tool-port",
108
+ freebsd_pkg_repo="FreeBSD-latest",
109
+ )
110
+ with mock.patch.dict(cli_install.TOOLS, {"test": tool_config}, clear=True):
111
+ result = cli_install.install_tool("test")
112
+ self.assertTrue(result)
113
+ mock_run.assert_called_once_with(
114
+ ["sudo", "pkg", "install", "-y", "-r", "FreeBSD-latest", "test-tool-port"],
115
+ check=True,
116
+ )
117
+
48
118
 
49
119
  class TestExtractTarMember(unittest.TestCase):
50
120
  """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