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
- Single responsibility: Each gate does one thing well
- Fast execution: Gates run synchronously in hook flow
- Graceful failures: Return empty result
{}on non-critical errors - Logging: Use logger for debugging, not console
- Type safety: Leverage TypeScript interfaces
- Documentation: Add JSDoc comments explaining gate purpose