Files
2025-11-30 09:02:16 +08:00

7.3 KiB

TypeScript Gates

Guide to creating and working with TypeScript gates in the Turboshovel hook system.

Overview

TypeScript gates are gates defined without a command field in gates.json. They're implemented as TypeScript modules in hooks-app/src/gates/.

{
  "gates": {
    "commands": {
      "description": "Context-aware command injection",
      "on_pass": "CONTINUE",
      "on_fail": "CONTINUE"
    }
  }
}

When this gate runs, the system loads src/gates/commands.ts and calls its execute() function.

Built-in Gates

The plugin includes these TypeScript gates:

Gate Purpose Default Hook
plugin-path Verify plugin path resolution in subagents (manual)

Creating a TypeScript Gate

1. Create the Gate Module

Create hooks-app/src/gates/my-gate.ts:

import { HookInput, GateResult } from '../types';

/**
 * My custom gate
 *
 * Describe what this gate does and when it should be used.
 */
export async function execute(input: HookInput): Promise<GateResult> {
  // Access hook input data
  const { cwd, hook_event_name, tool_name, user_message } = input;

  // Your gate logic here
  const shouldPass = true;

  if (shouldPass) {
    return {
      additionalContext: 'Gate passed - injecting this context'
    };
  } else {
    return {
      decision: 'block',
      reason: 'Gate failed because...'
    };
  }
}

2. Register in Index

Add to hooks-app/src/gates/index.ts:

export * as pluginPath from './plugin-path';
export * as myGate from './my-gate';  // Add this line

Note: Gate name in gates.json uses kebab-case (my-gate), which maps to camelCase export (myGate).

3. Add to gates.json

Add to plugin/hooks/gates.json (for plugin default) or project .claude/gates.json:

{
  "gates": {
    "my-gate": {
      "description": "My custom gate",
      "on_pass": "CONTINUE",
      "on_fail": "BLOCK"
    }
  },
  "hooks": {
    "UserPromptSubmit": {
      "gates": ["my-gate"]
    }
  }
}

4. Build

cd plugin/hooks/hooks-app
npm run build

HookInput Interface

interface HookInput {
  hook_event_name: string;  // "PostToolUse", "UserPromptSubmit", etc.
  cwd: string;              // Current working directory

  // PostToolUse
  tool_name?: string;       // "Edit", "Write", etc.
  file_path?: string;       // File being edited

  // SubagentStop
  agent_name?: string;      // "rust-agent", "code-review-agent", etc.
  subagent_name?: string;   // Alternative agent name field
  output?: string;          // Agent output

  // UserPromptSubmit
  user_message?: string;    // User's prompt text

  // SlashCommand/Skill
  command?: string;         // "/code-review", etc.
  skill?: string;           // "executing-plans", etc.
}

GateResult Interface

interface GateResult {
  // Success - add context and continue
  additionalContext?: string;

  // Block agent from proceeding
  decision?: 'block';
  reason?: string;

  // Stop Claude entirely
  continue?: false;
  message?: string;
}

Return Values

Pass with context injection:

return {
  additionalContext: 'This content is injected into the conversation'
};

Pass silently:

return {};

Block execution:

return {
  decision: 'block',
  reason: 'Cannot proceed because...'
};

Stop Claude entirely:

return {
  continue: false,
  message: 'Stopping because...'
};

Accessing Session State

Gates can read/write session state for cross-hook coordination:

import { HookInput, GateResult } from '../types';
import { Session } from '../session';

export async function execute(input: HookInput): Promise<GateResult> {
  const session = new Session(input.cwd);

  // Read state
  const activeCommand = await session.get('active_command');
  const editedFiles = await session.get('edited_files');

  // Write state
  await session.set('active_command', '/my-command');
  await session.append('edited_files', '/path/to/file.ts');

  // Check array membership
  const hasRustFiles = await session.contains('file_extensions', 'rs');

  return {};
}

Using the Logger

import { logger } from '../logger';

export async function execute(input: HookInput): Promise<GateResult> {
  await logger.debug('Gate starting', { input });
  await logger.info('Processing', { file: input.file_path });
  await logger.warn('Potential issue', { reason: '...' });
  await logger.error('Gate failed', { error: '...' });

  return {};
}

Logs go to $TMPDIR/turboshovel/hooks-YYYY-MM-DD.log.

Example: Commands Gate

The built-in commands gate shows a complete implementation:

// src/gates/commands.ts
import { HookInput, GateResult } from '../types';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as yaml from 'js-yaml';

interface ClaudeMdFrontmatter {
  commands?: Record<string, string>;
}

async function parseClaudeMd(cwd: string): Promise<Record<string, string>> {
  const claudeMdPath = path.join(cwd, 'CLAUDE.md');
  try {
    const content = await fs.readFile(claudeMdPath, 'utf-8');
    const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
    if (!frontmatterMatch) return {};
    const frontmatter = yaml.load(frontmatterMatch[1]) as ClaudeMdFrontmatter;
    return frontmatter.commands || {};
  } catch {
    return {};
  }
}

function detectNeededCommands(userMessage: string): string[] {
  const needed: string[] = [];
  const lower = userMessage.toLowerCase();
  if (lower.includes('run project test command')) needed.push('test');
  if (lower.includes('run project check command')) needed.push('check');
  if (lower.includes('run project build command')) needed.push('build');
  return [...new Set(needed)];
}

export async function execute(input: HookInput): Promise<GateResult> {
  const commands = await parseClaudeMd(input.cwd);
  const needed = detectNeededCommands(input.user_message || '');

  if (needed.length === 0) return {};

  const lines = ['<project_commands>'];
  for (const cmd of needed) {
    if (commands[cmd]) {
      lines.push(`  <${cmd}>${commands[cmd]}</${cmd}>`);
    }
  }
  lines.push('</project_commands>');

  return { additionalContext: lines.join('\n') };
}

Development Workflow

Build

cd plugin/hooks/hooks-app
npm run build

Test Manually

# Test a hook event
echo '{"hook_event_name": "UserPromptSubmit", "cwd": "/path/to/project", "user_message": "Run project test command"}' | \
  CLAUDE_PLUGIN_ROOT=/path/to/plugin \
  node dist/cli.js

Run Tests

npm test

Watch Mode

npm run build -- --watch

Naming Conventions

gates.json TypeScript Export File
plugin-path pluginPath plugin-path.ts
my-custom-gate myCustomGate my-custom-gate.ts

The gate loader converts kebab-case to camelCase automatically.

Best Practices

  1. Single responsibility: Each gate does one thing well
  2. Fast execution: Gates run synchronously in hook flow
  3. Graceful failures: Return empty result {} on non-critical errors
  4. Logging: Use logger for debugging, not console
  5. Type safety: Leverage TypeScript interfaces
  6. Documentation: Add JSDoc comments explaining gate purpose