feat: 添加 Commands 系统支持斜杠命令
实现类似 OpenCode 的 Commands 功能: - 支持 Markdown 格式定义命令,带 YAML frontmatter - 变量替换:$ARGUMENTS, $1/$2, @filepath, !`shell` - 三级加载:builtin < user < project - 7 个内置命令:init, review, test, fix, explain, commit, help - 集成终端 UI 支持 /commands 列表和命令执行 - 完整单元测试覆盖 (46 tests)
This commit is contained in:
@@ -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,
|
||||
];
|
||||
@@ -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<CommandExecutionResult> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<Command[]> {
|
||||
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<void> {
|
||||
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<Command | null> {
|
||||
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();
|
||||
@@ -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<string, Command>();
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化注册表
|
||||
*/
|
||||
async initialize(workdir: string = process.cwd()): Promise<void> {
|
||||
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<void> {
|
||||
this.commands.clear();
|
||||
this.initialized = false;
|
||||
await this.initialize(workdir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
*/
|
||||
getStats(): {
|
||||
total: number;
|
||||
bySource: Record<string, number>;
|
||||
} {
|
||||
const commands = this.getAll();
|
||||
const bySource: Record<string, number> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+12
-1
@@ -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) {
|
||||
|
||||
@@ -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(' 用法: /<command> [arguments]'));
|
||||
console.log(chalk.gray(' 示例: /review main..feature'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 尝试执行自定义 Command
|
||||
private async tryExecuteCommand(input: string): Promise<CommandExecutionResult | null> {
|
||||
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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user