code-aide 1.6.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 (46) hide show
  1. {code_aide-1.6.0 → code_aide-1.7.1}/PKG-INFO +3 -2
  2. {code_aide-1.6.0 → code_aide-1.7.1}/README.md +2 -1
  3. {code_aide-1.6.0 → code_aide-1.7.1}/TODO.md +19 -18
  4. code_aide-1.7.1/specs/auto-migrate-deprecated-installs.md +128 -0
  5. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/__init__.py +1 -1
  6. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/commands_actions.py +38 -8
  7. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/commands_tools.py +58 -1
  8. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/detection.py +127 -6
  9. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/install.py +14 -2
  10. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/operations.py +125 -19
  11. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/status.py +30 -0
  12. {code_aide-1.6.0 → code_aide-1.7.1}/tests/test_commands_actions.py +78 -0
  13. code_aide-1.7.1/tests/test_commands_tools.py +214 -0
  14. code_aide-1.7.1/tests/test_detection.py +299 -0
  15. {code_aide-1.6.0 → code_aide-1.7.1}/tests/test_install.py +57 -0
  16. code_aide-1.7.1/tests/test_operations.py +371 -0
  17. code_aide-1.6.0/tests/test_commands_tools.py +0 -78
  18. code_aide-1.6.0/tests/test_detection.py +0 -116
  19. code_aide-1.6.0/tests/test_operations.py +0 -159
  20. {code_aide-1.6.0 → code_aide-1.7.1}/.github/workflows/ci.yml +0 -0
  21. {code_aide-1.6.0 → code_aide-1.7.1}/.github/workflows/publish.yml +0 -0
  22. {code_aide-1.6.0 → code_aide-1.7.1}/.gitignore +0 -0
  23. {code_aide-1.6.0 → code_aide-1.7.1}/.gitlab-ci.yml +0 -0
  24. {code_aide-1.6.0 → code_aide-1.7.1}/.pre-commit-config.yaml +0 -0
  25. {code_aide-1.6.0 → code_aide-1.7.1}/AGENTS.md +0 -0
  26. {code_aide-1.6.0 → code_aide-1.7.1}/CLAUDE.md +0 -0
  27. {code_aide-1.6.0 → code_aide-1.7.1}/LICENSE +0 -0
  28. {code_aide-1.6.0 → code_aide-1.7.1}/pyproject.toml +0 -0
  29. {code_aide-1.6.0 → code_aide-1.7.1}/specs/claude-native-installer-migration.md +0 -0
  30. {code_aide-1.6.0 → code_aide-1.7.1}/specs/missing-coding-llm-cli-tools.md +0 -0
  31. {code_aide-1.6.0 → code_aide-1.7.1}/specs/pre-commit-uv-setup.md +0 -0
  32. {code_aide-1.6.0 → code_aide-1.7.1}/specs/remove-bundled-version-baseline.md +0 -0
  33. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/__main__.py +0 -0
  34. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/config.py +0 -0
  35. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/console.py +0 -0
  36. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/constants.py +0 -0
  37. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/data/tools.json +0 -0
  38. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/entry.py +0 -0
  39. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/prereqs.py +0 -0
  40. {code_aide-1.6.0 → code_aide-1.7.1}/src/code_aide/versions.py +0 -0
  41. {code_aide-1.6.0 → code_aide-1.7.1}/tests/test_config.py +0 -0
  42. {code_aide-1.6.0 → code_aide-1.7.1}/tests/test_console.py +0 -0
  43. {code_aide-1.6.0 → code_aide-1.7.1}/tests/test_constants.py +0 -0
  44. {code_aide-1.6.0 → code_aide-1.7.1}/tests/test_status.py +0 -0
  45. {code_aide-1.6.0 → code_aide-1.7.1}/tests/test_versions.py +0 -0
  46. {code_aide-1.6.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.6.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
@@ -157,7 +157,8 @@ uv run pytest tests/test_install.py::TestDetectOsArch -v
157
157
  - Example: `Fixed timeout handling in status command`
158
158
  5. Tag and push:
159
159
  - `git tag vX.Y.Z`
160
- - `git push origin main --follow-tags`
160
+ - `git push origin main`
161
+ - `git push origin vX.Y.Z`
161
162
  6. Confirm GitHub Actions:
162
163
  - CI should pass.
163
164
  - Publish workflow should upload to PyPI and create GitHub Release notes.
@@ -131,7 +131,8 @@ uv run pytest tests/test_install.py::TestDetectOsArch -v
131
131
  - Example: `Fixed timeout handling in status command`
132
132
  5. Tag and push:
133
133
  - `git tag vX.Y.Z`
134
- - `git push origin main --follow-tags`
134
+ - `git push origin main`
135
+ - `git push origin vX.Y.Z`
135
136
  6. Confirm GitHub Actions:
136
137
  - CI should pass.
137
138
  - Publish workflow should upload to PyPI and create GitHub Release notes.
@@ -7,8 +7,8 @@ remove, status, and version metadata).
7
7
 
8
8
  - [x] Standardize `--dryrun` flag: `update-versions` uses `--dry-run` but
9
9
  `install` uses `--dryrun`. Change `update-versions` to `--dryrun`.
10
- - [x] Add missing `success()` message for direct-download installs in non-dryrun
11
- mode (`install.py`, `install_tool()`).
10
+ - [x] Add missing `success()` message for direct-download installs in
11
+ non-dryrun mode (`install.py`, `install_tool()`).
12
12
  - [x] Replace PID-based temp directory naming with `tempfile.mkdtemp()` in
13
13
  `install.py` `install_direct_download()`.
14
14
  - [x] Guard `package.split("/", 1)` in `detection.py`
@@ -18,21 +18,21 @@ remove, status, and version metadata).
18
18
 
19
19
  - [ ] Handle missing `node` cleanly during prerequisite checks
20
20
  (`FileNotFoundError` path in Node version probing).
21
- - [ ] Read tool version output from both stdout and stderr so status does not
22
- miss installed versions.
21
+ - [ ] Read tool version output from both stdout and stderr so status does
22
+ not miss installed versions.
23
23
  - [ ] Make version cache writes atomic (write temp file + rename) to avoid
24
- partial/corrupted `versions.json`. Apply to `save_bundled_versions()` too.
25
- - [ ] Warn when `versions.json` cache contains invalid JSON instead of silently
26
- returning empty data.
24
+ partial/corrupted `versions.json`.
25
+ - [ ] Warn when `versions.json` cache contains invalid JSON instead of
26
+ silently returning empty data.
27
27
  - [ ] Use `os.pathsep` instead of hardcoded `":"` in `prereqs.py`
28
28
  `check_path_directories()`.
29
29
 
30
30
  ## Security and Integrity
31
31
 
32
- - [ ] Add integrity verification for direct-download tarballs (not only install
33
- script SHA256).
34
- - [ ] Extend tool metadata to support tarball checksum/signature fields where
35
- applicable.
32
+ - [ ] Add integrity verification for direct-download tarballs (not only
33
+ install script SHA256).
34
+ - [ ] Extend tool metadata to support tarball checksum/signature fields
35
+ where applicable.
36
36
 
37
37
  ## CLI and Automation UX
38
38
 
@@ -40,21 +40,23 @@ remove, status, and version metadata).
40
40
  - [ ] Add a focused `doctor` command for environment checks (PATH,
41
41
  prerequisites, command health).
42
42
  - [ ] Consider `install --force` for reinstall/repair flows.
43
- - [ ] Add cleanup support for stale direct-download versions no longer in use.
44
- - [ ] Expand `upgrade` help text to mention the default "only out-of-date tools"
45
- behavior.
43
+ - [ ] Add cleanup support for stale direct-download versions no longer in
44
+ use.
45
+ - [ ] Expand `upgrade` help text to mention the default "only out-of-date
46
+ tools" behavior.
46
47
 
47
48
  ## Documentation
48
49
 
49
- - [ ] Document that running `code-aide` with no subcommand defaults to `status`.
50
+ - [ ] Document that running `code-aide` with no subcommand defaults to
51
+ `status`.
50
52
  - [ ] Add `install --dryrun` to the README usage examples.
51
53
  - [ ] Document environment variables and config file paths
52
54
  (`~/.config/code-aide/versions.json`).
53
55
 
54
56
  ## Platform and Package Detection
55
57
 
56
- - [ ] Broaden system package metadata detection beyond Gentoo-specific tooling
57
- where practical.
58
+ - [ ] Broaden system package metadata detection beyond Gentoo-specific
59
+ tooling where practical.
58
60
 
59
61
  ## Maintainability and Tests
60
62
 
@@ -65,6 +67,5 @@ remove, status, and version metadata).
65
67
  - `cmd_remove()` (completely untested)
66
68
  - `fetch_url()` (completely untested)
67
69
  - `check_prerequisites()` / `install_nodejs_npm()` (completely untested)
68
- - `save_bundled_versions()` (untested)
69
70
  - `get_system_package_info()` (completely untested)
70
71
  - [ ] Add tests for prerequisite edge cases and cache write behavior.
@@ -0,0 +1,128 @@
1
+ # Auto-migrate deprecated install methods
2
+
3
+ ## Context
4
+
5
+ Claude Code switched from npm to a native installer. Users who installed
6
+ Claude via npm are stuck: `code-aide upgrade` runs `npm install -g @latest`
7
+ (the detected method) instead of the native script installer (the configured
8
+ method). `code-aide install` exits early because the command already exists.
9
+ There is no migration path. code-aide should detect this and fix it
10
+ automatically.
11
+
12
+ This is a general pattern -- any tool could change install methods in the
13
+ future. The solution should not be Claude-specific.
14
+
15
+ ## Approach
16
+
17
+ A detected install method is "deprecated" when:
18
+
19
+ - Detected method is `npm` or `brew_npm` AND config `install_type` is
20
+ `script` or `direct_download`
21
+
22
+ Methods that are never considered deprecated (user-managed choices):
23
+
24
+ - `brew_formula`, `brew_cask` -- user explicitly chose Homebrew
25
+ - `system` -- managed by system package manager
26
+
27
+ Migration flow: remove old (npm), install new (configured method), with
28
+ clear messaging. If the new install fails after removal, provide recovery
29
+ instructions.
30
+
31
+ ## Changes
32
+
33
+ ### 1. `src/code_aide/data/tools.json` -- add deprecated_npm_package
34
+
35
+ Add a `deprecated_npm_package` field to Claude's entry:
36
+
37
+ ```json
38
+ "claude": {
39
+ "name": "Claude CLI (Claude Code)",
40
+ "install_type": "script",
41
+ "deprecated_npm_package": "@anthropic-ai/claude-code",
42
+ ...
43
+ }
44
+ ```
45
+
46
+ This records the old npm package name explicitly so the migration removal
47
+ step does not rely solely on extracting the package name from the binary
48
+ path.
49
+
50
+ ### 2. `src/code_aide/detection.py` -- add deprecation detection
51
+
52
+ Add two functions:
53
+
54
+ - `is_deprecated_install(tool_name)` -- returns True when the detected
55
+ install method is `npm` or `brew_npm` but the configured `install_type` is
56
+ something else (script, direct_download). Also returns True when
57
+ `deprecated_npm_package` is set and the detected method is npm. Brew
58
+ formula/cask and system installs are never deprecated (user-managed).
59
+ - `format_migration_warning(tool_name)` -- returns a human-readable warning
60
+ string, or None if not deprecated. Uses existing `format_install_method()`
61
+ for labels.
62
+
63
+ ### 3. `src/code_aide/operations.py` -- auto-migrate in upgrade
64
+
65
+ Add `_migrate_install_method()` private function that:
66
+
67
+ 1. Warns about the deprecated method
68
+ 2. Calls `remove_tool()` to remove the old npm install
69
+ 3. Calls `install_tool()` to install via the configured method
70
+ 4. If install fails after remove, prints recovery instructions (`code-aide
71
+ install <tool>` and manual curl command for script types)
72
+
73
+ Modify `upgrade_tool()`: after `detect_install_method()`, check
74
+ `is_deprecated_install()`. If True, call `_migrate_install_method()` instead
75
+ of the normal upgrade branch.
76
+
77
+ Modify `remove_tool()` npm branch: fall back to
78
+ `tool_config.get("deprecated_npm_package")` when looking up the npm package
79
+ name. Current code: `npm_package = detail or
80
+ tool_config.get("npm_package")`. Change to also check
81
+ `deprecated_npm_package`.
82
+
83
+ New imports needed: `is_deprecated_install`, `format_install_method` from
84
+ detection, `install_tool` from install.
85
+
86
+ Note: after `remove_tool()` succeeds, `command_exists()` returns False so
87
+ `install_tool()` proceeds normally with the configured `install_type`.
88
+
89
+ ### 4. `src/code_aide/commands_actions.py` -- include deprecated in auto-upgrade
90
+
91
+ Modify `cmd_upgrade()` no-args path: after building the version-outdated
92
+ list, also add any installed tools with deprecated install methods. Without
93
+ this, a tool whose version is current but install method is deprecated would
94
+ be silently skipped by `code-aide upgrade`.
95
+
96
+ New import: `is_deprecated_install` from detection.
97
+
98
+ ### 5. `src/code_aide/commands_tools.py` -- show warnings in status/list
99
+
100
+ In both `cmd_list()` and `cmd_status()`, after displaying "Installed via:",
101
+ call `format_migration_warning()` and display the warning if present.
102
+
103
+ At the end of `cmd_status()`, add a summary line counting tools that need
104
+ migration.
105
+
106
+ New imports: `format_migration_warning`, `is_deprecated_install` from
107
+ detection.
108
+
109
+ ### 6. Tests
110
+
111
+ - `tests/test_detection.py`: Tests for `is_deprecated_install()` covering
112
+ npm to script (deprecated), npm to npm (not deprecated), brew_formula
113
+ (never deprecated), system (never deprecated), not installed, unknown
114
+ tool, npm to direct_download. Tests for `format_migration_warning()`.
115
+ - `tests/test_operations.py`: Tests for migration in `upgrade_tool()`:
116
+ triggers migration, normal upgrade when not deprecated, migration fails on
117
+ remove, migration fails on install with recovery message.
118
+ - `tests/test_commands_tools.py`: Test that status/list show migration
119
+ warnings for deprecated installs.
120
+
121
+ ## Verification
122
+
123
+ 1. `pre-commit run --all-files` -- formatting passes
124
+ 2. `uv run pytest tests/ -v` -- all tests pass
125
+ 3. `uv run python -m code_aide status` -- shows migration warning for
126
+ npm-installed Claude (if applicable)
127
+ 4. `uv run python -m code_aide upgrade claude` -- performs migration (remove
128
+ npm, install via script)
@@ -1,3 +1,3 @@
1
1
  """code-aide - Manage AI coding CLI tools."""
2
2
 
3
- __version__ = "1.6.0"
3
+ __version__ = "1.7.1"
@@ -5,9 +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 (
9
+ detect_install_method,
10
+ get_brew_package_info,
11
+ is_deprecated_install,
12
+ )
8
13
  from code_aide.install import install_tool
9
14
  from code_aide.console import error, info, success, warning
10
- 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
+ )
11
21
  from code_aide.prereqs import (
12
22
  check_path_directories,
13
23
  check_prerequisites,
@@ -123,9 +133,20 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
123
133
  for name, config in TOOLS.items():
124
134
  if not is_tool_installed(name):
125
135
  continue
136
+ if is_deprecated_install(name):
137
+ if name not in tools_to_upgrade:
138
+ tools_to_upgrade.append(name)
139
+ continue
126
140
  latest = config.get("latest_version")
127
141
  if not latest:
128
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
129
150
  status = get_tool_status(name, config)
130
151
  if status["version"] and status_version_matches_latest(
131
152
  status["version"], latest
@@ -140,7 +161,8 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
140
161
 
141
162
  validate_tools(tools_to_upgrade)
142
163
 
143
- upgraded = []
164
+ updated = []
165
+ unchanged = []
144
166
  failed = []
145
167
  skipped = []
146
168
 
@@ -152,8 +174,11 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
152
174
  skipped.append(tool)
153
175
  continue
154
176
 
155
- if upgrade_tool(tool):
156
- 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)
157
182
  else:
158
183
  failed.append(tool)
159
184
 
@@ -162,8 +187,11 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
162
187
  info("Upgrade Summary")
163
188
  print("=" * 42)
164
189
 
165
- if upgraded:
166
- 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)}")
167
195
 
168
196
  if skipped:
169
197
  warning(f"Skipped (not installed): {', '.join(skipped)}")
@@ -172,9 +200,11 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
172
200
  error(f"Failed to upgrade: {', '.join(failed)}")
173
201
  sys.exit(1)
174
202
 
175
- if upgraded:
203
+ if updated:
176
204
  success("All upgrades completed successfully!")
177
- 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:
178
208
  info(
179
209
  "No tools were upgraded (all were either not installed or already up to date)"
180
210
  )
@@ -8,12 +8,18 @@ from typing import List
8
8
  from code_aide.constants import Colors, PACKAGE_MANAGERS, TOOLS
9
9
  from code_aide.detection import (
10
10
  format_install_method,
11
+ format_migration_warning,
12
+ get_brew_package_info,
11
13
  get_system_package_info,
12
14
  detect_install_method,
13
15
  )
14
16
  from code_aide.console import command_exists, info, warning
15
17
  from code_aide.prereqs import detect_package_manager, is_tool_installed
16
- 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
+ )
17
23
  from code_aide.versions import (
18
24
  extract_version_from_string,
19
25
  status_version_matches_latest,
@@ -48,6 +54,9 @@ def cmd_list(args: argparse.Namespace) -> None:
48
54
  " Installed via: "
49
55
  f"{format_install_method(install_info['method'], install_info['detail'])}"
50
56
  )
57
+ migration_msg = format_migration_warning(tool_name)
58
+ if migration_msg:
59
+ warning(f" {migration_msg}")
51
60
 
52
61
  if tool_config.get("min_node_version"):
53
62
  print(f" Requires: Node.js v{tool_config['min_node_version']}+")
@@ -99,6 +108,7 @@ def cmd_status(args: argparse.Namespace) -> None:
99
108
  print()
100
109
 
101
110
  outdated_count = 0
111
+ migration_count = 0
102
112
  config_outdated: List[str] = []
103
113
 
104
114
  for tool_name, tool_config in TOOLS.items():
@@ -123,6 +133,44 @@ def cmd_status(args: argparse.Namespace) -> None:
123
133
  print_system_version_status(
124
134
  status["version"], latest_version, pkg_info
125
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)
126
174
  elif latest_version:
127
175
  if status_version_matches_latest(status["version"], latest_version):
128
176
  version_annotation = (
@@ -156,6 +204,10 @@ def cmd_status(args: argparse.Namespace) -> None:
156
204
  " Installed via: "
157
205
  f"{format_install_method(install_info['method'], install_info['detail'])}"
158
206
  )
207
+ migration_msg = format_migration_warning(tool_name)
208
+ if migration_msg:
209
+ warning(f" {migration_msg}")
210
+ migration_count += 1
159
211
 
160
212
  if status["user"]:
161
213
  print(f" User: {status['user']}")
@@ -181,3 +233,8 @@ def cmd_status(args: argparse.Namespace) -> None:
181
233
  f"{Colors.YELLOW}{outdated_count} tool(s) can be upgraded with "
182
234
  f"'code-aide upgrade'.{Colors.NC}"
183
235
  )
236
+ if migration_count > 0:
237
+ print(
238
+ f"{Colors.YELLOW}{migration_count} tool(s) need migration to a "
239
+ f"new install method. Run 'code-aide upgrade' to migrate.{Colors.NC}"
240
+ )
@@ -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":
@@ -186,3 +247,63 @@ def format_install_method(method: Optional[str], detail: Optional[str]) -> str:
186
247
  if method:
187
248
  return method
188
249
  return "unknown"
250
+
251
+
252
+ # Install methods that are user-managed and never considered deprecated
253
+ _USER_MANAGED_METHODS = frozenset({"brew_formula", "brew_cask", "system"})
254
+
255
+ # Install methods that are considered deprecated when they don't match
256
+ # the configured install_type
257
+ _DEPRECATED_METHODS = frozenset({"npm", "brew_npm"})
258
+
259
+
260
+ def is_deprecated_install(tool_name: str) -> bool:
261
+ """Check if a tool's detected install method is deprecated.
262
+
263
+ Returns True when the detected install method is npm or brew_npm
264
+ but the configured install_type is something else (e.g. script,
265
+ direct_download). Brew formula/cask and system installs are
266
+ user-managed and never considered deprecated.
267
+ """
268
+ tool_config = TOOLS.get(tool_name)
269
+ if not tool_config:
270
+ return False
271
+
272
+ install_info = detect_install_method(tool_name)
273
+ detected = install_info["method"]
274
+
275
+ if not detected:
276
+ return False
277
+
278
+ if detected in _USER_MANAGED_METHODS:
279
+ return False
280
+
281
+ configured = tool_config.get("install_type")
282
+ if not configured:
283
+ return False
284
+
285
+ if detected in _DEPRECATED_METHODS and detected != configured:
286
+ return True
287
+
288
+ return False
289
+
290
+
291
+ def format_migration_warning(tool_name: str) -> Optional[str]:
292
+ """Return a human-readable migration warning, or None if not needed."""
293
+ if not is_deprecated_install(tool_name):
294
+ return None
295
+
296
+ tool_config = TOOLS.get(tool_name)
297
+ if not tool_config:
298
+ return None
299
+
300
+ install_info = detect_install_method(tool_name)
301
+ detected_label = format_install_method(
302
+ install_info["method"], install_info["detail"]
303
+ )
304
+ configured_label = format_install_method(tool_config["install_type"], None)
305
+
306
+ return (
307
+ f"Installed via {detected_label} but configured method is "
308
+ f"{configured_label}. Run 'code-aide upgrade {tool_name}' to migrate."
309
+ )
@@ -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"]