From bae273ca49d0831decf3e23968779570ba84c2e8 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 23 Sep 2026 10:54:36 +0200 Subject: [PATCH] feat(scripts): hook that points a missing command at the catalog When a Bash call fails with "command not found", the plugin hook adds the catalog entry that provides the binary and the install command to Claude's context: `rg` -> catalog entry `ripgrep`, `install_tool.sh ripgrep install`. The cli-tools-skill fork had such a hook, and it could never have fired. Measured against the Claude Code hooks reference and by feeding it the documented payloads: - it was registered for PostToolUse only, which fires after a successful call; a missing command exits 127 and fires PostToolUseFailure; - it read `output`/`stdout`/`stderr` at the top level, where the input has `error` (PostToolUseFailure) or `tool_response.stderr` (PostToolUse); - it printed to stdout, which these events do not pass to the model; the text has to go into hookSpecificOutput.additionalContext. Given either documented payload it prints nothing. This hook listens on both events: PostToolUseFailure reads `error`, and PostToolUse reads `tool_response.stderr` for a missing command inside a pipeline or list that still succeeded as a whole. It matches only the lines shells print -- bash, zsh (including `(eval):1:`) and dash forms -- not the phrase anywhere in the output, looks the binary up by file name and `binary_name` in catalog/*.json, and fails open on any error. tests/test_detect_missing_tool.py covers each message form, the real stderr of bash, sh and (where installed) zsh, both payload shapes, the JSON output and the hooks.json registration. With the fork's script in place, the output test fails: it emits nothing. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01NxSeVq1hDnGBCqKLGcjQ6m Agent-Host: 32116e Signed-off-by: Sebastian Mendel --- hooks/hooks.json | 29 ++++++++ scripts/AGENTS.md | 3 + scripts/detect_missing_tool.py | 111 ++++++++++++++++++++++++++++ tests/test_detect_missing_tool.py | 119 ++++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 hooks/hooks.json create mode 100755 scripts/detect_missing_tool.py create mode 100644 tests/test_detect_missing_tool.py diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..eec73e7 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,29 @@ +{ + "description": "Point a command-not-found failure at the cli-tools catalog", + "hooks": { + "PostToolUseFailure": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/detect_missing_tool.py\"", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/detect_missing_tool.py\"", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index a98a8a9..86a638c 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -52,6 +52,9 @@ **Bash completion:** - `install_completion.sh`: Install/remove a tool's bash completion; `--all` backfills every declared tool +**Claude Code plugin hook:** +- `detect_missing_tool.py`: registered in `hooks/hooks.json` for `PostToolUseFailure` and `PostToolUse` on Bash; turns a shell "command not found" into the catalog entry and install command, passed to the model as `additionalContext`. Standard library only, fails open + **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` diff --git a/scripts/detect_missing_tool.py b/scripts/detect_missing_tool.py new file mode 100755 index 0000000..f734e83 --- /dev/null +++ b/scripts/detect_missing_tool.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Claude Code hook: point a failed command at the cli-tools catalog. + +Registered in hooks/hooks.json for PostToolUseFailure and PostToolUse on Bash. +A command that is not installed makes the shell print a "command not found" +line; this hook finds it, looks the binary up in catalog/*.json and adds the +install command to Claude's context. + +- PostToolUseFailure carries the text in ``error`` ("Exit code 127" and then + the command's interleaved output). A missing command usually ends up here. +- PostToolUse carries ``tool_response.stderr``; it matters when the failing + command is not the last one in a pipeline or list, so the call as a whole + still succeeded. + +Plain stdout from these events never reaches the model; the text has to go +into ``hookSpecificOutput.additionalContext``. Standard library only, and any +failure is silent: a hook must never break the tool call it observes. +""" + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# The forms shells print, one per line: +# bash: line 1: rg: command not found /bin/bash: rg: command not found +# zsh: command not found: rg (eval):1: command not found: rg +# sh: 1: rg: not found (dash) +_NAME = r"(?P[A-Za-z0-9][A-Za-z0-9._+-]*)" +PATTERNS = [ + re.compile(rf"^\S*sh(?:: line \d+)?: {_NAME}: command not found$", re.M), + re.compile(rf"command not found: {_NAME}$", re.M), + re.compile(rf"^\S*sh: \d+: {_NAME}: not found$", re.M), +] + + +def missing_commands(text: str) -> list[str]: + """Command names the shell reported as not found, in order, de-duplicated.""" + found: list[str] = [] + for pattern in PATTERNS: + for match in pattern.finditer(text): + if match.group("name") not in found: + found.append(match.group("name")) + return found + + +def catalog_entry(binary: str, catalog: Path) -> str | None: + """The catalog entry that provides ``binary``, or None.""" + if (catalog / f"{binary}.json").is_file(): + return binary + for path in sorted(catalog.glob("*.json")): + try: + if json.loads(path.read_text()).get("binary_name") == binary: + return path.stem + except (OSError, ValueError, AttributeError): + continue + return None + + +def advice(binary: str, root: Path) -> str: + entry = catalog_entry(binary, root / "catalog") + if entry is None: + return ( + f"`{binary}` is not installed and has no entry in the cli-tools catalog. " + "Check `type -P -a` and `hash -r` first; the cli-tools skill lists alternatives " + "and troubleshooting." + ) + install = root / "scripts" / "install_tool.sh" + via = "" if entry == binary else f" (provided by catalog entry `{entry}`)" + return ( + f"`{binary}` is not installed{via}. Check `type -P -a {binary}` and `hash -r` first " + f"in case it is only off PATH; otherwise install it with `{install} {entry} install`. " + "The cli-tools skill covers the rest of the workflow." + ) + + +def context_for(event: dict, root: Path = ROOT) -> str | None: + if event.get("tool_name") != "Bash": + return None + name = event.get("hook_event_name") + if name == "PostToolUseFailure": + text = event.get("error") or "" + elif name == "PostToolUse": + response = event.get("tool_response") or {} + text = (response.get("stderr") or "") if isinstance(response, dict) else "" + else: + return None + binaries = missing_commands(text) + if not binaries: + return None + return "\n".join(advice(b, root) for b in binaries) + + +def main() -> int: + try: + event = json.load(sys.stdin) + context = context_for(event) + except Exception: # noqa: BLE001 - a hook must fail open + return 0 + if context: + json.dump( + {"hookSpecificOutput": {"hookEventName": event["hook_event_name"], "additionalContext": context}}, + sys.stdout, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_detect_missing_tool.py b/tests/test_detect_missing_tool.py new file mode 100644 index 0000000..ccec4ca --- /dev/null +++ b/tests/test_detect_missing_tool.py @@ -0,0 +1,119 @@ +"""The plugin hook that points a command-not-found failure at the catalog. + +The hook input shapes follow the Claude Code hooks reference: PostToolUseFailure +carries the failure text in ``error``, PostToolUse carries ``tool_response`` +with ``stdout``/``stderr``, and only ``hookSpecificOutput.additionalContext`` +reaches the model. +""" + +import importlib.util +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = PROJECT_ROOT / "scripts" / "detect_missing_tool.py" + +spec = importlib.util.spec_from_file_location("detect_missing_tool", SCRIPT) +hook = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hook) + +skip_on_windows = pytest.mark.skipif(sys.platform == "win32", reason="Shell script tests require POSIX shell") + + +def _failure(text: str) -> dict: + return {"hook_event_name": "PostToolUseFailure", "tool_name": "Bash", "error": f"Exit code 127\n{text}"} + + +def _run_hook(event: dict | str) -> subprocess.CompletedProcess: + data = event if isinstance(event, str) else json.dumps(event) + return subprocess.run([sys.executable, str(SCRIPT)], input=data, capture_output=True, text=True) + + +class TestMessageForms: + @pytest.mark.parametrize( + "line", + [ + "/bin/bash: line 1: zzq: command not found", + "bash: zzq: command not found", + "zsh: command not found: zzq", + "(eval):1: command not found: zzq", + "sh: 1: zzq: not found", + ], + ) + def test_shell_forms_name_the_command(self, line): + assert hook.missing_commands(f"some output\n{line}\nmore") == ["zzq"] + + @pytest.mark.parametrize( + "text", + [ + "bash: line 1: ./zzq: No such file or directory", + "see the 'command not found' section of the docs", + "grep: zzq: No such file or directory", + ], + ) + def test_other_failures_are_ignored(self, text): + assert hook.missing_commands(text) == [] + + @skip_on_windows + @pytest.mark.parametrize("shell", ["bash", "sh", "zsh"]) + def test_real_shell_output(self, shell): + if shutil.which(shell) is None: + pytest.skip(f"{shell} not installed") + proc = subprocess.run([shell, "-c", "zzq-not-a-command --version"], capture_output=True, text=True) + assert proc.returncode == 127 + assert hook.missing_commands(proc.stderr) == ["zzq-not-a-command"], proc.stderr + + +class TestContext: + def test_failure_names_the_catalog_entry_and_the_installer(self): + context = hook.context_for(_failure("/bin/bash: line 1: rg: command not found")) + assert "catalog entry `ripgrep`" in context + assert f"{PROJECT_ROOT / 'scripts' / 'install_tool.sh'} ripgrep install" in context + + def test_uncataloged_command_says_so(self): + context = hook.context_for(_failure("bash: zzq: command not found")) + assert "no entry in the cli-tools catalog" in context + + def test_post_tool_use_reads_stderr_only(self): + event = { + "hook_event_name": "PostToolUse", + "tool_name": "Bash", + "tool_response": {"stdout": "bash: rg: command not found", "stderr": ""}, + } + assert hook.context_for(event) is None + event["tool_response"] = {"stdout": "", "stderr": "bash: rg: command not found"} + assert "ripgrep" in hook.context_for(event) + + def test_other_tools_are_ignored(self): + event = _failure("bash: rg: command not found") | {"tool_name": "Read"} + assert hook.context_for(event) is None + + +class TestMain: + def test_emits_additional_context_for_the_event(self): + proc = _run_hook(_failure("bash: rg: command not found")) + assert proc.returncode == 0 + out = json.loads(proc.stdout)["hookSpecificOutput"] + assert out["hookEventName"] == "PostToolUseFailure" + assert "ripgrep" in out["additionalContext"] + + @pytest.mark.parametrize("data", ["not json", "{}", json.dumps(_failure("all fine"))]) + def test_prints_nothing_and_exits_zero_otherwise(self, data): + proc = _run_hook(data) + assert (proc.returncode, proc.stdout, proc.stderr) == (0, "", "") + + +class TestRegistration: + def test_both_events_run_the_script(self): + config = json.loads((PROJECT_ROOT / "hooks" / "hooks.json").read_text())["hooks"] + for event in ("PostToolUseFailure", "PostToolUse"): + (entry,) = config[event] + assert entry["matcher"] == "Bash" + (command,) = entry["hooks"] + assert command["command"] == 'python3 "${CLAUDE_PLUGIN_ROOT}/scripts/detect_missing_tool.py"' + assert SCRIPT.is_file()