mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
draft version of skill tool feature
This commit is contained in:
parent
5fddcd509c
commit
bd6e16d41b
22 changed files with 1891 additions and 9 deletions
31
packages/core/src/skills/index.ts
Normal file
31
packages/core/src/skills/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Skills feature implementation
|
||||
*
|
||||
* This module provides the foundation for the skills feature, which allows
|
||||
* users to define reusable skill configurations that can be loaded by the
|
||||
* model via a dedicated Skills tool.
|
||||
*
|
||||
* Skills are stored as directories in `.qwen/skills/` (project-level) or
|
||||
* `~/.qwen/skills/` (user-level), with each directory containing a SKILL.md
|
||||
* file with YAML frontmatter for metadata.
|
||||
*/
|
||||
|
||||
// Core types and interfaces
|
||||
export type {
|
||||
SkillConfig,
|
||||
SkillLevel,
|
||||
SkillValidationResult,
|
||||
ListSkillsOptions,
|
||||
SkillErrorCode,
|
||||
} from './types.js';
|
||||
|
||||
export { SkillError } from './types.js';
|
||||
|
||||
// Main management class
|
||||
export { SkillManager } from './skill-manager.js';
|
||||
463
packages/core/src/skills/skill-manager.test.ts
Normal file
463
packages/core/src/skills/skill-manager.test.ts
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { SkillManager } from './skill-manager.js';
|
||||
import { type SkillConfig, SkillError } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
|
||||
// Mock file system operations
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('os');
|
||||
|
||||
// Mock yaml parser - use vi.hoisted for proper hoisting
|
||||
const mockParseYaml = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../utils/yaml-parser.js', () => ({
|
||||
parse: mockParseYaml,
|
||||
stringify: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('SkillManager', () => {
|
||||
let manager: SkillManager;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock Config object using test utility
|
||||
mockConfig = makeFakeConfig({});
|
||||
|
||||
// Mock the project root method
|
||||
vi.spyOn(mockConfig, 'getProjectRoot').mockReturnValue('/test/project');
|
||||
|
||||
// Mock os.homedir
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
|
||||
// Reset and setup mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup yaml parser mocks with sophisticated behavior
|
||||
mockParseYaml.mockImplementation((yamlString: string) => {
|
||||
// Handle different test cases based on YAML content
|
||||
if (yamlString.includes('allowedTools:')) {
|
||||
return {
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
allowedTools: ['read_file', 'write_file'],
|
||||
};
|
||||
}
|
||||
if (yamlString.includes('name: skill1')) {
|
||||
return { name: 'skill1', description: 'First skill' };
|
||||
}
|
||||
if (yamlString.includes('name: skill2')) {
|
||||
return { name: 'skill2', description: 'Second skill' };
|
||||
}
|
||||
if (yamlString.includes('name: skill3')) {
|
||||
return { name: 'skill3', description: 'Third skill' };
|
||||
}
|
||||
if (!yamlString.includes('name:')) {
|
||||
return { description: 'A test skill' }; // Missing name case
|
||||
}
|
||||
if (!yamlString.includes('description:')) {
|
||||
return { name: 'test-skill' }; // Missing description case
|
||||
}
|
||||
// Default case
|
||||
return {
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
};
|
||||
});
|
||||
|
||||
manager = new SkillManager(mockConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const validSkillConfig: SkillConfig = {
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
level: 'project',
|
||||
filePath: '/test/project/.qwen/skills/test-skill/SKILL.md',
|
||||
body: 'You are a helpful assistant with this skill.',
|
||||
};
|
||||
|
||||
const validMarkdown = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
You are a helpful assistant with this skill.
|
||||
`;
|
||||
|
||||
describe('parseSkillContent', () => {
|
||||
it('should parse valid markdown content', () => {
|
||||
const config = manager.parseSkillContent(
|
||||
validMarkdown,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('test-skill');
|
||||
expect(config.description).toBe('A test skill');
|
||||
expect(config.body).toBe('You are a helpful assistant with this skill.');
|
||||
expect(config.level).toBe('project');
|
||||
expect(config.filePath).toBe(validSkillConfig.filePath);
|
||||
});
|
||||
|
||||
it('should parse content with allowedTools', () => {
|
||||
const markdownWithTools = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
allowedTools:
|
||||
- read_file
|
||||
- write_file
|
||||
---
|
||||
|
||||
You are a helpful assistant with this skill.
|
||||
`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
markdownWithTools,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.allowedTools).toEqual(['read_file', 'write_file']);
|
||||
});
|
||||
|
||||
it('should determine level from file path', () => {
|
||||
const projectPath = '/test/project/.qwen/skills/test-skill/SKILL.md';
|
||||
const userPath = '/home/user/.qwen/skills/test-skill/SKILL.md';
|
||||
|
||||
const projectConfig = manager.parseSkillContent(
|
||||
validMarkdown,
|
||||
projectPath,
|
||||
'project',
|
||||
);
|
||||
const userConfig = manager.parseSkillContent(
|
||||
validMarkdown,
|
||||
userPath,
|
||||
'user',
|
||||
);
|
||||
|
||||
expect(projectConfig.level).toBe('project');
|
||||
expect(userConfig.level).toBe('user');
|
||||
});
|
||||
|
||||
it('should throw error for invalid frontmatter format', () => {
|
||||
const invalidMarkdown = `No frontmatter here
|
||||
Just content`;
|
||||
|
||||
expect(() =>
|
||||
manager.parseSkillContent(
|
||||
invalidMarkdown,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
),
|
||||
).toThrow(SkillError);
|
||||
});
|
||||
|
||||
it('should throw error for missing name', () => {
|
||||
const markdownWithoutName = `---
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
You are a helpful assistant.
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
manager.parseSkillContent(
|
||||
markdownWithoutName,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
),
|
||||
).toThrow(SkillError);
|
||||
});
|
||||
|
||||
it('should throw error for missing description', () => {
|
||||
const markdownWithoutDescription = `---
|
||||
name: test-skill
|
||||
---
|
||||
|
||||
You are a helpful assistant.
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
manager.parseSkillContent(
|
||||
markdownWithoutDescription,
|
||||
validSkillConfig.filePath,
|
||||
'project',
|
||||
),
|
||||
).toThrow(SkillError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('should validate valid configuration', () => {
|
||||
const result = manager.validateConfig(validSkillConfig);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should report error for missing name', () => {
|
||||
const invalidConfig = { ...validSkillConfig, name: '' };
|
||||
const result = manager.validateConfig(invalidConfig);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('"name" cannot be empty');
|
||||
});
|
||||
|
||||
it('should report error for missing description', () => {
|
||||
const invalidConfig = { ...validSkillConfig, description: '' };
|
||||
const result = manager.validateConfig(invalidConfig);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('"description" cannot be empty');
|
||||
});
|
||||
|
||||
it('should report error for invalid allowedTools type', () => {
|
||||
const invalidConfig = {
|
||||
...validSkillConfig,
|
||||
allowedTools: 'not-an-array' as unknown as string[],
|
||||
};
|
||||
const result = manager.validateConfig(invalidConfig);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('"allowedTools" must be an array');
|
||||
});
|
||||
|
||||
it('should warn for empty body', () => {
|
||||
const configWithEmptyBody = { ...validSkillConfig, body: '' };
|
||||
const result = manager.validateConfig(configWithEmptyBody);
|
||||
|
||||
expect(result.isValid).toBe(true); // Still valid
|
||||
expect(result.warnings).toContain('Skill body is empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSkill', () => {
|
||||
it('should load skill from project level first', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test-skill', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
|
||||
|
||||
const config = await manager.loadSkill('test-skill');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.name).toBe('test-skill');
|
||||
});
|
||||
|
||||
it('should fall back to user level if project level fails', async () => {
|
||||
vi.mocked(fs.readdir)
|
||||
.mockRejectedValueOnce(new Error('Project dir not found')) // project level fails
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'test-skill', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>); // user level succeeds
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
|
||||
|
||||
const config = await manager.loadSkill('test-skill');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.name).toBe('test-skill');
|
||||
});
|
||||
|
||||
it('should return null if not found at either level', async () => {
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found'));
|
||||
|
||||
const config = await manager.loadSkill('nonexistent');
|
||||
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSkillForRuntime', () => {
|
||||
it('should load skill for runtime', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
||||
{ name: 'test-skill', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); // SKILL.md
|
||||
|
||||
const config = await manager.loadSkillForRuntime('test-skill');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.name).toBe('test-skill');
|
||||
});
|
||||
|
||||
it('should return null if skill not found', async () => {
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found'));
|
||||
|
||||
const config = await manager.loadSkillForRuntime('nonexistent');
|
||||
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSkills', () => {
|
||||
beforeEach(() => {
|
||||
// Mock directory listing for skills directories (with Dirent objects)
|
||||
vi.mocked(fs.readdir)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'skill1', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'skill2', isDirectory: () => true, isFile: () => false },
|
||||
{
|
||||
name: 'not-a-dir.txt',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
},
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'skill3', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'skill1', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
|
||||
// Mock file reading for valid skills
|
||||
vi.mocked(fs.readFile).mockImplementation((filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('skill1')) {
|
||||
return Promise.resolve(`---
|
||||
name: skill1
|
||||
description: First skill
|
||||
---
|
||||
Skill 1 content`);
|
||||
} else if (pathStr.includes('skill2')) {
|
||||
return Promise.resolve(`---
|
||||
name: skill2
|
||||
description: Second skill
|
||||
---
|
||||
Skill 2 content`);
|
||||
} else if (pathStr.includes('skill3')) {
|
||||
return Promise.resolve(`---
|
||||
name: skill3
|
||||
description: Third skill
|
||||
---
|
||||
Skill 3 content`);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should list skills from both levels', async () => {
|
||||
const skills = await manager.listSkills();
|
||||
|
||||
expect(skills).toHaveLength(3); // skill1 (project takes precedence), skill2, skill3
|
||||
expect(skills.map((s) => s.name).sort()).toEqual([
|
||||
'skill1',
|
||||
'skill2',
|
||||
'skill3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should prioritize project level over user level', async () => {
|
||||
const skills = await manager.listSkills();
|
||||
const skill1 = skills.find((s) => s.name === 'skill1');
|
||||
|
||||
expect(skill1!.level).toBe('project');
|
||||
});
|
||||
|
||||
it('should filter by level', async () => {
|
||||
const projectSkills = await manager.listSkills({
|
||||
level: 'project',
|
||||
});
|
||||
|
||||
expect(projectSkills).toHaveLength(2); // skill1, skill2
|
||||
expect(projectSkills.every((s) => s.level === 'project')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty directories', async () => {
|
||||
vi.mocked(fs.readdir).mockReset();
|
||||
vi.mocked(fs.readdir).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
|
||||
);
|
||||
|
||||
const skills = await manager.listSkills({ force: true });
|
||||
|
||||
expect(skills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle directory read errors', async () => {
|
||||
vi.mocked(fs.readdir).mockReset();
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found'));
|
||||
|
||||
const skills = await manager.listSkills({ force: true });
|
||||
|
||||
expect(skills).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSkillsBaseDir', () => {
|
||||
it('should return project-level base dir', () => {
|
||||
const baseDir = manager.getSkillsBaseDir('project');
|
||||
|
||||
expect(baseDir).toBe(path.join('/test/project', '.qwen', 'skills'));
|
||||
});
|
||||
|
||||
it('should return user-level base dir', () => {
|
||||
const baseDir = manager.getSkillsBaseDir('user');
|
||||
|
||||
expect(baseDir).toBe(path.join('/home/user', '.qwen', 'skills'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('change listeners', () => {
|
||||
it('should notify listeners when cache is refreshed', async () => {
|
||||
const listener = vi.fn();
|
||||
manager.addChangeListener(listener);
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
|
||||
);
|
||||
|
||||
await manager.refreshCache();
|
||||
|
||||
expect(listener).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove listener when cleanup function is called', async () => {
|
||||
const listener = vi.fn();
|
||||
const removeListener = manager.addChangeListener(listener);
|
||||
|
||||
removeListener();
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
|
||||
);
|
||||
|
||||
await manager.refreshCache();
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse errors', () => {
|
||||
it('should track parse errors', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'bad-skill', isDirectory: () => true, isFile: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
'invalid content without frontmatter',
|
||||
);
|
||||
|
||||
await manager.listSkills({ force: true });
|
||||
|
||||
const errors = manager.getParseErrors();
|
||||
expect(errors.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
452
packages/core/src/skills/skill-manager.ts
Normal file
452
packages/core/src/skills/skill-manager.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { parse as parseYaml } from '../utils/yaml-parser.js';
|
||||
import type {
|
||||
SkillConfig,
|
||||
SkillLevel,
|
||||
ListSkillsOptions,
|
||||
SkillValidationResult,
|
||||
} from './types.js';
|
||||
import { SkillError, SkillErrorCode } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
const QWEN_CONFIG_DIR = '.qwen';
|
||||
const SKILLS_CONFIG_DIR = 'skills';
|
||||
const SKILL_MANIFEST_FILE = 'SKILL.md';
|
||||
|
||||
/**
|
||||
* Manages skill configurations stored as directories containing SKILL.md files.
|
||||
* Provides discovery, parsing, validation, and caching for skills.
|
||||
*/
|
||||
export class SkillManager {
|
||||
private skillsCache: Map<SkillLevel, SkillConfig[]> | null = null;
|
||||
private readonly changeListeners: Set<() => void> = new Set();
|
||||
private parseErrors: Map<string, SkillError> = new Map();
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
/**
|
||||
* Adds a listener that will be called when skills change.
|
||||
* @returns A function to remove the listener.
|
||||
*/
|
||||
addChangeListener(listener: () => void): () => void {
|
||||
this.changeListeners.add(listener);
|
||||
return () => {
|
||||
this.changeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies all registered change listeners.
|
||||
*/
|
||||
private notifyChangeListeners(): void {
|
||||
for (const listener of this.changeListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.warn('Skill change listener threw an error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets any parse errors that occurred during skill loading.
|
||||
* @returns Map of skill paths to their parse errors.
|
||||
*/
|
||||
getParseErrors(): Map<string, SkillError> {
|
||||
return new Map(this.parseErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available skills.
|
||||
*
|
||||
* @param options - Filtering options
|
||||
* @returns Array of skill configurations
|
||||
*/
|
||||
async listSkills(options: ListSkillsOptions = {}): Promise<SkillConfig[]> {
|
||||
const skills: SkillConfig[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
const levelsToCheck: SkillLevel[] = options.level
|
||||
? [options.level]
|
||||
: ['project', 'user'];
|
||||
|
||||
// Check if we should use cache or force refresh
|
||||
const shouldUseCache = !options.force && this.skillsCache !== null;
|
||||
|
||||
// Initialize cache if it doesn't exist or we're forcing a refresh
|
||||
if (!shouldUseCache) {
|
||||
await this.refreshCache();
|
||||
}
|
||||
|
||||
// Collect skills from each level (project takes precedence over user)
|
||||
for (const level of levelsToCheck) {
|
||||
const levelSkills = this.skillsCache?.get(level) || [];
|
||||
|
||||
for (const skill of levelSkills) {
|
||||
// Skip if we've already seen this name (precedence: project > user)
|
||||
if (seenNames.has(skill.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
skills.push(skill);
|
||||
seenNames.add(skill.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name for consistent ordering
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a skill configuration by name.
|
||||
* If level is specified, only searches that level.
|
||||
* If level is omitted, searches project-level first, then user-level.
|
||||
*
|
||||
* @param name - Name of the skill to load
|
||||
* @param level - Optional level to limit search to
|
||||
* @returns SkillConfig or null if not found
|
||||
*/
|
||||
async loadSkill(
|
||||
name: string,
|
||||
level?: SkillLevel,
|
||||
): Promise<SkillConfig | null> {
|
||||
if (level) {
|
||||
return this.findSkillByNameAtLevel(name, level);
|
||||
}
|
||||
|
||||
// Try project level first
|
||||
const projectSkill = await this.findSkillByNameAtLevel(name, 'project');
|
||||
if (projectSkill) {
|
||||
return projectSkill;
|
||||
}
|
||||
|
||||
// Try user level
|
||||
return this.findSkillByNameAtLevel(name, 'user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a skill with its full content, ready for runtime use.
|
||||
* This includes loading additional files from the skill directory.
|
||||
*
|
||||
* @param name - Name of the skill to load
|
||||
* @param level - Optional level to limit search to
|
||||
* @returns SkillConfig or null if not found
|
||||
*/
|
||||
async loadSkillForRuntime(
|
||||
name: string,
|
||||
level?: SkillLevel,
|
||||
): Promise<SkillConfig | null> {
|
||||
const skill = await this.loadSkill(name, level);
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a skill configuration.
|
||||
*
|
||||
* @param config - Configuration to validate
|
||||
* @returns Validation result
|
||||
*/
|
||||
validateConfig(config: Partial<SkillConfig>): SkillValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check required fields
|
||||
if (typeof config.name !== 'string') {
|
||||
errors.push('Missing or invalid "name" field');
|
||||
} else if (config.name.trim() === '') {
|
||||
errors.push('"name" cannot be empty');
|
||||
}
|
||||
|
||||
if (typeof config.description !== 'string') {
|
||||
errors.push('Missing or invalid "description" field');
|
||||
} else if (config.description.trim() === '') {
|
||||
errors.push('"description" cannot be empty');
|
||||
}
|
||||
|
||||
// Validate allowedTools if present
|
||||
if (config.allowedTools !== undefined) {
|
||||
if (!Array.isArray(config.allowedTools)) {
|
||||
errors.push('"allowedTools" must be an array');
|
||||
} else {
|
||||
for (const tool of config.allowedTools) {
|
||||
if (typeof tool !== 'string') {
|
||||
errors.push('"allowedTools" must contain only strings');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if body is empty
|
||||
if (!config.body || config.body.trim() === '') {
|
||||
warnings.push('Skill body is empty');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the skills cache by loading all skills from disk.
|
||||
*/
|
||||
async refreshCache(): Promise<void> {
|
||||
const skillsCache = new Map<SkillLevel, SkillConfig[]>();
|
||||
this.parseErrors.clear();
|
||||
|
||||
const levels: SkillLevel[] = ['project', 'user'];
|
||||
|
||||
for (const level of levels) {
|
||||
const levelSkills = await this.listSkillsAtLevel(level);
|
||||
skillsCache.set(level, levelSkills);
|
||||
}
|
||||
|
||||
this.skillsCache = skillsCache;
|
||||
this.notifyChangeListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a SKILL.md file and returns the configuration.
|
||||
*
|
||||
* @param filePath - Path to the SKILL.md file
|
||||
* @param level - Storage level
|
||||
* @returns SkillConfig
|
||||
* @throws SkillError if parsing fails
|
||||
*/
|
||||
parseSkillFile(filePath: string, level: SkillLevel): Promise<SkillConfig> {
|
||||
return this.parseSkillFileInternal(filePath, level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation of skill file parsing.
|
||||
*/
|
||||
private async parseSkillFileInternal(
|
||||
filePath: string,
|
||||
level: SkillLevel,
|
||||
): Promise<SkillConfig> {
|
||||
let content: string;
|
||||
|
||||
try {
|
||||
content = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
const skillError = new SkillError(
|
||||
`Failed to read skill file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
SkillErrorCode.FILE_ERROR,
|
||||
);
|
||||
this.parseErrors.set(filePath, skillError);
|
||||
throw skillError;
|
||||
}
|
||||
|
||||
return this.parseSkillContent(content, filePath, level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses skill content from a string.
|
||||
*
|
||||
* @param content - File content
|
||||
* @param filePath - File path for error reporting
|
||||
* @param level - Storage level
|
||||
* @returns SkillConfig
|
||||
* @throws SkillError if parsing fails
|
||||
*/
|
||||
parseSkillContent(
|
||||
content: string,
|
||||
filePath: string,
|
||||
level: SkillLevel,
|
||||
): SkillConfig {
|
||||
try {
|
||||
// Split frontmatter and content
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid format: missing YAML frontmatter');
|
||||
}
|
||||
|
||||
const [, frontmatterYaml, body] = match;
|
||||
|
||||
// Parse YAML frontmatter
|
||||
const frontmatter = parseYaml(frontmatterYaml) as Record<string, unknown>;
|
||||
|
||||
// Extract required fields
|
||||
const nameRaw = frontmatter['name'];
|
||||
const descriptionRaw = frontmatter['description'];
|
||||
|
||||
if (nameRaw == null || nameRaw === '') {
|
||||
throw new Error('Missing "name" in frontmatter');
|
||||
}
|
||||
|
||||
if (descriptionRaw == null || descriptionRaw === '') {
|
||||
throw new Error('Missing "description" in frontmatter');
|
||||
}
|
||||
|
||||
// Convert to strings
|
||||
const name = String(nameRaw);
|
||||
const description = String(descriptionRaw);
|
||||
|
||||
// Extract optional fields
|
||||
const allowedToolsRaw = frontmatter['allowedTools'] as
|
||||
| unknown[]
|
||||
| undefined;
|
||||
let allowedTools: string[] | undefined;
|
||||
|
||||
if (allowedToolsRaw !== undefined) {
|
||||
if (Array.isArray(allowedToolsRaw)) {
|
||||
allowedTools = allowedToolsRaw.map(String);
|
||||
} else {
|
||||
throw new Error('"allowedTools" must be an array');
|
||||
}
|
||||
}
|
||||
|
||||
const config: SkillConfig = {
|
||||
name,
|
||||
description,
|
||||
allowedTools,
|
||||
level,
|
||||
filePath,
|
||||
body: body.trim(),
|
||||
};
|
||||
|
||||
// Validate the parsed configuration
|
||||
const validation = this.validateConfig(config);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
const skillError = new SkillError(
|
||||
`Failed to parse skill file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
SkillErrorCode.PARSE_ERROR,
|
||||
);
|
||||
this.parseErrors.set(filePath, skillError);
|
||||
throw skillError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base directory for skills at a specific level.
|
||||
*
|
||||
* @param level - Storage level
|
||||
* @returns Absolute directory path
|
||||
*/
|
||||
getSkillsBaseDir(level: SkillLevel): string {
|
||||
const baseDir =
|
||||
level === 'project'
|
||||
? path.join(
|
||||
this.config.getProjectRoot(),
|
||||
QWEN_CONFIG_DIR,
|
||||
SKILLS_CONFIG_DIR,
|
||||
)
|
||||
: path.join(os.homedir(), QWEN_CONFIG_DIR, SKILLS_CONFIG_DIR);
|
||||
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists skills at a specific level.
|
||||
*
|
||||
* @param level - Storage level to scan
|
||||
* @returns Array of skill configurations
|
||||
*/
|
||||
private async listSkillsAtLevel(level: SkillLevel): Promise<SkillConfig[]> {
|
||||
const projectRoot = this.config.getProjectRoot();
|
||||
const homeDir = os.homedir();
|
||||
const isHomeDirectory = path.resolve(projectRoot) === path.resolve(homeDir);
|
||||
|
||||
// If project level is requested but project root is same as home directory,
|
||||
// return empty array to avoid conflicts between project and global skills
|
||||
if (level === 'project' && isHomeDirectory) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const baseDir = this.getSkillsBaseDir(level);
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(baseDir, { withFileTypes: true });
|
||||
const skills: SkillConfig[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Only process directories (each skill is a directory)
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const skillDir = path.join(baseDir, entry.name);
|
||||
const skillManifest = path.join(skillDir, SKILL_MANIFEST_FILE);
|
||||
|
||||
try {
|
||||
// Check if SKILL.md exists
|
||||
await fs.access(skillManifest);
|
||||
|
||||
const config = await this.parseSkillFileInternal(
|
||||
skillManifest,
|
||||
level,
|
||||
);
|
||||
skills.push(config);
|
||||
} catch (error) {
|
||||
// Skip directories without valid SKILL.md
|
||||
if (error instanceof SkillError) {
|
||||
// Parse error was already recorded
|
||||
console.warn(
|
||||
`Failed to parse skill at ${skillDir}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
} catch (_error) {
|
||||
// Directory doesn't exist or can't be read
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a skill by name at a specific level.
|
||||
*
|
||||
* @param name - Name of the skill to find
|
||||
* @param level - Storage level to search
|
||||
* @returns SkillConfig or null if not found
|
||||
*/
|
||||
private async findSkillByNameAtLevel(
|
||||
name: string,
|
||||
level: SkillLevel,
|
||||
): Promise<SkillConfig | null> {
|
||||
await this.ensureLevelCache(level);
|
||||
|
||||
const levelSkills = this.skillsCache?.get(level) || [];
|
||||
|
||||
// Find the skill with matching name
|
||||
return levelSkills.find((skill) => skill.name === name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the cache is populated for a specific level without loading other levels.
|
||||
*/
|
||||
private async ensureLevelCache(level: SkillLevel): Promise<void> {
|
||||
if (!this.skillsCache) {
|
||||
this.skillsCache = new Map<SkillLevel, SkillConfig[]>();
|
||||
}
|
||||
|
||||
if (!this.skillsCache.has(level)) {
|
||||
const levelSkills = await this.listSkillsAtLevel(level);
|
||||
this.skillsCache.set(level, levelSkills);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
packages/core/src/skills/types.ts
Normal file
105
packages/core/src/skills/types.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents the storage level for a skill configuration.
|
||||
* - 'project': Stored in `.qwen/skills/` within the project directory
|
||||
* - 'user': Stored in `~/.qwen/skills/` in the user's home directory
|
||||
*/
|
||||
export type SkillLevel = 'project' | 'user';
|
||||
|
||||
/**
|
||||
* Core configuration for a skill as stored in SKILL.md files.
|
||||
* Each skill directory contains a SKILL.md file with YAML frontmatter
|
||||
* containing metadata, followed by markdown content describing the skill.
|
||||
*/
|
||||
export interface SkillConfig {
|
||||
/** Unique name identifier for the skill */
|
||||
name: string;
|
||||
|
||||
/** Human-readable description of what this skill provides */
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Optional list of tool names that this skill is allowed to use.
|
||||
* For v1, this is informational only (no gating).
|
||||
*/
|
||||
allowedTools?: string[];
|
||||
|
||||
/**
|
||||
* Storage level - determines where the configuration file is stored
|
||||
*/
|
||||
level: SkillLevel;
|
||||
|
||||
/**
|
||||
* Absolute path to the skill directory containing SKILL.md
|
||||
*/
|
||||
filePath: string;
|
||||
|
||||
/**
|
||||
* The markdown body content from SKILL.md (after the frontmatter)
|
||||
*/
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime configuration for a skill when it's being actively used.
|
||||
* Extends SkillConfig with additional runtime-specific fields.
|
||||
*/
|
||||
export type SkillRuntimeConfig = SkillConfig;
|
||||
|
||||
/**
|
||||
* Result of a validation operation on a skill configuration.
|
||||
*/
|
||||
export interface SkillValidationResult {
|
||||
/** Whether the configuration is valid */
|
||||
isValid: boolean;
|
||||
|
||||
/** Array of error messages if validation failed */
|
||||
errors: string[];
|
||||
|
||||
/** Array of warning messages (non-blocking issues) */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for listing skills.
|
||||
*/
|
||||
export interface ListSkillsOptions {
|
||||
/** Filter by storage level */
|
||||
level?: SkillLevel;
|
||||
|
||||
/** Force refresh from disk, bypassing cache. Defaults to false. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a skill operation fails.
|
||||
*/
|
||||
export class SkillError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: SkillErrorCode,
|
||||
readonly skillName?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SkillError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error codes for skill operations.
|
||||
*/
|
||||
export const SkillErrorCode = {
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
INVALID_CONFIG: 'INVALID_CONFIG',
|
||||
INVALID_NAME: 'INVALID_NAME',
|
||||
FILE_ERROR: 'FILE_ERROR',
|
||||
PARSE_ERROR: 'PARSE_ERROR',
|
||||
} as const;
|
||||
|
||||
export type SkillErrorCode =
|
||||
(typeof SkillErrorCode)[keyof typeof SkillErrorCode];
|
||||
Loading…
Add table
Add a link
Reference in a new issue