From f0385ef22104070a4f9f50b9365b5ac9e0d91a56 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 12 Dec 2025 18:51:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(commands):=20=E5=AE=9E=E7=8E=B0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=20CRUD=20=E5=AE=8C=E6=95=B4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core: - 新增 CommandManager 类,支持创建、更新、删除命令 - 验证命令名称防止路径遍历攻击 - 自动生成 Markdown 文件(含 YAML frontmatter) - 内置命令保护(不可修改/删除) Server: - POST /api/commands - 创建命令 - GET /api/commands/:name/content - 获取命令完整内容 - PUT /api/commands/:name - 更新命令 - DELETE /api/commands/:name - 删除命令 UI: - 新增 createCommand、updateCommand、deleteCommand、getCommandContent 函数 - 新增 CreateCommandInput、UpdateCommandInput、CommandContent 类型 --- packages/core/src/commands/index.ts | 10 + packages/core/src/commands/manager.ts | 430 +++++++++++++++++++++++++ packages/core/src/index.ts | 12 +- packages/server/src/routes/commands.ts | 247 +++++++++++++- packages/ui/src/api/client.ts | 32 ++ packages/ui/src/api/types.ts | 43 +++ packages/ui/src/index.ts | 9 + 7 files changed, 780 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/commands/manager.ts diff --git a/packages/core/src/commands/index.ts b/packages/core/src/commands/index.ts index 30515da..b765d68 100644 --- a/packages/core/src/commands/index.ts +++ b/packages/core/src/commands/index.ts @@ -26,5 +26,15 @@ export { // 执行器 export { CommandExecutor, createCommandExecutor } from './executor.js'; +// 管理器 +export { + CommandManager, + createCommandManager, + type CreateCommandInput, + type UpdateCommandInput, + type CommandContent, + type CommandOperationResult, +} from './manager.js'; + // 内置 Commands export { builtinCommands } from './builtin/index.js'; diff --git a/packages/core/src/commands/manager.ts b/packages/core/src/commands/manager.ts new file mode 100644 index 0000000..1f7a611 --- /dev/null +++ b/packages/core/src/commands/manager.ts @@ -0,0 +1,430 @@ +/** + * Command 管理器 + * + * 负责命令的 CRUD 操作(创建、更新、删除) + * 命令存储在文件系统的 Markdown 文件中 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as yaml from 'yaml'; +import type { Command, CommandFrontmatter } from './types.js'; +import { commandLoader } from './loader.js'; +import { getCommandRegistry } from './registry.js'; + +/** + * 创建命令的输入参数 + */ +export interface CreateCommandInput { + /** 命令名称(支持嵌套如 deploy/staging) */ + name: string; + /** 命令描述 */ + description?: string; + /** 提示词模板 */ + template: string; + /** 指定 Agent */ + agent?: string; + /** 指定模型 */ + model?: string; + /** 是否作为子任务执行 */ + subtask?: boolean; + /** 存储位置 */ + scope: 'user' | 'project'; +} + +/** + * 更新命令的输入参数 + */ +export interface UpdateCommandInput { + /** 命令描述 */ + description?: string; + /** 提示词模板 */ + template?: string; + /** 指定 Agent */ + agent?: string; + /** 指定模型 */ + model?: string; + /** 是否作为子任务执行 */ + subtask?: boolean; +} + +/** + * 命令完整内容(包含 template) + */ +export interface CommandContent { + name: string; + description?: string; + template: string; + agent?: string; + model?: string; + subtask?: boolean; + source: string; + sourcePath?: string; +} + +/** + * 操作结果 + */ +export interface CommandOperationResult { + success: boolean; + path?: string; + error?: string; +} + +/** + * 命令管理器 + */ +export class CommandManager { + private workdir: string; + + constructor(workdir: string = process.cwd()) { + this.workdir = workdir; + } + + /** + * 验证命令名称是否合法 + * 防止路径遍历攻击 + */ + private validateCommandName(name: string): boolean { + // 不能为空 + if (!name || name.trim() === '') { + return false; + } + + // 不能包含 .. 或绝对路径 + if (name.includes('..') || path.isAbsolute(name)) { + return false; + } + + // 只允许字母、数字、连字符、下划线和斜杠 + if (!/^[a-zA-Z0-9_\-/]+$/.test(name)) { + return false; + } + + // 不能以斜杠开头或结尾 + if (name.startsWith('/') || name.endsWith('/')) { + return false; + } + + // 不能有连续的斜杠 + if (name.includes('//')) { + return false; + } + + return true; + } + + /** + * 生成 Markdown 内容 + */ + private generateMarkdownContent(input: { + description?: string; + template: string; + agent?: string; + model?: string; + subtask?: boolean; + }): string { + const frontmatter: CommandFrontmatter = {}; + + if (input.description) { + frontmatter.description = input.description; + } + if (input.agent) { + frontmatter.agent = input.agent; + } + if (input.model) { + frontmatter.model = input.model; + } + if (input.subtask !== undefined) { + frontmatter.subtask = input.subtask; + } + + // 如果有 frontmatter,生成 YAML + if (Object.keys(frontmatter).length > 0) { + const yamlStr = yaml.stringify(frontmatter).trim(); + return `---\n${yamlStr}\n---\n\n${input.template}`; + } + + // 没有 frontmatter,直接返回模板 + return input.template; + } + + /** + * 获取命令文件路径 + */ + getCommandFilePath(name: string, scope: 'user' | 'project'): string { + const baseDir = + scope === 'user' + ? commandLoader.getUserCommandsDir() + : commandLoader.getProjectCommandsDir(this.workdir); + + return path.join(baseDir, `${name}.md`); + } + + /** + * 创建命令 + */ + async create(input: CreateCommandInput): Promise { + // 验证命令名称 + if (!this.validateCommandName(input.name)) { + return { + success: false, + error: `Invalid command name: ${input.name}. Name can only contain letters, numbers, hyphens, underscores, and forward slashes.`, + }; + } + + // 验证模板不为空 + if (!input.template || input.template.trim() === '') { + return { + success: false, + error: 'Template cannot be empty', + }; + } + + // 检查是否已存在同名命令 + const registry = getCommandRegistry(); + const existing = registry.get(input.name); + if (existing) { + return { + success: false, + error: `Command already exists: ${input.name} (source: ${existing.source})`, + }; + } + + // 生成文件路径 + const filePath = this.getCommandFilePath(input.name, input.scope); + + // 确保目录存在 + const dir = path.dirname(filePath); + try { + await fs.mkdir(dir, { recursive: true }); + } catch (error) { + return { + success: false, + error: `Failed to create directory: ${dir}`, + }; + } + + // 生成 Markdown 内容 + const content = this.generateMarkdownContent({ + description: input.description, + template: input.template, + agent: input.agent, + model: input.model, + subtask: input.subtask, + }); + + // 写入文件 + try { + await fs.writeFile(filePath, content, 'utf-8'); + } catch (error) { + return { + success: false, + error: `Failed to write file: ${filePath}`, + }; + } + + // 重新加载命令注册表 + await registry.reload(this.workdir); + + return { + success: true, + path: filePath, + }; + } + + /** + * 更新命令 + */ + async update( + name: string, + input: UpdateCommandInput + ): Promise { + // 验证命令名称 + if (!this.validateCommandName(name)) { + return { + success: false, + error: `Invalid command name: ${name}`, + }; + } + + // 获取现有命令 + const registry = getCommandRegistry(); + const existing = registry.get(name); + + if (!existing) { + return { + success: false, + error: `Command not found: ${name}`, + }; + } + + // 内置命令不可修改 + if (existing.source === 'builtin') { + return { + success: false, + error: `Cannot modify builtin command: ${name}`, + }; + } + + // 获取文件路径 + const filePath = existing.sourcePath; + if (!filePath) { + return { + success: false, + error: `Command source path not found: ${name}`, + }; + } + + // 合并更新 + const updatedCommand = { + description: input.description ?? existing.description, + template: input.template ?? existing.template, + agent: input.agent ?? existing.agent, + model: input.model ?? existing.model, + subtask: input.subtask ?? existing.subtask, + }; + + // 生成新的 Markdown 内容 + const content = this.generateMarkdownContent(updatedCommand); + + // 写入文件 + try { + await fs.writeFile(filePath, content, 'utf-8'); + } catch (error) { + return { + success: false, + error: `Failed to write file: ${filePath}`, + }; + } + + // 重新加载命令注册表 + await registry.reload(this.workdir); + + return { + success: true, + path: filePath, + }; + } + + /** + * 删除命令 + */ + async delete(name: string): Promise { + // 验证命令名称 + if (!this.validateCommandName(name)) { + return { + success: false, + error: `Invalid command name: ${name}`, + }; + } + + // 获取现有命令 + const registry = getCommandRegistry(); + const existing = registry.get(name); + + if (!existing) { + return { + success: false, + error: `Command not found: ${name}`, + }; + } + + // 内置命令不可删除 + if (existing.source === 'builtin') { + return { + success: false, + error: `Cannot delete builtin command: ${name}`, + }; + } + + // 获取文件路径 + const filePath = existing.sourcePath; + if (!filePath) { + return { + success: false, + error: `Command source path not found: ${name}`, + }; + } + + // 删除文件 + try { + await fs.unlink(filePath); + } catch (error) { + return { + success: false, + error: `Failed to delete file: ${filePath}`, + }; + } + + // 尝试删除空的父目录 + try { + const dir = path.dirname(filePath); + const files = await fs.readdir(dir); + if (files.length === 0) { + await fs.rmdir(dir); + } + } catch { + // 忽略目录删除失败 + } + + // 重新加载命令注册表 + await registry.reload(this.workdir); + + return { + success: true, + path: filePath, + }; + } + + /** + * 获取命令完整内容(包含 template) + */ + async getContent(name: string): Promise<{ + success: boolean; + data?: CommandContent; + error?: string; + }> { + // 验证命令名称 + if (!this.validateCommandName(name)) { + return { + success: false, + error: `Invalid command name: ${name}`, + }; + } + + // 获取命令 + const registry = getCommandRegistry(); + const command = registry.get(name); + + if (!command) { + return { + success: false, + error: `Command not found: ${name}`, + }; + } + + return { + success: true, + data: { + name: command.name, + description: command.description, + template: command.template, + agent: command.agent, + model: command.model, + subtask: command.subtask, + source: command.source, + sourcePath: command.sourcePath, + }, + }; + } +} + +/** + * 创建命令管理器实例 + */ +export function createCommandManager( + workdir: string = process.cwd() +): CommandManager { + return new CommandManager(workdir); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5797833..86fb4ea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,8 +33,16 @@ export { SessionStorage } from './session/storage.js'; export type { SessionData, SessionSummary } from './session/types.js'; // Commands -export { getCommandRegistry, createCommandExecutor } from './commands/index.js'; -export type { Command, CommandInput, CommandExecutionResult } from './commands/index.js'; +export { getCommandRegistry, createCommandExecutor, createCommandManager } from './commands/index.js'; +export type { + Command, + CommandInput, + CommandExecutionResult, + CreateCommandInput, + UpdateCommandInput, + CommandContent, + CommandOperationResult, +} from './commands/index.js'; const program = new Command(); diff --git a/packages/server/src/routes/commands.ts b/packages/server/src/routes/commands.ts index 762e280..743e270 100644 --- a/packages/server/src/routes/commands.ts +++ b/packages/server/src/routes/commands.ts @@ -20,10 +20,30 @@ const SearchCommandInputSchema = z.object({ export const commandsRouter = new Hono(); +// Zod schemas for CRUD +const CreateCommandInputSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + template: z.string().min(1), + agent: z.string().optional(), + model: z.string().optional(), + subtask: z.boolean().optional(), + scope: z.enum(['user', 'project']), +}); + +const UpdateCommandInputSchema = z.object({ + description: z.string().optional(), + template: z.string().optional(), + agent: z.string().optional(), + model: z.string().optional(), + subtask: z.boolean().optional(), +}); + // Core 模块类型 interface CommandModule { getCommandRegistry: () => CommandRegistry; createCommandExecutor: (workdir: string) => CommandExecutor; + createCommandManager: (workdir: string) => CommandManager; } interface CommandRegistry { @@ -67,6 +87,43 @@ interface CommandExecutionResult { error?: string; } +interface CommandManager { + create(input: { + name: string; + description?: string; + template: string; + agent?: string; + model?: string; + subtask?: boolean; + scope: 'user' | 'project'; + }): Promise<{ success: boolean; path?: string; error?: string }>; + update( + name: string, + input: { + description?: string; + template?: string; + agent?: string; + model?: string; + subtask?: boolean; + } + ): Promise<{ success: boolean; path?: string; error?: string }>; + delete(name: string): Promise<{ success: boolean; path?: string; error?: string }>; + getContent(name: string): Promise<{ + success: boolean; + data?: { + name: string; + description?: string; + template: string; + agent?: string; + model?: string; + subtask?: boolean; + source: string; + sourcePath?: string; + }; + error?: string; + }>; +} + // Core 模块缓存 let commandModule: CommandModule | null = null; @@ -82,7 +139,8 @@ async function initCommandModule(): Promise { if ( typeof core.getCommandRegistry !== 'function' || - typeof core.createCommandExecutor !== 'function' + typeof core.createCommandExecutor !== 'function' || + typeof core.createCommandManager !== 'function' ) { console.warn('[Commands] Core module missing command exports'); return null; @@ -91,6 +149,7 @@ async function initCommandModule(): Promise { commandModule = { getCommandRegistry: core.getCommandRegistry as () => CommandRegistry, createCommandExecutor: core.createCommandExecutor as (workdir: string) => CommandExecutor, + createCommandManager: core.createCommandManager as (workdir: string) => CommandManager, }; // Initialize registry with server workdir @@ -321,3 +380,189 @@ commandsRouter.post('/:name{.+}/execute', async (c) => { ); } }); + +// ============================================================================ +// CRUD 操作 +// ============================================================================ + +/** + * POST /commands - 创建命令 + */ +commandsRouter.post('/', 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 = CreateCommandInputSchema.parse(body); + + const config = getConfig(); + const manager = module.createCommandManager(config.workdir); + const result = await manager.create(input); + + if (result.success) { + return c.json({ + success: true, + data: { + name: input.name, + path: result.path, + }, + }); + } else { + return c.json( + { + success: false, + error: result.error, + }, + 400 + ); + } + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Invalid input', + }, + 400 + ); + } +}); + +/** + * GET /commands/:name/content - 获取命令完整内容(包含 template) + */ +commandsRouter.get('/:name{.+}/content', 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 config = getConfig(); + const manager = module.createCommandManager(config.workdir); + const result = await manager.getContent(name); + + if (result.success) { + return c.json({ + success: true, + data: result.data, + }); + } else { + return c.json( + { + success: false, + error: result.error, + }, + 404 + ); + } +}); + +/** + * PUT /commands/:name - 更新命令 + */ +commandsRouter.put('/: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 + ); + } + + try { + const body = await c.req.json(); + const input = UpdateCommandInputSchema.parse(body); + + const config = getConfig(); + const manager = module.createCommandManager(config.workdir); + const result = await manager.update(name, input); + + if (result.success) { + return c.json({ + success: true, + data: { + name, + path: result.path, + }, + }); + } else { + return c.json( + { + success: false, + error: result.error, + }, + 400 + ); + } + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Invalid input', + }, + 400 + ); + } +}); + +/** + * DELETE /commands/:name - 删除命令 + */ +commandsRouter.delete('/: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 config = getConfig(); + const manager = module.createCommandManager(config.workdir); + const result = await manager.delete(name); + + if (result.success) { + return c.json({ + success: true, + data: { + name, + path: result.path, + }, + }); + } else { + return c.json( + { + success: false, + error: result.error, + }, + 400 + ); + } +}); diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 5b48f7a..8a978fb 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -14,6 +14,9 @@ import type { CommandSearchResult, CommandExecuteResult, CommandListResponse, + CreateCommandInput, + UpdateCommandInput, + CommandContent, } from './types.js'; // Re-export types @@ -31,6 +34,9 @@ export type { CommandSearchResult, CommandExecuteResult, CommandListResponse, + CreateCommandInput, + UpdateCommandInput, + CommandContent, } from './types.js'; // API Configuration @@ -203,3 +209,29 @@ export async function reloadCommands(): Promise<{ }> { return request('POST', '/commands/reload'); } + +// Commands CRUD +export async function createCommand( + input: CreateCommandInput +): Promise<{ success: boolean; data?: { name: string; path: string }; error?: string }> { + return request('POST', '/commands', input); +} + +export async function getCommandContent( + name: string +): Promise<{ success: boolean; data?: CommandContent; error?: string }> { + return request('GET', `/commands/${encodeURIComponent(name)}/content`); +} + +export async function updateCommand( + name: string, + input: UpdateCommandInput +): Promise<{ success: boolean; data?: { name: string; path: string }; error?: string }> { + return request('PUT', `/commands/${encodeURIComponent(name)}`, input); +} + +export async function deleteCommand( + name: string +): Promise<{ success: boolean; data?: { name: string; path: string }; error?: string }> { + return request('DELETE', `/commands/${encodeURIComponent(name)}`); +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 920bd66..1f8d643 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -128,3 +128,46 @@ export interface CommandListResponse { bySource: Record; }; } + +// ============ Command CRUD 相关 ============ + +export interface CreateCommandInput { + /** 命令名称(支持嵌套如 deploy/staging) */ + name: string; + /** 命令描述 */ + description?: string; + /** 提示词模板 */ + template: string; + /** 指定 Agent */ + agent?: string; + /** 指定模型 */ + model?: string; + /** 是否作为子任务执行 */ + subtask?: boolean; + /** 存储位置 */ + scope: 'user' | 'project'; +} + +export interface UpdateCommandInput { + /** 命令描述 */ + description?: string; + /** 提示词模板 */ + template?: string; + /** 指定 Agent */ + agent?: string; + /** 指定模型 */ + model?: string; + /** 是否作为子任务执行 */ + subtask?: boolean; +} + +export interface CommandContent { + name: string; + description?: string; + template: string; + agent?: string; + model?: string; + subtask?: boolean; + source: string; + sourcePath?: string; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 1d0b1ed..b619e29 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -27,6 +27,11 @@ export { executeCommand, searchCommands, reloadCommands, + // Commands CRUD + createCommand, + getCommandContent, + updateCommand, + deleteCommand, } from './api/client.js'; // Types @@ -45,6 +50,10 @@ export type { CommandSearchResult, CommandExecuteResult, CommandListResponse, + // Command CRUD types + CreateCommandInput, + UpdateCommandInput, + CommandContent, } from './api/client.js'; // Primitives (shadcn/ui style)