hooklib: router + nix-deploy-output PreToolUse hook
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.cache/
|
||||
.claude/
|
||||
*.log
|
||||
@@ -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
|
||||
```
|
||||
Executable
+36
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch.sh PreToolUse"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+52
@@ -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
|
||||
}
|
||||
}'
|
||||
Reference in New Issue
Block a user