483 lines
11 KiB
TypeScript
Executable File
483 lines
11 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
|
|
/**
|
|
* test-hook.ts - Test Claude Code hook scripts with sample input
|
|
*
|
|
* Usage:
|
|
* ./test-hook.ts <hook-script> [options]
|
|
* ./test-hook.ts validate-bash.sh --event PreToolUse --tool Bash
|
|
*/
|
|
|
|
import { existsSync, statSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { spawn } from "bun";
|
|
|
|
// ANSI colors
|
|
const colors = {
|
|
red: "\x1b[0;31m",
|
|
green: "\x1b[0;32m",
|
|
yellow: "\x1b[1;33m",
|
|
blue: "\x1b[0;34m",
|
|
reset: "\x1b[0m",
|
|
};
|
|
|
|
/**
|
|
* JSON input structure for Claude Code hooks.
|
|
*/
|
|
interface HookInput {
|
|
session_id: string;
|
|
transcript_path: string;
|
|
cwd: string;
|
|
hook_event_name: string;
|
|
tool_name?: string;
|
|
tool_input?: Record<string, unknown>;
|
|
reason?: string;
|
|
}
|
|
|
|
/**
|
|
* Options for testing a hook script.
|
|
*/
|
|
interface TestOptions {
|
|
event: string;
|
|
tool?: string;
|
|
filePath?: string;
|
|
content?: string;
|
|
command?: string;
|
|
reason?: string;
|
|
customInput?: string;
|
|
verbose: boolean;
|
|
timeout: number;
|
|
}
|
|
|
|
// Show help
|
|
function showHelp() {
|
|
console.log(`Usage: test-hook.ts <hook-script> [options]
|
|
|
|
Test Claude Code hook scripts with sample input.
|
|
|
|
Arguments:
|
|
hook-script Path to hook script to test
|
|
|
|
Options:
|
|
-e, --event Event type (default: PreToolUse)
|
|
PreToolUse, PostToolUse, UserPromptSubmit, Notification,
|
|
Stop, SubagentStop, PreCompact, SessionStart, SessionEnd
|
|
-t, --tool Tool name (e.g., Write, Edit, Bash)
|
|
-f, --file File path for tool input
|
|
-c, --content File content for Write tool
|
|
--command Command for Bash tool
|
|
-r, --reason Reason for session events
|
|
--input Custom JSON input (overrides all other options)
|
|
--timeout Timeout in milliseconds (default: 5000)
|
|
-v, --verbose Verbose output
|
|
-h, --help Show this help
|
|
|
|
Examples:
|
|
# Test PreToolUse hook with Bash tool
|
|
./test-hook.ts validate-bash.sh -e PreToolUse -t Bash --command "rm -rf /"
|
|
|
|
# Test PostToolUse hook with Write tool
|
|
./test-hook.ts format-code.sh -e PostToolUse -t Write -f test.ts -c "console.log('test');"
|
|
|
|
# Test with custom JSON input
|
|
./test-hook.ts my-hook.sh --input '{"tool_name":"Write","tool_input":{"file_path":"test.txt"}}'
|
|
|
|
# Test SessionStart hook
|
|
./test-hook.ts welcome.sh -e SessionStart -r startup
|
|
|
|
Event Types:
|
|
PreToolUse - Before tool execution (can block)
|
|
PostToolUse - After tool completes successfully
|
|
UserPromptSubmit - When user submits prompt
|
|
Notification - When notification sent
|
|
Stop - When main agent finishes
|
|
SubagentStop - When subagent finishes
|
|
PreCompact - Before conversation compacts
|
|
SessionStart - When session starts/resumes
|
|
SessionEnd - When session ends
|
|
`);
|
|
}
|
|
|
|
// Parse arguments
|
|
function parseArgs(): { scriptPath: string; options: TestOptions } {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
|
|
showHelp();
|
|
process.exit(0);
|
|
}
|
|
|
|
const scriptPath = args[0];
|
|
const options: TestOptions = {
|
|
event: "PreToolUse",
|
|
verbose: false,
|
|
timeout: 5000,
|
|
};
|
|
|
|
for (let i = 1; i < args.length; i++) {
|
|
const arg = args[i];
|
|
const next = args[i + 1];
|
|
|
|
switch (arg) {
|
|
case "-e":
|
|
case "--event":
|
|
options.event = next;
|
|
i++;
|
|
break;
|
|
case "-t":
|
|
case "--tool":
|
|
options.tool = next;
|
|
i++;
|
|
break;
|
|
case "-f":
|
|
case "--file":
|
|
options.filePath = next;
|
|
i++;
|
|
break;
|
|
case "-c":
|
|
case "--content":
|
|
options.content = next;
|
|
i++;
|
|
break;
|
|
case "--command":
|
|
options.command = next;
|
|
i++;
|
|
break;
|
|
case "-r":
|
|
case "--reason":
|
|
options.reason = next;
|
|
i++;
|
|
break;
|
|
case "--input":
|
|
options.customInput = next;
|
|
i++;
|
|
break;
|
|
case "--timeout": {
|
|
const parsed = parseInt(next, 10);
|
|
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
console.error(
|
|
`${colors.red}Error: --timeout must be a positive number${colors.reset}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
options.timeout = parsed;
|
|
i++;
|
|
break;
|
|
}
|
|
case "-v":
|
|
case "--verbose":
|
|
options.verbose = true;
|
|
break;
|
|
default:
|
|
console.error(
|
|
`${colors.red}Error: Unknown option ${arg}${colors.reset}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
return { scriptPath, options };
|
|
}
|
|
|
|
// Generate sample input based on event and options
|
|
function generateInput(options: TestOptions): HookInput {
|
|
const baseInput: HookInput = {
|
|
session_id: `test-session-${Date.now()}`,
|
|
transcript_path: "/tmp/transcript.jsonl",
|
|
cwd: process.cwd(),
|
|
hook_event_name: options.event,
|
|
};
|
|
|
|
// Add tool-specific fields
|
|
if (options.tool) {
|
|
baseInput.tool_name = options.tool;
|
|
|
|
// Generate appropriate tool_input
|
|
switch (options.tool) {
|
|
case "Write":
|
|
baseInput.tool_input = {
|
|
file_path: options.filePath || "/tmp/test-file.txt",
|
|
content: options.content || "Test content",
|
|
};
|
|
break;
|
|
|
|
case "Edit":
|
|
baseInput.tool_input = {
|
|
file_path: options.filePath || "/tmp/test-file.txt",
|
|
old_string: "old",
|
|
new_string: "new",
|
|
replace_all: false,
|
|
};
|
|
break;
|
|
|
|
case "Read":
|
|
baseInput.tool_input = {
|
|
file_path: options.filePath || "/tmp/test-file.txt",
|
|
offset: 0,
|
|
limit: 2000,
|
|
};
|
|
break;
|
|
|
|
case "Bash":
|
|
baseInput.tool_input = {
|
|
command: options.command || "echo 'test'",
|
|
description: "Test bash command",
|
|
};
|
|
break;
|
|
|
|
case "Grep":
|
|
baseInput.tool_input = {
|
|
pattern: "test",
|
|
path: options.filePath || ".",
|
|
};
|
|
break;
|
|
|
|
default:
|
|
baseInput.tool_input = {
|
|
file_path: options.filePath,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Add reason for session events
|
|
if (["SessionStart", "SessionEnd", "PreCompact"].includes(options.event)) {
|
|
baseInput.reason = options.reason || "test";
|
|
}
|
|
|
|
return baseInput;
|
|
}
|
|
|
|
// Run hook script with input
|
|
async function runHook(
|
|
scriptPath: string,
|
|
input: HookInput,
|
|
timeout: number,
|
|
verbose: boolean,
|
|
): Promise<{
|
|
exitCode: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
timedOut: boolean;
|
|
}> {
|
|
const inputJson = JSON.stringify(input, null, 2);
|
|
|
|
if (verbose) {
|
|
console.log(`${colors.blue}Input JSON:${colors.reset}`);
|
|
console.log(inputJson);
|
|
console.log();
|
|
}
|
|
|
|
// Spawn process
|
|
const proc = spawn({
|
|
cmd: [scriptPath],
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_PROJECT_DIR: process.cwd(),
|
|
},
|
|
});
|
|
|
|
// Write input to stdin
|
|
proc.stdin.write(inputJson);
|
|
proc.stdin.end();
|
|
|
|
let timedOut = false;
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
timedOut = true;
|
|
proc.kill();
|
|
}, timeout);
|
|
|
|
try {
|
|
const result = await proc.exited;
|
|
clearTimeout(timeoutId);
|
|
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
|
|
return {
|
|
exitCode: timedOut ? null : result,
|
|
stdout,
|
|
stderr,
|
|
timedOut,
|
|
};
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Main function
|
|
async function main() {
|
|
const { scriptPath, options } = parseArgs();
|
|
|
|
// Validate script path
|
|
const resolvedPath = resolve(scriptPath);
|
|
if (!existsSync(resolvedPath)) {
|
|
console.error(
|
|
`${colors.red}Error: Script not found: ${scriptPath}${colors.reset}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check if executable
|
|
const stats = statSync(resolvedPath);
|
|
if (!(stats.mode & 0o111)) {
|
|
console.error(
|
|
`${colors.yellow}Warning: Script is not executable${colors.reset}`,
|
|
);
|
|
console.error(`Run: chmod +x ${scriptPath}`);
|
|
console.log();
|
|
}
|
|
|
|
console.log(
|
|
`${colors.blue}Testing hook script: ${scriptPath}${colors.reset}`,
|
|
);
|
|
console.log();
|
|
|
|
// Generate or parse input
|
|
let input: HookInput;
|
|
if (options.customInput) {
|
|
try {
|
|
input = JSON.parse(options.customInput);
|
|
console.log(`${colors.blue}Using custom input${colors.reset}`);
|
|
} catch (_error) {
|
|
console.error(
|
|
`${colors.red}Error: Invalid JSON in --input${colors.reset}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
input = generateInput(options);
|
|
console.log(
|
|
`${colors.blue}Generated input for ${options.event}${options.tool ? ` with ${options.tool}` : ""}${colors.reset}`,
|
|
);
|
|
}
|
|
|
|
if (options.verbose) {
|
|
console.log();
|
|
}
|
|
|
|
// Run hook
|
|
console.log(`${colors.blue}Running hook...${colors.reset}`);
|
|
console.log();
|
|
|
|
const startTime = Date.now();
|
|
let result: Awaited<ReturnType<typeof runHook>>;
|
|
|
|
try {
|
|
result = await runHook(
|
|
resolvedPath,
|
|
input,
|
|
options.timeout,
|
|
options.verbose,
|
|
);
|
|
} catch (error) {
|
|
console.error(`${colors.red}✗ Hook execution failed${colors.reset}`);
|
|
console.error(error);
|
|
process.exit(1);
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Display results
|
|
console.log(`${colors.blue}=== Results ===${colors.reset}`);
|
|
console.log();
|
|
|
|
// Handle timeout
|
|
if (result.timedOut) {
|
|
console.log(
|
|
`${colors.red}✗ Hook timed out after ${options.timeout}ms${colors.reset}`,
|
|
);
|
|
console.log();
|
|
}
|
|
|
|
// Exit code
|
|
const exitCode = result.exitCode ?? -1;
|
|
let exitCodeColor = colors.green;
|
|
let exitCodeLabel = "Success";
|
|
|
|
if (result.timedOut) {
|
|
exitCodeColor = colors.red;
|
|
exitCodeLabel = "Timeout (killed)";
|
|
} else if (exitCode === 2) {
|
|
exitCodeColor = colors.red;
|
|
exitCodeLabel = "Blocked (exit 2)";
|
|
} else if (exitCode !== 0) {
|
|
exitCodeColor = colors.yellow;
|
|
exitCodeLabel = "Warning (non-zero)";
|
|
}
|
|
|
|
console.log(
|
|
`${exitCodeColor}Exit Code: ${exitCode} - ${exitCodeLabel}${colors.reset}`,
|
|
);
|
|
console.log(`Duration: ${duration}ms`);
|
|
console.log();
|
|
|
|
// Stdout
|
|
if (result.stdout) {
|
|
console.log(`${colors.green}Stdout:${colors.reset}`);
|
|
console.log(result.stdout);
|
|
console.log();
|
|
} else {
|
|
console.log(`${colors.blue}Stdout: (empty)${colors.reset}`);
|
|
console.log();
|
|
}
|
|
|
|
// Stderr
|
|
if (result.stderr) {
|
|
console.log(`${colors.yellow}Stderr:${colors.reset}`);
|
|
console.log(result.stderr);
|
|
console.log();
|
|
} else {
|
|
console.log(`${colors.blue}Stderr: (empty)${colors.reset}`);
|
|
console.log();
|
|
}
|
|
|
|
// Summary
|
|
console.log(`${colors.blue}=== Summary ===${colors.reset}`);
|
|
console.log();
|
|
|
|
if (exitCode === 0) {
|
|
console.log(`${colors.green}✓ Hook executed successfully${colors.reset}`);
|
|
if (result.stdout) {
|
|
console.log(" Stdout will be shown to user");
|
|
}
|
|
} else if (exitCode === 2) {
|
|
console.log(
|
|
`${colors.red}✗ Hook blocked operation (exit 2)${colors.reset}`,
|
|
);
|
|
if (result.stderr) {
|
|
console.log(" Stderr will be shown to Claude");
|
|
}
|
|
} else {
|
|
console.log(
|
|
`${colors.yellow}⚠ Hook returned warning (exit ${exitCode})${colors.reset}`,
|
|
);
|
|
if (result.stderr) {
|
|
console.log(" Stderr will be shown to user");
|
|
}
|
|
}
|
|
|
|
// Performance warning
|
|
if (duration > 1000) {
|
|
console.log(
|
|
`${colors.yellow}⚠ Hook took ${duration}ms (>1s)${colors.reset}`,
|
|
);
|
|
console.log(" Consider optimizing for faster execution");
|
|
}
|
|
|
|
console.log();
|
|
|
|
// Exit with same code as hook
|
|
process.exit(exitCode === 0 ? 0 : 1);
|
|
}
|
|
|
|
// Run main
|
|
main().catch((error) => {
|
|
console.error(`${colors.red}Fatal error:${colors.reset}`, error);
|
|
process.exit(1);
|
|
});
|