feat(commands): 实现命令 CRUD 完整功能
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 类型
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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<CommandOperationResult> {
|
||||
// 验证命令名称
|
||||
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<CommandOperationResult> {
|
||||
// 验证命令名称
|
||||
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<CommandOperationResult> {
|
||||
// 验证命令名称
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user