From fe3c970596c076436c9f414a9a9e6cdbfc98f9c0 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 22 Jul 2026 10:51:16 +0200 Subject: [PATCH 1/5] feat(scripts): catalog-driven bash-completion installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools may declare an optional `bash_completion` object in their catalog entry with exactly one of `command` (generate to stdout) or `source_path` (copy from clone_path). Completions install to the XDG user completions dir, named after binary_name so the bash-completion framework lazy-loads them. - lib/completion.sh: install/remove/post-install hooks, framework bootstrap, and validation that rejects non-completion output (a bare "complete" in help text is not enough — some tools treat an unknown `completion` argument as a scan target and their output would otherwise pass). - lib/bashrc.sh: reusable idempotent managed-block insert/remove, hardened against unbalanced markers. - install_tool.sh: installs completion after a successful install/update/ reconcile, removes it on uninstall. Best-effort — never fails the tool op. - install_completion.sh: CLI wrapper, plus `--all` backfill. - make completions / make completion-. - catalog/gh.json: first declaration, verified end-to-end. - tests: both shapes, validation rejection, removal, --all, and catalog invariants (one shape per entry; no two entries share a binary_name). Claude-Session: https://claude.ai/code/session_01E3fMneH9KcaT7UYaHpfsHs Signed-off-by: Sebastian Mendel --- AGENTS.md | 2 + Makefile | 1 + Makefile.d/user.mk | 10 ++ bash | 0 catalog/gh.json | 3 + complete | 0 complete-bash | 0 completion | 0 completions | 0 generate-shell-completion | 0 scripts/AGENTS.md | 30 ++++- scripts/install_completion.sh | 65 ++++++++++ scripts/install_tool.sh | 22 +++- scripts/lib/bashrc.sh | 52 ++++++++ scripts/lib/completion.sh | 177 +++++++++++++++++++++++++ tags | 11 ++ tests/test_completion.py | 237 ++++++++++++++++++++++++++++++++++ 17 files changed, 606 insertions(+), 4 deletions(-) create mode 100644 bash create mode 100644 complete create mode 100644 complete-bash create mode 100644 completion create mode 100644 completions create mode 100644 generate-shell-completion create mode 100755 scripts/install_completion.sh create mode 100644 scripts/lib/bashrc.sh create mode 100644 scripts/lib/completion.sh create mode 100644 tags create mode 100644 tests/test_completion.py diff --git a/AGENTS.md b/AGENTS.md index 0852dab..6c621be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,8 @@ This repo **is** a CLI tool manager, so the word "upgrade" is overloaded: | `make reset-pins` | Remove all version pins | | `make upgrade-all` | Full system upgrade (data + managers + tools) | | `./scripts/set_auto_update.sh ` | Enable auto-update for a tool | +| `make completions` | Install bash completions for all installed tools that declare one | +| `make completion-` | Install bash completion for one tool (e.g. `make completion-gh`) | | `uv run python audit.py --versions` | Show multi-version runtime status | | `uv run python audit.py --versions php` | Show specific runtime versions | diff --git a/Makefile b/Makefile index 98c0168..fc3bec8 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ export install-aws install-kubectl install-terraform install-ansible install-docker \ install-brew install-rust install-uv install-% upgrade-% uninstall-% reconcile-% \ reconcile-all reconcile-all-dry-run \ + completions completion-% \ build build-dist build-wheel check-dist publish-test publish-prod \ clean clean-build clean-test clean-pyc clean-all \ scripts-perms audit-auto detect-managers upgrade-managed upgrade-dry-run \ diff --git a/Makefile.d/user.mk b/Makefile.d/user.mk index 3b62e5e..b52ceb2 100644 --- a/Makefile.d/user.mk +++ b/Makefile.d/user.mk @@ -236,6 +236,16 @@ reconcile-all: scripts-perms ## Remove duplicate installs across ALL tools (conf reconcile-all-dry-run: scripts-perms ## Preview duplicate-install cleanup across ALL tools (removes nothing) @$(PYTHON) audit.py --reconcile --all +# ---------------------------------------------------------------------------- +# BASH COMPLETION +# ---------------------------------------------------------------------------- + +completions: scripts-perms ## Install bash completions for all installed tools that declare them + @./scripts/install_completion.sh --all + +completion-%: scripts-perms ## Install bash completion for one tool (e.g., make completion-gh) + @./scripts/install_completion.sh "$*" install + # ---------------------------------------------------------------------------- # SYSTEM MANAGEMENT # ---------------------------------------------------------------------------- diff --git a/bash b/bash new file mode 100644 index 0000000..e69de29 diff --git a/catalog/gh.json b/catalog/gh.json index 3e78fe7..597d126 100644 --- a/catalog/gh.json +++ b/catalog/gh.json @@ -11,5 +11,8 @@ "x86_64": "amd64", "aarch64": "arm64", "armv7l": "armv6" + }, + "bash_completion": { + "command": "gh completion -s bash" } } diff --git a/complete b/complete new file mode 100644 index 0000000..e69de29 diff --git a/complete-bash b/complete-bash new file mode 100644 index 0000000..e69de29 diff --git a/completion b/completion new file mode 100644 index 0000000..e69de29 diff --git a/completions b/completions new file mode 100644 index 0000000..e69de29 diff --git a/generate-shell-completion b/generate-shell-completion new file mode 100644 index 0000000..e69de29 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 7bf7e0a..63e9eb9 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -49,7 +49,10 @@ - `test_smoke.sh`: Smoke test for audit output - `auto_update_scope_prototype.sh`: Prototype for scope detection -**Shared utilities:** `scripts/lib/` directory (10 modules): +**Bash completion:** +- `install_completion.sh`: Install/remove a tool's bash completion; `--all` backfills every declared tool + +**Shared utilities:** `scripts/lib/` directory (12 modules): - `lib/common.sh` — Logging and output formatting - `lib/config.sh` — Read user config from `~/.config/cli-audit/config.yml` - `lib/pins.sh` — Read/write version pins from `~/.config/cli-audit/pins.json` @@ -57,6 +60,31 @@ - `lib/capability.sh`, `lib/dependency.sh` — Capability and dependency checks - `lib/install_strategy.sh`, `lib/reconcile.sh` — Installation strategies - `lib/path_check.sh`, `lib/policy.sh`, `lib/scope_detection.sh` — Path and policy utilities +- `lib/completion.sh` — Bash-completion install/remove + framework bootstrap +- `lib/bashrc.sh` — Idempotent managed-block insert/remove in shell rc files + +### Bash completion (catalog-driven) + +A catalog entry may declare an optional `bash_completion` object with **exactly one** of: + +```jsonc +"bash_completion": { "command": "gh completion bash" } // generate to stdout +"bash_completion": { "source_path": "shell/completion.bash" } // copy from clone_path +``` + +Completion files are written to `${XDG_DATA_HOME:-~/.local/share}/bash-completion/completions/` +and named after the tool's **`binary_name`** (the command being completed), so the +bash-completion framework lazy-loads them. Consequences to respect: + +- **Never declare `bash_completion` on two entries sharing a `binary_name`** — the second + would overwrite the first (e.g. the `compose` entry has `binary_name: docker`, so + completion belongs on `docker` only). +- Generated output is validated (`complete -…` / `compgen ` / `COMPREPLY`) before being + written; tools that echo help text for an unknown `completion` argument are rejected. +- All completion work is **best-effort** — it never fails the surrounding install/uninstall. + +`install_tool.sh` installs completion after a successful install/update/reconcile and removes +it on uninstall. Use `make completions` to backfill tools installed before this existed. ## Setup & environment diff --git a/scripts/install_completion.sh b/scripts/install_completion.sh new file mode 100755 index 0000000..f459449 --- /dev/null +++ b/scripts/install_completion.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Install or remove a tool's bash completion (catalog-driven). +# Usage: install_completion.sh TOOL [install|remove] +# install_completion.sh --all +# +# install Ensure the bash-completion framework, then install TOOL's +# completion (no-op if TOOL declares no bash_completion). +# remove Remove TOOL's installed completion file. +# --all Backfill completions for every catalog tool that declares one; +# tools that are not installed are skipped automatically (their +# generator command fails validation). +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib/completion.sh +. "$DIR/lib/completion.sh" + +# Bulk backfill mode. +if [ "${1:-}" = "--all" ]; then + command -v jq >/dev/null 2>&1 || { echo "Error: jq required" >&2; exit 1; } + ensure_bash_completion_framework || true + installed=0 + skipped=0 + for f in "$(completion_catalog_dir)"/*.json; do + [ -f "$f" ] || continue + jq -e '.bash_completion' "$f" >/dev/null 2>&1 || continue + tool="$(basename "$f" .json)" + if install_completion "$tool" >/dev/null 2>&1; then + echo "[completion] $tool: installed" + installed=$((installed + 1)) + else + skipped=$((skipped + 1)) + fi + done + echo "[completion] done: $installed installed, $skipped skipped (not installed or no valid completion)" + exit 0 +fi + +TOOL="${1:-}" +ACTION="${2:-install}" + +if [ -z "$TOOL" ]; then + echo "Usage: $0 TOOL [install|remove] | $0 --all" >&2 + exit 1 +fi + +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + +case "$ACTION" in + install) + ensure_bash_completion_framework || true + install_completion "$TOOL" + ;; + remove) + remove_completion "$TOOL" + ;; + *) + echo "Usage: $0 TOOL [install|remove]" >&2 + exit 1 + ;; +esac diff --git a/scripts/install_tool.sh b/scripts/install_tool.sh index 1f1a67e..3a91772 100755 --- a/scripts/install_tool.sh +++ b/scripts/install_tool.sh @@ -7,6 +7,9 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Source reconciliation libraries . "$DIR/lib/reconcile.sh" +# Source bash-completion lifecycle helpers (install_completion / remove_completion / +# post_install_completion). All completion operations are best-effort. +. "$DIR/lib/completion.sh" TOOL="${1:-}" ACTION="${2:-install}" @@ -48,6 +51,9 @@ fi # Handle uninstall universally - remove ALL installations found if [ "$ACTION" = "uninstall" ]; then + # Remove any installed bash completion for this tool (best-effort). + remove_completion "$TOOL" || true + # For dedicated_script tools, delegate to their own uninstall handler first if [ "$INSTALL_METHOD" = "dedicated_script" ]; then script_name="$(jq -r '.script // ""' "$CATALOG_FILE" 2>/dev/null || true)" @@ -117,7 +123,9 @@ if [ "$INSTALL_METHOD" = "auto" ]; then install|update|reconcile) # Pass the actual action to reconcile_tool reconcile_tool "$CATALOG_FILE" "$ACTION" - exit $? + rc=$? + [ "$rc" -eq 0 ] && post_install_completion "$TOOL" + exit "$rc" ;; status) reconcile_tool "$CATALOG_FILE" "status" @@ -144,6 +152,14 @@ if [ ! -x "$INSTALLER_SCRIPT" ]; then exit 1 fi -# Execute installer with all remaining arguments +# Execute installer with all remaining arguments. +# (Not exec'd, so we can install bash completion after a successful install.) shift # Remove TOOL from $@ -exec "$INSTALLER_SCRIPT" "$TOOL" "$@" +rc=0 +"$INSTALLER_SCRIPT" "$TOOL" "$@" || rc=$? +if [ "$rc" -eq 0 ]; then + case "$ACTION" in + install|update|reconcile) post_install_completion "$TOOL" ;; + esac +fi +exit "$rc" diff --git a/scripts/lib/bashrc.sh b/scripts/lib/bashrc.sh new file mode 100644 index 0000000..cc177cf --- /dev/null +++ b/scripts/lib/bashrc.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Idempotent management of delimited "managed blocks" in a shell rc file. +# Shared helper: insert a block once, remove it cleanly without disturbing the +# surrounding user content. +# +# Delimiters are derived from a short, stable MARKER_ID so multiple independent +# blocks can coexist in the same file. + +# bashrc_ensure_block FILE MARKER_ID CONTENT +# Append the delimited block if (and only if) it is not already present. +bashrc_ensure_block() { + local file="$1" id="$2" content="$3" + local begin="# >>> cli-audit: ${id} >>>" + local end="# <<< cli-audit: ${id} <<<" + [ -f "$file" ] || touch "$file" + if grep -qF "$begin" "$file" 2>/dev/null; then + return 0 + fi + { + printf '\n%s\n' "$begin" + printf '%s\n' "$content" + printf '%s\n' "$end" + } >>"$file" +} + +# bashrc_remove_block FILE MARKER_ID +# Delete only the delimited region; leave all other lines untouched. +# Hardened against an unbalanced block: if the begin marker has no matching +# end marker (manual tampering / a write interrupted mid-block), the region is +# restored intact rather than deleting everything that follows the begin +# marker. +bashrc_remove_block() { + local file="$1" id="$2" + local begin="# >>> cli-audit: ${id} >>>" + local end="# <<< cli-audit: ${id} <<<" + [ -f "$file" ] || return 0 + grep -qF "$begin" "$file" 2>/dev/null || return 0 + local tmp + tmp="$(mktemp)" + awk -v b="$begin" -v e="$end" ' + !skip && $0 == b { skip=1; buf=$0 ORS; next } + skip { + buf = buf $0 ORS + if ($0 == e) { skip=0; buf="" } + next + } + { print } + END { if (skip) printf "%s", buf } + ' "$file" >"$tmp" + cat "$tmp" >"$file" + rm -f "$tmp" +} diff --git a/scripts/lib/completion.sh b/scripts/lib/completion.sh new file mode 100644 index 0000000..5961f73 --- /dev/null +++ b/scripts/lib/completion.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Bash-completion installation for cataloged tools. +# +# Catalog-driven: a tool's catalog JSON may declare an optional "bash_completion" +# object with EXACTLY ONE of: +# { "command": "" } +# { "source_path": "" } +# +# Completion scripts are written to the XDG user completions directory, named +# after the tool's COMMAND (binary_name) so the bash-completion framework +# lazy-loads them when that command is tab-completed. + +# Resolve this lib's directory and the repo layout. +_COMPLETION_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib/bashrc.sh +. "$_COMPLETION_LIB_DIR/bashrc.sh" + +completion_dir() { + printf '%s/bash-completion/completions' "${XDG_DATA_HOME:-$HOME/.local/share}" +} + +# completion_catalog_dir -> the catalog directory (overridable for tests via +# CLI_AUDIT_CATALOG_DIR; defaults to the repo catalog). +completion_catalog_dir() { + printf '%s' "${CLI_AUDIT_CATALOG_DIR:-$_COMPLETION_LIB_DIR/../../catalog}" +} + +# _completion_catalog_file TOOL -> path to catalog JSON (echoes nothing if absent) +_completion_catalog_file() { + local tool="$1" + local f + f="$(completion_catalog_dir)/${tool}.json" + [ -f "$f" ] && printf '%s' "$f" +} + +# _completion_name TOOL -> the command name the completion file must be named +# after (binary_name from the catalog, falling back to the tool name). +_completion_name() { + local tool="$1" catalog="$2" name + name="$(jq -r '.binary_name // ""' "$catalog" 2>/dev/null)" + [ -n "$name" ] && [ "$name" != "null" ] && { printf '%s' "$name"; return; } + printf '%s' "$tool" +} + +# _completion_looks_valid FILE -> 0 if the file looks like a bash completion +# script. Requires a real completion signature — the `complete` builtin WITH a +# flag (-F/-o/-C/-W/…), a `compgen` call, or a `COMPREPLY` assignment. A bare +# word "complete" in help/scan output is intentionally NOT enough: some tools +# treat an unknown "completion" subcommand as an argument and echo help or even +# run (e.g. a linter scanning a path named "complete"), whose output contains +# the word "complete" but is not a completion script. +_completion_looks_valid() { + local f="$1" + [ -s "$f" ] || return 1 + grep -qE 'complete[[:space:]]+-|compgen[[:space:]]|COMPREPLY' "$f" +} + +# Ensure the bash-completion framework is present and loadable in interactive +# shells. Best-effort: warns and returns non-zero on failure, never aborts. +ensure_bash_completion_framework() { + # 1) Ensure the framework files exist (apt, best-effort). + if [ ! -r /usr/share/bash-completion/bash_completion ] && [ ! -r /etc/bash_completion ]; then + if command -v apt-get >/dev/null 2>&1; then + echo "[completion] Installing bash-completion framework..." >&2 + sudo apt-get update -qq 2>/dev/null || true + sudo apt-get install -y -qq bash-completion 2>/dev/null || { + echo "[completion] Warning: could not install bash-completion package" >&2 + return 1 + } + else + echo "[completion] Warning: bash-completion framework not found and no apt-get" >&2 + return 1 + fi + fi + + # 2) Ensure interactive shells load it (guarded; no-op if already loaded by + # /etc/bash.bashrc). The block sources the framework only when its + # initializer function is not yet defined. + local content + content='if ! declare -F _init_completion >/dev/null 2>&1; then + for _f in /usr/share/bash-completion/bash_completion /etc/bash_completion; do + [ -r "$_f" ] && { . "$_f"; break; } + done + unset _f +fi' + bashrc_ensure_block "$HOME/.bashrc" "bash-completion" "$content" +} + +# install_completion TOOL +# Install the tool's bash completion into the XDG completions dir. +# No-op (returns 0) when the tool declares no bash_completion. +# Best-effort: returns non-zero on failure but never aborts a caller. +install_completion() { + local tool="$1" + command -v jq >/dev/null 2>&1 || { echo "[completion] jq required" >&2; return 1; } + + local catalog + catalog="$(_completion_catalog_file "$tool")" + [ -n "$catalog" ] || return 0 + + local bc + bc="$(jq -c '.bash_completion // empty' "$catalog" 2>/dev/null)" + [ -n "$bc" ] || return 0 + + local cmd src + cmd="$(jq -r '.bash_completion.command // ""' "$catalog" 2>/dev/null)" + src="$(jq -r '.bash_completion.source_path // ""' "$catalog" 2>/dev/null)" + + local tmp + tmp="$(mktemp)" + + if [ -n "$cmd" ] && [ "$cmd" != "null" ]; then + # Generate to stdout. stderr is discarded so tool noise never lands in the + # completion file; validation below rejects anything that isn't completion. + bash -c "$cmd" >"$tmp" 2>/dev/null || true + elif [ -n "$src" ] && [ "$src" != "null" ]; then + local clone_path base full + clone_path="$(jq -r '.clone_path // ""' "$catalog" 2>/dev/null)" + clone_path="${clone_path/#\~/$HOME}" + base="$clone_path" + if [ -z "$base" ]; then + echo "[completion] $tool: source_path needs a clone_path in catalog" >&2 + rm -f "$tmp" + return 1 + fi + full="$base/$src" + if [ -f "$full" ]; then + cp "$full" "$tmp" + fi + else + rm -f "$tmp" + return 0 + fi + + if ! _completion_looks_valid "$tmp"; then + echo "[completion] $tool: generated output is not a valid completion; skipping" >&2 + rm -f "$tmp" + return 1 + fi + + local name dir + name="$(_completion_name "$tool" "$catalog")" + dir="$(completion_dir)" + mkdir -p "$dir" + mv "$tmp" "$dir/$name" + echo "[completion] $tool: installed completion ($name)" >&2 +} + +# remove_completion TOOL +remove_completion() { + local tool="$1" + local catalog name dir + catalog="$(_completion_catalog_file "$tool")" + [ -n "$catalog" ] || return 0 + name="$(_completion_name "$tool" "$catalog")" + dir="$(completion_dir)" + if [ -f "$dir/$name" ]; then + rm -f "$dir/$name" + echo "[completion] $tool: removed completion ($name)" >&2 + fi +} + +# post_install_completion TOOL +# Convenience hook for the install lifecycle: ensure the framework once, then +# install the tool's completion. Entirely best-effort; always returns 0 so it +# can never fail the surrounding tool install. +post_install_completion() { + local tool="$1" + local catalog bc + catalog="$(_completion_catalog_file "$tool")" + [ -n "$catalog" ] || return 0 + bc="$(jq -c '.bash_completion // empty' "$catalog" 2>/dev/null)" + [ -n "$bc" ] || return 0 + ensure_bash_completion_framework || true + install_completion "$tool" || true + return 0 +} diff --git a/tags b/tags new file mode 100644 index 0000000..10c059a --- /dev/null +++ b/tags @@ -0,0 +1,11 @@ +!_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/ +!_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/ +!_TAG_OUTPUT_EXCMD mixed /number, pattern, mixed, or combineV2/ +!_TAG_OUTPUT_FILESEP slash /slash or backslash/ +!_TAG_OUTPUT_MODE u-ctags /u-ctags or e-ctags/ +!_TAG_PATTERN_LENGTH_LIMIT 96 /0 for no limit/ +!_TAG_PROC_CWD /home/sme/p/coding_agent_cli_toolset/ // +!_TAG_PROGRAM_AUTHOR Universal Ctags Team // +!_TAG_PROGRAM_NAME Universal Ctags /Derived from Exuberant Ctags/ +!_TAG_PROGRAM_URL https://ctags.io/ /official site/ +!_TAG_PROGRAM_VERSION 5.9.0 // diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 0000000..f29aa94 --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,237 @@ +"""Tests for the catalog-driven bash-completion framework. + +Exercises scripts/lib/completion.sh directly (sourced in a subshell) against a +fixture catalog (CLI_AUDIT_CATALOG_DIR) and a temp XDG_DATA_HOME. No network and +no real tool execution: completion "commands" are plain `printf` snippets. +""" + +import json +import os +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +LIB = PROJECT_ROOT / "scripts" / "lib" / "completion.sh" + + +def _write_catalog(catalog_dir: Path, name: str, entry: dict) -> None: + catalog_dir.mkdir(parents=True, exist_ok=True) + (catalog_dir / f"{name}.json").write_text(json.dumps(entry)) + + +def _run(catalog_dir: Path, xdg: Path, home: Path, snippet: str) -> subprocess.CompletedProcess: + env = { + **os.environ, + "CLI_AUDIT_CATALOG_DIR": str(catalog_dir), + "XDG_DATA_HOME": str(xdg), + "HOME": str(home), + } + return subprocess.run( + ["bash", "-c", f'source "{LIB}"\n{snippet}'], + capture_output=True, + text=True, + env=env, + ) + + +def _completions_path(xdg: Path, name: str) -> Path: + return xdg / "bash-completion" / "completions" / name + + +class TestInstallCompletion: + def test_command_shape_installs_named_by_binary(self, tmp_path): + catalog = tmp_path / "catalog" + _write_catalog( + catalog, + "faketool", + { + "name": "faketool", + "binary_name": "ft", + "bash_completion": {"command": "printf 'complete -F _ft ft\\n'"}, + }, + ) + xdg = tmp_path / "xdg" + proc = _run(catalog, xdg, tmp_path, "install_completion faketool") + assert proc.returncode == 0, proc.stderr + # File must be named after the COMMAND (binary_name), not the tool. + target = _completions_path(xdg, "ft") + assert target.exists() + assert "complete -F _ft ft" in target.read_text() + assert not _completions_path(xdg, "faketool").exists() + + def test_source_path_shape(self, tmp_path): + clone = tmp_path / "clone" + (clone / "shell").mkdir(parents=True) + (clone / "shell" / "comp.bash").write_text("complete -F _s s\n") + catalog = tmp_path / "catalog" + _write_catalog( + catalog, + "srctool", + { + "name": "srctool", + "binary_name": "s", + "clone_path": str(clone), + "bash_completion": {"source_path": "shell/comp.bash"}, + }, + ) + xdg = tmp_path / "xdg" + proc = _run(catalog, xdg, tmp_path, "install_completion srctool") + assert proc.returncode == 0, proc.stderr + assert _completions_path(xdg, "s").read_text() == "complete -F _s s\n" + + def test_no_bash_completion_is_noop(self, tmp_path): + catalog = tmp_path / "catalog" + _write_catalog(catalog, "plain", {"name": "plain", "binary_name": "plain"}) + xdg = tmp_path / "xdg" + proc = _run(catalog, xdg, tmp_path, "install_completion plain; echo rc=$?") + assert proc.returncode == 0, proc.stderr + assert "rc=0" in proc.stdout + assert not _completions_path(xdg, "plain").exists() + + def test_unknown_tool_is_noop(self, tmp_path): + catalog = tmp_path / "catalog" + catalog.mkdir() + xdg = tmp_path / "xdg" + proc = _run(catalog, xdg, tmp_path, "install_completion ghost; echo rc=$?") + assert proc.returncode == 0, proc.stderr + assert "rc=0" in proc.stdout + + def test_garbage_output_is_rejected(self, tmp_path): + catalog = tmp_path / "catalog" + _write_catalog( + catalog, + "badtool", + { + "name": "badtool", + "binary_name": "bad", + "bash_completion": {"command": "printf 'usage: bad [opts]\\n'"}, + }, + ) + xdg = tmp_path / "xdg" + proc = _run(catalog, xdg, tmp_path, "install_completion badtool; echo rc=$?") + # Non-completion output must not be written. + assert not _completions_path(xdg, "bad").exists() + assert "rc=1" in proc.stdout + + def test_empty_output_is_rejected(self, tmp_path): + catalog = tmp_path / "catalog" + _write_catalog( + catalog, + "emptytool", + { + "name": "emptytool", + "binary_name": "et", + "bash_completion": {"command": "true"}, + }, + ) + xdg = tmp_path / "xdg" + proc = _run(catalog, xdg, tmp_path, "install_completion emptytool; echo rc=$?") + assert not _completions_path(xdg, "et").exists() + assert "rc=1" in proc.stdout + + +class TestRemoveCompletion: + def test_remove_deletes_installed_file(self, tmp_path): + catalog = tmp_path / "catalog" + _write_catalog( + catalog, + "faketool", + { + "name": "faketool", + "binary_name": "ft", + "bash_completion": {"command": "printf 'complete -F _ft ft\\n'"}, + }, + ) + xdg = tmp_path / "xdg" + _run(catalog, xdg, tmp_path, "install_completion faketool") + assert _completions_path(xdg, "ft").exists() + proc = _run(catalog, xdg, tmp_path, "remove_completion faketool") + assert proc.returncode == 0, proc.stderr + assert not _completions_path(xdg, "ft").exists() + + def test_remove_missing_is_noop(self, tmp_path): + catalog = tmp_path / "catalog" + _write_catalog( + catalog, + "faketool", + {"name": "faketool", "binary_name": "ft", "bash_completion": {"command": "true"}}, + ) + xdg = tmp_path / "xdg" + proc = _run(catalog, xdg, tmp_path, "remove_completion faketool; echo rc=$?") + assert "rc=0" in proc.stdout + + +class TestRealCatalogSchema: + """Invariants over the shipped catalog's bash_completion declarations.""" + + def _entries(self): + for f in sorted((PROJECT_ROOT / "catalog").glob("*.json")): + data = json.loads(f.read_text()) + if "bash_completion" in data: + yield f.stem, data + + def test_declarations_have_exactly_one_shape(self): + for tool, data in self._entries(): + bc = data["bash_completion"] + assert isinstance(bc, dict), f"{tool}: bash_completion must be an object" + keys = set(bc) & {"command", "source_path"} + assert len(keys) == 1, f"{tool}: need exactly one of command|source_path, got {sorted(bc)}" + assert bc[keys.pop()], f"{tool}: bash_completion value must be non-empty" + + def test_source_path_entries_have_clone_path(self): + for tool, data in self._entries(): + if "source_path" in data["bash_completion"]: + assert data.get("clone_path"), f"{tool}: source_path requires clone_path" + + def test_no_two_declarations_share_a_binary_name(self): + # The completion file is named after binary_name, so two declaring + # entries with the same binary_name would silently overwrite each other + # (e.g. the `compose` entry has binary_name "docker"). + seen: dict[str, str] = {} + for tool, data in self._entries(): + binary = data.get("binary_name") or tool + assert binary not in seen, f"{tool} and {seen[binary]} both declare bash_completion for binary '{binary}'" + seen[binary] = tool + + +WRAPPER = PROJECT_ROOT / "scripts" / "install_completion.sh" + + +class TestWrapperCli: + def _run_wrapper(self, catalog, xdg, home, *args): + env = { + **os.environ, + "CLI_AUDIT_CATALOG_DIR": str(catalog), + "XDG_DATA_HOME": str(xdg), + "HOME": str(home), + } + return subprocess.run( + ["bash", str(WRAPPER), *args], + capture_output=True, + text=True, + env=env, + ) + + def test_no_args_prints_usage(self, tmp_path): + proc = self._run_wrapper(tmp_path / "catalog", tmp_path / "xdg", tmp_path) + assert proc.returncode == 1 + assert "Usage" in proc.stderr + + def test_all_backfills_only_declared_and_valid(self, tmp_path): + catalog = tmp_path / "catalog" + _write_catalog( + catalog, + "faketool", + { + "name": "faketool", + "binary_name": "ft", + "bash_completion": {"command": "printf 'complete -F _ft ft\\n'"}, + }, + ) + _write_catalog(catalog, "plain", {"name": "plain", "binary_name": "plain"}) + xdg = tmp_path / "xdg" + proc = self._run_wrapper(catalog, xdg, tmp_path, "--all") + assert proc.returncode == 0, proc.stderr + assert _completions_path(xdg, "ft").exists() + assert not _completions_path(xdg, "plain").exists() + assert "1 installed" in proc.stdout From 029195f0eb8bc567cac46f808e1e540c7d7638a4 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 22 Jul 2026 10:52:54 +0200 Subject: [PATCH 2/5] test(completion): skip shell-based tests on Windows The completion lib is POSIX shell; invoking it via `bash` fails on windows-latest. Use the repo's existing skip_on_windows idiom for the shell-invoking classes; the catalog-schema invariants stay platform-independent. Claude-Session: https://claude.ai/code/session_01E3fMneH9KcaT7UYaHpfsHs Signed-off-by: Sebastian Mendel --- tests/test_completion.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_completion.py b/tests/test_completion.py index f29aa94..0d6e32b 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,11 +8,16 @@ import json import os import subprocess +import sys from pathlib import Path +import pytest + PROJECT_ROOT = Path(__file__).resolve().parent.parent LIB = PROJECT_ROOT / "scripts" / "lib" / "completion.sh" +skip_on_windows = pytest.mark.skipif(sys.platform == "win32", reason="Shell script tests require POSIX shell") + def _write_catalog(catalog_dir: Path, name: str, entry: dict) -> None: catalog_dir.mkdir(parents=True, exist_ok=True) @@ -38,6 +43,7 @@ def _completions_path(xdg: Path, name: str) -> Path: return xdg / "bash-completion" / "completions" / name +@skip_on_windows class TestInstallCompletion: def test_command_shape_installs_named_by_binary(self, tmp_path): catalog = tmp_path / "catalog" @@ -130,6 +136,7 @@ def test_empty_output_is_rejected(self, tmp_path): assert "rc=1" in proc.stdout +@skip_on_windows class TestRemoveCompletion: def test_remove_deletes_installed_file(self, tmp_path): catalog = tmp_path / "catalog" @@ -197,6 +204,7 @@ def test_no_two_declarations_share_a_binary_name(self): WRAPPER = PROJECT_ROOT / "scripts" / "install_completion.sh" +@skip_on_windows class TestWrapperCli: def _run_wrapper(self, catalog, xdg, home, *args): env = { From 9b6c053a08450a032f87a0851d8b2666872cd550 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 22 Jul 2026 11:14:14 +0200 Subject: [PATCH 3/5] feat(catalog): declare bash_completion across the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited all 112 catalog entries for a bash-completion generator. Each candidate generator was executed and its output validated (complete -…/compgen/COMPREPLY), then checked to confirm it registers the entry's own binary_name. 40 entries now declare bash_completion (39 command, 1 source_path for rbenv). Six tools have a generator that must NOT be used, because the completion file is named after binary_name and would shadow another tool: - gh-aw emits gh's completion (registers gh, never gh-aw) - rga forwards to ripgrep and returns ripgrep's script verbatim (registers rg) - compose has binary_name docker; docker already covers it - fzf --bash is shell integration (key bindings, global `complete -D`), not a completion script — belongs in .bashrc via eval - rustup completions registers rustup/cargo, never rustc - gcloud has no clone_path to resolve source_path against, and its file registers three commands in one lazily-loaded file codex, pip and pipx are not installed on the audit machine; their generators come from official docs. Runtime validation makes that safe — an invalid generator writes nothing. catalog/COVERAGE.md records the full classification. Claude-Session: https://claude.ai/code/session_01E3fMneH9KcaT7UYaHpfsHs Signed-off-by: Sebastian Mendel --- catalog/COVERAGE.md | 48 ++++++++++++++++++++++++++++++++++++++ catalog/ast-grep.json | 5 +++- catalog/bat.json | 10 ++++++-- catalog/black.json | 5 +++- catalog/codex.json | 9 +++++-- catalog/composer.json | 9 +++++-- catalog/dasel.json | 5 +++- catalog/delta.json | 5 +++- catalog/dive.json | 3 +++ catalog/docker.json | 3 +++ catalog/fd.json | 10 ++++++-- catalog/fx.json | 3 +++ catalog/git-absorb.json | 5 +++- catalog/git-lfs.json | 3 +++ catalog/gitleaks.json | 3 +++ catalog/glab.json | 3 +++ catalog/golangci-lint.json | 5 +++- catalog/gup.json | 5 +++- catalog/jj.json | 5 +++- catalog/just.json | 5 +++- catalog/kubectl.json | 3 +++ catalog/mlr.json | 5 +++- catalog/node.json | 5 +++- catalog/npm.json | 9 +++++-- catalog/parallel.json | 5 +++- catalog/pip.json | 10 ++++++-- catalog/pipx.json | 3 +++ catalog/pnpm.json | 9 +++++-- catalog/poetry.json | 5 +++- catalog/rbenv.json | 3 +++ catalog/ripgrep.json | 5 +++- catalog/ruff.json | 5 +++- catalog/scc.json | 5 +++- catalog/symfony.json | 5 +++- catalog/trivy.json | 3 +++ catalog/uv.json | 5 +++- catalog/vhs.json | 3 +++ catalog/watchexec.json | 3 +++ catalog/yq.json | 5 +++- catalog/zellij.json | 5 +++- 40 files changed, 216 insertions(+), 34 deletions(-) diff --git a/catalog/COVERAGE.md b/catalog/COVERAGE.md index 21db9a0..60112f6 100644 --- a/catalog/COVERAGE.md +++ b/catalog/COVERAGE.md @@ -63,3 +63,51 @@ Most now in catalog, one dedicated script: - **5 tools** are system packages only All installable tools either have catalog entries or use appropriate dedicated scripts. + +## Bash completion coverage + +Every catalog entry was audited for a bash-completion generator (sweep of +2026-07-22; each generator was executed and its output validated against +`complete -…` / `compgen ` / `COMPREPLY`, then checked to confirm it registers +the entry's own `binary_name`). + +**40 entries declare `bash_completion`** — 39 `command`, 1 `source_path` (rbenv). + +Declared (`command`): ast-grep, bat, black, codex, composer, dasel, delta, dive, +docker, fd, fx, gh, git-absorb, git-lfs, gitleaks, glab, golangci-lint, gup, jj, +just, kubectl, mlr, node, npm, parallel, pip, pipx, pnpm, poetry, ripgrep, ruff, +scc, symfony, trivy, uv, vhs, watchexec, yq, zellij + +Declared (`source_path`): rbenv (`completions/rbenv.bash`, under its `clone_path`) + +### Deliberately excluded (a generator exists but must not be used) + +The completion file is named after `binary_name`, so a script that registers a +*different* command would silently shadow another tool's completion: + +| Tool | Why excluded | +|------|--------------| +| `gh-aw` | `gh-aw completion bash` emits **gh's** completion (`complete … __start_gh gh`); it never registers `gh-aw` and would shadow the distro `gh` completion | +| `rga` | `rga --generate complete-bash` forwards to ripgrep and returns ripgrep's script verbatim (registers `rg`) | +| `compose` | `binary_name` is `docker`; no compose-specific generator exists — `docker` already covers it | +| `fzf` | `fzf --bash` is full shell *integration* (key bindings, a global `complete -D` handler), not a completion script. Use `eval "$(fzf --bash)"` in `.bashrc` instead | +| `rust` | `rustup completions bash` registers `rustup`/`cargo`, never `rustc` | +| `gcloud` | Ships `completion.bash.inc`, but the entry has no `clone_path` for `source_path` to resolve against, and the file registers three commands (`gcloud`, `bq`, `gsutil`) in one lazily-loaded file | + +### No bash completion available + +actionlint, ansible-core, aws, bandit, claude, ctags, curlie, difftastic, +direnv, dust, entr, eslint, flake8, gam, gem, gemini, gh-aw-firewall, git, +git-branchless, git-filter-repo, go, google-workspace-cli, gosec, httpie, +hyperfine, isort, jq, ninja, opengrep, php, pre-commit, prename, prettier, +python, qsv, rename.ul, ruby, ruby-build, sd, semgrep, shellcheck, shfmt, +sponge, templ, terraform, tfsec, tmux, tokei, tree, wslu, xsv, yarn + +(`git` and `docker` already ship completions via the distro `bash-completion` +package; `docker` is still declared so the generated script matches the +installed daemon version, and the XDG user directory takes precedence.) + +**Not verifiable on the audit machine** — `codex`, `pip`, `pipx` were not +installed, so their generators come from official documentation rather than a +local run. The runtime validation makes this safe: if the command does not +produce a valid completion script, nothing is written. diff --git a/catalog/ast-grep.json b/catalog/ast-grep.json index 46ffa15..0a1dfe5 100644 --- a/catalog/ast-grep.json +++ b/catalog/ast-grep.json @@ -12,5 +12,8 @@ "aarch64": "aarch64", "armv7l": "armv7" }, - "auto_update": true + "auto_update": true, + "bash_completion": { + "command": "ast-grep completions bash" + } } diff --git a/catalog/bat.json b/catalog/bat.json index 123ad72..6425d56 100644 --- a/catalog/bat.json +++ b/catalog/bat.json @@ -6,7 +6,10 @@ "homepage": "https://github.com/sharkdp/bat", "github_repo": "sharkdp/bat", "binary_name": "bat", - "candidates": ["bat", "batcat"], + "candidates": [ + "bat", + "batcat" + ], "download_url_template": "https://github.com/sharkdp/bat/releases/download/{version}/bat-{version}-{arch}-unknown-linux-musl.tar.gz", "arch_map": { "x86_64": "x86_64", @@ -40,5 +43,8 @@ "requires": [], "tags": [ "core" - ] + ], + "bash_completion": { + "command": "bat --completion bash" + } } diff --git a/catalog/black.json b/catalog/black.json index b972c82..c5ea863 100644 --- a/catalog/black.json +++ b/catalog/black.json @@ -5,5 +5,8 @@ "description": "The uncompromising Python code formatter", "homepage": "https://github.com/psf/black", "package_name": "black", - "version_command": "black --version 2>/dev/null | head -1" + "version_command": "black --version 2>/dev/null | head -1", + "bash_completion": { + "command": "_BLACK_COMPLETE=bash_source black" + } } diff --git a/catalog/codex.json b/catalog/codex.json index 8b4ae17..80a05e5 100644 --- a/catalog/codex.json +++ b/catalog/codex.json @@ -7,12 +7,17 @@ "github_repo": "openai/codex", "package_name": "@openai/codex", "binary_name": "codex", - "requires": ["node"], + "requires": [ + "node" + ], "version_command": "codex --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1", "guide": { "display_name": "OpenAI Codex CLI", "install_action": "upgrade", "order": 401 }, - "notes": "OpenAI's open-source coding agent. Requires OPENAI_API_KEY. Supports multiple AI providers including third-party models." + "notes": "OpenAI's open-source coding agent. Requires OPENAI_API_KEY. Supports multiple AI providers including third-party models.", + "bash_completion": { + "command": "codex completion bash" + } } diff --git a/catalog/composer.json b/catalog/composer.json index 2630e60..c7fa7c9 100644 --- a/catalog/composer.json +++ b/catalog/composer.json @@ -7,11 +7,16 @@ "github_repo": "composer/composer", "binary_name": "composer", "script": "install_composer.sh", - "requires": ["php"], + "requires": [ + "php" + ], "guide": { "display_name": "Composer", "install_action": "install", "order": 260 }, - "notes": "Installed from latest stable phar (https://getcomposer.org/download/latest-stable/composer.phar). Requires PHP." + "notes": "Installed from latest stable phar (https://getcomposer.org/download/latest-stable/composer.phar). Requires PHP.", + "bash_completion": { + "command": "composer completion bash" + } } diff --git a/catalog/dasel.json b/catalog/dasel.json index a482f44..fe0a518 100644 --- a/catalog/dasel.json +++ b/catalog/dasel.json @@ -13,5 +13,8 @@ "armv7l": "arm7" }, "version_command": "dasel version 2>/dev/null | grep -oE 'v?[0-9]+\\.[0-9]+\\.[0-9]+'", - "auto_update": true + "auto_update": true, + "bash_completion": { + "command": "dasel completion bash" + } } diff --git a/catalog/delta.json b/catalog/delta.json index 232c7e9..95a96c4 100644 --- a/catalog/delta.json +++ b/catalog/delta.json @@ -38,5 +38,8 @@ ], "tags": [ "core" - ] + ], + "bash_completion": { + "command": "delta --generate-completion bash" + } } diff --git a/catalog/dive.json b/catalog/dive.json index a825bd5..2a98993 100644 --- a/catalog/dive.json +++ b/catalog/dive.json @@ -11,5 +11,8 @@ "x86_64": "amd64", "aarch64": "arm64", "armv7l": "arm" + }, + "bash_completion": { + "command": "dive completion bash" } } diff --git a/catalog/docker.json b/catalog/docker.json index 40385cb..12b206b 100644 --- a/catalog/docker.json +++ b/catalog/docker.json @@ -11,5 +11,8 @@ "display_name": "Docker CLI", "install_action": "install", "order": 200 + }, + "bash_completion": { + "command": "docker completion bash" } } diff --git a/catalog/fd.json b/catalog/fd.json index 107ad25..1a7d771 100644 --- a/catalog/fd.json +++ b/catalog/fd.json @@ -6,7 +6,10 @@ "homepage": "https://github.com/sharkdp/fd", "github_repo": "sharkdp/fd", "binary_name": "fd", - "candidates": ["fd", "fdfind"], + "candidates": [ + "fd", + "fdfind" + ], "download_url_template": "https://github.com/sharkdp/fd/releases/download/{version}/fd-{version}-{arch}-unknown-linux-musl.tar.gz", "arch_map": { "x86_64": "x86_64", @@ -42,5 +45,8 @@ "core", "search", "file-utils" - ] + ], + "bash_completion": { + "command": "fd --gen-completions bash" + } } diff --git a/catalog/fx.json b/catalog/fx.json index 6d1623a..f30afb8 100644 --- a/catalog/fx.json +++ b/catalog/fx.json @@ -11,5 +11,8 @@ "x86_64": "amd64", "aarch64": "arm64", "armv7l": "armv6" + }, + "bash_completion": { + "command": "fx --comp bash" } } diff --git a/catalog/git-absorb.json b/catalog/git-absorb.json index ac5690e..ff0370d 100644 --- a/catalog/git-absorb.json +++ b/catalog/git-absorb.json @@ -12,5 +12,8 @@ "x86_64": "x86_64", "armv7l": "arm" }, - "notes": "Only x86_64 builds available; arm builds use different target triple" + "notes": "Only x86_64 builds available; arm builds use different target triple", + "bash_completion": { + "command": "git-absorb --gen-completions bash" + } } diff --git a/catalog/git-lfs.json b/catalog/git-lfs.json index 1d7d093..69984ba 100644 --- a/catalog/git-lfs.json +++ b/catalog/git-lfs.json @@ -11,5 +11,8 @@ "x86_64": "amd64", "aarch64": "arm64", "armv7l": "arm" + }, + "bash_completion": { + "command": "git-lfs completion bash" } } diff --git a/catalog/gitleaks.json b/catalog/gitleaks.json index 1d483e1..82d707f 100644 --- a/catalog/gitleaks.json +++ b/catalog/gitleaks.json @@ -11,5 +11,8 @@ "x86_64": "x64", "aarch64": "arm64", "armv7l": "armv7" + }, + "bash_completion": { + "command": "gitleaks completion bash" } } diff --git a/catalog/glab.json b/catalog/glab.json index 3964685..b8e125c 100644 --- a/catalog/glab.json +++ b/catalog/glab.json @@ -11,5 +11,8 @@ "x86_64": "amd64", "aarch64": "arm64", "armv7l": "armv6" + }, + "bash_completion": { + "command": "glab completion -s bash" } } diff --git a/catalog/golangci-lint.json b/catalog/golangci-lint.json index 1085109..7cc30f8 100644 --- a/catalog/golangci-lint.json +++ b/catalog/golangci-lint.json @@ -13,5 +13,8 @@ "aarch64": "arm64", "armv7l": "armv6" }, - "auto_update": true + "auto_update": true, + "bash_completion": { + "command": "golangci-lint completion bash" + } } diff --git a/catalog/gup.json b/catalog/gup.json index e814cec..f9a478d 100644 --- a/catalog/gup.json +++ b/catalog/gup.json @@ -46,5 +46,8 @@ "package-manager", "updater" ], - "auto_update": true + "auto_update": true, + "bash_completion": { + "command": "gup completion bash" + } } diff --git a/catalog/jj.json b/catalog/jj.json index 77ed88c..83a95c8 100644 --- a/catalog/jj.json +++ b/catalog/jj.json @@ -33,5 +33,8 @@ "tags": [ "git", "vcs" - ] + ], + "bash_completion": { + "command": "jj util completion bash" + } } diff --git a/catalog/just.json b/catalog/just.json index acd8440..24e625e 100644 --- a/catalog/just.json +++ b/catalog/just.json @@ -15,5 +15,8 @@ "tags": [ "core" ], - "auto_update": true + "auto_update": true, + "bash_completion": { + "command": "JUST_COMPLETE=bash just" + } } diff --git a/catalog/kubectl.json b/catalog/kubectl.json index 6a44f44..aa5c757 100644 --- a/catalog/kubectl.json +++ b/catalog/kubectl.json @@ -20,5 +20,8 @@ "display_name": "kubectl", "install_action": "install", "order": 203 + }, + "bash_completion": { + "command": "kubectl completion bash" } } diff --git a/catalog/mlr.json b/catalog/mlr.json index d74f20c..efe3ff6 100644 --- a/catalog/mlr.json +++ b/catalog/mlr.json @@ -17,5 +17,8 @@ "csv", "json", "text-utils" - ] + ], + "bash_completion": { + "command": "mlr completion bash" + } } diff --git a/catalog/node.json b/catalog/node.json index b50f09d..8339566 100644 --- a/catalog/node.json +++ b/catalog/node.json @@ -28,5 +28,8 @@ "package.json" ] }, - "notes": "Managed via nvm (Node Version Manager). Supports multiple concurrent LTS versions." + "notes": "Managed via nvm (Node Version Manager). Supports multiple concurrent LTS versions.", + "bash_completion": { + "command": "node --completion-bash" + } } diff --git a/catalog/npm.json b/catalog/npm.json index 1fa4f3e..3d9b49f 100644 --- a/catalog/npm.json +++ b/catalog/npm.json @@ -6,6 +6,11 @@ "homepage": "https://www.npmjs.com/", "package_name": "npm", "binary_name": "npm", - "requires": ["node"], - "notes": "npm comes bundled with Node.js but can be upgraded independently using 'npm install -g npm@latest'" + "requires": [ + "node" + ], + "notes": "npm comes bundled with Node.js but can be upgraded independently using 'npm install -g npm@latest'", + "bash_completion": { + "command": "npm completion" + } } diff --git a/catalog/parallel.json b/catalog/parallel.json index 3b224e3..151de8b 100644 --- a/catalog/parallel.json +++ b/catalog/parallel.json @@ -8,5 +8,8 @@ "script": "install_parallel.sh", "ftp_url": "https://ftp.gnu.org/gnu/parallel/", "notes": "Installed from GNU FTP releases. Installs to ~/.local/bin/parallel.", - "auto_update": true + "auto_update": true, + "bash_completion": { + "command": "parallel --shellcompletion bash" + } } diff --git a/catalog/pip.json b/catalog/pip.json index c1505d9..4dbc10f 100644 --- a/catalog/pip.json +++ b/catalog/pip.json @@ -6,7 +6,13 @@ "homepage": "https://pip.pypa.io/", "package_name": "pip", "binary_name": "pip", - "candidates": ["pip", "pip3"], + "candidates": [ + "pip", + "pip3" + ], "script": "install_pip.sh", - "notes": "pip is bundled with Python 3. Use python3 -m pip if pip command is not available." + "notes": "pip is bundled with Python 3. Use python3 -m pip if pip command is not available.", + "bash_completion": { + "command": "pip completion --bash" + } } diff --git a/catalog/pipx.json b/catalog/pipx.json index 021f3cb..c6c4a55 100644 --- a/catalog/pipx.json +++ b/catalog/pipx.json @@ -11,5 +11,8 @@ "brew": "pipx", "dnf": "pipx", "pacman": "python-pipx" + }, + "bash_completion": { + "command": "register-python-argcomplete pipx" } } diff --git a/catalog/pnpm.json b/catalog/pnpm.json index 333f2c2..1f42b28 100644 --- a/catalog/pnpm.json +++ b/catalog/pnpm.json @@ -6,7 +6,9 @@ "homepage": "https://pnpm.io/", "package_name": "pnpm", "binary_name": "pnpm", - "requires": ["node"], + "requires": [ + "node" + ], "available_methods": [ { "method": "npm", @@ -30,5 +32,8 @@ } } ], - "notes": "Can be installed via npm (npm install -g pnpm), apt, or brew" + "notes": "Can be installed via npm (npm install -g pnpm), apt, or brew", + "bash_completion": { + "command": "pnpm completion bash" + } } diff --git a/catalog/poetry.json b/catalog/poetry.json index 615e5d1..dcd2f3a 100644 --- a/catalog/poetry.json +++ b/catalog/poetry.json @@ -13,5 +13,8 @@ "dnf": "poetry", "pacman": "python-poetry" }, - "notes": "Some distributions may require installing via pipx or the official installer: curl -sSL https://install.python-poetry.org | python3 -" + "notes": "Some distributions may require installing via pipx or the official installer: curl -sSL https://install.python-poetry.org | python3 -", + "bash_completion": { + "command": "poetry completions bash" + } } diff --git a/catalog/rbenv.json b/catalog/rbenv.json index 6625ae9..1027cea 100644 --- a/catalog/rbenv.json +++ b/catalog/rbenv.json @@ -12,5 +12,8 @@ "display_name": "rbenv", "install_action": "install", "order": 30 + }, + "bash_completion": { + "source_path": "completions/rbenv.bash" } } diff --git a/catalog/ripgrep.json b/catalog/ripgrep.json index fcd3d23..540692c 100644 --- a/catalog/ripgrep.json +++ b/catalog/ripgrep.json @@ -39,5 +39,8 @@ "requires": [], "tags": [ "core" - ] + ], + "bash_completion": { + "command": "rg --generate complete-bash" + } } diff --git a/catalog/ruff.json b/catalog/ruff.json index a27e184..532a780 100644 --- a/catalog/ruff.json +++ b/catalog/ruff.json @@ -5,5 +5,8 @@ "description": "An extremely fast Python linter and code formatter, written in Rust", "homepage": "https://github.com/astral-sh/ruff", "package_name": "ruff", - "auto_update": true + "auto_update": true, + "bash_completion": { + "command": "ruff generate-shell-completion bash" + } } diff --git a/catalog/scc.json b/catalog/scc.json index 3a430b4..6a4af6c 100644 --- a/catalog/scc.json +++ b/catalog/scc.json @@ -40,5 +40,8 @@ "tags": [ "code-counter", "statistics" - ] + ], + "bash_completion": { + "command": "scc completion bash" + } } diff --git a/catalog/symfony.json b/catalog/symfony.json index 1b92a12..af5e2d7 100644 --- a/catalog/symfony.json +++ b/catalog/symfony.json @@ -19,5 +19,8 @@ "install_action": "install", "order": 265 }, - "notes": "Standalone CLI for Symfony development. Includes local web server, security checker, and cloud deployment." + "notes": "Standalone CLI for Symfony development. Includes local web server, security checker, and cloud deployment.", + "bash_completion": { + "command": "symfony completion bash" + } } diff --git a/catalog/trivy.json b/catalog/trivy.json index da657df..768f58e 100644 --- a/catalog/trivy.json +++ b/catalog/trivy.json @@ -11,5 +11,8 @@ "x86_64": "64bit", "aarch64": "ARM64", "armv7l": "ARM" + }, + "bash_completion": { + "command": "trivy completion bash" } } diff --git a/catalog/uv.json b/catalog/uv.json index ed19676..ea04160 100644 --- a/catalog/uv.json +++ b/catalog/uv.json @@ -12,5 +12,8 @@ "install_action": "reconcile", "order": 20 }, - "auto_update": true + "auto_update": true, + "bash_completion": { + "command": "uv generate-shell-completion bash" + } } diff --git a/catalog/vhs.json b/catalog/vhs.json index b00ee27..dff6ebb 100644 --- a/catalog/vhs.json +++ b/catalog/vhs.json @@ -11,5 +11,8 @@ "arch_map": { "x86_64": "x86_64", "aarch64": "arm64" + }, + "bash_completion": { + "command": "vhs completion bash" } } diff --git a/catalog/watchexec.json b/catalog/watchexec.json index 89c2e06..b0c8a0c 100644 --- a/catalog/watchexec.json +++ b/catalog/watchexec.json @@ -11,5 +11,8 @@ "x86_64": "x86_64", "aarch64": "aarch64", "armv7l": "armv7" + }, + "bash_completion": { + "command": "watchexec --completions bash" } } diff --git a/catalog/yq.json b/catalog/yq.json index 236e402..3b64d84 100644 --- a/catalog/yq.json +++ b/catalog/yq.json @@ -15,5 +15,8 @@ }, "tags": [ "core" - ] + ], + "bash_completion": { + "command": "yq completion bash" + } } diff --git a/catalog/zellij.json b/catalog/zellij.json index 011cdb1..4fcd713 100644 --- a/catalog/zellij.json +++ b/catalog/zellij.json @@ -27,5 +27,8 @@ "crate": "zellij" } } - ] + ], + "bash_completion": { + "command": "zellij setup --generate-completion bash" + } } From 046ee377da6f94da7212ba16b2587b2bc294d411 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 22 Jul 2026 11:30:14 +0200 Subject: [PATCH 4/5] fix(scripts): harden completion install against four review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by adversarial review of #121; each was reproduced before fixing and verified after. - _completion_catalog_file returned 1 for an unknown tool. Callers capture it in an assignment, so a `set -e` caller aborted before its own no-op guard: `install_completion.sh ` exited 1 with no output, and `make completion-` failed with a bare "Error 1". Always return 0. - mkdir/mv were unchecked and the trailing echo forced a 0 return, so an unwritable target reported "installed" while writing nothing and leaking the temp file. Check both, clean up, return 1. In --all this had inflated the installed count. - The generator ran with stdin inherited from make/the terminal and no timeout, so a generator that reads stdin would hang the install forever. No shipped entry triggers it today (all 38 local generators were re-run to confirm), but the blast radius warrants `timeout 30 … --- scripts/install_tool.sh | 4 +- scripts/lib/completion.sh | 33 +++++++-- tests/test_completion.py | 143 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 7 deletions(-) diff --git a/scripts/install_tool.sh b/scripts/install_tool.sh index 3a91772..6dc39ce 100755 --- a/scripts/install_tool.sh +++ b/scripts/install_tool.sh @@ -159,7 +159,9 @@ rc=0 "$INSTALLER_SCRIPT" "$TOOL" "$@" || rc=$? if [ "$rc" -eq 0 ]; then case "$ACTION" in - install|update|reconcile) post_install_completion "$TOOL" ;; + # || true: completion is best-effort and must never turn a successful + # install into a non-zero exit (this call is not otherwise set -e exempt). + install|update|reconcile) post_install_completion "$TOOL" || true ;; esac fi exit "$rc" diff --git a/scripts/lib/completion.sh b/scripts/lib/completion.sh index 5961f73..dc7c167 100644 --- a/scripts/lib/completion.sh +++ b/scripts/lib/completion.sh @@ -25,12 +25,15 @@ completion_catalog_dir() { printf '%s' "${CLI_AUDIT_CATALOG_DIR:-$_COMPLETION_LIB_DIR/../../catalog}" } -# _completion_catalog_file TOOL -> path to catalog JSON (echoes nothing if absent) +# _completion_catalog_file TOOL -> path to catalog JSON (echoes nothing if absent). +# Always returns 0: callers capture it in an assignment, and a non-zero status +# there would abort a `set -e` caller before their own "no catalog" guard runs. _completion_catalog_file() { local tool="$1" local f f="$(completion_catalog_dir)/${tool}.json" - [ -f "$f" ] && printf '%s' "$f" + [ -f "$f" ] || return 0 + printf '%s' "$f" } # _completion_name TOOL -> the command name the completion file must be named @@ -76,8 +79,13 @@ ensure_bash_completion_framework() { # 2) Ensure interactive shells load it (guarded; no-op if already loaded by # /etc/bash.bashrc). The block sources the framework only when its # initializer function is not yet defined. + # The interactive guard is essential: the distro bash_completion script has no + # guard of its own and enables `extglob`/`progcomp` at top level. Without + # `$- == *i*` a non-interactive shell that sources ~/.bashrc (e.g. `ssh host + # somescript`) would silently get extended globbing, changing how patterns + # like !(…) / @(…) parse. Also skipped in POSIX mode, as distro rc files do. local content - content='if ! declare -F _init_completion >/dev/null 2>&1; then + content='if [[ $- == *i* ]] && ! shopt -oq posix && ! declare -F _init_completion >/dev/null 2>&1; then for _f in /usr/share/bash-completion/bash_completion /etc/bash_completion; do [ -r "$_f" ] && { . "$_f"; break; } done @@ -112,7 +120,10 @@ install_completion() { if [ -n "$cmd" ] && [ "$cmd" != "null" ]; then # Generate to stdout. stderr is discarded so tool noise never lands in the # completion file; validation below rejects anything that isn't completion. - bash -c "$cmd" >"$tmp" 2>/dev/null || true + # stdin MUST be detached and the run bounded: a generator that falls through + # to reading stdin (or blocks) would otherwise hang the whole install + # indefinitely, since stdin is inherited from make/the user's terminal. + timeout 30 bash -c "$cmd" >"$tmp" 2>/dev/null /dev/null)" @@ -141,8 +152,18 @@ install_completion() { local name dir name="$(_completion_name "$tool" "$catalog")" dir="$(completion_dir)" - mkdir -p "$dir" - mv "$tmp" "$dir/$name" + # Both steps must be checked: the trailing echo would otherwise mask a failure + # behind a 0 return, reporting "installed" with nothing written. + if ! mkdir -p "$dir" 2>/dev/null; then + echo "[completion] $tool: cannot create $dir" >&2 + rm -f "$tmp" + return 1 + fi + if ! mv "$tmp" "$dir/$name" 2>/dev/null; then + echo "[completion] $tool: failed to install into $dir" >&2 + rm -f "$tmp" + return 1 + fi echo "[completion] $tool: installed completion ($name)" >&2 } diff --git a/tests/test_completion.py b/tests/test_completion.py index 0d6e32b..d0a2040 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -168,6 +168,149 @@ def test_remove_missing_is_noop(self, tmp_path): assert "rc=0" in proc.stdout +@skip_on_windows +class TestRobustness: + """Regressions for defects found in adversarial review of #121.""" + + def test_unknown_tool_via_wrapper_under_set_e_exits_zero(self, tmp_path): + # _completion_catalog_file used to return 1 for a missing catalog, which + # aborted the `set -euo pipefail` wrapper before its own no-op guard. + catalog = tmp_path / "catalog" + catalog.mkdir() + env = { + **os.environ, + "CLI_AUDIT_CATALOG_DIR": str(catalog), + "XDG_DATA_HOME": str(tmp_path / "xdg"), + "HOME": str(tmp_path), + } + proc = subprocess.run( + ["bash", str(WRAPPER), "ghost", "install"], + capture_output=True, + text=True, + env=env, + ) + assert proc.returncode == 0, f"stdout={proc.stdout} stderr={proc.stderr}" + + def test_unwritable_target_reports_failure_and_leaks_no_tempfile(self, tmp_path): + # mkdir/mv were unchecked, so a failed install still echoed "installed" + # and returned 0, leaving the temp file behind. + catalog = tmp_path / "catalog" + _write_catalog( + catalog, + "g", + {"name": "g", "binary_name": "g", "bash_completion": {"command": "printf 'complete -F _g g\\n'"}}, + ) + blocked = tmp_path / "blocked" + blocked.mkdir() + blocked.chmod(0o500) + tmpdir = tmp_path / "tmp" + tmpdir.mkdir() + env = { + **os.environ, + "CLI_AUDIT_CATALOG_DIR": str(catalog), + "XDG_DATA_HOME": str(blocked / "sub"), + "HOME": str(tmp_path), + "TMPDIR": str(tmpdir), + } + proc = subprocess.run( + ["bash", "-c", f'source "{LIB}"\ninstall_completion g; echo rc=$?'], + capture_output=True, + text=True, + env=env, + ) + blocked.chmod(0o700) + assert "rc=1" in proc.stdout + assert "installed completion" not in proc.stderr + assert list(tmpdir.iterdir()) == [], "temp file leaked" + + def test_generator_reading_stdin_does_not_hang(self, tmp_path): + # stdin is now detached and the run bounded; `cat` used to block forever. + catalog = tmp_path / "catalog" + _write_catalog( + catalog, + "h", + {"name": "h", "binary_name": "h", "bash_completion": {"command": "cat"}}, + ) + env = { + **os.environ, + "CLI_AUDIT_CATALOG_DIR": str(catalog), + "XDG_DATA_HOME": str(tmp_path / "xdg"), + "HOME": str(tmp_path), + } + proc = subprocess.run( + ["bash", "-c", f'source "{LIB}"\ninstall_completion h'], + capture_output=True, + text=True, + env=env, + stdin=subprocess.PIPE, + timeout=30, + ) + assert proc.returncode == 1 + + def test_framework_block_is_guarded_for_non_interactive_shells(self, tmp_path): + # The distro bash_completion enables extglob/progcomp at top level and + # has no guard of its own; sourcing it non-interactively changes glob + # semantics for scripts (e.g. `ssh host somescript`). + env = {**os.environ, "HOME": str(tmp_path)} + subprocess.run( + ["bash", "-c", f'source "{LIB}"\nensure_bash_completion_framework'], + capture_output=True, + text=True, + env=env, + ) + bashrc = tmp_path / ".bashrc" + if not bashrc.exists(): + pytest.skip("framework not available in this environment") + assert "$- == *i*" in bashrc.read_text() + proc = subprocess.run( + ["bash", "-c", 'source "$HOME/.bashrc" 2>/dev/null; shopt extglob'], + capture_output=True, + text=True, + env=env, + ) + assert "off" in proc.stdout, "non-interactive shell got extglob enabled" + + +@skip_on_windows +class TestBashrcLib: + """Direct coverage for the shared managed-block primitives.""" + + BASHRC_LIB = PROJECT_ROOT / "scripts" / "lib" / "bashrc.sh" + + def _run(self, home, snippet): + return subprocess.run( + ["bash", "-c", f'source "{self.BASHRC_LIB}"\n{snippet}'], + capture_output=True, + text=True, + env={**os.environ, "HOME": str(home)}, + ) + + def test_ensure_is_idempotent(self, tmp_path): + rc = tmp_path / "rc" + proc = self._run( + tmp_path, + f'bashrc_ensure_block "{rc}" demo "X"; bashrc_ensure_block "{rc}" demo "X"; ' + f'grep -c ">>> cli-audit: demo >>>" "{rc}"', + ) + assert proc.stdout.strip().splitlines()[-1] == "1" + + def test_remove_preserves_surrounding_content(self, tmp_path): + rc = tmp_path / "rc" + rc.write_text("before\nafter\n") + proc = self._run( + tmp_path, + f'bashrc_ensure_block "{rc}" demo "X"; bashrc_remove_block "{rc}" demo; cat "{rc}"', + ) + assert "before" in proc.stdout and "after" in proc.stdout + assert "cli-audit" not in proc.stdout + + def test_remove_restores_unbalanced_block_intact(self, tmp_path): + rc = tmp_path / "rc" + rc.write_text("keepA\n# >>> cli-audit: demo >>>\norphan\nkeepB\n") + proc = self._run(tmp_path, f'bashrc_remove_block "{rc}" demo; cat "{rc}"') + assert "keepA" in proc.stdout and "keepB" in proc.stdout + + class TestRealCatalogSchema: """Invariants over the shipped catalog's bash_completion declarations.""" From 173f889132ed7cfc84e7c78ad94752f7c39065fc Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 22 Jul 2026 11:35:47 +0200 Subject: [PATCH 5/5] fix(scripts): use timeout only where the platform provides it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit hard-coded `timeout 30` around the completion generator. macOS ships no GNU `timeout`, so on macos-latest the command failed, the generator produced no output, and every completion was rejected as invalid — three tests failed in CI. Route the generator through _completion_run, which uses `timeout` (GNU) or `gtimeout` (Homebrew coreutils) when present and otherwise runs the command directly. Verified both branches: with timeout absent (PATH built from the real bin dirs minus timeout/gtimeout) the completion installs correctly, and a generator that reads stdin still returns immediately — ` --- scripts/lib/completion.sh | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/scripts/lib/completion.sh b/scripts/lib/completion.sh index dc7c167..701810f 100644 --- a/scripts/lib/completion.sh +++ b/scripts/lib/completion.sh @@ -45,6 +45,21 @@ _completion_name() { printf '%s' "$tool" } +# _completion_run CMD -> run a generator command, bounded by `timeout` when the +# platform provides one (GNU coreutils; absent from a stock macOS, where +# gtimeout may exist via Homebrew). Falls back to a plain run rather than +# failing, so completion generation still works without coreutils. +_completion_run() { + local cmd="$1" + if command -v timeout >/dev/null 2>&1; then + timeout 30 bash -c "$cmd" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout 30 bash -c "$cmd" + else + bash -c "$cmd" + fi +} + # _completion_looks_valid FILE -> 0 if the file looks like a bash completion # script. Requires a real completion signature — the `complete` builtin WITH a # flag (-F/-o/-C/-W/…), a `compgen` call, or a `COMPREPLY` assignment. A bare @@ -120,10 +135,13 @@ install_completion() { if [ -n "$cmd" ] && [ "$cmd" != "null" ]; then # Generate to stdout. stderr is discarded so tool noise never lands in the # completion file; validation below rejects anything that isn't completion. - # stdin MUST be detached and the run bounded: a generator that falls through - # to reading stdin (or blocks) would otherwise hang the whole install - # indefinitely, since stdin is inherited from make/the user's terminal. - timeout 30 bash -c "$cmd" >"$tmp" 2>/dev/null "$tmp" 2>/dev/null /dev/null)"