hooklib: router + nix-deploy-output PreToolUse hook

This commit is contained in:
oleks
2026-07-06 16:43:25 +03:00
commit 6e493cd8db
6 changed files with 183 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"name": "hooklib",
"version": "0.1.0",
"description": "A library of small, composable Claude Code hooks. Each hook is a drop-in script under hooks/<event>.d/ dispatched by a shared router. Ships with nix-deploy-output: a PreToolUse guard that rewrites/blocks bare `nix run .#deploy` (deploy-rs) invocations so their build/activation logs actually show up in Claude Code instead of rendering as empty output — it requires the command force plain, line-buffered logs via `2>&1 | cat`.",
"author": {
"name": "oleks",
"email": "plugins@oleks.space"
},
"license": "MIT",
"keywords": [
"hooks",
"pretooluse",
"nix",
"deploy-rs",
"bash",
"output",
"guardrail"
]
}
+3
View File
@@ -0,0 +1,3 @@
.cache/
.claude/
*.log
+58
View File
@@ -0,0 +1,58 @@
# hooklib
A small **library of composable Claude Code hooks**. Each hook is a drop-in
script; a shared router dispatches one event to all of them. Adding a hook is
just dropping an executable script into the matching directory — no wiring.
## Layout
```
hooks/
hooks.json # registers the router per event (currently PreToolUse:Bash)
dispatch.sh # router: feeds the event JSON to every hooks/<event>.d/*.sh
pretooluse.d/
10-nix-deploy-output.sh
```
### How dispatch works
`dispatch.sh <Event>` reads the event JSON from stdin once and replays it to
each script in `hooks/<event>.d/` (lexical order). A sub-hook that wants to act
prints a hook JSON response to stdout and exits 0; the **first** sub-hook to
emit non-empty output wins and the router forwards it verbatim. If nobody
speaks up, the router is silent (Claude Code treats that as "allow").
Scripts are numbered (`10-`, `20-`, …) to make ordering explicit.
## Hooks
### `nix-deploy-output` (PreToolUse / Bash)
**Problem:** `nix run .#deploy` (deploy-rs) shows **empty output** in Claude
Code. deploy-rs and nix render progress as an ANSI spinner on a TTY and log to
stderr; captured non-interactively, that collapses to a blank.
**Fix:** the command must fold stderr into stdout and defeat TTY detection so
the tools emit plain, line-buffered logs. The canonical form is:
```
nix run .#deploy 2>&1 | cat
```
The hook inspects any `nix run .#deploy…` command. If it is already in the
output-safe form (`2>&1` **and** a pipe into `cat`/`tee`/`less`) it passes
through untouched. Otherwise it **denies** the call and returns the corrected
command (preserving any `--` flags / host target) for Claude to run instead.
## Adding another hook
Drop `hooks/<event>.d/NN-name.sh` (executable) that reads the event JSON on
stdin and prints a hook response only when it wants to intervene. If the event
isn't `PreToolUse`, add it to `hooks/hooks.json` pointing at
`dispatch.sh <Event>`.
## Install (local dev)
```
claude plugin install hooklib@oleks-local
```
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# hooklib dispatcher — routes one Claude Code hook event to every drop-in
# script under hooks/<event>.d/, in lexical order.
#
# Usage (from hooks.json): dispatch.sh <EventName>
#
# The event JSON is read once from stdin and replayed to each sub-hook on
# its own stdin. A sub-hook that wants to act prints a hook JSON response
# (e.g. a PreToolUse permissionDecision) to stdout and exits 0. The FIRST
# sub-hook to emit non-empty stdout wins: the dispatcher forwards that
# response verbatim and stops. If no sub-hook emits anything, the dispatcher
# stays silent and exits 0, which Claude Code treats as "no opinion / allow".
#
# Adding a hook is just dropping an executable script into the matching
# hooks/<event>.d/ directory — no wiring changes required.
set -euo pipefail
event="${1:?dispatch.sh needs an event name}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
dir="${here}/$(printf '%s' "$event" | tr '[:upper:]' '[:lower:]').d"
payload="$(cat)"
[ -d "$dir" ] || exit 0
for hook in "$dir"/*.sh; do
[ -e "$hook" ] || continue
out="$(printf '%s' "$payload" | bash "$hook" || true)"
if [ -n "$out" ]; then
printf '%s\n' "$out"
exit 0
fi
done
exit 0
+15
View File
@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch.sh PreToolUse"
}
]
}
]
}
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# nix-deploy-output — make `nix run .#deploy` (deploy-rs) logs visible.
#
# Symptom this fixes: running `nix run .#deploy` in Claude Code shows an
# EMPTY command output. deploy-rs and nix draw their progress as an ANSI
# spinner on a TTY and send activation logs to stderr; when Claude Code
# captures that, the fancy redraw collapses to nothing and you see a blank.
#
# The fix is to force plain, line-buffered output that also captures stderr:
# piping through `cat` makes nix/deploy-rs detect "not a TTY" and emit plain
# log lines, and `2>&1` folds stderr (where deploy-rs logs live) into stdout.
#
# This hook is a PreToolUse guard on Bash. When it sees a deploy invocation
# that is NOT already in the output-safe form, it DENIES the call and hands
# Claude the exact corrected command to run instead. Commands already ending
# in `2>&1 | cat` (or `2>&1 |cat`) pass through untouched.
set -euo pipefail
payload="$(cat)"
cmd="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
[ -n "$cmd" ] || exit 0
# Only care about deploy-rs runs: `nix run .#deploy`, `nix run '.#deploy'`,
# `nix run ".#deploy"`, optionally with a target like `.#deploy.host`.
printf '%s' "$cmd" | grep -Eq "nix run [\"']?\.#deploy" || exit 0
# Already output-safe? Must fold stderr into stdout AND defeat TTY detection.
if printf '%s' "$cmd" | grep -Eq '2>&1' \
&& printf '%s' "$cmd" | grep -Eq '\|[[:space:]]*(cat|tee|less -R|less)([[:space:]]|$)'; then
exit 0
fi
# Build the corrected command. Strip any trailing pipeline/redirection the
# model may have already appended, then re-add the canonical suffix.
base="$(printf '%s' "$cmd" | sed -E 's/[[:space:]]*(2>&1)?[[:space:]]*(\|[[:space:]]*(cat|tee[^|]*|less[^|]*))?[[:space:]]*$//')"
fixed="${base} 2>&1 | cat"
reason="deploy-rs / nix draw progress as an ANSI TTY spinner and log to stderr, which Claude Code renders as EMPTY output. Re-run with stderr folded in and output piped through cat to force plain, line-buffered logs:
${fixed}
(hooklib:nix-deploy-output)"
jq -cn --arg r "$reason" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'