Skip to content

Repository files navigation

@fnrhombus/claude-code-hooks

Write Claude Code hooks as typed functions. Not JSON pipelines.

npm ci types license

// 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.Bash sees tool_input.command as a string, not unknown. preToolUse.Edit sees file_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 like continue: false. Return undefined or void for 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-hooks

Requires 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:

  1. Fetch hooks.md, compare its hash to the one in types.ts
  2. If unchanged, exit immediately (fully deterministic, no LLM reasoning)
  3. If changed:
    • File a tracking GitHub issue
    • Create a feature branch + worktree
    • Regenerate types.ts via the regen-hook-types Claude 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
  4. 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

  • fnclaude/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.

Support

About

Strongly-typed TypeScript wrapper for Claude Code hook entrypoints. Write hook scripts as typed functions instead of JSON.parse(process.stdin).

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

Generated from fntemplate/npm