Project 6: Hook Orchestrator - Type-Safe Hook Framework
Project 6: Hook Orchestrator - Type-Safe Hook Framework
Project Overview
| Attribute | Value |
|---|---|
| Project Number | 6 of 40 |
| Category | Hooks System Mastery |
| Main Programming Language | Bun/TypeScript |
| Alternative Languages | Deno/TypeScript, Node/TypeScript |
| Difficulty Level | Level 4: Expert |
| Time Estimate | 2-3 Weeks |
| Coolness Level | Level 4: Hardcore Tech Flex |
| Business Potential | Open Core Infrastructure |
| Knowledge Area | Hooks / Framework Development / Type Safety |
| Primary Tool | Bun, TypeScript, Zod |
| Main Reference | “Programming TypeScript” by Boris Cherny |
Summary: Build a type-safe hook framework in Bun that provides: typed payloads for all hook events, middleware pipeline for composable logic, plugin architecture for reusable hook components, hot-reloading during development, and comprehensive testing utilities.
Real World Outcome
Instead of writing ad-hoc shell scripts for each hook, you use your framework:
// Using your hook framework - clean, type-safe, composable
import { createHook, middleware, validators } from "@my-org/claude-hooks";
const fileGuardian = createHook("PreToolUse")
.use(middleware.logging({ level: "info" }))
.use(middleware.rateLimit({ max: 100, window: "1m" }))
.use(validators.blockFiles([".env", "secrets/*"]))
.use(async (ctx, next) => {
// Custom logic with full type safety
if (ctx.payload.tool_name === "Bash") {
const command = ctx.payload.tool_input.command;
console.log(`Bash command: ${command}`);
}
await next();
})
.build();
// Framework handles stdin/stdout, error handling, exit codes
await fileGuardian.run();
# Run with hot-reload during development:
$ bun run --watch hooks/file-guardian.ts
# Test your hook in isolation:
$ bun test hooks/file-guardian.test.ts
# Bundle for production:
$ bun build hooks/file-guardian.ts --outfile=dist/file-guardian.js
What This Teaches You
- Deep understanding of all hook event types and payloads
- TypeScript generics and type inference
- Middleware pattern implementation
- Framework design principles
- Testing CLI tools
The Core Question You’re Answering
“How can I build a reusable, type-safe foundation for all my Claude Code hooks that makes development faster and more reliable?”
This is the meta-project - building a framework for building hooks:
+-----------------------------------------------------------------------+
| WITHOUT FRAMEWORK vs WITH FRAMEWORK |
+-----------------------------------------------------------------------+
| |
| WITHOUT (Ad-hoc scripts) WITH (Your framework) |
| +---------------------------+ +---------------------------+ |
| | #!/bin/bash | | import { createHook } | |
| | cat > /dev/null | | from "@my/claude-hooks" | |
| | # parse JSON manually | | | |
| | PAYLOAD=$(cat) | | createHook("PreToolUse") | |
| | TOOL=$(echo $PAYLOAD | | | .use(logging()) | |
| | jq -r '.tool_name') | | .use(myValidator) | |
| | if [ "$TOOL" = "Edit" ]...| | .build() | |
| | # handle errors manually | | .run(); | |
| | # hope exit code is right | | | |
| +---------------------------+ +---------------------------+ |
| |
| Problems: Benefits: |
| - No type safety - Full TypeScript types |
| - Error-prone JSON parsing - Automatic payload parsing |
| - Repeated boilerplate - Reusable middleware |
| - Hard to test - Built-in testing utilities |
| - No composition - Composable architecture |
| |
+-----------------------------------------------------------------------+
Concepts You Must Understand First
Stop and research these before coding:
1. All Hook Event Types
Your framework needs to type ALL events:
| Event | Payload Fields | Output |
|---|---|---|
SessionStart |
session_id, cwd | None (exit 0/2) |
UserPromptSubmit |
prompt, session_id, cwd | {modified_prompt: string} or none |
PreToolUse |
tool_name, tool_input, session_id | {block: true, reason: string} or none |
PostToolUse |
tool_name, tool_input, tool_output, session_id | None |
Notification |
message, urgency, session_id | None |
Stop |
reason, error?, duration?, session_id | None |
SubagentStop |
subagent_id, result, session_id | None |
PreCompact |
session_id | None |
Reference: Claude Code Docs - Complete hooks reference
2. Middleware Pattern
The middleware pattern chains handlers:
+-----------------------------------------------------------------------+
| MIDDLEWARE EXECUTION FLOW |
+-----------------------------------------------------------------------+
| |
| Request |
| | |
| v |
| +-------+ +-------+ +-------+ +-------+ |
| | MW 1 |---->| MW 2 |---->| MW 3 |---->|Handler| |
| | | | | | | | | |
| |before | |before | |before | |execute| |
| +-------+ +-------+ +-------+ +-------+ |
| ^ ^ ^ | |
| | | | v |
| +-------+ +-------+ +-------+ |
| | after |<----| after |<----| after | |
| +-------+ +-------+ +-------+ |
| | |
| v |
| Response |
| |
+-----------------------------------------------------------------------+
// Koa-style middleware:
async function middleware(ctx, next) {
// Before logic
console.log("Before");
await next(); // Call next middleware
// After logic
console.log("After");
}
Reference: “Enterprise Integration Patterns” by Hohpe - Pipes and Filters
3. Bun for Tooling
Bun advantages for CLI frameworks:
// Fast stdin reading
const payload = await Bun.stdin.json();
// Built-in TypeScript
// No compilation step needed!
// Built-in testing
import { test, expect } from "bun:test";
// Fast process spawning
const result = Bun.spawnSync(["ls"]);
// File watching for hot reload
const watcher = fs.watch(path, callback);
Reference: Bun documentation
Questions to Guide Your Design
Before implementing, think through these framework design decisions:
1. Type Safety Architecture
How do you type discriminated unions for different events?
// Option A: Union type
type HookEvent =
| { hook_event_name: "PreToolUse"; tool_name: string; tool_input: unknown }
| { hook_event_name: "PostToolUse"; tool_name: string; tool_output: unknown }
| { hook_event_name: "SessionStart"; cwd: string };
// Option B: Generic with event parameter
interface HookHandler<E extends EventName> {
(ctx: Context<E>): Promise<void>;
}
// Option C: Zod schemas with inference
const PreToolUseSchema = z.object({...});
type PreToolUseEvent = z.infer<typeof PreToolUseSchema>;
2. Middleware API Design
Which API style is best?
// Option A: Koa-style (ctx, next)
.use(async (ctx, next) => {
console.log("before");
await next();
console.log("after");
})
// Option B: Express-style (req, res, next)
.use((event, response, next) => {
next();
})
// Option C: Functional composition
.use(logging())
.use(rateLimit({ max: 10 }))
.use(customHandler)
3. Developer Experience
What makes the framework pleasant to use?
// Good DX elements:
// 1. Auto-complete for event names
createHook("Pre") // IDE suggests "PreToolUse", "PreCompact"
// 2. Type errors for wrong payloads
ctx.payload.tool_name // Error if event doesn't have tool_name
// 3. Easy testing
const result = await testHook(myHook, mockPayload);
expect(result.exitCode).toBe(0);
// 4. Helpful error messages
// "PreToolUse handler accessed 'prompt' which doesn't exist on this event"
Thinking Exercise
Design the Type Hierarchy
Create the core type definitions:
// ===== EVENT TYPES =====
// Base fields all events share
interface BaseEvent {
hook_event_name: string;
session_id: string;
}
// Specific event types
interface SessionStartEvent extends BaseEvent {
hook_event_name: "SessionStart";
cwd: string;
}
interface PreToolUseEvent extends BaseEvent {
hook_event_name: "PreToolUse";
tool_name: string;
tool_input: Record<string, unknown>;
}
interface PostToolUseEvent extends BaseEvent {
hook_event_name: "PostToolUse";
tool_name: string;
tool_input: Record<string, unknown>;
tool_output: Record<string, unknown>;
}
// Union of all events
type HookEvent =
| SessionStartEvent
| PreToolUseEvent
| PostToolUseEvent
| UserPromptSubmitEvent
| NotificationEvent
| StopEvent
| SubagentStopEvent
| PreCompactEvent;
// ===== DESIGN QUESTIONS =====
// 1. How do you make tool_input type-safe per tool?
// Edit has file_path, Write has content, Bash has command...
// 2. How do you type middleware that works across events?
// Some middleware (logging) works on all, some (fileValidator) only PreToolUse
// 3. How do you validate at runtime without losing compile-time types?
// Zod schemas + type inference
Questions to Answer:
- Should you have a generic
HookContext<E extends HookEvent>?- Yes - allows type-safe access to event-specific fields
- How do you type the output (block vs allow vs modify)?
- Different output types per event
- PreToolUse can block, UserPromptSubmit can modify
- Can you infer types from the event name?
- Yes, with TypeScript’s type narrowing
The Interview Questions They’ll Ask
1. “How would you design a type-safe middleware system in TypeScript?”
Answer:
// 1. Define context type with generics
interface Context<E extends HookEvent> {
payload: E;
result: HookResult;
state: Map<string, unknown>;
}
// 2. Define middleware type
type Middleware<E extends HookEvent> = (
ctx: Context<E>,
next: () => Promise<void>
) => Promise<void>;
// 3. Compose middleware
function compose<E extends HookEvent>(
...middlewares: Middleware<E>[]
): Middleware<E> {
return async (ctx, finalNext) => {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) throw new Error("next() called multiple times");
index = i;
const fn = middlewares[i];
if (!fn) return finalNext();
await fn(ctx, () => dispatch(i + 1));
}
await dispatch(0);
};
}
Key points:
- Generics preserve event type through the chain
- Context carries both payload and accumulated state
- Compose function creates a single middleware from many
2. “What’s the difference between compile-time and runtime type checking?”
Answer:
| Aspect | Compile-time (TypeScript) | Runtime (Zod) |
|---|---|---|
| When | During development | During execution |
| Errors | IDE shows errors | Throws at runtime |
| Performance | Zero cost | Some overhead |
| External data | Can’t validate | Validates JSON |
| Refactoring | Safe refactoring | Manual updates |
For hooks, you need BOTH:
- Compile-time: Type-safe handler code
- Runtime: Validate JSON from stdin (untrusted input)
// Best of both worlds:
const schema = z.object({...});
type Event = z.infer<typeof schema>; // Types from schema
const validated: Event = schema.parse(input); // Runtime check
3. “How do you test CLI tools that read from stdin?”
Answer:
// 1. Abstract stdin reading
class HookRunner {
constructor(private reader: () => Promise<unknown>) {}
async run() {
const input = await this.reader();
// ...
}
}
// 2. Inject mock in tests
import { test, expect } from "bun:test";
test("blocks .env files", async () => {
const mockPayload = {
hook_event_name: "PreToolUse",
tool_name: "Edit",
tool_input: { file_path: ".env" }
};
const runner = new HookRunner(() => Promise.resolve(mockPayload));
const result = await runner.run();
expect(result.blocked).toBe(true);
});
// 3. Or use process mocking
import { spawnSync } from "bun";
test("integration test", () => {
const result = spawnSync(["bun", "hook.ts"], {
stdin: Buffer.from(JSON.stringify(mockPayload))
});
expect(result.exitCode).toBe(2); // Blocked
});
4. “How would you implement hot-reloading for a CLI framework?”
Answer:
// 1. Watch for file changes
import { watch } from "fs";
function startDevServer(hookPath: string) {
let currentHandler = require(hookPath);
watch(hookPath, async () => {
// Clear require cache
delete require.cache[require.resolve(hookPath)];
// Reload module
currentHandler = require(hookPath);
console.log("Hook reloaded!");
});
// Return function that always uses latest handler
return (...args) => currentHandler(...args);
}
// 2. In Bun, use --watch flag
$ bun run --watch hooks/my-hook.ts
// 3. For complex setups, use Bun's native hot reload
import { FileSystemRouter } from "bun";
5. “What are the trade-offs of building a framework vs using raw scripts?”
Answer:
| Aspect | Framework | Raw Scripts |
|---|---|---|
| Learning curve | Higher upfront | Lower upfront |
| Development speed | Faster long-term | Slower |
| Consistency | Enforced patterns | Ad-hoc |
| Testing | Built-in utilities | Manual setup |
| Maintenance | Centralized updates | Each script |
| Flexibility | Constrained | Unlimited |
| Performance | Some overhead | Minimal |
| Team scaling | Easier onboarding | Tribal knowledge |
When to build a framework:
- Building many hooks (>5)
- Team collaboration
- Need consistent patterns
- Testing is important
- Plan to distribute/share
Hints in Layers
Hint 1: Start with Types
Define Zod schemas for all hook events first:
import { z } from "zod";
export const PreToolUseSchema = z.object({
hook_event_name: z.literal("PreToolUse"),
session_id: z.string(),
tool_name: z.string(),
tool_input: z.record(z.unknown()),
});
export type PreToolUseEvent = z.infer<typeof PreToolUseSchema>;
Hint 2: Build the Runner
Create a run(handler) function that handles boilerplate:
async function run<E extends HookEvent>(
schema: z.Schema<E>,
handler: (ctx: Context<E>) => Promise<HookResult>
): Promise<void> {
const input = await Bun.stdin.json();
const payload = schema.parse(input);
const ctx = createContext(payload);
const result = await handler(ctx);
if (result.output) {
console.log(JSON.stringify(result.output));
}
process.exit(result.exitCode);
}
Hint 3: Add Middleware Composition
Implement the compose function:
function compose<E extends HookEvent>(
middlewares: Middleware<E>[]
): Middleware<E> {
return async (ctx, next) => {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) {
throw new Error("next() called multiple times");
}
index = i;
const fn = middlewares[i] || next;
await fn(ctx, () => dispatch(i + 1));
}
await dispatch(0);
};
}
Hint 4: Add Testing Utilities
Create helpers for testing hooks:
export async function testHook<E extends HookEvent>(
hook: Hook<E>,
payload: E
): Promise<{ exitCode: number; output?: unknown }> {
const ctx = createContext(payload);
let exitCode = 0;
let output: unknown;
// Mock process.exit
const mockExit = (code: number) => { exitCode = code; };
// Run hook
await hook.handler(ctx);
return { exitCode, output: ctx.output };
}
Books That Will Help
| Topic | Book | Chapter | Why It Helps |
|---|---|---|---|
| TypeScript advanced | “Programming TypeScript” by Boris Cherny | Ch. 4, 6 | Generics, type inference |
| Middleware patterns | “Enterprise Integration Patterns” by Hohpe | Ch. 3 | Pipes and Filters pattern |
| Framework design | “Framework Design Guidelines” by Cwalina | Ch. 2-4 | API design principles |
| Testing patterns | “Testing JavaScript Applications” by Lucas da Costa | Ch. 5-7 | CLI testing strategies |
| Zod and validation | Zod documentation | All | Runtime type validation |
Implementation Guide
Core Framework Structure
claude-hooks/
src/
index.ts # Main exports
types/
events.ts # Event type definitions
middleware.ts # Middleware types
context.ts # Context types
schemas/
events.ts # Zod schemas
core/
runner.ts # Hook runner
compose.ts # Middleware composition
builder.ts # Hook builder API
middleware/
logging.ts # Built-in logging
rateLimit.ts # Rate limiting
validators.ts # Common validators
testing/
utils.ts # Test utilities
test/
runner.test.ts
compose.test.ts
middleware.test.ts
Complete Implementation
// ===== src/types/events.ts =====
export type EventName =
| "SessionStart"
| "UserPromptSubmit"
| "PreToolUse"
| "PostToolUse"
| "Notification"
| "Stop"
| "SubagentStop"
| "PreCompact";
export interface BaseEvent {
hook_event_name: EventName;
session_id: string;
}
export interface SessionStartEvent extends BaseEvent {
hook_event_name: "SessionStart";
cwd: string;
}
export interface UserPromptSubmitEvent extends BaseEvent {
hook_event_name: "UserPromptSubmit";
prompt: string;
cwd: string;
}
export interface PreToolUseEvent extends BaseEvent {
hook_event_name: "PreToolUse";
tool_name: string;
tool_input: Record<string, unknown>;
}
export interface PostToolUseEvent extends BaseEvent {
hook_event_name: "PostToolUse";
tool_name: string;
tool_input: Record<string, unknown>;
tool_output: Record<string, unknown>;
}
export interface NotificationEvent extends BaseEvent {
hook_event_name: "Notification";
message: string;
urgency?: "low" | "medium" | "high";
}
export interface StopEvent extends BaseEvent {
hook_event_name: "Stop";
reason: string;
error?: string;
duration_seconds?: number;
}
export interface SubagentStopEvent extends BaseEvent {
hook_event_name: "SubagentStop";
subagent_id: string;
result: Record<string, unknown>;
}
export interface PreCompactEvent extends BaseEvent {
hook_event_name: "PreCompact";
}
export type HookEvent =
| SessionStartEvent
| UserPromptSubmitEvent
| PreToolUseEvent
| PostToolUseEvent
| NotificationEvent
| StopEvent
| SubagentStopEvent
| PreCompactEvent;
// Event name to type mapping
export type EventMap = {
SessionStart: SessionStartEvent;
UserPromptSubmit: UserPromptSubmitEvent;
PreToolUse: PreToolUseEvent;
PostToolUse: PostToolUseEvent;
Notification: NotificationEvent;
Stop: StopEvent;
SubagentStop: SubagentStopEvent;
PreCompact: PreCompactEvent;
};
// ===== src/schemas/events.ts =====
import { z } from "zod";
export const SessionStartSchema = z.object({
hook_event_name: z.literal("SessionStart"),
session_id: z.string(),
cwd: z.string(),
});
export const UserPromptSubmitSchema = z.object({
hook_event_name: z.literal("UserPromptSubmit"),
session_id: z.string(),
prompt: z.string(),
cwd: z.string(),
});
export const PreToolUseSchema = z.object({
hook_event_name: z.literal("PreToolUse"),
session_id: z.string(),
tool_name: z.string(),
tool_input: z.record(z.unknown()),
});
export const PostToolUseSchema = z.object({
hook_event_name: z.literal("PostToolUse"),
session_id: z.string(),
tool_name: z.string(),
tool_input: z.record(z.unknown()),
tool_output: z.record(z.unknown()),
});
export const NotificationSchema = z.object({
hook_event_name: z.literal("Notification"),
session_id: z.string(),
message: z.string(),
urgency: z.enum(["low", "medium", "high"]).optional(),
});
export const StopSchema = z.object({
hook_event_name: z.literal("Stop"),
session_id: z.string(),
reason: z.string(),
error: z.string().optional(),
duration_seconds: z.number().optional(),
});
export const SubagentStopSchema = z.object({
hook_event_name: z.literal("SubagentStop"),
session_id: z.string(),
subagent_id: z.string(),
result: z.record(z.unknown()),
});
export const PreCompactSchema = z.object({
hook_event_name: z.literal("PreCompact"),
session_id: z.string(),
});
export const EventSchemas = {
SessionStart: SessionStartSchema,
UserPromptSubmit: UserPromptSubmitSchema,
PreToolUse: PreToolUseSchema,
PostToolUse: PostToolUseSchema,
Notification: NotificationSchema,
Stop: StopSchema,
SubagentStop: SubagentStopSchema,
PreCompact: PreCompactSchema,
};
// ===== src/types/context.ts =====
import type { HookEvent, EventMap, EventName } from "./events";
export interface HookResult {
action: "allow" | "block" | "modify";
output?: Record<string, unknown>;
}
export interface Context<E extends HookEvent = HookEvent> {
payload: E;
result: HookResult;
state: Map<string, unknown>;
// Helper methods
block(reason?: string): void;
allow(): void;
modify(output: Record<string, unknown>): void;
}
export function createContext<E extends HookEvent>(payload: E): Context<E> {
const result: HookResult = { action: "allow" };
const state = new Map<string, unknown>();
return {
payload,
result,
state,
block(reason?: string) {
this.result = {
action: "block",
output: reason ? { reason } : undefined,
};
},
allow() {
this.result = { action: "allow" };
},
modify(output: Record<string, unknown>) {
this.result = { action: "modify", output };
},
};
}
// ===== src/types/middleware.ts =====
import type { Context, HookResult } from "./context";
import type { HookEvent } from "./events";
export type NextFunction = () => Promise<void>;
export type Middleware<E extends HookEvent = HookEvent> = (
ctx: Context<E>,
next: NextFunction
) => Promise<void>;
// ===== src/core/compose.ts =====
import type { Middleware } from "../types/middleware";
import type { HookEvent } from "../types/events";
export function compose<E extends HookEvent>(
middlewares: Middleware<E>[]
): Middleware<E> {
return async (ctx, next) => {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) {
throw new Error("next() called multiple times");
}
index = i;
const fn = middlewares[i];
if (!fn) {
await next();
return;
}
await fn(ctx, () => dispatch(i + 1));
}
await dispatch(0);
};
}
// ===== src/core/builder.ts =====
import type { EventName, EventMap, HookEvent } from "../types/events";
import type { Middleware } from "../types/middleware";
import type { Context } from "../types/context";
import { createContext } from "../types/context";
import { compose } from "./compose";
import { EventSchemas } from "../schemas/events";
export class HookBuilder<E extends HookEvent> {
private middlewares: Middleware<E>[] = [];
private eventName: EventName;
constructor(eventName: EventName) {
this.eventName = eventName;
}
use(middleware: Middleware<E>): this {
this.middlewares.push(middleware);
return this;
}
build(): Hook<E> {
const composed = compose(this.middlewares);
const schema = EventSchemas[this.eventName];
return {
eventName: this.eventName,
handler: composed,
schema,
async run() {
try {
// Read and validate input
const input = await Bun.stdin.json();
const payload = schema.parse(input) as E;
// Create context and run middleware
const ctx = createContext(payload);
await composed(ctx, async () => {});
// Handle output
if (ctx.result.output) {
console.log(JSON.stringify(ctx.result.output));
}
// Exit with appropriate code
const exitCode = ctx.result.action === "block" ? 2 : 0;
process.exit(exitCode);
} catch (error) {
console.error(`Hook error: ${error}`);
process.exit(0); // Don't block on errors
}
},
};
}
}
export interface Hook<E extends HookEvent> {
eventName: EventName;
handler: Middleware<E>;
schema: unknown;
run(): Promise<void>;
}
export function createHook<K extends EventName>(
eventName: K
): HookBuilder<EventMap[K]> {
return new HookBuilder<EventMap[K]>(eventName);
}
// ===== src/middleware/logging.ts =====
import type { Middleware } from "../types/middleware";
import type { HookEvent } from "../types/events";
interface LoggingOptions {
level?: "debug" | "info" | "warn" | "error";
includePayload?: boolean;
}
export function logging(options: LoggingOptions = {}): Middleware<HookEvent> {
const { level = "info", includePayload = false } = options;
return async (ctx, next) => {
const start = Date.now();
const { hook_event_name, session_id } = ctx.payload;
console.error(
`[${level.toUpperCase()}] ${hook_event_name} started (session: ${session_id})`
);
if (includePayload) {
console.error(`Payload: ${JSON.stringify(ctx.payload)}`);
}
await next();
const duration = Date.now() - start;
console.error(
`[${level.toUpperCase()}] ${hook_event_name} completed in ${duration}ms`
);
};
}
// ===== src/middleware/validators.ts =====
import type { Middleware } from "../types/middleware";
import type { PreToolUseEvent } from "../types/events";
import * as path from "path";
export function blockFiles(patterns: string[]): Middleware<PreToolUseEvent> {
return async (ctx, next) => {
const toolInput = ctx.payload.tool_input;
const filePath = toolInput.file_path as string | undefined;
if (filePath) {
const normalized = path.basename(filePath);
for (const pattern of patterns) {
if (pattern.includes("*")) {
// Simple glob matching
const regex = new RegExp(
"^" + pattern.replace(/\*/g, ".*") + "$"
);
if (regex.test(normalized)) {
ctx.block(`File matches blocked pattern: ${pattern}`);
return;
}
} else if (normalized === pattern || filePath.includes(pattern)) {
ctx.block(`File is blocked: ${pattern}`);
return;
}
}
}
await next();
};
}
export function blockTools(toolNames: string[]): Middleware<PreToolUseEvent> {
return async (ctx, next) => {
if (toolNames.includes(ctx.payload.tool_name)) {
ctx.block(`Tool is blocked: ${ctx.payload.tool_name}`);
return;
}
await next();
};
}
// ===== src/testing/utils.ts =====
import type { HookEvent, EventMap, EventName } from "../types/events";
import type { Hook } from "../core/builder";
import { createContext, type Context } from "../types/context";
export interface TestResult {
exitCode: number;
output?: unknown;
context: Context<HookEvent>;
}
export async function testHook<E extends HookEvent>(
hook: Hook<E>,
payload: E
): Promise<TestResult> {
const ctx = createContext(payload);
await hook.handler(ctx, async () => {});
return {
exitCode: ctx.result.action === "block" ? 2 : 0,
output: ctx.result.output,
context: ctx,
};
}
export function mockPayload<K extends EventName>(
eventName: K,
overrides: Partial<EventMap[K]> = {}
): EventMap[K] {
const defaults: Record<EventName, Partial<HookEvent>> = {
SessionStart: { cwd: "/test" },
UserPromptSubmit: { prompt: "test prompt", cwd: "/test" },
PreToolUse: { tool_name: "Edit", tool_input: {} },
PostToolUse: { tool_name: "Edit", tool_input: {}, tool_output: {} },
Notification: { message: "test" },
Stop: { reason: "complete" },
SubagentStop: { subagent_id: "test", result: {} },
PreCompact: {},
};
return {
hook_event_name: eventName,
session_id: "test-session",
...defaults[eventName],
...overrides,
} as EventMap[K];
}
// ===== src/index.ts =====
export { createHook, type Hook } from "./core/builder";
export { compose } from "./core/compose";
export { createContext, type Context, type HookResult } from "./types/context";
export type { Middleware, NextFunction } from "./types/middleware";
export * from "./types/events";
export * from "./schemas/events";
// Middleware
export { logging } from "./middleware/logging";
export { blockFiles, blockTools } from "./middleware/validators";
// Testing
export { testHook, mockPayload } from "./testing/utils";
Example Usage
// hooks/file-guardian.ts
import {
createHook,
logging,
blockFiles,
type PreToolUseEvent,
} from "@my-org/claude-hooks";
const hook = createHook("PreToolUse")
.use(logging({ level: "info" }))
.use(blockFiles([".env", "secrets/*", "*.pem"]))
.use(async (ctx, next) => {
// Custom logic
const toolName = ctx.payload.tool_name;
console.error(`Checking tool: ${toolName}`);
await next();
})
.build();
await hook.run();
Test File
// hooks/file-guardian.test.ts
import { test, expect } from "bun:test";
import {
createHook,
blockFiles,
testHook,
mockPayload,
} from "@my-org/claude-hooks";
test("blocks .env files", async () => {
const hook = createHook("PreToolUse")
.use(blockFiles([".env"]))
.build();
const payload = mockPayload("PreToolUse", {
tool_name: "Edit",
tool_input: { file_path: "/project/.env" },
});
const result = await testHook(hook, payload);
expect(result.exitCode).toBe(2);
expect(result.context.result.action).toBe("block");
});
test("allows safe files", async () => {
const hook = createHook("PreToolUse")
.use(blockFiles([".env"]))
.build();
const payload = mockPayload("PreToolUse", {
tool_name: "Edit",
tool_input: { file_path: "/project/app.ts" },
});
const result = await testHook(hook, payload);
expect(result.exitCode).toBe(0);
});
Architecture Diagram
+-----------------------------------------------------------------------+
| HOOK ORCHESTRATOR ARCHITECTURE |
+-----------------------------------------------------------------------+
| |
| User Code |
| +-------------------------------------------------------------------+ |
| | createHook("PreToolUse") | |
| | .use(logging()) | |
| | .use(blockFiles([".env"])) | |
| | .use(customHandler) | |
| | .build() | |
| | .run() | |
| +-------------------------------------------------------------------+ |
| | |
| v |
| Framework Core |
| +-------------------------------------------------------------------+ |
| | HookBuilder | |
| | - Collects middleware | |
| | - Selects event schema | |
| | - Builds composed handler | |
| +-------------------------------------------------------------------+ |
| | |
| v |
| +-------------------------------------------------------------------+ |
| | Hook.run() | |
| | 1. Read stdin (Bun.stdin.json()) | |
| | 2. Validate with Zod schema | |
| | 3. Create typed Context | |
| | 4. Execute middleware chain | |
| | 5. Output JSON if needed | |
| | 6. Exit with appropriate code | |
| +-------------------------------------------------------------------+ |
| | |
| v |
| Middleware Chain |
| +-------------------------------------------------------------------+ |
| | | |
| | +----------+ +----------+ +----------+ +----------+ | |
| | | logging |--->| blockFiles|--->| custom |--->| (end) | | |
| | +----------+ +----------+ +----------+ +----------+ | |
| | | | | | | |
| | v v v v | |
| | ctx.result ctx.block() await next() ctx.allow() | |
| | | |
| +-------------------------------------------------------------------+ |
| |
+-----------------------------------------------------------------------+
Learning Milestones
Milestone 1: Types Catch Errors at Compile Time
Goal: TypeScript prevents invalid event access
Test:
// This should be a compile error:
createHook("SessionStart")
.use((ctx) => {
ctx.payload.tool_name; // Error: 'tool_name' doesn't exist
});
What You’ve Learned:
- TypeScript generics
- Discriminated unions
- Type inference
Milestone 2: Middleware Composes Correctly
Goal: Multiple middleware execute in order
Test:
const order: string[] = [];
const hook = createHook("PreToolUse")
.use(async (ctx, next) => { order.push("1-before"); await next(); order.push("1-after"); })
.use(async (ctx, next) => { order.push("2-before"); await next(); order.push("2-after"); })
.build();
await testHook(hook, mockPayload("PreToolUse"));
expect(order).toEqual(["1-before", "2-before", "2-after", "1-after"]);
What You’ve Learned:
- Middleware pattern
- Async composition
- Execution order
Milestone 3: Framework is Reusable
Goal: New hooks take minutes, not hours
Test: Time yourself building a new hook with the framework vs raw script.
What You’ve Learned:
- Framework value proposition
- API design
- Developer experience
Summary
This project taught you to build a type-safe hook framework:
- Type System Design: Generics, discriminated unions, Zod schemas
- Middleware Pattern: Composable, chainable handlers
- Framework Architecture: Builder pattern, dependency injection
- Testing Strategies: Mocking stdin, integration tests
- Developer Experience: Auto-complete, error messages, hot-reload
With your Hook Orchestrator, building new hooks becomes a pleasure. Project 7 will use this framework to build session persistence, and Project 8 will create an analytics dashboard.