Test your Claude Code hooks like code: replay tool events against your merged settings, run the matching hooks, and fail CI when one doesn't fire or doesn't block.
pnpm add hookassertRequires Node.js 20 or newer. The package ships ESM only; on that range require(esm)
is unflagged, so a CommonJS consumer can require() it directly.
hookassert is a command. Ask it what it ships:
hookassert --helphookassert explain <event> [tool]shows which hooks a tool event fires, and why: every firing hook printed with its settings layer, absolute source file, and line, and every matcher that did not fire printed with the reason. It never spawns a process — the Claude Code version it runs against comes only from--claude-versionor theHOOKASSERT_CLAUDE_VERSIONenvironment variable, falling back to"undetermined".hookassert lintchecks hook declarations for matcher and command mistakes.hookassert recordcaptures real hook payloads from a Claude Code session.hookassert test <fixture>...replays one or more fixture files against your merged settings, runs the hooks that actually fire, and asserts what happened against each case's declaredexpect. It is the one command that spawns processes, so it asks for consent first: on a terminal it prints the exact commands about to run and waits for confirmation;--yesand--ciboth skip the prompt, and a non-interactive run given neither exits6withERR_CONSENT_REQUIREDinstead of running anything.--dry-runexcludes every case from the spawn plan;--claude-version,--settingsand--format(pretty/json/github) mean exactly what they do forexplain, and two options aretest's own:--timeout <ms>sets the default hook timeout in milliseconds for hooks that declare none (a fixture file's owndefaults.timeoutMsstill wins over it), and a repeatable--env <NAME>opts one ambient environment variable into every spawned hook's environment by name — a value is never accepted here, only a name. Exits0when every case passes (and, with--ci, none isunknowneither),1when at least one fails, and3when there are no failures but--ciwas given and at least one case isunknown.
lint and record have no behavior yet: each currently exits 4 with ERR_USAGE, so
a script that wires one up fails loudly instead of silently reporting success.
The package root publishes the type vocabulary those commands report in, and no runtime
value — EventName, SettingsLayer, Provenance, and ResolvedHook:
import type { ResolvedHook } from "hookassert";
function summarize(hook: ResolvedHook): string {
const at = `${hook.provenance.file}:${String(hook.provenance.line)}`;
return `${hook.event} runs ${hook.command} (declared at ${at})`;
}All public symbols are named exports from the package root. Deep imports are private and blocked by the package export map.
See the generated TypeDoc documentation from pnpm docs:build for the full API
reference.
corepack pnpm@11.18.0 install --frozen-lockfile
pnpm hooks:install
pnpm checkSee CONTRIBUTING.md for the complete workflow.
MIT © tomada