PostToolUse Hook for BSD/GNU Tool Miss Detection¶
A
PostToolUseorPostToolUseFailurehook can catch "command not found" and BSD/GNU incompatibility errors the moment they occur, feed the fix back to the agent viaadditionalContext, and optionally update CLAUDE.md for the next session.
The Problem¶
macOS ships with BSD versions of core utilities. GNU and BSD implementations diverge in ways that silently break agent-generated shell commands:
| Tool | BSD (macOS) | GNU (Linux) |
|---|---|---|
grep |
No -P (PCRE) flag |
-P supported |
sed |
-i '' requires empty-string argument |
-i takes no argument |
date |
-d not supported |
-d parses date strings |
xargs |
No -r (no-run-if-empty) |
-r supported |
The agent doesn't know which variant is present. CLAUDE.md instructions help but must be written before the problem is observed. A runtime hook catches the failure and corrects it in the same session.
Hook Events¶
Two events are relevant:
| Event | When it fires | Has tool_response |
|---|---|---|
PostToolUse |
After a successful Bash call | Yes — full stdout/stderr |
PostToolUseFailure |
After a non-zero exit | No — only error string |
PostToolUseFailure is the right event for "command not found" (exit 127) and most BSD/GNU errors (non-zero exits). PostToolUse can catch cases where the command succeeds but produces an error-like message on stderr.
Both PostToolUse and PostToolUseFailure hooks can return additionalContext in their JSON output to inject information into the agent's context without blocking the tool call.
Implementation¶
1. Detect and Return additionalContext¶
#!/bin/bash
# .claude/hooks/detect-cli-compat.sh
INPUT=$(cat)
ERROR=$(echo "$INPUT" | jq -r '.error // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Check for command not found
if echo "$ERROR" | grep -q "command not found"; then
MISSING=$(echo "$ERROR" | grep -oE '[^:]+: command not found' | awk '{print $1}')
jq -n --arg msg "Command not found: $MISSING. Check if it needs to be installed (e.g. brew install $MISSING) or use an available alternative." \
'{additionalContext: $msg}'
exit 0
fi
# Check for known BSD/GNU incompatibilities
if echo "$ERROR" | grep -qE "invalid option|illegal option|unrecognized option"; then
# BSD grep -P failure
if echo "$COMMAND" | grep -q "grep.*-P"; then
jq -n '{additionalContext: "BSD grep does not support -P (PCRE). Use grep -E for extended regex, or install GNU grep via `brew install grep` and use `ggrep -P`."}'
exit 0
fi
# BSD sed -i without argument
if echo "$COMMAND" | grep -qE "sed.*-i[^' ]"; then
jq -n '{additionalContext: "BSD sed requires an extension argument with -i. Use `sed -i '\'''\'' ...` on macOS, or `sed -i ...` on Linux."}'
exit 0
fi
fi
exit 0
Register in .claude/settings.json:
{
"hooks": {
"PostToolUseFailure": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/detect-cli-compat.sh"
}
]
}
]
}
}
2. Once-Per-Session Enforcement¶
To avoid repeating the same advice every invocation, gate the response on a sentinel file keyed to session_id. The session_id field is present in every hook input:
#!/bin/bash
# .claude/hooks/detect-cli-compat.sh
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
ERROR=$(echo "$INPUT" | jq -r '.error // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
SENTINEL_DIR="$HOME/.claude/sessions"
SENTINEL="$SENTINEL_DIR/${SESSION_ID}.compat-notified"
# Determine if we have a match
MESSAGE=""
if echo "$ERROR" | grep -q "command not found"; then
MISSING=$(echo "$ERROR" | grep -oE '\S+: command not found' | awk '{print $1}' | tr -d ':')
MESSAGE="Command not found: $MISSING. Install via \`brew install $MISSING\` or use a built-in alternative."
elif echo "$COMMAND" | grep -q "grep.*-P" && echo "$ERROR" | grep -q "invalid option"; then
MESSAGE="BSD grep lacks -P. Use grep -E or install GNU grep: \`brew install grep\` then use \`ggrep -P\`."
elif echo "$COMMAND" | grep -qE "sed.*-i[^' ]" && echo "$ERROR" | grep -q "option"; then
MESSAGE="BSD sed -i requires an empty-string arg on macOS: \`sed -i '' ...\`."
fi
[ -z "$MESSAGE" ] && exit 0
# Once-per-session: skip if already notified
mkdir -p "$SENTINEL_DIR"
[ -f "$SENTINEL" ] && exit 0
touch "$SENTINEL"
jq -n --arg msg "$MESSAGE" '{additionalContext: $msg}'
once: true is only available for skills, not command hooks — the sentinel file is the correct pattern for session-scoped state.
3. Persist to CLAUDE.md¶
After notifying the agent, optionally write the fix to CLAUDE.md for future sessions:
# Append to project CLAUDE.md (only if not already present)
NOTE="- macOS: BSD grep lacks -P; use grep -E or ggrep. BSD sed -i requires empty arg: sed -i '' ..."
if ! grep -qF "BSD grep" "$CLAUDE_PROJECT_DIR/CLAUDE.md" 2>/dev/null; then
echo "" >> "$CLAUDE_PROJECT_DIR/CLAUDE.md"
echo "$NOTE" >> "$CLAUDE_PROJECT_DIR/CLAUDE.md"
fi
Important constraint: CLAUDE.md is loaded at session start, not on every turn. Writes mid-session take effect next session — use additionalContext for immediate in-session feedback and CLAUDE.md for cross-session persistence.
Flow¶
sequenceDiagram
participant Agent
participant Shell
participant Hook
participant CLAUDE.md
Agent->>Shell: bash grep -P "pattern" file.txt
Shell-->>Hook: PostToolUseFailure (exit 1, error: "invalid option")
Hook->>Hook: Match error pattern
Hook->>Hook: Check sentinel file
Hook->>Hook: Write sentinel file
Hook-->>Agent: additionalContext: "BSD grep lacks -P..."
Hook->>CLAUDE.md: Append note (takes effect next session)
Agent->>Shell: bash grep -E "pattern" file.txt
Auto-Remediation via Homebrew¶
The hook can run brew install as a side-effect:
if echo "$ERROR" | grep -q "command not found"; then
MISSING=$(echo "$ERROR" | grep -oE '\S+: command not found' | awk '{print $1}' | tr -d ':')
if command -v brew &>/dev/null && [ -n "$MISSING" ]; then
brew install "$MISSING" &>/dev/null &
MESSAGE="$MISSING not found — installing via Homebrew in background. Retry in a moment."
fi
fi
This is viable — hooks can spawn subprocesses — but only opt in if you accept hooks installing system packages without normal review.
Relationship to PreToolUse Prevention¶
The official bash_command_validator example uses a PreToolUse hook to rewrite commands before they run — e.g., redirecting grep to ripgrep. BSD/GNU divergence is a documented portability concern (GNU sed manual, GNU grep manual). These two approaches are complementary:
| Approach | Event | Mechanism | Use when |
|---|---|---|---|
| PreToolUse rewrite | PreToolUse |
Block + redirect | Known incompatibility, safe substitute exists |
| PostToolUse detection | PostToolUseFailure |
additionalContext | Unknown missing tool, discovered at runtime |
Static allowlisting catches known patterns. Runtime detection handles the cases that weren't anticipated.
When This Backfires¶
Runtime hook detection adds overhead to every failing Bash call. Three conditions make this pattern worse than the alternative:
- Overly broad pattern matching produces false positives. If the regex matches "invalid option" in output unrelated to BSD/GNU divergence, the agent receives misleading advice and may spend turns chasing the wrong fix. Test detection patterns against real failure output before deploying.
- Sentinel files break in ephemeral environments. Containerised CI runners, sandboxed shells, or environments where
$HOMEis not persistent will fail to find or create the sentinel file, causing the hook to emit the context message on every invocation rather than once per session. Use an in-process variable or skip once-per-session gating when$HOMEis not guaranteed. - The hook can mask the original error. When
additionalContextfires alongside a non-zero exit, the agent sees both the error and the advisory message. If the advisory message is confident ("use grep -E instead"), the agent may act on it even when the actual error has a different root cause, bypassing diagnostic steps. Keep advisory text conditional and hedged.
additionalContext delivery regressions¶
The entire mechanism assumes additionalContext from the hook reaches the model. That channel has regressed in recent Claude Code versions. Two open issues are worth checking before relying on the pattern:
- anthropics/claude-code#24788 (Feb 2026) —
PostToolUsehooks emittingadditionalContextproduce no visible output when triggered by MCP tool calls. - anthropics/claude-code#55889 (May 2026) — in v2.1.123, all three context-injection channels (
additionalContext,systemMessage, plain stdout) are dropped for hooks matchingBash— the same matcher this pattern uses.
Verify in your target version that a trivial hook (e.g. jq -n '{additionalContext: "ping"}' on a failing Bash call) actually surfaces to the model before assuming the mechanism is wired end-to-end. If it doesn't, fall back to writing the advisory to a file the next turn can read, or use the PreToolUse rewrite approach described earlier.
Key Takeaways¶
PostToolUseFailurereceives theerrorstring but nottool_response; match against both.errorand.tool_input.commandonce: trueis not available for command hooks — implement once-per-session logic with a sentinel file keyed tosession_id- Use
additionalContextfor immediate in-session feedback; write to CLAUDE.md for cross-session persistence — they are not interchangeable - CLAUDE.md writes take effect at the next session start, not in the current session
- Auto-remediation via
brew installis technically viable but installs system packages silently — opt into this deliberately
Related¶
- Hook Catalog: Guardrails, Sandboxing, and CLI Enforcement
- Hooks and Lifecycle Events: Intercepting Agent Behavior
- PostToolUse Hooks: Automatic Formatting and Linting After Every File Edit
- On-Demand Skill Hooks: Session-Scoped Hook Guardrails
- Conditional Hook Execution: Filter Hooks by Tool Pattern
- CLI Scripts as Agent Tools: Return Only What Matters
- Unix CLI as the Native Tool Interface for AI Agents