termcode
Version:
Superior terminal AI coding agent with enterprise-grade security, intelligent error recovery, performance monitoring, and plugin system - Advanced Claude Code alternative
446 lines (445 loc) • 15.6 kB
JavaScript
import { promises as fs } from "node:fs";
import path from "node:path";
import { log } from "../util/logging.js";
import { HookConfigSchema } from "./types.js";
import { BuiltinHooks } from "./builtins.js";
import { HookExecutor } from "./executor.js";
import { HookValidator } from "./validator.js";
export class HookManager {
hooks = new Map();
config;
executor;
validator;
configPath;
executionHistory = [];
constructor(configDir = path.join(process.env.HOME || "~", ".termcode")) {
this.configPath = path.join(configDir, "hooks.json");
this.config = this.getDefaultConfig();
this.executor = new HookExecutor(this.config);
this.validator = new HookValidator();
}
/**
* Initialize hook manager and load configuration
*/
async initialize() {
try {
await this.loadConfiguration();
await this.validateConfiguration();
await this.registerHooks();
log.info(`Hook manager initialized with ${this.getTotalHookCount()} hooks`);
}
catch (error) {
log.error("Failed to initialize hook manager:", error);
throw error;
}
}
/**
* Execute hooks for a specific type and context
*/
async executeHooks(type, context, additionalContext) {
if (!this.config.enabled) {
return [];
}
const hooks = this.getApplicableHooks(type, context, additionalContext);
if (hooks.length === 0) {
return [];
}
const startTime = Date.now();
log.debug(`Executing ${hooks.length} hooks for ${type}`);
const results = [];
const concurrencyLimit = Math.min(this.config.maxConcurrency, hooks.length);
// Execute hooks in priority order with concurrency control
const sortedHooks = hooks.sort((a, b) => a.priority - b.priority);
for (let i = 0; i < sortedHooks.length; i += concurrencyLimit) {
const batch = sortedHooks.slice(i, i + concurrencyLimit);
const batchPromises = batch.map(hook => this.executeHook(hook, context, additionalContext));
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);
// Check for critical failures that should stop execution
const criticalFailure = batchResults.find(r => !r.success && r.result.error?.includes('CRITICAL'));
if (criticalFailure) {
log.error(`Critical hook failure, stopping execution: ${criticalFailure.error}`);
break;
}
}
const executionTime = Date.now() - startTime;
log.debug(`Hook execution completed in ${executionTime}ms`);
// Store execution history for analysis
this.executionHistory.push(...results);
this.cleanupExecutionHistory();
return results;
}
/**
* Execute a single hook with retries and timeout
*/
async executeHook(hook, context, additionalContext) {
const startTime = Date.now();
let lastError;
for (let attempt = 0; attempt <= hook.retries; attempt++) {
try {
const result = await this.executor.execute(hook, context, additionalContext);
const executionTime = Date.now() - startTime;
return {
hookId: hook.id,
success: true,
executionTime,
result,
warnings: result.suggestions
};
}
catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < hook.retries) {
const backoffTime = Math.min(1000 * Math.pow(this.config.retryPolicy.backoffMultiplier, attempt), this.config.retryPolicy.maxBackoffTime);
log.warn(`Hook ${hook.id} failed (attempt ${attempt + 1}), retrying in ${backoffTime}ms`);
await this.sleep(backoffTime);
}
}
}
const executionTime = Date.now() - startTime;
return {
hookId: hook.id,
success: false,
executionTime,
result: { success: false, error: lastError?.message || "Hook execution failed" },
error: lastError
};
}
/**
* Get hooks applicable to the current context
*/
getApplicableHooks(type, context, additionalContext) {
const typeHooks = this.hooks.get(type) || [];
return typeHooks.filter(hook => {
if (!hook.enabled)
return false;
// Check conditions
if (hook.conditions && !this.checkConditions(hook.conditions, context)) {
return false;
}
// Check matcher
return this.matchesContext(hook, context, additionalContext);
});
}
/**
* Check if hook matches the current context
*/
matchesContext(hook, context, additionalContext) {
const { matcher } = hook;
// Tool name matching
if (matcher.toolNames && additionalContext?.toolName) {
if (!matcher.toolNames.includes(additionalContext.toolName)) {
return false;
}
}
// Provider matching
if (matcher.providers && !matcher.providers.includes(context.provider)) {
return false;
}
// Model matching
if (matcher.models && !matcher.models.includes(context.model)) {
return false;
}
// Pattern matching
if (matcher.patterns) {
const textToMatch = JSON.stringify(additionalContext || context);
const hasMatch = matcher.patterns.some(pattern => pattern.test(textToMatch));
if (!hasMatch)
return false;
}
// File type matching
if (matcher.fileTypes && additionalContext?.filePaths) {
const hasMatchingFile = additionalContext.filePaths.some((filePath) => {
const ext = path.extname(filePath).slice(1);
return matcher.fileTypes.includes(ext);
});
if (!hasMatchingFile)
return false;
}
// Condition matching
if (matcher.conditions) {
return matcher.conditions.every(condition => this.evaluateCondition(condition, context, additionalContext));
}
return true;
}
/**
* Evaluate a matcher condition
*/
evaluateCondition(condition, context, additionalContext) {
const data = { ...context, ...additionalContext };
const actualValue = this.getNestedValue(data, condition.path);
switch (condition.operator) {
case 'equals':
return actualValue === condition.value;
case 'contains':
return String(actualValue).includes(String(condition.value));
case 'matches':
return new RegExp(condition.value).test(String(actualValue));
case 'gt':
return Number(actualValue) > Number(condition.value);
case 'lt':
return Number(actualValue) < Number(condition.value);
default:
return false;
}
}
/**
* Check hook conditions
*/
checkConditions(conditions, context) {
return conditions.every(cond => {
let result = false;
switch (cond.type) {
case 'file_exists':
result = this.fileExists(path.resolve(context.repoPath, cond.condition));
break;
case 'command_available':
result = this.commandAvailable(cond.condition);
break;
case 'env_var':
result = Boolean(process.env[cond.condition]);
break;
case 'git_status':
result = this.checkGitStatus(cond.condition, context.repoPath);
break;
default:
result = false;
}
return cond.negate ? !result : result;
});
}
/**
* Register built-in and user-defined hooks
*/
async registerHooks() {
this.hooks.clear();
for (const hook of this.config.hooks) {
const hookType = hook.type;
if (!this.hooks.has(hookType)) {
this.hooks.set(hookType, []);
}
this.hooks.get(hookType).push(hook);
}
// Register built-in hooks
const builtinHooks = BuiltinHooks.getAll();
for (const hook of builtinHooks) {
if (!this.hooks.has(hook.type)) {
this.hooks.set(hook.type, []);
}
this.hooks.get(hook.type).push(hook);
}
}
/**
* Load configuration from file
*/
async loadConfiguration() {
try {
const configData = await fs.readFile(this.configPath, 'utf8');
const parsed = JSON.parse(configData);
// Validate configuration
const validated = HookConfigSchema.parse(parsed);
this.config = validated;
log.debug(`Loaded ${this.config.hooks.length} hooks from configuration`);
}
catch (error) {
if (error.code === 'ENOENT') {
log.info("No hook configuration found, using defaults");
await this.saveConfiguration();
}
else {
log.warn("Failed to load hook configuration:", error);
this.config = this.getDefaultConfig();
}
}
}
/**
* Save current configuration to file
*/
async saveConfiguration() {
try {
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2));
log.debug("Hook configuration saved");
}
catch (error) {
log.error("Failed to save hook configuration:", error);
}
}
/**
* Validate configuration
*/
async validateConfiguration() {
const errors = [];
for (const hook of this.config.hooks) {
const validation = await this.validator.validate(hook);
if (!validation.valid) {
errors.push(`Hook ${hook.id}: ${validation.errors.join(', ')}`);
}
}
if (errors.length > 0) {
throw new Error(`Hook configuration validation failed:\n${errors.join('\n')}`);
}
}
/**
* Get default configuration
*/
getDefaultConfig() {
return {
enabled: true,
hooks: [],
globalTimeout: 300000,
maxConcurrency: 5,
retryPolicy: {
maxRetries: 3,
backoffMultiplier: 2,
maxBackoffTime: 30000
},
logging: {
enabled: true,
level: 'info'
}
};
}
/**
* Utility methods
*/
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => current?.[key], obj);
}
fileExists(filePath) {
try {
const fs = require('node:fs');
fs.accessSync(filePath);
return true;
}
catch {
return false;
}
}
commandAvailable(command) {
try {
const { execSync } = require('node:child_process');
execSync(`which ${command}`, { stdio: 'ignore' });
return true;
}
catch {
return false;
}
}
checkGitStatus(condition, repoPath) {
try {
const { execSync } = require('node:child_process');
const result = execSync('git status --porcelain', {
cwd: repoPath,
encoding: 'utf8'
});
switch (condition) {
case 'clean':
return result.trim() === '';
case 'dirty':
return result.trim() !== '';
default:
return false;
}
}
catch {
return false;
}
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
getTotalHookCount() {
return Array.from(this.hooks.values()).reduce((total, hooks) => total + hooks.length, 0);
}
cleanupExecutionHistory() {
// Keep only last 1000 execution results
if (this.executionHistory.length > 1000) {
this.executionHistory = this.executionHistory.slice(-1000);
}
}
/**
* Public API methods
*/
/**
* Add a new hook
*/
async addHook(hook) {
const validation = await this.validator.validate(hook);
if (!validation.valid) {
throw new Error(`Invalid hook: ${validation.errors.join(', ')}`);
}
this.config.hooks.push(hook);
await this.saveConfiguration();
await this.registerHooks();
log.info(`Hook ${hook.id} added successfully`);
}
/**
* Remove a hook
*/
async removeHook(hookId) {
const index = this.config.hooks.findIndex(h => h.id === hookId);
if (index === -1) {
throw new Error(`Hook ${hookId} not found`);
}
this.config.hooks.splice(index, 1);
await this.saveConfiguration();
await this.registerHooks();
log.info(`Hook ${hookId} removed successfully`);
}
/**
* Enable/disable a hook
*/
async toggleHook(hookId, enabled) {
const hook = this.config.hooks.find(h => h.id === hookId);
if (!hook) {
throw new Error(`Hook ${hookId} not found`);
}
hook.enabled = enabled;
await this.saveConfiguration();
await this.registerHooks();
log.info(`Hook ${hookId} ${enabled ? 'enabled' : 'disabled'}`);
}
/**
* Get hook execution statistics
*/
getExecutionStats() {
const total = this.executionHistory.length;
const successful = this.executionHistory.filter(r => r.success).length;
const successRate = total > 0 ? (successful / total) * 100 : 0;
const avgTime = total > 0 ?
this.executionHistory.reduce((sum, r) => sum + r.executionTime, 0) / total : 0;
const hookCounts = new Map();
this.executionHistory.forEach(r => {
hookCounts.set(r.hookId, (hookCounts.get(r.hookId) || 0) + 1);
});
const mostUsed = Array.from(hookCounts.entries())
.map(([hookId, count]) => ({ hookId, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const recentFailures = this.executionHistory
.filter(r => !r.success)
.slice(-10);
return {
totalExecutions: total,
successRate,
averageExecutionTime: avgTime,
mostUsedHooks: mostUsed,
recentFailures
};
}
/**
* List all hooks
*/
listHooks() {
return [...this.config.hooks];
}
/**
* Get hook by ID
*/
getHook(hookId) {
return this.config.hooks.find(h => h.id === hookId);
}
}
// Export singleton instance
export const hookManager = new HookManager();