Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
]
}
}
3 changes: 3 additions & 0 deletions scripts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
111 changes: 111 additions & 0 deletions scripts/detect_missing_tool.py
Original file line number Diff line number Diff line change
@@ -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<name>[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:

Check failure on line 96 in scripts/detect_missing_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to not always return the same value.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AaDNeguUfOJ38_iNk9aW&open=AaDNeguUfOJ38_iNk9aW&pullRequest=159
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())
119 changes: 119 additions & 0 deletions tests/test_detect_missing_tool.py
Original file line number Diff line number Diff line change
@@ -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()
Loading