code-aide 1.15.5__tar.gz → 1.15.7__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 (61) hide show
  1. {code_aide-1.15.5 → code_aide-1.15.7}/PKG-INFO +1 -1
  2. {code_aide-1.15.5 → code_aide-1.15.7}/script-archive/amp-install.sh +111 -40
  3. {code_aide-1.15.5 → code_aide-1.15.7}/script-archive/claude-install.sh +29 -1
  4. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/__init__.py +1 -1
  5. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/commands_actions.py +5 -2
  6. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/data/tools.json +2 -2
  7. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/install.py +49 -13
  8. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/prereqs.py +9 -14
  9. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_commands_actions.py +34 -1
  10. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_install.py +16 -0
  11. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_prereqs.py +3 -9
  12. {code_aide-1.15.5 → code_aide-1.15.7}/.github/workflows/ci.yml +0 -0
  13. {code_aide-1.15.5 → code_aide-1.15.7}/.github/workflows/publish.yml +0 -0
  14. {code_aide-1.15.5 → code_aide-1.15.7}/.gitignore +0 -0
  15. {code_aide-1.15.5 → code_aide-1.15.7}/.gitlab-ci.yml +0 -0
  16. {code_aide-1.15.5 → code_aide-1.15.7}/.pre-commit-config.yaml +0 -0
  17. {code_aide-1.15.5 → code_aide-1.15.7}/AGENTS.md +0 -0
  18. {code_aide-1.15.5 → code_aide-1.15.7}/CLAUDE.md +0 -0
  19. {code_aide-1.15.5 → code_aide-1.15.7}/LICENSE +0 -0
  20. {code_aide-1.15.5 → code_aide-1.15.7}/README.md +0 -0
  21. {code_aide-1.15.5 → code_aide-1.15.7}/TODO.md +0 -0
  22. {code_aide-1.15.5 → code_aide-1.15.7}/pyproject.toml +0 -0
  23. {code_aide-1.15.5 → code_aide-1.15.7}/script-archive/README.md +0 -0
  24. {code_aide-1.15.5 → code_aide-1.15.7}/script-archive/cursor-install.sh +0 -0
  25. {code_aide-1.15.5 → code_aide-1.15.7}/specs/2026-05-03-qwen3.6-code-quality-analysis-original.md +0 -0
  26. {code_aide-1.15.5 → code_aide-1.15.7}/specs/2026-05-03-qwen3.6-code-quality-analysis.md +0 -0
  27. {code_aide-1.15.5 → code_aide-1.15.7}/specs/auto-migrate-deprecated-installs.md +0 -0
  28. {code_aide-1.15.5 → code_aide-1.15.7}/specs/claude-native-installer-migration.md +0 -0
  29. {code_aide-1.15.5 → code_aide-1.15.7}/specs/missing-coding-llm-cli-tools.md +0 -0
  30. {code_aide-1.15.5 → code_aide-1.15.7}/specs/pre-commit-uv-setup.md +0 -0
  31. {code_aide-1.15.5 → code_aide-1.15.7}/specs/remove-bundled-version-baseline.md +0 -0
  32. {code_aide-1.15.5 → code_aide-1.15.7}/specs/unify-upgrade-eligibility-with-shared-evaluator.md +0 -0
  33. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/__main__.py +0 -0
  34. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/commands_tools.py +0 -0
  35. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/config.py +0 -0
  36. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/console.py +0 -0
  37. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/constants.py +0 -0
  38. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/detection.py +0 -0
  39. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/detection_types.py +0 -0
  40. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/entry.py +0 -0
  41. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/install_types.py +0 -0
  42. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/operation_handlers.py +0 -0
  43. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/operations.py +0 -0
  44. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/package_info.py +0 -0
  45. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/package_managers.py +0 -0
  46. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/status.py +0 -0
  47. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/status_print.py +0 -0
  48. {code_aide-1.15.5 → code_aide-1.15.7}/src/code_aide/versions.py +0 -0
  49. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_commands_tools.py +0 -0
  50. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_config.py +0 -0
  51. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_console.py +0 -0
  52. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_constants.py +0 -0
  53. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_detection.py +0 -0
  54. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_entry.py +0 -0
  55. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_error_paths.py +0 -0
  56. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_install_types.py +0 -0
  57. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_operations.py +0 -0
  58. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_package_managers.py +0 -0
  59. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_status.py +0 -0
  60. {code_aide-1.15.5 → code_aide-1.15.7}/tests/test_versions.py +0 -0
  61. {code_aide-1.15.5 → code_aide-1.15.7}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-aide
3
- Version: 1.15.5
3
+ Version: 1.15.7
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
@@ -8,40 +8,108 @@ AMP_STORAGE_BASE="${AMP_STORAGE_BASE:-https://static.ampcode.com}"
8
8
  AMP_URL="${AMP_URL:-https://ampcode.com}"
9
9
  AMP_VERSION="${AMP_VERSION:-}"
10
10
 
11
- # Colors for output
12
- RED='\033[0;31m'
13
- GREEN='\033[0;32m'
14
- YELLOW='\033[1;33m'
15
- BLUE='\033[0;34m'
16
- NC='\033[0m' # No Color
17
-
18
- # Cleanup on interrupt
19
- cleanup() {
20
- echo -e "\n${YELLOW}Installation interrupted...${NC}"
21
- rm -f "$AMP_HOME/amp-install-"* 2>/dev/null || true
22
- rm -f "$BIN_DIR/tmp."* 2>/dev/null || true
23
- exit 1
11
+ if [[ -t 2 ]]; then
12
+ TTY_OUTPUT=1
13
+ else
14
+ TTY_OUTPUT=0
15
+ fi
16
+
17
+ if [[ $TTY_OUTPUT -eq 1 && -z "${NO_COLOR:-}" ]] && command -v tput >/dev/null 2>&1; then
18
+ COLOR_COUNT="$(tput colors 2>/dev/null || printf '0')"
19
+ else
20
+ COLOR_COUNT=0
21
+ fi
22
+
23
+ if [[ "$COLOR_COUNT" =~ ^[0-9]+$ && "$COLOR_COUNT" -ge 8 ]]; then
24
+ BOLD="$(tput bold 2>/dev/null || true)"
25
+ DIM="$(tput dim 2>/dev/null || true)"
26
+ RESET="$(tput sgr0 2>/dev/null || true)"
27
+ ACCENT="$(tput setaf 6 2>/dev/null || true)"
28
+ SUCCESS="$(tput setaf 2 2>/dev/null || true)"
29
+ WARNING="$(tput setaf 3 2>/dev/null || true)"
30
+ ERROR="$(tput setaf 1 2>/dev/null || true)"
31
+ else
32
+ BOLD=''
33
+ DIM=''
34
+ RESET=''
35
+ ACCENT=''
36
+ SUCCESS=''
37
+ WARNING=''
38
+ ERROR=''
39
+ fi
40
+
41
+ case ":$TTY_OUTPUT:${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in
42
+ :1:*UTF-8* | :1:*utf8* | :1:*UTF8*)
43
+ ORB_LINES=' · · ·
44
+ · ◍ ● ◍ ·
45
+ · ● AMP ● ·
46
+ · ◍ ● ◍ ·
47
+ · · ·'
48
+ STEP_GLYPH='·──◍'
49
+ SUCCESS_GLYPH='◍──·'
50
+ WARN_GLYPH='·─!'
51
+ ERROR_GLYPH='·─╳'
52
+ ;;
53
+ *)
54
+ ORB_LINES=' . . .
55
+ . o O o .
56
+ . O AMP O .
57
+ . o O o .
58
+ . . .'
59
+ STEP_GLYPH='-->'
60
+ SUCCESS_GLYPH='<--'
61
+ WARN_GLYPH='!'
62
+ ERROR_GLYPH='x'
63
+ ;;
64
+ esac
65
+
66
+ print_header() {
67
+ if [[ $TTY_OUTPUT -eq 1 ]]; then
68
+ printf '\n' >&2
69
+ printf '%s\n' "$ORB_LINES" | while IFS= read -r line; do
70
+ printf '%s%s%s\n' "$ACCENT" "$line" "$RESET" >&2
71
+ done
72
+ printf '\n%s%s%s\n' "$BOLD$ACCENT" 'AMP CLI INSTALLER' "$RESET" >&2
73
+ printf '%s%s%s\n\n' "$DIM" \
74
+ 'Installs Amp into ~/.amp/bin and links it onto your PATH.' "$RESET" >&2
75
+ else
76
+ printf 'Amp CLI installer\n' >&2
77
+ fi
24
78
  }
25
79
 
26
- trap cleanup INT TERM
27
-
28
80
  log() {
29
- echo -e "${BLUE}[INFO]${NC} $1" >&2
81
+ printf '%s%s%s %s\n' "$ACCENT" "$STEP_GLYPH" "$RESET" "$1" >&2
30
82
  }
31
83
 
32
84
  warn() {
33
- echo -e "${YELLOW}[WARN]${NC} $1" >&2
85
+ printf '%s%s%s %s\n' "$WARNING" "$WARN_GLYPH" "$RESET" "$1" >&2
34
86
  }
35
87
 
36
88
  error() {
37
- echo -e "${RED}[ERROR]${NC} $1" >&2
89
+ printf '%s%s%s %b\n' "$ERROR" "$ERROR_GLYPH" "$RESET" "$1" >&2
38
90
  exit 1
39
91
  }
40
92
 
41
93
  success() {
42
- echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
94
+ printf '%s%s%s %s%s%s\n' "$SUCCESS" "$SUCCESS_GLYPH" "$RESET" \
95
+ "$BOLD" "$1" "$RESET" >&2
96
+ }
97
+
98
+ detail() {
99
+ printf ' %s%s%s\n' "$DIM" "$1" "$RESET" >&2
43
100
  }
44
101
 
102
+ # Cleanup on interrupt
103
+ cleanup() {
104
+ printf '\n' >&2
105
+ warn 'Installation interrupted'
106
+ rm -f "$AMP_HOME/amp-install-"* 2>/dev/null || true
107
+ rm -f "$BIN_DIR/tmp."* 2>/dev/null || true
108
+ exit 1
109
+ }
110
+
111
+ trap cleanup INT TERM
112
+
45
113
  # Check if command exists
46
114
  command_exists() {
47
115
  command -v "$1" >/dev/null 2>&1
@@ -155,8 +223,9 @@ downloader() {
155
223
  download_file() {
156
224
  local url="$1"
157
225
  local output_file="$2"
226
+ local label="${3:-$(basename "$output_file")}"
158
227
 
159
- log "Downloading $(basename "$output_file")..."
228
+ log "Downloading $label"
160
229
 
161
230
  # Use secure temporary file
162
231
  local temp_file
@@ -171,8 +240,9 @@ download_file() {
171
240
  download_gzipped_file() {
172
241
  local url="$1"
173
242
  local output_file="$2"
243
+ local label="${3:-$(basename "$output_file").gz}"
174
244
 
175
- log "Downloading $(basename "$output_file").gz..."
245
+ log "Downloading $label"
176
246
 
177
247
  local temp_gz_file
178
248
  temp_gz_file=$(mktemp "$(dirname "$output_file")/tmp.XXXXXX.gz")
@@ -198,7 +268,7 @@ verify_checksum() {
198
268
  local file="$1"
199
269
  local expected_checksum="$2"
200
270
 
201
- log "Verifying checksum..."
271
+ log "Verifying checksum"
202
272
 
203
273
  local actual_checksum
204
274
  actual_checksum=$($SHA256_CMD "$file" | cut -d' ' -f1)
@@ -223,8 +293,8 @@ verify_signature() {
223
293
  local signature_path="$AMP_HOME/amp-install-signature.minisign"
224
294
  local pubkey_path="$AMP_HOME/signing-key.pub"
225
295
 
226
- download_file "$signature_url" "$signature_path"
227
- download_file "$AMP_URL/.well-known/signing-key.pub" "$pubkey_path"
296
+ download_file "$signature_url" "$signature_path" 'signature'
297
+ download_file "$AMP_URL/.well-known/signing-key.pub" "$pubkey_path" 'signing key'
228
298
 
229
299
  if ! minisign -Vm "$file" -x "$signature_path" -p "$pubkey_path" >/dev/null 2>&1; then
230
300
  rm -f "$signature_path" "$pubkey_path"
@@ -240,7 +310,7 @@ fetch_latest_version() {
240
310
  local version_url="$AMP_STORAGE_BASE/cli/cli-version.txt"
241
311
  local version_path="$AMP_HOME/amp-install-version.txt"
242
312
 
243
- download_file "$version_url" "$version_path"
313
+ download_file "$version_url" "$version_path" 'latest version'
244
314
  cat "$version_path"
245
315
  rm -f "$version_path"
246
316
  }
@@ -262,7 +332,7 @@ install_amp_binary() {
262
332
  else
263
333
  # Fetch version first to ensure consistent downloads (avoids race conditions
264
334
  # where a new version is published mid-download)
265
- log "Fetching latest version..."
335
+ log "Fetching latest version"
266
336
  version=$(fetch_latest_version)
267
337
  log "Installing version: $version"
268
338
  fi
@@ -280,16 +350,16 @@ install_amp_binary() {
280
350
  local checksum_path="$AMP_HOME/amp-install-checksum.txt"
281
351
 
282
352
  # Download checksum first
283
- download_file "$checksum_url" "$checksum_path"
353
+ download_file "$checksum_url" "$checksum_path" 'checksum'
284
354
  local expected_checksum
285
355
  expected_checksum=$(cat "$checksum_path")
286
356
 
287
357
  if has_gzip_support; then
288
358
  # Download compressed binary and verify the decompressed bytes against checksum
289
- download_gzipped_file "${binary_url}.gz" "$binary_path"
359
+ download_gzipped_file "${binary_url}.gz" "$binary_path" 'Amp binary'
290
360
  else
291
361
  warn "gzip not found; downloading uncompressed binary"
292
- download_file "$binary_url" "$binary_path"
362
+ download_file "$binary_url" "$binary_path" 'Amp binary'
293
363
  fi
294
364
 
295
365
  # Verify checksum
@@ -445,20 +515,20 @@ update_shell_profile() {
445
515
 
446
516
  # Ask user before modifying shell config
447
517
  local tilde_profile="${shell_profile/#$HOME/\~}"
448
- echo ""
518
+ printf '\n'
449
519
  if [[ -t 0 ]]; then
450
520
  # Interactive: ask user
451
521
  read -p "Add ~/.local/bin to your PATH in $tilde_profile? [y/n] " -n 1 -r
452
- echo ""
522
+ printf '\n'
453
523
  if [[ ! $REPLY =~ ^[Yy]$ ]]; then
454
524
  log "Skipped modifying shell config."
455
525
  log "To use amp, add ~/.local/bin to your PATH manually:"
456
- echo " $path_export"
526
+ printf ' %s\n' "$path_export"
457
527
  return
458
528
  fi
459
529
  else
460
530
  # Non-interactive: add automatically
461
- log "Adding ~/.local/bin to PATH in $tilde_profile..."
531
+ log "Adding ~/.local/bin to PATH in $tilde_profile"
462
532
  fi
463
533
 
464
534
  # Create config file if it doesn't exist
@@ -475,16 +545,18 @@ update_shell_profile() {
475
545
  } >> "$shell_profile"
476
546
 
477
547
  success "Added ~/.local/bin to PATH in $tilde_profile"
478
- echo ""
548
+ printf '\n'
479
549
  log "To use amp immediately, run:"
480
- echo " $path_export"
550
+ printf ' %s\n' "$path_export"
481
551
  }
482
552
 
483
553
  # Main installation
484
554
  main() {
485
- log "Starting Amp CLI binary installation..."
555
+ print_header
556
+ detail "Install directory: $BIN_DIR"
486
557
 
487
558
  # Check prerequisites
559
+ log "Checking prerequisites"
488
560
  check_prereqs
489
561
 
490
562
  # Create directories
@@ -501,10 +573,9 @@ main() {
501
573
  # Update shell profile
502
574
  update_shell_profile
503
575
 
504
- echo ""
505
- success "Amp CLI installed successfully!"
506
- log "Run 'amp --help' to get started"
507
- log "Visit $AMP_URL for documentation"
576
+ success "Amp CLI installed"
577
+ detail "Run 'amp --help' to get started"
578
+ detail "Docs: $AMP_URL/manual"
508
579
  }
509
580
 
510
581
  main "$@"
@@ -155,11 +155,39 @@ chmod +x "$binary_path"
155
155
 
156
156
  # Run claude install to set up launcher and shell integration
157
157
  echo "Setting up Claude Code..."
158
- "$binary_path" install ${TARGET:+"$TARGET"}
158
+ install_code=0
159
+ "$binary_path" install ${TARGET:+"$TARGET"} || install_code=$?
159
160
 
160
161
  # Clean up downloaded file
161
162
  rm -f "$binary_path"
162
163
 
164
+ if [ "$install_code" -ne 0 ]; then
165
+ # A signal death mid-install kills the binary's TUI with no chance to
166
+ # restore the terminal, leaving the user's shell in raw mode (typed
167
+ # characters stop echoing). Restore it before printing anything.
168
+ if [ "$install_code" -ge 128 ] && [ -t 0 ]; then
169
+ stty sane 2>/dev/null || true
170
+ fi
171
+ # Red when stderr is a terminal, so the explanation stands out from the
172
+ # surrounding install output; plain when piped or captured
173
+ red="" reset=""
174
+ if [ -t 2 ]; then
175
+ red=$'\033[31m'
176
+ reset=$'\033[0m'
177
+ fi
178
+ # Signal deaths (exit code 128+N) print nothing of their own — bash shows
179
+ # only e.g. "Killed". 137 = SIGKILL, which on Linux is almost always the
180
+ # kernel OOM killer on small hosts; macOS has no equivalent OOM kill, so
181
+ # the out-of-memory explanation is Linux-only.
182
+ if [ "$install_code" -eq 137 ] && [ "$os" = "linux" ]; then
183
+ echo "${red}Installation was killed before it could finish (exit code 137). This usually means the system ran out of memory.${reset}" >&2
184
+ echo "${red}Claude Code needs roughly 512MB of free memory to install. Free up memory, then run this script again.${reset}" >&2
185
+ elif [ "$install_code" -ge 128 ]; then
186
+ echo "${red}Installation was killed before it could finish (exit code $install_code).${reset}" >&2
187
+ fi
188
+ exit "$install_code"
189
+ fi
190
+
163
191
  echo ""
164
192
  echo "✅ Installation complete!"
165
193
  echo ""
@@ -1,3 +1,3 @@
1
1
  """code-aide - Manage AI coding CLI tools."""
2
2
 
3
- __version__ = "1.15.5"
3
+ __version__ = "1.15.7"
@@ -73,6 +73,7 @@ def cmd_install(args: argparse.Namespace) -> None:
73
73
  )
74
74
 
75
75
  installed = []
76
+ installed_bin_dirs = []
76
77
  failed = []
77
78
 
78
79
  for tool in tools_to_install:
@@ -82,8 +83,10 @@ def cmd_install(args: argparse.Namespace) -> None:
82
83
  else:
83
84
  info(f"=== Installing {tool} ===")
84
85
 
85
- if install_tool(tool, dryrun=dryrun):
86
+ outcome = install_tool(tool, dryrun=dryrun)
87
+ if outcome:
86
88
  installed.append(tool)
89
+ installed_bin_dirs.extend(outcome.bin_dirs)
87
90
  else:
88
91
  failed.append(tool)
89
92
 
@@ -120,7 +123,7 @@ def cmd_install(args: argparse.Namespace) -> None:
120
123
  print(f" {tool_config['next_steps']}")
121
124
 
122
125
  print()
123
- check_path_directories(installed)
126
+ check_path_directories(installed_bin_dirs)
124
127
  success("All installations completed successfully!")
125
128
 
126
129
 
@@ -28,7 +28,7 @@
28
28
  "command": "claude",
29
29
  "install_type": "script",
30
30
  "install_url": "https://claude.ai/install.sh",
31
- "install_sha256": "005ec1a937f32dfbb74f9e810287bcb12cba2d5cae4c9277aa8c6364adbf1787",
31
+ "install_sha256": "b3f79015b54c751440a6488f07b1b64f9088742b9052bc1bd356d13108320d2a",
32
32
  "version_url": "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest",
33
33
  "prerequisites": [],
34
34
  "min_node_version": null,
@@ -64,7 +64,7 @@
64
64
  "command": "amp",
65
65
  "install_type": "script",
66
66
  "install_url": "https://ampcode.com/install.sh",
67
- "install_sha256": "4526d2cacd0254cd8aafd3cd27705f088598a4e21b72a3cdca503d157779e7b7",
67
+ "install_sha256": "4ceab71af4f5a126d8ffe0e1af3b06f210be67eae93c8e4583793a52064fc2e2",
68
68
  "install_script_path_prepend": [
69
69
  "~/.local/bin"
70
70
  ],
@@ -8,6 +8,7 @@ import shutil
8
8
  import subprocess
9
9
  import tarfile
10
10
  import tempfile
11
+ from dataclasses import dataclass
11
12
  from typing import Any, Dict, Optional
12
13
 
13
14
  from code_aide.constants import TOOLS
@@ -18,6 +19,29 @@ from code_aide.versions import check_script_tool, fetch_url
18
19
  DOWNLOAD_TIMEOUT_SECONDS = 120
19
20
 
20
21
 
22
+ @dataclass(frozen=True)
23
+ class InstallOutcome:
24
+ """Result of an install, including directories that may need PATH setup."""
25
+
26
+ succeeded: bool
27
+ bin_dirs: tuple[str, ...] = ()
28
+
29
+ def __bool__(self) -> bool:
30
+ return self.succeeded
31
+
32
+
33
+ def get_npm_global_bin_dir() -> Optional[str]:
34
+ """Return the directory where the active npm installs global executables."""
35
+ try:
36
+ prefix = run_command(["npm", "prefix", "--global"]).stdout.strip()
37
+ except (OSError, subprocess.CalledProcessError):
38
+ return None
39
+
40
+ if not prefix:
41
+ return None
42
+ return os.path.join(prefix, "bin")
43
+
44
+
21
45
  def run_pkg_command(
22
46
  base_cmd: list,
23
47
  pkg_name: str,
@@ -265,12 +289,14 @@ def install_direct_download(
265
289
  return False
266
290
 
267
291
 
268
- def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> bool:
292
+ def install_tool(
293
+ tool_name: str, dryrun: bool = False, force: bool = False
294
+ ) -> InstallOutcome:
269
295
  """Install a tool based on its configuration."""
270
296
  tool_config = TOOLS.get(tool_name)
271
297
  if not tool_config:
272
298
  error(f"Unknown tool: {tool_name}")
273
- return False
299
+ return InstallOutcome(False)
274
300
 
275
301
  if dryrun:
276
302
  info(f"[DRYRUN] Checking {tool_config['name']}...")
@@ -283,7 +309,7 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
283
309
  info(f"{tool_config['command']} already installed at {tool_path}")
284
310
  else:
285
311
  warning(f"{tool_config['command']} already installed at {tool_path}")
286
- return True
312
+ return InstallOutcome(True)
287
313
  if command_exists(tool_config["command"]) and force:
288
314
  tool_path = shutil.which(tool_config["command"])
289
315
  if dryrun:
@@ -301,7 +327,7 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
301
327
  freebsd_port = tool_config.get("freebsd_port")
302
328
  if not freebsd_port:
303
329
  error(f"{tool_config['name']} is not available on FreeBSD (no port exists)")
304
- return False
330
+ return InstallOutcome(False)
305
331
  pkg_repo = tool_config.get("freebsd_pkg_repo")
306
332
  if dryrun:
307
333
  repo_flag = f" -r {pkg_repo}" if pkg_repo else ""
@@ -309,7 +335,7 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
309
335
  f"[DRYRUN] Would install FreeBSD port: "
310
336
  f"pkg install{repo_flag} {freebsd_port}"
311
337
  )
312
- return True
338
+ return InstallOutcome(True)
313
339
  try:
314
340
  run_pkg_command(
315
341
  ["sudo", "pkg", "install", "-y"],
@@ -321,27 +347,32 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
321
347
  info(tool_config["next_steps"])
322
348
  if "docs_url" in tool_config:
323
349
  info(f"Documentation: {tool_config['docs_url']}")
324
- return True
350
+ return InstallOutcome(True)
325
351
  except subprocess.CalledProcessError as exc:
326
352
  error(f"Failed to install {tool_config['name']}: {exc.stderr}")
327
- return False
353
+ return InstallOutcome(False)
328
354
  except Exception as exc:
329
355
  error(f"Failed to install {tool_config['name']}: {exc}")
330
- return False
356
+ return InstallOutcome(False)
331
357
 
332
358
  try:
333
359
  install_type = get_tool_install_type(tool_config)
334
360
 
335
361
  if install_type == InstallType.NPM:
336
362
  npm_package = tool_config["npm_package"]
363
+ bin_dirs = ()
337
364
  if dryrun:
338
365
  info(f"[DRYRUN] Would install npm package: {npm_package}")
339
366
  else:
340
367
  run_command(["npm", "install", "-g", npm_package], check=True)
368
+ npm_bin_dir = get_npm_global_bin_dir()
369
+ if npm_bin_dir:
370
+ bin_dirs = (npm_bin_dir,)
341
371
  success(f"{tool_config['name']} installed successfully")
342
372
  info(tool_config["next_steps"])
343
373
  if "docs_url" in tool_config:
344
374
  info(f"Documentation: {tool_config['docs_url']}")
375
+ return InstallOutcome(True, bin_dirs)
345
376
 
346
377
  elif install_type == InstallType.SCRIPT:
347
378
  install_url = tool_config["install_url"]
@@ -361,7 +392,7 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
361
392
  if "docs_url" in tool_config:
362
393
  info(f"Documentation: {tool_config['docs_url']}")
363
394
  else:
364
- return False
395
+ return InstallOutcome(False)
365
396
 
366
397
  elif install_type == InstallType.DIRECT_DOWNLOAD:
367
398
  if install_direct_download(tool_name, tool_config, dryrun):
@@ -373,13 +404,18 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
373
404
  if "docs_url" in tool_config:
374
405
  info(f"Documentation: {tool_config['docs_url']}")
375
406
  else:
376
- return False
407
+ return InstallOutcome(False)
377
408
 
378
- return True
409
+ if not dryrun and tool_config.get("bin_dir"):
410
+ return InstallOutcome(
411
+ True, (os.path.expanduser(tool_config["bin_dir"]),)
412
+ )
413
+
414
+ return InstallOutcome(True)
379
415
 
380
416
  except subprocess.CalledProcessError as exc:
381
417
  error(f"Failed to install {tool_config['name']}: {exc.stderr}")
382
- return False
418
+ return InstallOutcome(False)
383
419
  except Exception as exc:
384
420
  error(f"Failed to install {tool_config['name']}: {exc}")
385
- return False
421
+ return InstallOutcome(False)
@@ -149,28 +149,23 @@ def is_tool_installed(tool_name: str) -> bool:
149
149
  return command_exists(tool_config["command"])
150
150
 
151
151
 
152
- def check_path_directories(tools_installed: Optional[List[str]] = None) -> None:
152
+ def check_path_directories(directories: Optional[List[str]] = None) -> None:
153
153
  """Check if binary installation directories are in PATH and warn if not.
154
154
 
155
- When *tools_installed* is given, only directories relevant to those
156
- tools are checked. Otherwise a small set of common directories is used.
155
+ When *directories* is given, only those directories are checked. Otherwise
156
+ a small set of common directories is used.
157
157
  """
158
158
  current_path = os.environ.get("PATH", "")
159
159
  path_entries = current_path.split(os.pathsep)
160
160
 
161
- if tools_installed:
161
+ if directories is not None:
162
162
  seen: set = set()
163
163
  dirs_to_check: List[str] = []
164
- for tool_name in tools_installed:
165
- tool_config = TOOLS.get(tool_name)
166
- if not tool_config:
167
- continue
168
- bin_dir = tool_config.get("bin_dir")
169
- if bin_dir:
170
- expanded = os.path.expanduser(bin_dir)
171
- if expanded not in seen:
172
- seen.add(expanded)
173
- dirs_to_check.append(expanded)
164
+ for directory in directories:
165
+ expanded = os.path.expanduser(directory)
166
+ if expanded not in seen:
167
+ seen.add(expanded)
168
+ dirs_to_check.append(expanded)
174
169
  else:
175
170
  dirs_to_check = [
176
171
  os.path.expanduser("~/.local/bin"),
@@ -8,6 +8,7 @@ from unittest import mock
8
8
 
9
9
  from code_aide import constants, commands_actions
10
10
  from code_aide import entry
11
+ from code_aide.install import InstallOutcome
11
12
  from code_aide.operations import UpgradeResult
12
13
 
13
14
 
@@ -39,7 +40,7 @@ class TestCmdInstall(unittest.TestCase):
39
40
  mock.patch.dict(constants._TOOLS_DATA, tools, clear=True),
40
41
  mock.patch.object(commands_actions, "validate_tools"),
41
42
  mock.patch.object(
42
- commands_actions, "install_tool", return_value=True
43
+ commands_actions, "install_tool", return_value=InstallOutcome(True)
43
44
  ) as mock_install_tool,
44
45
  mock.patch.object(commands_actions, "check_prerequisites") as mock_prereqs,
45
46
  ):
@@ -50,6 +51,38 @@ class TestCmdInstall(unittest.TestCase):
50
51
  mock_install_tool.assert_called_once_with("default_tool", dryrun=True)
51
52
  mock_prereqs.assert_not_called()
52
53
 
54
+ def test_checks_bin_directories_reported_by_installers(self):
55
+ tools = {
56
+ "test": {
57
+ "name": "Test Tool",
58
+ "command": "test",
59
+ "install_type": "npm",
60
+ "next_steps": "run test",
61
+ }
62
+ }
63
+ args = type(
64
+ "Args",
65
+ (),
66
+ {"tools": ["test"], "dryrun": False, "install_prerequisites": False},
67
+ )()
68
+
69
+ with (
70
+ mock.patch.dict(constants._TOOLS_DATA, tools, clear=True),
71
+ mock.patch.object(commands_actions, "validate_tools"),
72
+ mock.patch.object(commands_actions, "check_prerequisites"),
73
+ mock.patch.object(
74
+ commands_actions,
75
+ "install_tool",
76
+ return_value=InstallOutcome(True, ("/home/test/.npm-packages/bin",)),
77
+ ),
78
+ mock.patch.object(
79
+ commands_actions, "check_path_directories"
80
+ ) as mock_check_path,
81
+ ):
82
+ commands_actions.cmd_install(args)
83
+
84
+ mock_check_path.assert_called_once_with(["/home/test/.npm-packages/bin"])
85
+
53
86
 
54
87
  class TestCmdUpdateVersions(unittest.TestCase):
55
88
  """Tests for cmd_update_versions."""
@@ -286,6 +286,16 @@ class TestInstallDirectDownload(unittest.TestCase):
286
286
  class TestInstallTool(unittest.TestCase):
287
287
  """Tests for install_tool behavior."""
288
288
 
289
+ def test_get_npm_global_bin_dir_uses_active_prefix(self):
290
+ npm_prefix = mock.Mock(stdout="/home/test/.npm-packages\n")
291
+ with mock.patch.object(
292
+ cli_install, "run_command", return_value=npm_prefix
293
+ ) as mock_run:
294
+ result = cli_install.get_npm_global_bin_dir()
295
+
296
+ self.assertEqual(result, "/home/test/.npm-packages/bin")
297
+ mock_run.assert_called_once_with(["npm", "prefix", "--global"])
298
+
289
299
  def test_force_reinstalls_even_when_binary_exists(self):
290
300
  tool_config = {
291
301
  "name": "Test Tool",
@@ -301,11 +311,17 @@ class TestInstallTool(unittest.TestCase):
301
311
  mock.patch.object(
302
312
  cli_install.shutil, "which", return_value="/usr/local/bin/test-tool"
303
313
  ),
314
+ mock.patch.object(
315
+ cli_install,
316
+ "get_npm_global_bin_dir",
317
+ return_value="/home/test/.npm-packages/bin",
318
+ ),
304
319
  mock.patch.object(cli_install, "run_command") as mock_run,
305
320
  ):
306
321
  result = cli_install.install_tool("test", force=True)
307
322
 
308
323
  self.assertTrue(result)
324
+ self.assertEqual(result.bin_dirs, ("/home/test/.npm-packages/bin",))
309
325
  mock_run.assert_called_once_with(
310
326
  ["npm", "install", "-g", "test-tool"], check=True
311
327
  )
@@ -210,14 +210,12 @@ class TestCheckPathDirectories(unittest.TestCase):
210
210
  """Tests for check_path_directories."""
211
211
 
212
212
  def test_warns_when_existing_bin_dir_not_in_path(self):
213
- tool_config = {"name": "Test", "command": "test", "bin_dir": "/custom/bin"}
214
213
  with (
215
- mock.patch.dict(constants._TOOLS_DATA, {"test": tool_config}, clear=True),
216
214
  mock.patch.dict("os.environ", {"PATH": "/usr/bin"}, clear=False),
217
215
  mock.patch.object(cli_prereqs.os.path, "isdir", return_value=True),
218
216
  mock.patch("builtins.print") as mock_print,
219
217
  ):
220
- cli_prereqs.check_path_directories(["test"])
218
+ cli_prereqs.check_path_directories(["/custom/bin"])
221
219
  # Should have printed at least the bin dir as a warning detail
222
220
  printed = " ".join(
223
221
  str(call.args[0]) for call in mock_print.call_args_list if call.args
@@ -225,27 +223,23 @@ class TestCheckPathDirectories(unittest.TestCase):
225
223
  self.assertIn("/custom/bin", printed)
226
224
 
227
225
  def test_silent_when_dir_already_in_path(self):
228
- tool_config = {"name": "Test", "command": "test", "bin_dir": "/custom/bin"}
229
226
  with (
230
- mock.patch.dict(constants._TOOLS_DATA, {"test": tool_config}, clear=True),
231
227
  mock.patch.dict(
232
228
  "os.environ", {"PATH": "/custom/bin:/usr/bin"}, clear=False
233
229
  ),
234
230
  mock.patch.object(cli_prereqs.os.path, "isdir", return_value=True),
235
231
  mock.patch("builtins.print") as mock_print,
236
232
  ):
237
- cli_prereqs.check_path_directories(["test"])
233
+ cli_prereqs.check_path_directories(["/custom/bin"])
238
234
  self.assertFalse(mock_print.called)
239
235
 
240
236
  def test_silent_when_bin_dir_does_not_exist(self):
241
- tool_config = {"name": "Test", "command": "test", "bin_dir": "/missing/bin"}
242
237
  with (
243
- mock.patch.dict(constants._TOOLS_DATA, {"test": tool_config}, clear=True),
244
238
  mock.patch.dict("os.environ", {"PATH": "/usr/bin"}, clear=False),
245
239
  mock.patch.object(cli_prereqs.os.path, "isdir", return_value=False),
246
240
  mock.patch("builtins.print") as mock_print,
247
241
  ):
248
- cli_prereqs.check_path_directories(["test"])
242
+ cli_prereqs.check_path_directories(["/missing/bin"])
249
243
  self.assertFalse(mock_print.called)
250
244
 
251
245
 
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