Files
ai-terminal-assistant/src/hooks/manager.ts
T
kurihada 630ce9fd4b feat: 实现 Hook 系统
参考 open-code 的实现,添加工具执行前后的 hook 功能:

- 添加 Hook 类型定义 (tool.execute.before/after, file.edited/created/deleted 等)
- 实现 HookManager 管理器,支持插件注册和事件触发
- 实现配置文件加载器,支持 .ai-assistant.json/jsonc 格式
- 支持 glob 模式匹配文件触发 shell 命令
- 集成到 Agent 工具执行流程
- 添加 minimatch 依赖用于 glob 匹配
- 编写完整测试用例 (27 个测试)

配置示例:
```json
{
  "hooks": {
    "file_edited": {
      "*.ts": [{ "command": ["npx", "tsc", "--noEmit"] }]
    }
  }
}
```
2025-12-11 23:12:04 +08:00

496 lines
11 KiB
TypeScript

/**
* Hook 管理器
*
* 负责 hook 的注册、触发和管理
*/
import { spawn } from 'child_process';
import { minimatch } from 'minimatch';
import type {
Hooks,
HookType,
HookConfig,
HookEvent,
HookEventListener,
ShellCommandConfig,
FileHookConfig,
ToolExecuteBeforeInput,
ToolExecuteBeforeOutput,
ToolExecuteAfterInput,
ToolExecuteAfterOutput,
SessionStartInput,
SessionEndInput,
MessageBeforeInput,
MessageBeforeOutput,
MessageAfterInput,
FileChangeInput,
FileChangeOutput,
Plugin,
PluginInput,
} from './types.js';
/**
* Hook 管理器
*/
export class HookManager {
/** 已注册的 hooks */
private hooks: Hooks[] = [];
/** 配置型 hooks(从配置文件加载) */
private configHooks: HookConfig | null = null;
/** 事件监听器 */
private eventListeners: HookEventListener[] = [];
/** 当前工作目录 */
private workdir: string;
/** 会话 ID */
private sessionId: string;
constructor(workdir: string, sessionId?: string) {
this.workdir = workdir;
this.sessionId = sessionId || 'default';
}
/**
* 注册插件
*/
async registerPlugin(plugin: Plugin): Promise<void> {
const input: PluginInput = {
workdir: this.workdir,
sessionId: this.sessionId,
};
try {
const hooks = await plugin(input);
this.hooks.push(hooks);
} catch (error) {
console.error('Failed to register plugin:', error);
}
}
/**
* 注册 hooks 对象
*/
registerHooks(hooks: Hooks): void {
this.hooks.push(hooks);
}
/**
* 设置配置型 hooks
*/
setConfigHooks(config: HookConfig): void {
this.configHooks = config;
}
/**
* 添加事件监听器
*/
addEventListener(listener: HookEventListener): void {
this.eventListeners.push(listener);
}
/**
* 移除事件监听器
*/
removeEventListener(listener: HookEventListener): void {
const index = this.eventListeners.indexOf(listener);
if (index !== -1) {
this.eventListeners.splice(index, 1);
}
}
/**
* 发送事件
*/
private emitEvent(type: HookType, data: unknown): void {
const event: HookEvent = {
type,
timestamp: Date.now(),
data,
};
for (const listener of this.eventListeners) {
try {
listener(event);
} catch (error) {
console.error('Event listener error:', error);
}
}
}
/**
* 触发工具执行前 hook
*/
async triggerToolExecuteBefore(
input: ToolExecuteBeforeInput
): Promise<ToolExecuteBeforeOutput> {
const output: ToolExecuteBeforeOutput = {
args: { ...input.args },
};
for (const hook of this.hooks) {
if (hook['tool.execute.before']) {
try {
await hook['tool.execute.before'](input, output);
} catch (error) {
console.error('Hook tool.execute.before error:', error);
}
}
}
this.emitEvent('tool.execute.before', { input, output });
return output;
}
/**
* 触发工具执行后 hook
*/
async triggerToolExecuteAfter(
input: ToolExecuteAfterInput,
result: ToolExecuteAfterOutput['result']
): Promise<ToolExecuteAfterOutput> {
const output: ToolExecuteAfterOutput = {
result: { ...result },
};
for (const hook of this.hooks) {
if (hook['tool.execute.after']) {
try {
await hook['tool.execute.after'](input, output);
} catch (error) {
console.error('Hook tool.execute.after error:', error);
}
}
}
this.emitEvent('tool.execute.after', { input, output });
return output;
}
/**
* 触发会话开始 hook
*/
async triggerSessionStart(input: SessionStartInput): Promise<void> {
for (const hook of this.hooks) {
if (hook['session.start']) {
try {
await hook['session.start'](input);
} catch (error) {
console.error('Hook session.start error:', error);
}
}
}
this.emitEvent('session.start', input);
}
/**
* 触发会话结束 hook
*/
async triggerSessionEnd(input: SessionEndInput): Promise<void> {
for (const hook of this.hooks) {
if (hook['session.end']) {
try {
await hook['session.end'](input);
} catch (error) {
console.error('Hook session.end error:', error);
}
}
}
// 执行配置型 session_completed hooks
if (this.configHooks?.session_completed) {
await this.executeShellCommands(this.configHooks.session_completed);
}
this.emitEvent('session.end', input);
}
/**
* 触发消息前 hook
*/
async triggerMessageBefore(
input: MessageBeforeInput
): Promise<MessageBeforeOutput> {
const output: MessageBeforeOutput = {
content: input.content,
};
for (const hook of this.hooks) {
if (hook['message.before']) {
try {
await hook['message.before'](input, output);
} catch (error) {
console.error('Hook message.before error:', error);
}
}
}
this.emitEvent('message.before', { input, output });
return output;
}
/**
* 触发消息后 hook
*/
async triggerMessageAfter(input: MessageAfterInput): Promise<void> {
for (const hook of this.hooks) {
if (hook['message.after']) {
try {
await hook['message.after'](input);
} catch (error) {
console.error('Hook message.after error:', error);
}
}
}
this.emitEvent('message.after', input);
}
/**
* 触发文件编辑 hook
*/
async triggerFileEdited(input: FileChangeInput): Promise<FileChangeOutput> {
const output: FileChangeOutput = {};
// 执行插件 hooks
for (const hook of this.hooks) {
if (hook['file.edited']) {
try {
await hook['file.edited'](input, output);
} catch (error) {
console.error('Hook file.edited error:', error);
}
}
}
// 执行配置型 hooks
if (this.configHooks?.file_edited) {
const results = await this.executeFileHooks(
input.path,
this.configHooks.file_edited
);
output.commandResults = results;
}
this.emitEvent('file.edited', { input, output });
return output;
}
/**
* 触发文件创建 hook
*/
async triggerFileCreated(input: FileChangeInput): Promise<FileChangeOutput> {
const output: FileChangeOutput = {};
// 执行插件 hooks
for (const hook of this.hooks) {
if (hook['file.created']) {
try {
await hook['file.created'](input, output);
} catch (error) {
console.error('Hook file.created error:', error);
}
}
}
// 执行配置型 hooks
if (this.configHooks?.file_created) {
const results = await this.executeFileHooks(
input.path,
this.configHooks.file_created
);
output.commandResults = results;
}
this.emitEvent('file.created', { input, output });
return output;
}
/**
* 触发文件删除 hook
*/
async triggerFileDeleted(input: FileChangeInput): Promise<FileChangeOutput> {
const output: FileChangeOutput = {};
// 执行插件 hooks
for (const hook of this.hooks) {
if (hook['file.deleted']) {
try {
await hook['file.deleted'](input, output);
} catch (error) {
console.error('Hook file.deleted error:', error);
}
}
}
// 执行配置型 hooks
if (this.configHooks?.file_deleted) {
const results = await this.executeFileHooks(
input.path,
this.configHooks.file_deleted
);
output.commandResults = results;
}
this.emitEvent('file.deleted', { input, output });
return output;
}
/**
* 执行文件 hooks
* 根据文件路径匹配 glob 模式并执行对应命令
*/
private async executeFileHooks(
filePath: string,
config: FileHookConfig
): Promise<FileChangeOutput['commandResults']> {
const results: FileChangeOutput['commandResults'] = [];
for (const [pattern, commands] of Object.entries(config)) {
// 使用 minimatch 进行 glob 匹配
if (minimatch(filePath, pattern, { matchBase: true })) {
const commandResults = await this.executeShellCommands(commands, {
FILE_PATH: filePath,
});
results.push(...commandResults);
}
}
return results;
}
/**
* 执行 shell 命令列表
*/
private async executeShellCommands(
commands: ShellCommandConfig[],
extraEnv?: Record<string, string>
): Promise<Array<{ command: string[]; success: boolean; output?: string; error?: string }>> {
const results: Array<{
command: string[];
success: boolean;
output?: string;
error?: string;
}> = [];
for (const cmdConfig of commands) {
const result = await this.executeShellCommand(cmdConfig, extraEnv);
results.push(result);
}
return results;
}
/**
* 执行单个 shell 命令
*/
private executeShellCommand(
config: ShellCommandConfig,
extraEnv?: Record<string, string>
): Promise<{ command: string[]; success: boolean; output?: string; error?: string }> {
return new Promise((resolve) => {
const [cmd, ...args] = config.command;
const timeout = config.timeout || 30000;
const cwd = config.cwd || this.workdir;
const env = {
...process.env,
...config.environment,
...extraEnv,
};
let stdout = '';
let stderr = '';
const child = spawn(cmd, args, {
cwd,
env,
shell: true,
});
const timer = setTimeout(() => {
child.kill('SIGTERM');
resolve({
command: config.command,
success: false,
error: `Command timed out after ${timeout}ms`,
});
}, timeout);
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
clearTimeout(timer);
resolve({
command: config.command,
success: code === 0,
output: stdout.trim() || undefined,
error: stderr.trim() || undefined,
});
});
child.on('error', (error) => {
clearTimeout(timer);
resolve({
command: config.command,
success: false,
error: error.message,
});
});
});
}
/**
* 获取所有已注册的 hooks 数量
*/
getHookCount(): number {
return this.hooks.length;
}
/**
* 清空所有 hooks
*/
clear(): void {
this.hooks = [];
this.configHooks = null;
this.eventListeners = [];
}
}
// 全局 Hook 管理器实例
let globalHookManager: HookManager | null = null;
/**
* 获取全局 Hook 管理器
*/
export function getHookManager(): HookManager | null {
return globalHookManager;
}
/**
* 初始化全局 Hook 管理器
*/
export function initHookManager(workdir: string, sessionId?: string): HookManager {
globalHookManager = new HookManager(workdir, sessionId);
return globalHookManager;
}
/**
* 重置全局 Hook 管理器
*/
export function resetHookManager(): void {
if (globalHookManager) {
globalHookManager.clear();
globalHookManager = null;
}
}