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:

  1. Should you have a generic HookContext<E extends HookEvent>?
    • Yes - allows type-safe access to event-specific fields
  2. How do you type the output (block vs allow vs modify)?
    • Different output types per event
    • PreToolUse can block, UserPromptSubmit can modify
  3. 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.