diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..bad1dc3 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "cli-tools", + "description": "Install, audit, update and reconcile the CLI tools a coding agent depends on, from a JSON tool catalog", + "author": { + "name": "Netresearch DTT GmbH", + "url": "https://www.netresearch.de" + }, + "repository": "https://github.com/netresearch/coding_agent_cli_toolset", + "license": "(MIT AND CC-BY-SA-4.0)", + "keywords": ["cli", "tools", "install", "audit", "environment"], + "skills": [ + "./skills/cli-tools" + ] +} diff --git a/LICENSE-CC-BY-SA-4.0 b/LICENSE-CC-BY-SA-4.0 new file mode 100644 index 0000000..ceaead9 --- /dev/null +++ b/LICENSE-CC-BY-SA-4.0 @@ -0,0 +1,19 @@ +Creative Commons Attribution-ShareAlike 4.0 International + +Copyright (c) 2025-2026 Netresearch DTT GmbH + +This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 +International License. To view a copy of this license, visit +https://creativecommons.org/licenses/by-sa/4.0/ or send a letter to +Creative Commons, PO Box 1866, Mountain View, CA 94042, USA. + +You are free to: +- Share: copy and redistribute the material in any medium or format +- Adapt: remix, transform, and build upon the material for any purpose, + even commercially + +Under the following terms: +- Attribution: You must give appropriate credit, provide a link to the + license, and indicate if changes were made. +- ShareAlike: If you remix, transform, or build upon the material, you + must distribute your contributions under the same license as the original. diff --git a/LICENSE b/LICENSE-MIT similarity index 100% rename from LICENSE rename to LICENSE-MIT diff --git a/README.md b/README.md index db37448..2cc95b8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/netresearch/coding_agent_cli_toolset/actions/workflows/ci.yml/badge.svg)](https://github.com/netresearch/coding_agent_cli_toolset/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/netresearch/coding_agent_cli_toolset/branch/main/graph/badge.svg)](https://codecov.io/gh/netresearch/coding_agent_cli_toolset) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE-MIT) @@ -18,6 +18,22 @@ make upgrade # Interactive remediation for outdated/missing tools That's it. Run these periodically to keep your AI coding agent toolchain current. +## Claude Code plugin + +The repository is also a Claude Code plugin, `cli-tools`. Its skill +(`skills/cli-tools/`) lets an agent resolve `command not found`, install or +update catalog tools, and audit a project's environment through the scripts in +this repository — the plugin carries no copy of them. + +```bash +/plugin marketplace add netresearch/claude-code-marketplace +/plugin install cli-tools@netresearch-claude-code-marketplace +``` + +The plugin sets no `version`, so Claude Code versions it by commit and every +change on `main` reaches users without a release. The shell scripts need only +bash and jq; `audit.py` needs the uv environment (see the skill's "Cold start"). + ## Scope: agent toolchain - This audit targets CLIs that coding agents commonly utilize themselves if present on the machine. It is agent-focused; tools may be reported as NOT INSTALLED on your host if you don't use them. - Upstream versions are resolved from GitHub releases, PyPI, crates.io, or the npm registry for Node CLIs. @@ -768,4 +784,8 @@ The audit system uses two JSON files: - **Baseline refresh**: Run `python audit.py --update-baseline` to update upstream versions ## License -MIT + +Split licensing: + +- **Code** (scripts, Python package, workflows, configuration): [MIT](LICENSE-MIT) +- **Content** (the skill in `skills/`: SKILL.md and its references): [CC-BY-SA-4.0](LICENSE-CC-BY-SA-4.0) diff --git a/evals/evals.json b/evals/evals.json new file mode 100644 index 0000000..e130af9 --- /dev/null +++ b/evals/evals.json @@ -0,0 +1,407 @@ +[ + { + "name": "install-ripgrep-fd", + "prompt": "Install ripgrep and fd on this system", + "assertions": [ + { + "type": "tool_use", + "description": "Runs installation commands for ripgrep and fd", + "value": "Bash" + }, + { + "type": "content_contains", + "value": "ripgrep", + "description": "Mentions ripgrep in output" + }, + { + "type": "content_contains", + "value": "fd", + "description": "Mentions fd in output" + } + ] + }, + { + "name": "audit-project-dependencies", + "prompt": "Audit this project's CLI tool dependencies and report what's missing or outdated", + "assertions": [ + { + "type": "tool_use", + "description": "Runs environment audit or detection scripts", + "value": "Bash" + }, + { + "type": "content_contains", + "value": "missing", + "description": "Reports missing or outdated tools" + } + ] + }, + { + "name": "command-not-found-rg", + "prompt": "I just ran a command and got: bash: rg: command not found. Can you fix this?", + "assertions": [ + { + "type": "tool_use", + "description": "Checks if rg exists and installs ripgrep", + "value": "Bash" + }, + { + "type": "content_contains", + "value": "ripgrep", + "description": "Identifies rg as ripgrep using binary_to_tool_map" + } + ] + }, + { + "name": "command-not-found-batcat", + "prompt": "I'm getting 'bat: command not found' on Ubuntu. Help?", + "assertions": [ + { + "type": "tool_use", + "description": "Installs bat and handles Debian batcat alias", + "value": "Bash" + }, + { + "type": "content_contains", + "value": "batcat", + "description": "Mentions the Debian batcat alias situation" + } + ] + }, + { + "name": "prefer-modern-tool-grep", + "prompt": "I need to search for TODO comments across my entire codebase recursively", + "assertions": [ + { + "type": "content_contains", + "value": "rg", + "description": "Recommends rg (ripgrep) over grep -r" + }, + { + "type": "content_contains", + "value": "ripgrep", + "description": "Explains rg is the ripgrep binary" + } + ] + }, + { + "name": "prefer-modern-tool-find", + "prompt": "How do I find all Python files in this project?", + "assertions": [ + { + "type": "content_contains", + "value": "fd", + "description": "Recommends fd over find" + }, + { + "type": "content_contains", + "value": ".py", + "description": "Shows how to filter by Python file extension" + } + ] + }, + { + "name": "prefer-modern-tool-json", + "prompt": "I need to extract the version field from package.json using the command line", + "assertions": [ + { + "type": "content_contains", + "value": "jq", + "description": "Recommends jq for JSON processing instead of grep/sed" + }, + { + "type": "content_contains", + "value": ".version", + "description": "Shows jq path expression to extract version field" + } + ] + }, + { + "name": "prefer-modern-tool-yaml", + "prompt": "How can I modify a value in my docker-compose.yml from the command line?", + "assertions": [ + { + "type": "content_contains", + "value": "yq", + "description": "Recommends yq for YAML editing instead of sed" + }, + { + "type": "content_contains", + "value": "docker-compose", + "description": "References docker-compose.yml editing" + } + ] + }, + { + "name": "detect-project-type-python", + "prompt": "What CLI tools does this Python project need? There's a pyproject.toml in the root.", + "assertions": [ + { + "type": "content_contains", + "value": "python", + "description": "Identifies Python project type" + }, + { + "type": "content_contains", + "value": "ruff", + "description": "Recommends ruff or other Python linting tools" + } + ] + }, + { + "name": "detect-project-type-node", + "prompt": "I have a package.json. What tools should I have installed for this Node.js project?", + "assertions": [ + { + "type": "content_contains", + "value": "node", + "description": "Identifies Node.js runtime requirement" + }, + { + "type": "content_contains", + "value": "eslint", + "description": "Recommends eslint or prettier for Node projects" + } + ] + }, + { + "name": "install-specific-tool-jq", + "prompt": "Install jq on this system", + "assertions": [ + { + "type": "tool_use", + "description": "Runs install command for jq", + "value": "Bash" + }, + { + "type": "content_contains", + "value": "jq", + "description": "Confirms jq installation" + } + ] + }, + { + "name": "batch-update-tools", + "prompt": "Update all my CLI tools to their latest versions", + "assertions": [ + { + "type": "tool_use", + "description": "Runs auto_update.sh or equivalent update commands", + "value": "Bash" + }, + { + "type": "content_contains", + "value": "update", + "description": "References updating tools to latest versions" + } + ] + }, + { + "name": "path-troubleshooting", + "prompt": "I installed ripgrep with cargo but 'rg' still says command not found. What's wrong?", + "assertions": [ + { + "type": "content_contains", + "value": "cargo/bin", + "description": "Identifies ~/.cargo/bin PATH issue" + }, + { + "type": "content_contains", + "value": "PATH", + "description": "Explains PATH configuration fix" + } + ] + }, + { + "name": "binary-name-mapping", + "prompt": "I need to install the 'ansible' command. What package provides it?", + "assertions": [ + { + "type": "content_contains", + "value": "ansible-core", + "description": "Maps ansible binary to ansible-core catalog entry" + }, + { + "type": "content_contains", + "value": "ansible", + "description": "References the ansible binary name" + } + ] + }, + { + "name": "install-via-script", + "prompt": "Use the skill's install script to install shellcheck", + "assertions": [ + { + "type": "tool_use", + "description": "Runs install_tool.sh shellcheck install", + "value": "Bash" + }, + { + "type": "content_contains", + "value": "shellcheck", + "description": "References shellcheck installation" + } + ] + }, + { + "name": "environment-check-path", + "prompt": "Check if my PATH is properly configured for development tools", + "assertions": [ + { + "type": "tool_use", + "description": "Checks PATH for common tool directories", + "value": "Bash" + }, + { + "type": "content_contains", + "value": "PATH", + "description": "Reports PATH configuration status" + } + ] + }, + { + "name": "catalog-lookup", + "prompt": "Is 'terraform' in the cli-tools catalog? What install methods are available?", + "assertions": [ + { + "type": "tool_use", + "description": "Reads catalog/terraform.json", + "value": "Read" + }, + { + "type": "content_contains", + "value": "terraform", + "description": "Shows terraform catalog entry details" + } + ] + }, + { + "name": "prefer-modern-tool-diff", + "prompt": "I want to compare two source files and see a readable diff", + "assertions": [ + { + "type": "content_contains", + "value": "difft", + "description": "Recommends difftastic over plain diff" + }, + { + "type": "content_contains", + "value": "diff", + "description": "Compares difftastic against traditional diff" + } + ] + }, + { + "name": "prefer-modern-tool-benchmark", + "prompt": "I want to benchmark how fast two different commands are", + "assertions": [ + { + "type": "content_contains", + "value": "hyperfine", + "description": "Recommends hyperfine over time command" + }, + { + "type": "content_contains", + "value": "benchmark", + "description": "Explains benchmarking methodology" + } + ] + }, + { + "name": "security-tool-suggestion", + "prompt": "I want to scan my Python code for security vulnerabilities from the command line", + "assertions": [ + { + "type": "content_contains", + "value": "semgrep", + "description": "Recommends semgrep or bandit for security scanning" + }, + { + "type": "content_contains", + "value": "security", + "description": "Addresses security vulnerability scanning" + } + ] + }, + { + "name": "install-permission-blocked", + "prompt": "I can't use sudo to install tools. How can I install ripgrep without root access?", + "assertions": [ + { + "type": "content_contains", + "value": "cargo", + "description": "Suggests cargo install or manual binary download as non-root alternatives" + }, + { + "type": "content_contains", + "value": "ripgrep", + "description": "References ripgrep as the target tool to install" + } + ] + }, + { + "name": "hash-stale-after-install", + "prompt": "I just installed a tool but bash still says command not found even though which shows it. What's happening?", + "assertions": [ + { + "type": "content_contains", + "value": "hash", + "description": "Recommends hash -r to clear shell command cache" + }, + { + "type": "content_contains", + "value": "cache", + "description": "Explains the shell hash table caching mechanism" + } + ] + }, + { + "name": "docker-project-tools", + "prompt": "What tools should I have for a project with Dockerfiles and docker-compose.yml?", + "assertions": [ + { + "type": "content_contains", + "value": "docker", + "description": "Lists docker as required" + }, + { + "type": "content_contains", + "value": "dive", + "description": "Recommends dive or trivy for Docker projects" + } + ] + }, + { + "name": "csv-processing-tool", + "prompt": "I need to filter and sort a large CSV file from the command line. What tool should I use?", + "assertions": [ + { + "type": "content_contains", + "value": "qsv", + "description": "Recommends qsv over awk for CSV processing" + }, + { + "type": "content_contains", + "value": "CSV", + "description": "Addresses CSV file processing use case" + } + ] + }, + { + "name": "tool-integration-pipeline", + "prompt": "Show me how to combine fd and rg to find YAML files containing a specific key", + "assertions": [ + { + "type": "content_contains", + "value": "fd", + "description": "Uses fd to find files" + }, + { + "type": "content_contains", + "value": "rg", + "description": "Uses rg to search content" + } + ] + } +] diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..230b108 --- /dev/null +++ b/plugin.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "cli-tools", + "description": "Install, audit, update and reconcile the CLI tools a coding agent depends on, from a JSON tool catalog", + "author": { + "name": "Netresearch DTT GmbH", + "url": "https://www.netresearch.de" + }, + "repository": "https://github.com/netresearch/coding_agent_cli_toolset", + "license": "(MIT AND CC-BY-SA-4.0)", + "keywords": ["cli", "tools", "install", "audit", "environment"] +} diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index a98a8a9..bd9f5eb 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -46,6 +46,8 @@ **Utilities:** - `check_python_package_managers.sh`: Detect multiple Python package managers - `check_node_package_managers.sh`: Detect multiple Node.js package managers +- `check_environment.sh`: Environment audit (PATH, duplicate installs, package managers, a project's required tools) — used by the `cli-tools` plugin skill; bash + jq only +- `detect_project_type.sh`: Project types of a directory and their required/recommended catalog tools (`text` or `json`) — kept in sync with `skills/cli-tools/references/project_type_requirements.md` by `tests/test_claude_plugin.py` - `test_smoke.sh`: Smoke test for audit output - `auto_update_scope_prototype.sh`: Prototype for scope detection diff --git a/scripts/README.md b/scripts/README.md index 337c2b0..b376747 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -26,6 +26,8 @@ Configurable via `INSTALL_STRATEGY` in `.env`: | `install_tool.sh` | **Main orchestrator** - reads catalog, delegates to installers | | `install_group.sh` | **Group installer** - installs all tools with a specific tag | | `installers/*.sh` | **Generic installers** - one per installation method | +| `check_environment.sh` | **Environment audit** - PATH, duplicate installs, package managers, a project's required tools | +| `detect_project_type.sh` | **Project detection** - project types and their required/recommended catalog tools (`text`/`json`) | ## Usage Examples diff --git a/scripts/check_environment.sh b/scripts/check_environment.sh new file mode 100755 index 0000000..fe07eb4 --- /dev/null +++ b/scripts/check_environment.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# check_environment.sh - Audit a development environment: PATH, duplicate +# installations, package managers, and the tools a project needs. +# +# Usage: check_environment.sh [audit|path|duplicates|project|managers|update-check] [project_dir] +# +# Needs only bash and jq -- no Python environment -- so it runs from a fresh +# checkout or plugin install. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CATALOG_DIR="${CLI_AUDIT_CATALOG_DIR:-$DIR/../catalog}" +# shellcheck source=lib/capability.sh +. "$DIR/lib/capability.sh" + +ACTION="${1:-audit}" +PROJECT_DIR="${2:-.}" + +if [ -t 1 ]; then + RED=$'\033[0;31m' GREEN=$'\033[0;32m' YELLOW=$'\033[0;33m' BLUE=$'\033[0;34m' NC=$'\033[0m' +else + RED="" GREEN="" YELLOW="" BLUE="" NC="" +fi + +log_ok() { printf '%s✓%s %s\n' "$GREEN" "$NC" "$*"; } +log_warn() { printf '%s⚠%s %s\n' "$YELLOW" "$NC" "$*"; } +log_error() { printf '%s✗%s %s\n' "$RED" "$NC" "$*"; } +log_info() { printf '%s→%s %s\n' "$BLUE" "$NC" "$*"; } + +# first_line CMD... -> first output line of CMD, stdin detached. The output is +# captured whole before it is cut: `cmd | head -1` lets SIGPIPE, under +# pipefail, turn a working command into a failure. +first_line() { + local out + out="$("$@" &1)" || true + printf '%s' "${out%%$'\n'*}" +} + +# binary_for TOOL -> the command a catalog tool provides (rust -> rustc, +# ansible-core -> ansible); the tool name itself when the catalog has no entry. +binary_for() { + local tool="$1" file="$CATALOG_DIR/$1.json" bin="" + if [ -f "$file" ] && command -v jq >/dev/null 2>&1; then + bin="$(jq -r '.binary_name // empty' "$file" 2>/dev/null || true)" + fi + printf '%s' "${bin:-$tool}" +} + +# debian_alias NAME ALIAS -> ALIAS when only ALIAS is on PATH, else NAME +debian_alias() { + if ! command -v "$1" >/dev/null 2>&1 && command -v "$2" >/dev/null 2>&1; then + printf '%s' "$2" + else + printf '%s' "$1" + fi +} + +# check_tool TOOL [BINARY] +check_tool() { + local tool="$1" + local binary="${2:-$(binary_for "$1")}" + if command -v "$binary" >/dev/null 2>&1; then + log_ok "$tool: $(first_line "$binary" --version)" + return 0 + fi + log_error "$tool: NOT INSTALLED" + return 1 +} + +check_path() { + log_info "Checking PATH configuration..." + local issues=0 p node_path + for p in "$HOME/.local/bin" "$HOME/.cargo/bin" "$HOME/.rbenv/bin" "$HOME/go/bin"; do + if [ -d "$p" ] && [[ ":$PATH:" != *":$p:"* ]]; then + log_warn "$p exists but is not in PATH" + issues=$((issues + 1)) + fi + done + if node_path="$(command -v node 2>/dev/null)" && [[ "$node_path" == /usr/* ]] && [ -d "$HOME/.nvm" ]; then + log_warn "System node ($node_path) may shadow nvm-managed node" + issues=$((issues + 1)) + fi + if [ "$issues" -eq 0 ]; then + log_ok "PATH configuration looks good" + else + log_warn "$issues PATH issue(s) found" + fi + return "$issues" +} + +check_duplicates() { + log_info "Checking for duplicate installations..." + local issues=0 tool found + for tool in node python3 ruby cargo; do + # detect_all_installations (lib/capability.sh) skips virtualenvs and + # counts a file once however many PATH entries reach it. + found="$(detect_all_installations "$tool" "$tool")" + if [ -n "$found" ] && [ "$(wc -l <<<"$found")" -gt 1 ]; then + log_warn "$tool has $(wc -l <<<"$found") installations:" + sed 's/^/ /' <<<"$found" + issues=$((issues + 1)) + fi + done + if [ "$issues" -eq 0 ]; then + log_ok "No duplicate installations detected" + fi + return "$issues" +} + +check_project() { + local project_dir="$1" required tool missing=0 + log_info "Checking project requirements in $project_dir..." + "$DIR/detect_project_type.sh" text "$project_dir" + echo "" + required="$("$DIR/detect_project_type.sh" json "$project_dir" | jq -r '.required_tools[]')" + while IFS= read -r tool; do + [ -n "$tool" ] || continue + check_tool "$tool" || missing=$((missing + 1)) + done <<<"$required" + if [ "$missing" -gt 0 ]; then + log_warn "$missing required tool(s) missing -- install with scripts/install_tool.sh " + else + log_ok "All required tools installed" + fi +} + +check_package_managers() { + log_info "Checking package managers..." + local entry name binary version found=0 + for entry in apt:apt-get brew:brew cargo:cargo npm:npm pnpm:pnpm yarn:yarn pip:pip3 uv:uv pipx:pipx gem:gem go:go; do + name="${entry%%:*}" + binary="${entry##*:}" + if command -v "$binary" >/dev/null 2>&1; then + if [ "$binary" = go ]; then + version="$(first_line go version)" # go has no --version flag + else + version="$(first_line "$binary" --version)" + fi + printf ' %s●%s %s: %s\n' "$GREEN" "$NC" "$name" "$version" + found=$((found + 1)) + fi + done + log_ok "$found package manager(s) available" +} + +run_audit() { + echo "═══════════════════════════════════════════════" + echo " CLI Tools Environment Audit" + echo "═══════════════════════════════════════════════" + echo "" + # check_path and check_duplicates return their issue count; under set -e + # a bare call would end the audit at the first issue. + check_path || true + echo "" + check_duplicates || true + echo "" + check_package_managers + echo "" + check_project "$PROJECT_DIR" + echo "" + echo "═══════════════════════════════════════════════" + echo " Core Tools" + echo "═══════════════════════════════════════════════" + check_tool git || true + check_tool jq || true + check_tool ripgrep || true + # Debian and Ubuntu ship fd and bat as fdfind and batcat + check_tool fd "$(debian_alias fd fdfind)" || true + check_tool fzf || true + check_tool bat "$(debian_alias bat batcat)" || true +} + +case "$ACTION" in + audit | check) run_audit ;; + path) check_path ;; + duplicates) check_duplicates ;; + project) check_project "$PROJECT_DIR" ;; + managers) check_package_managers ;; + update-check) DRY_RUN=1 "$DIR/auto_update.sh" update ;; + *) + echo "Usage: $0 {audit|path|duplicates|project|managers|update-check} [project_dir]" >&2 + exit 1 + ;; +esac diff --git a/scripts/detect_project_type.sh b/scripts/detect_project_type.sh new file mode 100755 index 0000000..c84e907 --- /dev/null +++ b/scripts/detect_project_type.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# detect_project_type.sh - Detect project types from the files in a directory +# and name the catalog tools each type requires or recommends. +# +# Usage: detect_project_type.sh [text|json] [project_dir] +set -euo pipefail + +FORMAT="${1:-text}" +PROJECT_DIR="${2:-.}" + +detect_types() { + local dir="$1" + local -a types=() + + if [ -f "$dir/pyproject.toml" ] || [ -f "$dir/setup.py" ] || [ -f "$dir/requirements.txt" ] || [ -f "$dir/Pipfile" ]; then + types+=("python") + fi + [ -f "$dir/package.json" ] && types+=("node") + [ -f "$dir/Cargo.toml" ] && types+=("rust") + [ -f "$dir/go.mod" ] && types+=("go") + if [ -f "$dir/Gemfile" ] || [ -f "$dir/.ruby-version" ]; then + types+=("ruby") + fi + if [ -f "$dir/composer.json" ] || [ -f "$dir/composer.lock" ] || compgen -G "$dir/*.php" >/dev/null; then + types+=("php") + fi + if [ -f "$dir/Dockerfile" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ] \ + || [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ]; then + types+=("docker") + fi + if compgen -G "$dir/*.tf" >/dev/null || [ -d "$dir/terraform" ]; then + types+=("terraform") + fi + if [ -d "$dir/k8s" ] || compgen -G "$dir/*/deployment.yaml" >/dev/null; then + types+=("kubernetes") + fi + if [ -f "$dir/ansible.cfg" ] || [ -d "$dir/playbooks" ]; then + types+=("ansible") + fi + if [ -f "$dir/Makefile" ] || compgen -G "$dir/*.sh" >/dev/null; then + types+=("shell") + fi + + # ${a[@]+...}: an empty array is "unbound" under set -u before bash 4.4 + printf '%s\n' ${types[@]+"${types[@]}"} +} + +# Catalog names (catalog/.json), so each can be passed to install_tool.sh +required_tools() { + case "$1" 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" ;; + *) echo "" ;; + esac +} + +recommended_tools() { + case "$1" in + python) echo "ruff black" ;; + node) echo "eslint prettier" ;; + go) echo "golangci-lint" ;; + docker) echo "dive trivy" ;; + terraform) echo "tfsec trivy" ;; + shell) echo "shellcheck shfmt" ;; + *) echo "" ;; + esac +} + +detected="$(detect_types "$PROJECT_DIR")" + +case "$FORMAT" in + json) + required="" recommended="" + while IFS= read -r t; do + [ -n "$t" ] || continue + required+=" $(required_tools "$t")" + recommended+=" $(recommended_tools "$t")" + done <<<"$detected" + jq -n \ + --arg types "$detected" --arg required "$required" --arg recommended "$recommended" \ + 'def words: [splits("[[:space:]]+") | select(length > 0)] | unique; + {project_types: ($types | [splits("\n") | select(length > 0)]), + required_tools: ($required | words), + recommended_tools: ($recommended | words)}' + ;; + text) + if [ -z "$detected" ]; then + echo "No specific project type detected" + exit 0 + fi + echo "Detected project types: $(tr '\n' ' ' <<<"$detected")" + echo "" + while IFS= read -r t; do + req="$(required_tools "$t")" + rec="$(recommended_tools "$t")" + echo "[$t]" + if [ -n "$req" ]; then echo " Required: $req"; fi + if [ -n "$rec" ]; then echo " Recommended: $rec"; fi + done <<<"$detected" + ;; + *) + echo "Usage: $0 [text|json] [project_dir]" >&2 + exit 1 + ;; +esac diff --git a/skills/cli-tools/SKILL.md b/skills/cli-tools/SKILL.md new file mode 100644 index 0000000..3a06cfa --- /dev/null +++ b/skills/cli-tools/SKILL.md @@ -0,0 +1,85 @@ +--- +name: cli-tools +description: "Use when a command fails with 'command not found', when installing, updating or removing CLI tools (ripgrep, fd, jq, yq, bat, gh, …), when auditing what a project or machine has installed, or when choosing a modern tool over a legacy one (rg over grep -r, fd over find, jq over grep on JSON). Triggers on: command not found, install tool, missing binary, environment audit, update tools, duplicate installation, which, apt install, brew install." +license: "(MIT AND CC-BY-SA-4.0)" +compatibility: "Requires bash 4+ and jq. The Python audit (audit.py) additionally needs uv." +metadata: + repository: "https://github.com/netresearch/coding_agent_cli_toolset" + author: "Netresearch DTT GmbH" +--- + +# CLI Tools + +Install, audit, update and reconcile CLI tools from the toolset's catalog +(`catalog/.json`, one entry per tool). The skill ships inside that +repository; every script named below lives in `${CLAUDE_SKILL_DIR}/../../scripts/` +— call it by that path, never by a copy. + +## Missing tool (`command not found`) + +1. **Diagnose first**: `type -P -a `, then `hash -r`. It may already be + installed off PATH (`~/.local/bin`, `~/.cargo/bin`, `$(go env GOPATH)/bin`). +2. **Map binary to catalog name** with `references/binary_to_tool_map.md` + (`rg` → `ripgrep`, `ansible` → `ansible-core`, `difft` → `difftastic`), or + `jq -r 'select(.binary_name == "") | input_filename' ${CLAUDE_SKILL_DIR}/../../catalog/*.json`. +3. **Install**: `${CLAUDE_SKILL_DIR}/../../scripts/install_tool.sh install` +4. **Verify**: `type -P ` and ` --version`. + +Full flow: `references/resolution-workflow.md`. + +## Actions + +`${CLAUDE_SKILL_DIR}/../../scripts/install_tool.sh ` with action +`install` (default), `update`, `uninstall`, `reconcile` (switch to the preferred +install method and remove the other copy) or `status`. + +## Environment audit + +- `${CLAUDE_SKILL_DIR}/../../scripts/check_environment.sh audit ` — + PATH problems, duplicate installations, package managers, required tools +- `${CLAUDE_SKILL_DIR}/../../scripts/detect_project_type.sh json ` — + project types plus required and recommended catalog tools + +Per-type tool lists and the tools that belong in the project rather than on the +machine (phpstan, mypy, …): `references/project_type_requirements.md`. + +## Batch update + +`${CLAUDE_SKILL_DIR}/../../scripts/auto_update.sh update` updates every detected +package manager and its packages; prefix `DRY_RUN=1` to preview. + +## Preferred modern tools + +| 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 security | `semgrep` | +| sed on TOML | `dasel` | | | + +Install commands and per-tool gotchas: `references/preferred-tools.md`. + +## Cold start + +The shell scripts need only bash and jq, so they work straight from a fresh +plugin install. Without the Python environment two things degrade, neither +fatal: the user config (`~/.config/cli-audit/config.yml`) falls back to its +defaults, and the audit snapshot is not refreshed after an install +(`# Warning: Failed to refresh snapshot`). `audit.py` itself (the version +table, `make audit`) does need that environment: run it as +`uv run --project ${CLAUDE_SKILL_DIR}/../.. python ${CLAUDE_SKILL_DIR}/../../audit.py`, +which creates one on first use. + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| Installed but not found | `hash -r`, or add the install dir to PATH | +| No sudo | `cargo install`, `uv tool install`, a release binary; on Debian/Ubuntu `apt-get download` + `dpkg -x` | +| Debian `bat` = `batcat`, `fd` = `fdfind` | symlink into `~/.local/bin/` | +| Global npm install lands off PATH | a `node` shim ahead of nvm — see troubleshooting | + +PATH, permissions, portability (`timeout` on macOS) and probe pitfalls: +`references/troubleshooting.md`. diff --git a/skills/cli-tools/references/binary_to_tool_map.md b/skills/cli-tools/references/binary_to_tool_map.md new file mode 100644 index 0000000..1a7048a --- /dev/null +++ b/skills/cli-tools/references/binary_to_tool_map.md @@ -0,0 +1,58 @@ +# Binary to Catalog Entry + +A "command not found" names a binary; `install_tool.sh` wants the catalog +entry (`catalog/.json`). Usually they are the same. The authoritative +lookup is the catalog itself: + +```bash +jq -r 'select(.binary_name == "") | input_filename' "${CLAUDE_SKILL_DIR}/../../catalog/"*.json +``` + +`tests/test_claude_plugin.py` checks the first table below against the +catalog, so it cannot drift silently. + +## Binary name differs from the catalog name + +| Binary | Catalog entry | +|--------|---------------| +| `rg` | `ripgrep` | +| `ansible` | `ansible-core` | +| `difft` | `difftastic` | +| `file-rename` | `prename` | +| `python3` | `python` | +| `rustc` | `rust` | +| `awf` | `gh-aw-firewall` | +| `gws` | `google-workspace-cli` | +| `wslview` | `wslu` | +| `ble.sh` | `blesh` | + +`docker` is the binary of two entries: `docker` (the engine and CLI) and +`compose` (the Compose plugin, invoked as `docker compose`). + +## Distribution aliases + +Debian and Ubuntu rename two binaries in their own packages: + +| Command | Catalog entry | Note | +|---------|---------------|------| +| `fdfind` | `fd` | symlink to `~/.local/bin/fd` | +| `batcat` | `bat` | symlink to `~/.local/bin/bat` | + +## Binaries that come with another entry + +| Binaries | Install | +|----------|---------| +| `cargo`, `rustup` | `rust` | +| `gofmt` | `go` | +| `npx` | `node` (Node 26 no longer bundles `corepack`) | +| `pip3` | `pip` or `python` | +| `gem`, `irb` | `ruby` | + +## Lookup order + +1. A catalog file named like the binary (`catalog/.json`). +2. The `binary_name` lookup above. +3. The tables in this file. +4. Common variations: `tool3` → `tool`, `toolcat` → `tool`, `toolfind` → `tool`. +5. Otherwise the tool is not cataloged: install it with its own package + manager, or add a catalog entry. diff --git a/skills/cli-tools/references/preferred-tools.md b/skills/cli-tools/references/preferred-tools.md new file mode 100644 index 0000000..baa61e3 --- /dev/null +++ b/skills/cli-tools/references/preferred-tools.md @@ -0,0 +1,157 @@ +# Preferred Tools - Detailed Reference + +Modern CLI tools that replace legacy Unix utilities. See `SKILL.md` for the +full Legacy/Modern table — this file adds the install command and the +gotcha per tool that a generic tutorial would get wrong. Every tool here has a +catalog entry, so `install_tool.sh ` is the first choice; the commands +below are the manual fallback. + +--- + +## Contents + +- File Search & Code Navigation +- Structured Data Processing +- Git & Diff Tools +- Security +- Benchmarking +- Viewing & General + +## File Search & Code Navigation + +### rg (ripgrep) instead of grep + +**Install:** `cargo install ripgrep` or `apt install ripgrep` + +```bash +rg 'TODO|FIXME' # recursive by default, respects .gitignore +rg --json 'pattern' | jq 'select(.type == "match")' +``` + +### fd instead of find + +**Install:** `cargo install fd-find` or `apt install fd-find` + +```bash +fd -e json +``` + +CAUTION: `fd -e tmp -x rm {}` is destructive — preview matches with +`fd -e tmp` alone first. + +### rga (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, Office docs, ZIP archives, and SQLite databases by +converting to text on-the-fly: `rga 'financial statement' ` + +### tokei / scc instead of cloc or wc -l + +**Install:** `cargo install tokei` or `go install github.com/boyter/scc/v3@latest` + +Both are far faster than cloc with accurate language detection. `scc` +additionally estimates complexity and cost (`scc --by-file`, `scc -f json`). + +--- + +## Structured Data Processing + +jq/yq/dasel/qsv usage depth (filtering, transforms, joins) lives in +`data-tools-skill` — this section covers only the install/selection gotcha +per tool. + +### jq (JSON) + +**Install:** `apt install jq` or https://jqlang.github.io/jq/ + +### yq (YAML) + +**Install:** `go install github.com/mikefarah/yq/v4@latest` or `brew install yq` + +**Important:** Do NOT use `pip install yq` — that installs kislyuk/yq, a +different (Python jq-wrapper) tool. This skill documents Mike Farah's +Go-based yq (`mikefarah/yq`). + +### dasel (TOML/XML/JSON/YAML, one syntax) + +**Install:** `go install github.com/tomwright/dasel/v2/cmd/dasel@latest` + +Only tool in this catalog with native TOML support. + +### qsv (CSV) + +**Install:** https://github.com/dathere/qsv/releases + +Handles quoting/headers/encoding correctly where `awk`/`sed` break; see +`data-tools-skill` for the cookbook, including `qsv sqlp` for SQL-on-CSV. + +--- + +## Git & Diff Tools + +### difft (difftastic) instead of diff + +**Install:** `cargo install difftastic` + +Structural diff — understands language syntax, ignores formatting-only +changes. + +```bash +git config --global diff.tool difftastic +git config --global difftool.difftastic.cmd 'difft "$LOCAL" "$REMOTE"' +``` + +### git absorb instead of git commit --fixup + +**Install:** `cargo install git-absorb` + +Auto-identifies which staged hunks belong to which prior commit and +creates the fixup commits: `git add -p && git absorb`. + +--- + +## Security + +### semgrep instead of manual grep for security + +**Install:** `pip install semgrep` or `brew install semgrep` + +AST-aware static analysis with pre-built OWASP/CWE rulesets — far more +accurate than text-based grep patterns for security review. + +```bash +semgrep --config auto --json . | jq '.results[] | {path: .path, line: .start.line}' +``` + +--- + +## Benchmarking + +### hyperfine instead of time + +**Install:** `cargo install hyperfine` or `apt install hyperfine` + +```bash +hyperfine --warmup 3 'grep -r "pattern" .' 'rg "pattern"' +``` + +NOTE: `--prepare` commands that clear the page cache +(`echo 3 | sudo tee /proc/sys/vm/drop_caches`) require sudo/root. + +Do not assert a speedup from memory — run hyperfine on the actual +workload before making the claim. + +--- + +## Viewing & General + +### bat instead of cat + +**Install:** `cargo install bat` or `apt install bat` (binary is `batcat` +on Debian/Ubuntu — see `references/binary_to_tool_map.md`) + +```bash +bat -pp data.json | jq '.' # plain mode strips decoration for piping +``` diff --git a/skills/cli-tools/references/project_type_requirements.md b/skills/cli-tools/references/project_type_requirements.md new file mode 100644 index 0000000..8c6480d --- /dev/null +++ b/skills/cli-tools/references/project_type_requirements.md @@ -0,0 +1,46 @@ +# Project Type Requirements + +`scripts/detect_project_type.sh json ` detects the types below and lists +their **required** and **recommended** tools by catalog name, ready for +`install_tool.sh`. `scripts/check_environment.sh project ` checks the +required ones. The two tables must agree; `tests/test_claude_plugin.py` +checks the first against the script. + +## Detected types + +| Type | Detected by | Required | Recommended | +|------|-------------|----------|-------------| +| python | `pyproject.toml`, `setup.py`, `requirements.txt`, `Pipfile` | `python`, `uv` | `ruff`, `black` | +| node | `package.json` | `node`, `npm` | `eslint`, `prettier` | +| rust | `Cargo.toml` | `rust` | | +| go | `go.mod` | `go` | `golangci-lint` | +| ruby | `Gemfile`, `.ruby-version` | `ruby` | | +| php | `composer.json`, `composer.lock`, `*.php` | `php`, `composer` | | +| docker | `Dockerfile`, `docker-compose.y(a)ml`, `compose.y(a)ml` | `docker`, `compose` | `dive`, `trivy` | +| terraform | `*.tf`, `terraform/` | `terraform` | `tfsec`, `trivy` | +| kubernetes | `k8s/`, `*/deployment.yaml` | `kubectl` | | +| ansible | `ansible.cfg`, `playbooks/` | `ansible-core` | | +| shell | `Makefile`, `*.sh` | | `shellcheck`, `shfmt` | + +## Tools that belong to the project, not the machine + +These are pinned per project and installed by its own package manager, so they +have no catalog entry. Install them there, not globally: + +| Type | Tools | Install | +|------|-------|---------| +| python | `mypy`, `pytest` | `uv add --dev ` | +| node | `typescript` | `npm install --save-dev typescript` | +| php | `phpstan`, `phpcs`, `phpunit`, `php-cs-fixer` | `composer require --dev ` | +| ruby | `bundler`, `rubocop` | `gem install bundler`, then the Gemfile | +| rust | `cargo-audit`, `cargo-watch` | `cargo install ` | + +## Always useful + +`git`, `gh`, `jq`, `yq`, `ripgrep`, `fd`, `fzf`, `bat`, `delta` — all +cataloged; `check_environment.sh audit` reports the core ones. + +## Several types at once + +A repository is often several types (python + shell, node + docker). The JSON +output merges and de-duplicates the tool lists of every detected type. diff --git a/skills/cli-tools/references/resolution-workflow.md b/skills/cli-tools/references/resolution-workflow.md new file mode 100644 index 0000000..4205c2b --- /dev/null +++ b/skills/cli-tools/references/resolution-workflow.md @@ -0,0 +1,48 @@ +# Missing Tool Resolution Workflow + +## Phase 1: Diagnostic (BEFORE attempting install) + +1. **Check if tool exists elsewhere:** + ```bash + type -P -a # every executable of that name on PATH, in order + hash -r # forget a stale path the shell remembered + ``` + +2. **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:** + ```bash + # Find the binary in common locations (avoids slow full-disk scans) + find /usr/local/bin /usr/bin /opt -maxdepth 3 -type f -name "" 2>/dev/null + find "$HOME/.local/bin" -maxdepth 1 -type f -name "" 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](./binary_to_tool_map.md) (e.g., `rg` -> `ripgrep`) +3. Install: `${CLAUDE_SKILL_DIR}/../../scripts/install_tool.sh install` + +## Phase 3: Verification (AFTER install) + +1. **Confirm installation succeeded:** + ```bash + type -P # should print a path + --version # should print a version + ``` + +2. **If "command not found" persists after install:** + ```bash + hash -r # Clear shell's command hash + source ~/.bashrc # Reload shell configuration + # Or start a new shell session + ``` + +3. **Retry original command** diff --git a/skills/cli-tools/references/troubleshooting.md b/skills/cli-tools/references/troubleshooting.md new file mode 100644 index 0000000..0a51653 --- /dev/null +++ b/skills/cli-tools/references/troubleshooting.md @@ -0,0 +1,209 @@ +# Troubleshooting + +## Contents + +- PATH Issues +- Node/nvm: global installs land off-PATH (a `node` shim hijacks npm's prefix) +- Installation Blocked (Permission/System Restrictions) +- Batch Updaters That "Freeze" +- `timeout` Is Not Portable — Guard It +- Probing a Tool for a Capability: Validate the Output Shape + +## PATH Issues + +When a tool installs but still shows "command not found": + +1. **Check where it was installed:** + ```bash + # Common install locations + ls -la ~/.local/bin/ + ls -la ~/.cargo/bin/ + ls -la ~/.npm-global/bin/ + ls -la /usr/local/bin/ + ``` + +2. **Ensure PATH includes common directories:** + ```bash + # Add to ~/.bashrc or ~/.zshrc + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.npm-global/bin:$PATH" + ``` + +3. **Reload shell configuration:** + ```bash + source ~/.bashrc # or ~/.zshrc + hash -r # Clear command cache + exec $SHELL # Restart shell + ``` + +## Node/nvm: global installs land off-PATH (a `node` shim hijacks npm's prefix) + +When a global install succeeds (`npm i -g ` reports "added N packages") but +`command -v ` 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: + +```bash +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 is +``` + +If `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: + +```bash +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 -r +``` + +## Installation Blocked (Permission/System Restrictions) + +When system prevents normal installation, use these alternatives: + +1. **Docker (no install required):** + ```bash + # Run tool in container + docker run --rm -v "$PWD:/work" -w /work + + # Create alias for convenience + alias ='docker run --rm -v "$PWD:/work" -w /work ' + ``` + +2. **Manual binary download:** + ```bash + # Download release binary directly + curl -L -o ~/.local/bin/ + chmod +x ~/.local/bin/ + ``` + +3. **Compile from source:** + ```bash + git clone + cd + make && make install PREFIX=~/.local + ``` + +4. **Use package manager with user scope:** + ```bash + pip install --user + npm install -g --prefix ~/.npm-global + cargo install # Installs to ~/.cargo/bin + ``` + +5. **Extract the distro package (Debian/Ubuntu, no root):** + + `apt-get download` needs no privileges — only `apt-get install` does. Fetch + the `.deb`, unpack it somewhere writable, and copy the binary onto PATH: + + ```bash + cd "$(mktemp -d)" + apt-get download zstd brotli # add every package you need + for d in *.deb; do dpkg -x "$d" ./root; done + mkdir -p ~/.local/bin + cp ./root/usr/bin/zstd ./root/usr/bin/brotli ~/.local/bin/ + zstd --version && brotli --version # confirm they run + ``` + + Reach for this **first** on Debian/Ubuntu when options 1-4 do not apply: it + is faster than compiling and gives the distro's own build, correctly linked + against the system's libraries. + + It fits the case the other four miss — a C utility with no GitHub release + binary, no pip/npm/cargo package, and a compile that would pull a toolchain. + Verified on `zstd` and `brotli`, which a repo's pre-commit hook required + while `sudo` wanted a password. + + Caveats: this does **not** resolve dependencies, so a package needing a + shared library the host lacks still fails at run time — that is why the + version check above is part of the recipe, not an afterthought. Add + `~/.local/bin` to PATH if it is not there already (see PATH Issues above). + For a library rather than a binary, extract it the same way and point + `LD_LIBRARY_PATH` at `./root/usr/lib/...`, but weigh that against a + container (option 1) — dozens of interdependent libraries get fragile fast. + +## Batch Updaters That "Freeze" + +A batch update script that suppresses output (`cmd >/dev/null 2>&1`) but leaves +stdin attached to the terminal turns any hidden interactive prompt into an +invisible, indefinite hang — observed with `composer global update` waiting +hours on an unseen GitHub-token prompt. + +When wrapping package-manager commands for unattended runs: + +1. **Detach stdin**: run every command `/fd` (fd 0 → `/dev/pts/*` with + fd 1/2 → `/dev/null` = waiting on an invisible prompt) and + `/proc//wchan` (`wait_woken` ≈ tty read). + +## `timeout` Is Not Portable — Guard It + +`timeout` is GNU coreutils. A stock macOS does **not** ship it (Homebrew +coreutils provides `gtimeout`), so a hard-coded `timeout 2 --version` +does not merely lose its time bound — the whole command fails with +`timeout: command not found`, producing **empty output**. Wrapped in the usual +`|| true` / `2>/dev/null`, that failure is silent, and every downstream check +sees "the tool produced nothing" rather than "the guard is missing". Symptom on +a CI matrix: a step that works on `ubuntu-latest` and fails on `macos-latest` +with the tool reported as absent or unversioned. + +Resolve it once and reuse: + +```bash +run_bounded() { # run_bounded SECONDS CMD... + local secs="$1"; shift + if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@" + elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@" + else "$@" # no bound available — still correct, just unbounded + fi +} +``` + +Falling back to an unbounded run is the right default: ` completion bash`, ` --version`, ` config get …`), a +non-zero exit is not the only failure mode, and neither is empty output. A tool +that does not recognise the subcommand may **treat your probe words as +arguments and do real work**: `bandit complete bash` runs a security scan over +paths named `complete` and `bash`, exits 0, and prints a report that contains +the word "complete" — passing any check that merely greps for a keyword. + +So: + +1. **Validate the shape of what came back**, not just that something did. For a + bash completion script, require an actual registration + (`complete -…`, `compgen `, `COMPREPLY`) rather than the substring + `complete`. +2. **Run probes from a scratch directory** with stdin detached, so a + misinterpreted argument cannot match real files or consume input. +3. **Confirm the result refers to the tool you probed.** A wrapper can return + its host's answer: `rga --generate complete-bash` forwards to ripgrep and + returns ripgrep's script verbatim, and a `gh` extension's `completion` + subcommand can emit `gh`'s own completion. Installing either under the + wrapper's name shadows the host tool. diff --git a/tests/test_claude_plugin.py b/tests/test_claude_plugin.py new file mode 100644 index 0000000..a91ca41 --- /dev/null +++ b/tests/test_claude_plugin.py @@ -0,0 +1,197 @@ +"""The Claude Code plugin shipped from this repository. + +The skill in ``skills/cli-tools`` calls the repository's own scripts and names +catalog entries in its references. Those references are prose, so nothing +would notice when a script moves or a catalog entry is renamed; these tests +tie them to the files they describe. +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SKILL_DIR = PROJECT_ROOT / "skills" / "cli-tools" +REFERENCES = SKILL_DIR / "references" +CATALOG = PROJECT_ROOT / "catalog" +SCRIPTS = PROJECT_ROOT / "scripts" + +skip_on_windows = pytest.mark.skipif(sys.platform == "win32", reason="Shell script tests require POSIX shell") + + +def _catalog_names() -> set[str]: + return {p.stem for p in CATALOG.glob("*.json")} + + +def _table_rows(markdown: str, heading: str) -> list[list[str]]: + """Cells of the first table under ``heading``, header and rule excluded.""" + section = markdown.split(heading, 1)[1] + rows = [] + for line in section.splitlines()[1:]: + if line.startswith("#"): + break + if line.startswith("|") and not line.startswith("|--"): + rows.append([c.strip() for c in line.strip("|").split("|")]) + return rows[1:] + + +def _names(cell: str) -> list[str]: + return re.findall(r"`([^`]+)`", cell) + + +class TestManifest: + def test_plugin_json_is_valid_and_points_at_the_skill(self): + manifest = json.loads((PROJECT_ROOT / ".claude-plugin" / "plugin.json").read_text()) + assert manifest["name"] == "cli-tools" + for path in manifest["skills"]: + assert (PROJECT_ROOT / path / "SKILL.md").is_file(), path + + def test_plugin_json_sets_no_version(self): + # Without a version, Claude Code versions the plugin by commit SHA, so + # every merge reaches users. A pinned version would freeze them until + # someone bumps it -- and this repository has no release flow that does. + manifest = json.loads((PROJECT_ROOT / ".claude-plugin" / "plugin.json").read_text()) + assert "version" not in manifest + + def test_content_license_file_exists(self): + manifest = json.loads((PROJECT_ROOT / ".claude-plugin" / "plugin.json").read_text()) + assert "CC-BY-SA-4.0" in manifest["license"] + assert (PROJECT_ROOT / "LICENSE-CC-BY-SA-4.0").is_file() + + +class TestSkill: + def test_frontmatter_name_matches_directory(self): + text = (SKILL_DIR / "SKILL.md").read_text() + front = text.split("---", 2)[1] + assert re.search(r"^name: cli-tools$", front, re.M) + assert re.search(r"^description: ", front, re.M) + + def test_every_referenced_script_exists_and_is_executable(self): + text = (SKILL_DIR / "SKILL.md").read_text() + scripts = set(re.findall(r"\$\{CLAUDE_SKILL_DIR\}/\.\./\.\./scripts/([\w./-]+\.sh)", text)) + assert scripts, "SKILL.md names no scripts -- the pattern no longer matches" + for name in scripts: + path = SCRIPTS / name + assert path.is_file(), name + assert os.access(path, os.X_OK), name + + def test_every_referenced_reference_exists(self): + texts = [(SKILL_DIR / "SKILL.md").read_text(), *(p.read_text() for p in REFERENCES.glob("*.md"))] + named = {m for t in texts for m in re.findall(r"references/([\w-]+\.md)", t)} + assert named + for name in named: + assert (REFERENCES / name).is_file(), name + + +class TestBinaryMap: + def test_differing_binary_names_match_the_catalog(self): + rows = _table_rows((REFERENCES / "binary_to_tool_map.md").read_text(), "## Binary name differs") + assert rows + for binary_cell, entry_cell in rows: + binary, entry = _names(binary_cell)[0], _names(entry_cell)[0] + data = json.loads((CATALOG / f"{entry}.json").read_text()) + assert data.get("binary_name") == binary, (binary, entry) + + def test_every_catalog_entry_with_a_differing_binary_is_listed(self): + rows = _table_rows((REFERENCES / "binary_to_tool_map.md").read_text(), "## Binary name differs") + listed = {_names(entry)[0] for _, entry in rows} + differing = set() + for path in CATALOG.glob("*.json"): + binary = json.loads(path.read_text()).get("binary_name") + if binary and binary != path.stem: + differing.add(path.stem) + # compose shares the `docker` binary with the docker entry; the file + # explains it below the table instead of listing it. + assert differing - {"compose"} == listed + + +@skip_on_windows +class TestProjectTypes: + # One marker file per detected type, as named in the reference table + MARKERS = { + "python": "pyproject.toml", + "node": "package.json", + "rust": "Cargo.toml", + "go": "go.mod", + "ruby": "Gemfile", + "php": "composer.json", + "docker": "Dockerfile", + "terraform": "main.tf", + "ansible": "ansible.cfg", + "shell": "Makefile", + } + + def _detect(self, directory: Path) -> dict: + proc = subprocess.run( + [str(SCRIPTS / "detect_project_type.sh"), "json", str(directory)], capture_output=True, text=True + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + def test_empty_directory_yields_empty_lists(self, tmp_path): + assert self._detect(tmp_path) == {"project_types": [], "required_tools": [], "recommended_tools": []} + + def test_reference_table_matches_the_script(self, tmp_path): + rows = _table_rows((REFERENCES / "project_type_requirements.md").read_text(), "## Detected types") + table = {row[0]: (sorted(_names(row[2])), sorted(_names(row[3]))) for row in rows} + for project_type, marker in self.MARKERS.items(): + directory = tmp_path / project_type + directory.mkdir() + (directory / marker).touch() + result = self._detect(directory) + assert result["project_types"] == [project_type], marker + assert (result["required_tools"], result["recommended_tools"]) == table[project_type], project_type + + def test_kubernetes_is_detected_from_a_k8s_directory(self, tmp_path): + (tmp_path / "k8s").mkdir() + assert self._detect(tmp_path)["project_types"] == ["kubernetes"] + + def test_every_named_tool_is_cataloged(self): + rows = _table_rows((REFERENCES / "project_type_requirements.md").read_text(), "## Detected types") + named = {name for row in rows for name in _names(row[2]) + _names(row[3])} + assert named - _catalog_names() == set() + + +@skip_on_windows +class TestCheckEnvironment: + def test_required_tools_are_found_by_their_binary_name(self, tmp_path): + # `rust` provides rustc, not a binary called rust: checking the catalog + # name reported an installed toolchain as missing. + project = tmp_path / "project" + project.mkdir() + (project / "Cargo.toml").touch() + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + rustc = bin_dir / "rustc" + rustc.write_text("#!/bin/sh\necho rustc 9.9.9\n") + rustc.chmod(0o755) + proc = subprocess.run( + [str(SCRIPTS / "check_environment.sh"), "project", str(project)], + capture_output=True, + text=True, + env={**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"}, + ) + assert proc.returncode == 0, proc.stderr + assert "rust: rustc 9.9.9" in proc.stdout + assert "NOT INSTALLED" not in proc.stdout + + def test_one_file_reached_through_two_path_entries_is_not_a_duplicate(self, tmp_path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + cargo = bin_dir / "cargo" + cargo.write_text("#!/bin/sh\n") + cargo.chmod(0o755) + link = tmp_path / "link" + link.symlink_to(bin_dir) + proc = subprocess.run( + [str(SCRIPTS / "check_environment.sh"), "duplicates"], + capture_output=True, + text=True, + env={**os.environ, "PATH": f"{bin_dir}:{link}:{bin_dir}:/usr/bin:/bin"}, + ) + assert "cargo has" not in proc.stdout, proc.stdout