feat(hooks): 添加 Hooks 配置管理功能
- 新增 Server Hooks API 路由 (CRUD + 测试执行) - 新增 HooksPanel 组件用于管理所有钩子类型 - 新增 HookEditor 组件用于编辑单个钩子规则 - 支持 file_edited/file_created/file_deleted/session_completed 四种钩子 - 集成到 web 和 desktop 应用
This commit is contained in:
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { createBunWebSocket } from 'hono/bun';
|
||||
|
||||
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter } from './routes/index.js';
|
||||
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter } from './routes/index.js';
|
||||
import {
|
||||
handleWebSocket,
|
||||
handleWebSocketMessage,
|
||||
@@ -84,6 +84,7 @@ api.route('/config', configRouter);
|
||||
api.route('/files', filesRouter);
|
||||
api.route('/commands', commandsRouter);
|
||||
api.route('/mcp', mcpRouter);
|
||||
api.route('/hooks', hooksRouter);
|
||||
|
||||
// SSE 事件流
|
||||
api.get('/sessions/:id/events', handleSSE);
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* Hooks API Routes
|
||||
*
|
||||
* 提供 Hooks 配置管理的 REST API
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { getConfig } from './config.js';
|
||||
|
||||
// Core Hooks 模块类型
|
||||
interface HooksModule {
|
||||
loadProjectConfig: (directory: string) => Promise<ProjectConfig | null>;
|
||||
loadHookConfig: (directory: string) => Promise<HookConfig | null>;
|
||||
getConfigFilePath: (directory: string) => Promise<string | null>;
|
||||
createDefaultConfig: (directory: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface ShellCommandConfig {
|
||||
command: string[];
|
||||
environment?: Record<string, string>;
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
interface FileHookConfig {
|
||||
[pattern: string]: ShellCommandConfig[];
|
||||
}
|
||||
|
||||
interface HookConfig {
|
||||
file_edited?: FileHookConfig;
|
||||
file_created?: FileHookConfig;
|
||||
file_deleted?: FileHookConfig;
|
||||
session_completed?: ShellCommandConfig[];
|
||||
}
|
||||
|
||||
interface ProjectConfig {
|
||||
hooks?: HookConfig;
|
||||
plugins?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface HookTestResult {
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export const hooksRouter = new Hono();
|
||||
|
||||
// Core 模块缓存
|
||||
let hooksModule: HooksModule | null = null;
|
||||
|
||||
/**
|
||||
* 初始化 Hooks 模块
|
||||
*/
|
||||
async function initHooksModule(): Promise<HooksModule | null> {
|
||||
if (hooksModule) return hooksModule;
|
||||
|
||||
try {
|
||||
const corePath = '@ai-assistant/core';
|
||||
const core = (await import(corePath)) as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof core.loadProjectConfig !== 'function' ||
|
||||
typeof core.loadHookConfig !== 'function' ||
|
||||
typeof core.getConfigFilePath !== 'function' ||
|
||||
typeof core.createDefaultConfig !== 'function'
|
||||
) {
|
||||
console.warn('[Hooks] Core module missing Hooks exports');
|
||||
return null;
|
||||
}
|
||||
|
||||
hooksModule = {
|
||||
loadProjectConfig: core.loadProjectConfig as HooksModule['loadProjectConfig'],
|
||||
loadHookConfig: core.loadHookConfig as HooksModule['loadHookConfig'],
|
||||
getConfigFilePath: core.getConfigFilePath as HooksModule['getConfigFilePath'],
|
||||
createDefaultConfig: core.createDefaultConfig as HooksModule['createDefaultConfig'],
|
||||
};
|
||||
|
||||
console.log('[Hooks] Hooks module initialized');
|
||||
return hooksModule;
|
||||
} catch (error) {
|
||||
console.warn('[Hooks] Failed to load Hooks module:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 JSON 中的注释(支持 JSONC 格式)
|
||||
*/
|
||||
function stripJsonComments(jsonString: string): string {
|
||||
let result = jsonString.replace(/\/\/.*$/gm, '');
|
||||
result = result.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取配置文件
|
||||
*/
|
||||
async function readConfigFile(configPath: string): Promise<ProjectConfig | null> {
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
const cleanContent = stripJsonComments(content);
|
||||
return JSON.parse(cleanContent);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入配置文件
|
||||
*/
|
||||
async function writeConfigFile(configPath: string, config: ProjectConfig): Promise<void> {
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建配置文件路径
|
||||
*/
|
||||
async function getOrCreateConfigPath(workdir: string): Promise<string> {
|
||||
const module = await initHooksModule();
|
||||
if (module) {
|
||||
const existingPath = await module.getConfigFilePath(workdir);
|
||||
if (existingPath) return existingPath;
|
||||
}
|
||||
return path.join(workdir, '.ai-assistant.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /hooks/config - 获取完整钩子配置
|
||||
*/
|
||||
hooksRouter.get('/config', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const hookConfig = await module.loadHookConfig(config.workdir);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: hookConfig || {
|
||||
file_edited: {},
|
||||
file_created: {},
|
||||
file_deleted: {},
|
||||
session_completed: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /hooks/config - 更新完整钩子配置
|
||||
*/
|
||||
hooksRouter.put('/config', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const newHookConfig = await c.req.json<HookConfig>();
|
||||
const config = getConfig();
|
||||
const configPath = await getOrCreateConfigPath(config.workdir);
|
||||
|
||||
// 读取现有配置
|
||||
let projectConfig = await readConfigFile(configPath);
|
||||
if (!projectConfig) {
|
||||
projectConfig = {};
|
||||
}
|
||||
|
||||
// 更新 hooks 部分
|
||||
projectConfig.hooks = newHookConfig;
|
||||
|
||||
// 写入配置文件
|
||||
await writeConfigFile(configPath, projectConfig);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: newHookConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update config',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /hooks/file-edited - 获取 file_edited 钩子
|
||||
*/
|
||||
hooksRouter.get('/file-edited', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const hookConfig = await module.loadHookConfig(config.workdir);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: hookConfig?.file_edited || {},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /hooks/file-edited - 更新 file_edited 钩子
|
||||
*/
|
||||
hooksRouter.put('/file-edited', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const newFileEditedHooks = await c.req.json<FileHookConfig>();
|
||||
const config = getConfig();
|
||||
const configPath = await getOrCreateConfigPath(config.workdir);
|
||||
|
||||
let projectConfig = await readConfigFile(configPath);
|
||||
if (!projectConfig) {
|
||||
projectConfig = {};
|
||||
}
|
||||
if (!projectConfig.hooks) {
|
||||
projectConfig.hooks = {};
|
||||
}
|
||||
|
||||
projectConfig.hooks.file_edited = newFileEditedHooks;
|
||||
await writeConfigFile(configPath, projectConfig);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: newFileEditedHooks,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update file_edited hooks',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /hooks/file-created - 获取 file_created 钩子
|
||||
*/
|
||||
hooksRouter.get('/file-created', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const hookConfig = await module.loadHookConfig(config.workdir);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: hookConfig?.file_created || {},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /hooks/file-created - 更新 file_created 钩子
|
||||
*/
|
||||
hooksRouter.put('/file-created', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const newFileCreatedHooks = await c.req.json<FileHookConfig>();
|
||||
const config = getConfig();
|
||||
const configPath = await getOrCreateConfigPath(config.workdir);
|
||||
|
||||
let projectConfig = await readConfigFile(configPath);
|
||||
if (!projectConfig) {
|
||||
projectConfig = {};
|
||||
}
|
||||
if (!projectConfig.hooks) {
|
||||
projectConfig.hooks = {};
|
||||
}
|
||||
|
||||
projectConfig.hooks.file_created = newFileCreatedHooks;
|
||||
await writeConfigFile(configPath, projectConfig);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: newFileCreatedHooks,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update file_created hooks',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /hooks/file-deleted - 获取 file_deleted 钩子
|
||||
*/
|
||||
hooksRouter.get('/file-deleted', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const hookConfig = await module.loadHookConfig(config.workdir);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: hookConfig?.file_deleted || {},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /hooks/file-deleted - 更新 file_deleted 钩子
|
||||
*/
|
||||
hooksRouter.put('/file-deleted', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const newFileDeletedHooks = await c.req.json<FileHookConfig>();
|
||||
const config = getConfig();
|
||||
const configPath = await getOrCreateConfigPath(config.workdir);
|
||||
|
||||
let projectConfig = await readConfigFile(configPath);
|
||||
if (!projectConfig) {
|
||||
projectConfig = {};
|
||||
}
|
||||
if (!projectConfig.hooks) {
|
||||
projectConfig.hooks = {};
|
||||
}
|
||||
|
||||
projectConfig.hooks.file_deleted = newFileDeletedHooks;
|
||||
await writeConfigFile(configPath, projectConfig);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: newFileDeletedHooks,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update file_deleted hooks',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /hooks/session-completed - 获取 session_completed 钩子
|
||||
*/
|
||||
hooksRouter.get('/session-completed', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const hookConfig = await module.loadHookConfig(config.workdir);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: hookConfig?.session_completed || [],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /hooks/session-completed - 更新 session_completed 钩子
|
||||
*/
|
||||
hooksRouter.put('/session-completed', async (c) => {
|
||||
const module = await initHooksModule();
|
||||
|
||||
if (!module) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Hooks module not available',
|
||||
},
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const newSessionCompletedHooks = await c.req.json<ShellCommandConfig[]>();
|
||||
const config = getConfig();
|
||||
const configPath = await getOrCreateConfigPath(config.workdir);
|
||||
|
||||
let projectConfig = await readConfigFile(configPath);
|
||||
if (!projectConfig) {
|
||||
projectConfig = {};
|
||||
}
|
||||
if (!projectConfig.hooks) {
|
||||
projectConfig.hooks = {};
|
||||
}
|
||||
|
||||
projectConfig.hooks.session_completed = newSessionCompletedHooks;
|
||||
await writeConfigFile(configPath, projectConfig);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: newSessionCompletedHooks,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update session_completed hooks',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /hooks/test - 测试执行钩子命令
|
||||
*/
|
||||
hooksRouter.post('/test', async (c) => {
|
||||
try {
|
||||
const commandConfig = await c.req.json<ShellCommandConfig>();
|
||||
const config = getConfig();
|
||||
|
||||
// 验证命令配置
|
||||
if (!commandConfig.command || !Array.isArray(commandConfig.command) || commandConfig.command.length === 0) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid command configuration: command must be a non-empty array',
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const timeout = commandConfig.timeout || 30000;
|
||||
const cwd = commandConfig.cwd || config.workdir;
|
||||
|
||||
// 执行命令
|
||||
const result = await new Promise<HookTestResult>((resolve) => {
|
||||
const [cmd, ...args] = commandConfig.command;
|
||||
const proc = spawn(cmd, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...commandConfig.environment,
|
||||
},
|
||||
shell: true,
|
||||
timeout,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
resolve({
|
||||
success: code === 0,
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: code ?? -1,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
resolve({
|
||||
success: false,
|
||||
stdout,
|
||||
stderr: error.message,
|
||||
exitCode: -1,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to test command',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -10,3 +10,4 @@ export { configRouter, getConfig, setConfig } from './config.js';
|
||||
export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js';
|
||||
export { commandsRouter } from './commands.js';
|
||||
export { mcpRouter } from './mcp.js';
|
||||
export { hooksRouter } from './hooks.js';
|
||||
|
||||
Reference in New Issue
Block a user