diff --git a/package-lock.json b/package-lock.json index 13f9b64..60c7568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "vscode-jsonrpc": "^8.2.1", "vscode-languageserver-protocol": "^3.17.5", "web-tree-sitter": "^0.25.10", + "yaml": "^2.8.2", "zod": "^4.1.13" }, "bin": { @@ -3661,6 +3662,21 @@ "node": ">=8" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", diff --git a/package.json b/package.json index 9944a9a..c5fdf65 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "vscode-jsonrpc": "^8.2.1", "vscode-languageserver-protocol": "^3.17.5", "web-tree-sitter": "^0.25.10", + "yaml": "^2.8.2", "zod": "^4.1.13" }, "devDependencies": { diff --git a/src/skills/builtin/index.ts b/src/skills/builtin/index.ts new file mode 100644 index 0000000..b5c0a9c --- /dev/null +++ b/src/skills/builtin/index.ts @@ -0,0 +1,357 @@ +/** + * 内置 Skills + * + * 提供一些常用的预定义 Skills + */ + +import type { Skill } from '../types.js'; + +/** + * 代码审查 Skill + */ +export const codeReviewSkill: Skill = { + name: 'code-review', + displayName: '代码审查', + description: '对代码进行全面审查,检查潜在问题、最佳实践和改进建议', + category: 'development', + promptTemplate: `请对以下代码进行全面审查: + +{{code}} + +审查重点: +{{#if focus}} +- {{focus}} +{{else}} +- 代码质量和可读性 +- 潜在的 bug 和错误 +- 性能问题 +- 安全隐患 +- 最佳实践遵循情况 +{{/if}} + +请提供: +1. 发现的问题列表(按严重程度排序) +2. 具体的改进建议 +3. 代码中做得好的地方`, + parameters: { + code: { + type: 'string', + description: '要审查的代码', + required: true, + }, + focus: { + type: 'string', + description: '审查重点(可选)', + required: false, + }, + }, + keywords: ['review', 'code', 'quality', '审查', '代码', '质量'], + source: 'builtin', + enabled: true, +}; + +/** + * 代码解释 Skill + */ +export const explainCodeSkill: Skill = { + name: 'explain-code', + displayName: '代码解释', + description: '详细解释代码的功能、逻辑和工作原理', + category: 'development', + promptTemplate: `请详细解释以下代码: + +{{code}} + +{{#if level}} +解释级别:{{level}} +{{/if}} + +请包含: +1. 代码的整体功能 +2. 主要逻辑流程 +3. 关键部分的详细解释 +4. 使用的设计模式或技术(如果有)`, + parameters: { + code: { + type: 'string', + description: '要解释的代码', + required: true, + }, + level: { + type: 'string', + description: '解释级别(beginner/intermediate/advanced)', + required: false, + enum: ['beginner', 'intermediate', 'advanced'], + default: 'intermediate', + }, + }, + keywords: ['explain', 'code', 'understand', '解释', '理解', '说明'], + source: 'builtin', + enabled: true, +}; + +/** + * 文档生成 Skill + */ +export const generateDocsSkill: Skill = { + name: 'generate-docs', + displayName: '文档生成', + description: '为代码生成文档注释或 README', + category: 'documentation', + promptTemplate: `请为以下代码生成{{type}}: + +{{code}} + +{{#if style}} +文档风格:{{style}} +{{/if}} + +要求: +- 清晰描述功能和用途 +- 说明参数和返回值(如果适用) +- 提供使用示例 +- 使用规范的格式`, + parameters: { + code: { + type: 'string', + description: '要生成文档的代码', + required: true, + }, + type: { + type: 'string', + description: '文档类型', + required: false, + enum: ['JSDoc', 'TSDoc', 'README', '注释', 'API文档'], + default: '文档注释', + }, + style: { + type: 'string', + description: '文档风格(简洁/详细)', + required: false, + }, + }, + keywords: ['docs', 'documentation', 'jsdoc', '文档', '注释', '说明'], + source: 'builtin', + enabled: true, +}; + +/** + * 单元测试生成 Skill + */ +export const generateTestsSkill: Skill = { + name: 'generate-tests', + displayName: '测试生成', + description: '为代码生成单元测试', + category: 'testing', + promptTemplate: `请为以下代码生成单元测试: + +{{code}} + +测试框架:{{framework}} + +要求: +- 覆盖主要功能路径 +- 包含边界条件测试 +- 包含错误处理测试 +- 使用清晰的测试描述 +- 遵循 AAA 模式(Arrange-Act-Assert)`, + parameters: { + code: { + type: 'string', + description: '要测试的代码', + required: true, + }, + framework: { + type: 'string', + description: '测试框架', + required: false, + enum: ['vitest', 'jest', 'mocha', 'pytest', 'unittest'], + default: 'vitest', + }, + }, + keywords: ['test', 'unit', 'testing', '测试', '单元测试', 'vitest', 'jest'], + source: 'builtin', + enabled: true, +}; + +/** + * 重构建议 Skill + */ +export const refactorSuggestSkill: Skill = { + name: 'refactor-suggest', + displayName: '重构建议', + description: '分析代码并提供重构建议', + category: 'development', + promptTemplate: `请分析以下代码并提供重构建议: + +{{code}} + +{{#if goal}} +重构目标:{{goal}} +{{/if}} + +请提供: +1. 当前代码的问题分析 +2. 具体的重构建议 +3. 重构后的代码示例 +4. 重构的好处说明`, + parameters: { + code: { + type: 'string', + description: '要重构的代码', + required: true, + }, + goal: { + type: 'string', + description: '重构目标(如:提高可读性、优化性能、减少重复)', + required: false, + }, + }, + keywords: ['refactor', 'improve', 'optimize', '重构', '优化', '改进'], + source: 'builtin', + enabled: true, +}; + +/** + * Bug 修复 Skill + */ +export const fixBugSkill: Skill = { + name: 'fix-bug', + displayName: 'Bug 修复', + description: '分析代码问题并提供修复方案', + category: 'debugging', + promptTemplate: `请分析以下代码中的问题并提供修复方案: + +代码: +{{code}} + +{{#if error}} +错误信息: +{{error}} +{{/if}} + +{{#if context}} +上下文: +{{context}} +{{/if}} + +请提供: +1. 问题的根本原因分析 +2. 修复方案 +3. 修复后的代码 +4. 如何避免类似问题的建议`, + parameters: { + code: { + type: 'string', + description: '有问题的代码', + required: true, + }, + error: { + type: 'string', + description: '错误信息', + required: false, + }, + context: { + type: 'string', + description: '额外的上下文信息', + required: false, + }, + }, + keywords: ['bug', 'fix', 'debug', 'error', '修复', '错误', '调试'], + source: 'builtin', + enabled: true, +}; + +/** + * Git Commit 消息生成 Skill + */ +export const gitCommitSkill: Skill = { + name: 'git-commit', + displayName: 'Git Commit', + description: '根据代码变更生成规范的 Git commit 消息', + category: 'git', + promptTemplate: `请根据以下代码变更生成规范的 Git commit 消息: + +变更内容: +{{diff}} + +{{#if type}} +Commit 类型:{{type}} +{{/if}} + +要求: +- 遵循 Conventional Commits 规范 +- 第一行不超过 50 个字符 +- 清晰描述变更的目的 +- 如果有 breaking changes,请说明`, + parameters: { + diff: { + type: 'string', + description: 'Git diff 内容或变更描述', + required: true, + }, + type: { + type: 'string', + description: 'Commit 类型', + required: false, + enum: ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore'], + }, + }, + keywords: ['git', 'commit', 'message', '提交', '消息'], + source: 'builtin', + enabled: true, +}; + +/** + * API 设计 Skill + */ +export const apiDesignSkill: Skill = { + name: 'api-design', + displayName: 'API 设计', + description: '设计 RESTful API 接口', + category: 'architecture', + promptTemplate: `请为以下需求设计 RESTful API: + +需求描述: +{{requirement}} + +{{#if constraints}} +约束条件: +{{constraints}} +{{/if}} + +请提供: +1. API 端点设计(路径、方法、参数) +2. 请求/响应格式(JSON 示例) +3. 错误处理方案 +4. 认证/授权建议(如果需要)`, + parameters: { + requirement: { + type: 'string', + description: 'API 需求描述', + required: true, + }, + constraints: { + type: 'string', + description: '设计约束(如:现有系统兼容性、性能要求等)', + required: false, + }, + }, + keywords: ['api', 'rest', 'design', 'endpoint', 'API', '接口', '设计'], + source: 'builtin', + enabled: true, +}; + +/** + * 所有内置 Skills + */ +export const builtinSkills: Skill[] = [ + codeReviewSkill, + explainCodeSkill, + generateDocsSkill, + generateTestsSkill, + refactorSuggestSkill, + fixBugSkill, + gitCommitSkill, + apiDesignSkill, +]; diff --git a/src/skills/index.ts b/src/skills/index.ts new file mode 100644 index 0000000..a62fc72 --- /dev/null +++ b/src/skills/index.ts @@ -0,0 +1,29 @@ +/** + * Skills 模块 + * + * 提供 Skill 系统的所有功能导出 + */ + +// 类型 +export type { + Skill, + SkillParameter, + SkillContext, + SkillExecutionResult, + SkillFile, + SkillSearchResult, + SkillRegistryConfig, +} from './types.js'; + +// 加载器 +export { SkillLoader, skillLoader } from './loader.js'; + +// 注册表 +export { + SkillRegistry, + getSkillRegistry, + resetSkillRegistry, +} from './registry.js'; + +// 内置 Skills +export { builtinSkills } from './builtin/index.js'; diff --git a/src/skills/loader.ts b/src/skills/loader.ts new file mode 100644 index 0000000..72b91af --- /dev/null +++ b/src/skills/loader.ts @@ -0,0 +1,201 @@ +/** + * Skill 加载器 + * + * 负责从文件系统加载 Skill 定义。 + * 支持从以下位置加载: + * 1. 内置 Skills(代码中定义) + * 2. 用户 Skills(~/.config/ai-terminal/skills/) + * 3. 项目 Skills(./.ai-terminal/skills/) + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as yaml from 'yaml'; +import type { Skill, SkillFile } from './types.js'; + +/** + * Skill 加载器 + */ +export class SkillLoader { + /** + * 从目录加载所有 Skills + */ + async loadFromDirectory( + dir: string, + source: 'user' | 'project' + ): Promise { + const skills: Skill[] = []; + + try { + const exists = await fs + .access(dir) + .then(() => true) + .catch(() => false); + + if (!exists) { + return skills; + } + + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (['.yaml', '.yml', '.json', '.md'].includes(ext)) { + const filePath = path.join(dir, entry.name); + try { + const skill = await this.loadFromFile(filePath, source); + if (skill) { + skills.push(skill); + } + } catch (error) { + console.warn(`加载 Skill 文件失败: ${filePath}`, error); + } + } + } else if (entry.isDirectory()) { + // 递归加载子目录 + const subDir = path.join(dir, entry.name); + const subSkills = await this.loadFromDirectory(subDir, source); + skills.push(...subSkills); + } + } + } catch (error) { + console.warn(`读取 Skills 目录失败: ${dir}`, error); + } + + return skills; + } + + /** + * 从单个文件加载 Skill + */ + async loadFromFile( + filePath: string, + source: 'user' | 'project' + ): Promise { + const ext = path.extname(filePath).toLowerCase(); + const content = await fs.readFile(filePath, 'utf-8'); + + let skillData: SkillFile | null = null; + + if (ext === '.md') { + // Markdown 格式:从 frontmatter 和内容中解析 + skillData = this.parseMarkdownSkill(content, filePath); + } else if (ext === '.yaml' || ext === '.yml') { + // YAML 格式 + skillData = yaml.parse(content) as SkillFile; + } else if (ext === '.json') { + // JSON 格式 + skillData = JSON.parse(content) as SkillFile; + } + + if (!skillData?.skill) { + return null; + } + + // 验证必需字段 + const { skill } = skillData; + if (!skill.name || !skill.promptTemplate) { + console.warn(`Skill 文件缺少必需字段: ${filePath}`); + return null; + } + + return { + ...skill, + source, + sourcePath: filePath, + enabled: skill.enabled ?? true, + }; + } + + /** + * 解析 Markdown 格式的 Skill + * + * 格式示例: + * ```markdown + * --- + * name: code-review + * description: 代码审查 + * parameters: + * focus: + * type: string + * description: 审查重点 + * --- + * + * # 代码审查 + * + * 请审查以下代码,重点关注 {{focus}}: + * + * {{code}} + * ``` + */ + private parseMarkdownSkill( + content: string, + filePath: string + ): SkillFile | null { + // 解析 frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + + if (!frontmatterMatch) { + // 没有 frontmatter,使用文件名作为 name,整个内容作为 promptTemplate + const name = path.basename(filePath, path.extname(filePath)); + return { + skill: { + name, + description: `Skill: ${name}`, + promptTemplate: content.trim(), + }, + }; + } + + const [, frontmatterStr, bodyContent] = frontmatterMatch; + + try { + const frontmatter = yaml.parse(frontmatterStr) as Partial; + + // 如果 frontmatter 中没有 promptTemplate,使用 body 内容 + const promptTemplate = frontmatter.promptTemplate || bodyContent.trim(); + + // 从文件名获取默认 name + const defaultName = path.basename(filePath, path.extname(filePath)); + + return { + skill: { + name: frontmatter.name || defaultName, + displayName: frontmatter.displayName, + description: frontmatter.description || `Skill: ${defaultName}`, + category: frontmatter.category, + promptTemplate, + parameters: frontmatter.parameters, + keywords: frontmatter.keywords, + version: frontmatter.version, + author: frontmatter.author, + enabled: frontmatter.enabled, + }, + }; + } catch (error) { + console.warn(`解析 Skill frontmatter 失败: ${filePath}`, error); + return null; + } + } + + /** + * 获取用户 Skills 目录 + */ + getUserSkillsDir(): string { + const home = process.env.HOME || process.env.USERPROFILE || ''; + return path.join(home, '.config', 'ai-terminal', 'skills'); + } + + /** + * 获取项目 Skills 目录 + */ + getProjectSkillsDir(workdir: string = process.cwd()): string { + return path.join(workdir, '.ai-terminal', 'skills'); + } +} + +/** + * 全局 Skill 加载器实例 + */ +export const skillLoader = new SkillLoader(); diff --git a/src/skills/registry.ts b/src/skills/registry.ts new file mode 100644 index 0000000..b2f6f62 --- /dev/null +++ b/src/skills/registry.ts @@ -0,0 +1,358 @@ +/** + * Skill 注册表 + * + * 管理所有可用的 Skills,支持: + * - 注册/注销 Skills + * - 按名称/分类/关键词查询 + * - 渲染 Skill 提示模板 + */ + +import type { + Skill, + SkillContext, + SkillExecutionResult, + SkillSearchResult, + SkillRegistryConfig, +} from './types.js'; +import { skillLoader } from './loader.js'; +import { builtinSkills } from './builtin/index.js'; + +/** + * Skill 注册表 + */ +export class SkillRegistry { + private skills = new Map(); + private config: SkillRegistryConfig; + private initialized = false; + + constructor(config: SkillRegistryConfig = {}) { + this.config = { + autoLoad: true, + ...config, + }; + } + + /** + * 初始化注册表 + */ + async initialize(workdir: string = process.cwd()): Promise { + if (this.initialized) { + return; + } + + // 1. 注册内置 Skills + for (const skill of builtinSkills) { + this.register(skill); + } + + // 2. 加载用户 Skills + if (this.config.autoLoad) { + const userDir = + this.config.userSkillsDir || skillLoader.getUserSkillsDir(); + const userSkills = await skillLoader.loadFromDirectory(userDir, 'user'); + for (const skill of userSkills) { + this.register(skill); + } + } + + // 3. 加载项目 Skills + if (this.config.autoLoad) { + const projectDir = + this.config.projectSkillsDir || skillLoader.getProjectSkillsDir(workdir); + const projectSkills = await skillLoader.loadFromDirectory( + projectDir, + 'project' + ); + for (const skill of projectSkills) { + this.register(skill); + } + } + + this.initialized = true; + } + + /** + * 注册 Skill + */ + register(skill: Skill): void { + // 项目 Skills 优先级最高,可以覆盖同名的内置/用户 Skills + const existing = this.skills.get(skill.name); + if (existing) { + // 优先级: project > user > builtin + const priority = { project: 3, user: 2, builtin: 1 }; + if (priority[skill.source] < priority[existing.source]) { + return; // 不覆盖更高优先级的 Skill + } + } + this.skills.set(skill.name, skill); + } + + /** + * 注销 Skill + */ + unregister(name: string): boolean { + return this.skills.delete(name); + } + + /** + * 获取 Skill + */ + get(name: string): Skill | undefined { + return this.skills.get(name); + } + + /** + * 检查 Skill 是否存在 + */ + has(name: string): boolean { + return this.skills.has(name); + } + + /** + * 获取所有 Skills + */ + getAll(): Skill[] { + return Array.from(this.skills.values()); + } + + /** + * 获取启用的 Skills + */ + getEnabled(): Skill[] { + return this.getAll().filter((s) => s.enabled !== false); + } + + /** + * 按分类获取 Skills + */ + getByCategory(category: string): Skill[] { + return this.getEnabled().filter((s) => s.category === category); + } + + /** + * 获取所有分类 + */ + getCategories(): string[] { + const categories = new Set(); + for (const skill of this.getEnabled()) { + if (skill.category) { + categories.add(skill.category); + } + } + return Array.from(categories).sort(); + } + + /** + * 搜索 Skills + */ + search(query: string, limit: number = 10): SkillSearchResult[] { + const queryLower = query.toLowerCase(); + const results: SkillSearchResult[] = []; + + for (const skill of this.getEnabled()) { + let score = 0; + let matchReason = ''; + + // 精确名称匹配 + if (skill.name.toLowerCase() === queryLower) { + score = 100; + matchReason = '名称精确匹配'; + } + // 名称前缀匹配 + else if (skill.name.toLowerCase().startsWith(queryLower)) { + score = 80; + matchReason = '名称前缀匹配'; + } + // 名称包含匹配 + else if (skill.name.toLowerCase().includes(queryLower)) { + score = 60; + matchReason = '名称包含匹配'; + } + // 描述匹配 + else if (skill.description.toLowerCase().includes(queryLower)) { + score = 40; + matchReason = '描述匹配'; + } + // 关键词匹配 + else if ( + skill.keywords?.some((k) => k.toLowerCase().includes(queryLower)) + ) { + score = 30; + matchReason = '关键词匹配'; + } + // 分类匹配 + else if (skill.category?.toLowerCase().includes(queryLower)) { + score = 20; + matchReason = '分类匹配'; + } + + if (score > 0) { + results.push({ skill, score, matchReason }); + } + } + + // 按分数降序排序 + results.sort((a, b) => b.score - a.score); + + return results.slice(0, limit); + } + + /** + * 渲染 Skill 提示模板 + */ + renderPrompt( + skill: Skill, + params: Record, + context?: SkillContext + ): SkillExecutionResult { + try { + // 验证必需参数 + if (skill.parameters) { + for (const [name, param] of Object.entries(skill.parameters)) { + if (param.required && !(name in params)) { + return { + success: false, + error: `缺少必需参数: ${name}`, + }; + } + } + } + + // 构建变量映射 + const variables: Record = {}; + + // 添加参数值 + for (const [key, value] of Object.entries(params)) { + variables[key] = String(value); + } + + // 添加上下文变量 + if (context?.variables) { + for (const [key, value] of Object.entries(context.variables)) { + if (!(key in variables)) { + variables[key] = value; + } + } + } + + // 添加默认值 + if (skill.parameters) { + for (const [name, param] of Object.entries(skill.parameters)) { + if (!(name in variables) && param.default !== undefined) { + variables[name] = String(param.default); + } + } + } + + // 渲染模板 + let prompt = skill.promptTemplate; + + // 替换 {{variable}} 格式的变量 + prompt = prompt.replace(/\{\{(\w+)\}\}/g, (match, varName) => { + if (varName in variables) { + return variables[varName]; + } + // 保留未匹配的变量(可能是用户意图保留) + return match; + }); + + return { + success: true, + prompt, + }; + } catch (error) { + return { + success: false, + error: `渲染 Skill 提示失败: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * 执行 Skill(渲染模板并返回提示) + */ + execute( + name: string, + params: Record, + context?: SkillContext + ): SkillExecutionResult { + const skill = this.get(name); + + if (!skill) { + return { + success: false, + error: `Skill 不存在: ${name}`, + }; + } + + if (skill.enabled === false) { + return { + success: false, + error: `Skill 已禁用: ${name}`, + }; + } + + return this.renderPrompt(skill, params, context); + } + + /** + * 重新加载 Skills + */ + async reload(workdir: string = process.cwd()): Promise { + this.skills.clear(); + this.initialized = false; + await this.initialize(workdir); + } + + /** + * 获取 Skill 统计信息 + */ + getStats(): { + total: number; + enabled: number; + bySource: Record; + byCategory: Record; + } { + const skills = this.getAll(); + const enabled = this.getEnabled(); + + const bySource: Record = {}; + const byCategory: Record = {}; + + for (const skill of skills) { + bySource[skill.source] = (bySource[skill.source] || 0) + 1; + if (skill.category) { + byCategory[skill.category] = (byCategory[skill.category] || 0) + 1; + } + } + + return { + total: skills.length, + enabled: enabled.length, + bySource, + byCategory, + }; + } +} + +/** + * 全局 Skill 注册表实例 + */ +let skillRegistryInstance: SkillRegistry | null = null; + +/** + * 获取全局 Skill 注册表 + */ +export function getSkillRegistry(): SkillRegistry { + if (!skillRegistryInstance) { + skillRegistryInstance = new SkillRegistry(); + } + return skillRegistryInstance; +} + +/** + * 重置全局 Skill 注册表(用于测试) + */ +export function resetSkillRegistry(): void { + skillRegistryInstance = null; +} diff --git a/src/skills/types.ts b/src/skills/types.ts new file mode 100644 index 0000000..b244c71 --- /dev/null +++ b/src/skills/types.ts @@ -0,0 +1,109 @@ +/** + * Skill 系统类型定义 + * + * Skill 是可复用的提示模板,类似于 Claude Code 的 Skills 功能。 + * 与 Agent 不同,Skill 不是独立的执行单元,而是预定义的提示模板, + * 可以被 Agent 调用来执行特定任务。 + */ + +/** + * Skill 参数定义 + */ +export interface SkillParameter { + /** 参数类型 */ + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + /** 参数描述 */ + description: string; + /** 是否必需 */ + required?: boolean; + /** 默认值 */ + default?: unknown; + /** 枚举值(仅 string 类型) */ + enum?: string[]; +} + +/** + * Skill 定义 + */ +export interface Skill { + /** Skill 唯一标识 */ + name: string; + /** Skill 显示名称 */ + displayName?: string; + /** Skill 描述 */ + description: string; + /** Skill 分类 */ + category?: string; + /** 提示模板(支持变量插值 {{variable}}) */ + promptTemplate: string; + /** Skill 参数定义 */ + parameters?: Record; + /** 关键词(用于搜索) */ + keywords?: string[]; + /** 来源(内置/用户定义/项目) */ + source: 'builtin' | 'user' | 'project'; + /** 来源路径(用户定义或项目 Skill 的文件路径) */ + sourcePath?: string; + /** 是否启用 */ + enabled?: boolean; + /** 版本 */ + version?: string; + /** 作者 */ + author?: string; +} + +/** + * Skill 执行上下文 + */ +export interface SkillContext { + /** 当前工作目录 */ + workdir: string; + /** 额外的上下文变量 */ + variables?: Record; +} + +/** + * Skill 执行结果 + */ +export interface SkillExecutionResult { + /** 是否成功 */ + success: boolean; + /** 渲染后的提示 */ + prompt?: string; + /** 错误信息 */ + error?: string; +} + +/** + * Skill 文件格式(YAML/JSON) + */ +export interface SkillFile { + /** 文件版本 */ + version?: string; + /** Skill 定义 */ + skill: Omit; +} + +/** + * Skill 搜索结果 + */ +export interface SkillSearchResult { + /** Skill */ + skill: Skill; + /** 匹配分数 */ + score: number; + /** 匹配原因 */ + matchReason: string; +} + +/** + * Skill 注册表配置 + */ +export interface SkillRegistryConfig { + /** 用户 Skills 目录 */ + userSkillsDir?: string; + /** 项目 Skills 目录 */ + projectSkillsDir?: string; + /** 是否自动加载 */ + autoLoad?: boolean; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 91e10e2..d269b52 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -11,6 +11,9 @@ import { todoReadTool, todoWriteTool } from './todo/index.js'; // Task 工具(Agent 子任务) import { taskTool, agentOutputTool } from './task/index.js'; +// Skill 工具 +import { skillTool, skillSearchTool } from './skill/index.js'; + // 文件系统工具 import { readFileTool, @@ -53,6 +56,10 @@ const allToolsWithMetadata: ToolWithMetadata[] = [ taskTool, agentOutputTool, + // Skill 工具 (deferLoading: false) + skillTool, + skillSearchTool, + // 文件系统工具 (deferLoading: true) readFileTool, writeFileTool, @@ -91,6 +98,7 @@ export { toolRegistry } from './registry.js'; export { toolSearchTool } from './tool-search.js'; export { todoManager } from './todo/index.js'; export { initTaskContext, updateTaskDescription } from './task/index.js'; +export { updateSkillDescription } from './skill/index.js'; export type { ToolWithMetadata, ToolMetadata, ToolCategory, ToolSearchResult } from './types.js'; // 兼容旧代码:导出所有工具数组(基础 Tool 类型) diff --git a/src/tools/skill/index.ts b/src/tools/skill/index.ts new file mode 100644 index 0000000..3449ac3 --- /dev/null +++ b/src/tools/skill/index.ts @@ -0,0 +1,2 @@ +export { skillTool, updateSkillDescription } from './skill.js'; +export { skillSearchTool } from './skill_search.js'; diff --git a/src/tools/skill/skill.ts b/src/tools/skill/skill.ts new file mode 100644 index 0000000..4114a12 --- /dev/null +++ b/src/tools/skill/skill.ts @@ -0,0 +1,147 @@ +/** + * Skill 工具 + * + * 允许 Agent 调用预定义的 Skill 来执行特定任务。 + * Skill 是可复用的提示模板,不同于 Agent(独立执行单元)。 + */ + +import type { ToolWithMetadata } from '../types.js'; +import { getSkillRegistry } from '../../skills/registry.js'; + +/** + * 获取 Skill 工具动态描述 + */ +function getSkillDescription(): string { + const registry = getSkillRegistry(); + const skills = registry.getEnabled(); + + if (skills.length === 0) { + return '执行 Skill(当前没有可用的 Skill)'; + } + + // 按分类分组 + const byCategory = new Map(); + const uncategorized: typeof skills = []; + + for (const skill of skills) { + if (skill.category) { + if (!byCategory.has(skill.category)) { + byCategory.set(skill.category, []); + } + byCategory.get(skill.category)!.push(skill); + } else { + uncategorized.push(skill); + } + } + + // 构建描述 + let description = '执行预定义的 Skill 来完成特定任务。\n\n可用的 Skills:\n'; + + for (const [category, categorySkills] of byCategory) { + description += `\n[${category}]\n`; + for (const skill of categorySkills) { + description += `- ${skill.name}: ${skill.description}\n`; + } + } + + if (uncategorized.length > 0) { + description += '\n[其他]\n'; + for (const skill of uncategorized) { + description += `- ${skill.name}: ${skill.description}\n`; + } + } + + description += ` +使用示例: +- skill({ skill_name: "code-review", params: { code: "..." } }) +- skill({ skill_name: "generate-tests", params: { code: "...", framework: "vitest" } })`; + + return description; +} + +/** + * Skill 工具 + */ +export const skillTool: ToolWithMetadata = { + name: 'skill', + description: getSkillDescription(), + parameters: { + skill_name: { + type: 'string', + description: 'Skill 名称', + required: true, + }, + params: { + type: 'object', + description: 'Skill 参数(根据具体 Skill 定义)', + required: false, + }, + }, + metadata: { + name: 'skill', + category: 'agent', + description: '执行预定义的 Skill', + keywords: ['skill', 'template', 'prompt', '技能', '模板', '提示'], + deferLoading: false, + }, + async execute(params) { + const { skill_name, params: skillParams = {} } = params as { + skill_name: string; + params?: Record; + }; + + const registry = getSkillRegistry(); + + // 检查 Skill 是否存在 + const skill = registry.get(skill_name); + if (!skill) { + // 尝试搜索相似的 Skill + const suggestions = registry.search(skill_name, 3); + let errorMsg = `Skill 不存在: ${skill_name}`; + + if (suggestions.length > 0) { + errorMsg += '\n\n你可能想要的 Skill:\n'; + for (const { skill: s, matchReason } of suggestions) { + errorMsg += `- ${s.name}: ${s.description} (${matchReason})\n`; + } + } + + return { + success: false, + output: '', + error: errorMsg, + }; + } + + // 执行 Skill(渲染模板) + const result = registry.execute(skill_name, skillParams, { + workdir: process.cwd(), + }); + + if (!result.success) { + return { + success: false, + output: '', + error: result.error || 'Skill 执行失败', + }; + } + + // 返回渲染后的提示 + return { + success: true, + output: result.prompt || '', + metadata: { + skill: skill_name, + category: skill.category, + source: skill.source, + }, + }; + }, +}; + +/** + * 更新 Skill 工具描述 + */ +export function updateSkillDescription(): void { + (skillTool as { description: string }).description = getSkillDescription(); +} diff --git a/src/tools/skill/skill_search.ts b/src/tools/skill/skill_search.ts new file mode 100644 index 0000000..2aa15eb --- /dev/null +++ b/src/tools/skill/skill_search.ts @@ -0,0 +1,192 @@ +/** + * Skill 搜索工具 + * + * 搜索可用的 Skills,帮助用户发现适合的 Skill。 + */ + +import type { ToolWithMetadata } from '../types.js'; +import { getSkillRegistry } from '../../skills/registry.js'; + +/** + * Skill 搜索工具 + */ +export const skillSearchTool: ToolWithMetadata = { + name: 'skill_search', + description: '搜索可用的 Skills,根据关键词或分类查找合适的 Skill', + parameters: { + query: { + type: 'string', + description: '搜索关键词', + required: false, + }, + category: { + type: 'string', + description: '按分类筛选', + required: false, + }, + list_all: { + type: 'boolean', + description: '列出所有可用的 Skills', + required: false, + }, + }, + metadata: { + name: 'skill_search', + category: 'agent', + description: '搜索可用的 Skills', + keywords: ['skill', 'search', 'find', '技能', '搜索', '查找'], + deferLoading: false, + }, + async execute(params) { + const { query, category, list_all } = params as { + query?: string; + category?: string; + list_all?: boolean; + }; + + const registry = getSkillRegistry(); + + // 列出所有 Skills + if (list_all) { + const skills = registry.getEnabled(); + const stats = registry.getStats(); + + if (skills.length === 0) { + return { + success: true, + output: '当前没有可用的 Skills。', + }; + } + + // 按分类分组 + const byCategory = new Map(); + const uncategorized: typeof skills = []; + + for (const skill of skills) { + if (skill.category) { + if (!byCategory.has(skill.category)) { + byCategory.set(skill.category, []); + } + byCategory.get(skill.category)!.push(skill); + } else { + uncategorized.push(skill); + } + } + + let output = `📚 可用的 Skills (共 ${stats.total} 个)\n\n`; + + for (const [cat, catSkills] of byCategory) { + output += `## ${cat}\n`; + for (const skill of catSkills) { + output += `- **${skill.name}**: ${skill.description}`; + if (skill.source !== 'builtin') { + output += ` [${skill.source}]`; + } + output += '\n'; + } + output += '\n'; + } + + if (uncategorized.length > 0) { + output += '## 其他\n'; + for (const skill of uncategorized) { + output += `- **${skill.name}**: ${skill.description}`; + if (skill.source !== 'builtin') { + output += ` [${skill.source}]`; + } + output += '\n'; + } + } + + output += `\n来源统计: 内置 ${stats.bySource.builtin || 0}, 用户 ${stats.bySource.user || 0}, 项目 ${stats.bySource.project || 0}`; + + return { + success: true, + output, + metadata: { stats }, + }; + } + + // 按分类筛选 + if (category) { + const skills = registry.getByCategory(category); + + if (skills.length === 0) { + const categories = registry.getCategories(); + return { + success: true, + output: `分类 "${category}" 下没有 Skills。\n\n可用分类: ${categories.join(', ')}`, + }; + } + + let output = `📁 分类 "${category}" 下的 Skills:\n\n`; + for (const skill of skills) { + output += `- **${skill.name}**: ${skill.description}\n`; + if (skill.parameters) { + const paramNames = Object.keys(skill.parameters); + output += ` 参数: ${paramNames.join(', ')}\n`; + } + } + + return { + success: true, + output, + metadata: { category, count: skills.length }, + }; + } + + // 关键词搜索 + if (query) { + const results = registry.search(query, 10); + + if (results.length === 0) { + return { + success: true, + output: `没有找到匹配 "${query}" 的 Skills。\n\n使用 skill_search({ list_all: true }) 查看所有可用的 Skills。`, + }; + } + + let output = `🔍 搜索 "${query}" 的结果:\n\n`; + for (const { skill, score, matchReason } of results) { + output += `- **${skill.name}** (${score}分): ${skill.description}\n`; + output += ` 匹配: ${matchReason}`; + if (skill.category) { + output += ` | 分类: ${skill.category}`; + } + output += '\n'; + } + + return { + success: true, + output, + metadata: { query, resultCount: results.length }, + }; + } + + // 默认显示统计和分类 + const stats = registry.getStats(); + const categories = registry.getCategories(); + + let output = `📊 Skills 概览\n\n`; + output += `总数: ${stats.total} (启用: ${stats.enabled})\n`; + output += `来源: 内置 ${stats.bySource.builtin || 0}, 用户 ${stats.bySource.user || 0}, 项目 ${stats.bySource.project || 0}\n\n`; + + if (categories.length > 0) { + output += `可用分类:\n`; + for (const cat of categories) { + output += `- ${cat} (${stats.byCategory[cat]} 个)\n`; + } + } + + output += `\n使用方法:\n`; + output += `- skill_search({ list_all: true }) - 列出所有 Skills\n`; + output += `- skill_search({ query: "关键词" }) - 搜索 Skills\n`; + output += `- skill_search({ category: "分类名" }) - 按分类筛选`; + + return { + success: true, + output, + metadata: { stats }, + }; + }, +}; diff --git a/tests/unit/skills/loader.test.ts b/tests/unit/skills/loader.test.ts new file mode 100644 index 0000000..7b47f88 --- /dev/null +++ b/tests/unit/skills/loader.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SkillLoader } from '../../../src/skills/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('SkillLoader - Skill 加载器', () => { + let loader: SkillLoader; + + beforeEach(() => { + vi.clearAllMocks(); + loader = new SkillLoader(); + }); + + describe('loadFromFile - 从文件加载', () => { + it('加载 YAML 格式的 Skill', async () => { + const yamlContent = ` +skill: + name: test-skill + description: 测试 Skill + promptTemplate: "提示模板 {{param}}" + parameters: + param: + type: string + required: true + description: 参数描述 +`; + + vi.mocked(fs.readFile).mockResolvedValue(yamlContent); + + const skill = await loader.loadFromFile('/test/skill.yaml', 'user'); + + expect(skill).not.toBeNull(); + expect(skill?.name).toBe('test-skill'); + expect(skill?.description).toBe('测试 Skill'); + expect(skill?.source).toBe('user'); + expect(skill?.parameters?.param.required).toBe(true); + }); + + it('加载 JSON 格式的 Skill', async () => { + const jsonContent = JSON.stringify({ + skill: { + name: 'json-skill', + description: 'JSON Skill', + promptTemplate: '提示', + }, + }); + + vi.mocked(fs.readFile).mockResolvedValue(jsonContent); + + const skill = await loader.loadFromFile('/test/skill.json', 'project'); + + expect(skill).not.toBeNull(); + expect(skill?.name).toBe('json-skill'); + expect(skill?.source).toBe('project'); + }); + + it('加载 Markdown 格式的 Skill(带 frontmatter)', async () => { + const mdContent = `--- +name: md-skill +description: Markdown Skill +parameters: + code: + type: string + required: true + description: 代码 +--- + +# 代码审查 + +请审查以下代码: + +{{code}} +`; + + vi.mocked(fs.readFile).mockResolvedValue(mdContent); + + const skill = await loader.loadFromFile('/test/skill.md', 'user'); + + expect(skill).not.toBeNull(); + expect(skill?.name).toBe('md-skill'); + expect(skill?.description).toBe('Markdown Skill'); + expect(skill?.promptTemplate).toContain('{{code}}'); + expect(skill?.promptTemplate).toContain('代码审查'); + }); + + it('加载 Markdown 格式的 Skill(无 frontmatter)', async () => { + const mdContent = `# 简单提示 + +这是一个简单的提示模板。 + +{{input}} +`; + + vi.mocked(fs.readFile).mockResolvedValue(mdContent); + + const skill = await loader.loadFromFile('/test/simple.md', 'user'); + + expect(skill).not.toBeNull(); + expect(skill?.name).toBe('simple'); // 从文件名获取 + expect(skill?.promptTemplate).toContain('简单提示'); + }); + + it('缺少必需字段时返回 null', async () => { + const yamlContent = ` +skill: + description: 没有 name 和 promptTemplate +`; + + vi.mocked(fs.readFile).mockResolvedValue(yamlContent); + + const skill = await loader.loadFromFile('/test/invalid.yaml', 'user'); + + expect(skill).toBeNull(); + }); + }); + + describe('loadFromDirectory - 从目录加载', () => { + it('目录不存在时返回空数组', async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('Not found')); + + const skills = await loader.loadFromDirectory('/non-existent', 'user'); + + expect(skills).toEqual([]); + }); + + it('加载目录中的所有 Skill 文件', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'skill1.yaml', isFile: () => true, isDirectory: () => false }, + { name: 'skill2.json', isFile: () => true, isDirectory: () => false }, + { name: 'readme.txt', isFile: () => true, isDirectory: () => false }, // 不支持的格式 + ] as any); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce(`skill:\n name: skill1\n description: 1\n promptTemplate: t1`) + .mockResolvedValueOnce(JSON.stringify({ skill: { name: 'skill2', description: '2', promptTemplate: 't2' } })); + + const skills = await loader.loadFromDirectory('/test', 'user'); + + expect(skills.length).toBe(2); + expect(skills.map((s) => s.name)).toContain('skill1'); + expect(skills.map((s) => s.name)).toContain('skill2'); + }); + + it('递归加载子目录', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir) + .mockResolvedValueOnce([ + { name: 'subdir', isFile: () => false, isDirectory: () => true }, + { name: 'root.yaml', isFile: () => true, isDirectory: () => false }, + ] as any) + .mockResolvedValueOnce([ + { name: 'sub.yaml', isFile: () => true, isDirectory: () => false }, + ] as any); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce(`skill:\n name: root\n description: root\n promptTemplate: root`) + .mockResolvedValueOnce(`skill:\n name: sub\n description: sub\n promptTemplate: sub`); + + const skills = await loader.loadFromDirectory('/test', 'user'); + + expect(skills.length).toBe(2); + expect(skills.map((s) => s.name)).toContain('root'); + expect(skills.map((s) => s.name)).toContain('sub'); + }); + }); + + describe('目录路径', () => { + it('getUserSkillsDir 返回用户 Skills 目录', () => { + const originalHome = process.env.HOME; + process.env.HOME = '/home/testuser'; + + const dir = loader.getUserSkillsDir(); + + expect(dir).toBe('/home/testuser/.config/ai-terminal/skills'); + + process.env.HOME = originalHome; + }); + + it('getProjectSkillsDir 返回项目 Skills 目录', () => { + const dir = loader.getProjectSkillsDir('/workspace'); + + expect(dir).toBe('/workspace/.ai-terminal/skills'); + }); + }); +}); diff --git a/tests/unit/skills/registry.test.ts b/tests/unit/skills/registry.test.ts new file mode 100644 index 0000000..127f3ef --- /dev/null +++ b/tests/unit/skills/registry.test.ts @@ -0,0 +1,407 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + SkillRegistry, + getSkillRegistry, + resetSkillRegistry, +} from '../../../src/skills/registry.js'; +import type { Skill } from '../../../src/skills/types.js'; + +// Mock loader to prevent file system access +vi.mock('../../../src/skills/loader.js', () => ({ + skillLoader: { + loadFromDirectory: vi.fn().mockResolvedValue([]), + getUserSkillsDir: vi.fn().mockReturnValue('/mock/user/skills'), + getProjectSkillsDir: vi.fn().mockReturnValue('/mock/project/skills'), + }, +})); + +// Mock builtin skills +vi.mock('../../../src/skills/builtin/index.js', () => ({ + builtinSkills: [ + { + name: 'test-builtin', + description: '内置测试 Skill', + promptTemplate: '测试提示: {{param1}}', + parameters: { + param1: { type: 'string', required: true, description: '参数1' }, + }, + source: 'builtin', + enabled: true, + }, + ], +})); + +describe('SkillRegistry - Skill 注册表', () => { + let registry: SkillRegistry; + + beforeEach(() => { + resetSkillRegistry(); + registry = new SkillRegistry({ autoLoad: false }); + }); + + describe('register - 注册', () => { + it('成功注册 Skill', () => { + const skill: Skill = { + name: 'test-skill', + description: '测试 Skill', + promptTemplate: '测试提示', + source: 'user', + }; + + registry.register(skill); + expect(registry.has('test-skill')).toBe(true); + }); + + it('高优先级 Skill 可以覆盖低优先级', () => { + const builtinSkill: Skill = { + name: 'same-name', + description: '内置版本', + promptTemplate: '内置提示', + source: 'builtin', + }; + + const projectSkill: Skill = { + name: 'same-name', + description: '项目版本', + promptTemplate: '项目提示', + source: 'project', + }; + + registry.register(builtinSkill); + registry.register(projectSkill); + + const result = registry.get('same-name'); + expect(result?.source).toBe('project'); + }); + + it('低优先级 Skill 不能覆盖高优先级', () => { + const projectSkill: Skill = { + name: 'same-name', + description: '项目版本', + promptTemplate: '项目提示', + source: 'project', + }; + + const builtinSkill: Skill = { + name: 'same-name', + description: '内置版本', + promptTemplate: '内置提示', + source: 'builtin', + }; + + registry.register(projectSkill); + registry.register(builtinSkill); + + const result = registry.get('same-name'); + expect(result?.source).toBe('project'); + }); + }); + + describe('get - 获取', () => { + it('获取存在的 Skill', () => { + const skill: Skill = { + name: 'test-skill', + description: '测试', + promptTemplate: '提示', + source: 'user', + }; + + registry.register(skill); + const result = registry.get('test-skill'); + + expect(result).toBeDefined(); + expect(result?.name).toBe('test-skill'); + }); + + it('获取不存在的 Skill 返回 undefined', () => { + const result = registry.get('non-existent'); + expect(result).toBeUndefined(); + }); + }); + + describe('search - 搜索', () => { + beforeEach(() => { + const skills: Skill[] = [ + { + name: 'code-review', + description: '代码审查', + promptTemplate: '审查代码', + keywords: ['review', 'quality'], + category: 'development', + source: 'builtin', + enabled: true, + }, + { + name: 'generate-tests', + description: '生成测试', + promptTemplate: '生成测试代码', + keywords: ['test', 'unit'], + category: 'testing', + source: 'builtin', + enabled: true, + }, + { + name: 'explain-code', + description: '解释代码功能', + promptTemplate: '解释代码', + category: 'development', + source: 'builtin', + enabled: true, + }, + ]; + + for (const skill of skills) { + registry.register(skill); + } + }); + + it('按名称精确匹配', () => { + const results = registry.search('code-review'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].skill.name).toBe('code-review'); + expect(results[0].score).toBe(100); + }); + + it('按名称前缀匹配', () => { + const results = registry.search('code'); + expect(results.length).toBe(2); // code-review, explain-code + }); + + it('按描述匹配', () => { + const results = registry.search('审查'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].skill.name).toBe('code-review'); + }); + + it('按关键词匹配', () => { + const results = registry.search('quality'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].skill.name).toBe('code-review'); + }); + + it('按分类匹配', () => { + const results = registry.search('testing'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].skill.name).toBe('generate-tests'); + }); + + it('限制结果数量', () => { + const results = registry.search('code', 1); + expect(results.length).toBe(1); + }); + }); + + describe('renderPrompt - 渲染模板', () => { + it('成功渲染模板', () => { + const skill: Skill = { + name: 'test', + description: '测试', + promptTemplate: '你好 {{name}},请{{action}}', + source: 'user', + }; + + const result = registry.renderPrompt(skill, { + name: '世界', + action: '测试', + }); + + expect(result.success).toBe(true); + expect(result.prompt).toBe('你好 世界,请测试'); + }); + + it('缺少必需参数时返回错误', () => { + const skill: Skill = { + name: 'test', + description: '测试', + promptTemplate: '{{param}}', + parameters: { + param: { type: 'string', required: true, description: '必需参数' }, + }, + source: 'user', + }; + + const result = registry.renderPrompt(skill, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain('缺少必需参数'); + }); + + it('使用默认值', () => { + const skill: Skill = { + name: 'test', + description: '测试', + promptTemplate: '值: {{param}}', + parameters: { + param: { + type: 'string', + required: false, + default: '默认值', + description: '可选参数', + }, + }, + source: 'user', + }; + + const result = registry.renderPrompt(skill, {}); + + expect(result.success).toBe(true); + expect(result.prompt).toBe('值: 默认值'); + }); + + it('保留未匹配的变量', () => { + const skill: Skill = { + name: 'test', + description: '测试', + promptTemplate: '{{known}} 和 {{unknown}}', + source: 'user', + }; + + const result = registry.renderPrompt(skill, { known: '已知' }); + + expect(result.success).toBe(true); + expect(result.prompt).toBe('已知 和 {{unknown}}'); + }); + }); + + describe('execute - 执行', () => { + it('成功执行 Skill', () => { + const skill: Skill = { + name: 'test', + description: '测试', + promptTemplate: '提示: {{param}}', + source: 'user', + enabled: true, + }; + + registry.register(skill); + const result = registry.execute('test', { param: '值' }); + + expect(result.success).toBe(true); + expect(result.prompt).toBe('提示: 值'); + }); + + it('Skill 不存在时返回错误', () => { + const result = registry.execute('non-existent', {}); + + expect(result.success).toBe(false); + expect(result.error).toContain('不存在'); + }); + + it('Skill 禁用时返回错误', () => { + const skill: Skill = { + name: 'disabled', + description: '禁用的 Skill', + promptTemplate: '提示', + source: 'user', + enabled: false, + }; + + registry.register(skill); + const result = registry.execute('disabled', {}); + + expect(result.success).toBe(false); + expect(result.error).toContain('禁用'); + }); + }); + + describe('getByCategory - 按分类获取', () => { + it('返回指定分类的 Skills', () => { + const skills: Skill[] = [ + { + name: 'skill1', + description: '1', + promptTemplate: '1', + category: 'dev', + source: 'builtin', + enabled: true, + }, + { + name: 'skill2', + description: '2', + promptTemplate: '2', + category: 'dev', + source: 'builtin', + enabled: true, + }, + { + name: 'skill3', + description: '3', + promptTemplate: '3', + category: 'test', + source: 'builtin', + enabled: true, + }, + ]; + + for (const skill of skills) { + registry.register(skill); + } + + const devSkills = registry.getByCategory('dev'); + expect(devSkills.length).toBe(2); + }); + }); + + describe('getStats - 统计信息', () => { + it('返回正确的统计信息', () => { + const skills: Skill[] = [ + { + name: 'builtin1', + description: '1', + promptTemplate: '1', + category: 'dev', + source: 'builtin', + enabled: true, + }, + { + name: 'user1', + description: '2', + promptTemplate: '2', + category: 'dev', + source: 'user', + enabled: true, + }, + { + name: 'disabled1', + description: '3', + promptTemplate: '3', + source: 'builtin', + enabled: false, + }, + ]; + + for (const skill of skills) { + registry.register(skill); + } + + const stats = registry.getStats(); + + expect(stats.total).toBe(3); + expect(stats.enabled).toBe(2); + expect(stats.bySource.builtin).toBe(2); + expect(stats.bySource.user).toBe(1); + expect(stats.byCategory.dev).toBe(2); + }); + }); +}); + +describe('getSkillRegistry - 全局注册表', () => { + beforeEach(() => { + resetSkillRegistry(); + }); + + it('返回单例实例', () => { + const registry1 = getSkillRegistry(); + const registry2 = getSkillRegistry(); + + expect(registry1).toBe(registry2); + }); + + it('resetSkillRegistry 重置实例', () => { + const registry1 = getSkillRegistry(); + resetSkillRegistry(); + const registry2 = getSkillRegistry(); + + expect(registry1).not.toBe(registry2); + }); +}); diff --git a/tests/unit/tools/skill/skill.test.ts b/tests/unit/tools/skill/skill.test.ts new file mode 100644 index 0000000..1e0c14a --- /dev/null +++ b/tests/unit/tools/skill/skill.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { skillTool } from '../../../../src/tools/skill/skill.js'; +import { getSkillRegistry, resetSkillRegistry } from '../../../../src/skills/registry.js'; +import type { Skill } from '../../../../src/skills/types.js'; + +// Mock loader to prevent file system access +vi.mock('../../../../src/skills/loader.js', () => ({ + skillLoader: { + loadFromDirectory: vi.fn().mockResolvedValue([]), + getUserSkillsDir: vi.fn().mockReturnValue('/mock/user/skills'), + getProjectSkillsDir: vi.fn().mockReturnValue('/mock/project/skills'), + }, +})); + +// Mock builtin skills +vi.mock('../../../../src/skills/builtin/index.js', () => ({ + builtinSkills: [ + { + name: 'code-review', + description: '代码审查', + promptTemplate: '请审查以下代码:\n\n{{code}}\n\n重点:{{focus}}', + parameters: { + code: { type: 'string', required: true, description: '代码' }, + focus: { type: 'string', required: false, default: '代码质量', description: '审查重点' }, + }, + category: 'development', + source: 'builtin', + enabled: true, + }, + { + name: 'disabled-skill', + description: '禁用的 Skill', + promptTemplate: '不应该执行', + source: 'builtin', + enabled: false, + }, + ], +})); + +describe('skillTool - Skill 工具', () => { + beforeEach(async () => { + vi.clearAllMocks(); + resetSkillRegistry(); + // 初始化注册表 + const registry = getSkillRegistry(); + await registry.initialize(); + }); + + describe('工具定义', () => { + it('有正确的名称', () => { + expect(skillTool.name).toBe('skill'); + }); + + it('有正确的元数据', () => { + expect(skillTool.metadata.category).toBe('agent'); + expect(skillTool.metadata.keywords).toContain('skill'); + }); + + it('skill_name 参数是必须的', () => { + expect(skillTool.parameters.skill_name.required).toBe(true); + }); + + it('params 参数是可选的', () => { + expect(skillTool.parameters.params.required).toBe(false); + }); + }); + + describe('execute - 执行', () => { + it('成功执行 Skill 并渲染模板', async () => { + const result = await skillTool.execute({ + skill_name: 'code-review', + params: { + code: 'function add(a, b) { return a + b; }', + focus: '性能', + }, + }); + + expect(result.success).toBe(true); + expect(result.output).toContain('function add'); + expect(result.output).toContain('性能'); + expect(result.metadata?.skill).toBe('code-review'); + }); + + it('使用默认参数值', async () => { + const result = await skillTool.execute({ + skill_name: 'code-review', + params: { + code: 'const x = 1;', + }, + }); + + expect(result.success).toBe(true); + expect(result.output).toContain('代码质量'); // 默认值 + }); + + it('Skill 不存在时返回错误', async () => { + const result = await skillTool.execute({ + skill_name: 'non-existent', + params: {}, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('不存在'); + }); + + it('Skill 不存在时提供建议', async () => { + const result = await skillTool.execute({ + skill_name: 'code-rev', // 类似 code-review + params: {}, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('code-review'); // 建议 + }); + + it('缺少必需参数时返回错误', async () => { + const result = await skillTool.execute({ + skill_name: 'code-review', + params: {}, // 缺少 code 参数 + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('缺少必需参数'); + }); + + it('禁用的 Skill 返回错误', async () => { + const result = await skillTool.execute({ + skill_name: 'disabled-skill', + params: {}, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('禁用'); + }); + + it('返回正确的 metadata', async () => { + const result = await skillTool.execute({ + skill_name: 'code-review', + params: { code: 'test' }, + }); + + expect(result.metadata?.skill).toBe('code-review'); + expect(result.metadata?.category).toBe('development'); + expect(result.metadata?.source).toBe('builtin'); + }); + }); +}); diff --git a/tests/unit/tools/skill/skill_search.test.ts b/tests/unit/tools/skill/skill_search.test.ts new file mode 100644 index 0000000..a9f02e6 --- /dev/null +++ b/tests/unit/tools/skill/skill_search.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { skillSearchTool } from '../../../../src/tools/skill/skill_search.js'; +import { getSkillRegistry, resetSkillRegistry } from '../../../../src/skills/registry.js'; + +// Mock loader to prevent file system access +vi.mock('../../../../src/skills/loader.js', () => ({ + skillLoader: { + loadFromDirectory: vi.fn().mockResolvedValue([]), + getUserSkillsDir: vi.fn().mockReturnValue('/mock/user/skills'), + getProjectSkillsDir: vi.fn().mockReturnValue('/mock/project/skills'), + }, +})); + +// Mock builtin skills +vi.mock('../../../../src/skills/builtin/index.js', () => ({ + builtinSkills: [ + { + name: 'code-review', + description: '代码审查', + promptTemplate: '审查代码', + keywords: ['review', 'quality'], + category: 'development', + source: 'builtin', + enabled: true, + }, + { + name: 'generate-tests', + description: '生成测试', + promptTemplate: '生成测试', + keywords: ['test', 'unit'], + category: 'testing', + source: 'builtin', + enabled: true, + }, + { + name: 'explain-code', + description: '解释代码功能', + promptTemplate: '解释代码', + category: 'development', + source: 'builtin', + enabled: true, + }, + ], +})); + +describe('skillSearchTool - Skill 搜索工具', () => { + beforeEach(async () => { + vi.clearAllMocks(); + resetSkillRegistry(); + const registry = getSkillRegistry(); + await registry.initialize(); + }); + + describe('工具定义', () => { + it('有正确的名称', () => { + expect(skillSearchTool.name).toBe('skill_search'); + }); + + it('有正确的元数据', () => { + expect(skillSearchTool.metadata.category).toBe('agent'); + expect(skillSearchTool.metadata.keywords).toContain('search'); + }); + + it('所有参数都是可选的', () => { + expect(skillSearchTool.parameters.query.required).toBe(false); + expect(skillSearchTool.parameters.category.required).toBe(false); + expect(skillSearchTool.parameters.list_all.required).toBe(false); + }); + }); + + describe('execute - 执行', () => { + it('list_all 列出所有 Skills', async () => { + const result = await skillSearchTool.execute({ list_all: true }); + + expect(result.success).toBe(true); + expect(result.output).toContain('code-review'); + expect(result.output).toContain('generate-tests'); + expect(result.output).toContain('explain-code'); + expect(result.output).toContain('development'); + expect(result.output).toContain('testing'); + }); + + it('按关键词搜索', async () => { + const result = await skillSearchTool.execute({ query: 'review' }); + + expect(result.success).toBe(true); + expect(result.output).toContain('code-review'); + expect(result.metadata?.query).toBe('review'); + }); + + it('按分类筛选', async () => { + const result = await skillSearchTool.execute({ category: 'testing' }); + + expect(result.success).toBe(true); + expect(result.output).toContain('generate-tests'); + expect(result.output).not.toContain('code-review'); + }); + + it('分类不存在时显示可用分类', async () => { + const result = await skillSearchTool.execute({ category: 'non-existent' }); + + expect(result.success).toBe(true); + expect(result.output).toContain('没有 Skills'); + expect(result.output).toContain('可用分类'); + }); + + it('搜索无结果时提示', async () => { + const result = await skillSearchTool.execute({ query: 'xxxxxxxxx' }); + + expect(result.success).toBe(true); + expect(result.output).toContain('没有找到'); + }); + + it('无参数时显示概览', async () => { + const result = await skillSearchTool.execute({}); + + expect(result.success).toBe(true); + expect(result.output).toContain('概览'); + expect(result.output).toContain('使用方法'); + }); + }); +});