Agent skill

Typescript Hook Writer

by pr-pm in pr-pm/prpm

Expert guidance for developing Claude Code hooks in TypeScript with shared utilities, esbuild compilation, and Vitest testing - distributes compiled JS while maintaining TypeScript development…

MITAuto-check: notesTesting & QA

Install Typescript Hook Writer

skills CLI
$ npx skills add pr-pm/prpm --skill typescript-hook-writer -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install pr-pm/prpm typescript-hook-writer --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/pr-pm/prpm.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/typescript-hook-writer .claude/skills/typescript-hook-writer && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
typescript-hook-writer
GitHub stars
122
Token cost
~8.6k tokens
SKILL.md length
1,313 words
Files
1
Skills in repo
35
Repo updated
First seen
Licence
MIT

At a glance

Expert guidance for developing Claude Code hooks in TypeScript with shared utilities, esbuild compilation, and Vitest testing - distributes compiled JS while maintaining TypeScript development…

  • Works in 12 steps: package.json → tsconfig.json → vitest.config.ts → …
  • Tasks that involve Unit testing
  • SKILL.md covers When to Use This Skill, Why TypeScript for Hooks?, Project Structure and Setup: packages/hooks…, plus 5 more sections
  • Calls pnpm, npm and jq; reaches github.com

What it does

Typescript Hook Writer is an agent skill from pr-pm/prpm. Expert guidance for developing Claude Code hooks in TypeScript with shared utilities, esbuild compilation, and Vitest testing - distributes compiled JS while maintaining TypeScript development experience

Its SKILL.md is about 8.6k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Testing & QA, covering Unit testing and Hooks and plugins. It works with TypeScript, Vitest and Bash. The repository describes itself as: The universal registry for AI coding tools. The licence is MIT.

When your agent uses it

  • Tasks that involve Unit testing
  • Tasks that involve Hooks and plugins

Example prompts

  • “/typescript-hook-writer”

Requirements

  • Node.js

Workflow steps

12 steps, taken from the step headings in SKILL.md.

  1. package.json
  2. tsconfig.json
  3. vitest.config.ts
  4. Build Script: scripts/build-all-hooks.ts
  5. Create Hook Directory Structure
  6. Copy Shared Utilities
  7. Write Hook Implementation
  8. Create hook.json Configuration
  9. 5: Advanced Hook Configuration (Optional)
  10. Build the Hook
  11. Create Test File
  12. Run Tests

What it can do on your machine

Read from SKILL.md and the folder at commit 5f993e6. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Shell commands in SKILL.md call:

    • pnpm
    • npm
    • jq
    • prettier
    • node

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    Hosts in commands or code, which the agent is likely to contact:

    • github.com

    Also links to:

    • code.claude.com
    • typescriptlang.org
    • esbuild.github.io
    • vitest.dev
    • prpm.dev

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Typescript Hook Writer loads about 8.6k tokens when it runs. Until then it costs about 57 tokens; SKILL.md has 1,313 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~57
When it runs · the whole SKILL.md, loaded when a task matches
~8.6k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check: notes

The automated check noted patterns worth knowing about, such as sudo or a known installer.

  • NoteMentions a .env fileSKILL.md:1208
    const blockedPatterns = ['.env', '.env.*', '*.pem', '*.key', '*credentials*'];

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from pr-pm/prpm at commit 5f993e6, republished under its MIT licence (© pr-pm). 1,313 words, ~8,602 tokens.

Download SKILL.mdSave it as .claude/skills/typescript-hook-writer/SKILL.md (or your agent's skills folder).
name
typescript-hook-writer
description
Expert guidance for developing Claude Code hooks in TypeScript with shared utilities, esbuild compilation, and Vitest testing - distributes compiled JS while maintaining TypeScript development experience

TypeScript Hook Writer

Use this skill when developing Claude Code hooks in TypeScript. This skill ensures you maintain type safety, shared utilities, proper build pipeline, and comprehensive testing while distributing single-file JavaScript bundles to users.

When to Use This Skill

  • Creating new TypeScript hooks for Claude Code
  • Setting up the hooks development environment
  • Adding shared utilities for hooks
  • Writing tests for hooks with Vitest
  • Building hooks for distribution
  • Publishing TypeScript-based hooks as PRPM packages

Why TypeScript for Hooks?

Advantages over bash:

  • Type safety catches errors at compile time
  • Shared utility functions reduce code duplication
  • Better IDE support with autocomplete and refactoring
  • Easier to test with Vitest
  • More readable for complex logic
  • Strong validation with TypeScript interfaces

Trade-offs:

  • Requires build step (esbuild)
  • Slightly larger bundle size (~2-3KB vs bash)
  • Users still just need Node.js (no TypeScript dependency)

When to use TypeScript hooks:

  • Complex input validation or pattern matching
  • Hooks that share common logic
  • Hooks requiring automated testing
  • Teams familiar with TypeScript

When to stick with bash:

  • Simple one-off hooks (< 20 lines)
  • Hooks that just call other CLI tools
  • Extreme performance requirements (though difference is negligible)

Project Structure

packages/hooks/
├── package.json              # Build tooling (esbuild, tsx, vitest)
├── tsconfig.json             # TypeScript configuration
├── vitest.config.ts          # Test configuration
├── scripts/
│   └── build-all-hooks.ts    # Build script (compiles all hooks)
├── shared/
│   ├── types.ts              # Shared TypeScript interfaces
│   ├── hook-utils.ts         # Shared utility functions
│   └── hook-utils.test.ts    # Tests for shared utilities

# Each hook lives in .claude/hooks/
.claude/hooks/
├── my-hook/
│   ├── hook.json             # Hook configuration
│   ├── README.md             # Documentation
│   ├── src/
│   │   ├── hook.ts           # TypeScript source (development)
│   │   ├── hook.test.ts      # Vitest tests
│   │   ├── hook-utils.ts     # Copied shared utilities
│   │   └── types.ts          # Copied shared types
│   └── dist/
│       └── hook.js           # Compiled bundle (distributed)

Setup: packages/hooks Infrastructure

1. package.json
json
{
  "name": "@prpm/hooks",
  "version": "1.0.0",
  "description": "Build system and shared utilities for PRPM Claude Code hooks",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsx scripts/build-all-hooks.ts",
    "build:watch": "tsx scripts/build-all-hooks.ts --watch",
    "test": "vitest",
    "test:watch": "vitest --watch",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  },
  "devDependencies": {
    "@types/node": "^20.10.0",
    "esbuild": "^0.19.8",
    "tsx": "^4.7.0",
    "typescript": "^5.3.3",
    "vitest": "^1.0.4"
  }
}
2. tsconfig.json
json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "lib": ["ES2022"],
    "moduleResolution": "node",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "types": ["node"]
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
3. vitest.config.ts
typescript
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['**/*.test.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'dist/',
        '**/*.test.ts',
        '**/scripts/**',
      ],
    },
  },
});
4. Build Script: scripts/build-all-hooks.ts
typescript
#!/usr/bin/env tsx
/**
 * Build script for compiling all Claude Code hooks to standalone JavaScript bundles
 */

import { build, BuildOptions } from 'esbuild';
import { readdirSync, statSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Root directory is app/ which is 3 levels up from packages/hooks/scripts/
const ROOT_DIR = join(__dirname, '../../..');
const HOOKS_DIR = join(ROOT_DIR, '.claude/hooks');

interface HookInfo {
  name: string;
  srcPath: string;
  distPath: string;
}

/**
 * Find all hooks with TypeScript source files
 */
function findHooks(): HookInfo[] {
  const hooks: HookInfo[] = [];

  if (!existsSync(HOOKS_DIR)) {
    console.error(`Hooks directory not found: ${HOOKS_DIR}`);
    return hooks;
  }

  const entries = readdirSync(HOOKS_DIR);

  for (const entry of entries) {
    const hookPath = join(HOOKS_DIR, entry);

    // Skip non-directories and special directories
    if (!statSync(hookPath).isDirectory() || entry === 'shared') {
      continue;
    }

    const srcPath = join(hookPath, 'src/hook.ts');
    const distPath = join(hookPath, 'dist/hook.js');

    if (existsSync(srcPath)) {
      hooks.push({
        name: entry,
        srcPath,
        distPath,
      });
    }
  }

  return hooks;
}

/**
 * Build a single hook
 */
async function buildHook(hook: HookInfo): Promise<void> {
  const buildOptions: BuildOptions = {
    entryPoints: [hook.srcPath],
    bundle: true,
    platform: 'node',
    target: 'node18',
    outfile: hook.distPath,
    format: 'cjs',
    minify: false, // Keep readable for debugging
    sourcemap: false,
    logLevel: 'error',
    banner: {
      js: '#!/usr/bin/env node',
    },
  };

  try {
    // Ensure dist directory exists
    const distDir = dirname(hook.distPath);
    if (!existsSync(distDir)) {
      mkdirSync(distDir, { recursive: true });
    }

    await build(buildOptions);
    console.log(`✓ Built ${hook.name}`);

    // Make the output file executable
    const { chmodSync } = await import('fs');
    chmodSync(hook.distPath, 0o755);
  } catch (error) {
    console.error(`✗ Failed to build ${hook.name}:`, error);
    throw error;
  }
}

/**
 * Build all hooks
 */
async function buildAll(watch: boolean = false): Promise<void> {
  console.log('🔨 Building PRPM Claude Code hooks...\n');

  const hooks = findHooks();

  if (hooks.length === 0) {
    console.log('No hooks found to build.');
    return;
  }

  console.log(`Found ${hooks.length} hooks:\n`);

  // Build all hooks in parallel
  try {
    await Promise.all(hooks.map(hook => buildHook(hook)));
    console.log(`\n✓ Built ${hooks.length} hooks successfully`);
  } catch (error) {
    console.error('\n✗ Build failed');
    process.exit(1);
  }

  if (watch) {
    console.log('\n👀 Watching for changes...');
    console.log('⚠️  Watch mode not yet implemented. Run `npm run build` after changes.');
  }
}

// Parse command line args
const args = process.argv.slice(2);
const watch = args.includes('--watch') || args.includes('-w');

// Run build
buildAll(watch).catch(error => {
  console.error('Build error:', error);
  process.exit(1);
});

Shared Utilities Pattern

Why Copy Instead of Import?

Each hook gets its own copy of shared utilities rather than importing from a shared package:

Benefits:

  • Each hook is a standalone single-file bundle
  • No external dependencies at runtime
  • Simpler distribution (just one .js file)
  • No module resolution issues
  • Each hook can be updated independently

Trade-off:

  • Slight code duplication (~1-2KB per hook)
  • Changes to shared utilities require rebuilding all hooks
shared/types.ts

Define TypeScript interfaces for hook input, exit codes, and options:

typescript
/**
 * Shared TypeScript types for Claude Code hooks
 */

export interface HookInput {
  session_id?: string;
  transcript_path?: string;
  current_dir?: string;
  input?: {
    file_path?: string;
    command?: string;
    content?: string;
    old_string?: string;
    new_string?: string;
    [key: string]: any;
  };
  message?: string;
  tool?: string;
  [key: string]: any;
}

export enum HookExitCode {
  Success = 0,    // Continue operation
  Error = 1,      // Log error but continue
  Block = 2,      // Block operation (PreToolUse only)
}

export interface ExecOptions {
  skipOnMissing?: boolean;  // Exit successfully if command not found
  background?: boolean;     // Run in background (don't wait)
  timeout?: number;         // Timeout in milliseconds
  env?: Record<string, string>; // Environment variables
}

export interface PatternMatch {
  matched: boolean;
  pattern?: string;
}
shared/hook-utils.ts

Common utility functions used across hooks:

typescript
import { readFileSync, appendFileSync, existsSync } from 'fs';
import { execSync } from 'child_process';
import type { HookInput, HookExitCode, ExecOptions, PatternMatch } from './types';

/**
 * Read and parse JSON from stdin
 */
export function readStdin(): HookInput {
  try {
    const input = readFileSync(0, 'utf-8');
    return JSON.parse(input);
  } catch (error) {
    return {};
  }
}

/**
 * Extract file path from hook input
 */
export function getFilePath(input: HookInput): string | undefined {
  return input.input?.file_path;
}

/**
 * Extract command from hook input
 */
export function getCommand(input: HookInput): string | undefined {
  return input.input?.command;
}

/**
 * Extract content from hook input
 */
export function getContent(input: HookInput): string | undefined {
  return input.input?.content || input.input?.new_string;
}

/**
 * Check if file has one of the specified extensions
 */
export function hasExtension(filePath: string, extensions: string[]): boolean {
  return extensions.some(ext => filePath.endsWith(ext));
}

/**
 * Check if a command exists in PATH
 */
export function commandExists(command: string): boolean {
  try {
    execSync(`command -v ${command}`, { stdio: 'ignore' });
    return true;
  } catch {
    return false;
  }
}

/**
 * Execute a command with options
 */
export function execCommand(
  command: string,
  args: string[],
  options: ExecOptions = {}
): void {
  // Check if command exists if skipOnMissing is true
  if (options.skipOnMissing && !commandExists(command)) {
    return;
  }

  const fullCommand = `${command} ${args.map(arg => `"${arg}"`).join(' ')}`;

  if (options.background) {
    // Run in background - don't wait for completion
    execSync(`(${fullCommand} &)`, {
      stdio: 'ignore',
      timeout: options.timeout,
      env: { ...process.env, ...options.env },
    });
  } else {
    // Run synchronously
    execSync(fullCommand, {
      stdio: 'inherit',
      timeout: options.timeout,
      env: { ...process.env, ...options.env },
    });
  }
}

/**
 * Match file path against glob patterns
 */
export function matchesPattern(filePath: string, patterns: string[]): PatternMatch {
  for (const pattern of patterns) {
    // Convert glob pattern to regex
    const regexPattern = pattern
      .replace(/\./g, '\\.')
      .replace(/\*/g, '.*')
      .replace(/\?/g, '.');

    if (new RegExp(`^${regexPattern}$`).test(filePath)) {
      return { matched: true, pattern };
    }
  }

  return { matched: false };
}

/**
 * Append line to log file
 */
export function appendToLog(logFile: string, line: string): void {
  try {
    appendFileSync(logFile, line + '\n', 'utf-8');
  } catch {
    // Fail silently
  }
}

/**
 * Get current timestamp in YYYY-MM-DD HH:MM:SS format
 */
export function getTimestamp(): string {
  return new Date().toISOString().replace('T', ' ').substring(0, 19);
}

/**
 * Log error message to stderr
 */
export function logError(message: string): void {
  console.error(message);
}

/**
 * Log warning message to stderr
 */
export function logWarning(message: string): void {
  console.error(message);
}

/**
 * Exit hook with specified code
 */
export function exitHook(code: HookExitCode): never {
  process.exit(code);
}

// Re-export HookExitCode for convenience
export { HookExitCode } from './types';

Creating a TypeScript Hook

Step 1: Create Hook Directory Structure
bash
mkdir -p .claude/hooks/my-hook/src
mkdir -p .claude/hooks/my-hook/dist
Step 2: Copy Shared Utilities

Copy shared/types.ts and shared/hook-utils.ts into the hook's src/ directory:

bash
cp packages/hooks/shared/types.ts .claude/hooks/my-hook/src/
cp packages/hooks/shared/hook-utils.ts .claude/hooks/my-hook/src/

Why copy instead of symlink? Each hook becomes a standalone bundle when compiled. The build process bundles utilities into the final .js file.

Step 3: Write Hook Implementation

.claude/hooks/my-hook/src/hook.ts:

typescript
#!/usr/bin/env tsx
/**
 * My Hook
 * Description of what this hook does
 */

import {
  readStdin,
  getFilePath,
  hasExtension,
  execCommand,
  logError,
  logWarning,
  exitHook,
  HookExitCode,
} from './hook-utils';

async function main() {
  // Read input from stdin
  const input = readStdin();

  // Extract file path
  const filePath = getFilePath(input);
  if (!filePath) {
    exitHook(HookExitCode.Success);
  }

  // Validate file extension
  const supportedExtensions = ['.ts', '.tsx', '.js', '.jsx'];
  if (!hasExtension(filePath, supportedExtensions)) {
    exitHook(HookExitCode.Success);
  }

  // Perform hook action
  try {
    execCommand('prettier', ['--write', filePath], {
      skipOnMissing: true,
      background: true,
    });

    exitHook(HookExitCode.Success);
  } catch (error) {
    logError(`Failed to format ${filePath}: ${error}`);
    exitHook(HookExitCode.Error);
  }
}

main().catch(() => {
  exitHook(HookExitCode.Success); // Don't block on errors
});
Step 4: Create hook.json Configuration

.claude/hooks/my-hook/hook.json:

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/my-hook/dist/hook.js",
            "timeout": 5000
          }
        ]
      }
    ]
  }
}

Important: Reference dist/hook.js (compiled), not src/hook.ts (source).

Step 4.5: Advanced Hook Configuration (Optional)

All hook types support optional fields for controlling execution behavior:

json
{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Edit|Write",
      "hooks": [{
        "type": "command",
        "command": "node .claude/hooks/my-hook/dist/hook.js",
        "timeout": 5000,
        "continue": true,              // Whether Claude continues after hook (default: true)
        "stopReason": "string",        // Message shown when continue is false
        "suppressOutput": false,       // Hide stdout from transcript (default: false)
        "systemMessage": "string"      // Warning message shown to user
      }]
    }]
  }
}
continue (boolean, default: true)

Controls whether Claude continues after hook execution.

When to use false:

  • Security hooks that must block operations
  • Validation hooks that found critical errors
  • Hooks that require user intervention
json
{
  "type": "command",
  "command": "node .claude/hooks/security-validator/dist/hook.js",
  "continue": false,
  "stopReason": "Security validation failed. Please review the detected issues before proceeding."
}

Exit code interaction:

  • If hook exits with HookExitCode.Block (2): continue is ignored, operation is blocked
  • If hook exits with HookExitCode.Success (0) or HookExitCode.Error (1): continue field determines behavior
stopReason (string)

Message displayed to user when continue: false. Should explain why execution stopped and what action is needed.

json
{
  "continue": false,
  "stopReason": "Pre-commit checks failed. Fix linting errors and try again."
}
suppressOutput (boolean, default: false)

Hides hook stdout from transcript mode (Ctrl-R). Stderr is always shown.

When to use true:

  • Hooks that produce verbose output
  • Debugging logs not useful to users
  • Noisy background operations
json
{
  "type": "command",
  "command": "node .claude/hooks/cloud-sync/dist/hook.js",
  "suppressOutput": true  // Don't show sync progress in transcript
}

Note: Always show critical errors via stderr (use logError()), as stderr is never suppressed.

systemMessage (string)

Warning or info message shown to user when hook executes. Useful for non-blocking warnings.

TypeScript example:

typescript
// In your hook.ts
if (outdatedDeps.length > 0) {
  logWarning(`Found ${outdatedDeps.length} outdated dependencies`);
  // systemMessage in hook.json will also show to user
}
json
{
  "type": "command",
  "command": "node .claude/hooks/dependency-checker/dist/hook.js",
  "systemMessage": "⚠️  Some dependencies are outdated. Consider running 'npm update'."
}

Difference from stopReason:

  • systemMessage: Informational, Claude continues
  • stopReason: Critical, requires continue: false
Step 5: Build the Hook
bash
cd packages/hooks
pnpm build

Output:

🔨 Building PRPM Claude Code hooks...

Found 1 hooks:

✓ Built my-hook

✓ Built 1 hooks successfully

The compiled hook is now at .claude/hooks/my-hook/dist/hook.js (~2-3KB single file).

Testing TypeScript Hooks

Step 1: Create Test File

.claude/hooks/my-hook/src/hook.test.ts:

typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock the hook-utils module
vi.mock('./hook-utils', () => ({
  readStdin: vi.fn(),
  getFilePath: vi.fn(),
  hasExtension: vi.fn(),
  execCommand: vi.fn(),
  logError: vi.fn(),
  exitHook: vi.fn((code: number) => {
    throw new Error(`EXIT_${code}`);
  }),
  HookExitCode: {
    Success: 0,
    Error: 1,
    Block: 2,
  },
}));

describe('my-hook', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should exit successfully if no file path is provided', async () => {
    const { readStdin, getFilePath, exitHook, HookExitCode } = await import('./hook-utils');

    vi.mocked(readStdin).mockReturnValue({});
    vi.mocked(getFilePath).mockReturnValue(undefined);

    try {
      await import('./hook');
    } catch (error: any) {
      expect(error.message).toBe('EXIT_0');
    }

    expect(exitHook).toHaveBeenCalledWith(HookExitCode.Success);
  });

  it('should format supported file types', async () => {
    const { readStdin, getFilePath, hasExtension, execCommand, exitHook, HookExitCode } = await import('./hook-utils');

    vi.mocked(readStdin).mockReturnValue({
      input: { file_path: '/path/to/file.ts' },
    });
    vi.mocked(getFilePath).mockReturnValue('/path/to/file.ts');
    vi.mocked(hasExtension).mockReturnValue(true);

    try {
      await import('./hook');
    } catch (error: any) {
      expect(error.message).toBe('EXIT_0');
    }

    expect(execCommand).toHaveBeenCalledWith(
      'prettier',
      ['--write', '/path/to/file.ts'],
      expect.objectContaining({ background: true })
    );
  });

  it('should skip unsupported file types', async () => {
    const { readStdin, getFilePath, hasExtension, execCommand, exitHook } = await import('./hook-utils');

    vi.mocked(readStdin).mockReturnValue({
      input: { file_path: '/path/to/file.py' },
    });
    vi.mocked(getFilePath).mockReturnValue('/path/to/file.py');
    vi.mocked(hasExtension).mockReturnValue(false);

    try {
      await import('./hook');
    } catch (error: any) {
      // Expected exit
    }

    expect(execCommand).not.toHaveBeenCalled();
  });
});
Step 2: Run Tests
bash
cd packages/hooks
pnpm test:run

Output:

✓ .claude/hooks/my-hook/src/hook.test.ts (3 tests) 5ms

Test Files  1 passed (1)
Tests  3 passed (3)
Duration  182ms
Step 3: Run Tests with Coverage
bash
pnpm test:coverage

Coverage report shows which code paths are tested.

Build Workflow: TypeScript → JavaScript

Development vs Distribution

Development files (NOT distributed):

  • .claude/hooks/my-hook/src/hook.ts - TypeScript source
  • .claude/hooks/my-hook/src/hook.test.ts - Tests
  • .claude/hooks/my-hook/src/types.ts - Type definitions
  • .claude/hooks/my-hook/src/hook-utils.ts - Utilities

Distribution files (what users get):

  • .claude/hooks/my-hook/dist/hook.js - Compiled JavaScript bundle (~2-3KB)
  • .claude/hooks/my-hook/hook.json - Hook configuration
  • .claude/hooks/my-hook/README.md - Documentation
Build Process

The build script (packages/hooks/scripts/build-all-hooks.ts) does the following:

  1. Scans .claude/hooks/ for directories with src/hook.ts
  2. Compiles each hook with esbuild:
    • Bundles all imports into single file
    • Converts TypeScript to JavaScript
    • Targets Node.js 18+
    • Outputs CommonJS format
    • Adds #!/usr/bin/env node shebang
  3. Outputs to dist/hook.js in each hook directory
  4. Sets permissions to make file executable (chmod +x)
When to Build

Automatic build:

  • Publishing with prpm publish - prepublishOnly script builds automatically (if configured)

Manual build for:

  • Testing hook locally before committing
  • Debugging compiled output
  • Verifying build succeeds before pushing

How to build manually:

bash
# Build all hooks once
cd packages/hooks
pnpm build

# Output:
# 🔨 Building PRPM Claude Code hooks...
# Found 7 hooks:
# ✓ Built prettier-on-save
# ✓ Built command-logger
# ...
# ✓ Built 7 hooks successfully

Build output structure:

.claude/hooks/my-hook/
├── src/
│   ├── hook.ts           ← TypeScript source (input)
│   ├── hook-utils.ts
│   └── types.ts
└── dist/
    └── hook.js           ← Compiled JavaScript (output)

What gets bundled:

esbuild traces all imports and bundles them into a single dist/hook.js:

typescript
// src/hook.ts imports these
import { readStdin, getFilePath } from './hook-utils';
import { HookExitCode } from './types';

// dist/hook.js contains:
// - All code from hook.ts
// - All code from hook-utils.ts
// - All type definitions (compiled to runtime checks)
// - No external dependencies
// - Total size: ~2-3KB

Why this approach:

  • ✅ Users don't need TypeScript or build tools
  • ✅ Single-file distribution is simple
  • ✅ No runtime dependencies (just Node.js)
  • ✅ Hooks load instantly (no module resolution)
  • ✅ Each hook is independent

Publishing TypeScript Hooks as PRPM Packages

Step 1: Build Hooks

IMPORTANT: Always build before updating prpm.json or publishing:

bash
cd packages/hooks
pnpm build

Verify dist files exist:

bash
ls -lh .claude/hooks/*/dist/hook.js
# Should show compiled hooks with ~2-3KB size each
Step 2: Update prpm.json

Add the hook to the root prpm.json:

json
{
  "packages": [
    {
      "name": "my-hook",
      "version": "1.0.0",
      "description": "Brief description of what the hook does",
      "format": "claude",
      "subtype": "hook",
      "tags": ["formatting", "automation", "typescript"],
      "files": [
        ".claude/hooks/my-hook/hook.json",
        ".claude/hooks/my-hook/dist/hook.js",
        ".claude/hooks/my-hook/README.md"
      ]
    }
  ]
}

Important files array:

  • hook.json - Hook configuration
  • dist/hook.js - Compiled JavaScript (NOT src/hook.ts)
  • README.md - Documentation

Do NOT include:

  • src/ directory (source code)
  • *.test.ts files
  • node_modules/
  • Development files
Show full SKILL.md (512 more words)Show less
Step 2: Create README.md

.claude/hooks/my-hook/README.md:

markdown
# My Hook

Brief description of what this hook does.

## What It Does

- Automatically formats code after Claude edits files
- Supports TypeScript, JavaScript, JSON, and Markdown
- Runs in background (non-blocking)
- Gracefully skips if Prettier not installed

## Installation

```bash
prpm install @prpm/my-hook

Requirements

  • Node.js 18+ (already required for Claude Code)
  • Prettier (optional): npm install -g prettier

Configuration

This hook activates on PostToolUse for Edit and Write tools.

To customize supported file extensions, fork and modify the source.

Examples

When Claude writes a TypeScript file:

Claude: I'll create a new component...
[Hook auto-formats component.tsx with Prettier]

Troubleshooting

Hook not running?

  • Check .claude/settings.json includes the hook
  • Verify dist/hook.js exists and is executable
  • Check transcript (Ctrl-R) for hook errors

Format not applying?

  • Ensure Prettier is installed: prettier --version
  • Check Prettier config in project (.prettierrc)

### Step 3: Set Up Automatic Build Before Publishing

**Good news:** PRPM now supports `prepublishOnly` scripts! Add this to your prpm.json:

```json
{
  "name": "prpm-packages",
  "license": "MIT",
  "repository": "https://github.com/username/repo",
  "scripts": {
    "prepublishOnly": "cd packages/hooks && npm run build"
  },
  "packages": [
    // ... your packages
  ]
}

What happens:

  • When you run prpm publish, the prepublishOnly script runs automatically
  • Hooks are compiled from TypeScript to JavaScript
  • If the build fails, publish is aborted (prevents publishing broken code)
  • If the build succeeds, publishing continues with up-to-date dist files

Manual build (for local testing):

bash
cd packages/hooks
npm run build

Verify dist/hook.js files exist:

bash
ls -lh .claude/hooks/*/dist/hook.js
# Should show compiled hooks with ~2-3KB size each
Step 4: Publish
bash
# From project root
prpm publish

What happens automatically:

  1. prepublishOnly script runs: cd packages/hooks && npm run build
  2. All hooks are compiled to dist/hook.js
  3. Packages are published with up-to-date compiled files

Users will receive:

  • hook.json - Configuration
  • dist/hook.js - Single-file executable (~2-3KB)
  • README.md - Documentation

Users do NOT get:

  • TypeScript source (src/ directory)
  • Build tooling
  • Tests

Why this matters: The prepublishOnly script prevents publishing stale dist files. If you modify a hook's TypeScript source but forget to build, the build happens automatically before publish.

Best Practices

1. Keep Hooks Fast

Target < 100ms execution time. Use background execution for slow operations:

typescript
// BAD - blocks for 5 seconds
execCommand('npm', ['test']);

// GOOD - runs in background
execCommand('npm', ['test'], { background: true });
2. Fail Gracefully

Never crash. Handle missing tools:

typescript
execCommand('prettier', ['--write', filePath], {
  skipOnMissing: true,  // Exit successfully if prettier not found
  background: true,
});
3. Use Type Guards

Validate input shape:

typescript
function isValidInput(input: HookInput): boolean {
  return !!(input.input?.file_path && typeof input.input.file_path === 'string');
}

if (!isValidInput(input)) {
  exitHook(HookExitCode.Success);
}
4. Copy Shared Utilities

Always copy (not import) shared utilities into each hook's src/ directory:

bash
cp packages/hooks/shared/{types.ts,hook-utils.ts} .claude/hooks/my-hook/src/

This ensures standalone compilation.

5. Test Edge Cases

Test with edge cases:

typescript
it('should handle files with spaces', async () => {
  const input = { input: { file_path: '/path/my file.ts' } };
  // ...
});

it('should handle Unicode filenames', async () => {
  const input = { input: { file_path: '/path/文件.ts' } };
  // ...
});

it('should handle missing input fields', async () => {
  const input = { input: {} };
  // ...
});
6. Use Descriptive Exit Codes
typescript
// Success - continue operation
exitHook(HookExitCode.Success);

// Block - prevent operation (PreToolUse only)
exitHook(HookExitCode.Block);

// Error - log but continue
exitHook(HookExitCode.Error);
7. Log to stderr
typescript
// WRONG - pollutes transcript
console.log('Processing file...');

// RIGHT - logs to stderr
logError('⚠️  Warning: something happened');
logWarning('ℹ️  Info: skipping file');

Common Patterns

Pattern: File Extension Filter
typescript
const supportedExtensions = ['.ts', '.tsx', '.js', '.jsx'];

if (!hasExtension(filePath, supportedExtensions)) {
  exitHook(HookExitCode.Success);
}
Pattern: Sensitive File Blocker
typescript
const blockedPatterns = ['.env', '.env.*', '*.pem', '*.key', '*credentials*'];

const match = matchesPattern(filePath, blockedPatterns);
if (match.matched) {
  logError(`⛔ Blocked: Cannot modify sensitive file '${filePath}'`);
  logError(`   Pattern: ${match.pattern}`);
  exitHook(HookExitCode.Block);
}
Pattern: Command Logger
typescript
import { join } from 'path';
import { homedir } from 'os';

const command = getCommand(input);
if (!command) {
  exitHook(HookExitCode.Success);
}

const logFile = join(homedir(), '.claude-commands.log');
const logLine = `[${getTimestamp()}] ${command}`;

appendToLog(logFile, logLine);
exitHook(HookExitCode.Success);
Pattern: Content Validator
typescript
const content = getContent(input);
if (!content) {
  exitHook(HookExitCode.Success);
}

const dangerousPatterns = [
  /password\s*=\s*["'][^"']+["']/i,
  /api[_-]?key\s*=\s*["'][^"']+["']/i,
  /AKIA[0-9A-Z]{16}/, // AWS access key
];

for (const pattern of dangerousPatterns) {
  if (pattern.test(content)) {
    logWarning(`⚠️  Warning: Potential credential detected in ${filePath}`);
    logWarning(`   Pattern matched: ${pattern}`);
    break;
  }
}

exitHook(HookExitCode.Success);

Debugging TypeScript Hooks

1. Test Compilation
bash
cd packages/hooks
pnpm build

Check for TypeScript errors.

2. Test Execution Manually
bash
echo '{"input":{"file_path":"/tmp/test.ts"}}' | node .claude/hooks/my-hook/dist/hook.js
echo $?  # Check exit code
3. Check Hook Registration

Verify hook appears in .claude/settings.json:

bash
cat .claude/settings.json | jq '.hooks'
4. View Transcript

Run Claude Code and check transcript (Ctrl-R) for hook execution:

PostToolUse hook: my-hook
  command: node .claude/hooks/my-hook/dist/hook.js
  exit: 0
  duration: 47ms
5. Add Debug Logging

Temporarily add debug output:

typescript
logError(`[DEBUG] Processing file: ${filePath}`);
logError(`[DEBUG] Extensions: ${JSON.stringify(supportedExtensions)}`);

Migration: Bash to TypeScript

Converting an existing bash hook to TypeScript:

Before (bash)
bash
#!/bin/bash
set -euo pipefail

INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')

if [[ -z "$FILE" ]]; then
  exit 0
fi

if ! command -v prettier &>/dev/null; then
  exit 0
fi

prettier --write "$FILE" &
exit 0
After (TypeScript)
typescript
#!/usr/bin/env tsx
import {
  readStdin,
  getFilePath,
  execCommand,
  exitHook,
  HookExitCode,
} from './hook-utils';

async function main() {
  const input = readStdin();
  const filePath = getFilePath(input);

  if (!filePath) {
    exitHook(HookExitCode.Success);
  }

  execCommand('prettier', ['--write', filePath], {
    skipOnMissing: true,
    background: true,
  });

  exitHook(HookExitCode.Success);
}

main().catch(() => exitHook(HookExitCode.Success));

Benefits:

  • Type safety for input object
  • Shared utilities reduce code
  • Easier to test
  • Better IDE support

Quick Reference

Build Commands
bash
pnpm build              # Build all hooks once
pnpm build:watch        # Watch mode (not yet implemented)
pnpm test               # Run tests in watch mode
pnpm test:run           # Run tests once
pnpm test:coverage      # Run tests with coverage
Hook Configuration Fields

Required:

  • type - "command" or "prompt"
  • command - Path to compiled hook (e.g., "node .claude/hooks/my-hook/dist/hook.js")

Optional:

  • timeout - Max execution time in ms (default: 60000)
  • continue - Continue after hook? (default: true)
  • stopReason - Message when continue=false
  • suppressOutput - Hide stdout from transcript (default: false)
  • systemMessage - Warning message to user
Exit Codes (HookExitCode enum)
typescript
HookExitCode.Success = 0    // Continue operation
HookExitCode.Error = 1      // Log error but continue
HookExitCode.Block = 2      // Block operation (PreToolUse only)
Hook Structure Checklist
  • Created .claude/hooks/my-hook/src/hook.ts
  • Copied types.ts and hook-utils.ts to src/
  • Created hook.json referencing dist/hook.js
  • Created README.md with installation and usage
  • Created hook.test.ts with test coverage
  • Built hook with pnpm build
  • Verified dist/hook.js exists and is executable
  • Added to prpm.json with correct files array
  • Tested manually with sample JSON input
  • Tested in real Claude Code session
prpm.json Files Array

Include:

  • .claude/hooks/my-hook/hook.json
  • .claude/hooks/my-hook/dist/hook.js
  • .claude/hooks/my-hook/README.md

Exclude:

  • src/ directory
  • *.test.ts files
  • node_modules/
  • Development files
Common Utilities
typescript
readStdin()                          // Parse stdin JSON
getFilePath(input)                   // Extract file path
getCommand(input)                    // Extract command
getContent(input)                    // Extract content
hasExtension(path, ['.ts', '.js'])   // Check extension
matchesPattern(path, ['*.env'])      // Glob matching
commandExists('prettier')            // Check command exists
execCommand('cmd', ['arg'], opts)    // Execute command
appendToLog(file, line)              // Append to log
getTimestamp()                       // Current timestamp
logError(msg)                        // Log to stderr
logWarning(msg)                      // Log warning
exitHook(HookExitCode.Success)       // Exit with code

Resources

© pr-pm, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in .claude/skills/typescript-hook-writer of pr-pm/prpm.

Open the folder on GitHubat commit 5f993e6

Compare with similar skills

Typescript Hook Writer next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Typescript Hook Writer compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Typescript Hook Writer this skillpr-pm/prpm122—~8.6kAutomated safety check: NotesMIT
Test Writing WorkflowiOfficeAI/AionUi33k1 repos~1.2kAutomated safety check: PassApache-2.0
Ckeditor5 TestingTriliumNext/Trilium38k—~3.3kAutomated safety check: PassAGPL-3.0
Creating A Packagec15t/c15t1.9k—~913Automated safety check: PassApache-2.0
Svelte Testingspences10/sveltest113—~579Automated safety check: PassMIT
Effect TStellahq/opensession392—~3.7kAutomated safety check: PassMIT

Similar skills

  • Test Writing Workflow

    iOfficeAI/AionUi

    Sets the test-writing workflow for the repository: risk-first scenario lists, behavior-focused Vitest tests, a full run before each commit and a coverage target.

    33k GitHub starsUsed in 1 repo~1.2k tokens
    Testing & QAAuto-check passed
  • Ckeditor5 Testing

    TriliumNext/Trilium

    Testing CKEditor 5 plugins in the Trilium monorepo. An agent skill from TriliumNext/Trilium.

    38k GitHub stars~3.3k tokensUpdated today
    Testing & QAAuto-check passed
  • Scaffold a new workspace package in the c15t monorepo. An agent skill from c15t/c15t.

    1.9k GitHub stars~913 tokensUpdated today
    Testing & QAAuto-check passed
  • Svelte Testing

    spences10/sveltest

    Fix and create Svelte 5 tests with vitest-browser-svelte and Playwright.

    113 GitHub stars~579 tokensUpdated yesterday
    Testing & QAAuto-check passed
  • Effect TS

    tellahq/opensession

    Write idiomatic Effect v4 TypeScript verified against the pinned effect@4.0.0-rc.112 source.

    392 GitHub stars~3.7k tokensUpdated today
    Testing & QAAuto-check passed
  • Ha Frontend Testing

    home-assistant/frontend

    Home Assistant frontend testing and validation workflow. An agent skill from home-assistant/frontend.

    5.7k GitHub stars~1.7k tokensUpdated today
    Testing & QAAuto-check passed

More from pr-pm/prpm

All 35 skills in this repo
  • Reference for writing Claude Code agent files: location, frontmatter fields, validation limits, tool and model choices, and the required content format.

    122 GitHub starsUsed in 3 repos~4k tokens
    Auto-check passed
  • Covers how to build, configure and publish Claude Code hooks: event types, exit codes, JSON I/O, and PRPM packaging.

    122 GitHub starsUsed in 2 repos~3.9k tokens
    Auto-check: notes
  • Shows how to write .claude/rules/ files correctly: paths frontmatter instead of globs, quoted glob patterns, global rules and conversion of Cursor rules.

    122 GitHub starsUsed in 2 repos~551 tokens
    Auto-check passed
  • Reference for writing portable Agent Skills packages, covering SKILL.md frontmatter limits, name rules, directory layout and where Codex CLI, GitHub Copilot and Amp look for skills.

    122 GitHub starsUsed in 1 repo~1.5k tokens
    Auto-check passed
  • A skill your agent uses when building custom Kiro AI agents or when user asks for agent configurations - provides JSON structure, tool configuration, prompt patterns, and security best practices for…

    122 GitHub starsUsed in 1 repo~1.9k tokens
    Auto-check passed
  • A skill your agent uses when creating OpenCode agents - provides markdown format with YAML frontmatter, mode/tools/permission configuration, and best practices for specialized AI assistants

    122 GitHub starsUsed in 1 repo~2k tokens
    Auto-check passed

Questions about Typescript Hook Writer

What does Typescript Hook Writer do?

Expert guidance for developing Claude Code hooks in TypeScript with shared utilities, esbuild compilation, and Vitest testing - distributes compiled JS while maintaining TypeScript development…. Typescript Hook Writer is an agent skill from pr-pm/prpm.

When should I use Typescript Hook Writer?

Typescript Hook Writer fits situations like: tasks that involve Unit testing; tasks that involve Hooks and plugins.

How do I install Typescript Hook Writer in Claude Code?

Run `npx skills add pr-pm/prpm --skill typescript-hook-writer -a claude-code`. Or copy the skill folder (.claude/skills/typescript-hook-writer in pr-pm/prpm) into .claude/skills/typescript-hook-writer in your project. Claude Code loads it when a task matches its description.

How do I install Typescript Hook Writer in Codex?

Run `npx skills add pr-pm/prpm --skill typescript-hook-writer -a codex`. Or copy the skill folder (.claude/skills/typescript-hook-writer in pr-pm/prpm) into .agents/skills/typescript-hook-writer in your project. Codex loads it when a task matches its description.

Can I use Typescript Hook Writer in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add pr-pm/prpm --skill typescript-hook-writer -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/typescript-hook-writer, .gemini/skills/typescript-hook-writer, .github/skills/typescript-hook-writer and .opencode/skills/typescript-hook-writer in your project.

What does Typescript Hook Writer need to run?

Going by SKILL.md and its folder, Typescript Hook Writer needs the command-line tools its instructions call (pnpm, npm, jq, prettier and node). Our summary lists: Node.js.

Does Typescript Hook Writer access the network?

SKILL.md names 6 domains. In commands or code: github.com; the agent is likely to contact it when it follows the instructions. As links in the text: code.claude.com, typescriptlang.org, esbuild.github.io, vitest.dev and prpm.dev. This is read from the text; nothing was executed.

Is Typescript Hook Writer safe to install?

Our automated static check of SKILL.md found notes only (mentions a .env file), nothing it rates as a warning. It is not a guarantee. Review the folder before installing.

What licence does Typescript Hook Writer use?

Typescript Hook Writer is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Typescript Hook Writer use?

About 8.6k tokens (SKILL.md is roughly 34k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Typescript Hook Writer?

Skills that share tags, products or a category with Typescript Hook Writer: Test Writing Workflow (iOfficeAI/AionUi, 33k stars), Ckeditor5 Testing (TriliumNext/Trilium, 38k stars), Creating A Package (c15t/c15t, 1.9k stars) and Svelte Testing (spences10/sveltest, 113 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Typescript Hook Writer?

pr-pm (a GitHub organization) maintains it in pr-pm/prpm, which has 122 GitHub stars. The repository holds 35 skills in this directory. The repository was last updated on October 6, 2026.

Source: pr-pm/prpm on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.