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/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/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/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" + } } 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..6dc39ce 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,16 @@ 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 + # || 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/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..701810f --- /dev/null +++ b/scripts/lib/completion.sh @@ -0,0 +1,216 @@ +#!/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). +# 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" ] || return 0 + 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_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 +# 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. + # 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 [[ $- == *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 + 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. + # stdin MUST be detached: a generator that falls through to reading stdin + # would otherwise hang the whole install indefinitely, since stdin is + # inherited from make/the user's terminal. "$tmp" 2>/dev/null /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)" + # 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 +} + +# 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..d0a2040 --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,388 @@ +"""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 +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) + (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 + + +@skip_on_windows +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 + + +@skip_on_windows +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 + + +@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.""" + + 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" + + +@skip_on_windows +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