code-aide 1.17.0__tar.gz → 1.18.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 (67) hide show
  1. code_aide-1.18.1/CLAUDE.md +1 -0
  2. {code_aide-1.17.0 → code_aide-1.18.1}/PKG-INFO +19 -1
  3. {code_aide-1.17.0 → code_aide-1.18.1}/README.md +18 -0
  4. code_aide-1.18.1/TODO.md +30 -0
  5. {code_aide-1.17.0 → code_aide-1.18.1}/script-archive/amp-install.sh +9 -6
  6. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/__init__.py +1 -1
  7. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/commands_actions.py +53 -1
  8. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/data/tools.json +1 -1
  9. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/entry.py +24 -0
  10. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/operations.py +65 -1
  11. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/status.py +5 -2
  12. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_commands_actions.py +152 -2
  13. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_operations.py +73 -0
  14. code_aide-1.18.1/tests/test_package_info.py +57 -0
  15. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_status.py +46 -0
  16. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_versions.py +51 -0
  17. code_aide-1.17.0/CLAUDE.md +0 -1
  18. code_aide-1.17.0/TODO.md +0 -77
  19. {code_aide-1.17.0 → code_aide-1.18.1}/.github/workflows/ci.yml +0 -0
  20. {code_aide-1.17.0 → code_aide-1.18.1}/.github/workflows/publish.yml +0 -0
  21. {code_aide-1.17.0 → code_aide-1.18.1}/.gitignore +0 -0
  22. {code_aide-1.17.0 → code_aide-1.18.1}/.gitlab-ci.yml +0 -0
  23. {code_aide-1.17.0 → code_aide-1.18.1}/.pre-commit-config.yaml +0 -0
  24. {code_aide-1.17.0 → code_aide-1.18.1}/AGENTS.md +0 -0
  25. {code_aide-1.17.0 → code_aide-1.18.1}/LICENSE +0 -0
  26. {code_aide-1.17.0 → code_aide-1.18.1}/Makefile +0 -0
  27. {code_aide-1.17.0 → code_aide-1.18.1}/pyproject.toml +0 -0
  28. {code_aide-1.17.0 → code_aide-1.18.1}/script-archive/README.md +0 -0
  29. {code_aide-1.17.0 → code_aide-1.18.1}/script-archive/antigravity-install.sh +0 -0
  30. {code_aide-1.17.0 → code_aide-1.18.1}/script-archive/claude-install.sh +0 -0
  31. {code_aide-1.17.0 → code_aide-1.18.1}/script-archive/cursor-install.sh +0 -0
  32. {code_aide-1.17.0 → code_aide-1.18.1}/specs/2026-05-03-qwen3.6-code-quality-analysis-original.md +0 -0
  33. {code_aide-1.17.0 → code_aide-1.18.1}/specs/2026-05-03-qwen3.6-code-quality-analysis.md +0 -0
  34. {code_aide-1.17.0 → code_aide-1.18.1}/specs/antigravity-cli-integration.md +0 -0
  35. {code_aide-1.17.0 → code_aide-1.18.1}/specs/auto-migrate-deprecated-installs.md +0 -0
  36. {code_aide-1.17.0 → code_aide-1.18.1}/specs/claude-native-installer-migration.md +0 -0
  37. {code_aide-1.17.0 → code_aide-1.18.1}/specs/missing-coding-llm-cli-tools.md +0 -0
  38. {code_aide-1.17.0 → code_aide-1.18.1}/specs/pre-commit-uv-setup.md +0 -0
  39. {code_aide-1.17.0 → code_aide-1.18.1}/specs/remove-bundled-version-baseline.md +0 -0
  40. {code_aide-1.17.0 → code_aide-1.18.1}/specs/unify-upgrade-eligibility-with-shared-evaluator.md +0 -0
  41. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/__main__.py +0 -0
  42. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/commands_tools.py +0 -0
  43. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/config.py +0 -0
  44. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/console.py +0 -0
  45. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/constants.py +0 -0
  46. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/detection.py +0 -0
  47. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/detection_types.py +0 -0
  48. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/install.py +0 -0
  49. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/install_types.py +0 -0
  50. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/operation_handlers.py +0 -0
  51. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/package_info.py +0 -0
  52. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/package_managers.py +0 -0
  53. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/prereqs.py +0 -0
  54. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/status_print.py +0 -0
  55. {code_aide-1.17.0 → code_aide-1.18.1}/src/code_aide/versions.py +0 -0
  56. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_commands_tools.py +0 -0
  57. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_config.py +0 -0
  58. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_console.py +0 -0
  59. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_constants.py +0 -0
  60. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_detection.py +0 -0
  61. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_entry.py +0 -0
  62. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_error_paths.py +0 -0
  63. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_install.py +0 -0
  64. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_install_types.py +0 -0
  65. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_package_managers.py +0 -0
  66. {code_aide-1.17.0 → code_aide-1.18.1}/tests/test_prereqs.py +0 -0
  67. {code_aide-1.17.0 → code_aide-1.18.1}/uv.lock +0 -0
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: code-aide
3
- Version: 1.17.0
3
+ Version: 1.18.1
4
4
  Summary: Manage AI coding CLI tools (Claude, Antigravity, 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
@@ -49,6 +49,9 @@ pipx install code-aide
49
49
  ## Usage
50
50
 
51
51
  ```bash
52
+ # Show compact one-line-per-tool status (default when no command is given)
53
+ code-aide
54
+
52
55
  # List available tools and their status
53
56
  code-aide list
54
57
 
@@ -67,6 +70,9 @@ code-aide install
67
70
  # Install with automatic prerequisite installation (Node.js, npm)
68
71
  code-aide install -p
69
72
 
73
+ # Verify checksums and show what would be installed, without installing
74
+ code-aide install -n
75
+
70
76
  # Upgrade installed tools (no args = only out-of-date tools)
71
77
  code-aide upgrade [NAMES]
72
78
 
@@ -79,6 +85,9 @@ code-aide remove -n
79
85
  # Remove all managed tools without prompting
80
86
  code-aide remove -y
81
87
 
88
+ # Remove stale version directories of direct-download tools
89
+ code-aide clean [NAMES]
90
+
82
91
  # Check upstream for latest versions (dry-run)
83
92
  code-aide update-versions -n
84
93
 
@@ -163,6 +172,15 @@ consulted when the relevant install method or platform applies.
163
172
  - PATH configuration validation and warnings
164
173
  - Supports Linux and macOS
165
174
 
175
+ ## Configuration
176
+
177
+ - Tool definitions are bundled at `src/code_aide/data/tools.json`.
178
+ - The version cache lives at `~/.config/code-aide/versions.json` by default; set
179
+ `XDG_CONFIG_HOME` to relocate the `code-aide` config directory.
180
+ - Color output honors `NO_COLOR`, `FORCE_COLOR`, `CLICOLOR_FORCE`, `CLICOLOR=0`,
181
+ and `TERM=dumb`, in that priority order; color is disabled when stdout is not
182
+ a terminal.
183
+
166
184
  ## Requirements
167
185
 
168
186
  - Python 3.11+
@@ -22,6 +22,9 @@ pipx install code-aide
22
22
  ## Usage
23
23
 
24
24
  ```bash
25
+ # Show compact one-line-per-tool status (default when no command is given)
26
+ code-aide
27
+
25
28
  # List available tools and their status
26
29
  code-aide list
27
30
 
@@ -40,6 +43,9 @@ code-aide install
40
43
  # Install with automatic prerequisite installation (Node.js, npm)
41
44
  code-aide install -p
42
45
 
46
+ # Verify checksums and show what would be installed, without installing
47
+ code-aide install -n
48
+
43
49
  # Upgrade installed tools (no args = only out-of-date tools)
44
50
  code-aide upgrade [NAMES]
45
51
 
@@ -52,6 +58,9 @@ code-aide remove -n
52
58
  # Remove all managed tools without prompting
53
59
  code-aide remove -y
54
60
 
61
+ # Remove stale version directories of direct-download tools
62
+ code-aide clean [NAMES]
63
+
55
64
  # Check upstream for latest versions (dry-run)
56
65
  code-aide update-versions -n
57
66
 
@@ -136,6 +145,15 @@ consulted when the relevant install method or platform applies.
136
145
  - PATH configuration validation and warnings
137
146
  - Supports Linux and macOS
138
147
 
148
+ ## Configuration
149
+
150
+ - Tool definitions are bundled at `src/code_aide/data/tools.json`.
151
+ - The version cache lives at `~/.config/code-aide/versions.json` by default; set
152
+ `XDG_CONFIG_HOME` to relocate the `code-aide` config directory.
153
+ - Color output honors `NO_COLOR`, `FORCE_COLOR`, `CLICOLOR_FORCE`, `CLICOLOR=0`,
154
+ and `TERM=dumb`, in that priority order; color is disabled when stdout is not
155
+ a terminal.
156
+
139
157
  ## Requirements
140
158
 
141
159
  - Python 3.11+
@@ -0,0 +1,30 @@
1
+ # TODO
2
+
3
+ Keep the project focused on managing AI coding CLI tools (install, upgrade,
4
+ remove, status, and version metadata).
5
+
6
+ All items below are deferred; none are scheduled.
7
+
8
+ | # | Item | Risk | Effort | Notes |
9
+ |:---|:-------------------------------|:-----|:-------|:-------------------------------------------------------|
10
+ | 1 | `doctor` command | Low | Med | Health checks exist but are scattered |
11
+ | 2 | Cross-distro package detection | Med | High | Only Gentoo supported; other Linux distros get nothing |
12
+ | 3 | Split large modules | Low | High | Refactoring only; no behavior change |
13
+ | 4 | Tests for edge cases | Med | Med | PATH oddities, more version parsing corners |
14
+
15
+ ## CLI and Automation UX
16
+
17
+ - [ ] Add a focused `doctor` command for environment checks (PATH,
18
+ prerequisites, command health).
19
+
20
+ ## Platform and Package Detection
21
+
22
+ - [ ] Broaden system package metadata detection beyond Gentoo-specific tooling
23
+ where practical; note that non-Gentoo system installs currently report "up to
24
+ date" because no available version is detectable.
25
+
26
+ ## Maintainability and Tests
27
+
28
+ - [ ] Split larger modules into smaller focused files where practical
29
+ (`install.py`, `commands_actions.py`, `versions.py`).
30
+ - [ ] Add tests for more prerequisite edge cases and PATH oddities.
@@ -347,6 +347,8 @@ install_amp_binary() {
347
347
  fi
348
348
 
349
349
  local binary_path="$BIN_DIR/$binary_name"
350
+ local staged_binary_path
351
+ staged_binary_path=$(mktemp "$BIN_DIR/tmp.XXXXXX")
350
352
  local checksum_path="$AMP_HOME/amp-install-checksum.txt"
351
353
 
352
354
  # Download checksum first
@@ -356,21 +358,22 @@ install_amp_binary() {
356
358
 
357
359
  if has_gzip_support; then
358
360
  # Download compressed binary and verify the decompressed bytes against checksum
359
- download_gzipped_file "${binary_url}.gz" "$binary_path" 'Amp binary'
361
+ download_gzipped_file "${binary_url}.gz" "$staged_binary_path" 'Amp binary'
360
362
  else
361
363
  warn "gzip not found; downloading uncompressed binary"
362
- download_file "$binary_url" "$binary_path" 'Amp binary'
364
+ download_file "$binary_url" "$staged_binary_path" 'Amp binary'
363
365
  fi
364
366
 
365
367
  # Verify checksum
366
- verify_checksum "$binary_path" "$expected_checksum"
368
+ verify_checksum "$staged_binary_path" "$expected_checksum"
367
369
 
368
370
  # Verify signature (optional, only if minisign is installed)
369
371
  # Disabled until release signing is enabled
370
- # verify_signature "$binary_path" "$minisign_signature_url"
372
+ # verify_signature "$staged_binary_path" "$minisign_signature_url"
371
373
 
372
- # Make executable
373
- chmod +x "$binary_path"
374
+ # Make executable before atomically replacing the installed binary
375
+ chmod 0755 "$staged_binary_path"
376
+ mv "$staged_binary_path" "$binary_path"
374
377
 
375
378
  # Clean up checksum file
376
379
  rm -f "$checksum_path"
@@ -1,3 +1,3 @@
1
1
  """code-aide - Manage AI coding CLI tools."""
2
2
 
3
- __version__ = "1.17.0"
3
+ __version__ = "1.18.1"
@@ -16,6 +16,7 @@ from code_aide.install_types import (
16
16
  from code_aide.console import error, info, success, warning
17
17
  from code_aide.operations import (
18
18
  UpgradeResult,
19
+ clean_tool_versions,
19
20
  remove_tool,
20
21
  upgrade_tool,
21
22
  validate_tools,
@@ -46,6 +47,7 @@ from code_aide.config import (
46
47
  def cmd_install(args: argparse.Namespace) -> None:
47
48
  """Handle install command."""
48
49
  dryrun = getattr(args, "dryrun", False)
50
+ force = getattr(args, "force", False)
49
51
 
50
52
  if args.tools:
51
53
  tools_to_install = args.tools
@@ -86,7 +88,7 @@ def cmd_install(args: argparse.Namespace) -> None:
86
88
  else:
87
89
  info(f"=== Installing {tool} ===")
88
90
 
89
- outcome = install_tool(tool, dryrun=dryrun)
91
+ outcome = install_tool(tool, dryrun=dryrun, force=force)
90
92
  if outcome:
91
93
  installed.append(tool)
92
94
  installed_bin_dirs.extend(outcome.bin_dirs)
@@ -294,6 +296,56 @@ def cmd_remove(args: argparse.Namespace) -> None:
294
296
  info("No tools were removed (all were not installed)")
295
297
 
296
298
 
299
+ def cmd_clean(args: argparse.Namespace) -> None:
300
+ """Handle clean command: remove stale direct-download version dirs."""
301
+ dryrun = getattr(args, "dryrun", False)
302
+
303
+ if args.tools:
304
+ tools_to_clean = resolve_tool_names(args.tools, TOOLS)
305
+ else:
306
+ tools_to_clean = [
307
+ name
308
+ for name, config in TOOLS.items()
309
+ if parse_install_type(config.get("install_type"))
310
+ == InstallType.DIRECT_DOWNLOAD
311
+ ]
312
+ if not tools_to_clean:
313
+ info("No direct-download tools with cleanable versions")
314
+ return
315
+ info(
316
+ "No tools specified, cleaning direct-download tools: "
317
+ f"{', '.join(tools_to_clean)}"
318
+ )
319
+
320
+ validate_tools(tools_to_clean)
321
+
322
+ cleaned: Dict[str, List[str]] = {}
323
+ skipped = []
324
+ for tool in tools_to_clean:
325
+ print()
326
+ info(f"=== {'Checking' if dryrun else 'Cleaning'} {tool} ===")
327
+ removed = clean_tool_versions(tool, dryrun=dryrun)
328
+ if removed is None:
329
+ skipped.append(tool)
330
+ elif removed:
331
+ cleaned[tool] = removed
332
+
333
+ print()
334
+ print("=" * 42)
335
+ info("Cleanup Summary" + (" (dry run)" if dryrun else ""))
336
+ print("=" * 42)
337
+
338
+ if cleaned:
339
+ total = sum(len(paths) for paths in cleaned.values())
340
+ details = ", ".join(f"{tool} ({len(paths)})" for tool, paths in cleaned.items())
341
+ noun = "Would remove" if dryrun else "Removed"
342
+ success(f"{noun} {total} stale version directories: {details}")
343
+ if skipped:
344
+ info(f"Not cleanable (no versioned install layout): {', '.join(skipped)}")
345
+ if not cleaned:
346
+ success("Nothing to clean!")
347
+
348
+
297
349
  def cmd_update_versions(args: argparse.Namespace) -> None:
298
350
  """Handle update-versions command: check upstream for latest tool versions."""
299
351
  bundled = load_bundled_tools()
@@ -89,7 +89,7 @@
89
89
  "command": "amp",
90
90
  "install_type": "script",
91
91
  "install_url": "https://ampcode.com/install.sh",
92
- "install_sha256": "832d67ed6b6b4414a1578ec8064c258532703a7c4ff675bab7408a17d847c291",
92
+ "install_sha256": "07bea1669144a5191c873a61a3244266ca046557bff7123f0be41d0723592297",
93
93
  "install_script_path_prepend": [
94
94
  "~/.local/bin"
95
95
  ],
@@ -5,6 +5,7 @@ import logging
5
5
 
6
6
  from code_aide import __version__
7
7
  from code_aide.commands_actions import (
8
+ cmd_clean,
8
9
  cmd_install,
9
10
  cmd_remove,
10
11
  cmd_update_versions,
@@ -83,6 +84,12 @@ def main() -> None:
83
84
  action="store_true",
84
85
  help="Verify SHA256 checksums without installing (dry run mode)",
85
86
  )
87
+ install_parser.add_argument(
88
+ "-f",
89
+ "--force",
90
+ action="store_true",
91
+ help="Reinstall even if the command already exists in PATH",
92
+ )
86
93
  install_parser.set_defaults(func=cmd_install)
87
94
 
88
95
  upgrade_parser = subparsers.add_parser("upgrade", help="Upgrade tools")
@@ -111,6 +118,23 @@ def main() -> None:
111
118
  action="store_true",
112
119
  help="Do not prompt for confirmation",
113
120
  )
121
+ clean_parser = subparsers.add_parser(
122
+ "clean",
123
+ help="Remove stale direct-download version directories",
124
+ )
125
+ clean_parser.add_argument(
126
+ "tools",
127
+ nargs="*",
128
+ help="Tools to clean (default: direct-download tools)",
129
+ )
130
+ clean_parser.add_argument(
131
+ "-n",
132
+ "--dryrun",
133
+ action="store_true",
134
+ help="Show what would be removed without removing",
135
+ )
136
+ clean_parser.set_defaults(func=cmd_clean)
137
+
114
138
  remove_parser.set_defaults(func=cmd_remove)
115
139
 
116
140
  update_versions_parser = subparsers.add_parser(
@@ -1,10 +1,12 @@
1
1
  """Upgrade and remove operations for managed tools."""
2
2
 
3
+ import fnmatch
3
4
  import os
5
+ import shutil
4
6
  import subprocess
5
7
  import sys
6
8
  from enum import Enum
7
- from typing import Dict, List, TypedDict
9
+ from typing import Dict, List, Optional, TypedDict
8
10
 
9
11
  from code_aide.constants import TOOLS
10
12
  from code_aide.detection import (
@@ -270,6 +272,68 @@ def remove_tool(tool_name: str) -> bool:
270
272
  return False
271
273
 
272
274
 
275
+ def clean_tool_versions(tool_name: str, dryrun: bool = False) -> Optional[List[str]]:
276
+ """Remove stale version directories for a direct-download tool.
277
+
278
+ Keeps the directory the tool's symlinks currently point at. Returns
279
+ the list of removed (or, with dryrun, removable) directory paths,
280
+ or None when the tool has no versioned install layout.
281
+ """
282
+ tool_config = TOOLS[tool_name]
283
+ install_dir_template = tool_config.get("install_dir", "")
284
+ if not install_dir_template or "{version}" not in install_dir_template:
285
+ return None
286
+
287
+ expanded = os.path.expanduser(install_dir_template)
288
+ parent = os.path.dirname(expanded)
289
+ if not parent or not os.path.isdir(parent):
290
+ info(f"No install directory for {tool_config['name']}; nothing to clean")
291
+ return []
292
+
293
+ # Only consider directories matching the versioned template shape.
294
+ pattern = os.path.basename(expanded).replace("{version}", "*")
295
+
296
+ # Never remove the version the symlinks currently point at.
297
+ current_dirs = set()
298
+ bin_dir = os.path.expanduser(tool_config.get("bin_dir", ""))
299
+ for link_name in tool_config.get("symlinks", {}):
300
+ link_path = os.path.join(bin_dir, link_name)
301
+ if os.path.islink(link_path):
302
+ current_dirs.add(os.path.dirname(os.path.realpath(link_path)))
303
+
304
+ if not current_dirs:
305
+ warning(
306
+ f"Could not determine the current version directory of "
307
+ f"{tool_config['name']}; not removing anything"
308
+ )
309
+ return []
310
+
311
+ stale = []
312
+ for entry in sorted(os.listdir(parent)):
313
+ full = os.path.join(parent, entry)
314
+ if not os.path.isdir(full) or os.path.islink(full):
315
+ continue
316
+ if not fnmatch.fnmatch(entry, pattern):
317
+ continue
318
+ if full in current_dirs or os.path.realpath(full) in current_dirs:
319
+ continue
320
+ stale.append(full)
321
+
322
+ if not stale:
323
+ info(f"No stale version directories for {tool_config['name']}")
324
+ return []
325
+
326
+ if dryrun:
327
+ for path in stale:
328
+ info(f"[DRYRUN] Would remove stale version directory: {path}")
329
+ return stale
330
+
331
+ for path in stale:
332
+ shutil.rmtree(path)
333
+ info(f"Removed stale version directory: {path}")
334
+ return stale
335
+
336
+
273
337
  def validate_tools(tools: List[str]) -> None:
274
338
  """Validate that all tool names are valid."""
275
339
  invalid_tools = [tool for tool in tools if tool not in TOOLS]
@@ -360,8 +360,11 @@ def get_tool_status(tool_name: str, tool_config: Dict[str, Any]) -> ToolStatus:
360
360
  check=False,
361
361
  stdin=subprocess.DEVNULL,
362
362
  )
363
- if result.returncode == 0 and result.stdout.strip():
364
- status_info["version"] = result.stdout.strip().split("\n")[0]
363
+ if result.returncode == 0:
364
+ # Some tools print their version to stderr; prefer stdout.
365
+ output = result.stdout.strip() or result.stderr.strip()
366
+ if output:
367
+ status_info["version"] = output.split("\n")[0]
365
368
  except subprocess.TimeoutExpired:
366
369
  status_info["errors"].append("Version check timed out after 10s")
367
370
  except Exception:
@@ -48,9 +48,74 @@ class TestCmdInstall(unittest.TestCase):
48
48
  with contextlib.redirect_stdout(buf):
49
49
  commands_actions.cmd_install(args)
50
50
 
51
- mock_install_tool.assert_called_once_with("default_tool", dryrun=True)
51
+ mock_install_tool.assert_called_once_with(
52
+ "default_tool", dryrun=True, force=False
53
+ )
52
54
  mock_prereqs.assert_not_called()
53
55
 
56
+ def test_passes_force_flag_to_install_tool(self):
57
+ tools = {
58
+ "test": {
59
+ "name": "Test Tool",
60
+ "command": "test",
61
+ "install_type": "npm",
62
+ "next_steps": "run test",
63
+ }
64
+ }
65
+ args = type(
66
+ "Args",
67
+ (),
68
+ {
69
+ "tools": ["test"],
70
+ "dryrun": False,
71
+ "install_prerequisites": False,
72
+ "force": True,
73
+ },
74
+ )()
75
+
76
+ with (
77
+ mock.patch.dict(constants._TOOLS_DATA, tools, clear=True),
78
+ mock.patch.object(
79
+ commands_actions,
80
+ "install_tool",
81
+ return_value=InstallOutcome(True),
82
+ ) as mock_install,
83
+ ):
84
+ commands_actions.cmd_install(args)
85
+
86
+ mock_install.assert_called_once_with("test", dryrun=False, force=True)
87
+
88
+ def test_defaults_to_force_disabled(self):
89
+ tools = {
90
+ "test": {
91
+ "name": "Test Tool",
92
+ "command": "test",
93
+ "install_type": "npm",
94
+ "next_steps": "run test",
95
+ }
96
+ }
97
+ args = type(
98
+ "Args",
99
+ (),
100
+ {
101
+ "tools": ["test"],
102
+ "dryrun": False,
103
+ "install_prerequisites": False,
104
+ },
105
+ )()
106
+
107
+ with (
108
+ mock.patch.dict(constants._TOOLS_DATA, tools, clear=True),
109
+ mock.patch.object(
110
+ commands_actions,
111
+ "install_tool",
112
+ return_value=InstallOutcome(True),
113
+ ) as mock_install,
114
+ ):
115
+ commands_actions.cmd_install(args)
116
+
117
+ mock_install.assert_called_once_with("test", dryrun=False, force=False)
118
+
54
119
  def test_checks_bin_directories_reported_by_installers(self):
55
120
  tools = {
56
121
  "test": {
@@ -111,7 +176,7 @@ class TestCmdInstall(unittest.TestCase):
111
176
  ):
112
177
  commands_actions.cmd_install(args)
113
178
 
114
- mock_install.assert_called_once_with("antigravity", dryrun=False)
179
+ mock_install.assert_called_once_with("antigravity", dryrun=False, force=False)
115
180
 
116
181
 
117
182
  class TestCmdUpdateVersions(unittest.TestCase):
@@ -580,3 +645,88 @@ class TestCmdRemove(unittest.TestCase):
580
645
 
581
646
  mock_input.assert_not_called()
582
647
  mock_remove.assert_called_once_with("a")
648
+
649
+
650
+ class TestCmdClean(unittest.TestCase):
651
+ """Tests for cmd_clean selection and dry-run behavior."""
652
+
653
+ def _tools(self):
654
+ return {
655
+ "dl": {
656
+ "name": "DL Tool",
657
+ "command": "dl",
658
+ "install_type": "direct_download",
659
+ "install_dir": "~/.local/share/dl/versions/{version}",
660
+ },
661
+ "npmtool": {
662
+ "name": "NPM Tool",
663
+ "command": "npmtool",
664
+ "install_type": "npm",
665
+ "npm_package": "npmtool",
666
+ },
667
+ }
668
+
669
+ def test_default_clean_targets_only_direct_download_tools(self):
670
+ args = type("Args", (), {"tools": [], "dryrun": True})()
671
+ with (
672
+ mock.patch.dict(constants._TOOLS_DATA, self._tools(), clear=True),
673
+ mock.patch.object(
674
+ commands_actions, "clean_tool_versions", return_value=[]
675
+ ) as mock_clean,
676
+ contextlib.redirect_stdout(io.StringIO()) as buf,
677
+ ):
678
+ commands_actions.cmd_clean(args)
679
+
680
+ mock_clean.assert_called_once_with("dl", dryrun=True)
681
+ self.assertIn("cleaning direct-download tools: dl", buf.getvalue())
682
+
683
+ def test_named_non_cleanable_tool_is_reported(self):
684
+ args = type("Args", (), {"tools": ["npmtool"], "dryrun": False})()
685
+ with (
686
+ mock.patch.dict(constants._TOOLS_DATA, self._tools(), clear=True),
687
+ mock.patch.object(
688
+ commands_actions, "clean_tool_versions", return_value=None
689
+ ) as mock_clean,
690
+ contextlib.redirect_stdout(io.StringIO()) as buf,
691
+ ):
692
+ commands_actions.cmd_clean(args)
693
+
694
+ mock_clean.assert_called_once_with("npmtool", dryrun=False)
695
+ self.assertIn(
696
+ "Not cleanable (no versioned install layout): npmtool", buf.getvalue()
697
+ )
698
+ self.assertIn("Nothing to clean!", buf.getvalue())
699
+
700
+ def test_dryrun_summary_is_labeled(self):
701
+ args = type("Args", (), {"tools": ["dl"], "dryrun": True})()
702
+ with (
703
+ mock.patch.dict(constants._TOOLS_DATA, self._tools(), clear=True),
704
+ mock.patch.object(
705
+ commands_actions,
706
+ "clean_tool_versions",
707
+ return_value=["/tmp/stale"],
708
+ ),
709
+ contextlib.redirect_stdout(io.StringIO()) as buf,
710
+ ):
711
+ commands_actions.cmd_clean(args)
712
+
713
+ self.assertIn("Cleanup Summary (dry run)", buf.getvalue())
714
+ self.assertIn(
715
+ "Would remove 1 stale version directories: dl (1)", buf.getvalue()
716
+ )
717
+
718
+ def test_removal_summary_is_not_labeled_dry_run(self):
719
+ args = type("Args", (), {"tools": ["dl"], "dryrun": False})()
720
+ with (
721
+ mock.patch.dict(constants._TOOLS_DATA, self._tools(), clear=True),
722
+ mock.patch.object(
723
+ commands_actions,
724
+ "clean_tool_versions",
725
+ return_value=["/tmp/stale"],
726
+ ),
727
+ contextlib.redirect_stdout(io.StringIO()) as buf,
728
+ ):
729
+ commands_actions.cmd_clean(args)
730
+
731
+ self.assertIn("[INFO] Cleanup Summary", buf.getvalue())
732
+ self.assertNotIn("(dry run)", buf.getvalue())
@@ -512,3 +512,76 @@ class TestCmdUpgradeDefaultSelection(unittest.TestCase):
512
512
  cli_commands_actions.cmd_upgrade(args)
513
513
 
514
514
  mock_upgrade.assert_called_once_with("current")
515
+
516
+
517
+ class TestCleanToolVersions(unittest.TestCase):
518
+ """Tests for clean_tool_versions."""
519
+
520
+ def _setup(self, td, template_name="versions/{version}"):
521
+ parent = os.path.join(td, os.path.dirname(template_name))
522
+ os.makedirs(parent, exist_ok=True)
523
+ version_dirs = []
524
+ for v in ("1.0.0", "2.0.0"):
525
+ version_dir = os.path.join(
526
+ parent, template_name.replace("{version}", v).split("/")[-1]
527
+ )
528
+ os.makedirs(version_dir)
529
+ with open(os.path.join(version_dir, "tool-bin"), "w") as f:
530
+ f.write("#!/bin/sh\n")
531
+ version_dirs.append(version_dir)
532
+ bin_dir = os.path.join(td, "bin")
533
+ os.makedirs(bin_dir)
534
+ os.symlink(
535
+ os.path.join(version_dirs[1], "tool-bin"),
536
+ os.path.join(bin_dir, "tool"),
537
+ )
538
+ return {
539
+ "name": "Test Tool",
540
+ "command": "tool",
541
+ "install_type": "direct_download",
542
+ "install_dir": os.path.join(td, template_name),
543
+ "bin_dir": bin_dir,
544
+ "symlinks": {"tool": "tool-bin"},
545
+ }
546
+
547
+ def _clean(self, cfg, dryrun=False):
548
+ with mock.patch.dict(constants._TOOLS_DATA, {"test": cfg}, clear=True):
549
+ return cli_operations.clean_tool_versions("test", dryrun=dryrun)
550
+
551
+ def test_dryrun_lists_stale_without_removing(self):
552
+ with tempfile.TemporaryDirectory() as td:
553
+ cfg = self._setup(td)
554
+ stale = self._clean(cfg, dryrun=True)
555
+ self.assertEqual(stale, [os.path.join(td, "versions", "1.0.0")])
556
+ self.assertTrue(os.path.isdir(os.path.join(td, "versions", "1.0.0")))
557
+
558
+ def test_removes_stale_and_keeps_current(self):
559
+ with tempfile.TemporaryDirectory() as td:
560
+ cfg = self._setup(td)
561
+ stale = self._clean(cfg)
562
+ self.assertEqual(stale, [os.path.join(td, "versions", "1.0.0")])
563
+ self.assertFalse(os.path.exists(os.path.join(td, "versions", "1.0.0")))
564
+ self.assertTrue(os.path.isdir(os.path.join(td, "versions", "2.0.0")))
565
+
566
+ def test_prefix_pattern_leaves_unrelated_dirs(self):
567
+ with tempfile.TemporaryDirectory() as td:
568
+ cfg = self._setup(td, template_name="store/pkg-{version}")
569
+ os.makedirs(os.path.join(td, "store", "data"))
570
+ stale = self._clean(cfg)
571
+ self.assertEqual(stale, [os.path.join(td, "store", "pkg-1.0.0")])
572
+ self.assertTrue(os.path.isdir(os.path.join(td, "store", "data")))
573
+
574
+ def test_no_current_version_removes_nothing(self):
575
+ with tempfile.TemporaryDirectory() as td:
576
+ cfg = self._setup(td)
577
+ os.remove(os.path.join(td, "bin", "tool"))
578
+ stale = self._clean(cfg)
579
+ self.assertEqual(stale, [])
580
+ self.assertTrue(os.path.isdir(os.path.join(td, "versions", "1.0.0")))
581
+ self.assertTrue(os.path.isdir(os.path.join(td, "versions", "2.0.0")))
582
+
583
+ def test_tool_without_versioned_layout_returns_none(self):
584
+ with tempfile.TemporaryDirectory() as td:
585
+ cfg = self._setup(td)
586
+ cfg["install_dir"] = os.path.join(td, "flat")
587
+ self.assertIsNone(self._clean(cfg))
@@ -0,0 +1,57 @@
1
+ """Unit tests for package version info lookups."""
2
+
3
+ import subprocess
4
+ import unittest
5
+ from unittest import mock
6
+
7
+ from code_aide import package_info as cli_package_info
8
+
9
+
10
+ class TestGetSystemPackageInfo(unittest.TestCase):
11
+ """Tests for get_system_package_info."""
12
+
13
+ def test_returns_empty_info_when_qfile_missing(self):
14
+ with mock.patch.object(cli_package_info, "command_exists", return_value=False):
15
+ info = cli_package_info.get_system_package_info("/usr/bin/tool")
16
+ self.assertEqual(
17
+ info,
18
+ {
19
+ "package": None,
20
+ "installed_version": None,
21
+ "available_version": None,
22
+ "available_date": None,
23
+ },
24
+ )
25
+
26
+ def test_returns_empty_info_when_qfile_finds_nothing(self):
27
+ no_match = subprocess.CompletedProcess(["qfile"], 1, stdout="", stderr="")
28
+ with (
29
+ mock.patch.object(cli_package_info, "command_exists", return_value=True),
30
+ mock.patch.object(
31
+ cli_package_info.subprocess, "run", return_value=no_match
32
+ ),
33
+ ):
34
+ info = cli_package_info.get_system_package_info("/usr/bin/tool")
35
+ self.assertIsNone(info["package"])
36
+ self.assertIsNone(info["installed_version"])
37
+
38
+ def test_resolves_installed_version_through_qlist_and_qatom(self):
39
+ responses = [
40
+ subprocess.CompletedProcess(
41
+ ["qfile"], 0, stdout="dev-util/tool\n", stderr=""
42
+ ),
43
+ subprocess.CompletedProcess(
44
+ ["qlist"], 0, stdout="dev-util/tool-1.4.2\n", stderr=""
45
+ ),
46
+ subprocess.CompletedProcess(["qatom"], 0, stdout="1.4.2\n", stderr=""),
47
+ ]
48
+ with (
49
+ mock.patch.object(cli_package_info, "command_exists", return_value=True),
50
+ mock.patch.object(
51
+ cli_package_info.subprocess, "run", side_effect=responses
52
+ ),
53
+ mock.patch.object(cli_package_info.globmod, "glob", return_value=[]),
54
+ ):
55
+ info = cli_package_info.get_system_package_info("/usr/bin/tool")
56
+ self.assertEqual(info["package"], "dev-util/tool")
57
+ self.assertEqual(info["installed_version"], "1.4.2")
@@ -1,5 +1,6 @@
1
1
  """Unit tests for status-rendering helpers."""
2
2
 
3
+ import subprocess
3
4
  import unittest
4
5
  from unittest import mock
5
6
 
@@ -371,3 +372,48 @@ class TestToolUpgradeEvaluatorDiscovery(unittest.TestCase):
371
372
  assessment.decision, cli_status.UpgradeDecision.PACKAGE_MANAGED
372
373
  )
373
374
  mock_pkg.assert_called_once_with("/usr/bin/sys-tool")
375
+
376
+
377
+ class TestGetToolStatus(unittest.TestCase):
378
+ """Tests for get_tool_status version capture."""
379
+
380
+ _TOOL_CONFIG = {"name": "Test Tool", "command": "test-tool"}
381
+
382
+ @staticmethod
383
+ def _completed(returncode=0, stdout="", stderr=""):
384
+ return subprocess.CompletedProcess(
385
+ ["test-tool", "--version"], returncode, stdout=stdout, stderr=stderr
386
+ )
387
+
388
+ def _run(self, **kwargs):
389
+ with (
390
+ mock.patch.object(cli_status, "is_tool_installed", return_value=True),
391
+ mock.patch.object(
392
+ cli_status.subprocess, "run", return_value=self._completed(**kwargs)
393
+ ),
394
+ ):
395
+ return cli_status.get_tool_status("test", self._TOOL_CONFIG)
396
+
397
+ def test_captures_version_from_stdout(self):
398
+ status = self._run(stdout="1.2.3\n")
399
+ self.assertEqual(status["version"], "1.2.3")
400
+
401
+ def test_captures_version_from_stderr_when_stdout_empty(self):
402
+ status = self._run(stdout="", stderr="2.0.0\n")
403
+ self.assertEqual(status["version"], "2.0.0")
404
+
405
+ def test_stdout_wins_over_stderr(self):
406
+ status = self._run(stdout="1.2.3\n", stderr="noise\n")
407
+ self.assertEqual(status["version"], "1.2.3")
408
+
409
+ def test_nonzero_exit_leaves_version_missing(self):
410
+ status = self._run(returncode=1, stdout="1.2.3\n")
411
+ self.assertIsNone(status["version"])
412
+
413
+ def test_not_installed_short_circuits(self):
414
+ with mock.patch.object(
415
+ cli_status, "is_tool_installed", return_value=False
416
+ ) as mock_installed:
417
+ status = cli_status.get_tool_status("test", self._TOOL_CONFIG)
418
+ mock_installed.assert_called_once_with("test")
419
+ self.assertIsNone(status["version"])
@@ -420,3 +420,54 @@ class TestExtractScriptVersion(unittest.TestCase):
420
420
  )
421
421
 
422
422
  self.assertEqual(result, "1.0.0")
423
+
424
+
425
+ class TestFetchUrl(unittest.TestCase):
426
+ """Tests for fetch_url network fetch behavior."""
427
+
428
+ @staticmethod
429
+ def _response(content=b"payload", last_modified="Tue, 25 Aug 2026 10:00:00 GMT"):
430
+ response = mock.Mock()
431
+ response.read.return_value = content
432
+ response.headers = {"Last-Modified": last_modified}
433
+ response.__enter__ = mock.Mock(return_value=response)
434
+ response.__exit__ = mock.Mock(return_value=False)
435
+ return response
436
+
437
+ def test_returns_content_and_last_modified(self):
438
+ with mock.patch.object(
439
+ cli_versions.urllib.request,
440
+ "urlopen",
441
+ return_value=self._response(content=b"abc"),
442
+ ) as mock_urlopen:
443
+ content, last_modified = cli_versions.fetch_url("https://example.com/x")
444
+ self.assertEqual(content, b"abc")
445
+ self.assertEqual(last_modified, "Tue, 25 Aug 2026 10:00:00 GMT")
446
+ request = mock_urlopen.call_args[0][0]
447
+ self.assertEqual(request.full_url, "https://example.com/x")
448
+ self.assertEqual(mock_urlopen.call_args[1]["timeout"], 30)
449
+
450
+ def test_sends_code_aide_user_agent(self):
451
+ with mock.patch.object(
452
+ cli_versions.urllib.request, "urlopen", return_value=self._response()
453
+ ) as mock_urlopen:
454
+ cli_versions.fetch_url("https://example.com/x")
455
+ request = mock_urlopen.call_args[0][0]
456
+ self.assertIn("code-aide/", request.headers.get("User-agent", ""))
457
+
458
+ def test_last_modified_is_none_when_header_absent(self):
459
+ with mock.patch.object(
460
+ cli_versions.urllib.request,
461
+ "urlopen",
462
+ return_value=self._response(last_modified=None),
463
+ ):
464
+ content, last_modified = cli_versions.fetch_url("https://example.com/x")
465
+ self.assertEqual(content, b"payload")
466
+ self.assertIsNone(last_modified)
467
+
468
+ def test_custom_timeout_is_passed(self):
469
+ with mock.patch.object(
470
+ cli_versions.urllib.request, "urlopen", return_value=self._response()
471
+ ) as mock_urlopen:
472
+ cli_versions.fetch_url("https://example.com/x", timeout=5)
473
+ self.assertEqual(mock_urlopen.call_args[1]["timeout"], 5)
@@ -1 +0,0 @@
1
- See AGENTS.md for project instructions.
code_aide-1.17.0/TODO.md DELETED
@@ -1,77 +0,0 @@
1
- # TODO
2
-
3
- Keep the project focused on managing AI coding CLI tools (install, upgrade,
4
- remove, status, and version metadata).
5
-
6
- ## Summary
7
-
8
- | # | Item | Risk | Effort | Notes |
9
- |:---|:--------------------------------------------|:---------|:--------|:-------------------------------------------------------------------|
10
- | 1 | ~~Handle missing `node` cleanly~~ (done) | - | - | Fixed in `prereqs.py` `check_prerequisites()` |
11
- | 2 | Read version from stdout+stderr | Med | Low | Some tools write version to stderr; shown as not installed |
12
- | 3 | ~~Atomic version cache writes~~ (done) | - | - | Fixed in `config.py` `save_versions_cache()` |
13
- | 4 | ~~Warn on invalid cache JSON~~ (done) | - | - | `config.py` `load_versions_cache()` logs a warning |
14
- | 5 | ~~Use `os.pathsep` not `":"`~~ (done) | - | - | Fixed in `prereqs.py` `check_path_directories()` |
15
- | 6 | ~~Verify direct-download tarballs~~ (done) | - | - | `download_sha256` recorded by update-versions, verified on install |
16
- | 7 | ~~Tarball checksum metadata fields~~ (done) | - | - | `download_sha256` field in tools schema and versions cache |
17
- | 8 | `--json` output mode | Low | Med | Needed for CI/automation consumers |
18
- | 9 | `doctor` command | Low | Med | Health checks exist but are scattered |
19
- | 10 | `install --force` | Low | Trivial | Internal `force` param exists; not exposed in CLI |
20
- | 11 | Cleanup stale direct-download versions | Low | Med | Old version dirs accumulate on disk |
21
- | 12 | Document default subcommand | Very Low | Trivial | One line in README |
22
- | 13 | Document `install --dryrun` | Very Low | Trivial | One line in README |
23
- | 14 | Document env vars and config paths | Low | Low | `XDG_CONFIG_HOME` undocumented |
24
- | 15 | Cross-distro package detection | Med | High | Only Gentoo supported; other Linux distros get nothing |
25
- | 16 | Split large modules | Low | High | Refactoring only; no behavior change |
26
- | 17 | Tests for untested functions | Med | High | `fetch_url`, `get_system_package_info` |
27
- | 18 | Tests for edge cases | Med | Med | PATH oddities, more version parsing corners |
28
-
29
- ## Reliability and Correctness
30
-
31
- - [x] Handle missing `node` cleanly during prerequisite checks (`OSError` path
32
- in Node version probing).
33
- - [ ] Read tool version output from both stdout and stderr so status does not
34
- miss installed versions.
35
- - [x] Make version cache writes atomic (temp file + rename) to avoid
36
- partial/corrupted `versions.json`.
37
- - [x] Warn when `versions.json` cache contains invalid JSON instead of silently
38
- returning empty data.
39
- - [x] Use `os.pathsep` instead of hardcoded `":"` in `prereqs.py`
40
- `check_path_directories()`.
41
-
42
- ## Security and Integrity
43
-
44
- - [x] Verify direct-download tarballs against a checksum recorded by
45
- `update-versions` (warn when no checksum has been recorded yet).
46
- - [x] Extend tool metadata with a `download_sha256` field for tarball checksums.
47
- - [ ] Consider signature verification (beyond SHA256) for downloads where
48
- upstream publishes signatures.
49
-
50
- ## CLI and Automation UX
51
-
52
- - [ ] Add `--json` output mode for `list`, `status`, and `update-versions`.
53
- - [ ] Add a focused `doctor` command for environment checks (PATH,
54
- prerequisites, command health).
55
- - [ ] Consider `install --force` for reinstall/repair flows.
56
- - [ ] Add cleanup support for stale direct-download versions no longer in use.
57
-
58
- ## Documentation
59
-
60
- - [ ] Document that running `code-aide` with no subcommand defaults to `status`.
61
- - [ ] Add `install --dryrun` to the README usage examples.
62
- - [ ] Document environment variables and config file paths
63
- (`~/.config/code-aide/versions.json`).
64
-
65
- ## Platform and Package Detection
66
-
67
- - [ ] Broaden system package metadata detection beyond Gentoo-specific tooling
68
- where practical.
69
-
70
- ## Maintainability and Tests
71
-
72
- - [ ] Split larger modules into smaller focused files where practical
73
- (`commands_actions.py`, `versions.py`, `install.py`).
74
- - [ ] Add tests for remaining untested functions:
75
- - `fetch_url()` (network; needs mocking)
76
- - `get_system_package_info()` (Gentoo tooling)
77
- - [ ] Add tests for more prerequisite edge cases and PATH oddities.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes