Skip to content

Payshak/claude-hook-kit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

claude-hook-kit

TypeScript SDK for building Claude Code hooks — typed, testable, composable.

Claude Code's hook system lets you intercept tool calls, inject context, and react to session events. Without this package, hooks are raw shell scripts that read/write JSON with no type safety, no testing utilities, and no shared patterns. claude-hook-kit fixes that.

import { defineHook, block, allow } from 'claude-hook-kit'

defineHook<'PreToolUse'>(async (event) => {
  if (event.tool_name === 'Bash') {
    const { command } = event.tool_input
    if (command.includes('rm -rf /')) {
      return block('Refusing to delete the filesystem')
    }
  }
  return allow()
}).run()

Installation

npm install claude-hook-kit

Requires Node.js ≥ 18.

What are Claude Code hooks?

Hooks are shell commands that Claude Code runs at specific lifecycle points. They receive a JSON payload on stdin and write a JSON response to stdout. Claude Code uses the response to decide whether to allow, block, or augment the operation.

Event When it fires Can block? Can inject context?
PreToolUse Before any tool call
PostToolUse After a tool call completes
SessionStart On startup, resume, clear, compact
UserPromptSubmit When the user submits a message
Stop When Claude finishes a response ✅ (force continue)

Register hooks in ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "node /path/to/my-hook.js"
      }]
    }]
  }
}

API

defineHook<Event>(handler)

The core function. Wraps your handler with stdin parsing, stdout serialization, and error handling.

import { defineHook, allow } from 'claude-hook-kit'

// Generic — no type parameter (untyped event)
defineHook(async (event) => allow()).run()

// Typed — full inference on the event payload
defineHook<'PreToolUse'>(async (event) => {
  console.error(event.tool_name, event.session_id)
  return allow()
}).run()

Call .run() at the bottom of your hook script. It reads stdin, calls your handler, writes stdout, and exits.

For unit testing, use .handle() instead:

const hook = defineHook<'PreToolUse'>(async (event) => {
  if (event.tool_name === 'Bash' && event.tool_input.command.includes('sudo')) {
    return block('No sudo')
  }
  return allow()
})

// Test without touching stdin/stdout
const response = await hook.handle({
  session_id: 'test',
  tool_name: 'Bash',
  tool_input: { command: 'sudo apt install vim' },
})
assert.deepEqual(response, { decision: 'block', reason: 'No sudo' })

Response builders

allow()

Lets the operation proceed. Equivalent to exiting 0 with no output.

block(reason?)

Prevents the operation. Claude sees reason as an error message and decides how to respond.

return block('This file is read-only')

inject(context)

Injects a string into Claude's context window. Claude reads this before continuing.

return inject('# Rule\nAlways use `const` instead of `let` in this codebase.')

Pattern matchers

matchesGlob(path, pattern)

Test a file path against a glob pattern. Supports *, **, ?, [...], {a,b}.

import { matchesGlob } from 'claude-hook-kit'

matchesGlob('src/components/Button.tsx', '**/*.tsx')  // true
matchesGlob('package.json', '*.json')                  // true
matchesGlob('README.md', '**/*.ts')                    // false

matchesRegex(text, pattern)

Test a string against a RegExp or regex string.

import { matchesRegex } from 'claude-hook-kit'

matchesRegex('git push origin main', /\bgit\s+push\b/)  // true
matchesRegex('npm install', 'npm\\s+install\\s+-g')      // false

matchesAnyGlob(path, patterns[])

matchesAnyRegex(text, patterns[])

Array variants — returns true if any pattern matches.


TypeScript types

All hook event payloads are fully typed. Import them directly:

import type {
  PreToolUseInput,
  PostToolUseInput,
  SessionStartInput,
  UserPromptSubmitInput,
  StopInput,
  BashToolInput,
  EditToolInput,
  WriteToolInput,
  HookResponse,
} from 'claude-hook-kit'

Extending tool types

If you use custom tools or MCP tools, extend ToolInputMap:

declare module 'claude-hook-kit' {
  interface ToolInputMap {
    MyCustomTool: { param: string; value: number }
  }
}

Examples

Bash guard — block dangerous commands

// bash-guard.ts
import { defineHook, block, allow, matchesAnyRegex } from 'claude-hook-kit'
import type { BashToolInput } from 'claude-hook-kit'

const BLOCKED = [
  /\brm\s+-[rRfF]*f[rRfF]*\s+\//,  // rm -rf /
  /\bcurl\b.*\|\s*(ba)?sh\b/,        // curl | sh
  />\s*\/etc\/passwd/,               // overwrite /etc/passwd
]

defineHook<'PreToolUse'>(async (event) => {
  if (event.tool_name !== 'Bash') return allow()
  const { command } = event.tool_input as BashToolInput
  if (matchesAnyRegex(command, BLOCKED)) {
    return block(`Blocked dangerous command: ${command.slice(0, 80)}`)
  }
  return allow()
}).run()

Skill injector — inject docs based on file patterns

// skill-injector.ts
import { defineHook, inject, allow, matchesAnyGlob } from 'claude-hook-kit'

defineHook<'PreToolUse'>(async (event) => {
  if (!['Read', 'Edit', 'Write'].includes(event.tool_name)) return allow()

  const { file_path } = event.tool_input as { file_path: string }

  if (matchesAnyGlob(file_path, ['**/*.prisma', '**/schema.prisma'])) {
    return inject('Remember: run `npx prisma generate` after every schema change.')
  }

  return allow()
}).run()

Session logger — record every tool call

// session-logger.ts
import { appendFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { defineHook, allow } from 'claude-hook-kit'

const LOG_DIR = join(homedir(), '.claude', 'session-logs')

defineHook<'PostToolUse'>(async (event) => {
  mkdirSync(LOG_DIR, { recursive: true })
  const line = JSON.stringify({
    ts: new Date().toISOString(),
    tool: event.tool_name,
    session: event.session_id,
  })
  appendFileSync(join(LOG_DIR, `${event.session_id}.jsonl`), line + '\n')
  return allow()
}).run()

Startup context injection

// startup-context.ts
import { defineHook, inject, allow } from 'claude-hook-kit'
import { readFileSync } from 'node:fs'

defineHook<'SessionStart'>(async (event) => {
  if (event.trigger !== 'startup') return allow()

  const rules = readFileSync('./ARCHITECTURE.md', 'utf8')
  return inject(`# Project Architecture\n\n${rules}`)
}).run()

Running hook scripts

Hook scripts need to be executable Node.js files. The simplest approach is to compile with tsc first:

# Compile
npx tsc --outDir dist examples/bash-guard.ts

# Register in settings.json
# "command": "node /path/to/dist/bash-guard.js"

Or use tsx for zero-compile TypeScript:

# "command": "npx tsx /path/to/bash-guard.ts"

License

MIT

About

TypeScript SDK for building Claude Code hooks — typed, testable, composable

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors