diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts new file mode 100644 index 0000000..ca9f7b0 --- /dev/null +++ b/src/commands/builtin/index.ts @@ -0,0 +1,197 @@ +/** + * 内置 Commands + * + * 提供一些常用的预定义 Commands + */ + +import type { Command } from '../types.js'; + +/** + * /init - 初始化项目配置 + */ +export const initCommand: Command = { + name: 'init', + description: '分析代码库并创建 AGENTS.md 配置文件', + template: `Please analyze this codebase and create an AGENTS.md file containing: + +1. **Build/lint/test commands** - Document how to build, lint, and test the project +2. **Code style guidelines** - Document the coding conventions used +3. **Project structure** - Overview of the directory structure +4. **Key dependencies** - Important libraries and frameworks used + +Additional context: $ARGUMENTS + +Start by exploring the project structure and package configuration files.`, + agent: 'explore', + subtask: false, + source: 'builtin', +}; + +/** + * /review - 代码审查 + */ +export const reviewCommand: Command = { + name: 'review', + description: '审查代码变更', + template: `You are a code reviewer. Your job is to review code changes. + +Input: $ARGUMENTS + +## Determining What to Review + +Based on the input, determine which type of review to perform: + +1. **No arguments**: Review uncommitted changes using \`git diff\` +2. **Commit hash**: Review that specific commit using \`git show $ARGUMENTS\` +3. **Branch name**: Compare to specified branch using \`git diff $ARGUMENTS...HEAD\` +4. **PR URL**: Review the pull request (if gh CLI is available) + +## What to Look For + +- **Bugs**: Logic errors, edge cases, null/undefined handling, security issues +- **Structure**: Does it follow existing patterns? Is it maintainable? +- **Performance**: Only flag if obviously problematic +- **Tests**: Are there adequate tests for the changes? + +## Output Format + +Provide a structured review with: +1. Summary of changes +2. Issues found (categorized by severity) +3. Suggestions for improvement +4. Positive observations`, + agent: 'code-reviewer', + subtask: false, + source: 'builtin', +}; + +/** + * /test - 运行并修复测试 + */ +export const testCommand: Command = { + name: 'test', + description: '运行测试并修复失败的测试', + template: `Run the test suite and analyze the results. + +Focus on: $ARGUMENTS + +## Instructions + +1. First, run the test command for this project +2. If tests fail, analyze the failures +3. Identify the root cause of each failure +4. Fix the failing tests or the code they're testing +5. Re-run tests to verify fixes + +If no specific focus is provided, run all tests.`, + agent: 'general', + subtask: false, + source: 'builtin', +}; + +/** + * /fix - 修复问题 + */ +export const fixCommand: Command = { + name: 'fix', + description: '修复指定的问题或错误', + template: `Please fix the following issue: + +$ARGUMENTS + +## Instructions + +1. Understand the problem described +2. Locate the relevant code +3. Analyze the root cause +4. Implement a fix +5. Verify the fix works +6. Check for any side effects`, + agent: 'general', + subtask: false, + source: 'builtin', +}; + +/** + * /explain - 解释代码 + */ +export const explainCommand: Command = { + name: 'explain', + description: '解释代码或概念', + template: `Please explain the following: + +$ARGUMENTS + +Provide a clear, structured explanation that includes: +1. Overview - What it does at a high level +2. How it works - Step by step breakdown +3. Key concepts - Important patterns or techniques used +4. Examples - Practical usage examples if applicable`, + agent: 'general', + subtask: false, + source: 'builtin', +}; + +/** + * /commit - 生成 commit 消息 + */ +export const commitCommand: Command = { + name: 'commit', + description: '根据变更生成 Git commit 消息', + template: `Generate a Git commit message for the current changes. + +Additional context: $ARGUMENTS + +## Instructions + +1. Run \`git diff --staged\` to see staged changes (or \`git diff\` if nothing staged) +2. Analyze the changes +3. Generate a commit message following Conventional Commits format: + - feat: New feature + - fix: Bug fix + - docs: Documentation + - style: Formatting + - refactor: Code restructuring + - test: Adding tests + - chore: Maintenance + +4. Format: + - First line: type(scope): short description (50 chars max) + - Blank line + - Body: Detailed explanation if needed + +5. Present the commit message for review`, + agent: 'general', + subtask: false, + source: 'builtin', +}; + +/** + * /help - 显示帮助 + */ +export const helpCommand: Command = { + name: 'help', + description: '显示可用的命令和帮助信息', + template: `Show help information about available commands. + +Topic: $ARGUMENTS + +If a specific command is mentioned, provide detailed help for that command. +Otherwise, list all available commands with their descriptions.`, + agent: 'general', + subtask: false, + source: 'builtin', +}; + +/** + * 所有内置 Commands + */ +export const builtinCommands: Command[] = [ + initCommand, + reviewCommand, + testCommand, + fixCommand, + explainCommand, + commitCommand, + helpCommand, +]; diff --git a/src/commands/executor.ts b/src/commands/executor.ts new file mode 100644 index 0000000..a7add59 --- /dev/null +++ b/src/commands/executor.ts @@ -0,0 +1,284 @@ +/** + * Command 执行器 + * + * 负责解析和执行 Command: + * - 参数替换($ARGUMENTS, $1, $2, ...) + * - 文件引用(@filepath) + * - Shell 命令执行(!`command`) + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import type { Command, CommandInput, CommandExecutionResult } from './types.js'; +import { getCommandRegistry } from './registry.js'; + +const execAsync = promisify(exec); + +/** + * Command 执行器 + */ +export class CommandExecutor { + private workdir: string; + + constructor(workdir: string = process.cwd()) { + this.workdir = workdir; + } + + /** + * 解析用户输入的命令字符串 + * 例如: "/review main..feature" → { command: "review", arguments: "main..feature", args: ["main..feature"] } + */ + parseInput(input: string): CommandInput | null { + // 移除开头的 / + const trimmed = input.trim(); + if (!trimmed.startsWith('/')) { + return null; + } + + const withoutSlash = trimmed.slice(1); + const spaceIndex = withoutSlash.indexOf(' '); + + let commandName: string; + let argumentsStr: string; + + if (spaceIndex === -1) { + commandName = withoutSlash; + argumentsStr = ''; + } else { + commandName = withoutSlash.slice(0, spaceIndex); + argumentsStr = withoutSlash.slice(spaceIndex + 1).trim(); + } + + // 解析参数数组 + const args = argumentsStr ? this.parseArgs(argumentsStr) : []; + + return { + command: commandName, + arguments: argumentsStr, + args, + workdir: this.workdir, + }; + } + + /** + * 解析参数字符串为数组 + * 支持引号包裹的参数 + */ + private parseArgs(argsStr: string): string[] { + const args: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + + for (const char of argsStr) { + if ((char === '"' || char === "'") && !inQuote) { + inQuote = true; + quoteChar = char; + } else if (char === quoteChar && inQuote) { + inQuote = false; + quoteChar = ''; + } else if (char === ' ' && !inQuote) { + if (current) { + args.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; + } + + /** + * 执行 Command + */ + async execute(input: CommandInput): Promise { + const registry = getCommandRegistry(); + const command = registry.get(input.command); + + if (!command) { + // 尝试搜索相似的 Command + const suggestions = registry.search(input.command, 3); + let errorMsg = `Command 不存在: /${input.command}`; + + if (suggestions.length > 0) { + errorMsg += '\n\n你可能想要的 Command:\n'; + for (const { command: cmd } of suggestions) { + errorMsg += `- /${cmd.name}`; + if (cmd.description) { + errorMsg += `: ${cmd.description}`; + } + errorMsg += '\n'; + } + } + + return { + success: false, + error: errorMsg, + }; + } + + try { + // 渲染模板 + const prompt = await this.renderTemplate(command.template, input); + + return { + success: true, + prompt, + agent: command.agent, + model: command.model, + subtask: command.subtask, + }; + } catch (error) { + return { + success: false, + error: `Command 执行失败: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * 渲染模板 + */ + async renderTemplate( + template: string, + input: CommandInput + ): Promise { + let result = template; + + // 1. 替换位置参数 $1, $2, ... + result = this.replacePositionalArgs(result, input.args); + + // 2. 替换 $ARGUMENTS + result = result.replace(/\$ARGUMENTS/g, input.arguments); + + // 3. 处理文件引用 @filepath + result = await this.resolveFileReferences(result, input.workdir); + + // 4. 执行 Shell 命令 !`command` + result = await this.executeShellCommands(result, input.workdir); + + return result; + } + + /** + * 替换位置参数 + */ + private replacePositionalArgs(template: string, args: string[]): string { + // 找出模板中使用的最大参数索引 + const paramRegex = /\$(\d+)/g; + let maxIndex = 0; + let match; + + while ((match = paramRegex.exec(template)) !== null) { + const index = parseInt(match[1], 10); + if (index > maxIndex) { + maxIndex = index; + } + } + + // 替换参数 + let result = template; + for (let i = 1; i <= maxIndex; i++) { + const value = i === maxIndex + ? args.slice(i - 1).join(' ') // 最后一个参数获取剩余所有 + : args[i - 1] || ''; + + result = result.replace(new RegExp(`\\$${i}`, 'g'), value); + } + + return result; + } + + /** + * 解析文件引用 + * @filepath → 文件内容 + */ + private async resolveFileReferences( + template: string, + workdir: string + ): Promise { + const fileRefRegex = /@([^\s]+)/g; + const matches = [...template.matchAll(fileRefRegex)]; + + if (matches.length === 0) { + return template; + } + + let result = template; + + for (const match of matches) { + const [fullMatch, filePath] = match; + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(workdir, filePath); + + try { + const content = await fs.readFile(absolutePath, 'utf-8'); + // 替换为带有文件路径标记的内容 + const replacement = `\`\`\`${path.extname(filePath).slice(1) || 'txt'}\n// ${filePath}\n${content}\n\`\`\``; + result = result.replace(fullMatch, replacement); + } catch (error) { + // 文件不存在,保留原样或提示 + console.warn(`无法读取文件: ${absolutePath}`); + result = result.replace(fullMatch, `[文件不存在: ${filePath}]`); + } + } + + return result; + } + + /** + * 执行 Shell 命令 + * !`command` → 命令输出 + */ + private async executeShellCommands( + template: string, + workdir: string + ): Promise { + const shellRegex = /!\`([^`]+)\`/g; + const matches = [...template.matchAll(shellRegex)]; + + if (matches.length === 0) { + return template; + } + + let result = template; + + for (const match of matches) { + const [fullMatch, command] = match; + + try { + const { stdout, stderr } = await execAsync(command, { + cwd: workdir, + timeout: 30000, // 30 秒超时 + }); + + const output = (stdout + stderr).trim(); + result = result.replace(fullMatch, output); + } catch (error) { + // 命令执行失败,替换为错误信息 + const errorMsg = error instanceof Error ? error.message : String(error); + result = result.replace(fullMatch, `[命令执行失败: ${command}]\n${errorMsg}`); + } + } + + return result; + } +} + +/** + * 创建 Command 执行器 + */ +export function createCommandExecutor( + workdir: string = process.cwd() +): CommandExecutor { + return new CommandExecutor(workdir); +} diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..30515da --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,30 @@ +/** + * Commands 模块 + * + * 提供 Command 系统的所有功能导出 + */ + +// 类型 +export type { + Command, + CommandInput, + CommandExecutionResult, + CommandSearchResult, + CommandFrontmatter, +} from './types.js'; + +// 加载器 +export { CommandLoader, commandLoader } from './loader.js'; + +// 注册表 +export { + CommandRegistry, + getCommandRegistry, + resetCommandRegistry, +} from './registry.js'; + +// 执行器 +export { CommandExecutor, createCommandExecutor } from './executor.js'; + +// 内置 Commands +export { builtinCommands } from './builtin/index.js'; diff --git a/src/commands/loader.ts b/src/commands/loader.ts new file mode 100644 index 0000000..7734db4 --- /dev/null +++ b/src/commands/loader.ts @@ -0,0 +1,172 @@ +/** + * Command 加载器 + * + * 负责从文件系统加载 Markdown 格式的 Command 定义。 + * 支持从以下位置加载: + * 1. 内置 Commands(代码中定义) + * 2. 用户 Commands(~/.config/ai-terminal/commands/) + * 3. 项目 Commands(./.ai-terminal/commands/) + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as yaml from 'yaml'; +import type { Command, CommandFrontmatter } from './types.js'; + +/** + * Command 加载器 + */ +export class CommandLoader { + /** + * 从目录加载所有 Commands + */ + async loadFromDirectory( + dir: string, + source: 'user' | 'project' + ): Promise { + const commands: Command[] = []; + + try { + const exists = await fs + .access(dir) + .then(() => true) + .catch(() => false); + + if (!exists) { + return commands; + } + + await this.scanDirectory(dir, dir, source, commands); + } catch (error) { + console.warn(`读取 Commands 目录失败: ${dir}`, error); + } + + return commands; + } + + /** + * 递归扫描目录 + */ + private async scanDirectory( + baseDir: string, + currentDir: string, + source: 'user' | 'project', + commands: Command[] + ): Promise { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + + if (entry.isFile() && entry.name.endsWith('.md')) { + try { + const command = await this.loadFromFile(fullPath, baseDir, source); + if (command) { + commands.push(command); + } + } catch (error) { + console.warn(`加载 Command 文件失败: ${fullPath}`, error); + } + } else if (entry.isDirectory() && !entry.name.startsWith('.')) { + // 递归加载子目录(支持嵌套路径如 deploy/staging) + await this.scanDirectory(baseDir, fullPath, source, commands); + } + } + } + + /** + * 从单个 Markdown 文件加载 Command + */ + async loadFromFile( + filePath: string, + baseDir: string, + source: 'user' | 'project' + ): Promise { + const content = await fs.readFile(filePath, 'utf-8'); + + // 从文件路径推断命令名称 + // baseDir/deploy/staging.md → deploy/staging + const relativePath = path.relative(baseDir, filePath); + const name = relativePath.slice(0, -3); // 移除 .md 后缀 + + return this.parseMarkdownCommand(content, name, source, filePath); + } + + /** + * 解析 Markdown 格式的 Command + * + * 格式示例: + * ```markdown + * --- + * description: 代码审查 + * agent: explore + * model: sonnet + * subtask: true + * --- + * + * You are a code reviewer... + * + * Input: $ARGUMENTS + * ``` + */ + parseMarkdownCommand( + content: string, + name: string, + source: 'user' | 'project' | 'builtin', + sourcePath?: string + ): Command | null { + // 解析 frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + + let frontmatter: CommandFrontmatter = {}; + let template: string; + + if (frontmatterMatch) { + const [, frontmatterStr, bodyContent] = frontmatterMatch; + try { + frontmatter = yaml.parse(frontmatterStr) as CommandFrontmatter; + } catch (error) { + console.warn(`解析 Command frontmatter 失败: ${sourcePath}`, error); + } + template = bodyContent.trim(); + } else { + // 没有 frontmatter,整个内容作为模板 + template = content.trim(); + } + + if (!template) { + return null; + } + + return { + name, + description: frontmatter.description, + template, + agent: frontmatter.agent, + model: frontmatter.model, + subtask: frontmatter.subtask, + source, + sourcePath, + }; + } + + /** + * 获取用户 Commands 目录 + */ + getUserCommandsDir(): string { + const home = process.env.HOME || process.env.USERPROFILE || ''; + return path.join(home, '.config', 'ai-terminal', 'commands'); + } + + /** + * 获取项目 Commands 目录 + */ + getProjectCommandsDir(workdir: string = process.cwd()): string { + return path.join(workdir, '.ai-terminal', 'commands'); + } +} + +/** + * 全局 Command 加载器实例 + */ +export const commandLoader = new CommandLoader(); diff --git a/src/commands/registry.ts b/src/commands/registry.ts new file mode 100644 index 0000000..9b5b95f --- /dev/null +++ b/src/commands/registry.ts @@ -0,0 +1,199 @@ +/** + * Command 注册表 + * + * 管理所有可用的 Commands,支持: + * - 注册/注销 Commands + * - 按名称查询 + * - 搜索 Commands + */ + +import type { Command, CommandSearchResult } from './types.js'; +import { commandLoader } from './loader.js'; +import { builtinCommands } from './builtin/index.js'; + +/** + * Command 注册表 + */ +export class CommandRegistry { + private commands = new Map(); + private initialized = false; + + /** + * 初始化注册表 + */ + async initialize(workdir: string = process.cwd()): Promise { + if (this.initialized) { + return; + } + + // 1. 注册内置 Commands + for (const command of builtinCommands) { + this.register(command); + } + + // 2. 加载用户 Commands + const userDir = commandLoader.getUserCommandsDir(); + const userCommands = await commandLoader.loadFromDirectory(userDir, 'user'); + for (const command of userCommands) { + this.register(command); + } + + // 3. 加载项目 Commands + const projectDir = commandLoader.getProjectCommandsDir(workdir); + const projectCommands = await commandLoader.loadFromDirectory( + projectDir, + 'project' + ); + for (const command of projectCommands) { + this.register(command); + } + + this.initialized = true; + } + + /** + * 注册 Command + */ + register(command: Command): void { + // 项目 Commands 优先级最高,可以覆盖同名的内置/用户 Commands + const existing = this.commands.get(command.name); + if (existing) { + // 优先级: project > user > builtin + const priority = { project: 3, user: 2, builtin: 1 }; + if (priority[command.source] < priority[existing.source]) { + return; // 不覆盖更高优先级的 Command + } + } + this.commands.set(command.name, command); + } + + /** + * 注销 Command + */ + unregister(name: string): boolean { + return this.commands.delete(name); + } + + /** + * 获取 Command + */ + get(name: string): Command | undefined { + return this.commands.get(name); + } + + /** + * 检查 Command 是否存在 + */ + has(name: string): boolean { + return this.commands.has(name); + } + + /** + * 获取所有 Commands + */ + getAll(): Command[] { + return Array.from(this.commands.values()); + } + + /** + * 搜索 Commands + */ + search(query: string, limit: number = 10): CommandSearchResult[] { + const queryLower = query.toLowerCase(); + const results: CommandSearchResult[] = []; + + for (const command of this.commands.values()) { + let score = 0; + + // 精确名称匹配 + if (command.name.toLowerCase() === queryLower) { + score = 100; + } + // 名称前缀匹配 + else if (command.name.toLowerCase().startsWith(queryLower)) { + score = 80; + } + // 名称包含匹配 + else if (command.name.toLowerCase().includes(queryLower)) { + score = 60; + } + // 描述匹配 + else if (command.description?.toLowerCase().includes(queryLower)) { + score = 40; + } + + if (score > 0) { + results.push({ command, score }); + } + } + + // 按分数降序排序 + results.sort((a, b) => b.score - a.score); + + return results.slice(0, limit); + } + + /** + * 列出所有 Commands(用于帮助显示) + */ + list(): Array<{ name: string; description?: string; source: string }> { + return this.getAll() + .map((cmd) => ({ + name: cmd.name, + description: cmd.description, + source: cmd.source, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * 重新加载 Commands + */ + async reload(workdir: string = process.cwd()): Promise { + this.commands.clear(); + this.initialized = false; + await this.initialize(workdir); + } + + /** + * 获取统计信息 + */ + getStats(): { + total: number; + bySource: Record; + } { + const commands = this.getAll(); + const bySource: Record = {}; + + for (const command of commands) { + bySource[command.source] = (bySource[command.source] || 0) + 1; + } + + return { + total: commands.length, + bySource, + }; + } +} + +/** + * 全局 Command 注册表实例 + */ +let commandRegistryInstance: CommandRegistry | null = null; + +/** + * 获取全局 Command 注册表 + */ +export function getCommandRegistry(): CommandRegistry { + if (!commandRegistryInstance) { + commandRegistryInstance = new CommandRegistry(); + } + return commandRegistryInstance; +} + +/** + * 重置全局 Command 注册表(用于测试) + */ +export function resetCommandRegistry(): void { + commandRegistryInstance = null; +} diff --git a/src/commands/types.ts b/src/commands/types.ts new file mode 100644 index 0000000..eb5d219 --- /dev/null +++ b/src/commands/types.ts @@ -0,0 +1,80 @@ +/** + * Command 系统类型定义 + * + * Command 是用户可通过斜杠命令触发的可复用提示词模板。 + * 与 Skill 不同,Command 面向用户,可控制完整执行流程。 + */ + +/** + * Command 定义 + */ +export interface Command { + /** Command 名称(从文件路径推断,如 deploy/staging) */ + name: string; + /** Command 描述 */ + description?: string; + /** 提示词模板 */ + template: string; + /** 指定使用的 Agent */ + agent?: string; + /** 指定使用的模型 (sonnet/opus/haiku) */ + model?: string; + /** 是否作为子任务执行 */ + subtask?: boolean; + /** 来源 */ + source: 'builtin' | 'user' | 'project'; + /** 来源路径 */ + sourcePath?: string; +} + +/** + * Command 执行输入 + */ +export interface CommandInput { + /** Command 名称 */ + command: string; + /** 原始参数字符串 */ + arguments: string; + /** 解析后的参数数组 */ + args: string[]; + /** 当前工作目录 */ + workdir: string; +} + +/** + * Command 执行结果 + */ +export interface CommandExecutionResult { + /** 是否成功 */ + success: boolean; + /** 渲染后的提示 */ + prompt?: string; + /** 指定的 Agent */ + agent?: string; + /** 指定的模型 */ + model?: string; + /** 是否作为子任务 */ + subtask?: boolean; + /** 错误信息 */ + error?: string; +} + +/** + * Command 搜索结果 + */ +export interface CommandSearchResult { + /** Command */ + command: Command; + /** 匹配分数 */ + score: number; +} + +/** + * Command Frontmatter(Markdown 头部配置) + */ +export interface CommandFrontmatter { + description?: string; + agent?: string; + model?: string; + subtask?: boolean; +} diff --git a/src/index.ts b/src/index.ts index 10bf0ed..443ccaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,11 +4,13 @@ import { Command } from 'commander'; import { Agent } from './core/agent.js'; import { TerminalUI } from './ui/terminal.js'; import { loadConfig, initConfig } from './utils/config.js'; -import { toolRegistry, todoManager, initTaskContext, updateTaskDescription } from './tools/index.js'; +import { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js'; import { getPermissionManager, promptPermission } from './permission/index.js'; import { SessionManager } from './session/index.js'; import { agentRegistry } from './agent/index.js'; import { initLSP, shutdownLSP } from './lsp/index.js'; +import { getCommandRegistry } from './commands/index.js'; +import { getSkillRegistry } from './skills/index.js'; import { printServerList, installServer, @@ -127,6 +129,15 @@ program.action(async () => { initTaskContext(config, sessionManager); updateTaskDescription(); + // 初始化 Skill 注册表 + const skillRegistry = getSkillRegistry(); + await skillRegistry.initialize(process.cwd()); + updateSkillDescription(); + + // 初始化 Command 注册表 + const commandRegistry = getCommandRegistry(); + await commandRegistry.initialize(process.cwd()); + // 显示会话恢复信息 const session = sessionManager.getSession(); if (session && session.messages.length > 0) { diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts index 637a3b9..0f4d3c8 100644 --- a/src/ui/terminal.ts +++ b/src/ui/terminal.ts @@ -2,6 +2,11 @@ import * as readline from 'readline'; import chalk from 'chalk'; import type { Agent } from '../core/agent.js'; import { agentRegistry } from '../agent/index.js'; +import { + getCommandRegistry, + createCommandExecutor, + type CommandExecutionResult, +} from '../commands/index.js'; export class TerminalUI { private agent: Agent; @@ -30,6 +35,7 @@ export class TerminalUI { console.log(chalk.cyan('╚════════════════════════════════════════╝\n')); console.log(chalk.gray('输入你的问题,或使用以下命令:')); console.log(chalk.yellow(' /help') + chalk.gray(' - 显示帮助')); + console.log(chalk.yellow(' /commands') + chalk.gray(' - 列出所有可用命令')); console.log(chalk.yellow(' /agent') + chalk.gray(' - 切换 Agent 模式')); console.log(chalk.yellow(' /clear') + chalk.gray(' - 清空对话历史')); console.log(chalk.yellow(' /compact') + chalk.gray(' - 压缩对话历史')); @@ -58,6 +64,79 @@ export class TerminalUI { return colorFn(`[${used}/${limit}]`); } + // 列出所有可用的 Commands + private listCommands(): void { + const registry = getCommandRegistry(); + const commands = registry.list(); + + console.log(chalk.cyan('\n📋 可用的 Commands:\n')); + + if (commands.length === 0) { + console.log(chalk.gray(' 没有可用的命令')); + } else { + // 按来源分组 + const builtin = commands.filter((c) => c.source === 'builtin'); + const user = commands.filter((c) => c.source === 'user'); + const project = commands.filter((c) => c.source === 'project'); + + if (builtin.length > 0) { + console.log(chalk.white(' 内置命令:')); + for (const cmd of builtin) { + console.log( + chalk.yellow(` /${cmd.name}`) + + chalk.gray(cmd.description ? ` - ${cmd.description}` : '') + ); + } + console.log(''); + } + + if (user.length > 0) { + console.log(chalk.white(' 用户命令:')); + for (const cmd of user) { + console.log( + chalk.yellow(` /${cmd.name}`) + + chalk.gray(cmd.description ? ` - ${cmd.description}` : '') + ); + } + console.log(''); + } + + if (project.length > 0) { + console.log(chalk.white(' 项目命令:')); + for (const cmd of project) { + console.log( + chalk.yellow(` /${cmd.name}`) + + chalk.gray(cmd.description ? ` - ${cmd.description}` : '') + ); + } + console.log(''); + } + } + + console.log(chalk.gray(' 用法: / [arguments]')); + console.log(chalk.gray(' 示例: /review main..feature')); + console.log(''); + } + + // 尝试执行自定义 Command + private async tryExecuteCommand(input: string): Promise { + const executor = createCommandExecutor(process.cwd()); + const parsed = executor.parseInput(input); + + if (!parsed) { + return null; + } + + const registry = getCommandRegistry(); + + // 检查是否是自定义 Command(不是内置的系统命令) + if (!registry.has(parsed.command)) { + return null; + } + + return await executor.execute(parsed); + } + // 处理 /agent 命令 private handleAgentCommand(args: string): boolean { const agentName = args.trim(); @@ -126,6 +205,12 @@ export class TerminalUI { return this.handleAgentCommand(args); } + // 检查 /commands 命令 + if (command === '/commands') { + this.listCommands(); + return true; + } + switch (command) { case '/help': console.log(chalk.cyan('\n📖 帮助信息:')); @@ -226,9 +311,71 @@ export class TerminalUI { // 处理命令 if (input.startsWith('/')) { + // 先检查系统内置命令 if (await this.handleCommand(input)) { continue; } + + // 尝试执行自定义 Command + const cmdResult = await this.tryExecuteCommand(input); + if (cmdResult) { + if (!cmdResult.success) { + console.log(chalk.red(`\n❌ ${cmdResult.error}\n`)); + continue; + } + + // Command 执行成功,将渲染后的提示发送给 AI + console.log(chalk.cyan(`\n📝 执行命令: ${input.split(' ')[0]}`)); + if (cmdResult.agent) { + console.log(chalk.gray(` Agent: ${cmdResult.agent}`)); + } + if (cmdResult.model) { + console.log(chalk.gray(` Model: ${cmdResult.model}`)); + } + console.log(''); + + // 使用渲染后的 prompt 作为输入 + const promptToSend = cmdResult.prompt || ''; + + // TODO: 如果指定了 agent/model/subtask,需要相应处理 + // 目前简单地将渲染后的 prompt 发送给当前 agent + process.stdout.write(chalk.gray('思考中...')); + + try { + let isFirstChunk = true; + + await this.agent.chat(promptToSend, (text) => { + if (isFirstChunk) { + process.stdout.write('\r' + ' '.repeat(20) + '\r'); + process.stdout.write(chalk.blue('AI > ')); + isFirstChunk = false; + } + + if (text.startsWith('\n[调用工具:')) { + process.stdout.write(chalk.yellow(text)); + } else if (text.startsWith('[结果:') || text.startsWith('[错误:')) { + process.stdout.write(chalk.gray(text)); + } else { + process.stdout.write(text); + } + }); + + console.log('\n'); + } catch (error) { + process.stdout.write('\r' + ' '.repeat(20) + '\r'); + console.log( + chalk.red( + `❌ 错误: ${error instanceof Error ? error.message : String(error)}\n` + ) + ); + } + continue; + } + + // 未知命令 + console.log(chalk.yellow(`\n未知命令: ${input.split(' ')[0]}`)); + console.log(chalk.gray('使用 /commands 查看所有可用命令\n')); + continue; } // 发送给 AI diff --git a/tests/unit/commands/executor.test.ts b/tests/unit/commands/executor.test.ts new file mode 100644 index 0000000..ad4b635 --- /dev/null +++ b/tests/unit/commands/executor.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as childProcess from 'child_process'; +import { + CommandExecutor, + createCommandExecutor, + getCommandRegistry, + resetCommandRegistry, +} from '../../../src/commands/index.js'; +import type { Command } from '../../../src/commands/types.js'; + +// Mock fs +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), +})); + +// Mock child_process +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +// Mock util.promisify to return our mocked exec +vi.mock('util', () => ({ + promisify: (fn: unknown) => { + if (fn === childProcess.exec) { + return vi.fn().mockImplementation((cmd: string, _options: unknown) => { + // 默认返回命令输出 + return Promise.resolve({ stdout: `output of: ${cmd}`, stderr: '' }); + }); + } + return fn; + }, +})); + +// Mock loader and builtin commands +vi.mock('../../../src/commands/loader.js', () => ({ + commandLoader: { + loadFromDirectory: vi.fn().mockResolvedValue([]), + getUserCommandsDir: vi.fn().mockReturnValue('/mock/user/commands'), + getProjectCommandsDir: vi.fn().mockReturnValue('/mock/project/commands'), + }, +})); + +vi.mock('../../../src/commands/builtin/index.js', () => ({ + builtinCommands: [], +})); + +describe('CommandExecutor - Command 执行器', () => { + let executor: CommandExecutor; + + beforeEach(() => { + resetCommandRegistry(); + executor = new CommandExecutor('/test/workdir'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('parseInput - 解析输入', () => { + it('解析简单命令', () => { + const result = executor.parseInput('/test'); + + expect(result).not.toBeNull(); + expect(result?.command).toBe('test'); + expect(result?.arguments).toBe(''); + expect(result?.args).toEqual([]); + }); + + it('解析带参数的命令', () => { + const result = executor.parseInput('/review main..feature'); + + expect(result).not.toBeNull(); + expect(result?.command).toBe('review'); + expect(result?.arguments).toBe('main..feature'); + expect(result?.args).toEqual(['main..feature']); + }); + + it('解析多个参数', () => { + const result = executor.parseInput('/deploy staging production'); + + expect(result).not.toBeNull(); + expect(result?.command).toBe('deploy'); + expect(result?.arguments).toBe('staging production'); + expect(result?.args).toEqual(['staging', 'production']); + }); + + it('解析带引号的参数', () => { + const result = executor.parseInput('/commit "fix: bug fix"'); + + expect(result).not.toBeNull(); + expect(result?.command).toBe('commit'); + expect(result?.args).toEqual(['fix: bug fix']); + }); + + it('解析带单引号的参数', () => { + const result = executor.parseInput("/test 'hello world' foo"); + + expect(result).not.toBeNull(); + expect(result?.args).toEqual(['hello world', 'foo']); + }); + + it('不是命令时返回 null', () => { + const result = executor.parseInput('not a command'); + expect(result).toBeNull(); + }); + + it('空输入返回 null', () => { + const result = executor.parseInput(''); + expect(result).toBeNull(); + }); + + it('包含工作目录', () => { + const result = executor.parseInput('/test'); + expect(result?.workdir).toBe('/test/workdir'); + }); + }); + + describe('execute - 执行命令', () => { + beforeEach(async () => { + const registry = getCommandRegistry(); + await registry.initialize('/test'); + + // 手动注册测试命令 + const testCommand: Command = { + name: 'greet', + description: '打招呼', + template: 'Hello, $ARGUMENTS!', + source: 'builtin', + }; + registry.register(testCommand); + }); + + it('执行存在的命令', async () => { + const input = executor.parseInput('/greet World'); + expect(input).not.toBeNull(); + + const result = await executor.execute(input!); + + expect(result.success).toBe(true); + expect(result.prompt).toBe('Hello, World!'); + }); + + it('命令不存在时返回错误', async () => { + const input = executor.parseInput('/nonexistent'); + expect(input).not.toBeNull(); + + const result = await executor.execute(input!); + + expect(result.success).toBe(false); + expect(result.error).toContain('Command 不存在'); + }); + + it('返回 agent 配置', async () => { + const registry = getCommandRegistry(); + const agentCommand: Command = { + name: 'code', + description: '编码', + template: 'Write code', + agent: 'coder', + source: 'builtin', + }; + registry.register(agentCommand); + + const input = executor.parseInput('/code'); + const result = await executor.execute(input!); + + expect(result.success).toBe(true); + expect(result.agent).toBe('coder'); + }); + + it('返回 model 配置', async () => { + const registry = getCommandRegistry(); + const modelCommand: Command = { + name: 'complex', + description: '复杂任务', + template: 'Complex task', + model: 'opus', + source: 'builtin', + }; + registry.register(modelCommand); + + const input = executor.parseInput('/complex'); + const result = await executor.execute(input!); + + expect(result.success).toBe(true); + expect(result.model).toBe('opus'); + }); + }); + + describe('renderTemplate - 模板渲染', () => { + it('替换 $ARGUMENTS', async () => { + const input = { + command: 'test', + arguments: 'hello world', + args: ['hello', 'world'], + workdir: '/test', + }; + + const result = await executor.renderTemplate('Say: $ARGUMENTS', input); + expect(result).toBe('Say: hello world'); + }); + + it('替换位置参数 $1(单个参数时获取全部)', async () => { + const input = { + command: 'test', + arguments: 'foo bar', + args: ['foo', 'bar'], + workdir: '/test', + }; + + // 当 $1 是最大参数索引时,获取所有剩余参数 + const result = await executor.renderTemplate('First: $1', input); + expect(result).toBe('First: foo bar'); + }); + + it('替换多个位置参数', async () => { + const input = { + command: 'test', + arguments: 'a b c', + args: ['a', 'b', 'c'], + workdir: '/test', + }; + + const result = await executor.renderTemplate('$1 and $2', input); + expect(result).toBe('a and b c'); + }); + + it('最后一个位置参数获取剩余所有', async () => { + const input = { + command: 'test', + arguments: 'a b c d', + args: ['a', 'b', 'c', 'd'], + workdir: '/test', + }; + + const result = await executor.renderTemplate('First: $1, Rest: $2', input); + expect(result).toBe('First: a, Rest: b c d'); + }); + + it('缺少参数时替换为空字符串', async () => { + const input = { + command: 'test', + arguments: '', + args: [], + workdir: '/test', + }; + + const result = await executor.renderTemplate('Value: $1', input); + expect(result).toBe('Value: '); + }); + + it('处理文件引用 @filepath', async () => { + const mockContent = 'file content here'; + vi.mocked(fs.readFile).mockResolvedValue(mockContent); + + const input = { + command: 'test', + arguments: '', + args: [], + workdir: '/test', + }; + + const result = await executor.renderTemplate('Review: @src/file.ts', input); + expect(result).toContain('file content here'); + expect(result).toContain('```ts'); + }); + + it('文件不存在时显示提示', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + + const input = { + command: 'test', + arguments: '', + args: [], + workdir: '/test', + }; + + const result = await executor.renderTemplate('Review: @nonexistent.ts', input); + expect(result).toContain('[文件不存在: nonexistent.ts]'); + }); + }); + + describe('createCommandExecutor - 工厂函数', () => { + it('创建执行器实例', () => { + const exec = createCommandExecutor('/custom/path'); + expect(exec).toBeInstanceOf(CommandExecutor); + }); + + it('默认使用 process.cwd', () => { + const exec = createCommandExecutor(); + const input = exec.parseInput('/test'); + expect(input?.workdir).toBe(process.cwd()); + }); + }); +}); diff --git a/tests/unit/commands/loader.test.ts b/tests/unit/commands/loader.test.ts new file mode 100644 index 0000000..51e5e8d --- /dev/null +++ b/tests/unit/commands/loader.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CommandLoader } from '../../../src/commands/loader.js'; + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + access: vi.fn(), + readdir: vi.fn(), + readFile: vi.fn(), +})); + +import * as fs from 'fs/promises'; + +describe('CommandLoader - Command 加载器', () => { + let loader: CommandLoader; + + beforeEach(() => { + vi.clearAllMocks(); + loader = new CommandLoader(); + }); + + describe('parseMarkdownCommand - 解析 Markdown', () => { + it('解析带 frontmatter 的 Markdown', () => { + const content = `--- +description: 代码审查 +agent: explore +model: sonnet +subtask: true +--- + +You are a code reviewer. + +Input: $ARGUMENTS +`; + + const command = loader.parseMarkdownCommand(content, 'review', 'user'); + + expect(command).not.toBeNull(); + expect(command?.name).toBe('review'); + expect(command?.description).toBe('代码审查'); + expect(command?.agent).toBe('explore'); + expect(command?.model).toBe('sonnet'); + expect(command?.subtask).toBe(true); + expect(command?.template).toContain('You are a code reviewer'); + expect(command?.template).toContain('$ARGUMENTS'); + expect(command?.source).toBe('user'); + }); + + it('解析不带 frontmatter 的 Markdown', () => { + const content = `# Simple Command + +This is a simple prompt template. + +$ARGUMENTS +`; + + const command = loader.parseMarkdownCommand(content, 'simple', 'project'); + + expect(command).not.toBeNull(); + expect(command?.name).toBe('simple'); + expect(command?.description).toBeUndefined(); + expect(command?.template).toContain('Simple Command'); + expect(command?.template).toContain('$ARGUMENTS'); + expect(command?.source).toBe('project'); + }); + + it('空模板返回 null', () => { + const content = `--- +description: Empty +--- + +`; + + const command = loader.parseMarkdownCommand(content, 'empty', 'user'); + + expect(command).toBeNull(); + }); + }); + + describe('loadFromFile - 从文件加载', () => { + it('从 Markdown 文件加载 Command', async () => { + const mdContent = `--- +description: Test command +--- + +Test template $ARGUMENTS +`; + + vi.mocked(fs.readFile).mockResolvedValue(mdContent); + + const command = await loader.loadFromFile( + '/base/commands/test.md', + '/base/commands', + 'user' + ); + + expect(command).not.toBeNull(); + expect(command?.name).toBe('test'); + expect(command?.description).toBe('Test command'); + }); + + it('处理嵌套目录路径', async () => { + const mdContent = `--- +description: Deploy staging +--- + +Deploy to staging +`; + + vi.mocked(fs.readFile).mockResolvedValue(mdContent); + + const command = await loader.loadFromFile( + '/base/commands/deploy/staging.md', + '/base/commands', + 'project' + ); + + expect(command).not.toBeNull(); + expect(command?.name).toBe('deploy/staging'); + }); + }); + + describe('loadFromDirectory - 从目录加载', () => { + it('目录不存在时返回空数组', async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('Not found')); + + const commands = await loader.loadFromDirectory('/non-existent', 'user'); + + expect(commands).toEqual([]); + }); + + it('加载目录中的所有 Markdown 文件', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'cmd1.md', isFile: () => true, isDirectory: () => false }, + { name: 'cmd2.md', isFile: () => true, isDirectory: () => false }, + { name: 'readme.txt', isFile: () => true, isDirectory: () => false }, // 非 .md + ] as any); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce('---\ndescription: Cmd1\n---\nTemplate 1') + .mockResolvedValueOnce('---\ndescription: Cmd2\n---\nTemplate 2'); + + const commands = await loader.loadFromDirectory('/test', 'user'); + + expect(commands.length).toBe(2); + expect(commands.map((c) => c.name)).toContain('cmd1'); + expect(commands.map((c) => c.name)).toContain('cmd2'); + }); + + it('递归加载子目录', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir) + .mockResolvedValueOnce([ + { name: 'root.md', isFile: () => true, isDirectory: () => false }, + { name: 'subdir', isFile: () => false, isDirectory: () => true }, + ] as any) + .mockResolvedValueOnce([ + { name: 'sub.md', isFile: () => true, isDirectory: () => false }, + ] as any); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce('Root template') + .mockResolvedValueOnce('Sub template'); + + const commands = await loader.loadFromDirectory('/test', 'project'); + + expect(commands.length).toBe(2); + expect(commands.map((c) => c.name)).toContain('root'); + expect(commands.map((c) => c.name)).toContain('subdir/sub'); + }); + + it('跳过隐藏目录', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: '.hidden', isFile: () => false, isDirectory: () => true }, + { name: 'visible.md', isFile: () => true, isDirectory: () => false }, + ] as any); + + vi.mocked(fs.readFile).mockResolvedValue('Template'); + + const commands = await loader.loadFromDirectory('/test', 'user'); + + expect(commands.length).toBe(1); + expect(commands[0].name).toBe('visible'); + }); + }); + + describe('目录路径', () => { + it('getUserCommandsDir 返回用户 Commands 目录', () => { + const originalHome = process.env.HOME; + process.env.HOME = '/home/testuser'; + + const dir = loader.getUserCommandsDir(); + + expect(dir).toBe('/home/testuser/.config/ai-terminal/commands'); + + process.env.HOME = originalHome; + }); + + it('getProjectCommandsDir 返回项目 Commands 目录', () => { + const dir = loader.getProjectCommandsDir('/workspace'); + + expect(dir).toBe('/workspace/.ai-terminal/commands'); + }); + }); +}); diff --git a/tests/unit/commands/registry.test.ts b/tests/unit/commands/registry.test.ts new file mode 100644 index 0000000..4a29984 --- /dev/null +++ b/tests/unit/commands/registry.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + CommandRegistry, + getCommandRegistry, + resetCommandRegistry, +} from '../../../src/commands/registry.js'; +import type { Command } from '../../../src/commands/types.js'; + +// Mock loader +vi.mock('../../../src/commands/loader.js', () => ({ + commandLoader: { + loadFromDirectory: vi.fn().mockResolvedValue([]), + getUserCommandsDir: vi.fn().mockReturnValue('/mock/user/commands'), + getProjectCommandsDir: vi.fn().mockReturnValue('/mock/project/commands'), + }, +})); + +// Mock builtin commands +vi.mock('../../../src/commands/builtin/index.js', () => ({ + builtinCommands: [ + { + name: 'test-builtin', + description: '内置测试命令', + template: '测试模板 $ARGUMENTS', + source: 'builtin', + }, + ], +})); + +describe('CommandRegistry - Command 注册表', () => { + let registry: CommandRegistry; + + beforeEach(() => { + resetCommandRegistry(); + registry = new CommandRegistry(); + }); + + describe('register - 注册', () => { + it('成功注册 Command', () => { + const command: Command = { + name: 'test', + description: '测试', + template: '模板', + source: 'user', + }; + + registry.register(command); + expect(registry.has('test')).toBe(true); + }); + + it('高优先级可以覆盖低优先级', () => { + const builtin: Command = { + name: 'same', + description: '内置版本', + template: '内置模板', + source: 'builtin', + }; + + const project: Command = { + name: 'same', + description: '项目版本', + template: '项目模板', + source: 'project', + }; + + registry.register(builtin); + registry.register(project); + + const result = registry.get('same'); + expect(result?.source).toBe('project'); + expect(result?.description).toBe('项目版本'); + }); + + it('低优先级不能覆盖高优先级', () => { + const project: Command = { + name: 'same', + description: '项目版本', + template: '项目模板', + source: 'project', + }; + + const builtin: Command = { + name: 'same', + description: '内置版本', + template: '内置模板', + source: 'builtin', + }; + + registry.register(project); + registry.register(builtin); + + const result = registry.get('same'); + expect(result?.source).toBe('project'); + }); + }); + + describe('get - 获取', () => { + it('获取存在的 Command', () => { + const command: Command = { + name: 'test', + description: '测试', + template: '模板', + source: 'user', + }; + + registry.register(command); + const result = registry.get('test'); + + expect(result).toBeDefined(); + expect(result?.name).toBe('test'); + }); + + it('获取不存在的 Command 返回 undefined', () => { + const result = registry.get('non-existent'); + expect(result).toBeUndefined(); + }); + }); + + describe('search - 搜索', () => { + beforeEach(() => { + const commands: Command[] = [ + { + name: 'review', + description: '代码审查', + template: '审查模板', + source: 'builtin', + }, + { + name: 'test', + description: '运行测试', + template: '测试模板', + source: 'builtin', + }, + { + name: 'deploy/staging', + description: '部署到 staging', + template: '部署模板', + source: 'project', + }, + ]; + + for (const cmd of commands) { + registry.register(cmd); + } + }); + + it('按名称精确匹配', () => { + const results = registry.search('review'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].command.name).toBe('review'); + expect(results[0].score).toBe(100); + }); + + it('按名称前缀匹配', () => { + const results = registry.search('deploy'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].command.name).toBe('deploy/staging'); + }); + + it('按描述匹配', () => { + const results = registry.search('代码'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].command.name).toBe('review'); + }); + + it('限制结果数量', () => { + const results = registry.search('', 1); + expect(results.length).toBeLessThanOrEqual(1); + }); + }); + + describe('list - 列表', () => { + it('返回所有 Commands 的摘要', () => { + const command: Command = { + name: 'test', + description: '测试', + template: '模板', + source: 'user', + }; + + registry.register(command); + const list = registry.list(); + + expect(list.length).toBe(1); + expect(list[0].name).toBe('test'); + expect(list[0].description).toBe('测试'); + expect(list[0].source).toBe('user'); + }); + + it('按名称排序', () => { + const commands: Command[] = [ + { name: 'zebra', description: '', template: '', source: 'user' }, + { name: 'alpha', description: '', template: '', source: 'user' }, + { name: 'beta', description: '', template: '', source: 'user' }, + ]; + + for (const cmd of commands) { + registry.register(cmd); + } + + const list = registry.list(); + expect(list[0].name).toBe('alpha'); + expect(list[1].name).toBe('beta'); + expect(list[2].name).toBe('zebra'); + }); + }); + + describe('getStats - 统计', () => { + it('返回正确的统计信息', () => { + const commands: Command[] = [ + { name: 'b1', description: '', template: '', source: 'builtin' }, + { name: 'b2', description: '', template: '', source: 'builtin' }, + { name: 'u1', description: '', template: '', source: 'user' }, + { name: 'p1', description: '', template: '', source: 'project' }, + ]; + + for (const cmd of commands) { + registry.register(cmd); + } + + const stats = registry.getStats(); + + expect(stats.total).toBe(4); + expect(stats.bySource.builtin).toBe(2); + expect(stats.bySource.user).toBe(1); + expect(stats.bySource.project).toBe(1); + }); + }); +}); + +describe('getCommandRegistry - 全局注册表', () => { + beforeEach(() => { + resetCommandRegistry(); + }); + + it('返回单例实例', () => { + const registry1 = getCommandRegistry(); + const registry2 = getCommandRegistry(); + + expect(registry1).toBe(registry2); + }); + + it('resetCommandRegistry 重置实例', () => { + const registry1 = getCommandRegistry(); + resetCommandRegistry(); + const registry2 = getCommandRegistry(); + + expect(registry1).not.toBe(registry2); + }); +});