@typed-rocks/typed-claude-hooks
v0.1.5
Published
Type-safe Claude Code hooks in TypeScript
Maintainers
Readme
typed-claude-hooks
Type-safe hooks for Claude Code. All 30 events. Full autocomplete. One build command.
The Problem
Raw Claude Code hooks are shell commands in settings.json. You pipe JSON through stdin, parse it by hand, and hope you spelled the field names right:
#!/usr/bin/env node
const data = require('fs').readFileSync('/dev/stdin', 'utf8');
const input = JSON.parse(data);
// no types — typo in field name? silent bug
if (input.tool_input.comand.includes('rm -rf')) {
process.exit(2);
}The Fix
import { defineHandler } from "@typed-rocks/typed-claude-hooks"
export const blockRm = defineHandler("PreToolUse", { matcher: "Bash" }, async (input) => {
// input.tool_input is fully typed — autocomplete for command, timeout, description
if (input.tool_input.command.includes("rm -rf")) {
return {
hookSpecificOutput: {
permissionDecision: "deny" as const,
permissionDecisionReason: "No rm -rf allowed",
},
}
}
return {}
})- Type-safe everything — real TypeScript types for all 30 events, mistakes caught at compile time
- Smart type narrowing — pass
{ matcher: "Write" }and getfile_path+content; pass{ matcher: "Bash" }and getcommand - Test without subprocesses —
testHandlerruns your hook as a function call, no stdin/stdout piping - Zero-config settings.json — one command compiles your hooks and generates
settings.json
Quick Start
npx typed-claude-hooksThat is the whole setup. On first run it creates a self-contained
.typed-claude-hooks/ project, installs itself into it, compiles the example
hook, and writes .claude/settings.json:
.typed-claude-hooks/
|-- package.json
|-- hooks.config.ts <- edit this
|-- tsconfig.json
`-- node_modules/Nothing is added to your project root, so this works the same in a Python or Go
repository as it does in a TypeScript one. Edit hooks.config.ts and run the
command again to rebuild.
Prefer to try it first? Open the browser Playground. Monaco provides the package's TypeScript types and autocomplete, while compilation, settings preview, and ZIP creation happen entirely in your browser. Put the downloaded source at .typed-claude-hooks/hooks.config.ts and generated artifacts under .claude/hooks/typed-claude-hooks/. Merge the snippet's hooks property into .claude/settings.json; do not replace your settings file.
The Playground does not execute hooks. It supports one hooks.config.ts only, direct named export const handlers initialized with defineHandler(...), and imports from @typed-rocks/typed-claude-hooks, @typed-rocks/typed-claude-hooks/types, or node:*; arbitrary or extra npm packages and multi-file configs are not supported. Downloads target Node only, not Bun or Deno. Source stays in the page and is not persisted or uploaded.
Writing Hooks
Export handlers as named exports — each is automatically discovered by its event type:
import { defineHandler } from "@typed-rocks/typed-claude-hooks"
// Matcher narrows tool_input to FileWriteInput | FileEditInput
export const protectEnv = defineHandler("PreToolUse", { matcher: "Write|Edit" }, async (input) => {
if (input.tool_input.file_path.endsWith(".env")) {
return {
hookSpecificOutput: {
permissionDecision: "deny" as const,
permissionDecisionReason: "Cannot modify .env files",
},
}
}
return {}
})
// Non-tool events don't use matchers
export const logStop = defineHandler("Stop", async (input) => {
console.error(`Session stopped: ${input.session_id}`)
return {}
})For events with hook-specific output, hookEventName is optional while authoring. The generated runtime inserts the handler's event when it is omitted. You can also provide the exact event explicitly, and TypeScript rejects a mismatched event:
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny" as const,
permissionDecisionReason: "Blocked",
},
}defineHandler(event, fn) / defineHandler(event, options, fn)
Creates a typed handler for a specific hook event. For all five tool events — PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied — pass a matcher in the options to narrow tool_input to the matched tool's type:
// Matcher narrows tool_input to BashInput — full autocomplete
export const blockRm = defineHandler("PreToolUse", { matcher: "Bash" }, async (input) => {
input.tool_input.command // string, no cast needed
})
// Union matcher — tool_input is FileWriteInput | FileEditInput
export const protectEnv = defineHandler("PreToolUse", { matcher: "Write|Edit" }, async (input) => {
input.tool_input.file_path // string
})
// No matcher — tool_input stays unknown
export const logAll = defineHandler("PreToolUse", async (input) => { ... })
// Non-tool events don't use matchers
export const onStop = defineHandler("Stop", async (input) => { ... })Built-in tool inputs are typed for file and search tools, shell and web tools, agents and workflows, tasks and todos, planning and worktrees, notebooks and REPL, cron and wakeups, MCP resources, monitoring, notifications, and remote triggers. This includes tools such as Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Agent, AskUserQuestion, NotebookEdit, and the Task* tools. Unknown custom matcher names are accepted with tool_input typed as unknown.
Testing Hooks
Use testHandler to unit test your handlers without stdin/stdout or process spawning:
import { testHandler } from "@typed-rocks/typed-claude-hooks/testing"
import { protectEnv } from "./hooks.config"
const result = await testHandler(protectEnv, {
tool_name: "Write",
tool_input: { file_path: ".env", content: "SECRET=123" },
tool_use_id: "tu_1",
})
expect(result.hookSpecificOutput?.permissionDecision).toBe("deny")testHandler auto-fills base fields (session_id, cwd, transcript_path) with test defaults. Override any field by including it in the input.
CLI
typed-claude-hooks [config]
Scaffolds the sandbox if needed, then compiles hooks and merges them into the target settings.json.
| Flag | Default | Description |
|----------------|-----------------------------------------|-------------------------------------------|
| [config] | .typed-claude-hooks/hooks.config.ts | Path to the config file |
| -o, --output | .claude/settings.json | Path to the output settings.json |
| --hooks-dir | hooks/ next to target | Where to write compiled JS files |
| --runtime | node | Wrapper runtime: node, bun, or deno |
Passing an explicit [config] builds that file and skips the sandbox entirely — nothing is scaffolded and no dependency is installed.
Each run checks that the @typed-rocks/typed-claude-hooks version installed in the sandbox matches the CLI's own. On a mismatch it repins that one dependency and reinstalls; any dependencies you added for your own hooks are preserved. A file: or link: specifier is never rewritten.
--runtime applies only to that build. It is embedded in generated wrappers and is not persisted to the config or settings.json; omit it on a later build to return to Node.
Each handler can set shell: "bash" | "powershell" in its options. Bash is the default. Every handler always produces a self-contained .mjs bundle plus a mandatory .sh wrapper for Bash or .ps1 wrapper for PowerShell. The generated settings entry invokes the wrapper, never the .mjs file directly.
export const windowsHook = defineHandler(
"PreToolUse",
{ matcher: "Bash", shell: "powershell" },
async () => ({}),
)typed-claude-hooks init
Scaffolds the sandbox and installs its dependency, then stops. No settings.json is written and no hook artifacts are generated — use it when you want the config and its types in place before wiring anything into Claude Code. Existing files are never overwritten; init reports them as skipped.
How It Works
typed-claude-hooks does three things:
- Transpiles your
.tsconfig with esbuild and imports it - Bundles each named handler into a self-contained
.mjsfile and generates its.shor.ps1wrapper - Merges wrapper commands into
settings.json, preserving hand-written hooks
For example, blockRm generates:
.claude/hooks/typed-claude-hooks/PreToolUse/
|-- blockRm.mjs
`-- blockRm.shThe settings command points to blockRm.sh. Generated commands are recognized by their managed directory and replaced on rebuild without touching manual hooks.
Local Development
When working on typed-claude-hooks itself, build and run the CLI from the repo:
npm run build
node dist/cli/index.jsOr use npm link to make the typed-claude-hooks command available globally:
npm link
typed-claude-hooksTypes
All hook types are available as a separate export:
import type {
HookEvent,
PreToolUseHookInput,
StopHookInput,
SyncHookJSONOutput,
} from "@typed-rocks/typed-claude-hooks/types"Types are auto-extracted from the @anthropic-ai/claude-agent-sdk package and bundled with typed-claude-hooks — no extra dependencies needed.
License
MIT
