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:
2025-12-11 16:12:28 +08:00
parent 723558ff22
commit a476a4240c
11 changed files with 1873 additions and 1 deletions
+199
View File
@@ -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;
}