diff --git a/catalog/blesh.json b/catalog/blesh.json new file mode 100644 index 0000000..8ff83de --- /dev/null +++ b/catalog/blesh.json @@ -0,0 +1,13 @@ +{ + "name": "blesh", + "category": "general", + "install_method": "dedicated_script", + "script": "install_blesh.sh", + "description": "Bash Line Editor — full-featured line editor for interactive Bash (syntax highlighting, autosuggestions, completion UI)", + "homepage": "https://github.com/akinomyoga/ble.sh", + "github_repo": "akinomyoga/ble.sh", + "binary_name": "ble.sh", + "version_command": "bash \"$HOME/.local/share/blesh/ble.sh\" --version 2>/dev/null", + "notes": "Not a PATH binary — sourced from ~/.local/share/blesh/ble.sh via .bashrc. Built from source (git clone --recursive + make install PREFIX=~/.local). Requires git, make, gawk.", + "tags": [] +} diff --git a/docs/superpowers/specs/2026-07-22-blesh-and-bash-completion-design.md b/docs/superpowers/specs/2026-07-22-blesh-and-bash-completion-design.md new file mode 100644 index 0000000..c4830a2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-blesh-and-bash-completion-design.md @@ -0,0 +1,222 @@ +# Design: ble.sh tool + bash-completion installation support + +**Date:** 2026-07-22 +**Status:** Approved (design), pending implementation +**Delivery:** Two independent PRs + +## Problem + +1. **ble.sh** (Bash Line Editor — syntax highlighting, autosuggestions, richer + completion UI for interactive Bash) is not in the catalog. It should be + installable/upgradable/removable through the standard `make install-blesh` + flow. +2. The toolset installs many CLIs but never installs their **bash completion** + scripts, so tab-completion for `gh`, `glab`, `uv`, `rg`, etc. is missing. + We want completions installed automatically as part of a tool's lifecycle, + plus a way to backfill completions for tools already installed. + +These are thematically related (interactive-shell ergonomics) but technically +independent, so they ship as **two PRs**. + +--- + +## PR 1 — Add ble.sh + +ble.sh is *sourced* into an interactive shell, not placed on `PATH` as a +binary. Recommended upstream install is `git clone --recursive` + +`make install PREFIX=~/.local`, then a `source` line in `.bashrc`. This matches +the repo's `dedicated_script` pattern (cf. `install_tmux.sh`, built from source). + +### Catalog: `catalog/blesh.json` + +```jsonc +{ + "name": "blesh", + "category": "general", + "install_method": "dedicated_script", + "script": "install_blesh.sh", + "description": "Bash Line Editor — full-featured line editor for interactive Bash (syntax highlighting, autosuggestions, completion UI)", + "homepage": "https://github.com/akinomyoga/ble.sh", + "github_repo": "akinomyoga/ble.sh", + "binary_name": "ble.sh", + "version_command": "bash \"$HOME/.local/share/blesh/ble.sh\" --version 2>/dev/null", + "notes": "Not a PATH binary — sourced from ~/.local/share/blesh/ble.sh via .bashrc. Built from source (git clone --recursive + make install). Requires git, make, gawk.", + "tags": [] +} +``` + +**Unknowns — RESOLVED with evidence (2026-07-22, real install into a scratch prefix):** + +- **Installed-version detection.** ✅ `bash "$HOME/.local/share/blesh/ble.sh" + --version` prints `ble.sh (Bash Line Editor), version 0.4.0-devel4+d69e4d5` + (exit 0). `version_command` runs via `shell=True`, and detection falls back to + the standalone `` path when no PATH binary is found (ble.sh's + `binary_name` is never on PATH). `extract_version_number(...)` yields `0.4.0`. + No `version_regex` needed (not a real catalog field — unused in code). +- **Upstream version tracking.** ✅ `collect_github('akinomyoga','ble.sh')` + returns `('v0.4.0-devel3', '0.4.0')` via the `/releases/latest` redirect — + the `nightly` tag does not pollute it. Installed `0.4.0-develN` and upstream + both normalize to `0.4.0` and align. **No `skip_upstream` needed.** The + installer clones the default (`master`) branch, matching upstream detection. + +### Installer: `scripts/install_blesh.sh` + +Structure mirrors `install_tmux.sh`. Supports `install` | `update` | `uninstall`. + +- **Deps check:** ensure `git`, `make`, `gawk` (ble.sh needs gawk). Install via + apt (with `sudo`) only if missing, same pattern as `install_tmux.sh`. +- **install/update:** + - Clone to a fixed source dir (e.g. `~/.local/src/ble.sh`) with + `git clone --recursive --depth 1 --shallow-submodules`; if already present, + fetch/reset like `github_clone.sh` does. + - `make -C install PREFIX="$HOME/.local"` → installs to + `~/.local/share/blesh/`. + - Idempotently ensure a **single managed block** in `~/.bashrc`: + ```bash + # >>> cli-audit: ble.sh >>> + [[ $- == *i* ]] && source ~/.local/share/blesh/ble.sh + # <<< cli-audit: ble.sh <<< + ``` + Guarded by the delimiter comments; never appended twice. + - Report before/after version; call `refresh_snapshot "$TOOL"`. +- **uninstall:** remove `~/.local/share/blesh/`, the source dir, and the managed + `.bashrc` block (delete the delimited region only — never rewrite unrelated + lines). + +### Tests (PR 1) + +- Catalog validity test already iterates `catalog/*.json`; `blesh.json` must + pass its schema assertions (valid `install_method`, referenced `script` + exists and is executable). +- Add a focused assertion (pytest or `test_smoke.sh`) that + `install_blesh.sh` prints usage for an unknown action and that its + `.bashrc`-block insertion is idempotent (run the insert helper twice → one + block). Network/clone is **not** exercised in CI. + +### Docs (PR 1) + +- Regenerate / update `catalog/CATALOG_SUMMARY.md` and `catalog/COVERAGE.md` + if they are generated from the catalog; otherwise add the `blesh` row. + +--- + +## PR 2 — Bash-completion installation framework + +### Catalog schema: optional `bash_completion` + +Add an **optional** object to any catalog entry. Two shapes: + +```jsonc +// Shape A — generate to stdout (most modern CLIs) +"bash_completion": { "command": "gh completion -s bash" } + +// Shape B — copy a file shipped inside the tool's install/clone +"bash_completion": { "source_path": "shell/completion.bash" } +``` + +- Absent field ⇒ tool has no bash completion (or none worth shipping). +- `command` is run with the tool on `PATH`; stdout is captured. +- `source_path` is resolved relative to the tool's install/clone dir. +- Schema is additive and backward-compatible; existing entries unaffected. + +### Installer lib: `scripts/lib/completion.sh` + +Pure functions, sourced by the orchestrator and the make targets: + +- `completion_dir()` → `${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions` +- `install_completion `: + 1. Read `.bash_completion` from `catalog/.json`; no field ⇒ no-op (0). + 2. Shape A: run `command`, capture stdout to a temp file. + Shape B: locate `source_path`, copy to temp file. + 3. **Validate** the captured output is non-empty and looks like a bash + completion script (e.g. contains `complete ` or `_` / `compgen`); + refuse to install garbage. Log and return non-zero on validation failure. + 4. Move into `completion_dir()/`. +- `remove_completion `: delete `completion_dir()/` if present. +- `ensure_bash_completion_framework`: + - Ensure the `bash-completion` package is installed (apt, best-effort with + `sudo`; skip silently if unavailable/no sudo). + - Ensure a **single managed block** in `~/.bashrc` sources the framework + loader if the distro doesn't already (`/usr/share/bash-completion/bash_completion`), + same delimiter-guard technique as ble.sh. + - Called once before writing any completion file. + +All completion operations are **best-effort**: a failure logs a warning and +returns non-zero but must **never** abort or fail the tool install/uninstall +that invoked it. + +### Entry point: `scripts/install_completion.sh` + +Thin CLI wrapper: `install_completion.sh [install|remove]` → sources the +lib and dispatches. Used by make targets and callable standalone. + +### Lifecycle hooks: `scripts/install_tool.sh` + +- After a **successful** `install`/`update`/`reconcile` of a tool, call + `ensure_bash_completion_framework` (once) then `install_completion ` + — guarded so failure only warns. +- In the `uninstall` path, call `remove_completion `. +- Hooks are additive; they wrap existing success/exit points without changing + current control flow or exit codes. + +### Make targets + +In `Makefile.d/user.mk` (+ `.PHONY` in `Makefile`): + +- `make completions` — for every **installed** tool (per `local_state.json`) + that declares `bash_completion`, run `install_completion`. Ensures the + framework first. This is the backfill for pre-existing installs. +- `make completion-` — (re)install completion for one tool. + +### Coverage — broad sweep (this PR) + +Audit **all** catalog entries and declare `bash_completion` for every tool that +supports it: + +- Classify each tool as **command** / **source_path** / **none**. +- For `command` tools, use the tool's real, documented generator (verified by + running it where the tool is installed locally; otherwise from official docs). + Common conventions: ` completion bash`, ` completion -s bash`, + ` --completion bash`, ` generate-shell-completion bash`, + ` --generate complete-bash`. **Do not assume** a convention — verify + per tool. +- Record the classification result (tool → mechanism / none) in + `catalog/COVERAGE.md` so coverage is auditable. + +**Scope note:** the broad sweep makes PR 2 exceed the repo's ~300 net-LOC +guideline. This is a deliberate, accepted trade-off (chosen over a curated +subset); the framework code stays small and the bulk is mechanical, additive, +per-tool JSON plus a coverage table. + +### Tests (PR 2) + +- Unit test `install_completion` against a **fake tool** fixture for both + shapes (command via a stub script that echoes a known completion; source_path + via a fixture file) — assert the file lands in a temp `XDG_DATA_HOME` and that + validation rejects empty/garbage output. +- Test `remove_completion` deletes the file and is a no-op when absent. +- Test the `.bashrc` managed-block insert is idempotent. +- Test that `bash_completion` values in the catalog are schema-valid (object + with exactly one of `command` | `source_path`) — extend the catalog-validity + test. +- No network; no real tool execution in CI (use stubs/fixtures). + +--- + +## Non-goals / YAGNI + +- No zsh/fish completion (bash only, per request). +- No completion for tools that don't natively provide one (no hand-written + completion scripts maintained in-repo). +- No auto-refresh daemon; refresh is explicit (`make completions`) or happens + on the next install/upgrade of a tool. + +## Risks + +- **ble.sh version detection / upstream parsing** — RESOLVED with evidence from + a real scratch install (see PR 1 section); no residual risk. +- **bash-completion framework absence** — mitigated by + `ensure_bash_completion_framework`; on systems without apt/sudo it degrades to + a warning and the XDG files still work once a framework is present. +- **Wrong generator command per tool** — mitigated by output validation before + install and by verifying each generator during the broad sweep. diff --git a/scripts/install_blesh.sh b/scripts/install_blesh.sh new file mode 100755 index 0000000..6ef0182 --- /dev/null +++ b/scripts/install_blesh.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Install ble.sh (Bash Line Editor) from source. +# ble.sh is *sourced* into interactive Bash (not a PATH binary): it installs to +# ~/.local/share/blesh/ble.sh and is loaded via a managed block in ~/.bashrc. +# Recommended upstream install is `git clone --recursive` + `make install`. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$DIR/lib/install_strategy.sh" + +TOOL="blesh" +GITHUB_REPO="akinomyoga/ble.sh" +INSTALL_PREFIX="${HOME}/.local" +SRC_DIR="${HOME}/.local/src/ble.sh" +BLE_FILE="${INSTALL_PREFIX}/share/blesh/ble.sh" +SHARE_DIR="${INSTALL_PREFIX}/share/blesh" +BASHRC="${HOME}/.bashrc" + +# Managed .bashrc block delimiters (must be unique and stable) +BEGIN_MARK="# >>> cli-audit: ble.sh >>>" +END_MARK="# <<< cli-audit: ble.sh <<<" + +get_installed_version() { + if [ -f "$BLE_FILE" ]; then + bash "$BLE_FILE" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+[^ ]*' | head -1 || true + fi +} + +# Idempotently ensure the source line is present in ~/.bashrc. +ensure_bashrc_block() { + [ -f "$BASHRC" ] || touch "$BASHRC" + if grep -qF "$BEGIN_MARK" "$BASHRC" 2>/dev/null; then + return 0 + fi + { + printf '\n%s\n' "$BEGIN_MARK" + printf '[[ $- == *i* ]] && source -- %s\n' "$BLE_FILE" + printf '%s\n' "$END_MARK" + } >>"$BASHRC" + echo "[$TOOL] Added ble.sh source line to $BASHRC" >&2 +} + +# Remove the managed block from ~/.bashrc (deletes only the delimited region). +remove_bashrc_block() { + [ -f "$BASHRC" ] || return 0 + grep -qF "$BEGIN_MARK" "$BASHRC" 2>/dev/null || return 0 + local tmp + tmp="$(mktemp)" + # Hardened: if the begin marker has no matching end marker (tampering / a + # write interrupted mid-block), restore the region intact rather than + # deleting everything that follows the begin marker. + awk -v b="$BEGIN_MARK" -v e="$END_MARK" ' + !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 } + ' "$BASHRC" >"$tmp" + cat "$tmp" >"$BASHRC" + rm -f "$tmp" + echo "[$TOOL] Removed ble.sh source line from $BASHRC" >&2 +} + +ensure_deps() { + local missing_deps=() + command -v git >/dev/null 2>&1 || missing_deps+=(git) + command -v make >/dev/null 2>&1 || missing_deps+=(make) + command -v gawk >/dev/null 2>&1 || missing_deps+=(gawk) + if [ ${#missing_deps[@]} -gt 0 ]; then + echo "[$TOOL] Installing build dependencies: ${missing_deps[*]}" >&2 + sudo apt-get update -qq + sudo apt-get install -y -qq "${missing_deps[@]}" + fi +} + +install_blesh() { + ensure_deps + + local before + before="$(get_installed_version)" + + # Clone or update the source tree (with submodules). + if [ ! -d "$SRC_DIR/.git" ]; then + echo "[$TOOL] Cloning $GITHUB_REPO..." >&2 + mkdir -p "$(dirname "$SRC_DIR")" + rm -rf "$SRC_DIR" + git clone --recursive --depth 1 --shallow-submodules \ + "https://github.com/${GITHUB_REPO}.git" "$SRC_DIR" || { + echo "[$TOOL] Error: git clone failed" >&2 + return 1 + } + else + echo "[$TOOL] Updating $SRC_DIR..." >&2 + git -C "$SRC_DIR" fetch origin --depth 1 || { + echo "[$TOOL] Error: git fetch failed" >&2 + return 1 + } + git -C "$SRC_DIR" reset --hard origin/HEAD || { + echo "[$TOOL] Error: git reset failed" >&2 + return 1 + } + git -C "$SRC_DIR" submodule update --init --recursive --depth 1 || true + fi + + echo "[$TOOL] Building and installing to $INSTALL_PREFIX..." >&2 + make -C "$SRC_DIR" install PREFIX="$INSTALL_PREFIX" >/dev/null || { + echo "[$TOOL] Error: make install failed" >&2 + return 1 + } + + ensure_bashrc_block + + local after + after="$(get_installed_version)" + printf "[%s] before: %s\n" "$TOOL" "${before:-}" + printf "[%s] after: %s\n" "$TOOL" "${after:-}" + printf "[%s] path: %s\n" "$TOOL" "$BLE_FILE" + + refresh_snapshot "$TOOL" + return 0 +} + +uninstall_blesh() { + echo "[$TOOL] Removing $SHARE_DIR and $SRC_DIR" >&2 + rm -rf "$SHARE_DIR" "$SRC_DIR" + remove_bashrc_block +} + +# Only dispatch when executed directly; allow sourcing for tests. +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + case "${1:-install}" in + install|update) + install_blesh + ;; + uninstall) + uninstall_blesh + ;; + *) + echo "Usage: $0 [install|update|uninstall]" >&2 + exit 1 + ;; + esac +fi diff --git a/tests/test_blesh.py b/tests/test_blesh.py new file mode 100644 index 0000000..e6e17eb --- /dev/null +++ b/tests/test_blesh.py @@ -0,0 +1,111 @@ +"""Tests for the ble.sh (Bash Line Editor) catalog entry and installer. + +ble.sh is a dedicated_script tool that is *sourced* into interactive Bash +(not a PATH binary). These tests cover its catalog structure and the +installer's idempotent ~/.bashrc managed-block handling, without performing a +real network install. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +CATALOG = PROJECT_ROOT / "catalog" / "blesh.json" +SCRIPT = PROJECT_ROOT / "scripts" / "install_blesh.sh" + +skip_on_windows = pytest.mark.skipif(sys.platform == "win32", reason="Shell script tests require POSIX shell") + + +class TestBleshCatalog: + def test_catalog_exists_and_is_valid_json(self): + assert CATALOG.exists(), "blesh.json catalog file should exist" + json.loads(CATALOG.read_text()) + + def test_catalog_structure(self): + data = json.loads(CATALOG.read_text()) + assert data["name"] == "blesh" + assert data["install_method"] == "dedicated_script" + assert data["script"] == "install_blesh.sh" + assert data["github_repo"] == "akinomyoga/ble.sh" + assert data["binary_name"] == "ble.sh" + + def test_catalog_has_version_command(self): + data = json.loads(CATALOG.read_text()) + # ble.sh has no PATH binary; version must come from the sourced file. + assert "blesh/ble.sh" in data["version_command"] + assert "--version" in data["version_command"] + + def test_catalog_notes_document_sourced_nature(self): + data = json.loads(CATALOG.read_text()) + assert "source" in data.get("notes", "").lower() + + +@skip_on_windows +class TestBleshInstaller: + def test_script_exists_and_executable(self): + assert SCRIPT.exists(), "install_blesh.sh should exist" + assert os.access(SCRIPT, os.X_OK), "install_blesh.sh should be executable" + + def test_unknown_action_prints_usage(self): + proc = subprocess.run( + ["bash", str(SCRIPT), "bogus"], + capture_output=True, + text=True, + ) + assert proc.returncode == 1 + assert "Usage" in proc.stderr + + def _run_sourced(self, home: Path, snippet: str) -> subprocess.CompletedProcess: + """Source the installer (main-guard prevents dispatch) and run snippet.""" + env = {**os.environ, "HOME": str(home)} + return subprocess.run( + ["bash", "-c", f'source "{SCRIPT}"\n{snippet}'], + capture_output=True, + text=True, + env=env, + ) + + def test_bashrc_block_is_idempotent(self, tmp_path): + proc = self._run_sourced( + tmp_path, + "ensure_bashrc_block; ensure_bashrc_block; " r'grep -cF "# >>> cli-audit: ble.sh >>>" "$HOME/.bashrc"', + ) + assert proc.returncode == 0, proc.stderr + # Two inserts must yield exactly one managed block. + assert proc.stdout.strip().splitlines()[-1] == "1" + + def test_bashrc_block_removal(self, tmp_path): + proc = self._run_sourced( + tmp_path, + "ensure_bashrc_block; remove_bashrc_block; " r'grep -cF "cli-audit: ble.sh" "$HOME/.bashrc" || true', + ) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip().splitlines()[-1] == "0" + + def test_bashrc_block_preserves_existing_content(self, tmp_path): + bashrc = tmp_path / ".bashrc" + bashrc.write_text("export FOO=bar\nalias ll='ls -la'\n") + proc = self._run_sourced( + tmp_path, + 'ensure_bashrc_block; remove_bashrc_block; cat "$HOME/.bashrc"', + ) + assert proc.returncode == 0, proc.stderr + assert "export FOO=bar" in proc.stdout + assert "alias ll='ls -la'" in proc.stdout + assert "cli-audit: ble.sh" not in proc.stdout + + def test_bashrc_removal_preserves_content_when_end_marker_missing(self, tmp_path): + # A begin marker without its matching end marker (tampering / interrupted + # write) must NOT cause removal to delete everything that follows it. + bashrc = tmp_path / ".bashrc" + bashrc.write_text("keepA\n" "# >>> cli-audit: ble.sh >>>\n" "orphan\n" "keepB\n") + proc = self._run_sourced(tmp_path, 'remove_bashrc_block; cat "$HOME/.bashrc"') + assert proc.returncode == 0, proc.stderr + # Unbalanced block is left intact; trailing user content survives. + assert "keepA" in proc.stdout + assert "keepB" in proc.stdout