code-aide 1.7.0__tar.gz → 1.7.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 (43) hide show
  1. {code_aide-1.7.0 → code_aide-1.7.1}/PKG-INFO +1 -1
  2. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/__init__.py +1 -1
  3. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/commands_actions.py +34 -9
  4. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/commands_tools.py +44 -1
  5. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/detection.py +67 -6
  6. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/install.py +14 -2
  7. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/operations.py +90 -23
  8. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/status.py +30 -0
  9. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_commands_actions.py +78 -0
  10. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_commands_tools.py +51 -0
  11. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_detection.py +13 -0
  12. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_install.py +57 -0
  13. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_operations.py +71 -9
  14. {code_aide-1.7.0 → code_aide-1.7.1}/.github/workflows/ci.yml +0 -0
  15. {code_aide-1.7.0 → code_aide-1.7.1}/.github/workflows/publish.yml +0 -0
  16. {code_aide-1.7.0 → code_aide-1.7.1}/.gitignore +0 -0
  17. {code_aide-1.7.0 → code_aide-1.7.1}/.gitlab-ci.yml +0 -0
  18. {code_aide-1.7.0 → code_aide-1.7.1}/.pre-commit-config.yaml +0 -0
  19. {code_aide-1.7.0 → code_aide-1.7.1}/AGENTS.md +0 -0
  20. {code_aide-1.7.0 → code_aide-1.7.1}/CLAUDE.md +0 -0
  21. {code_aide-1.7.0 → code_aide-1.7.1}/LICENSE +0 -0
  22. {code_aide-1.7.0 → code_aide-1.7.1}/README.md +0 -0
  23. {code_aide-1.7.0 → code_aide-1.7.1}/TODO.md +0 -0
  24. {code_aide-1.7.0 → code_aide-1.7.1}/pyproject.toml +0 -0
  25. {code_aide-1.7.0 → code_aide-1.7.1}/specs/auto-migrate-deprecated-installs.md +0 -0
  26. {code_aide-1.7.0 → code_aide-1.7.1}/specs/claude-native-installer-migration.md +0 -0
  27. {code_aide-1.7.0 → code_aide-1.7.1}/specs/missing-coding-llm-cli-tools.md +0 -0
  28. {code_aide-1.7.0 → code_aide-1.7.1}/specs/pre-commit-uv-setup.md +0 -0
  29. {code_aide-1.7.0 → code_aide-1.7.1}/specs/remove-bundled-version-baseline.md +0 -0
  30. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/__main__.py +0 -0
  31. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/config.py +0 -0
  32. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/console.py +0 -0
  33. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/constants.py +0 -0
  34. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/data/tools.json +0 -0
  35. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/entry.py +0 -0
  36. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/prereqs.py +0 -0
  37. {code_aide-1.7.0 → code_aide-1.7.1}/src/code_aide/versions.py +0 -0
  38. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_config.py +0 -0
  39. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_console.py +0 -0
  40. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_constants.py +0 -0
  41. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_status.py +0 -0
  42. {code_aide-1.7.0 → code_aide-1.7.1}/tests/test_versions.py +0 -0
  43. {code_aide-1.7.0 → code_aide-1.7.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-aide
3
- Version: 1.7.0
3
+ Version: 1.7.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
@@ -1,3 +1,3 @@
1
1
  """code-aide - Manage AI coding CLI tools."""
2
2
 
3
- __version__ = "1.7.0"
3
+ __version__ = "1.7.1"
@@ -5,10 +5,19 @@ import sys
5
5
  from typing import Any, Dict, List
6
6
 
7
7
  from code_aide.constants import TOOLS
8
- from code_aide.detection import is_deprecated_install
8
+ from code_aide.detection import (
9
+ detect_install_method,
10
+ get_brew_package_info,
11
+ is_deprecated_install,
12
+ )
9
13
  from code_aide.install import install_tool
10
14
  from code_aide.console import error, info, success, warning
11
- from code_aide.operations import remove_tool, upgrade_tool, validate_tools
15
+ from code_aide.operations import (
16
+ UpgradeResult,
17
+ remove_tool,
18
+ upgrade_tool,
19
+ validate_tools,
20
+ )
12
21
  from code_aide.prereqs import (
13
22
  check_path_directories,
14
23
  check_prerequisites,
@@ -131,6 +140,13 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
131
140
  latest = config.get("latest_version")
132
141
  if not latest:
133
142
  continue
143
+ install_info = detect_install_method(name)
144
+ if install_info["method"] in ("brew_formula", "brew_cask"):
145
+ pkg_info = get_brew_package_info(
146
+ install_info["method"], install_info["detail"]
147
+ )
148
+ if pkg_info.get("outdated") is False:
149
+ continue
134
150
  status = get_tool_status(name, config)
135
151
  if status["version"] and status_version_matches_latest(
136
152
  status["version"], latest
@@ -145,7 +161,8 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
145
161
 
146
162
  validate_tools(tools_to_upgrade)
147
163
 
148
- upgraded = []
164
+ updated = []
165
+ unchanged = []
149
166
  failed = []
150
167
  skipped = []
151
168
 
@@ -157,8 +174,11 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
157
174
  skipped.append(tool)
158
175
  continue
159
176
 
160
- if upgrade_tool(tool):
161
- upgraded.append(tool)
177
+ result = upgrade_tool(tool)
178
+ if result == UpgradeResult.CHANGED:
179
+ updated.append(tool)
180
+ elif result == UpgradeResult.UNCHANGED:
181
+ unchanged.append(tool)
162
182
  else:
163
183
  failed.append(tool)
164
184
 
@@ -167,8 +187,11 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
167
187
  info("Upgrade Summary")
168
188
  print("=" * 42)
169
189
 
170
- if upgraded:
171
- success(f"Successfully upgraded: {', '.join(upgraded)}")
190
+ if updated:
191
+ success(f"Successfully updated: {', '.join(updated)}")
192
+
193
+ if unchanged:
194
+ info(f"No package-manager change: {', '.join(unchanged)}")
172
195
 
173
196
  if skipped:
174
197
  warning(f"Skipped (not installed): {', '.join(skipped)}")
@@ -177,9 +200,11 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
177
200
  error(f"Failed to upgrade: {', '.join(failed)}")
178
201
  sys.exit(1)
179
202
 
180
- if upgraded:
203
+ if updated:
181
204
  success("All upgrades completed successfully!")
182
- elif skipped and not upgraded:
205
+ elif unchanged and not updated and not failed:
206
+ info("No tools changed version during the upgrade attempt")
207
+ elif skipped and not updated:
183
208
  info(
184
209
  "No tools were upgraded (all were either not installed or already up to date)"
185
210
  )
@@ -9,12 +9,17 @@ from code_aide.constants import Colors, PACKAGE_MANAGERS, TOOLS
9
9
  from code_aide.detection import (
10
10
  format_install_method,
11
11
  format_migration_warning,
12
+ get_brew_package_info,
12
13
  get_system_package_info,
13
14
  detect_install_method,
14
15
  )
15
16
  from code_aide.console import command_exists, info, warning
16
17
  from code_aide.prereqs import detect_package_manager, is_tool_installed
17
- from code_aide.status import print_system_version_status, get_tool_status
18
+ from code_aide.status import (
19
+ get_tool_status,
20
+ print_brew_version_status,
21
+ print_system_version_status,
22
+ )
18
23
  from code_aide.versions import (
19
24
  extract_version_from_string,
20
25
  status_version_matches_latest,
@@ -128,6 +133,44 @@ def cmd_status(args: argparse.Namespace) -> None:
128
133
  print_system_version_status(
129
134
  status["version"], latest_version, pkg_info
130
135
  )
136
+ elif install_info["method"] in ("brew_formula", "brew_cask"):
137
+ pkg_info = get_brew_package_info(
138
+ install_info["method"], install_info["detail"]
139
+ )
140
+ if pkg_info.get("available_version"):
141
+ print_brew_version_status(
142
+ status["version"], latest_version, pkg_info
143
+ )
144
+ if pkg_info.get("outdated"):
145
+ outdated_count += 1
146
+ elif latest_version:
147
+ if status_version_matches_latest(
148
+ status["version"], latest_version
149
+ ):
150
+ version_annotation = (
151
+ f" Version: {status['version']} "
152
+ f"{Colors.GREEN}(up to date){Colors.NC}"
153
+ )
154
+ else:
155
+ installed_ver = extract_version_from_string(
156
+ status["version"]
157
+ )
158
+ if installed_ver and version_is_newer(
159
+ installed_ver, latest_version
160
+ ):
161
+ version_annotation = (
162
+ f" Version: {status['version']} "
163
+ f"{Colors.YELLOW}(newer than configured "
164
+ f"{latest_version}){Colors.NC}"
165
+ )
166
+ config_outdated.append(tool_name)
167
+ else:
168
+ version_annotation = (
169
+ f" Version: {status['version']} "
170
+ f"{Colors.YELLOW}(latest: {latest_version}){Colors.NC}"
171
+ )
172
+ outdated_count += 1
173
+ print(version_annotation)
131
174
  elif latest_version:
132
175
  if status_version_matches_latest(status["version"], latest_version):
133
176
  version_annotation = (
@@ -1,6 +1,7 @@
1
1
  """Install-method and system package detection helpers."""
2
2
 
3
3
  import glob as globmod
4
+ import json
4
5
  import os
5
6
  import re
6
7
  import shutil
@@ -41,12 +42,6 @@ def detect_install_method(tool_name: str) -> Dict[str, Optional[str]]:
41
42
  match = re.search(r"/node_modules/((?:@[^/]+/)?[^/]+)", real_path)
42
43
  if match:
43
44
  npm_package = match.group(1)
44
-
45
- if command_path.startswith("/opt/homebrew/bin/") or command_path.startswith(
46
- "/usr/local/bin/"
47
- ):
48
- return {"method": "brew_npm", "detail": npm_package}
49
-
50
45
  return {"method": "npm", "detail": npm_package}
51
46
 
52
47
  system_prefixes = ("/opt/", "/usr/bin/", "/usr/sbin/", "/usr/local/bin/")
@@ -163,6 +158,72 @@ def get_system_package_info(binary_path: str) -> Dict[str, Optional[str]]:
163
158
  return result
164
159
 
165
160
 
161
+ def get_brew_package_info(
162
+ method: str, package_name: Optional[str]
163
+ ) -> Dict[str, Optional[str]]:
164
+ """Get package version info for a Homebrew-managed tool."""
165
+ result: Dict[str, Optional[str]] = {
166
+ "package": package_name,
167
+ "installed_version": None,
168
+ "available_version": None,
169
+ "available_date": None,
170
+ "outdated": None,
171
+ }
172
+
173
+ if method not in ("brew_formula", "brew_cask") or not package_name:
174
+ return result
175
+
176
+ if not command_exists("brew"):
177
+ return result
178
+
179
+ command = ["brew", "info", "--json=v2"]
180
+ if method == "brew_cask":
181
+ command.append("--cask")
182
+ command.append(package_name)
183
+
184
+ try:
185
+ proc = subprocess.run(
186
+ command,
187
+ capture_output=True,
188
+ text=True,
189
+ timeout=10,
190
+ check=False,
191
+ stdin=subprocess.DEVNULL,
192
+ )
193
+ if proc.returncode != 0 or not proc.stdout.strip():
194
+ return result
195
+
196
+ payload = json.loads(proc.stdout)
197
+ if method == "brew_formula":
198
+ formulae = payload.get("formulae", [])
199
+ if not formulae:
200
+ return result
201
+ formula = formulae[0]
202
+ installed = formula.get("installed", [])
203
+ if installed:
204
+ result["installed_version"] = installed[-1].get("version")
205
+ linked_keg = formula.get("linked_keg")
206
+ if linked_keg:
207
+ result["installed_version"] = linked_keg
208
+ result["available_version"] = formula.get("versions", {}).get("stable")
209
+ result["outdated"] = bool(formula.get("outdated"))
210
+ else:
211
+ casks = payload.get("casks", [])
212
+ if not casks:
213
+ return result
214
+ cask = casks[0]
215
+ installed_version = cask.get("installed")
216
+ if isinstance(installed_version, list):
217
+ installed_version = installed_version[0] if installed_version else None
218
+ result["installed_version"] = installed_version
219
+ result["available_version"] = cask.get("version")
220
+ result["outdated"] = bool(cask.get("outdated"))
221
+ except Exception:
222
+ return result
223
+
224
+ return result
225
+
226
+
166
227
  def format_install_method(method: Optional[str], detail: Optional[str]) -> str:
167
228
  """Format detected local install method for display."""
168
229
  if method == "brew_formula":
@@ -234,7 +234,7 @@ def install_direct_download(
234
234
  return False
235
235
 
236
236
 
237
- def install_tool(tool_name: str, dryrun: bool = False) -> bool:
237
+ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> bool:
238
238
  """Install a tool based on its configuration."""
239
239
  tool_config = TOOLS.get(tool_name)
240
240
  if not tool_config:
@@ -246,13 +246,25 @@ def install_tool(tool_name: str, dryrun: bool = False) -> bool:
246
246
  else:
247
247
  info(f"Installing {tool_config['name']}...")
248
248
 
249
- if command_exists(tool_config["command"]):
249
+ if command_exists(tool_config["command"]) and not force:
250
250
  tool_path = shutil.which(tool_config["command"])
251
251
  if dryrun:
252
252
  info(f"{tool_config['command']} already installed at {tool_path}")
253
253
  else:
254
254
  warning(f"{tool_config['command']} already installed at {tool_path}")
255
255
  return True
256
+ if command_exists(tool_config["command"]) and force:
257
+ tool_path = shutil.which(tool_config["command"])
258
+ if dryrun:
259
+ info(
260
+ f"[DRYRUN] Would reinstall {tool_config['command']} despite existing "
261
+ f"binary at {tool_path}"
262
+ )
263
+ else:
264
+ info(
265
+ f"Reinstalling {tool_config['command']} despite existing binary at "
266
+ f"{tool_path}"
267
+ )
256
268
 
257
269
  try:
258
270
  install_type = tool_config["install_type"]
@@ -5,7 +5,8 @@ import os
5
5
  import shutil
6
6
  import subprocess
7
7
  import sys
8
- from typing import List
8
+ from enum import Enum
9
+ from typing import Dict, List
9
10
 
10
11
  from code_aide.constants import TOOLS
11
12
  from code_aide.detection import (
@@ -16,10 +17,60 @@ from code_aide.detection import (
16
17
  from code_aide.install import install_direct_download, install_tool, run_install_script
17
18
  from code_aide.console import error, info, run_command, success, warning
18
19
  from code_aide.prereqs import is_tool_installed
20
+ from code_aide.status import get_tool_status
19
21
 
20
22
 
21
- def _migrate_install_method(tool_name: str) -> bool:
22
- """Migrate a tool from a deprecated install method to the configured one."""
23
+ class UpgradeResult(Enum):
24
+ """Possible outcomes from `upgrade_tool()`.
25
+
26
+ Values:
27
+ - `CHANGED`: The upgrade or migration changed the detected install state.
28
+ - `UNCHANGED`: The upgrade command ran, but the detected install state did
29
+ not change.
30
+ - `FAILED`: The upgrade or migration failed.
31
+ """
32
+
33
+ CHANGED = "changed"
34
+ UNCHANGED = "unchanged"
35
+ FAILED = "failed"
36
+
37
+
38
+ def _get_upgrade_snapshot(
39
+ tool_name: str, tool_config: Dict[str, str]
40
+ ) -> Dict[str, str]:
41
+ """Capture install method and version before/after a change."""
42
+ install_info = detect_install_method(tool_name)
43
+ status = get_tool_status(tool_name, tool_config)
44
+ return {
45
+ "method": install_info["method"],
46
+ "detail": install_info["detail"],
47
+ "version": status.get("version"),
48
+ }
49
+
50
+
51
+ def _upgrade_result_from_snapshots(
52
+ tool_config: Dict[str, str], before: Dict[str, str], after: Dict[str, str]
53
+ ) -> UpgradeResult:
54
+ """Classify whether an upgrade actually changed the installed tool."""
55
+ if before == after:
56
+ version = after.get("version") or "unknown"
57
+ info(
58
+ f"{tool_config['name']} did not change after the upgrade attempt "
59
+ f"(current version: {version})"
60
+ )
61
+ return UpgradeResult.UNCHANGED
62
+ success(f"{tool_config['name']} upgraded successfully")
63
+ return UpgradeResult.CHANGED
64
+
65
+
66
+ def _migrate_install_method(tool_name: str) -> UpgradeResult:
67
+ """Migrate a tool from a deprecated install method to the configured one.
68
+
69
+ Returns:
70
+ - `UpgradeResult.CHANGED` when the tool is successfully migrated.
71
+ - `UpgradeResult.FAILED` when removal, reinstall, or post-check verification
72
+ fails.
73
+ """
23
74
  tool_config = TOOLS[tool_name]
24
75
  install_info = detect_install_method(tool_name)
25
76
  old_label = format_install_method(install_info["method"], install_info["detail"])
@@ -36,30 +87,47 @@ def _migrate_install_method(tool_name: str) -> bool:
36
87
  f"Failed to remove old {old_label} install of {tool_config['name']}. "
37
88
  "Migration aborted."
38
89
  )
39
- return False
90
+ return UpgradeResult.FAILED
40
91
 
41
- if not install_tool(tool_name):
92
+ if not install_tool(tool_name, force=True):
42
93
  error(f"Failed to install {tool_config['name']} via {new_label}.")
43
94
  error(
44
95
  f"The old {old_label} install has been removed. "
45
96
  f"To recover, run: code-aide install {tool_name}"
46
97
  )
47
- return False
98
+ return UpgradeResult.FAILED
99
+
100
+ after = detect_install_method(tool_name)
101
+ if after["method"] != tool_config["install_type"]:
102
+ detected_label = format_install_method(after["method"], after["detail"])
103
+ error(
104
+ f"Migration did not complete: {tool_config['name']} is still detected as "
105
+ f"{detected_label}."
106
+ )
107
+ return UpgradeResult.FAILED
48
108
 
49
109
  success(f"{tool_config['name']} migrated from {old_label} to {new_label}")
50
- return True
110
+ return UpgradeResult.CHANGED
111
+
51
112
 
113
+ def upgrade_tool(tool_name: str) -> UpgradeResult:
114
+ """Upgrade a tool based on its configuration.
52
115
 
53
- def upgrade_tool(tool_name: str) -> bool:
54
- """Upgrade a tool based on its configuration."""
116
+ Returns:
117
+ - `UpgradeResult.CHANGED` when the installed tool changed version or install
118
+ method.
119
+ - `UpgradeResult.UNCHANGED` when the upgrade command ran but the detected
120
+ install state did not change.
121
+ - `UpgradeResult.FAILED` when the upgrade could not be completed.
122
+ """
55
123
  tool_config = TOOLS.get(tool_name)
56
124
  if not tool_config:
57
125
  error(f"Unknown tool: {tool_name}")
58
- return False
126
+ return UpgradeResult.FAILED
59
127
 
60
128
  if not is_tool_installed(tool_name):
61
129
  warning(f"{tool_config['name']} is not installed. Use 'install' command first.")
62
- return False
130
+ return UpgradeResult.FAILED
63
131
 
64
132
  if is_deprecated_install(tool_name):
65
133
  return _migrate_install_method(tool_name)
@@ -67,62 +135,61 @@ def upgrade_tool(tool_name: str) -> bool:
67
135
  install_info = detect_install_method(tool_name)
68
136
  method = install_info["method"]
69
137
  detail = install_info["detail"]
138
+ before = _get_upgrade_snapshot(tool_name, tool_config)
70
139
 
71
140
  info(f"Upgrading {tool_config['name']} (installed via {method})...")
72
141
 
73
142
  try:
74
143
  if method == "brew_formula":
75
144
  run_command(["brew", "upgrade", detail], check=True, capture=False)
76
- success(f"{tool_config['name']} upgraded successfully")
77
145
 
78
146
  elif method == "brew_cask":
79
147
  run_command(
80
148
  ["brew", "upgrade", "--cask", detail], check=True, capture=False
81
149
  )
82
- success(f"{tool_config['name']} upgraded successfully")
83
150
 
84
151
  elif method in ("npm", "brew_npm"):
85
152
  npm_package = detail or tool_config.get("npm_package")
86
153
  if not npm_package:
87
154
  error(f"No npm package configured for {tool_config['name']}")
88
- return False
155
+ return UpgradeResult.FAILED
89
156
  run_command(["npm", "install", "-g", f"{npm_package}@latest"], check=True)
90
- success(f"{tool_config['name']} upgraded successfully")
91
157
 
92
158
  elif method == "script":
93
159
  install_url = tool_config["install_url"]
94
160
  expected_sha256 = tool_config.get("install_sha256")
95
161
  if run_install_script(install_url, tool_config["name"], expected_sha256):
96
- success(f"{tool_config['name']} upgraded successfully")
162
+ pass
97
163
  else:
98
- return False
164
+ return UpgradeResult.FAILED
99
165
 
100
166
  elif method == "direct_download":
101
167
  if not install_direct_download(tool_name, tool_config):
102
- return False
168
+ return UpgradeResult.FAILED
103
169
 
104
170
  elif method == "system":
105
171
  error(
106
172
  f"{tool_config['name']} is managed by the system package manager. "
107
173
  "Use your package manager to upgrade it."
108
174
  )
109
- return False
175
+ return UpgradeResult.FAILED
110
176
 
111
177
  else:
112
178
  error(
113
179
  f"Don't know how to upgrade {tool_config['name']} "
114
180
  f"(install method: {method})"
115
181
  )
116
- return False
182
+ return UpgradeResult.FAILED
117
183
 
118
- return True
184
+ after = _get_upgrade_snapshot(tool_name, tool_config)
185
+ return _upgrade_result_from_snapshots(tool_config, before, after)
119
186
 
120
187
  except subprocess.CalledProcessError as exc:
121
188
  error(f"Failed to upgrade {tool_config['name']}: {exc.stderr}")
122
- return False
189
+ return UpgradeResult.FAILED
123
190
  except Exception as exc:
124
191
  error(f"Failed to upgrade {tool_config['name']}: {exc}")
125
- return False
192
+ return UpgradeResult.FAILED
126
193
 
127
194
 
128
195
  def remove_tool(tool_name: str) -> bool:
@@ -51,6 +51,36 @@ def print_system_version_status(
51
51
  print(f" Packaged: {avail_ver} ({pkg_name}{date_suffix})")
52
52
 
53
53
 
54
+ def print_brew_version_status(
55
+ cli_version: str,
56
+ latest_version: Optional[str],
57
+ pkg_info: Dict[str, Optional[str]],
58
+ ) -> None:
59
+ """Print version status for a Homebrew-managed tool."""
60
+ avail_ver = pkg_info.get("available_version")
61
+ outdated = pkg_info.get("outdated")
62
+
63
+ if outdated:
64
+ print(
65
+ f" Version: {cli_version} {Colors.YELLOW}(Homebrew has {avail_ver})"
66
+ f"{Colors.NC}"
67
+ )
68
+ else:
69
+ print(f" Version: {cli_version} {Colors.GREEN}(up to date){Colors.NC}")
70
+
71
+ if avail_ver:
72
+ pkg_name = pkg_info.get("package") or "Homebrew"
73
+ if latest_version and not status_version_matches_latest(
74
+ avail_ver, latest_version
75
+ ):
76
+ print(
77
+ f" Packaged: {avail_ver} ({pkg_name}) "
78
+ f"{Colors.YELLOW}(upstream: {latest_version}){Colors.NC}"
79
+ )
80
+ else:
81
+ print(f" Packaged: {avail_ver} ({pkg_name})")
82
+
83
+
54
84
  def get_tool_status(tool_name: str, tool_config: Dict[str, Any]) -> Dict[str, Any]:
55
85
  """Get status information for a specific tool."""
56
86
  status_info = {
@@ -8,6 +8,7 @@ from unittest import mock
8
8
 
9
9
  from code_aide import commands_actions
10
10
  from code_aide import entry
11
+ from code_aide.operations import UpgradeResult
11
12
 
12
13
 
13
14
  class TestCmdInstall(unittest.TestCase):
@@ -136,3 +137,80 @@ class TestUpgradeNoArgsParsing(unittest.TestCase):
136
137
  (args,) = mock_upgrade.call_args[0]
137
138
  self.assertEqual(args.command, "upgrade")
138
139
  self.assertEqual(args.tools, [])
140
+
141
+
142
+ class TestCmdUpgrade(unittest.TestCase):
143
+ """Tests for cmd_upgrade output handling."""
144
+
145
+ def test_unchanged_upgrades_are_not_reported_as_updated(self):
146
+ tools = {
147
+ "test": {
148
+ "name": "Test Tool",
149
+ "command": "test",
150
+ "latest_version": "2.0.0",
151
+ }
152
+ }
153
+ args = type("Args", (), {"tools": ["test"]})()
154
+
155
+ with (
156
+ mock.patch.dict(commands_actions.TOOLS, tools, clear=True),
157
+ mock.patch.object(commands_actions, "validate_tools"),
158
+ mock.patch.object(commands_actions, "is_tool_installed", return_value=True),
159
+ mock.patch.object(
160
+ commands_actions,
161
+ "upgrade_tool",
162
+ return_value=UpgradeResult.UNCHANGED,
163
+ ),
164
+ ):
165
+ buf = io.StringIO()
166
+ with contextlib.redirect_stdout(buf):
167
+ commands_actions.cmd_upgrade(args)
168
+
169
+ output = buf.getvalue()
170
+ self.assertIn("No package-manager change: test", output)
171
+ self.assertNotIn("Successfully updated: test", output)
172
+
173
+ def test_default_upgrade_skips_brew_tool_when_homebrew_not_outdated(self):
174
+ tools = {
175
+ "brewtool": {
176
+ "name": "Brew Tool",
177
+ "command": "brewtool",
178
+ "latest_version": "9.9.9",
179
+ }
180
+ }
181
+ args = type("Args", (), {"tools": []})()
182
+
183
+ with (
184
+ mock.patch.dict(commands_actions.TOOLS, tools, clear=True),
185
+ mock.patch.object(commands_actions, "validate_tools"),
186
+ mock.patch.object(commands_actions, "is_tool_installed", return_value=True),
187
+ mock.patch.object(
188
+ commands_actions, "is_deprecated_install", return_value=False
189
+ ),
190
+ mock.patch.object(
191
+ commands_actions,
192
+ "detect_install_method",
193
+ return_value={"method": "brew_formula", "detail": "brewtool"},
194
+ ),
195
+ mock.patch.object(
196
+ commands_actions,
197
+ "get_brew_package_info",
198
+ return_value={
199
+ "package": "brewtool",
200
+ "installed_version": "1.0.0",
201
+ "available_version": "1.0.0",
202
+ "available_date": None,
203
+ "outdated": False,
204
+ },
205
+ ),
206
+ mock.patch.object(commands_actions, "get_tool_status") as mock_status,
207
+ mock.patch.object(commands_actions, "upgrade_tool") as mock_upgrade,
208
+ ):
209
+ buf = io.StringIO()
210
+ with contextlib.redirect_stdout(buf):
211
+ commands_actions.cmd_upgrade(args)
212
+
213
+ output = buf.getvalue()
214
+ self.assertIn("All installed tools are up to date", output)
215
+ mock_status.assert_not_called()
216
+ mock_upgrade.assert_not_called()
@@ -80,6 +80,57 @@ class TestCmdStatus(unittest.TestCase):
80
80
  self.assertIn("Example Tool", output)
81
81
  self.assertIn("tool(s) can be upgraded", output)
82
82
 
83
+ def test_brew_current_upstream_lag_does_not_count_as_upgradeable(self):
84
+ tools = {
85
+ "x": {
86
+ "name": "Example Tool",
87
+ "command": "example",
88
+ "install_type": "npm",
89
+ "latest_version": "2.0.0",
90
+ }
91
+ }
92
+ status = {
93
+ "installed": True,
94
+ "version": "1.0.0",
95
+ "user": None,
96
+ "usage": None,
97
+ "errors": [],
98
+ }
99
+ args = type("Args", (), {})()
100
+ with (
101
+ mock.patch.dict(commands_tools.TOOLS, tools, clear=True),
102
+ mock.patch.object(commands_tools, "get_tool_status", return_value=status),
103
+ mock.patch.object(
104
+ commands_tools.shutil, "which", return_value="/opt/homebrew/bin/example"
105
+ ),
106
+ mock.patch.object(
107
+ commands_tools,
108
+ "detect_install_method",
109
+ return_value={"method": "brew_formula", "detail": "example"},
110
+ ),
111
+ mock.patch.object(
112
+ commands_tools,
113
+ "get_brew_package_info",
114
+ return_value={
115
+ "package": "example",
116
+ "installed_version": "1.0.0",
117
+ "available_version": "1.0.0",
118
+ "available_date": None,
119
+ "outdated": False,
120
+ },
121
+ ),
122
+ mock.patch.object(
123
+ commands_tools, "format_migration_warning", return_value=None
124
+ ),
125
+ ):
126
+ buf = io.StringIO()
127
+ with contextlib.redirect_stdout(buf):
128
+ commands_tools.cmd_status(args)
129
+ output = buf.getvalue()
130
+ self.assertIn("Packaged: 1.0.0 (example)", output)
131
+ self.assertIn("upstream: 2.0.0", output)
132
+ self.assertNotIn("tool(s) can be upgraded", output)
133
+
83
134
  def test_status_shows_migration_warning(self):
84
135
  """cmd_status shows migration warning for deprecated install."""
85
136
  tools = {
@@ -57,6 +57,19 @@ class TestDetectInstallMethod(unittest.TestCase):
57
57
  {"method": "npm", "detail": "@google/gemini-cli"},
58
58
  )
59
59
 
60
+ @mock.patch.object(cli_detection.os.path, "realpath")
61
+ @mock.patch.object(cli_detection.shutil, "which")
62
+ def test_detects_npm_under_homebrew_prefix_as_npm(self, mock_which, mock_realpath):
63
+ mock_which.return_value = "/opt/homebrew/bin/copilot"
64
+ mock_realpath.return_value = (
65
+ "/opt/homebrew/lib/node_modules/@github/copilot/bin/copilot.js"
66
+ )
67
+
68
+ self.assertEqual(
69
+ cli_detection.detect_install_method("copilot"),
70
+ {"method": "npm", "detail": "@github/copilot"},
71
+ )
72
+
60
73
  @mock.patch.object(cli_detection.os.path, "realpath")
61
74
  @mock.patch.object(cli_detection.shutil, "which")
62
75
  def test_detects_system_package_opt(self, mock_which, mock_realpath):
@@ -176,3 +176,60 @@ class TestInstallDirectDownload(unittest.TestCase):
176
176
  self.assertTrue(os.path.isdir(os.path.dirname(install_dir)))
177
177
  self.assertTrue(os.path.exists(os.path.join(install_dir, "test-bin")))
178
178
  self.assertTrue(os.path.islink(os.path.join(bin_dir, "test")))
179
+
180
+
181
+ class TestInstallTool(unittest.TestCase):
182
+ """Tests for install_tool behavior."""
183
+
184
+ def test_force_reinstalls_even_when_binary_exists(self):
185
+ tool_config = {
186
+ "name": "Test Tool",
187
+ "command": "test-tool",
188
+ "install_type": "npm",
189
+ "npm_package": "test-tool",
190
+ "next_steps": "Run test-tool",
191
+ }
192
+
193
+ with (
194
+ mock.patch.dict(cli_install.TOOLS, {"test": tool_config}, clear=True),
195
+ mock.patch.object(cli_install, "command_exists", return_value=True),
196
+ mock.patch.object(
197
+ cli_install.shutil, "which", return_value="/usr/local/bin/test-tool"
198
+ ),
199
+ mock.patch.object(cli_install, "run_command") as mock_run,
200
+ ):
201
+ result = cli_install.install_tool("test", force=True)
202
+
203
+ self.assertTrue(result)
204
+ mock_run.assert_called_once_with(
205
+ ["npm", "install", "-g", "test-tool"], check=True
206
+ )
207
+
208
+ def test_force_dryrun_reports_reinstall_when_binary_exists(self):
209
+ tool_config = {
210
+ "name": "Test Tool",
211
+ "command": "test-tool",
212
+ "install_type": "npm",
213
+ "npm_package": "test-tool",
214
+ "next_steps": "Run test-tool",
215
+ }
216
+
217
+ with (
218
+ mock.patch.dict(cli_install.TOOLS, {"test": tool_config}, clear=True),
219
+ mock.patch.object(cli_install, "command_exists", return_value=True),
220
+ mock.patch.object(
221
+ cli_install.shutil, "which", return_value="/usr/local/bin/test-tool"
222
+ ),
223
+ mock.patch.object(cli_install, "info") as mock_info,
224
+ mock.patch.object(cli_install, "run_command") as mock_run,
225
+ ):
226
+ result = cli_install.install_tool("test", dryrun=True, force=True)
227
+
228
+ self.assertTrue(result)
229
+ mock_run.assert_not_called()
230
+ mock_info.assert_any_call("[DRYRUN] Checking Test Tool...")
231
+ mock_info.assert_any_call(
232
+ "[DRYRUN] Would reinstall test-tool despite existing binary at "
233
+ "/usr/local/bin/test-tool"
234
+ )
235
+ mock_info.assert_any_call("[DRYRUN] Would install npm package: test-tool")
@@ -7,6 +7,7 @@ from unittest import mock
7
7
 
8
8
  from code_aide import commands_actions as cli_commands_actions
9
9
  from code_aide import operations as cli_operations
10
+ from code_aide.operations import UpgradeResult
10
11
 
11
12
 
12
13
  class TestRemoveToolDirectDownload(unittest.TestCase):
@@ -137,13 +138,16 @@ class TestMigrateInstallMethod(unittest.TestCase):
137
138
  mock.patch.object(
138
139
  cli_operations,
139
140
  "detect_install_method",
140
- return_value={"method": "npm", "detail": "test-pkg"},
141
+ side_effect=[
142
+ {"method": "npm", "detail": "test-pkg"},
143
+ {"method": "script", "detail": None},
144
+ ],
141
145
  ),
142
146
  ):
143
147
  result = cli_operations.upgrade_tool("test")
144
- self.assertTrue(result)
148
+ self.assertEqual(result, UpgradeResult.CHANGED)
145
149
  mock_remove.assert_called_once_with("test")
146
- mock_install.assert_called_once_with("test")
150
+ mock_install.assert_called_once_with("test", force=True)
147
151
 
148
152
  def test_upgrade_normal_when_not_deprecated(self):
149
153
  """upgrade_tool does normal upgrade when not deprecated."""
@@ -163,14 +167,33 @@ class TestMigrateInstallMethod(unittest.TestCase):
163
167
  mock.patch.object(
164
168
  cli_operations,
165
169
  "detect_install_method",
166
- return_value={"method": "script", "detail": "native installer"},
170
+ side_effect=[
171
+ {"method": "script", "detail": "native installer"},
172
+ {"method": "script", "detail": "native installer"},
173
+ ],
167
174
  ),
168
175
  mock.patch.object(
169
176
  cli_operations, "run_install_script", return_value=True
170
177
  ) as mock_script,
178
+ mock.patch.object(
179
+ cli_operations,
180
+ "_get_upgrade_snapshot",
181
+ side_effect=[
182
+ {
183
+ "method": "script",
184
+ "detail": "native installer",
185
+ "version": "1.0.0",
186
+ },
187
+ {
188
+ "method": "script",
189
+ "detail": "native installer",
190
+ "version": "2.0.0",
191
+ },
192
+ ],
193
+ ),
171
194
  ):
172
195
  result = cli_operations.upgrade_tool("test")
173
- self.assertTrue(result)
196
+ self.assertEqual(result, UpgradeResult.CHANGED)
174
197
  mock_script.assert_called_once()
175
198
 
176
199
  def test_migration_fails_on_remove(self):
@@ -197,7 +220,7 @@ class TestMigrateInstallMethod(unittest.TestCase):
197
220
  ),
198
221
  ):
199
222
  result = cli_operations.upgrade_tool("test")
200
- self.assertFalse(result)
223
+ self.assertEqual(result, UpgradeResult.FAILED)
201
224
  mock_install.assert_not_called()
202
225
 
203
226
  def test_migration_fails_on_install(self):
@@ -222,7 +245,42 @@ class TestMigrateInstallMethod(unittest.TestCase):
222
245
  ),
223
246
  ):
224
247
  result = cli_operations.upgrade_tool("test")
225
- self.assertFalse(result)
248
+ self.assertEqual(result, UpgradeResult.FAILED)
249
+
250
+ def test_upgrade_reports_unchanged_when_version_does_not_change(self):
251
+ """Upgrade result is unchanged when package manager leaves version as-is."""
252
+ tool_config = {
253
+ "name": "Test Tool",
254
+ "command": "test-tool",
255
+ "install_type": "npm",
256
+ "npm_package": "test-tool",
257
+ }
258
+ with (
259
+ mock.patch.dict(cli_operations.TOOLS, {"test": tool_config}),
260
+ mock.patch.object(cli_operations, "is_tool_installed", return_value=True),
261
+ mock.patch.object(
262
+ cli_operations, "is_deprecated_install", return_value=False
263
+ ),
264
+ mock.patch.object(
265
+ cli_operations,
266
+ "detect_install_method",
267
+ return_value={"method": "npm", "detail": "test-tool"},
268
+ ),
269
+ mock.patch.object(cli_operations, "run_command") as mock_run,
270
+ mock.patch.object(
271
+ cli_operations,
272
+ "_get_upgrade_snapshot",
273
+ side_effect=[
274
+ {"method": "npm", "detail": "test-tool", "version": "1.0.0"},
275
+ {"method": "npm", "detail": "test-tool", "version": "1.0.0"},
276
+ ],
277
+ ),
278
+ ):
279
+ result = cli_operations.upgrade_tool("test")
280
+ self.assertEqual(result, UpgradeResult.UNCHANGED)
281
+ mock_run.assert_called_once_with(
282
+ ["npm", "install", "-g", "test-tool@latest"], check=True
283
+ )
226
284
 
227
285
 
228
286
  class TestCmdUpgradeDefaultSelection(unittest.TestCase):
@@ -268,7 +326,9 @@ class TestCmdUpgradeDefaultSelection(unittest.TestCase):
268
326
  cli_commands_actions, "get_tool_status", side_effect=_status
269
327
  ),
270
328
  mock.patch.object(
271
- cli_commands_actions, "upgrade_tool", return_value=True
329
+ cli_commands_actions,
330
+ "upgrade_tool",
331
+ return_value=UpgradeResult.CHANGED,
272
332
  ) as mock_upgrade,
273
333
  ):
274
334
  cli_commands_actions.cmd_upgrade(args)
@@ -301,7 +361,9 @@ class TestCmdUpgradeDefaultSelection(unittest.TestCase):
301
361
  return_value={"version": "1.0.0"},
302
362
  ),
303
363
  mock.patch.object(
304
- cli_commands_actions, "upgrade_tool", return_value=True
364
+ cli_commands_actions,
365
+ "upgrade_tool",
366
+ return_value=UpgradeResult.CHANGED,
305
367
  ) as mock_upgrade,
306
368
  ):
307
369
  cli_commands_actions.cmd_upgrade(args)
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