diff --git a/packages/cli/src/client/api.ts b/packages/cli/src/client/api.ts index 4a616a5..e78fcc5 100644 --- a/packages/cli/src/client/api.ts +++ b/packages/cli/src/client/api.ts @@ -46,6 +46,37 @@ export interface HealthStatus { }; } +// ============ Command 相关 ============ + +export interface CommandInfo { + name: string; + description?: string; + agent?: string; + model?: string; + subtask?: boolean; + source: string; + hasTemplate: boolean; +} + +export interface CommandSearchResult { + name: string; + description?: string; + source: string; + score: number; +} + +export interface CommandExecuteResult { + prompt: string; + agent?: string; + model?: string; + subtask?: boolean; +} + +export interface CommandListResponse { + commands: Array<{ name: string; description?: string; source: string }>; + stats: { total: number; bySource: Record }; +} + /** * API Client 类 */ @@ -180,6 +211,41 @@ export class APIClient { ? `${sseUrl}?token=${encodeURIComponent(this.token)}` : sseUrl; } + + // ============================================================================ + // Commands + // ============================================================================ + + async listCommands(): Promise<{ success: boolean; data: CommandListResponse }> { + return this.request('GET', '/api/commands'); + } + + async getCommand(name: string): Promise<{ success: boolean; data: CommandInfo }> { + return this.request('GET', `/api/commands/${encodeURIComponent(name)}`); + } + + async executeCommand( + name: string, + args: string = '' + ): Promise<{ success: boolean; data?: CommandExecuteResult; error?: string }> { + return this.request('POST', `/api/commands/${encodeURIComponent(name)}/execute`, { + arguments: args, + }); + } + + async searchCommands( + query: string, + limit: number = 10 + ): Promise<{ success: boolean; data: CommandSearchResult[] }> { + return this.request('POST', '/api/commands/search', { query, limit }); + } + + async reloadCommands(): Promise<{ + success: boolean; + data: { message: string; stats: { total: number; bySource: Record } }; + }> { + return this.request('POST', '/api/commands/reload'); + } } /** diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5e9cdaf..e0b385d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,7 +9,7 @@ import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { createBunWebSocket } from 'hono/bun'; -import { sessionsRouter, toolsRouter, configRouter, filesRouter } from './routes/index.js'; +import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter } from './routes/index.js'; import { handleWebSocket, handleWebSocketMessage, @@ -82,6 +82,7 @@ api.route('/sessions', sessionsRouter); api.route('/tools', toolsRouter); api.route('/config', configRouter); api.route('/files', filesRouter); +api.route('/commands', commandsRouter); // SSE 事件流 api.get('/sessions/:id/events', handleSSE); diff --git a/packages/server/src/routes/commands.ts b/packages/server/src/routes/commands.ts new file mode 100644 index 0000000..762e280 --- /dev/null +++ b/packages/server/src/routes/commands.ts @@ -0,0 +1,323 @@ +/** + * Commands API Routes + * + * 提供斜杠命令的 REST API,支持列出、查询、执行和重载命令 + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; +import { getConfig } from './config.js'; + +// Zod schemas +const ExecuteCommandInputSchema = z.object({ + arguments: z.string().default(''), +}); + +const SearchCommandInputSchema = z.object({ + query: z.string().min(1), + limit: z.number().int().min(1).max(50).default(10), +}); + +export const commandsRouter = new Hono(); + +// Core 模块类型 +interface CommandModule { + getCommandRegistry: () => CommandRegistry; + createCommandExecutor: (workdir: string) => CommandExecutor; +} + +interface CommandRegistry { + initialize(workdir: string): Promise; + reload(workdir: string): Promise; + get(name: string): Command | undefined; + getAll(): Command[]; + list(): Array<{ name: string; description?: string; source: string }>; + search(query: string, limit?: number): Array<{ command: Command; score: number }>; + getStats(): { total: number; bySource: Record }; +} + +interface CommandExecutor { + execute(input: CommandInput): Promise; +} + +interface Command { + name: string; + description?: string; + template: string; + agent?: string; + model?: string; + subtask?: boolean; + source: 'builtin' | 'user' | 'project'; + sourcePath?: string; +} + +interface CommandInput { + command: string; + arguments: string; + args: string[]; + workdir: string; +} + +interface CommandExecutionResult { + success: boolean; + prompt?: string; + agent?: string; + model?: string; + subtask?: boolean; + error?: string; +} + +// Core 模块缓存 +let commandModule: CommandModule | null = null; + +/** + * 初始化 Command 模块 + */ +async function initCommandModule(): Promise { + if (commandModule) return commandModule; + + try { + const corePath = '@ai-assistant/core'; + const core = (await import(corePath)) as Record; + + if ( + typeof core.getCommandRegistry !== 'function' || + typeof core.createCommandExecutor !== 'function' + ) { + console.warn('[Commands] Core module missing command exports'); + return null; + } + + commandModule = { + getCommandRegistry: core.getCommandRegistry as () => CommandRegistry, + createCommandExecutor: core.createCommandExecutor as (workdir: string) => CommandExecutor, + }; + + // Initialize registry with server workdir + const config = getConfig(); + const registry = commandModule.getCommandRegistry(); + await registry.initialize(config.workdir); + + console.log('[Commands] Command module initialized'); + return commandModule; + } catch (error) { + console.warn('[Commands] Failed to load core module:', error); + return null; + } +} + +/** + * GET /commands - 列出所有命令 + */ +commandsRouter.get('/', async (c) => { + const module = await initCommandModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Command module not available', + }, + 503 + ); + } + + const registry = module.getCommandRegistry(); + + return c.json({ + success: true, + data: { + commands: registry.list(), + stats: registry.getStats(), + }, + }); +}); + +/** + * POST /commands/search - 搜索命令 + * 注意:这个路由必须在 /:name 之前定义,否则会被匹配为命令名 + */ +commandsRouter.post('/search', async (c) => { + const module = await initCommandModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Command module not available', + }, + 503 + ); + } + + try { + const body = await c.req.json(); + const input = SearchCommandInputSchema.parse(body); + + const registry = module.getCommandRegistry(); + const results = registry.search(input.query, input.limit); + + return c.json({ + success: true, + data: results.map(({ command, score }) => ({ + name: command.name, + description: command.description, + source: command.source, + score, + })), + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Invalid input', + }, + 400 + ); + } +}); + +/** + * POST /commands/reload - 重新加载命令 + */ +commandsRouter.post('/reload', async (c) => { + const module = await initCommandModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Command module not available', + }, + 503 + ); + } + + try { + const config = getConfig(); + const registry = module.getCommandRegistry(); + await registry.reload(config.workdir); + + return c.json({ + success: true, + data: { + message: 'Commands reloaded', + stats: registry.getStats(), + }, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Reload failed', + }, + 500 + ); + } +}); + +/** + * GET /commands/:name - 获取命令详情 + * 使用 {.+} 匹配包含 / 的命令名(如 deploy/staging) + */ +commandsRouter.get('/:name{.+}', async (c) => { + const name = c.req.param('name'); + const module = await initCommandModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Command module not available', + }, + 503 + ); + } + + const registry = module.getCommandRegistry(); + const command = registry.get(name); + + if (!command) { + return c.json( + { + success: false, + error: `Command not found: ${name}`, + }, + 404 + ); + } + + // Return sanitized command info (exclude template for security) + return c.json({ + success: true, + data: { + name: command.name, + description: command.description, + agent: command.agent, + model: command.model, + subtask: command.subtask, + source: command.source, + hasTemplate: !!command.template, + }, + }); +}); + +/** + * POST /commands/:name/execute - 执行命令(渲染模板) + */ +commandsRouter.post('/:name{.+}/execute', async (c) => { + const name = c.req.param('name'); + const module = await initCommandModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Command module not available', + }, + 503 + ); + } + + try { + const body = await c.req.json().catch(() => ({})); + const input = ExecuteCommandInputSchema.parse(body); + + const config = getConfig(); + const executor = module.createCommandExecutor(config.workdir); + + // Parse arguments + const args = input.arguments ? input.arguments.split(/\s+/).filter(Boolean) : []; + + const result = await executor.execute({ + command: name, + arguments: input.arguments, + args, + workdir: config.workdir, + }); + + return c.json( + { + success: result.success, + data: result.success + ? { + prompt: result.prompt, + agent: result.agent, + model: result.model, + subtask: result.subtask, + } + : undefined, + error: result.error, + }, + result.success ? 200 : 400 + ); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Invalid input', + }, + 400 + ); + } +}); diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 3821201..4050a57 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -8,3 +8,4 @@ export { sessionsRouter } from './sessions.js'; export { toolsRouter, registerTool, getRegisteredTools } from './tools.js'; export { configRouter, getConfig, setConfig } from './config.js'; export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js'; +export { commandsRouter } from './commands.js'; diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 00aa5bc..d58a9d4 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -141,3 +141,41 @@ export interface PaginatedResponse { pageSize: number; hasMore: boolean; } + +// ============ Command 相关 ============ + +export interface CommandInfo { + name: string; + description?: string; + agent?: string; + model?: string; + subtask?: boolean; + source: 'builtin' | 'user' | 'project'; + hasTemplate: boolean; +} + +export interface CommandSearchResult { + name: string; + description?: string; + source: string; + score: number; +} + +export interface CommandExecuteResult { + prompt: string; + agent?: string; + model?: string; + subtask?: boolean; +} + +export interface CommandListResponse { + commands: Array<{ + name: string; + description?: string; + source: string; + }>; + stats: { + total: number; + bySource: Record; + }; +} diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 45cf938..5b48f7a 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -10,6 +10,10 @@ import type { FileReadResponse, FileTreeResponse, ServerConfig, + CommandInfo, + CommandSearchResult, + CommandExecuteResult, + CommandListResponse, } from './types.js'; // Re-export types @@ -23,6 +27,10 @@ export type { FileTreeNode, FileTreeResponse, ServerConfig, + CommandInfo, + CommandSearchResult, + CommandExecuteResult, + CommandListResponse, } from './types.js'; // API Configuration @@ -163,3 +171,35 @@ export async function updateConfig( ): Promise<{ success: boolean; data: ServerConfig }> { return request('PATCH', '/config', config); } + +// Commands +export async function listCommands(): Promise<{ success: boolean; data: CommandListResponse }> { + return request('GET', '/commands'); +} + +export async function getCommand(name: string): Promise<{ success: boolean; data: CommandInfo }> { + return request('GET', `/commands/${encodeURIComponent(name)}`); +} + +export async function executeCommand( + name: string, + args: string = '' +): Promise<{ success: boolean; data?: CommandExecuteResult; error?: string }> { + return request('POST', `/commands/${encodeURIComponent(name)}/execute`, { + arguments: args, + }); +} + +export async function searchCommands( + query: string, + limit: number = 10 +): Promise<{ success: boolean; data: CommandSearchResult[] }> { + return request('POST', '/commands/search', { query, limit }); +} + +export async function reloadCommands(): Promise<{ + success: boolean; + data: { message: string; stats: { total: number; bySource: Record } }; +}> { + return request('POST', '/commands/reload'); +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 18fb156..920bd66 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -90,3 +90,41 @@ export interface ServerConfig { allowedPaths: string[]; deniedPaths: string[]; } + +// ============ Command 相关 ============ + +export interface CommandInfo { + name: string; + description?: string; + agent?: string; + model?: string; + subtask?: boolean; + source: string; + hasTemplate: boolean; +} + +export interface CommandSearchResult { + name: string; + description?: string; + source: string; + score: number; +} + +export interface CommandExecuteResult { + prompt: string; + agent?: string; + model?: string; + subtask?: boolean; +} + +export interface CommandListResponse { + commands: Array<{ + name: string; + description?: string; + source: string; + }>; + stats: { + total: number; + bySource: Record; + }; +}