From 6e493cd8dbf8f9f8c341f45cb18ca6c62e65dd69 Mon Sep 17 00:00:00 2001 From: oleks Date: Mon, 6 Jul 2026 16:43:25 +0300 Subject: [PATCH] hooklib: router + nix-deploy-output PreToolUse hook --- .claude-plugin/plugin.json | 19 +++++++ .gitignore | 3 ++ README.md | 58 ++++++++++++++++++++++ hooks/dispatch.sh | 36 ++++++++++++++ hooks/hooks.json | 15 ++++++ hooks/pretooluse.d/10-nix-deploy-output.sh | 52 +++++++++++++++++++ 6 files changed, 183 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 .gitignore create mode 100644 README.md create mode 100755 hooks/dispatch.sh create mode 100644 hooks/hooks.json create mode 100755 hooks/pretooluse.d/10-nix-deploy-output.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..e24d058 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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/.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" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cc0620 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.cache/ +.claude/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..94e8856 --- /dev/null +++ b/README.md @@ -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/.d/*.sh + pretooluse.d/ + 10-nix-deploy-output.sh +``` + +### How dispatch works + +`dispatch.sh ` reads the event JSON from stdin once and replays it to +each script in `hooks/.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/.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 `. + +## Install (local dev) + +``` +claude plugin install hooklib@oleks-local +``` diff --git a/hooks/dispatch.sh b/hooks/dispatch.sh new file mode 100755 index 0000000..5b3f74d --- /dev/null +++ b/hooks/dispatch.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# hooklib dispatcher — routes one Claude Code hook event to every drop-in +# script under hooks/.d/, in lexical order. +# +# Usage (from hooks.json): dispatch.sh +# +# 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/.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 diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..389df89 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch.sh PreToolUse" + } + ] + } + ] + } +} diff --git a/hooks/pretooluse.d/10-nix-deploy-output.sh b/hooks/pretooluse.d/10-nix-deploy-output.sh new file mode 100755 index 0000000..882634a --- /dev/null +++ b/hooks/pretooluse.d/10-nix-deploy-output.sh @@ -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 + } +}'