From 4224413f9ff49bea278aa02fb0ab493410ca9b46 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 23 Sep 2026 10:48:06 +0200 Subject: [PATCH] feat(scripts): ship the repository as the cli-tools Claude Code plugin The cli-tools skill lived in netresearch/cli-tools-skill, a fork carrying its own copy of these scripts and catalog, and the two drifted: the last three PRs ported fixes the fork had and this repository lacked. The skill now ships from here and calls these scripts through ${CLAUDE_SKILL_DIR}/../../scripts/, so there is one copy. - .claude-plugin/plugin.json and the portable Agent Plugins plugin.json. No version: Claude Code then versions the plugin by commit, so every merge reaches users; a pinned version would freeze them until someone bumps it, and this repository has no release flow that would. - skills/cli-tools/SKILL.md and references/, ported from the fork and adapted. The binary map is rebuilt from the catalog (the fork mapped `docker` to `compose`; here docker has its own entry, and difft, awf, gws, wslview and ble.sh were missing). Project tools that are not cataloged (phpstan, mypy, ...) are now listed as project dependencies instead of machine requirements. - scripts/check_environment.sh and scripts/detect_project_type.sh, ported with fixes found by running them: * detect json printed `"project_types": [""]` for a directory with no type -- the JSON is now built by jq; * `k8s` was tested with -f, so a k8s/ directory never matched; * required tools were checked by catalog name, so rust (rustc), compose (docker) and ansible-core (ansible) read as missing -- the binary now comes from the catalog's binary_name; * duplicate detection counted one file reached through two PATH entries, or /bin -> /usr/bin, and virtualenvs, as several installations -- it now uses detect_all_installations; * version probes ran without --- .claude-plugin/plugin.json | 14 + LICENSE-CC-BY-SA-4.0 | 19 + LICENSE => LICENSE-MIT | 0 README.md | 24 +- evals/evals.json | 407 ++++++++++++++++++ plugin.json | 12 + scripts/AGENTS.md | 2 + scripts/README.md | 2 + scripts/check_environment.sh | 184 ++++++++ scripts/detect_project_type.sh | 113 +++++ skills/cli-tools/SKILL.md | 85 ++++ .../references/binary_to_tool_map.md | 58 +++ .../cli-tools/references/preferred-tools.md | 157 +++++++ .../references/project_type_requirements.md | 46 ++ .../references/resolution-workflow.md | 48 +++ .../cli-tools/references/troubleshooting.md | 209 +++++++++ tests/test_claude_plugin.py | 197 +++++++++ 17 files changed, 1575 insertions(+), 2 deletions(-) create mode 100644 .claude-plugin/plugin.json create mode 100644 LICENSE-CC-BY-SA-4.0 rename LICENSE => LICENSE-MIT (100%) create mode 100644 evals/evals.json create mode 100644 plugin.json create mode 100755 scripts/check_environment.sh create mode 100755 scripts/detect_project_type.sh create mode 100644 skills/cli-tools/SKILL.md create mode 100644 skills/cli-tools/references/binary_to_tool_map.md create mode 100644 skills/cli-tools/references/preferred-tools.md create mode 100644 skills/cli-tools/references/project_type_requirements.md create mode 100644 skills/cli-tools/references/resolution-workflow.md create mode 100644 skills/cli-tools/references/troubleshooting.md create mode 100644 tests/test_claude_plugin.py 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