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:
2025-12-12 18:51:38 +08:00
parent db711648e0
commit f0385ef221
7 changed files with 780 additions and 3 deletions
+10
View File
@@ -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';
+430
View File
@@ -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);
}
+10 -2
View File
@@ -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();
+246 -1
View File
@@ -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<CommandModule | null> {
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 | null> {
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
);
}
});
+32
View File
@@ -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)}`);
}
+43
View File
@@ -128,3 +128,46 @@ export interface CommandListResponse {
bySource: Record<string, number>;
};
}
// ============ 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;
}
+9
View File
@@ -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)