Skip to content

feat(scripts): hook that points a missing command at the catalog - #159

Merged
CybotTM merged 1 commit into
mainfrom
feat/missing-tool-hook
Sep 23, 2026
Merged

CybotTM merged 1 commit into
mainfrom
feat/missing-tool-hook

Conversation

@CybotTM

@CybotTM CybotTM commented Sep 23, 2026

Copy link
Copy Markdown
Member

Merging this adds the cli-tools plugin's hook. When a Bash call fails with command not found, Claude gets the catalog entry that provides the binary and the exact install command in its context (rg → catalog entry ripgrep…/scripts/install_tool.sh ripgrep install).

Why a new hook rather than the fork's. netresearch/cli-tools-skill shipped one, and against the hooks reference it could never have fired:

  • 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 of the input. The failure text actually arrives in error (PostToolUseFailure) or in tool_response.stderr (PostToolUse).
  • It printed to stdout, which these events do not pass to the model. Text reaches the model only through hookSpecificOutput.additionalContext.

Given either documented payload, the fork's script prints nothing; that was measured, not inferred.

How this one works. hooks/hooks.json registers scripts/detect_missing_tool.py for both events on Bash:

  • PostToolUseFailure reads error.
  • PostToolUse reads tool_response.stderr. This covers a missing command inside a pipeline or list whose exit status is still 0.

The script matches only the lines shells print: bash, zsh (including (eval):1:) and dash forms, never the phrase anywhere in the output. It looks the binary up by file name and by binary_name in catalog/*.json. It uses only the standard library and fails open on any error, so it cannot break the call it observes.

Tests. tests/test_detect_missing_tool.py covers:

  • each message form, plus the real stderr of bash, sh and (where installed) zsh;
  • both payload shapes, including that PostToolUse reads stderr only;
  • the JSON output and the hooks.json registration.

With the fork's script in place, the output test fails: it emits nothing. Full suite 1019 passed / 2 skipped.

Independent of #158, which adds the plugin manifest and skill; the hook is loaded once both are on main and the plugin is installed from this repository.

Assisted by claude-code:claude-opus-5-5 — Session

https://claude.ai/code/session_01NxSeVq1hDnGBCqKLGcjQ6m

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 <info@sebastianmendel.de>
Copilot AI lite review requested due to automatic review settings September 23, 2026 08:54
@coderabbitai

coderabbitai Bot commented Sep 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 45 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 7805e931-6323-4bdd-b998-ff6b8914da30

📥 Commits

Reviewing files that changed from the base of the PR and between cf67ad0 and bae273c.

📒 Files selected for processing (4)
  • hooks/hooks.json
  • scripts/AGENTS.md
  • scripts/detect_missing_tool.py
  • tests/test_detect_missing_tool.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@CybotTM

CybotTM commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

Review before merge

CodeRabbit refused this head as rate limited and Copilot is out of quota, so the review of bae273c is mine — plus one live run, because unit tests of a hook prove the script, not that Claude Code calls it.

Live, in a real session. With this branch rebased locally onto main (so the plugin manifest from #158 is present) and loaded via claude -p --plugin-dir, the model ran bash -c 'PATH=/nonexistent; rg --version'. That exited 127, and the model quoted the context it received word for word:

rg is not installed (provided by catalog entry ripgrep). Check type -P -a rg and hash -r first in case it is only off PATH; otherwise install it with …/scripts/install_tool.sh ripgrep install. The cli-tools skill covers the rest of the workflow.

So PostToolUseFailure fires for the failed Bash call, ${CLAUDE_PLUGIN_ROOT} resolves in the command, and additionalContext reaches the model.

Code. Only lines shells print are matched, never the phrase anywhere in the output. A command name must start alphanumeric, so paths such as ./x are excluded. Catalog lookup is by file name first, then binary_name. Any exception exits 0 with no output, so the hook fails open. PostToolUse reads stderr only, which a test pins.

21 checks pass, 0 fail; 0 unresolved threads; full suite 1019 passed / 2 skipped locally.

Assisted by claude-code:claude-opus-5-5 — Session

@CybotTM

CybotTM commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

Self-review: bae273c

The bot review this pull request demands is unsatisfiable (Copilot quota wall or repeated bot failures on this head). The diff on this head was reviewed by the PR author; this comment is the on-the-record attestation the merge gate reads back. It stops matching on the next push.

@CybotTM
CybotTM merged commit dd92e2b into main Sep 23, 2026
25 of 26 checks passed
@CybotTM
CybotTM deleted the feat/missing-tool-hook branch September 23, 2026 09:09
CybotTM added a commit to netresearch/skill-repo-skill that referenced this pull request Sep 23, 2026
Merging this documents two things the `cli-tools` move had to work out
by hand. A new `references/plugin-hooks.md` covers how a plugin hook
actually reaches the model. A **Relocation** section in
`skill-retirement.md` covers moving a skill to another repository
instead of retiring it.

**Plugin hooks.** `cli-tools-skill`'s hook could never have fired, and
no check noticed:

- It was registered for `PostToolUse`, which a failed command does not
trigger; `PostToolUseFailure` does.
- It read keys the input does not carry.
- It printed to stdout, which the model never sees.

The page states which event to pick, where each event carries the text,
and that only `hookSpecificOutput.additionalContext` reaches the model.
It also gives a live check with `claude -p --plugin-dir` that unit tests
cannot replace; that check is how the replacement hook
([coding_agent_cli_toolset#159](netresearch/coding_agent_cli_toolset#159))
was verified.

**Relocation.** The section gives the order: port first, verify the new
plugin live, then decide the version source. Without `version`, Claude
Code versions the plugin by commit SHA, and such a repository does not
belong in the release fleet (#346). After that come repointing, the
moved notice and archiving. Existing installations switch over only
after `claude plugin marketplace update` followed by `plugin update`;
measured on `cli-tools`, 1.9.2 → commit SHA.

`validate-skill.sh .` reports 0 errors.

_Assisted by claude-code:claude-opus-5-5 —
[Session](https://claude.ai/code/session_01NxSeVq1hDnGBCqKLGcjQ6m)_

https://claude.ai/code/session_01NxSeVq1hDnGBCqKLGcjQ6m
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants