
Cli Tools
- 66 installs
- 5 repo stars
- Updated August 3, 2026
- netresearch/cli-tools-skill
Helps with ai & agent building tasks.
About
cli-tools is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- cli-tools
- AI & Agent Building
- AI-coding skill
Cli Tools by the numbers
- 66 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #6,006 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/cli-tools-skill --skill cli-toolsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 66 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 3, 2026 |
| Repository | netresearch/cli-tools-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
CLI Tools Skill
Install, audit, update, and recommend CLI tools across 77 cataloged entries.
Triggers
- Reactive:
command not founderrors -- auto-resolve - Proactive: "check environment", "install X", "update tools"
- Advisory: Recommend modern alternatives (
grep->rg,find->fd, JSON->jq)
Preferred Modern Tools
Recommend over legacy equivalents. See references/preferred-tools.md for examples.
| Legacy | Modern | Legacy | Modern |
|---|---|---|---|
grep -r | rg | diff | difft |
find | fd | time | hyperfine |
| grep on JSON | jq | cat | bat |
| sed on YAML | yq | cloc | tokei/scc |
| awk on CSV | qsv | grep for sec | semgrep |
| sed on TOML | dasel |
Workflows
Missing Tool Resolution
1. Diagnose: which <tool>, command -v <tool>, type -a <tool> 2. Map binary: Check references/binary_to_tool_map.md (rg->ripgrep, ansible->ansible-core, batcat->bat) 3. Install: scripts/install_tool.sh <tool> install 4. Verify: which <tool> + <tool> --version; if still missing: hash -r, check PATH
See references/resolution-workflow.md for full diagnostic steps.
Environment Audit
Run scripts/check_environment.sh audit . and scripts/detect_project_type.sh, then cross-reference with references/project_type_requirements.md for per-type tool lists.
Batch Update
scripts/auto_update.sh (all managers) or scripts/install_tool.sh <tool> update (single).
Troubleshooting
| Symptom | Fix |
|---|---|
| Installed but not found | hash -r or add dir to PATH |
| No sudo | cargo install, pip install --user, manual binary |
Debian bat=batcat, fd=fdfind | Symlink to ~/.local/bin/ |
See references/troubleshooting.md for Docker fallbacks and permission workarounds.
Scripts
| Script | Purpose |
|---|---|
scripts/install_tool.sh | Install/update/uninstall/status |
scripts/auto_update.sh | Batch update package managers |
scripts/check_environment.sh | Audit environment and PATH |
scripts/detect_project_type.sh | Detect project type |
References
| File | Purpose |
|---|---|
references/binary_to_tool_map.md | Binary-to-catalog mapping |
references/project_type_requirements.md | Tools per project type |
references/preferred-tools.md | Modern tool usage patterns |
references/resolution-workflow.md | Diagnostic/install/verify flow |
references/troubleshooting.md | PATH, permissions, fallbacks |
Binary to Tool Mapping
When a "command not found" error occurs, use this mapping to find the correct catalog entry.
Direct Mappings (binary_name differs from catalog name)
| Binary | Catalog Entry | Notes |
|---|---|---|
rg | ripgrep | ripgrep search tool |
ansible | ansible-core | Ansible automation |
docker | compose | Docker Compose (not Docker daemon) |
file-rename | prename | Perl rename utility |
python3 | python | Python interpreter |
rustc | rust | Rust compiler (install via rustup) |
Common Aliases (same catalog name, common confusion)
| Command | Catalog Entry | Install Method |
|---|---|---|
fd | fd | fd-find on Debian/Ubuntu |
bat | bat | batcat on Debian/Ubuntu |
fdfind | fd | Debian alias for fd |
batcat | bat | Debian alias for bat |
cargo | rust | Install rust to get cargo |
node | node | Via nvm preferred |
npm | npm | Comes with node |
pip3 | pip | Python package manager |
pip | pip | Python package manager |
Lookup Algorithm
1. Check if binary name exists in catalog/*.json directly
2. Check binary_to_tool_map for alias
3. Check common variations:
- tool3 → tool (e.g., python3 → python)
- toolcat → tool (e.g., batcat → bat)
- toolfind → tool (e.g., fdfind → fd)
4. If not found, search catalog descriptionsPackage Manager Binaries
These tools come from installing their parent:
| Binary | Install Via |
|---|---|
cargo, rustc, rustup | rust |
go, gofmt | go |
node, npm, npx | node |
python3, pip3 | python |
ruby, gem, irb | ruby |
php, php-cli | php |
composer | composer (requires php) |
Preferred Tools - Detailed Reference
Modern CLI tools that replace legacy Unix utilities with faster, safer, and more ergonomic alternatives. Organized by domain.
---
File Search & Code Navigation
rg (ripgrep) instead of grep
Install: cargo install ripgrep or apt install ripgrep
ripgrep is a line-oriented search tool that recursively searches directories for a regex pattern. It respects .gitignore rules by default and is typically 10x faster than grep on large codebases.
# Basic search (recursive by default, unlike grep)
rg 'TODO|FIXME'
# Search specific file types
rg -t py 'import asyncio'
# Search with context lines
rg -C 3 'def process'
# Fixed string search (no regex interpretation)
rg -F 'array[0]'
# Search hidden files and ignored files too
rg -uu 'SECRET_KEY'
# Count matches per file
rg -c 'error' --sort path
# JSON output for piping to jq
rg --json 'pattern' | jq 'select(.type == "match")'Configuration (~/.ripgreprc, set via RIPGREP_CONFIG_PATH):
--smart-case
--max-columns=200
--glob=!.git
--glob=!node_modules
--glob=!vendorfd instead of find
Install: cargo install fd-find or apt install fd-find
fd is a fast, user-friendly alternative to find. It respects .gitignore, uses regex by default, and has sensible defaults (ignores hidden files, colorized output).
# Find files by name (regex by default)
fd 'test.*\.py$'
# Find by extension
fd -e json
# Find directories only
fd -t d src
# Find and execute command on each result
fd -e log -x gzip {}
# Find files modified in last 24h
fd --changed-within 1d
# Include hidden and ignored files
fd -HI 'config'
# CAUTION: Destructive - preview matches with `fd -e tmp` first, then:
fd -e tmp -x rm {}Configuration (.fdignore in project root, same syntax as .gitignore):
node_modules
.git
target
distrga (ripgrep-all) instead of grep on documents
Install: cargo install ripgrep_all or download from https://github.com/phiresky/ripgrep-all/releases
Searches inside PDFs, Word documents, Excel files, ZIP archives, SQLite databases, and more by converting them to text on-the-fly.
# Search PDFs in current directory
rga 'financial statement' ./reports/
# Search inside ZIP archives
rga 'config' ./backups/
# Search Office documents
rga 'quarterly revenue' ./documents/
# Limit to specific adapters
rga --rga-adapters=poppler 'pattern' ./pdfs/tokei / scc instead of cloc or wc -l
Install: cargo install tokei or go install github.com/boyter/scc/v3@latest
Both are dramatically faster than cloc for counting lines of code and provide accurate language detection. scc additionally estimates code complexity and cost.
# tokei - fast code statistics
tokei
tokei src/
tokei --sort code # Sort by code lines
# scc - code statistics with complexity/cost estimates
scc
scc --by-file # Show per-file stats
scc -f json # JSON output for processing
scc --no-cocomo # Skip cost estimate---
Structured Data Processing
jq instead of grep/awk/sed on JSON
Install: apt install jq or download from https://jqlang.github.io/jq/
jq is a lightweight command-line JSON processor. Never use grep/sed/awk on JSON - they break on nested structures, special characters, and multiline values.
# Extract a field
jq '.name' package.json
# Filter arrays
jq '.[] | select(.status == "active")' data.json
# Transform structure
jq '{name: .metadata.name, version: .spec.version}' manifest.json
# Combine with gh CLI
gh pr list --json number,title,author --jq '.[] | "\(.number): \(.title) (\(.author.login))"'
# Combine with curl
curl -s https://api.example.com/data | jq '.results[].name'
# Slurp multiple JSON objects into array
jq -s '.' *.json
# Raw output (no quotes) for scripting
jq -r '.version' package.jsonyq instead of sed/awk on YAML
Install: go install github.com/mikefarah/yq/v4@latest or brew install yq or download binary from https://github.com/mikefarah/yq/releases
Syntax-aware YAML processing that preserves comments and formatting. Important: Do NOT use pip install yq - that installs kislyuk/yq, a different tool (Python jq wrapper for YAML). This skill documents Mike Farah's Go-based yq.
# Read a value
yq '.metadata.name' chart.yaml
# Set a value (in-place)
yq -i '.spec.replicas = 3' deployment.yaml
# Merge YAML files
yq eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' base.yaml overlay.yaml
# Convert YAML to JSON
yq -o json '.' config.yaml
# Convert JSON to YAML
yq -P '.' config.json
# Edit array elements
yq -i '.services[0].ports[0] = "8080:80"' docker-compose.ymldasel instead of sed on TOML/XML/JSON/YAML
Install: go install github.com/tomwright/dasel/v2/cmd/dasel@latest
Universal data format selector - handles JSON, YAML, TOML, XML, and CSV with a single tool and consistent query syntax.
# Read from any format (auto-detected)
dasel -f config.toml '.database.host'
dasel -f pom.xml '.project.version'
# Write/update values
dasel put -f config.toml -t string -v 'localhost' '.database.host'
# Convert between formats
dasel -f config.yaml -w json
# Pipe mode
cat data.json | dasel -p json '.users.[0].name'qsv instead of awk/Python on CSV
Install: Download from https://github.com/dathere/qsv/releases
A fast CSV toolkit that correctly handles quoting, headers, encoding, and large files. Dramatically faster than awk/Python for CSV processing.
# View headers
qsv headers data.csv
# Select columns
qsv select name,email data.csv
# Filter rows
qsv search -s status 'active' data.csv
# Sort by column
qsv sort -s revenue -N -R data.csv # Numeric, reverse
# Statistics summary
qsv stats data.csv
# Frequency counts
qsv frequency -s category data.csv
# Join two CSVs
qsv join id users.csv user_id orders.csv
# SQL queries on CSV
qsv sqlp 'SELECT name, SUM(amount) FROM data GROUP BY name' data.csv
# Sample random rows
qsv sample 100 large-dataset.csv---
Git & Diff Tools
difft (difftastic) instead of diff
Install: cargo install difftastic
A structural diff tool that understands programming language syntax. Ignores formatting-only changes and provides accurate, readable diffs.
# Compare two files
difft old.py new.py
# Use as git diff tool
git -c diff.external=difft diff
git -c diff.external=difft show HEAD
# Configure as default git diff tool
git config --global diff.tool difftastic
git config --global difftool.difftastic.cmd 'difft "$LOCAL" "$REMOTE"'
git config --global difftool.prompt falsegit absorb instead of git commit --fixup
Install: cargo install git-absorb
Automatically identifies which staged changes belong to which previous commit and creates fixup commits. Replaces the manual workflow of git log, identifying the right commit, then git commit --fixup=<sha>.
# Stage changes then auto-absorb
git add -p
git absorb
# Then squash the fixups
git rebase -i --autosquash main
# Dry run - see what would happen
git absorb --dry-run---
Security
semgrep instead of manual grep for security
Install: pip install semgrep or brew install semgrep
AST-aware static analysis with pre-built rulesets for OWASP Top 10, CWEs, and language-specific security patterns. Far more accurate than text-based grep patterns.
# Run auto-detected rules
semgrep --config auto .
# OWASP Top 10 scan
semgrep --config "p/owasp-top-ten" .
# Language-specific rules
semgrep --config "p/python" .
semgrep --config "p/php" .
semgrep --config "p/javascript" .
# Output as JSON for processing
semgrep --config auto --json . | jq '.results[] | {path: .path, line: .start.line, message: .extra.message}'
# CI-friendly (fail on findings)
semgrep --config auto --error .---
Benchmarking
hyperfine instead of time
Install: cargo install hyperfine or apt install hyperfine
Statistical command benchmarking with warmup, multiple runs, comparison, and export features. Essential for making data-driven performance claims.
# Basic benchmark (auto-detects run count)
hyperfine 'fd -e py'
# Compare two commands side by side
hyperfine 'find . -name "*.py"' 'fd -e py'
# With warmup runs (important for disk cache)
hyperfine --warmup 3 'rg pattern'
# Minimum runs for statistical significance
hyperfine --min-runs 20 'command'
# Parameterized benchmarks
hyperfine -P threads 1 8 'sort --parallel={threads} data.txt'
# Shell selection (default is sh)
hyperfine -S bash 'echo ${BASH_VERSION}'
# Export results
hyperfine --export-markdown bench.md 'grep -r pattern .' 'rg pattern'
hyperfine --export-json bench.json 'command1' 'command2'
hyperfine --export-csv bench.csv 'command1' 'command2'
# Preparation command (run before each benchmark)
# NOTE: Clearing page cache requires sudo/root privileges
hyperfine --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' 'cat large-file'
# Cleanup command (run after each benchmark)
hyperfine --cleanup 'rm -f output.txt' 'generate output.txt'
# Show intermediate results
hyperfine --show-output 'echo hello'Interpreting results:
- Mean: Average execution time across all runs
- Stddev: Standard deviation - high values indicate inconsistent performance
- Min/Max: Fastest and slowest runs
- Relative: "X is Y times faster than Z" comparison
---
Viewing & General
bat instead of cat
Install: cargo install bat or apt install bat
A cat clone with syntax highlighting, line numbers, git integration, and automatic paging.
# View file with syntax highlighting
bat script.py
# Show specific lines
bat -r 10:20 main.go
# Plain mode (no decoration, for piping)
bat -pp data.json | jq '.'
# Show non-printable characters
bat -A config.yml
# Use as man pager
export MANPAGER="sh -c 'col -bx | bat -l man -p'"---
Tool Integration Patterns
These modern tools work well together through pipes and subshells:
# fd + rg: Find files then search contents
fd -e yaml | xargs rg 'apiVersion: v2'
# fd + bat: Find and view files
fd 'Dockerfile' -x bat {}
# rg + jq: Search JSON files and process matches
rg -l 'error' --glob '*.json' | xargs -I{} jq '.errors' {}
# gh + jq: GitHub API with structured processing
gh api repos/{owner}/{repo}/pulls --jq '.[].title'
# fd + hyperfine: Benchmark file operations
hyperfine 'fd -e py | wc -l' 'find . -name "*.py" | wc -l'
# scc + jq: Process code statistics
scc -f json | jq '.[] | {Name, Code, Lines}'
# qsv + jq: CSV to JSON pipeline
qsv tojsonl data.csv | jq 'select(.status == "active")'---
Performance Reference
Typical speedup factors (varies by workload and hardware):
| Legacy | Modern | Typical Speedup |
|---|---|---|
grep -r | rg | 5-15x |
find | fd | 3-8x |
cloc | tokei | 10-50x |
cloc | scc | 50-100x |
awk on CSV | qsv | 50-200x |
diff | difft | Similar speed, much better output |
Verify with hyperfine on your actual workload:
hyperfine --warmup 3 'grep -r "pattern" .' 'rg "pattern"'Project Type Requirements
Map project types to required and recommended tools.
Python Projects
Detection files: pyproject.toml, setup.py, setup.cfg, requirements.txt, Pipfile, poetry.lock
| Category | Tools | Priority |
|---|---|---|
| Required | python, uv or pip | Critical |
| Recommended | ruff, black, mypy | High |
| Optional | isort, bandit, pre-commit | Medium |
Install command: scripts/install_tool.sh python && scripts/install_tool.sh uv
Node.js Projects
Detection files: package.json, package-lock.json, yarn.lock, pnpm-lock.yaml
| Category | Tools | Priority |
|---|---|---|
| Required | node, npm or pnpm or yarn | Critical |
| Recommended | eslint, prettier | High |
| Optional | typescript (if tsconfig.json) | Medium |
Install command: scripts/install_tool.sh node
Rust Projects
Detection files: Cargo.toml, Cargo.lock
| Category | Tools | Priority |
|---|---|---|
| Required | rust (provides cargo, rustc) | Critical |
| Recommended | cargo-watch, cargo-audit | Medium |
Install command: scripts/install_tool.sh rust
Go Projects
Detection files: go.mod, go.sum
| Category | Tools | Priority |
|---|---|---|
| Required | go | Critical |
| Recommended | golangci-lint, gosec | High |
Install command: scripts/install_tool.sh go
PHP Projects
Detection files: composer.json, composer.lock, *.php
| Category | Tools | Priority |
|---|---|---|
| Required | php, composer | Critical |
| Recommended | phpstan, phpcs | High |
| Optional | phpunit, php-cs-fixer | Medium |
Install command: scripts/install_tool.sh php && scripts/install_tool.sh composer
Ruby Projects
Detection files: Gemfile, Gemfile.lock, .ruby-version
| Category | Tools | Priority |
|---|---|---|
| Required | ruby, bundler | Critical |
| Recommended | rubocop | High |
Install command: scripts/install_tool.sh ruby
Infrastructure Projects
Detection files: Dockerfile, docker-compose.yml, terraform/*.tf, ansible/*.yml
| Type | Required | Recommended |
|---|---|---|
| Docker | docker, compose | dive, trivy |
| Terraform | terraform | tfsec, trivy |
| Kubernetes | kubectl | helm |
| Ansible | ansible-core | ansible-lint |
Shell/Bash Projects
Detection files: *.sh, Makefile, .bashrc
| Category | Tools |
|---|---|
| Recommended | shellcheck, shfmt |
Generic Development
Always useful regardless of project type:
| Tool | Purpose |
|---|---|
git | Version control |
gh | GitHub CLI |
jq | JSON processing |
yq | YAML processing |
ripgrep | Fast search |
fd | Fast find |
fzf | Fuzzy finder |
bat | Better cat |
delta | Better diff |
Detection Priority
When multiple project types detected: 1. Check most specific first (Cargo.toml before generic files) 2. Report all detected types 3. Merge required tools from all types
Missing Tool Resolution Workflow
Phase 1: Diagnostic (BEFORE attempting install)
1. Check if tool exists elsewhere:
which <tool> # Is it installed but not in PATH?
command -v <tool> # Alternative check
type -a <tool> # Show all locations2. Why might it be missing?
- PATH issue: Tool installed but shell can't find it (check
~/.local/bin,/usr/local/bin) - Version conflict: Multiple versions installed, wrong one active
- Shell state: Installed in current session but shell hash table stale (
hash -r) - Package manager isolation: Installed via pip/npm/cargo but not in global PATH
3. If tool exists but not in PATH:
# Find the binary in common locations (avoids slow full-disk scans)
find /usr/local/bin /usr/bin /opt -maxdepth 3 -type f -name "<tool>" 2>/dev/null
find "$HOME/.local/bin" -maxdepth 1 -type f -name "<tool>" 2>/dev/null
# Add to PATH temporarily
export PATH="$PATH:/path/to/tool/directory"Phase 2: Installation
1. Extract tool name from error 2. Lookup in binary_to_tool_map.md (e.g., rg -> ripgrep) 3. Install: scripts/install_tool.sh <tool> install
Phase 3: Verification (AFTER install)
1. Confirm installation succeeded:
which <tool> # Should show path
<tool> --version # Should show version2. If "command not found" persists after install:
hash -r # Clear shell's command hash
source ~/.bashrc # Reload shell configuration
# Or start a new shell session3. Retry original command
Troubleshooting
PATH Issues
When a tool installs but still shows "command not found":
1. Check where it was installed:
# Common install locations
ls -la ~/.local/bin/<tool>
ls -la ~/.cargo/bin/<tool>
ls -la ~/.npm-global/bin/<tool>
ls -la /usr/local/bin/<tool>2. Ensure PATH includes common directories:
# Add to ~/.bashrc or ~/.zshrc
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.npm-global/bin:$PATH"3. Reload shell configuration:
source ~/.bashrc # or ~/.zshrc
hash -r # Clear command cache
exec $SHELL # Restart shellNode/nvm: global installs land off-PATH (a node shim hijacks npm's prefix)
When a global install succeeds (npm i -g <tool> reports "added N packages") but command -v <tool> then fails — or npm prefix -g points at a Node version that isn't your active/default one — suspect a manual `node` symlink on PATH ahead of nvm (commonly ~/.local/bin/node, often created to give another tool a stable node).
Because that shim is first on PATH, every node/npm resolves through it, so npm's global prefix is locked to that Node's tree — and globals (eslint, pnpm, prettier, …) land in a bin/ that isn't on PATH. npm self-update can also hit the wrong tree.
Diagnose:
command -v node # may show only the shim, e.g. ~/.local/bin/node
node -p 'process.execPath' # the REAL node the shim points at
npm prefix -g # the prefix globals install into
nvm version default # what nvm thinks the default isIf process.execPath / npm prefix -g disagree with the nvm default, the shim is the cause.
Fix — remove or re-point the shim identified above (the node on PATH that is not under ~/.nvm — commonly ~/.local/bin/node, but use the path your diagnosis returned), then align the nvm default:
SHIM=~/.local/bin/node # <- replace with the shim path from the diagnosis
rm "$SHIM" # or: ln -sf "$(nvm which default)" "$SHIM"
nvm alias default node # point default at the newest installed Node
hash -rInstallation Blocked (Permission/System Restrictions)
When system prevents normal installation, use these alternatives:
1. Docker (no install required):
# Run tool in container
docker run --rm -v "$PWD:/work" -w /work <tool-image> <tool> <args>
# Create alias for convenience
alias <tool>='docker run --rm -v "$PWD:/work" -w /work <tool-image> <tool>'2. Manual binary download:
# Download release binary directly
curl -L <release-url> -o ~/.local/bin/<tool>
chmod +x ~/.local/bin/<tool>3. Compile from source:
git clone <repo-url>
cd <repo>
make && make install PREFIX=~/.local4. Use package manager with user scope:
pip install --user <tool>
npm install -g <tool> --prefix ~/.npm-global
cargo install <tool> # Installs to ~/.cargo/bin#!/usr/bin/env bash
set -euo pipefail
# Auto-update all package managers and their packages
# Detects installed package managers and runs their native update tools
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$DIR/lib/common.sh"
. "$DIR/lib/scope_detection.sh"
DRY_RUN="${DRY_RUN:-0}"
VERBOSE="${VERBOSE:-0}"
SKIP_SYSTEM="${SKIP_SYSTEM:-0}"
SCOPE="${SCOPE:-}" # Can be: system, user, project, all, or auto-detect if empty
log() {
printf "[auto-update] %s\n" "$*" >&2
}
vlog() {
if [ "$VERBOSE" = "1" ]; then
printf "[auto-update] %s\n" "$*" >&2
fi
}
run_cmd() {
local desc="$1"
shift
if [ "$DRY_RUN" = "1" ]; then
log "DRY-RUN: $desc"
log " Command: $*"
else
log "$desc"
if [ "$VERBOSE" = "1" ]; then
"$@"
else
"$@" >/dev/null 2>&1 || true
fi
fi
}
# ============================================================================
# Package Manager Detection
# ============================================================================
detect_apt() {
command -v apt-get >/dev/null 2>&1
}
detect_brew() {
command -v brew >/dev/null 2>&1
}
detect_cargo() {
command -v cargo >/dev/null 2>&1
}
detect_pip() {
command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1
}
detect_pipx() {
command -v pipx >/dev/null 2>&1
}
detect_uv() {
command -v uv >/dev/null 2>&1
}
detect_npm() {
command -v npm >/dev/null 2>&1
}
detect_pnpm() {
command -v pnpm >/dev/null 2>&1
}
detect_yarn() {
command -v yarn >/dev/null 2>&1
}
detect_go() {
command -v go >/dev/null 2>&1
}
detect_gem() {
command -v gem >/dev/null 2>&1
}
detect_snap() {
command -v snap >/dev/null 2>&1
}
detect_flatpak() {
command -v flatpak >/dev/null 2>&1
}
detect_rustup() {
command -v rustup >/dev/null 2>&1
}
detect_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
command -v nvm >/dev/null 2>&1
}
detect_gcloud() {
command -v gcloud >/dev/null 2>&1
}
detect_az() {
command -v az >/dev/null 2>&1
}
detect_composer() {
command -v composer >/dev/null 2>&1
}
detect_poetry() {
command -v poetry >/dev/null 2>&1
}
detect_conda() {
command -v conda >/dev/null 2>&1
}
detect_mamba() {
command -v mamba >/dev/null 2>&1
}
detect_bundler() {
command -v bundle >/dev/null 2>&1
}
detect_jspm() {
command -v jspm >/dev/null 2>&1
}
detect_nuget() {
command -v nuget >/dev/null 2>&1 || command -v dotnet >/dev/null 2>&1
}
# ============================================================================
# System Package Managers (requires sudo)
# ============================================================================
update_apt() {
if ! detect_apt; then return; fi
log "APT: Updating package lists and upgrading packages"
if [ "$DRY_RUN" = "1" ]; then
log "DRY-RUN: sudo apt-get update && sudo apt-get upgrade -y"
else
if [ "$VERBOSE" = "1" ]; then
sudo apt-get update && sudo apt-get upgrade -y
else
sudo apt-get update >/dev/null 2>&1 || true
sudo apt-get upgrade -y >/dev/null 2>&1 || true
fi
log "APT: Complete"
fi
}
update_brew() {
if ! detect_brew; then return; fi
log "Homebrew: Updating and upgrading all packages"
run_cmd "Brew: Update package index" brew update
run_cmd "Brew: Upgrade packages" brew upgrade
run_cmd "Brew: Cleanup old versions" brew cleanup
log "Homebrew: Complete"
}
update_snap() {
if ! detect_snap; then return; fi
log "Snap: Refreshing all snaps"
run_cmd "Snap: Refresh all" sudo snap refresh
log "Snap: Complete"
}
update_flatpak() {
if ! detect_flatpak; then return; fi
log "Flatpak: Updating all applications"
run_cmd "Flatpak: Update" flatpak update -y
log "Flatpak: Complete"
}
# ============================================================================
# Language-Specific Package Managers
# ============================================================================
update_cargo() {
if ! detect_cargo; then return; fi
log "Cargo: Updating installed packages"
# Update rustup first
if detect_rustup; then
run_cmd "Rustup: Update toolchains" rustup update
# Update rustup components (clippy, rustfmt, rust-analyzer, etc.)
vlog "Rustup: Updating components"
for component in clippy rustfmt rust-analyzer rust-src; do
if rustup component list 2>/dev/null | grep -q "^${component}.*installed"; then
vlog "Rustup: Component $component is installed"
# Components are updated with rustup update, no separate update needed
fi
done
fi
# Install cargo-update if not present
if ! command -v cargo-install-update >/dev/null 2>&1; then
vlog "Installing cargo-update for package upgrades"
run_cmd "Cargo: Install cargo-update" cargo install cargo-update
fi
# Update all cargo-installed packages
if command -v cargo-install-update >/dev/null 2>&1; then
run_cmd "Cargo: Upgrade all packages" cargo install-update -a
fi
log "Cargo: Complete"
}
update_uv() {
if ! detect_uv; then return; fi
log "UV: Updating UV tools"
# Update uv itself
run_cmd "UV: Self-update" uv self update
# Update all uv-managed tools
if [ "$DRY_RUN" = "0" ]; then
local tools
# Filter out binary lines (starting with dash) and keep only tool names
tools="$(uv tool list 2>/dev/null | grep -v '^-' | awk 'NF > 0 {print $1}' || true)"
if [ -n "$tools" ]; then
log "UV: Upgrading $(echo "$tools" | wc -l) installed tools"
while IFS= read -r tool; do
[ -z "$tool" ] && continue
run_cmd "UV: Upgrade $tool" uv tool upgrade "$tool"
done <<< "$tools"
fi
else
log "DRY-RUN: uv self update"
log "DRY-RUN: uv tool upgrade <all-tools>"
fi
log "UV: Complete"
}
update_pipx() {
if ! detect_pipx; then return; fi
log "Pipx: Updating all packages"
run_cmd "Pipx: Upgrade pipx" pip3 install --user --upgrade pipx
run_cmd "Pipx: Upgrade all packages" pipx upgrade-all
# Explicitly list important dev tools we track via pipx
local important_tools=("semgrep" "pre-commit" "coverage" "tox" "checkov" "black" "flake8" "pylint" "mypy")
for tool in "${important_tools[@]}"; do
if pipx list 2>/dev/null | grep -q "package $tool"; then
vlog "Pipx: $tool is installed"
fi
done
log "Pipx: Complete"
}
update_pip() {
if ! detect_pip; then return; fi
log "Pip: Updating user-installed packages"
# Update pip itself
run_cmd "Pip: Self-update" python3 -m pip install --user --upgrade pip
# List and upgrade user packages
if [ "$DRY_RUN" = "0" ]; then
local outdated
outdated="$(python3 -m pip list --user --outdated --format=json 2>/dev/null || echo '[]')"
if [ "$outdated" != "[]" ] && [ -n "$outdated" ]; then
vlog "Found outdated pip packages"
# Extract package names and upgrade them
echo "$outdated" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
for pkg in data:
print(pkg['name'])
except:
pass
" | while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
run_cmd "Pip: Upgrade $pkg" python3 -m pip install --user --upgrade "$pkg"
done
fi
else
log "DRY-RUN: pip list --outdated and upgrade packages"
fi
log "Pip: Complete"
}
update_npm() {
if ! detect_npm; then return; fi
log "NPM: Updating global packages"
# Update npm itself
run_cmd "NPM: Self-update" npm install -g npm@latest
# Update all global packages
run_cmd "NPM: Upgrade global packages" npm update -g
log "NPM: Complete"
}
update_pnpm() {
if ! detect_pnpm; then return; fi
log "PNPM: Updating global packages"
# Update pnpm itself via corepack if available
if command -v corepack >/dev/null 2>&1; then
run_cmd "PNPM: Update via corepack" corepack prepare pnpm@latest --activate
else
run_cmd "PNPM: Self-update" npm install -g pnpm@latest
fi
# Update global packages
run_cmd "PNPM: Upgrade global packages" pnpm update -g
log "PNPM: Complete"
}
update_yarn() {
if ! detect_yarn; then return; fi
log "Yarn: Updating global packages"
# Update yarn itself via corepack if available
if command -v corepack >/dev/null 2>&1; then
run_cmd "Yarn: Update via corepack" corepack prepare yarn@stable --activate
else
run_cmd "Yarn: Self-update" npm install -g yarn@latest
fi
# Yarn doesn't have a built-in global package upgrade command
# Users typically manage this per-project
vlog "Yarn: Global package upgrades managed per-project"
log "Yarn: Complete"
}
update_go() {
if ! detect_go; then return; fi
log "Go: Updating installed binaries"
# Go doesn't have a built-in package manager for updating binaries
# List common go-installed tools and suggest updating
local gobin gopath
gobin="$(go env GOBIN 2>/dev/null || true)"
gopath="$(go env GOPATH 2>/dev/null || true)"
if [ -z "$gobin" ] && [ -n "$gopath" ]; then
gobin="$gopath/bin"
fi
if [ -n "$gobin" ] && [ -d "$gobin" ]; then
vlog "Go: Binaries in $gobin (manual upgrade needed: go install <package>@latest)"
log "Go: Update via go install <package>@latest for each tool"
fi
log "Go: Manual updates required"
}
update_gem() {
if ! detect_gem; then return; fi
log "RubyGems: Updating all gems"
run_cmd "Gem: Update system" gem update --system
run_cmd "Gem: Upgrade all gems" gem update
run_cmd "Gem: Cleanup old versions" gem cleanup
log "RubyGems: Complete"
}
update_composer() {
if ! detect_composer; then return; fi
log "Composer: Updating"
run_cmd "Composer: Self-update" composer self-update
run_cmd "Composer: Update global packages" composer global update
log "Composer: Complete"
}
update_poetry() {
if ! detect_poetry; then return; fi
log "Poetry: Updating"
# Try poetry self update first (Poetry 1.2+)
if poetry self update --help >/dev/null 2>&1; then
run_cmd "Poetry: Self-update" poetry self update
# Fallback to uv tool upgrade if poetry is managed by uv
elif command -v uv >/dev/null 2>&1 && uv tool list 2>/dev/null | grep -q "^poetry"; then
run_cmd "Poetry: Upgrade via UV" uv tool upgrade poetry
# Fallback to pipx upgrade if poetry is managed by pipx
elif command -v pipx >/dev/null 2>&1 && pipx list 2>/dev/null | grep -q "poetry"; then
run_cmd "Poetry: Upgrade via pipx" pipx upgrade poetry
else
vlog "Poetry: No automatic update method available"
log "Poetry: Manual update required (see https://python-poetry.org/docs/#updating-poetry)"
fi
log "Poetry: Complete"
}
update_gcloud() {
if ! detect_gcloud; then return; fi
log "Google Cloud SDK: Updating components"
run_cmd "gcloud: Update all components" gcloud components update --quiet
log "Google Cloud SDK: Complete"
}
update_az() {
if ! detect_az; then return; fi
log "Azure CLI: Updating"
# Azure CLI update method depends on installation type
if command -v apt-get >/dev/null 2>&1 && dpkg -l azure-cli >/dev/null 2>&1; then
# Installed via apt
run_cmd "Azure CLI: Update via apt" sudo apt-get update && sudo apt-get install --only-upgrade -y azure-cli
elif command -v brew >/dev/null 2>&1 && brew list azure-cli >/dev/null 2>&1; then
# Installed via brew
run_cmd "Azure CLI: Update via brew" brew upgrade azure-cli
else
# Try az upgrade command (available in az CLI 2.11.0+)
run_cmd "Azure CLI: Self-upgrade" az upgrade --yes
fi
log "Azure CLI: Complete"
}
# ============================================================================
# Main Orchestration
# ============================================================================
get_manager_stats() {
local mgr="$1"
local location version pkg_count
case "$mgr" in
apt)
location="$(command -v apt-get 2>/dev/null || echo "N/A")"
version="$(apt-get --version 2>/dev/null | head -n1 | awk '{print $2}' || echo "unknown")"
pkg_count="$(dpkg -l 2>/dev/null | grep '^ii' | wc -l | tr -d '[:space:]' || echo "0")"
;;
brew)
location="$(command -v brew 2>/dev/null || echo "N/A")"
version="$(brew --version 2>/dev/null | head -n1 | awk '{print $2}' || echo "unknown")"
pkg_count="$(brew list --formula 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
snap)
location="$(command -v snap 2>/dev/null || echo "N/A")"
version="$(snap version 2>/dev/null | grep '^snap' | awk '{print $2}' || echo "unknown")"
pkg_count="$(snap list 2>/dev/null | tail -n +2 | wc -l | tr -d '[:space:]' || echo "0")"
;;
flatpak)
location="$(command -v flatpak 2>/dev/null || echo "N/A")"
version="$(flatpak --version 2>/dev/null | awk '{print $2}' || echo "unknown")"
pkg_count="$(flatpak list --app 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
cargo)
location="$(command -v cargo 2>/dev/null || echo "N/A")"
version="$(cargo --version 2>/dev/null | awk '{print $2}' || echo "unknown")"
pkg_count="$(cargo install --list 2>/dev/null | grep -c '^[^ ]' | tr -d '[:space:]' || echo "0")"
;;
rustup)
location="$(command -v rustup 2>/dev/null || echo "N/A")"
version="$(rustup --version 2>/dev/null | awk '{print $2}' || echo "unknown")"
pkg_count="$(rustup toolchain list 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
uv)
location="$(command -v uv 2>/dev/null || echo "N/A")"
version="$(uv --version 2>/dev/null | awk '{print $2}' || echo "unknown")"
pkg_count="$(uv tool list 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
pipx)
location="$(command -v pipx 2>/dev/null || echo "N/A")"
version="$(pipx --version 2>/dev/null || echo "unknown")"
pkg_count="$(pipx list --short 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
pip)
location="$(command -v pip3 2>/dev/null || command -v pip 2>/dev/null || echo "N/A")"
version="$(/usr/bin/python3 -m pip --version 2>/dev/null | awk '{print $2}' || echo "unknown")"
pkg_count="$(/usr/bin/python3 -m pip list --user 2>/dev/null | tail -n +3 | wc -l | tr -d '[:space:]' || echo "0")"
pkg_count="${pkg_count:-0}"
;;
npm)
location="$(command -v npm 2>/dev/null || echo "N/A")"
version="$(npm --version 2>/dev/null || echo "unknown")"
pkg_count="$(npm list -g --depth=0 2>/dev/null | grep -c '^[├└]' | tr -d '[:space:]' || echo "0")"
;;
pnpm)
location="$(command -v pnpm 2>/dev/null || echo "N/A")"
version="$(pnpm --version 2>/dev/null || echo "unknown")"
pkg_count="$(pnpm list -g --depth=0 2>/dev/null | grep -c '^[├└]' | tr -d '[:space:]' || echo "0")"
;;
yarn)
location="$(command -v yarn 2>/dev/null || echo "N/A")"
version="$(yarn --version 2>/dev/null || echo "unknown")"
pkg_count="$(yarn global list 2>/dev/null | grep -c '^info' | tr -d '[:space:]' || echo "0")"
;;
go)
location="$(command -v go 2>/dev/null || echo "N/A")"
version="$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//' || echo "unknown")"
local gobin="$(go env GOBIN 2>/dev/null || echo "$(go env GOPATH 2>/dev/null)/bin")"
pkg_count="$([ -d "$gobin" ] && ls -1 "$gobin" 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
gem)
location="$(command -v gem 2>/dev/null || echo "N/A")"
version="$(gem --version 2>/dev/null || echo "unknown")"
pkg_count="$(gem list --no-versions 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
composer)
location="$(command -v composer 2>/dev/null || echo "N/A")"
version="$(composer --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown")"
pkg_count="$(composer global show 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
poetry)
location="$(command -v poetry 2>/dev/null || echo "N/A")"
version="$(poetry --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown")"
pkg_count="N/A"
;;
conda)
location="$(command -v conda 2>/dev/null || echo "N/A")"
version="$(conda --version 2>/dev/null | awk '{print $2}' || echo "unknown")"
pkg_count="$(conda list 2>/dev/null | tail -n +4 | wc -l | tr -d '[:space:]' || echo "0")"
;;
mamba)
location="$(command -v mamba 2>/dev/null || echo "N/A")"
version="$(mamba --version 2>/dev/null | awk '{print $2}' || echo "unknown")"
pkg_count="$(mamba list 2>/dev/null | tail -n +4 | wc -l | tr -d '[:space:]' || echo "0")"
;;
bundler)
location="$(command -v bundle 2>/dev/null || echo "N/A")"
version="$(bundle --version 2>/dev/null | awk '{print $3}' || echo "unknown")"
pkg_count="N/A"
;;
jspm)
location="$(command -v jspm 2>/dev/null || echo "N/A")"
version="$(jspm --version 2>/dev/null || echo "unknown")"
pkg_count="N/A"
;;
nuget)
if command -v nuget >/dev/null 2>&1; then
location="$(command -v nuget)"
version="$(nuget help 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown")"
else
location="$(command -v dotnet 2>/dev/null || echo "N/A")"
version="$(dotnet --version 2>/dev/null || echo "unknown")"
fi
pkg_count="N/A"
;;
gcloud)
location="$(command -v gcloud 2>/dev/null || echo "N/A")"
version="$(gcloud version 2>/dev/null | grep 'Google Cloud SDK' | awk '{print $4}' || echo "unknown")"
pkg_count="$(gcloud components list --filter='State.name:Installed' --format='value(id)' 2>/dev/null | wc -l | tr -d '[:space:]' || echo "0")"
;;
az)
location="$(command -v az 2>/dev/null || echo "N/A")"
version="$(az version --output tsv 2>/dev/null | grep '^azure-cli' | awk '{print $2}' || echo "unknown")"
pkg_count="$(az extension list 2>/dev/null | grep -c '"name":' | tr -d '[:space:]' || echo "0")"
;;
*)
location="unknown"
version="unknown"
pkg_count="0"
;;
esac
printf "%s|%s|%s" "$location" "$version" "$pkg_count"
}
# Check if a package manager is outdated by querying the snapshot
check_manager_outdated() {
local mgr="$1"
local current_version="$2"
# Skip if version is unknown
[ "$current_version" = "unknown" ] && return 1
# Use tools_snapshot.json directly (fast, no subprocess needed)
local snapshot_file="${CLI_AUDIT_SNAPSHOT_FILE:-tools_snapshot.json}"
# Check if snapshot exists
[ ! -f "$snapshot_file" ] && return 1
# Extract status for this tool from JSON snapshot
local status
status="$(python3 -c "
import json, sys
try:
with open('$snapshot_file') as f:
data = json.load(f)
for tool in data.get('tools', []):
if tool.get('tool') == '$mgr':
print(tool.get('status', ''))
sys.exit(0)
except:
pass
" 2>/dev/null)" || status=""
# Check if status is OUTDATED
if [ "$status" = "OUTDATED" ]; then
return 0 # Outdated
else
return 1 # Up-to-date or unknown
fi
}
# Show update hint for a specific manager
show_manager_update_hint() {
local mgr="$1"
case "$mgr" in
apt)
echo " • $mgr: Run 'sudo apt-get update && sudo apt-get upgrade -y' or 'make auto-update-system'"
;;
snap)
echo " • $mgr: Run 'sudo snap refresh' or 'make auto-update-system'"
;;
brew)
echo " • $mgr: Run 'brew update && brew upgrade' or 'make auto-update'"
;;
flatpak)
echo " • $mgr: Run 'flatpak update -y' or 'make auto-update'"
;;
cargo)
echo " • $mgr: Run 'cargo install cargo-update && cargo install-update -a' or 'make auto-update'"
;;
rustup)
echo " • $mgr: Run 'rustup update' or 'make auto-update'"
;;
uv)
echo " • $mgr: Run 'uv self update' or './scripts/auto_update.sh uv'"
;;
pipx)
echo " • $mgr: Run 'pip3 install --user --upgrade pipx' or './scripts/auto_update.sh pipx'"
;;
pip)
echo " • $mgr: Run 'python3 -m pip install --user --upgrade pip' or './scripts/auto_update.sh pip'"
;;
npm)
echo " • $mgr: Run 'npm install -g npm@latest' or './scripts/auto_update.sh npm'"
;;
pnpm)
echo " • $mgr: Run 'npm install -g pnpm@latest' or './scripts/auto_update.sh pnpm'"
;;
yarn)
echo " • $mgr: Run 'npm install -g yarn@latest' or './scripts/auto_update.sh yarn'"
;;
go)
echo " • $mgr: Download latest from https://go.dev/dl/ and install"
;;
gem)
echo " • $mgr: Run 'gem update --system' or './scripts/auto_update.sh gem'"
;;
composer)
echo " • $mgr: Run 'composer self-update'"
;;
poetry)
echo " • $mgr: Run 'poetry self update' or 'uv tool upgrade poetry'"
;;
conda)
echo " • $mgr: Run 'conda update -n base conda'"
;;
mamba)
echo " • $mgr: Run 'conda update -n base mamba' or 'mamba update mamba'"
;;
gcloud)
echo " • $mgr: Run 'gcloud components update' or './scripts/auto_update.sh gcloud'"
;;
az)
echo " • $mgr: Run 'az upgrade' or './scripts/auto_update.sh az'"
;;
*)
echo " • $mgr: Check official documentation for update instructions"
;;
esac
}
show_detected() {
log "Detecting installed package managers with scope information..."
echo ""
local all_managers=(apt snap brew flatpak cargo rustup uv pipx pip npm pnpm yarn go gem composer poetry conda mamba bundler jspm nuget gcloud az)
local found_managers=0
local found_scopes=0
local outdated_managers=()
# First pass: detect which managers are installed
local managers=()
for mgr in "${all_managers[@]}"; do
if command -v "$mgr" >/dev/null 2>&1 || \
([ "$mgr" = "apt" ] && command -v apt-get >/dev/null 2>&1) || \
([ "$mgr" = "bundler" ] && command -v bundle >/dev/null 2>&1) || \
([ "$mgr" = "nuget" ] && command -v dotnet >/dev/null 2>&1); then
managers+=("$mgr")
found_managers=$((found_managers + 1))
fi
done
if [ $found_managers -eq 0 ]; then
echo "No package managers detected."
return
fi
echo "Found $found_managers package managers:"
echo ""
printf "%-12s %-8s %-8s %-8s %s\n" "MANAGER" "VERSION" "SCOPE" "PACKAGES" "LOCATION"
printf "%-12s %-8s %-8s %-8s %s\n" "-------" "-------" "-----" "--------" "--------"
# Second pass: display one line per scope and check for updates
for mgr in "${managers[@]}"; do
# Get scopes for this manager
local scopes
scopes="$(get_manager_scopes "$mgr")"
# Skip if no scopes detected
[ -z "$scopes" ] && continue
# Get version and location once (reuse for all scopes)
local version location stats
stats="$(get_manager_stats "$mgr")"
IFS='|' read -r location version _ <<< "$stats"
# Check if manager itself is outdated (only once per manager)
# Wrap in subshell to prevent pipefail from exiting on check failure
if ( check_manager_outdated "$mgr" "$version" ); then
outdated_managers+=("$mgr")
fi
# Split scopes and print one line per scope
IFS=',' read -ra SCOPE_ARRAY <<< "$scopes"
for scope in "${SCOPE_ARRAY[@]}"; do
local pkg_count
pkg_count="$(get_manager_packages_by_scope "$mgr" "$scope")"
printf "%-12s %-8s %-8s %-8s %s\n" "$mgr" "$version" "$scope" "$pkg_count" "$location"
found_scopes=$((found_scopes + 1))
done
done
echo ""
log "$found_scopes total scopes across $found_managers managers"
echo ""
# Show outdated package managers if any
if [ ${#outdated_managers[@]} -gt 0 ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ Outdated Package Managers Detected"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "The following package managers have updates available:"
echo ""
for mgr in "${outdated_managers[@]}"; do
show_manager_update_hint "$mgr"
done
echo ""
echo "Run 'make auto-update' or './scripts/auto_update.sh update' to update all."
echo ""
fi
}
confirm_project_update() {
local mgr="$1"
local project_file="$2"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📦 PROJECT SCOPE UPDATE: $mgr"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Project: $(pwd)"
echo "File: $project_file"
echo ""
echo "This will update project dependencies"
echo ""
echo "⚠️ WARNING: This may break your project if dependencies are"
echo " version-pinned or have breaking changes"
echo ""
read -p "Continue with project update? [y/N] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
return 0
else
log "$mgr: Project update cancelled"
return 1
fi
}
run_all_updates() {
# Determine target scope
local target_scope="${SCOPE:-$(determine_default_scope)}"
log "Starting auto-update for scope: $target_scope"
echo ""
# System scope updates
if [ "$target_scope" = "system" ] || [ "$target_scope" = "all" ]; then
if [ "$SKIP_SYSTEM" = "0" ]; then
update_apt
update_snap
# Check if system-scoped
[ "$(get_brew_scopes)" = "system" ] && update_brew
[[ "$(get_flatpak_scopes)" == *"system"* ]] && update_flatpak
[[ "$(get_gem_scopes)" == *"system"* ]] && update_gem
else
log "Skipping system package managers (SKIP_SYSTEM=1)"
fi
fi
# User scope updates
if [ "$target_scope" = "user" ] || [ "$target_scope" = "all" ]; then
# User-only managers
update_cargo
update_uv
update_pipx
update_pip
update_npm
update_pnpm
update_yarn
update_go
update_composer
update_poetry
update_gcloud
# Check if user-scoped (conditional updates based on scope detection)
[ "$(get_brew_scopes)" = "user" ] && update_brew
[[ "$(get_flatpak_scopes)" == *"user"* ]] && update_flatpak
[[ "$(get_gem_scopes)" == *"user"* ]] && update_gem
[ "$(get_az_scopes)" = "user" ] && update_az
fi
# Project scope updates (require confirmation)
if [ "$target_scope" = "project" ]; then
log "Project scope update - checking for project dependencies..."
# NPM/PNPM/Yarn
if [ -f "./package.json" ]; then
if command -v npm >/dev/null 2>&1 && confirm_project_update "npm" "./package.json"; then
run_cmd "NPM: Update project dependencies" npm update
fi
fi
# Pip/UV
if [ -f "./pyproject.toml" ] || [ -d "./.venv" ]; then
if command -v pip3 >/dev/null 2>&1 && [ -n "${VIRTUAL_ENV:-}" ] && confirm_project_update "pip" "./.venv"; then
run_cmd "Pip: Update project dependencies" python3 -m pip install --upgrade -r requirements.txt 2>/dev/null || true
fi
fi
# Bundler/Gem
if [ -f "./Gemfile" ] && command -v bundle >/dev/null 2>&1 && confirm_project_update "bundler" "./Gemfile"; then
run_cmd "Bundler: Update project dependencies" bundle update
fi
# Composer
if [ -f "./composer.json" ] && command -v composer >/dev/null 2>&1 && confirm_project_update "composer" "./composer.json"; then
run_cmd "Composer: Update project dependencies" composer update
fi
fi
echo ""
log "Auto-update complete for scope: $target_scope"
}
# ============================================================================
# CLI Interface
# ============================================================================
usage() {
cat <<EOF
Usage: $0 [OPTIONS] [COMMAND]
Auto-update all package managers and their packages with scope-aware filtering.
Commands:
detect Show detected package managers with scope information (default)
update Run updates for detected package managers (scope-aware)
apt Update only APT packages
brew Update only Homebrew packages
cargo Update only Cargo packages (includes rustup components)
uv Update only UV tools
pipx Update only Pipx packages
pip Update only Pip packages
npm Update only NPM packages
pnpm Update only PNPM packages
yarn Update only Yarn packages
go Show Go update instructions
gem Update only RubyGems packages
snap Update only Snap packages
flatpak Update only Flatpak packages
gcloud Update Google Cloud SDK components
az Update Azure CLI
Options:
--dry-run Show what would be updated without making changes
--verbose Show detailed output
--skip-system Skip system package managers (apt, brew, snap, flatpak)
-h, --help Show this help message
Environment Variables:
DRY_RUN=1 Enable dry-run mode
VERBOSE=1 Enable verbose output
SKIP_SYSTEM=1 Skip system package managers
SCOPE=<scope> Set update scope: system, user, project, all
(Default: auto-detect based on current directory)
Scope Behavior:
- In project directory (has package.json, Gemfile, etc.): defaults to 'project'
- Outside project directory: defaults to 'user'
- 'system' scope: Updates system-wide packages (requires sudo)
- 'all' scope: Updates system + user (skips project for safety)
- Project updates always require explicit confirmation
Examples:
$0 detect # List detected managers with scopes
$0 update # Update based on directory context
SCOPE=user $0 update # Update only user-scoped packages
SCOPE=system $0 update # Update only system-scoped packages
SCOPE=project $0 update # Update project dependencies (with confirmation)
SCOPE=all $0 update # Update system + user scopes
$0 --dry-run update # Show what would be updated
$0 cargo # Update only Cargo packages
DRY_RUN=1 SCOPE=user $0 update # Dry-run user-scope updates
EOF
}
# Parse options
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
--skip-system)
SKIP_SYSTEM=1
shift
;;
-h|--help)
usage
exit 0
;;
detect)
show_detected
exit 0
;;
update)
run_all_updates
exit 0
;;
apt)
update_apt
exit 0
;;
brew)
update_brew
exit 0
;;
cargo)
update_cargo
exit 0
;;
uv)
update_uv
exit 0
;;
pipx)
update_pipx
exit 0
;;
pip)
update_pip
exit 0
;;
npm)
update_npm
exit 0
;;
pnpm)
update_pnpm
exit 0
;;
yarn)
update_yarn
exit 0
;;
go)
update_go
exit 0
;;
gem)
update_gem
exit 0
;;
snap)
update_snap
exit 0
;;
flatpak)
update_flatpak
exit 0
;;
gcloud)
update_gcloud
exit 0
;;
az)
update_az
exit 0
;;
*)
echo "Error: Unknown command '$1'" >&2
echo "Run '$0 --help' for usage information." >&2
exit 1
;;
esac
done
# Default: show detected managers
show_detected
#!/usr/bin/env bash
# check_environment.sh - Audit development environment and report issues
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR/lib/common.sh" 2>/dev/null || true
source "$DIR/lib/capability.sh" 2>/dev/null || true
# Colors (fallback if lib not loaded)
RED="${RED:-\033[0;31m}"
GREEN="${GREEN:-\033[0;32m}"
YELLOW="${YELLOW:-\033[0;33m}"
BLUE="${BLUE:-\033[0;34m}"
NC="${NC:-\033[0m}"
ACTION="${1:-audit}"
PROJECT_DIR="${2:-.}"
log_ok() { printf "${GREEN}✓${NC} %s\n" "$*"; }
log_warn() { printf "${YELLOW}⚠${NC} %s\n" "$*"; }
log_error() { printf "${RED}✗${NC} %s\n" "$*"; }
log_info() { printf "${BLUE}→${NC} %s\n" "$*"; }
# Check if a tool is installed
check_tool() {
local tool="$1"
local binary="${2:-$tool}"
if command -v "$binary" >/dev/null 2>&1; then
local version
version=$("$binary" --version 2>&1 | head -1 || echo "unknown")
log_ok "$tool: $version"
return 0
else
log_error "$tool: NOT INSTALLED"
return 1
fi
}
# Check PATH configuration
check_path() {
log_info "Checking PATH configuration..."
local issues=0
# Check common user paths
local user_paths=(
"$HOME/.local/bin"
"$HOME/.cargo/bin"
"$HOME/.nvm"
"$HOME/.rbenv/bin"
"$HOME/go/bin"
)
for p in "${user_paths[@]}"; do
if [ -d "$p" ]; then
if [[ ":$PATH:" != *":$p:"* ]]; then
log_warn "$p exists but not in PATH"
issues=$((issues + 1))
fi
fi
done
# Check for shadowing (user should come before system)
if command -v node >/dev/null 2>&1; then
local node_path
node_path=$(command -v node)
if [[ "$node_path" == /usr/* ]] && [ -d "$HOME/.nvm" ]; then
log_warn "System node ($node_path) may shadow nvm-managed node"
issues=$((issues + 1))
fi
fi
if [ $issues -eq 0 ]; then
log_ok "PATH configuration looks good"
else
log_warn "$issues PATH issue(s) found"
fi
return $issues
}
# Check for duplicate installations
check_duplicates() {
log_info "Checking for duplicate installations..."
local issues=0
# Tools commonly installed multiple ways
local tools=("node" "python3" "ruby" "cargo")
for tool in "${tools[@]}"; do
local paths
paths=$(type -a "$tool" 2>/dev/null | grep -c "is" || echo 0)
if [ "$paths" -gt 1 ]; then
log_warn "$tool has multiple installations:"
type -a "$tool" 2>/dev/null | head -5
issues=$((issues + 1))
fi
done
if [ $issues -eq 0 ]; then
log_ok "No duplicate installations detected"
fi
return $issues
}
# Detect and check project requirements
check_project() {
local project_dir="$1"
log_info "Checking project requirements in $project_dir..."
local missing=0
local outdated=0
# Use detect_project_type.sh
if [ -x "$DIR/detect_project_type.sh" ]; then
local types
types=$("$DIR/detect_project_type.sh" text "$project_dir" 2>/dev/null || echo "")
echo "$types"
echo ""
# Extract required tools and check each
local required
required=$("$DIR/detect_project_type.sh" json "$project_dir" 2>/dev/null | grep -o '"required_tools":\[.*\]' | sed 's/"required_tools":\[//;s/\]//;s/"//g;s/,/ /g' || echo "")
for tool in $required; do
if ! check_tool "$tool" >/dev/null 2>&1; then
missing=$((missing + 1))
fi
done
fi
if [ $missing -gt 0 ]; then
log_warn "$missing required tool(s) missing"
else
log_ok "All required tools installed"
fi
}
# Check package managers
check_package_managers() {
log_info "Checking package managers..."
local managers=(
"apt:apt-get"
"brew:brew"
"cargo:cargo"
"npm:npm"
"pnpm:pnpm"
"yarn:yarn"
"pip:pip3"
"uv:uv"
"pipx:pipx"
"gem:gem"
"go:go"
)
local found=0
for entry in "${managers[@]}"; do
local name="${entry%%:*}"
local binary="${entry##*:}"
if command -v "$binary" >/dev/null 2>&1; then
local version
version=$("$binary" --version 2>&1 | head -1 | cut -d' ' -f1-3 || echo "")
printf " ${GREEN}●${NC} %s: %s\n" "$name" "$version"
found=$((found + 1))
fi
done
log_ok "$found package manager(s) available"
}
# Main audit
run_audit() {
echo ""
echo "═══════════════════════════════════════════════"
echo " CLI Tools Environment Audit"
echo "═══════════════════════════════════════════════"
echo ""
check_path
echo ""
check_duplicates
echo ""
check_package_managers
echo ""
check_project "$PROJECT_DIR"
echo ""
echo "═══════════════════════════════════════════════"
echo " Core Tools Status"
echo "═══════════════════════════════════════════════"
echo ""
# Check core tools
check_tool "git" || true
check_tool "jq" || true
check_tool "ripgrep" "rg" || true
check_tool "fd" "fd" || check_tool "fd" "fdfind" || true
check_tool "fzf" || true
check_tool "bat" "bat" || check_tool "bat" "batcat" || true
echo ""
}
# Run update check
run_update_check() {
log_info "Checking for updates..."
if [ -x "$DIR/auto_update.sh" ]; then
DRY_RUN=1 "$DIR/auto_update.sh" update
else
log_warn "auto_update.sh not found"
fi
}
case "$ACTION" in
audit|check)
run_audit
;;
update-check)
run_update_check
;;
path)
check_path
;;
duplicates)
check_duplicates
;;
project)
check_project "$PROJECT_DIR"
;;
managers)
check_package_managers
;;
*)
echo "Usage: $0 {audit|update-check|path|duplicates|project|managers} [project_dir]"
exit 1
;;
esac
#!/usr/bin/env bash
# detect_project_type.sh - Detect project type from files in current directory
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Output format: json or text
FORMAT="${1:-text}"
detect_types() {
local types=()
local dir="${1:-.}"
# Python
if [ -f "$dir/pyproject.toml" ] || [ -f "$dir/setup.py" ] || [ -f "$dir/requirements.txt" ] || [ -f "$dir/Pipfile" ]; then
types+=("python")
fi
# Node.js
if [ -f "$dir/package.json" ]; then
types+=("node")
fi
# Rust
if [ -f "$dir/Cargo.toml" ]; then
types+=("rust")
fi
# Go
if [ -f "$dir/go.mod" ]; then
types+=("go")
fi
# Ruby
if [ -f "$dir/Gemfile" ] || [ -f "$dir/.ruby-version" ]; then
types+=("ruby")
fi
# PHP
if [ -f "$dir/composer.json" ] || [ -f "$dir/composer.lock" ] || compgen -G "$dir/*.php" > /dev/null 2>&1; then
types+=("php")
fi
# Docker
if [ -f "$dir/Dockerfile" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ] || [ -f "$dir/compose.yml" ]; then
types+=("docker")
fi
# Terraform
if compgen -G "$dir/*.tf" > /dev/null 2>&1 || [ -d "$dir/terraform" ]; then
types+=("terraform")
fi
# Kubernetes
if [ -f "$dir/k8s" ] || compgen -G "$dir/**/deployment.yaml" > /dev/null 2>&1; then
types+=("kubernetes")
fi
# Ansible
if [ -f "$dir/ansible.cfg" ] || [ -d "$dir/playbooks" ] || compgen -G "$dir/*.yml" > /dev/null 2>&1; then
# Check if yaml files look like ansible
if grep -rq "hosts:" "$dir"/*.yml 2>/dev/null || grep -rq "tasks:" "$dir"/*.yml 2>/dev/null; then
types+=("ansible")
fi
fi
# Shell/Make
if [ -f "$dir/Makefile" ] || compgen -G "$dir/*.sh" > /dev/null 2>&1; then
types+=("shell")
fi
echo "${types[@]}"
}
get_required_tools() {
local project_type="$1"
case "$project_type" in
python) echo "python uv" ;;
node) echo "node npm" ;;
rust) echo "rust" ;;
go) echo "go" ;;
ruby) echo "ruby" ;;
php) echo "php composer" ;;
docker) echo "docker compose" ;;
terraform) echo "terraform" ;;
kubernetes) echo "kubectl" ;;
ansible) echo "ansible-core" ;;
shell) echo "" ;;
*) echo "" ;;
esac
}
get_recommended_tools() {
local project_type="$1"
case "$project_type" in
python) echo "ruff black mypy" ;;
node) echo "eslint prettier" ;;
rust) echo "" ;;
go) echo "golangci-lint" ;;
ruby) echo "" ;;
php) echo "phpstan phpcs" ;;
docker) echo "dive trivy" ;;
terraform) echo "tfsec trivy" ;;
kubernetes) echo "" ;;
ansible) echo "" ;;
shell) echo "shellcheck shfmt" ;;
*) echo "" ;;
esac
}
# Main
PROJECT_DIR="${2:-.}"
TYPES=$(detect_types "$PROJECT_DIR")
if [ "$FORMAT" = "json" ]; then
# Output as JSON
echo "{"
echo " \"project_types\": [$(echo "$TYPES" | tr ' ' '\n' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')],"
all_required=""
all_recommended=""
for t in $TYPES; do
all_required="$all_required $(get_required_tools "$t")"
all_recommended="$all_recommended $(get_recommended_tools "$t")"
done
# Dedupe
all_required=$(echo "$all_required" | tr ' ' '\n' | sort -u | tr '\n' ' ')
all_recommended=$(echo "$all_recommended" | tr ' ' '\n' | sort -u | tr '\n' ' ')
echo " \"required_tools\": [$(echo "$all_required" | tr ' ' '\n' | grep -v '^$' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')],"
echo " \"recommended_tools\": [$(echo "$all_recommended" | tr ' ' '\n' | grep -v '^$' | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')]"
echo "}"
else
# Text output
if [ -z "$TYPES" ]; then
echo "No specific project type detected"
exit 0
fi
echo "Detected project types: $TYPES"
echo ""
for t in $TYPES; do
req=$(get_required_tools "$t")
rec=$(get_recommended_tools "$t")
echo "[$t]"
[ -n "$req" ] && echo " Required: $req"
[ -n "$rec" ] && echo " Recommended: $rec"
done
fi
#!/usr/bin/env bash
# Dedicated installer for Composer
# Downloads latest stable composer.phar and installs to /usr/local/bin
set -euo pipefail
INSTALL_DIR="${COMPOSER_INSTALL_DIR:-/usr/local/bin}"
COMPOSER_URL="https://getcomposer.org/download/latest-stable/composer.phar"
echo "[composer] Downloading latest stable composer.phar..."
# Download to temp file
TMP_FILE="$(mktemp)"
trap "rm -f '$TMP_FILE'" EXIT
if ! curl -fsSL "$COMPOSER_URL" -o "$TMP_FILE"; then
echo "[composer] Error: Failed to download from $COMPOSER_URL" >&2
exit 1
fi
# Verify it's a valid phar by checking version
echo "[composer] Verifying downloaded phar..."
if ! php "$TMP_FILE" --version >/dev/null 2>&1; then
echo "[composer] Error: Downloaded file is not a valid composer.phar" >&2
exit 1
fi
# Get version
COMPOSER_VERSION="$(php "$TMP_FILE" --version 2>/dev/null | head -1 || echo 'unknown')"
echo "[composer] Downloaded: $COMPOSER_VERSION"
# Get current version
CURRENT_VERSION="$(command -v composer >/dev/null 2>&1 && composer --version 2>/dev/null | head -1 || echo '<none>')"
echo "[composer] Current: $CURRENT_VERSION"
# Install composer.phar
echo "[composer] Installing to $INSTALL_DIR/composer..."
if [ -w "$INSTALL_DIR" ]; then
# User has write access
cp "$TMP_FILE" "$INSTALL_DIR/composer"
chmod 755 "$INSTALL_DIR/composer"
elif command -v sudo >/dev/null 2>&1; then
# Need sudo
echo "[composer] Requires sudo to install to $INSTALL_DIR"
sudo cp "$TMP_FILE" "$INSTALL_DIR/composer"
sudo chmod 755 "$INSTALL_DIR/composer"
else
echo "[composer] Error: Cannot write to $INSTALL_DIR and sudo not available" >&2
echo "[composer] Try: COMPOSER_INSTALL_DIR=~/.local/bin $0" >&2
exit 1
fi
# Verify installation
NEW_VERSION="$(composer --version 2>/dev/null | head -1 || echo '<failed>')"
echo "[composer] Installed: $NEW_VERSION"
if [ "$NEW_VERSION" = "<failed>" ]; then
echo "[composer] Error: Installation verification failed" >&2
exit 1
fi
echo "[composer] ✓ Installation successful"
#!/usr/bin/env bash
# Main orchestrator for tool installation
# Reads catalog and delegates to appropriate installer or reconciliation system
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source reconciliation libraries
. "$DIR/lib/reconcile.sh"
TOOL="${1:-}"
ACTION="${2:-install}"
if [ -z "$TOOL" ]; then
echo "Usage: $0 TOOL_NAME [ACTION]" >&2
echo "Actions: install, update, reconcile, status, uninstall" >&2
exit 1
fi
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
# Check if tool has catalog entry
if [ ! -f "$CATALOG_FILE" ]; then
echo "[$TOOL] Error: No catalog entry found" >&2
echo "[$TOOL] Available tools: $(find "$DIR/../catalog" -name '*.json' -exec basename {} .json \; | tr '\n' ' ')" >&2
exit 1
fi
# Check if jq is available
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required but not found" >&2
exit 1
fi
# Read install method from catalog
INSTALL_METHOD="$(jq -r '.install_method' "$CATALOG_FILE")"
if [ -z "$INSTALL_METHOD" ] || [ "$INSTALL_METHOD" = "null" ]; then
echo "[$TOOL] Error: No install_method specified in catalog" >&2
exit 1
fi
# Check if tool uses reconciliation system (install_method == "auto")
if [ "$INSTALL_METHOD" = "auto" ]; then
# Use reconciliation system
case "$ACTION" in
install|update|reconcile)
# Pass the actual action to reconcile_tool
reconcile_tool "$CATALOG_FILE" "$ACTION"
exit $?
;;
status)
reconcile_tool "$CATALOG_FILE" "status"
exit $?
;;
uninstall)
# Get current method and remove it
binary_name="$(jq -r '.binary_name // ""' "$CATALOG_FILE" 2>/dev/null || echo "$TOOL")"
current_method="$(detect_install_method "$TOOL" "$binary_name")"
if [ "$current_method" != "none" ]; then
remove_installation "$TOOL" "$current_method" "$binary_name"
echo "[$TOOL] Uninstalled (was via $current_method)"
else
echo "[$TOOL] Not installed"
fi
exit 0
;;
*)
echo "[$TOOL] Error: Unknown action: $ACTION" >&2
exit 1
;;
esac
fi
# Traditional path: delegate to appropriate installer
INSTALLER_SCRIPT="$DIR/installers/${INSTALL_METHOD}.sh"
if [ ! -f "$INSTALLER_SCRIPT" ]; then
echo "[$TOOL] Error: Installer not found: $INSTALLER_SCRIPT" >&2
echo "[$TOOL] install_method: $INSTALL_METHOD" >&2
exit 1
fi
if [ ! -x "$INSTALLER_SCRIPT" ]; then
echo "[$TOOL] Error: Installer not executable: $INSTALLER_SCRIPT" >&2
exit 1
fi
# Execute installer with all remaining arguments
shift # Remove TOOL from $@
exec "$INSTALLER_SCRIPT" "$TOOL" "$@"
#!/usr/bin/env bash
# AWS CLI installer
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$DIR/lib/install_strategy.sh"
TOOL="${1:-aws}"
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
if [ ! -f "$CATALOG_FILE" ]; then
echo "Error: Catalog file not found: $CATALOG_FILE" >&2
exit 1
fi
# Parse catalog
INSTALLER_URL="$(jq -r '.installer_url' "$CATALOG_FILE")"
BINARY_NAME="$(jq -r '.binary_name' "$CATALOG_FILE")"
# Get current version
before="$(command -v "$BINARY_NAME" >/dev/null 2>&1 && "$BINARY_NAME" --version || true)"
# Determine installation directory
BIN_DIR="$(get_install_dir "$BINARY_NAME")"
get_install_cmd "$BIN_DIR"
mkdir -p "$BIN_DIR" 2>/dev/null || true
# Download and install
TMP="$(mktemp -d)"
cd "$TMP"
curl -fsSL "$INSTALLER_URL" -o awscliv2.zip
unzip -q awscliv2.zip
# AWS installer supports --bin-dir and --install-dir options
./aws/install --bin-dir "$BIN_DIR" --install-dir "${BIN_DIR%/bin}/aws-cli" --update 2>/dev/null || \
./aws/install --bin-dir "$BIN_DIR" --install-dir "${BIN_DIR%/bin}/aws-cli" 2>/dev/null || true
cd - >/dev/null
rm -rf "$TMP"
# Report
after="$(command -v "$BINARY_NAME" >/dev/null 2>&1 && "$BINARY_NAME" --version || true)"
path="$(command -v "$BINARY_NAME" 2>/dev/null || true)"
printf "[%s] before: %s\n" "$TOOL" "${before:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<none>}"
if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi
# Refresh snapshot after successful installation
refresh_snapshot "$TOOL"
#!/usr/bin/env bash
# Delegator for tools with dedicated installation scripts
# Reads catalog to find which script to run, then delegates
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TOOL="${1:-}"
if [ -z "$TOOL" ]; then
echo "Usage: $0 TOOL_NAME" >&2
exit 1
fi
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
if [ ! -f "$CATALOG_FILE" ]; then
echo "Error: Catalog file not found: $CATALOG_FILE" >&2
exit 1
fi
# Check if jq is available
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required but not found" >&2
exit 1
fi
# Read script name from catalog
SCRIPT_NAME="$(jq -r '.script' "$CATALOG_FILE")"
if [ -z "$SCRIPT_NAME" ] || [ "$SCRIPT_NAME" = "null" ]; then
echo "[$TOOL] Error: No script specified in catalog" >&2
exit 1
fi
SCRIPT_PATH="$DIR/$SCRIPT_NAME"
if [ ! -f "$SCRIPT_PATH" ]; then
echo "[$TOOL] Error: Script not found: $SCRIPT_PATH" >&2
exit 1
fi
if [ ! -x "$SCRIPT_PATH" ]; then
echo "[$TOOL] Error: Script not executable: $SCRIPT_PATH" >&2
exit 1
fi
# Delegate to dedicated script (skip TOOL argument, pass only ACTION)
shift # Remove TOOL from $@
exec "$SCRIPT_PATH" "$@"
#!/usr/bin/env bash
# Generic installer for GitHub repository clones
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$DIR/lib/install_strategy.sh"
TOOL="${1:-}"
if [ -z "$TOOL" ]; then
echo "Usage: $0 TOOL_NAME [ACTION]" >&2
exit 1
fi
ACTION="${2:-install}"
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
if [ ! -f "$CATALOG_FILE" ]; then
echo "[$TOOL] Error: Catalog file not found: $CATALOG_FILE" >&2
exit 1
fi
# Parse catalog
GITHUB_REPO="$(jq -r '.github_repo // ""' "$CATALOG_FILE")"
CLONE_PATH="$(jq -r '.clone_path // ""' "$CATALOG_FILE")"
BRANCH="$(jq -r '.branch // "master"' "$CATALOG_FILE")"
if [ -z "$GITHUB_REPO" ]; then
echo "[$TOOL] Error: github_repo not specified in catalog" >&2
exit 1
fi
if [ -z "$CLONE_PATH" ]; then
echo "[$TOOL] Error: clone_path not specified in catalog" >&2
exit 1
fi
# Expand tilde in clone path
CLONE_PATH="${CLONE_PATH/#\~/$HOME}"
# Ensure git is available
if ! command -v git >/dev/null 2>&1; then
echo "[$TOOL] Error: git not found. Please install git first." >&2
exit 1
fi
# Get current version/commit if already cloned
before=""
if [ -d "$CLONE_PATH/.git" ]; then
cd "$CLONE_PATH"
before="$(git rev-parse --short HEAD 2>/dev/null || echo "<unknown>")"
cd - >/dev/null
fi
# Clone or update
if [ ! -d "$CLONE_PATH" ]; then
echo "[$TOOL] Cloning from https://github.com/$GITHUB_REPO" >&2
mkdir -p "$(dirname "$CLONE_PATH")"
git clone --depth=1 --branch="$BRANCH" "https://github.com/$GITHUB_REPO.git" "$CLONE_PATH" || {
echo "[$TOOL] Error: git clone failed" >&2
exit 1
}
else
echo "[$TOOL] Updating repository at $CLONE_PATH" >&2
cd "$CLONE_PATH"
git fetch origin "$BRANCH" --depth=1 || {
echo "[$TOOL] Error: git fetch failed" >&2
exit 1
}
git reset --hard "origin/$BRANCH" || {
echo "[$TOOL] Error: git reset failed" >&2
exit 1
}
cd - >/dev/null
fi
# Get new version/commit
after=""
if [ -d "$CLONE_PATH/.git" ]; then
cd "$CLONE_PATH"
after="$(git rev-parse --short HEAD 2>/dev/null || echo "<unknown>")"
cd - >/dev/null
fi
# Report
printf "[%s] before: %s\n" "$TOOL" "${before:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<unknown>}"
printf "[%s] path: %s\n" "$TOOL" "$CLONE_PATH"
# Refresh snapshot after successful installation
refresh_snapshot "$TOOL"
#!/usr/bin/env bash
# Generic installer for GitHub release binaries
# Reads tool metadata from catalog and installs binary
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$DIR/lib/common.sh"
. "$DIR/lib/install_strategy.sh"
TOOL="${1:-}"
if [ -z "$TOOL" ]; then
echo "Usage: $0 TOOL_NAME" >&2
exit 1
fi
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
if [ ! -f "$CATALOG_FILE" ]; then
echo "Error: Catalog file not found: $CATALOG_FILE" >&2
exit 1
fi
# Parse catalog
BINARY_NAME="$(jq -r '.binary_name' "$CATALOG_FILE")"
VERSION_URL="$(jq -r '.version_url // empty' "$CATALOG_FILE")"
DOWNLOAD_URL_TEMPLATE="$(jq -r '.download_url_template' "$CATALOG_FILE")"
FALLBACK_URL_TEMPLATE="$(jq -r '.fallback_url_template // empty' "$CATALOG_FILE")"
GITHUB_REPO="$(jq -r '.github_repo // empty' "$CATALOG_FILE")"
PRESERVE_DIR="$(jq -r '.preserve_directory // empty' "$CATALOG_FILE")"
# Get current version (try multiple version command formats)
before=""
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
before="$(timeout 2 "$BINARY_NAME" --version </dev/null 2>/dev/null || \
timeout 2 "$BINARY_NAME" version --client </dev/null 2>/dev/null | head -1 || \
timeout 2 "$BINARY_NAME" version </dev/null 2>/dev/null | head -1 || true)"
fi
# Detect OS and architecture
OS="linux"
ARCH_RAW="$(uname -m)"
ARCH="$ARCH_RAW"
# Apply architecture mapping if present
if jq -e ".arch_map.\"$ARCH_RAW\"" "$CATALOG_FILE" >/dev/null 2>&1; then
ARCH="$(jq -r ".arch_map.\"$ARCH_RAW\"" "$CATALOG_FILE")"
fi
# Determine installation directory
BIN_DIR="$(get_install_dir "$BINARY_NAME")"
get_install_cmd "$BIN_DIR"
mkdir -p "$BIN_DIR" 2>/dev/null || true
# Resolve latest version
LATEST=""
if [ -n "$VERSION_URL" ]; then
LATEST="$(curl -fsSL "$VERSION_URL" 2>/dev/null || true)"
fi
# Try GitLab project if available
GITLAB_PROJECT="$(jq -r '.gitlab_project // empty' "$CATALOG_FILE")"
if [ -z "$LATEST" ] && [ -n "$GITLAB_PROJECT" ]; then
ENCODED_PROJECT="${GITLAB_PROJECT//\//%2F}"
LATEST="$(curl -fsSL "https://gitlab.com/api/v4/projects/${ENCODED_PROJECT}/releases?per_page=1" 2>/dev/null | \
jq -r '.[0].tag_name // empty' 2>/dev/null || true)"
fi
# Fallback to GitHub releases if no version URL
if [ -z "$LATEST" ] && [ -n "$GITHUB_REPO" ]; then
LATEST="$(curl -fsSIL -H "User-Agent: cli-audit" -o /dev/null -w '%{url_effective}' \
"https://github.com/$GITHUB_REPO/releases/latest" 2>/dev/null | awk -F'/' '{print $NF}')"
fi
if [ -z "$LATEST" ]; then
echo "[$TOOL] Error: Unable to resolve latest version" >&2
echo "[$TOOL] before: ${before:-<none>}" >&2
exit 1
fi
# Normalize version: strip tool name prefix if present
# Some projects tag releases as "toolname-version" (e.g., jq-1.8.1)
# but their download URLs expect just the version number
# Example: jq tags as "jq-1.8.1" but URL is "...jq-{version}/..." where version=1.8.1
if [[ "$LATEST" == "${BINARY_NAME}-"* ]]; then
LATEST="${LATEST#${BINARY_NAME}-}"
fi
# Build download URL
# Support {version_nov} for version without 'v' prefix
# Support {arch_suffix} for tools like ninja that use empty suffix for x86_64
LATEST_NOV="${LATEST#v}"
DOWNLOAD_URL="${DOWNLOAD_URL_TEMPLATE//\{version\}/$LATEST}"
DOWNLOAD_URL="${DOWNLOAD_URL//\{version_nov\}/$LATEST_NOV}"
DOWNLOAD_URL="${DOWNLOAD_URL//\{os\}/$OS}"
DOWNLOAD_URL="${DOWNLOAD_URL//\{arch\}/$ARCH}"
DOWNLOAD_URL="${DOWNLOAD_URL//\{arch_suffix\}/$ARCH}"
# Download with retry and fallback
tmpfile="/tmp/$BINARY_NAME.$$"
rm -f "$tmpfile"
if ! curl -fL --retry 3 --retry-delay 1 --connect-timeout 10 -o "$tmpfile" "$DOWNLOAD_URL" 2>/dev/null; then
if [ -n "$FALLBACK_URL_TEMPLATE" ]; then
FALLBACK_URL="${FALLBACK_URL_TEMPLATE//\{version\}/$LATEST}"
FALLBACK_URL="${FALLBACK_URL//\{os\}/$OS}"
FALLBACK_URL="${FALLBACK_URL//\{arch\}/$ARCH}"
curl -fL --retry 3 --retry-delay 1 --connect-timeout 10 -o "$tmpfile" "$FALLBACK_URL"
else
echo "[$TOOL] Error: Download failed" >&2
exit 1
fi
fi
# Validate download
if ! [ -s "$tmpfile" ]; then
echo "[$TOOL] Error: Downloaded file is empty" >&2
rm -f "$tmpfile"
exit 1
fi
# Extract if archive, otherwise use directly
BINARY_PATH="$tmpfile"
EXTRACT_DIR=""
if [[ "$DOWNLOAD_URL" == *.tar.gz ]] || [[ "$DOWNLOAD_URL" == *.tgz ]]; then
# Extract tar.gz
EXTRACT_DIR="/tmp/${BINARY_NAME}-extract.$$"
mkdir -p "$EXTRACT_DIR"
if ! tar -xzf "$tmpfile" -C "$EXTRACT_DIR" 2>/dev/null; then
echo "[$TOOL] Error: Failed to extract tar.gz archive" >&2
rm -rf "$tmpfile" "$EXTRACT_DIR"
exit 1
fi
# Find the binary in extracted files
BINARY_PATH="$(find "$EXTRACT_DIR" -type f -name "$BINARY_NAME" -executable 2>/dev/null | head -1)"
if [ -z "$BINARY_PATH" ] || [ ! -f "$BINARY_PATH" ]; then
# Try without executable check (some archives don't preserve execute bit)
BINARY_PATH="$(find "$EXTRACT_DIR" -type f -name "$BINARY_NAME" 2>/dev/null | head -1)"
fi
if [ -z "$BINARY_PATH" ] || [ ! -f "$BINARY_PATH" ]; then
echo "[$TOOL] Error: Binary '$BINARY_NAME' not found in archive" >&2
echo "[$TOOL] Archive contents:" >&2
find "$EXTRACT_DIR" -type f 2>/dev/null | head -10 >&2
rm -rf "$tmpfile" "$EXTRACT_DIR"
exit 1
fi
rm -f "$tmpfile"
elif [[ "$DOWNLOAD_URL" == *.tar.xz ]]; then
# Extract tar.xz
EXTRACT_DIR="/tmp/${BINARY_NAME}-extract.$$"
mkdir -p "$EXTRACT_DIR"
if ! tar -xJf "$tmpfile" -C "$EXTRACT_DIR" 2>/dev/null; then
echo "[$TOOL] Error: Failed to extract tar.xz archive" >&2
rm -rf "$tmpfile" "$EXTRACT_DIR"
exit 1
fi
# Find the binary in extracted files
BINARY_PATH="$(find "$EXTRACT_DIR" -type f -name "$BINARY_NAME" -executable 2>/dev/null | head -1)"
if [ -z "$BINARY_PATH" ] || [ ! -f "$BINARY_PATH" ]; then
# Try without executable check (some archives don't preserve execute bit)
BINARY_PATH="$(find "$EXTRACT_DIR" -type f -name "$BINARY_NAME" 2>/dev/null | head -1)"
fi
if [ -z "$BINARY_PATH" ] || [ ! -f "$BINARY_PATH" ]; then
echo "[$TOOL] Error: Binary '$BINARY_NAME' not found in archive" >&2
echo "[$TOOL] Archive contents:" >&2
find "$EXTRACT_DIR" -type f 2>/dev/null | head -10 >&2
rm -rf "$tmpfile" "$EXTRACT_DIR"
exit 1
fi
rm -f "$tmpfile"
elif [[ "$DOWNLOAD_URL" == *.zip ]]; then
# Extract zip
EXTRACT_DIR="/tmp/${BINARY_NAME}-extract.$$"
mkdir -p "$EXTRACT_DIR"
if ! unzip -q "$tmpfile" -d "$EXTRACT_DIR" 2>/dev/null; then
echo "[$TOOL] Error: Failed to extract zip archive" >&2
rm -rf "$tmpfile" "$EXTRACT_DIR"
exit 1
fi
# Find the binary in extracted files
BINARY_PATH="$(find "$EXTRACT_DIR" -type f -name "$BINARY_NAME" -executable 2>/dev/null | head -1)"
if [ -z "$BINARY_PATH" ] || [ ! -f "$BINARY_PATH" ]; then
BINARY_PATH="$(find "$EXTRACT_DIR" -type f -name "$BINARY_NAME" 2>/dev/null | head -1)"
fi
if [ -z "$BINARY_PATH" ] || [ ! -f "$BINARY_PATH" ]; then
echo "[$TOOL] Error: Binary '$BINARY_NAME' not found in archive" >&2
echo "[$TOOL] Archive contents:" >&2
find "$EXTRACT_DIR" -type f 2>/dev/null | head -10 >&2
rm -rf "$tmpfile" "$EXTRACT_DIR"
exit 1
fi
rm -f "$tmpfile"
fi
# Note: We intentionally do NOT remove existing installations from apt/brew/cargo
# The new version in ~/.local/bin will take precedence via PATH ordering
# This allows:
# - No sudo password prompts
# - No disruption to package manager state
# - Clean fallback if ~/.local/bin version is removed
# - System packages can still satisfy dependencies for other tools
# Install
if [ -n "$PRESERVE_DIR" ] && [ -n "$EXTRACT_DIR" ]; then
# Tool requires full directory structure (e.g., GAM with bundled Python)
LIB_DIR="$(dirname "$BIN_DIR")/lib"
mkdir -p "$LIB_DIR"
# Remove old installation
rm -rf "$LIB_DIR/$PRESERVE_DIR"
# Move entire directory to ~/.local/lib
mv "$EXTRACT_DIR/$PRESERVE_DIR" "$LIB_DIR/"
# Create symlink in bin directory
ln -sf "$LIB_DIR/$PRESERVE_DIR/$BINARY_NAME" "$BIN_DIR/$BINARY_NAME"
else
# Standard binary installation
chmod +x "$BINARY_PATH"
$INSTALL -T "$BINARY_PATH" "$BIN_DIR/$BINARY_NAME"
fi
# Cleanup
rm -f "$tmpfile"
if [ -n "$EXTRACT_DIR" ] && [ -d "$EXTRACT_DIR" ]; then
rm -rf "$EXTRACT_DIR"
fi
# Report
after="$(command -v "$BINARY_NAME" >/dev/null 2>&1 && \
(timeout 2 "$BINARY_NAME" --version </dev/null 2>/dev/null || \
timeout 2 "$BINARY_NAME" version --client </dev/null 2>/dev/null | head -1 || \
timeout 2 "$BINARY_NAME" version </dev/null 2>/dev/null | head -1 || true))"
path="$(command -v "$BINARY_NAME" 2>/dev/null || true)"
printf "[%s] before: %s\n" "$TOOL" "${before:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<none>}"
if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi
# Refresh snapshot after successful installation
refresh_snapshot "$TOOL"
#!/usr/bin/env bash
# Generic installer for HashiCorp zip releases
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$DIR/lib/common.sh"
. "$DIR/lib/install_strategy.sh"
TOOL="${1:-}"
if [ -z "$TOOL" ]; then
echo "Usage: $0 TOOL_NAME" >&2
exit 1
fi
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
if [ ! -f "$CATALOG_FILE" ]; then
echo "Error: Catalog file not found: $CATALOG_FILE" >&2
exit 1
fi
# Parse catalog
PRODUCT_NAME="$(jq -r '.product_name' "$CATALOG_FILE")"
BINARY_NAME="$(jq -r '.binary_name' "$CATALOG_FILE")"
GITHUB_REPO="$(jq -r '.github_repo // empty' "$CATALOG_FILE")"
# Get current version
before="$(command -v "$BINARY_NAME" >/dev/null 2>&1 && "$BINARY_NAME" version 2>/dev/null | head -n1 || true)"
# Detect OS and architecture
OS="linux"
ARCH_RAW="$(uname -m)"
ARCH="$ARCH_RAW"
# Apply architecture mapping if present
if jq -e ".arch_map.\"$ARCH_RAW\"" "$CATALOG_FILE" >/dev/null 2>&1; then
ARCH="$(jq -r ".arch_map.\"$ARCH_RAW\"" "$CATALOG_FILE")"
fi
# Determine installation directory
BIN_DIR="$(get_install_dir "$BINARY_NAME")"
get_install_cmd "$BIN_DIR"
mkdir -p "$BIN_DIR" 2>/dev/null || true
# Remove distro package first if it exists
apt_remove_if_present "$BINARY_NAME" || true
# Get latest version from GitHub releases
LATEST_TAG=""
if [ -n "$GITHUB_REPO" ]; then
LATEST_TAG="$(curl -fsSIL -H "User-Agent: cli-audit" -o /dev/null -w '%{url_effective}' \
"https://github.com/$GITHUB_REPO/releases/latest" 2>/dev/null | awk -F'/' '{print $NF}')"
fi
VER="${LATEST_TAG#v}"
if [ -z "$VER" ]; then
echo "[$TOOL] Error: Could not resolve latest version" >&2
echo "[$TOOL] before: ${before:-<none>}" >&2
exit 1
fi
# Download and install
TMP="$(mktemp -d)"
URL="https://releases.hashicorp.com/${PRODUCT_NAME}/${VER}/${PRODUCT_NAME}_${VER}_${OS}_${ARCH}.zip"
if curl -fsSL "$URL" -o "$TMP/${PRODUCT_NAME}.zip"; then
unzip -q "$TMP/${PRODUCT_NAME}.zip" -d "$TMP" || true
if [ -f "$TMP/$BINARY_NAME" ]; then
$INSTALL "$TMP/$BINARY_NAME" "$BIN_DIR/$BINARY_NAME"
else
echo "[$TOOL] Error: Binary not found in zip" >&2
rm -rf "$TMP"
exit 1
fi
else
echo "[$TOOL] Error: Download failed from $URL" >&2
rm -rf "$TMP"
exit 1
fi
rm -rf "$TMP"
# Report
after="$(command -v "$BINARY_NAME" >/dev/null 2>&1 && "$BINARY_NAME" version 2>/dev/null | head -n1 || true)"
path="$(command -v "$BINARY_NAME" 2>/dev/null || true)"
printf "[%s] before: %s\n" "$TOOL" "${before:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<none>}"
if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi
# Refresh snapshot after successful installation
refresh_snapshot "$TOOL"
#!/usr/bin/env bash
# Generic installer for npm global packages
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$DIR/lib/install_strategy.sh"
# Load nvm if available (needed for node-based package managers)
export NVM_DIR="$HOME/.nvm"
if [ -s "$NVM_DIR/nvm.sh" ]; then
. "$NVM_DIR/nvm.sh" --no-use
nvm use default >/dev/null 2>&1 || true
fi
TOOL="${1:-}"
if [ -z "$TOOL" ]; then
echo "Usage: $0 TOOL_NAME" >&2
exit 1
fi
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
if [ ! -f "$CATALOG_FILE" ]; then
echo "Error: Catalog file not found: $CATALOG_FILE" >&2
exit 1
fi
# Parse catalog
PACKAGE_NAME="$(jq -r '.package_name // .name' "$CATALOG_FILE")"
# Detect available package manager (pnpm > npm > yarn)
PKG_MANAGER=""
if command -v pnpm >/dev/null 2>&1; then
PKG_MANAGER="pnpm"
elif command -v npm >/dev/null 2>&1; then
PKG_MANAGER="npm"
elif command -v yarn >/dev/null 2>&1; then
PKG_MANAGER="yarn"
fi
if [ -z "$PKG_MANAGER" ]; then
echo "[$TOOL] Error: No package manager found (pnpm, npm, or yarn required)" >&2
exit 1
fi
# Get current version
before=""
if command -v "$TOOL" >/dev/null 2>&1; then
before="$("$TOOL" --version 2>/dev/null || true)"
fi
# Install or upgrade globally
echo "[$TOOL] Installing package globally via $PKG_MANAGER: $PACKAGE_NAME" >&2
case "$PKG_MANAGER" in
pnpm)
pnpm add -g "$PACKAGE_NAME" || {
echo "[$TOOL] Error: pnpm install failed" >&2
exit 1
}
;;
npm)
npm install -g "$PACKAGE_NAME" || {
echo "[$TOOL] Error: npm install failed" >&2
exit 1
}
;;
yarn)
yarn global add "$PACKAGE_NAME" || {
echo "[$TOOL] Error: yarn install failed" >&2
exit 1
}
;;
esac
# Report
after=""
if command -v "$TOOL" >/dev/null 2>&1; then
after="$("$TOOL" --version 2>/dev/null || true)"
fi
path="$(command -v "$TOOL" 2>/dev/null || true)"
printf "[%s] before: %s\n" "$TOOL" "${before:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<none>}"
if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi
# Refresh snapshot after successful installation
refresh_snapshot "$TOOL"
#!/usr/bin/env bash
# npm installer - upgrades npm independently from Node.js
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$DIR/lib/common.sh"
. "$DIR/lib/install_strategy.sh"
TOOL="${1:-}"
if [ -z "$TOOL" ]; then
echo "Usage: $0 TOOL_NAME" >&2
exit 1
fi
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
if [ ! -f "$CATALOG_FILE" ]; then
echo "Error: Catalog file not found: $CATALOG_FILE" >&2
exit 1
fi
BINARY_NAME="npm"
# Get current version
before="$(timeout 2 npm --version </dev/null 2>/dev/null || echo '<none>')"
# Check if npm is available
if ! command -v npm >/dev/null 2>&1; then
echo "[$TOOL] Error: npm not found. Install Node.js first." >&2
exit 1
fi
# Upgrade npm to latest version
# npm can be upgraded independently from Node.js
echo "[$TOOL] Upgrading npm to latest version..."
npm install -g npm@latest 2>&1 | grep -v "^npm " || true
# Get new version
after="$(timeout 2 npm --version </dev/null 2>/dev/null || echo '<none>')"
path="$(command -v npm 2>/dev/null || true)"
# Report
printf "[%s] before: %s\n" "$TOOL" "${before:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<none>}"
if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi
if [ "$before" != "$after" ]; then
echo "[$TOOL] Successfully upgraded: $before → $after"
else
echo "[$TOOL] Already at latest version: $after"
fi
# Refresh snapshot after successful installation
refresh_snapshot "$TOOL"
#!/usr/bin/env bash
# Generic installer for package manager tools
# Installs tools via system package managers (apt, brew, etc.)
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$DIR/lib/common.sh"
. "$DIR/lib/install_strategy.sh"
TOOL="${1:-}"
if [ -z "$TOOL" ]; then
echo "Usage: $0 TOOL_NAME" >&2
exit 1
fi
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
if [ ! -f "$CATALOG_FILE" ]; then
echo "Error: Catalog file not found: $CATALOG_FILE" >&2
exit 1
fi
# --- Input validation ---
# Allowed binaries for version commands (first word of a pipeline or command)
readonly ALLOWED_VERSION_BINARIES="awk cat cd cut docker dpkg entr git grep head jq python python3 ruby sed tail timeout tr uname uv wc which"
# Allowed package name pattern: alphanumeric, hyphens, underscores, dots, forward slashes
validate_package_name() {
local pkg="$1"
if [[ ! "$pkg" =~ ^[a-zA-Z0-9._/@:+-]+$ ]]; then
echo "Error: Invalid package name: $pkg" >&2
return 1
fi
}
# Validate version_command: ensure all command words are in the allowlist
# Accepts pipes and common shell constructs, but every command must start
# with an allowed binary or the tool's own binary name.
validate_version_command() {
local cmd="$1"
local binary="$2"
# Split on pipes and semicolons to get individual commands
local IFS_SAVE="$IFS"
local segment first_word
while IFS= read -r segment; do
# Trim leading whitespace
segment="${segment#"${segment%%[![:space:]]*}"}"
[ -z "$segment" ] && continue
# Extract the first word (the command being run)
first_word="${segment%% *}"
# Strip any leading path (e.g., ~/.rbenv/plugins/ruby-build -> ruby-build)
first_word="${first_word##*/}"
# Check against allowlist or the tool's own binary
if [ "$first_word" = "$binary" ]; then
continue
fi
local allowed=false
for bin in $ALLOWED_VERSION_BINARIES; do
if [ "$first_word" = "$bin" ]; then
allowed=true
break
fi
done
if ! $allowed; then
echo "Error: version_command contains disallowed binary '$first_word'" >&2
return 1
fi
done < <(echo "$cmd" | tr '|;' '\n')
IFS="$IFS_SAVE"
}
# Run a version command safely using bash -c instead of eval
safe_version_check() {
local cmd="$1"
bash -c "$cmd" 2>/dev/null || true
}
# --- Parse catalog ---
BINARY_NAME="$(jq -r '.binary_name // .name' "$CATALOG_FILE")"
PACKAGES="$(jq -r '.packages // {}' "$CATALOG_FILE")"
NOTES="$(jq -r '.notes // empty' "$CATALOG_FILE")"
VERSION_CMD="$(jq -r '.version_command // empty' "$CATALOG_FILE")"
# Validate binary name
if [[ ! "$BINARY_NAME" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "Error: Invalid binary name: $BINARY_NAME" >&2
exit 1
fi
# Validate version command if present
if [ -n "$VERSION_CMD" ]; then
if ! validate_version_command "$VERSION_CMD" "$BINARY_NAME"; then
echo "Error: Refusing to execute unsafe version_command for $TOOL" >&2
exit 1
fi
fi
# Get current version
if [ -n "$VERSION_CMD" ]; then
before="$(safe_version_check "$VERSION_CMD")"
else
# Default version detection
before="$(command -v "$BINARY_NAME" >/dev/null 2>&1 && timeout 2 "$BINARY_NAME" --version </dev/null 2>/dev/null | head -1 || true)"
fi
# Check if tool is already available (e.g., comes with runtime)
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
if [ -n "$NOTES" ] && echo "$NOTES" | grep -q "comes with\|bundled with"; then
# Tool is already available and comes bundled
after="$before"
path="$(command -v "$BINARY_NAME" 2>/dev/null || true)"
printf "[%s] before: %s\n" "$TOOL" "${before:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<none>}"
if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi
printf "[%s] note: %s\n" "$TOOL" "Already available (bundled with runtime)"
# Refresh snapshot to record current version
refresh_snapshot "$TOOL"
exit 0
fi
fi
# Install via appropriate package manager
installed=false
if have brew; then
pkg="$(echo "$PACKAGES" | jq -r '.brew // empty')"
if [ "$pkg" != "null" ] && [ -n "$pkg" ]; then
validate_package_name "$pkg" || exit 1
brew install "$pkg" || brew upgrade "$pkg" || true
installed=true
fi
fi
if ! $installed && have apt-get; then
pkg="$(echo "$PACKAGES" | jq -r '.apt // empty')"
if [ "$pkg" != "null" ] && [ -n "$pkg" ]; then
validate_package_name "$pkg" || exit 1
sudo apt-get update && sudo apt-get install -y -- "$pkg" || true
installed=true
fi
fi
if ! $installed && have dnf; then
pkg="$(echo "$PACKAGES" | jq -r '.dnf // .rpm // empty')"
if [ "$pkg" != "null" ] && [ -n "$pkg" ]; then
validate_package_name "$pkg" || exit 1
sudo dnf install -y -- "$pkg" || true
installed=true
fi
fi
if ! $installed && have pacman; then
pkg="$(echo "$PACKAGES" | jq -r '.pacman // .arch // empty')"
if [ "$pkg" != "null" ] && [ -n "$pkg" ]; then
validate_package_name "$pkg" || exit 1
sudo pacman -S --noconfirm -- "$pkg" || true
installed=true
fi
fi
if ! $installed; then
echo "[$TOOL] No supported package manager found (tried: brew, apt, dnf, pacman)" >&2
exit 1
fi
# Report
if [ -n "$VERSION_CMD" ]; then
after="$(safe_version_check "$VERSION_CMD")"
else
# Default version detection
after="$(command -v "$BINARY_NAME" >/dev/null 2>&1 && timeout 2 "$BINARY_NAME" --version </dev/null 2>/dev/null | head -1 || true)"
fi
path="$(command -v "$BINARY_NAME" 2>/dev/null || true)"
printf "[%s] before: %s\n" "$TOOL" "${before:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<none>}"
if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi
# Refresh snapshot after successful installation
# Need to source install_strategy.sh for refresh_snapshot function
. "$(dirname "${BASH_SOURCE[0]}")/../lib/install_strategy.sh"
refresh_snapshot "$TOOL"
#!/usr/bin/env bash
# Generic installer for uv tools
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TOOL="${1:-}"
if [ -z "$TOOL" ]; then
echo "Usage: $0 TOOL_NAME" >&2
exit 1
fi
CATALOG_FILE="$DIR/../catalog/$TOOL.json"
if [ ! -f "$CATALOG_FILE" ]; then
echo "Error: Catalog file not found: $CATALOG_FILE" >&2
exit 1
fi
# Parse catalog
PACKAGE_NAME="$(jq -r '.package_name' "$CATALOG_FILE")"
PYTHON_VERSION="$(jq -r '.python_version // empty' "$CATALOG_FILE")"
# Ensure uv is available
if ! command -v uv >/dev/null 2>&1; then
echo "[$TOOL] Error: uv not found. Please install uv first." >&2
exit 1
fi
# Get current version (skip for tools that hang on --version)
if [ "$TOOL" = "codex" ] || [ "$TOOL" = "gam" ]; then
# codex/gam binaries hang on --version - will use uv tool list instead
before="<none>"
else
before="$(command -v "$TOOL" >/dev/null 2>&1 && timeout 2 "$TOOL" --version </dev/null 2>/dev/null || true)"
fi
# Install or upgrade with optional Python version pinning
if [ -n "$PYTHON_VERSION" ]; then
echo "[$TOOL] Installing with Python $PYTHON_VERSION..."
uv tool install --force --upgrade --python "$PYTHON_VERSION" "$PACKAGE_NAME" || true
else
uv tool install --force --upgrade "$PACKAGE_NAME" || true
fi
# Report
if [ "$TOOL" = "codex" ] || [ "$TOOL" = "gam" ]; then
# codex/gam binaries hang on --version - use uv tool list instead
after="$(uv tool list 2>/dev/null | grep -E "^(codex|gam7) " | head -1 || echo "<failed>")"
else
after="$(command -v "$TOOL" >/dev/null 2>&1 && timeout 2 "$TOOL" --version 2>/dev/null || true)"
fi
path="$(command -v "$TOOL" 2>/dev/null || true)"
printf "[%s] before: %s\n" "$TOOL" "${before:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<none>}"
if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi
# Refresh snapshot after successful installation
# Source install_strategy.sh for refresh_snapshot function
. "$(dirname "${BASH_SOURCE[0]}")/../lib/install_strategy.sh"
refresh_snapshot "$TOOL"
#!/usr/bin/env bash
# capability.sh - Installation method detection and availability checking
#
# This library provides capability detection for the reconciliation system:
# 1. Detect current installation method for a tool
# 2. Check if installation methods are available on the system
# 3. Get detailed information about current installations
set -euo pipefail
# Detect which installation method was used for a tool
# Args: tool_name, binary_name
# Returns: apt|cargo|npm|gem|pip|pipx|brew|github_release_binary|unknown|none
detect_install_method() {
local tool="$1"
local binary="${2:-$tool}"
# Check if binary exists
if ! command -v "$binary" >/dev/null 2>&1; then
echo "none"
return 0
fi
local binary_path
binary_path="$(command -v "$binary")"
# Detect by path patterns
case "$binary_path" in
"$HOME/.cargo/bin/"*)
echo "cargo"
return 0
;;
"$HOME/.local/bin/"*)
# Could be github_release_binary or pipx
# Check if pipx knows about it
if command -v pipx >/dev/null 2>&1 && pipx list 2>/dev/null | grep -q "package $tool"; then
echo "pipx"
else
echo "github_release_binary"
fi
return 0
;;
"$HOME/.rbenv/"*)
echo "gem"
return 0
;;
"$HOME/.nvm/"*)
echo "npm"
return 0
;;
"/usr/local/bin/"*)
# Could be brew or manual install
if command -v brew >/dev/null 2>&1 && brew list --formula 2>/dev/null | grep -q "^${tool}\$"; then
echo "brew"
else
echo "unknown"
fi
return 0
;;
"/usr/bin/"*|"/bin/"*)
# Check if it's an apt package
if command -v dpkg >/dev/null 2>&1; then
if dpkg -S "$binary_path" >/dev/null 2>&1; then
echo "apt"
return 0
fi
fi
# Check if it's pip-installed
if command -v pip >/dev/null 2>&1 || command -v pip3 >/dev/null 2>&1; then
local pip_cmd="${PIP:-pip3}"
if ! command -v "$pip_cmd" >/dev/null 2>&1; then
pip_cmd="pip"
fi
if "$pip_cmd" show "$tool" >/dev/null 2>&1; then
echo "pip"
return 0
fi
fi
echo "unknown"
return 0
;;
*)
echo "unknown"
return 0
;;
esac
}
# Check if an installation method is available on this system
# Args: method_name
# Returns: 0 if available, 1 if not
is_method_available() {
local method="$1"
case "$method" in
apt)
# Check if dpkg exists and user has sudo access (or is root)
if ! command -v dpkg >/dev/null 2>&1; then
return 1
fi
# Check sudo access (try non-interactively)
if [ "$(id -u)" -eq 0 ]; then
return 0 # root user
fi
if sudo -n true 2>/dev/null; then
return 0 # Has cached sudo credentials
fi
# Can't determine sudo access without prompting, assume available
# The actual operation will fail if sudo isn't available
return 0
;;
cargo)
command -v cargo >/dev/null 2>&1
return $?
;;
npm)
command -v npm >/dev/null 2>&1
return $?
;;
gem)
command -v gem >/dev/null 2>&1
return $?
;;
pip)
command -v pip >/dev/null 2>&1 || command -v pip3 >/dev/null 2>&1
return $?
;;
pipx)
command -v pipx >/dev/null 2>&1
return $?
;;
brew)
command -v brew >/dev/null 2>&1
return $?
;;
github_release_binary)
# Check if we have curl or wget, and can write to ~/.local/bin
if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then
if [ -d "$HOME/.local/bin" ] || mkdir -p "$HOME/.local/bin" 2>/dev/null; then
return 0
fi
fi
return 1
;;
dedicated_script)
# Dedicated scripts are always "available" as they handle their own logic
return 0
;;
*)
echo "Unknown method: $method" >&2
return 1
;;
esac
}
# Get detailed information about current installation
# Args: tool_name, binary_name
# Returns: JSON-like output with path, method, package info
get_current_method_details() {
local tool="$1"
local binary="${2:-$tool}"
if ! command -v "$binary" >/dev/null 2>&1; then
echo "method=none"
return 0
fi
local binary_path
binary_path="$(command -v "$binary")"
local method
method="$(detect_install_method "$tool" "$binary")"
echo "path=$binary_path"
echo "method=$method"
# Get additional details based on method
case "$method" in
apt)
if command -v dpkg >/dev/null 2>&1; then
local pkg
pkg="$(dpkg -S "$binary_path" 2>/dev/null | cut -d: -f1 || echo "unknown")"
echo "package=$pkg"
local version
version="$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || echo "unknown")"
echo "version=$version"
fi
;;
cargo)
if command -v cargo >/dev/null 2>&1; then
# Try to get version from cargo
local version
version="$("$binary" --version 2>/dev/null | head -1 || echo "unknown")"
echo "version=$version"
fi
;;
npm)
if command -v npm >/dev/null 2>&1; then
local version
version="$(npm list -g --depth=0 2>/dev/null | grep "$tool@" | sed 's/.*@//' || echo "unknown")"
echo "version=$version"
fi
;;
pip|pipx)
local pip_cmd="${PIP:-pip3}"
if ! command -v "$pip_cmd" >/dev/null 2>&1; then
pip_cmd="pip"
fi
if command -v "$pip_cmd" >/dev/null 2>&1; then
local version
version="$("$pip_cmd" show "$tool" 2>/dev/null | grep "^Version:" | awk '{print $2}' || echo "unknown")"
echo "version=$version"
fi
;;
brew)
if command -v brew >/dev/null 2>&1; then
local version
version="$(brew info "$tool" 2>/dev/null | head -1 | awk '{print $3}' || echo "unknown")"
echo "version=$version"
fi
;;
github_release_binary)
local version
version="$("$binary" --version 2>/dev/null | head -1 || echo "unknown")"
echo "version=$version"
;;
esac
}
# List all available installation methods on this system
list_available_methods() {
local methods=("apt" "cargo" "npm" "gem" "pip" "pipx" "brew" "github_release_binary")
local available=()
for method in "${methods[@]}"; do
if is_method_available "$method"; then
available+=("$method")
fi
done
printf '%s\n' "${available[@]}"
}
# Check if a specific tool can be installed via a method
# This checks both method availability AND tool-specific requirements
# Args: tool_name, method, catalog_config (JSON string)
can_install_via_method() {
local tool="$1"
local method="$2"
local config="${3:-{}}"
# First check if method is available
if ! is_method_available "$method"; then
return 1
fi
# Method-specific checks could go here
# For now, if method is available, assume tool can be installed
return 0
}
#!/usr/bin/env bash
# Catalog query functions for reading tool metadata
# Assumes: Scripts are run from app root, catalog is at $ROOT/catalog
# Get all tools with a specific tag
catalog_get_tools_by_tag() {
local tag="$1"
local catalog_dir="$ROOT/catalog"
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq required for catalog operations" >&2
return 1
fi
for json in "$catalog_dir"/*.json; do
[ -f "$json" ] || continue
if jq -e --arg tag "$tag" '.tags[]? | select(. == $tag)' "$json" >/dev/null 2>&1; then
jq -r '.name' "$json"
fi
done
}
# Get all available tags
catalog_get_all_tags() {
local catalog_dir="$ROOT/catalog"
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq required for catalog operations" >&2
return 1
fi
find "$catalog_dir" -name "*.json" -exec jq -r '.tags[]? // empty' {} \; 2>/dev/null | sort -u
}
# Check if tool has catalog entry
catalog_has_tool() {
local tool="$1"
local catalog_dir="$ROOT/catalog"
[ -f "$catalog_dir/$tool.json" ]
}
# Get tool property from catalog
catalog_get_property() {
local tool="$1"
local property="$2"
local catalog_dir="$ROOT/catalog"
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq required for catalog operations" >&2
return 1
fi
local json="$catalog_dir/$tool.json"
if [ -f "$json" ]; then
jq -r ".$property // empty" "$json"
fi
}
# Get guide-specific metadata from catalog
catalog_get_guide_property() {
local tool="$1"
local property="$2"
local default="${3:-}"
local catalog_dir="$ROOT/catalog"
if ! command -v jq >/dev/null 2>&1; then
echo "$default"
return
fi
local json="$catalog_dir/$tool.json"
if [ -f "$json" ]; then
local value="$(jq -r ".guide.$property // empty" "$json")"
if [ -n "$value" ] && [ "$value" != "null" ]; then
echo "$value"
else
echo "$default"
fi
else
echo "$default"
fi
}
#!/usr/bin/env bash
set -euo pipefail
# Common helpers for installer scripts
have() { command -v "$1" >/dev/null 2>&1; }
log() { printf '%s\n' "$*" >&2; }
os_id() {
if [ -f /etc/os-release ]; then . /etc/os-release; echo "${ID:-unknown}"; else echo unknown; fi
}
ensure_sudo() { have sudo || { log "sudo not available"; exit 1; }; }
apt_remove_if_present() {
have apt-get || return 0
ensure_sudo
for pkg in "$@"; do
if dpkg -s "$pkg" >/dev/null 2>&1; then sudo apt-get remove -y "$pkg" || true; fi
done
}
apt_purge_if_present() {
have apt-get || return 0
ensure_sudo
for pkg in "$@"; do
if dpkg -s "$pkg" >/dev/null 2>&1; then sudo apt-get purge -y "$pkg" || true; fi
done
}
brew_install() { brew install "$@"; }
brew_upgrade() { brew upgrade "$@" || true; }
brew_uninstall() { brew uninstall -f "$@" || true; }
pipx_install() { have pipx || python3 -m pip install --user pipx; pipx install "$1" || true; }
pipx_upgrade() { have pipx && pipx upgrade "$1" || true; }
pipx_uninstall() { have pipx && pipx uninstall "$1" || true; }
# nvm helpers
ensure_nvm_loaded() {
# shellcheck disable=SC1090
[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh" || true
}
nvm_install_lts() { ensure_nvm_loaded; have nvm || return 1; nvm install --lts; }
nvm_use_lts() { ensure_nvm_loaded; have nvm || return 1; nvm use --lts || nvm alias default 'lts/*' || true; }
# rustup helpers
rustup_update() { have rustup && rustup self update && rustup update || true; }
rustup_uninstall() { have rustup && rustup self uninstall -y || true; }
# Paths helpers for preferred sources
is_path_under() { case "$1" in "$2"*) return 0 ;; *) return 1 ;; esac }
prefers_nvm_node() {
local p
p="$(command -v node || true)"
is_path_under "$p" "$HOME/.nvm" || return 1
}
prefers_rustup() {
local p
p="$(command -v cargo || true)"
is_path_under "$p" "$HOME/.cargo" || return 1
}
# rbenv helpers
ensure_rbenv_loaded() {
# Add rbenv to PATH and initialize if available
if [ -d "$HOME/.rbenv" ]; then
export PATH="$HOME/.rbenv/bin:$PATH"
if command -v rbenv >/dev/null 2>&1; then
eval "$(rbenv init - bash)" || true
fi
fi
}
prefers_rbenv_ruby() {
local p
p="$(command -v ruby || true)"
is_path_under "$p" "$HOME/.rbenv" || return 1
}
#!/usr/bin/env bash
# dependency.sh - Dependency resolution and ordering
#
# This library handles tool dependencies:
# - Check if dependencies are satisfied
# - Resolve installation order via topological sort
# - Detect circular dependencies
# - Validate catalog order field consistency
set -euo pipefail
# Check if dependencies for a tool are satisfied
# Args: catalog_file
# Returns: 0 if satisfied, 1 if not
check_dependencies() {
local catalog_file="$1"
local tool
tool="$(basename "$catalog_file" .json)"
if ! command -v jq >/dev/null 2>&1; then
echo "[$tool] Warning: jq not available, cannot check dependencies" >&2
return 0 # Assume satisfied
fi
# Get requires array
local requires_count
requires_count="$(jq '.requires // [] | length' "$catalog_file" 2>/dev/null || echo "0")"
if [ "$requires_count" -eq 0 ]; then
return 0 # No dependencies
fi
local missing=()
for ((i=0; i<requires_count; i++)); do
local dep
dep="$(jq -r ".requires[$i]" "$catalog_file" 2>/dev/null || echo "")"
[ -z "$dep" ] && continue
# Check if dependency is installed
if ! command -v "$dep" >/dev/null 2>&1; then
missing+=("$dep")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
echo "[$tool] Missing dependencies: ${missing[*]}" >&2
return 1
fi
return 0
}
# Get list of dependencies for a tool
# Args: catalog_file
# Returns: space-separated list of dependencies
get_dependencies() {
local catalog_file="$1"
if ! command -v jq >/dev/null 2>&1; then
echo ""
return 0
fi
local deps
deps="$(jq -r '.requires // [] | join(" ")' "$catalog_file" 2>/dev/null || echo "")"
echo "$deps"
}
# Topological sort for installation order
# Args: catalog_dir
# Returns: ordered list of tools (one per line)
topological_sort() {
local catalog_dir="$1"
if [ ! -d "$catalog_dir" ]; then
echo "Error: Catalog directory not found: $catalog_dir" >&2
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq not available for topological sort" >&2
return 1
fi
# Build dependency graph
declare -A deps # tool -> space-separated dependencies
declare -A in_degree # tool -> number of incoming edges
declare -a all_tools
for catalog_file in "$catalog_dir"/*.json; do
[ -f "$catalog_file" ] || continue
local tool
tool="$(basename "$catalog_file" .json)"
all_tools+=("$tool")
# Get dependencies
local tool_deps
tool_deps="$(get_dependencies "$catalog_file")"
deps[$tool]="$tool_deps"
# Initialize in_degree
in_degree[$tool]=0
done
# Calculate in_degree
for tool in "${all_tools[@]}"; do
for dep in ${deps[$tool]}; do
if [ -n "${in_degree[$dep]+x}" ]; then
in_degree[$dep]=$((in_degree[$dep] + 1))
fi
done
done
# Find tools with no dependencies (in_degree == 0)
local queue=()
for tool in "${all_tools[@]}"; do
if [ "${in_degree[$tool]}" -eq 0 ]; then
queue+=("$tool")
fi
done
# Process queue
local sorted=()
while [ ${#queue[@]} -gt 0 ]; do
# Pop from queue
local current="${queue[0]}"
queue=("${queue[@]:1}")
sorted+=("$current")
# Reduce in_degree for dependents
for tool in "${all_tools[@]}"; do
if [[ " ${deps[$tool]} " == *" $current "* ]]; then
in_degree[$tool]=$((in_degree[$tool] - 1))
if [ "${in_degree[$tool]}" -eq 0 ]; then
queue+=("$tool")
fi
fi
done
done
# Check for cycles
if [ ${#sorted[@]} -ne ${#all_tools[@]} ]; then
echo "Error: Circular dependency detected" >&2
# Find tools not in sorted (they're part of cycle)
for tool in "${all_tools[@]}"; do
if [[ ! " ${sorted[*]} " =~ " ${tool} " ]]; then
echo " Tool in cycle: $tool (depends on: ${deps[$tool]})" >&2
fi
done
return 1
fi
# Output sorted list
printf '%s\n' "${sorted[@]}"
}
# Validate that catalog "order" field matches dependency requirements
# Args: catalog_dir
# Returns: 0 if consistent, 1 if not
validate_order_consistency() {
local catalog_dir="$1"
if [ ! -d "$catalog_dir" ]; then
echo "Error: Catalog directory not found: $catalog_dir" >&2
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Warning: jq not available, cannot validate order consistency" >&2
return 0
fi
local errors=0
for catalog_file in "$catalog_dir"/*.json; do
[ -f "$catalog_file" ] || continue
local tool
tool="$(basename "$catalog_file" .json)"
# Get tool's order
local tool_order
tool_order="$(jq -r '.guide.order // 999' "$catalog_file" 2>/dev/null || echo "999")"
# Get dependencies
local requires_count
requires_count="$(jq '.requires // [] | length' "$catalog_file" 2>/dev/null || echo "0")"
for ((i=0; i<requires_count; i++)); do
local dep
dep="$(jq -r ".requires[$i]" "$catalog_file" 2>/dev/null || echo "")"
[ -z "$dep" ] && continue
# Find dependency's catalog file
local dep_catalog="$catalog_dir/$dep.json"
if [ ! -f "$dep_catalog" ]; then
echo "Warning: Dependency $dep for $tool not found in catalog" >&2
continue
fi
# Get dependency's order
local dep_order
dep_order="$(jq -r '.guide.order // 999' "$dep_catalog" 2>/dev/null || echo "999")"
# Check if dependency has lower order (installed first)
if [ "$dep_order" -ge "$tool_order" ]; then
echo "Error: Order inconsistency: $tool (order=$tool_order) depends on $dep (order=$dep_order)" >&2
echo " Dependency $dep should have lower order number (installed before $tool)" >&2
errors=$((errors + 1))
fi
done
done
if [ $errors -gt 0 ]; then
echo ""
echo "Found $errors order consistency errors" >&2
return 1
fi
echo "✓ All order fields are consistent with dependencies"
return 0
}
# Get installation order respecting dependencies
# Args: catalog_dir, tool_list (optional, space-separated)
# Returns: ordered list of tools
get_install_order() {
local catalog_dir="$1"
local tool_list="${2:-}"
if [ -z "$tool_list" ]; then
# No specific tools requested, sort all tools
topological_sort "$catalog_dir"
else
# Build subgraph for requested tools + their dependencies
declare -A needed
declare -a queue
# Add requested tools to queue
for tool in $tool_list; do
queue+=("$tool")
done
# BFS to collect all dependencies
while [ ${#queue[@]} -gt 0 ]; do
local current="${queue[0]}"
queue=("${queue[@]:1}")
# Skip if already processed
[ -n "${needed[$current]+x}" ] && continue
needed[$current]=1
# Get dependencies
local catalog_file="$catalog_dir/$current.json"
if [ -f "$catalog_file" ]; then
local deps
deps="$(get_dependencies "$catalog_file")"
for dep in $deps; do
queue+=("$dep")
done
fi
done
# Now run topological sort on full catalog, filter to needed tools
local sorted
sorted="$(topological_sort "$catalog_dir")"
# Filter to only needed tools
while IFS= read -r tool; do
if [ -n "${needed[$tool]+x}" ]; then
echo "$tool"
fi
done <<< "$sorted"
fi
}
#!/usr/bin/env bash
# Shared installation strategy logic for all install scripts
# Determine installation directory based on INSTALL_STRATEGY
# Usage: get_install_dir TOOL_NAME
# Returns: Directory path where tool should be installed
get_install_dir() {
local tool_name="${1:-}"
local strategy="${INSTALL_STRATEGY:-USER}"
local prefix="${PREFIX:-$HOME/.local}"
local bin_dir=""
case "$strategy" in
CURRENT)
# Keep tool where it is currently installed
if [ -n "$tool_name" ]; then
local current_path="$(command -v "$tool_name" 2>/dev/null || true)"
if [ -n "$current_path" ]; then
bin_dir="$(dirname "$current_path")"
else
# Not installed, fall back to USER
bin_dir="$prefix/bin"
fi
else
# No specific tool, fall back to USER
bin_dir="$prefix/bin"
fi
;;
GLOBAL)
bin_dir="/usr/local/bin"
;;
PROJECT)
bin_dir="./.local/bin"
;;
USER|*)
bin_dir="$prefix/bin"
;;
esac
echo "$bin_dir"
}
# Get install command based on target directory
# Usage: get_install_cmd BIN_DIR
# Sets: INSTALL and RM variables
get_install_cmd() {
local bin_dir="$1"
if [ "$bin_dir" = "/usr/local/bin" ]; then
if [ -w "$bin_dir" ]; then
INSTALL="install -m 0755"
RM="rm -f"
else
INSTALL="sudo install -m 0755"
RM="sudo rm -f"
fi
else
INSTALL="install -m 0755"
RM="rm -f"
fi
}
# Refresh snapshot for a specific tool after installation
# Usage: refresh_snapshot TOOL_NAME
# Updates tools_snapshot.json with latest version of installed tool
refresh_snapshot() {
local tool_name="${1:-}"
if [ -z "$tool_name" ]; then
echo "# Warning: No tool name provided to refresh_snapshot" >&2
return 1
fi
# Path to project root (scripts/lib -> scripts -> root)
local project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
local audit_script="$project_root/audit.py"
if [ ! -f "$audit_script" ]; then
echo "# Warning: audit.py not found at $audit_script" >&2
return 1
fi
echo "# Refreshing snapshot for $tool_name..." >&2
# Brief delay to ensure binary is fully updated and PATH is refreshed
sleep 0.5
# Run audit in merge mode for this specific tool
CLI_AUDIT_COLLECT=1 CLI_AUDIT_MERGE=1 python3 "$audit_script" "$tool_name" >/dev/null 2>&1 || {
echo "# Warning: Failed to refresh snapshot for $tool_name" >&2
return 1
}
echo "# ✓ Snapshot updated for $tool_name" >&2
return 0
}
#!/usr/bin/env bash
# policy.sh - Installation method policy resolution
#
# This library resolves which installation method to use by evaluating:
# 1. Catalog available methods + priorities (maintainer knowledge)
# 2. User preferences (user configuration)
# 3. System capabilities (what's actually available)
#
# Decision: best_method = highest priority from (catalog ∩ user ∩ available)
set -euo pipefail
POLICY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$POLICY_LIB_DIR/capability.sh"
# Default config location
CONFIG_FILE="${AI_CLI_PREP_CONFIG:-$HOME/.ai_cli_prep/config.json}"
# Get user's preferred strategy from config
# Returns: auto|github_first|cargo_first|apt_first|npm_first
get_user_strategy() {
if [ ! -f "$CONFIG_FILE" ]; then
echo "auto"
return 0
fi
# Parse JSON config for preferred_strategy
if command -v jq >/dev/null 2>&1; then
local strategy
strategy="$(jq -r '.preferred_strategy // "auto"' "$CONFIG_FILE" 2>/dev/null || echo "auto")"
echo "$strategy"
else
# Fallback without jq - simple grep
if grep -q '"preferred_strategy"' "$CONFIG_FILE" 2>/dev/null; then
grep '"preferred_strategy"' "$CONFIG_FILE" | sed 's/.*: *"\([^"]*\)".*/\1/' || echo "auto"
else
echo "auto"
fi
fi
}
# Get user override for a specific tool
# Args: tool_name
# Returns: method name or empty string
get_user_override() {
local tool="$1"
if [ ! -f "$CONFIG_FILE" ]; then
echo ""
return 0
fi
# Parse JSON config for overrides.tool
if command -v jq >/dev/null 2>&1; then
local override
override="$(jq -r ".overrides.\"$tool\" // empty" "$CONFIG_FILE" 2>/dev/null || echo "")"
echo "$override"
else
# Fallback without jq
if grep -q "\"$tool\"" "$CONFIG_FILE" 2>/dev/null; then
grep "\"$tool\"" "$CONFIG_FILE" | sed 's/.*: *"\([^"]*\)".*/\1/' || echo ""
else
echo ""
fi
fi
}
# Check if user config allows sudo operations
# Returns: 0 if allowed, 1 if not
is_sudo_allowed() {
if [ ! -f "$CONFIG_FILE" ]; then
return 0 # Default: allow sudo
fi
if command -v jq >/dev/null 2>&1; then
local allow_sudo
allow_sudo="$(jq -r '.allow_sudo // true' "$CONFIG_FILE" 2>/dev/null || echo "true")"
[ "$allow_sudo" = "true" ]
else
# Fallback: check for explicit "allow_sudo": false
if grep -q '"allow_sudo" *: *false' "$CONFIG_FILE" 2>/dev/null; then
return 1
fi
return 0
fi
}
# Apply user strategy to adjust method priorities
# Args: method, base_priority, strategy
# Returns: adjusted priority
apply_strategy_to_priority() {
local method="$1"
local base_priority="$2"
local strategy="$3"
case "$strategy" in
auto)
# Use catalog priorities as-is
echo "$base_priority"
;;
github_first)
case "$method" in
github_release_binary) echo 1 ;;
cargo) echo 2 ;;
npm) echo 3 ;;
apt) echo 4 ;;
brew) echo 5 ;;
*) echo "$base_priority" ;;
esac
;;
cargo_first)
case "$method" in
cargo) echo 1 ;;
github_release_binary) echo 2 ;;
npm) echo 3 ;;
apt) echo 4 ;;
brew) echo 5 ;;
*) echo "$base_priority" ;;
esac
;;
npm_first)
case "$method" in
npm) echo 1 ;;
github_release_binary) echo 2 ;;
cargo) echo 3 ;;
apt) echo 4 ;;
brew) echo 5 ;;
*) echo "$base_priority" ;;
esac
;;
apt_first)
case "$method" in
apt) echo 1 ;;
brew) echo 2 ;;
github_release_binary) echo 3 ;;
cargo) echo 4 ;;
npm) echo 5 ;;
*) echo "$base_priority" ;;
esac
;;
*)
echo "$base_priority"
;;
esac
}
# Parse catalog available_methods and resolve best method
# Args: catalog_json_file
# Returns: method name or empty string if error
resolve_best_method() {
local catalog_file="$1"
local tool
tool="$(basename "$catalog_file" .json)"
if [ ! -f "$catalog_file" ]; then
echo "Error: Catalog file not found: $catalog_file" >&2
return 1
fi
# Check if tool uses reconciliation (install_method == "auto")
local install_method
if command -v jq >/dev/null 2>&1; then
install_method="$(jq -r '.install_method // ""' "$catalog_file" 2>/dev/null || echo "")"
else
install_method="$(grep '"install_method"' "$catalog_file" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || echo "")"
fi
if [ "$install_method" != "auto" ]; then
echo "Error: Tool $tool does not use reconciliation (install_method != 'auto')" >&2
return 1
fi
# Get user preferences
local user_strategy
user_strategy="$(get_user_strategy)"
local user_override
user_override="$(get_user_override "$tool")"
# If user has an override, use it (if available)
if [ -n "$user_override" ]; then
if is_method_available "$user_override"; then
echo "$user_override"
return 0
else
echo "Error: User override method '$user_override' not available for $tool" >&2
return 1
fi
fi
# Parse available_methods from catalog
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq not available, cannot parse catalog" >&2
return 1
fi
# Get all available methods with priorities
local best_method=""
local best_priority=9999
# Read available_methods array
local methods_count
methods_count="$(jq '.available_methods | length' "$catalog_file" 2>/dev/null || echo "0")"
if [ "$methods_count" -eq 0 ]; then
echo "Error: No available_methods defined in catalog for $tool" >&2
return 1
fi
for ((i=0; i<methods_count; i++)); do
local method
method="$(jq -r ".available_methods[$i].method" "$catalog_file" 2>/dev/null || echo "")"
[ -z "$method" ] && continue
local catalog_priority
catalog_priority="$(jq -r ".available_methods[$i].priority // 999" "$catalog_file" 2>/dev/null || echo "999")"
# Check if method is available on system
if ! is_method_available "$method"; then
continue
fi
# Skip apt if sudo not allowed
if [ "$method" = "apt" ] && ! is_sudo_allowed; then
continue
fi
# Apply user strategy to adjust priority
local adjusted_priority
adjusted_priority="$(apply_strategy_to_priority "$method" "$catalog_priority" "$user_strategy")"
# Track best (lowest priority number)
if [ "$adjusted_priority" -lt "$best_priority" ]; then
best_priority="$adjusted_priority"
best_method="$method"
fi
done
if [ -z "$best_method" ]; then
echo "Error: No available installation method found for $tool" >&2
return 1
fi
echo "$best_method"
return 0
}
# Get configuration for a specific method from catalog
# Args: catalog_file, method
# Returns: JSON config object or empty
get_method_config() {
local catalog_file="$1"
local method="$2"
if ! command -v jq >/dev/null 2>&1; then
echo "{}"
return 0
fi
# Find the method in available_methods array and return its config
local config
config="$(jq -r ".available_methods[] | select(.method == \"$method\") | .config // {}" "$catalog_file" 2>/dev/null || echo "{}")"
echo "$config"
}
# Explain the decision made for a tool
# Args: catalog_file
# Returns: human-readable explanation
explain_method_decision() {
local catalog_file="$1"
local tool
tool="$(basename "$catalog_file" .json)"
local best_method
best_method="$(resolve_best_method "$catalog_file" 2>/dev/null || echo "none")"
echo "[$tool] Policy decision:"
echo "[$tool] User strategy: $(get_user_strategy)"
local override
override="$(get_user_override "$tool")"
if [ -n "$override" ]; then
echo "[$tool] User override: $override"
fi
echo "[$tool] Best available method: $best_method"
}