@fnrhombus/claude-code-hooks
v0.1.1
Published
Strongly-typed TypeScript wrapper for Claude Code hook entrypoints. Write hook scripts as typed functions instead of JSON.parse(process.stdin).
Maintainers
Readme
@fnrhombus/claude-code-hooks
Write Claude Code hooks as typed functions. Not JSON pipelines.
// your-hook.ts
import { runHook, updateInput } from "@fnrhombus/claude-code-hooks";
runHook({
preToolUse: {
Bash: ({ tool_input }) =>
updateInput({ command: fixPaths(tool_input.command) }),
},
});That's the whole hook. No process.stdin.on('data'), no JSON.parse, no branching on hook_event_name, no assembling a hookSpecificOutput envelope by hand. The wrapper handles all of it — including per-tool narrowing, exit codes, and the universal output fields.
Currently the only TypeScript surface for Claude Code's hook API. Types stay in sync with the upstream docs automatically (see How it stays fresh).
Why
Claude Code hooks are a thin protocol: read JSON from stdin, write JSON to stdout, exit with a code. Simple — until you write a second one and realize you're copy-pasting the same JSON.parse / JSON.stringify / event dispatcher every time, and the only way to know what fields exist on a PreToolUse payload is to re-read the docs.
Today you write this:
let raw = "";
process.stdin.on("data", c => raw += c);
process.stdin.on("end", () => {
const input = JSON.parse(raw);
if (input.hook_event_name === "PreToolUse" && input.tool_name === "Bash") {
const fixed = fixPaths(input.tool_input.command);
if (fixed !== input.tool_input.command) {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
updatedInput: { command: fixed },
},
}));
}
}
});With @fnrhombus/claude-code-hooks:
import { runHook, updateInput, allow } from "@fnrhombus/claude-code-hooks";
runHook({
preToolUse: {
Bash: ({ tool_input }) => {
const fixed = fixPaths(tool_input.command);
return fixed === tool_input.command
? allow()
: updateInput({ command: fixed });
},
},
});Fewer lines, fully typed, impossible to misspell hookEventName or forget the envelope.
Features
- Typed everything. Every hook event, every built-in tool's
tool_input, every output field. 26 events × 11 tools × 12 output shapes, all derived from upstream docs. - Per-tool narrowing.
preToolUse.Bashseestool_input.commandas astring, notunknown.preToolUse.Editseesfile_path,old_string,new_string. No manual casts. - Zero-config dispatcher. One call —
runHook({ ... })— handles stdin parsing, routing by event, envelope assembly, stdout serialization, and exit codes. - Shorthand helpers.
allow(),deny(reason),updateInput({...}),block(reason),halt(reason),addContext(text)— no more hand-rolling the output JSON. - Escape hatches.
throw new HookBlock(reason)exits with code 2 (blocking). Every handler can also return universal fields likecontinue: false. Returnundefinedorvoidfor no-op. - Dual package. ESM + CJS both work. Bring-your-own runtime: Node 20+.
- Automatically fresh. The type definitions are regenerated from the upstream docs and published on every upstream change — see How it stays fresh.
Install
npm i @fnrhombus/claude-code-hooks
pnpm add @fnrhombus/claude-code-hooks
yarn add @fnrhombus/claude-code-hooksRequires Node 20+. No runtime dependencies.
60-second tour
Rewrite a command before it runs (the @fnrhombus/claude-code-pathfix use case):
import { runHook, updateInput } from "@fnrhombus/claude-code-hooks";
runHook({
preToolUse: {
Bash: ({ tool_input }) => {
const fixed = tool_input.command.replace(/^cd C:\\/, "cd /c/");
return { updatedInput: { command: fixed } };
},
},
});Deny dangerous commands:
import { runHook, deny, allow } from "@fnrhombus/claude-code-hooks";
runHook({
preToolUse: {
Bash: ({ tool_input }) =>
/rm\s+-rf\s+\//.test(tool_input.command)
? deny("refusing to rm -rf /")
: allow(),
},
});Inject context on session start:
import { runHook, addContext } from "@fnrhombus/claude-code-hooks";
runHook({
sessionStart: () => addContext(`Build status: ${getCIStatus()}`),
});Handle multiple events in one hook:
import { runHook, allow, addContext } from "@fnrhombus/claude-code-hooks";
runHook({
sessionStart: () => addContext("welcome back"),
preToolUse: {
Bash: ({ tool_input }) => lintCommand(tool_input.command),
Edit: ({ tool_input }) => guardProtectedFiles(tool_input.file_path),
default: () => allow(),
},
userPromptSubmit: ({ prompt }) =>
prompt.includes("API_KEY") ? { block: "prompt contains a secret" } : undefined,
});Block stopping when work is unfinished:
import { runHook, block } from "@fnrhombus/claude-code-hooks";
runHook({
stop: () => hasUnfinishedWork() ? block("tests still failing") : undefined,
});How it stays fresh
The upstream hooks API is documented as markdown. This repo bakes the SHA256 of that markdown into the first line of packages/core/src/types.ts. A scheduled job runs scripts/dev-cycle once a day:
- Fetch
hooks.md, compare its hash to the one intypes.ts - If unchanged, exit immediately (fully deterministic, no LLM reasoning)
- If changed:
- File a tracking GitHub issue
- Create a feature branch + worktree
- Regenerate
types.tsvia theregen-hook-typesClaude skill - Run the full test suite and fix wrapper code until it passes (with retry + research phases)
- Push, open a PR, wait for CI, run an automated PR review, merge
- Clean up the branch and worktree
- If the loop gives up, assign the issue to @fnrhombus and log an entry to
BLOCKERS.md
The effect: users never need to know when Anthropic updates the hook API. Semver-compatible changes go straight to npm; breaking changes become a version bump in a PR that merges itself on green.
What's in the box
| Export | Purpose |
|---|---|
| runHook(handlers) | Main entrypoint — reads stdin, dispatches, writes stdout, exits |
| dispatch(handlers, input) | Pure function version — no I/O, good for tests |
| allow(), deny(reason), ask(reason), defer(reason?) | PreToolUse decisions |
| updateInput(input) | Rewrite the tool input in-flight (no retry cost) |
| block(reason) | Block with a reason (UserPromptSubmit, PostToolUse, Stop, etc.) |
| halt(reason) | Stop Claude entirely (universal) |
| addContext(text) | Inject text into Claude's context (SessionStart, UserPromptSubmit, …) |
| setSessionTitle(title) | Set the session title |
| HookBlock | throw new HookBlock(reason) → exit 2 with reason |
| type HookInput, type HookOutput, type HookHandlers, … | Every type you could want — see types.ts |
Related
- fnrhombus/claude-code-pathfix — the hook that inspired this package. Transparently rewrites Windows paths to POSIX in Bash commands so Claude Code on Windows stops wasting tokens on retry loops. Uses this library as its typed backbone.
License
MIT © fnrhombus. See LICENSE.
