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
+197
View File
@@ -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,
];
+284
View File
@@ -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);
}
+30
View File
@@ -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';
+172
View File
@@ -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();
+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;
}
+80
View File
@@ -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 FrontmatterMarkdown 头部配置)
*/
export interface CommandFrontmatter {
description?: string;
agent?: string;
model?: string;
subtask?: boolean;
}
+12 -1
View File
@@ -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) {
+147
View File
@@ -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
+296
View File
@@ -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());
});
});
});
+206
View File
@@ -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');
});
});
});
+250
View File
@@ -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);
});
});