claudekit
Version:
CLI tools for Claude Code development workflow
588 lines (511 loc) • 17 kB
text/typescript
/**
* Hook Utilities
* Common utilities for hook implementation
*/
import { exec } from 'node:child_process';
import type { ExecOptions } from 'node:child_process';
import { promisify } from 'node:util';
import { setImmediate } from 'node:timers';
import fs from 'fs-extra';
import * as path from 'node:path';
import { Logger } from '../utils/logger.js';
const logger = new Logger('utils');
const execAsync = promisify(exec);
// Type alias for exec options
type ExecAsyncOptions = ExecOptions;
// Standard input reader with TTY detection and timeout
export async function readStdin(timeoutMs: number = 100): Promise<string> {
// If stdin is a TTY (interactive), return empty immediately
// This prevents hanging when run manually without piped input
if (process.stdin.isTTY) {
return '';
}
return new Promise((resolve, reject) => {
let data = '';
let resolved = false;
// Set timeout for piped input
const timer = setTimeout(() => {
resolveOnce(data); // Return whatever we have so far
}, timeoutMs);
const cleanup = (): void => {
clearTimeout(timer);
// Remove all listeners to prevent memory leaks
process.stdin.removeAllListeners('data');
process.stdin.removeAllListeners('end');
process.stdin.removeAllListeners('error');
// Properly close stdin to allow process to exit
// Use setImmediate to avoid potential issues with immediate destruction
setImmediate(() => {
if (process.stdin.readable && !process.stdin.destroyed) {
process.stdin.pause();
process.stdin.unpipe();
process.stdin.destroy();
}
});
};
const resolveOnce = (result: string): void => {
if (!resolved) {
resolved = true;
cleanup();
resolve(result);
}
};
const rejectOnce = (error: Error): void => {
if (!resolved) {
resolved = true;
cleanup();
reject(error);
}
};
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => {
resolveOnce(data);
});
process.stdin.on('error', (error) => {
rejectOnce(error);
});
// Important: Set stdin to flowing mode to trigger events or timeout
process.stdin.resume();
});
}
// Project root discovery
export async function findProjectRoot(startDir: string = process.cwd()): Promise<string> {
try {
const { stdout } = await execAsync('git rev-parse --show-toplevel', { cwd: startDir });
return stdout.trim();
} catch {
return process.cwd();
}
}
// Package manager detection
export interface PackageManager {
name: 'npm' | 'yarn' | 'pnpm';
exec: string;
run: string;
test: string;
}
export async function detectPackageManager(dir: string): Promise<PackageManager> {
if (await fs.pathExists(path.join(dir, 'pnpm-lock.yaml'))) {
return { name: 'pnpm', exec: 'pnpm dlx', run: 'pnpm run', test: 'pnpm test' };
}
if (await fs.pathExists(path.join(dir, 'yarn.lock'))) {
return { name: 'yarn', exec: 'yarn dlx', run: 'yarn', test: 'yarn test' };
}
if (await fs.pathExists(path.join(dir, 'package.json'))) {
// Check packageManager field
try {
const pkg = (await fs.readJson(path.join(dir, 'package.json'))) as {
packageManager?: string;
};
if (pkg.packageManager !== undefined && typeof pkg.packageManager === 'string') {
if (pkg.packageManager.startsWith('pnpm') === true) {
return { name: 'pnpm', exec: 'pnpm dlx', run: 'pnpm run', test: 'pnpm test' };
}
if (pkg.packageManager.startsWith('yarn') === true) {
return { name: 'yarn', exec: 'yarn dlx', run: 'yarn', test: 'yarn test' };
}
}
} catch {
// Ignore errors reading package.json
// This is expected when package.json doesn't exist or is malformed
}
}
return { name: 'npm', exec: 'npx', run: 'npm run', test: 'npm test' };
}
// Command execution wrapper
export interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
/** True when the process was killed due to timeout */
timedOut?: boolean;
/** Signal used to terminate the process, if any (e.g., SIGTERM) */
signal?: string | null;
/** Whether the process was killed */
killed?: boolean;
/** Elapsed time in milliseconds for the executed command */
durationMs?: number;
}
/**
* Get execution options with environment variables for proper process management
* @param options - The base execution options
* @param command - Optional command string to determine if test-specific env vars are needed
*/
export function getExecOptions(options: ExecAsyncOptions = {}, command?: string): ExecAsyncOptions {
// Only add vitest-specific env vars when running test commands
const isTestCommand = command !== undefined &&
(command.includes('test') || command.includes('vitest'));
// Start with process.env but exclude vitest vars for non-test commands
const processEnv = { ...process.env };
if (!isTestCommand) {
// Remove any vitest env vars that might be set from parent process
delete processEnv['VITEST_POOL_TIMEOUT'];
delete processEnv['VITEST_POOL_FORKS'];
delete processEnv['VITEST_WATCH'];
}
const baseEnv = {
...processEnv,
...options.env,
// Ensure CI-like behavior for process cleanup
CI: process.env['CI'] ?? 'false',
};
const baseOptions: ExecAsyncOptions = {
...options,
env: baseEnv,
};
if (isTestCommand) {
return {
...baseOptions,
env: {
...baseEnv,
// Force vitest to exit after tests complete (prevents hanging workers)
VITEST_POOL_TIMEOUT: '30000',
// Use single fork configuration to prevent multiple hanging workers
VITEST_POOL_FORKS: '1',
// Disable watch mode explicitly to ensure processes terminate
VITEST_WATCH: 'false',
},
};
}
return baseOptions;
}
export async function execCommand(
command: string,
args: string[] = [],
options: { cwd?: string; timeout?: number } = {}
): Promise<ExecResult> {
const fullCommand = `${command} ${args.join(' ')}`.trim();
const start = Date.now();
try {
const { stdout, stderr } = await execAsync(fullCommand, getExecOptions({
cwd: options.cwd ?? process.cwd(),
timeout: options.timeout ?? 30000,
maxBuffer: 1024 * 1024 * 10, // 10MB
}, fullCommand));
const durationMs = Date.now() - start;
return {
stdout: stdout?.toString() ?? '',
stderr: stderr?.toString() ?? '',
exitCode: 0,
durationMs,
timedOut: false
};
} catch (error) {
const durationMs = Date.now() - start;
const execError = error as {
stdout?: string;
stderr?: string;
code?: number;
killed?: boolean;
signal?: string | null;
timedOut?: boolean; // some environments
};
// Robust timeout detection:
// - child_process.exec sets error.killed=true and error.signal='SIGTERM' when timed out
// - some runtimes set error.timedOut=true
// - as a fallback, if a timeout was specified and elapsed time >= timeout - small delta, treat as timeout
const requestedTimeout = options.timeout ?? 30000;
const elapsedNearTimeout = durationMs >= Math.max(0, requestedTimeout - 25);
const didTimeOut =
execError.timedOut === true ||
(execError.killed === true &&
(execError.signal === 'SIGTERM' || execError.signal === 'SIGKILL')) ||
elapsedNearTimeout;
return {
stdout: execError.stdout ?? '',
stderr: execError.stderr ?? '',
exitCode: execError.code ?? 1,
timedOut: didTimeOut,
signal: execError.signal ?? null,
killed: execError.killed ?? false,
durationMs,
};
}
}
// Error formatting
export function formatError(title: string, details: string, instructions: string[]): string {
const instructionsList = instructions.map((inst, i) => `${i + 1}. ${inst}`).join('\n');
return `BLOCKED: ${title}\n\n${details}\n\nMANDATORY INSTRUCTIONS:\n${instructionsList}`;
}
// Tool availability checking
export async function checkToolAvailable(
tool: string,
configFile: string,
projectRoot: string
): Promise<boolean> {
// Check config file exists
if (!(await fs.pathExists(path.join(projectRoot, configFile)))) {
return false;
}
// Check tool is executable
const pm = await detectPackageManager(projectRoot);
const result = await execCommand(pm.exec, [tool, '--version'], {
cwd: projectRoot,
timeout: 10000,
});
return result.exitCode === 0;
}
/**
* Execute a shell command and return the output
*/
export async function executeCommand(
command: string,
cwd?: string
): Promise<{ stdout: string; stderr: string }> {
try {
const result = await execAsync(command, getExecOptions({ cwd }));
// Ensure stdout and stderr are always strings
// The Node.js types indicate these could be string | Buffer depending on encoding
// We always want strings for consistency
return {
stdout: String(result.stdout),
stderr: String(result.stderr)
};
} catch (error) {
logger.error(error instanceof Error ? error : new Error(`Command failed: ${command}`));
throw error;
}
}
/**
* Check if a file exists
*/
export async function fileExists(filePath: string): Promise<boolean> {
try {
return await fs.pathExists(filePath);
} catch {
return false;
}
}
/**
* Read JSON file
*/
export async function readJsonFile<T = unknown>(filePath: string): Promise<T> {
return await fs.readJson(filePath);
}
/**
* Write JSON file
*/
export async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
await fs.writeJson(filePath, data, { spaces: 2 });
}
/**
* Find files matching a pattern
*/
export async function findFiles(pattern: string, directory: string): Promise<string[]> {
const { stdout } = await executeCommand(`find . -name "${pattern}"`, directory);
return stdout
.split('\n')
.filter(Boolean)
.map((file) => path.join(directory, file));
}
/**
* Get file modification time
*/
export async function getFileModTime(filePath: string): Promise<Date> {
const stats = await fs.stat(filePath);
return stats.mtime;
}
/**
* Create directory if it doesn't exist
*/
export async function ensureDirectory(dirPath: string): Promise<void> {
await fs.ensureDir(dirPath);
}
/**
* Parse hook payload from stdin
*/
export async function parseStdinPayload(): Promise<unknown> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => {
try {
const payload = JSON.parse(data);
resolve(payload);
} catch {
reject(new Error('Invalid JSON payload'));
}
});
process.stdin.on('error', reject);
});
}
/**
* Extract file paths from hook payload
*/
export function extractFilePaths(payload: unknown): string[] {
const paths: string[] = [];
if (payload === null || payload === undefined || typeof payload !== 'object') {
return paths;
}
const obj = payload as Record<string, unknown>;
// Check common payload structures
if (typeof obj['file_path'] === 'string') {
paths.push(obj['file_path']);
}
if (
obj['tool_input'] !== null &&
obj['tool_input'] !== undefined &&
typeof obj['tool_input'] === 'object'
) {
const toolInput = obj['tool_input'] as Record<string, unknown>;
if (typeof toolInput['file_path'] === 'string') {
paths.push(toolInput['file_path']);
}
}
if (obj['edits'] !== null && obj['edits'] !== undefined && Array.isArray(obj['edits'])) {
obj['edits'].forEach((edit: unknown) => {
if (edit !== null && edit !== undefined && typeof edit === 'object') {
const editObj = edit as Record<string, unknown>;
if (typeof editObj['file_path'] === 'string') {
paths.push(editObj['file_path']);
}
}
});
}
return [...new Set(paths)]; // Remove duplicates
}
/**
* Format duration in human-readable format
*/
export function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`;
}
const seconds = Math.floor(ms / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
/**
* Format TypeScript errors with proper indentation
*/
export function formatTypeScriptErrors(result: ExecResult, command?: string): string {
const header = '████ TypeScript Validation Failed ████\n\n';
const message = 'TypeScript compilation errors must be fixed:\n\n';
const output = result.stderr || result.stdout;
const indentedOutput = output
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
const step2 =
command !== undefined && command !== null && command.length > 0
? `2. Run '${command}' to verify fixes`
: '2. Run type checking command to verify fixes';
const actions = `
REQUIRED ACTIONS:
1. Fix all TypeScript errors shown above
${step2}
3. Make necessary corrections
4. The validation will run again automatically`;
return header + message + indentedOutput + actions;
}
/**
* Format ESLint errors with proper indentation
*/
export function formatESLintErrors(result: ExecResult): string {
const header = '████ ESLint Validation Failed ████\n\n';
const message = 'ESLint errors must be fixed:\n\n';
const output = result.stdout || result.stderr;
const indentedOutput = output
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
const actions =
'\n\nREQUIRED ACTIONS:\n' +
'1. Fix all ESLint errors shown above\n' +
'2. Run lint command to verify fixes\n' +
'3. Make necessary corrections\n' +
'4. The validation will run again automatically';
return header + message + indentedOutput + actions;
}
/**
* Format Biome errors with proper indentation
*/
export function formatBiomeErrors(result: ExecResult): string {
const header = '████ Biome Validation Failed ████\n\n';
const message = 'Biome errors must be fixed:\n\n';
const output = result.stdout || result.stderr;
const indentedOutput = output
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
const actions =
'\n\nREQUIRED ACTIONS:\n' +
'1. Fix all Biome errors shown above\n' +
"2. Run 'npx biome check --write' to apply fixes and verify issues are resolved\n" +
'3. Common Biome fixes:\n' +
' - Import organization issues\n' +
' - Formatting inconsistencies\n' +
' - Code style violations\n' +
' - Suspicious patterns\n' +
'4. The validation will run again automatically';
return header + message + indentedOutput + actions;
}
/**
* Format test errors with proper indentation
*/
export function formatTestErrors(result: ExecResult): string {
const header = '████ Test Suite Failed ████\n\n';
const message = 'Test failures must be fixed:\n\n';
const output = result.stdout + result.stderr;
const indentedOutput = output
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
const actions =
'\n\nREQUIRED ACTIONS:\n' +
'1. Fix all test failures shown above\n' +
'2. Run test command to verify fixes\n' +
'3. Make necessary corrections\n' +
'4. The validation will run again automatically';
return header + message + indentedOutput + actions;
}
/**
* Extension Configuration Utilities
* Common utilities for handling file extension configuration in hooks
*/
export interface ExtensionConfigurable {
extensions?: string[] | undefined;
}
/**
* Normalize extension format - removes leading/trailing dots and whitespace
*/
function normalizeExtension(ext: string): string {
return ext.trim().replace(/^\.+|\.+$/g, '');
}
/**
* Escape special regex characters in extension strings
*/
function escapeRegexChars(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Create regex pattern for matching file extensions
*/
export function createExtensionPattern(extensions: string[]): RegExp {
const normalized = extensions.map(normalizeExtension).filter(Boolean);
const escaped = normalized.map(escapeRegexChars);
return new RegExp(`\\.(${escaped.join('|')})$`);
}
/**
* Check if a file should be processed based on extension configuration
*/
export function shouldProcessFileByExtension(
filePath: string | undefined,
config: ExtensionConfigurable,
defaultExtensions: string[] = ['js', 'jsx', 'ts', 'tsx']
): boolean {
if (filePath === undefined || filePath === '') {
return false;
}
const allowedExtensions = config.extensions || defaultExtensions;
const pattern = createExtensionPattern(allowedExtensions);
return pattern.test(filePath);
}