@aeriondyseti/hook-kit
v1.0.0
Published
Ergonomic helpers for writing Claude Code hook scripts.
Maintainers
Readme
@aeriondyseti/hook-kit
Ergonomic, typed helpers for writing Claude Code hook scripts.
- One class per hook event with two static methods:
parse()reads and validates stdin,emitOutput()writes the response JSON and exits. - A small
OutputBuilderfor styled multi-line text — boxes, tables, lists, dividers, icons, colors via tag markup. - A testing subpath (
@aeriondyseti/hook-kit/testing) withtestHook,mockXxxinput factories, and normalized result fields so you can assertresult.wasDeniedinstead of spelunking the payload.
Install
npm install @aeriondyseti/hook-kitRequires Node 20+. ESM-only.
A minimal hook
#!/usr/bin/env node
import { PreToolUse, runHook } from '@aeriondyseti/hook-kit';
runHook(() => {
const input = PreToolUse.parse();
const cmd = String((input.tool_input as { command?: unknown }).command ?? '');
if (/\brm\b.*-rf?\s+\//.test(cmd)) {
PreToolUse.emitOutput({
decision: 'deny',
reason: 'Refusing dangerous rm on the filesystem root.',
});
}
PreToolUse.emitOutput({});
});Wire it up in your settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "node /path/to/pre-tool-use.ts" }] }
]
}
}Styled output
import { ICONS, OutputBuilder, PostToolUse } from '@aeriondyseti/hook-kit';
const toUser = new OutputBuilder()
.appendBox(`${ICONS.check} ${input.tool_name}`, { title: '● PostToolUse', color: 'green' })
.appendTable(rows, { headers: ['key', 'value'], color: 'green' });
PostToolUse.emitOutput({ toUser });Colors and modifiers also work via inline tags:
builder.appendLine('<color:"red"><bold>boom</bold></color>');Available icons: check cross warn info arrow bullet dot star.
Available colors: black red green yellow blue magenta cyan white gray.
Available modifiers: bold dim italic underline.
Testing your hooks
import { describe, expect, it } from 'vitest';
import { PreToolUse } from '@aeriondyseti/hook-kit';
import { mockPreToolUse, testHook } from '@aeriondyseti/hook-kit/testing';
import { handle } from './pre-tool-use.js';
it('denies rm -rf', () => {
const result = testHook(
mockPreToolUse({ tool_name: 'Bash', tool_input: { command: 'rm -rf /' } }),
() => handle(PreToolUse.parse()),
);
expect(result.wasDenied).toBe(true);
expect(result.toClaude).toContain('rm');
});TestHookResult carries normalized fields so you don't have to walk the
payload yourself:
| Field | Meaning |
| -------------- | -------------------------------------------------------------------------- |
| wasDenied | permissionDecision === 'deny' or top-level decision === 'block' |
| wasAllowed | explicit allow, or no blocking/ask signal at all |
| wasAsked | permissionDecision === 'ask' |
| toUser | payload.systemMessage |
| toClaude | additionalContext → permissionDecisionReason → top-level reason |
Negative paths (malformed stdin, wrong hook_event_name) surface as a
thrown HookParseError:
expect(() => testHook(wrongEvent, () => PreToolUse.parse())).toThrow(HookParseError);Examples
Runnable dogfood hooks with colocated tests live in
examples/hooks/. Each hook exports a pure handle(input)
function so its policy is testable without touching stdin, with an
import.meta.url guard that drives the real parse/emit only when run as a
script.
| Hook | Shows |
| ---------------------------------------------------------------- | ----------------------------------------------------------- |
| pre-tool-use.ts | Deny branch, OutputBuilder with box + table, icons |
| pre-tool-use-advanced.ts | decision: 'ask' and updatedInput rewrites |
| post-tool-use.ts | Binary deny: true, toClaude context injection |
| user-prompt-submit.ts | Prompt-level deny, divider + list |
| session-start.ts | Context-injection pattern (no deny concept) |
Project direction
ROADMAP.md— features under consideration for future releases.CHANGELOG.md— release history, Keep a Changelog format.TECH-DEBT.md— known shortcuts and the context behind them, so contributors know what's intentional vs. what's waiting.
License
MIT.
