feat: 添加 Skills 系统支持可复用提示模板

- 新增 Skill 类型定义和加载器,支持 YAML/JSON/Markdown 格式
- 实现 Skill 注册表,支持搜索、分类和优先级覆盖
- 添加 8 个内置 Skills: code-review, explain-code, generate-docs 等
- 创建 skill 和 skill_search 工具供 Agent 调用
- 支持从用户目录和项目目录加载自定义 Skills
- 添加完整的单元测试覆盖
This commit is contained in:
2025-12-11 15:56:19 +08:00
parent ad5d30b262
commit 723558ff22
15 changed files with 2289 additions and 0 deletions
+193
View File
@@ -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');
});
});
});