feat: add skills system for extensible capability packages
Implement a three-tier skill system (bundled/managed/workspace) that extends Flynn's abilities via SKILL.md instructions injected into the system prompt. - SkillManifest/Skill types with requirements gating (OS, binaries, env) - Loader: discovers skills from directories, validates manifests, checks system requirements, infers manifest from SKILL.md if missing - SkillRegistry: holds skills, generates system prompt additions, supports override by name (workspace > managed > bundled) - SkillInstaller: copies/removes skills in managed directory with upgrade support - Config: add skills.workspace_dir, managed_dir, bundled_dir options - Daemon: loads all skills at startup, injects available skill instructions into the system prompt - Tests: 45 new tests (loader 22, registry 11, installer 12)
This commit is contained in:
@@ -50,6 +50,15 @@ const hooksSchema = z.object({
|
||||
silent: z.array(z.string()).default([]),
|
||||
}).default({});
|
||||
|
||||
const skillsSchema = z.object({
|
||||
/** Directory for user-created workspace skills. */
|
||||
workspace_dir: z.string().optional(),
|
||||
/** Directory for managed (installed) skills. Defaults to ~/.flynn/workspace/skills. */
|
||||
managed_dir: z.string().optional(),
|
||||
/** Directory for bundled skills shipped with Flynn. */
|
||||
bundled_dir: z.string().optional(),
|
||||
}).default({});
|
||||
|
||||
const mcpServerSchema = z.object({
|
||||
name: z.string(),
|
||||
command: z.string(),
|
||||
@@ -68,6 +77,7 @@ export const configSchema = z.object({
|
||||
models: modelsSchema,
|
||||
backends: backendsSchema.default({}),
|
||||
hooks: hooksSchema.default({}),
|
||||
skills: skillsSchema.default({}),
|
||||
mcp: mcpSchema.default({ servers: [] }),
|
||||
});
|
||||
|
||||
|
||||
+31
-2
@@ -9,6 +9,7 @@ import { GatewayServer } from '../gateway/index.js';
|
||||
import { ChannelRegistry, TelegramAdapter, WebChatAdapter } from '../channels/index.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
|
||||
import { McpManager } from '../mcp/index.js';
|
||||
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { mkdirSync, readFileSync, existsSync } from 'fs';
|
||||
@@ -25,6 +26,8 @@ export interface DaemonContext {
|
||||
gateway: GatewayServer;
|
||||
channelRegistry: ChannelRegistry;
|
||||
mcpManager: McpManager;
|
||||
skillRegistry: SkillRegistry;
|
||||
skillInstaller: SkillInstaller;
|
||||
}
|
||||
|
||||
function loadSystemPrompt(): string {
|
||||
@@ -199,11 +202,35 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
console.log('MCP servers stopped');
|
||||
});
|
||||
|
||||
// Initialize skills system
|
||||
const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills');
|
||||
const skillRegistry = new SkillRegistry();
|
||||
const skillInstaller = new SkillInstaller(config.skills.managed_dir ?? defaultManagedDir);
|
||||
|
||||
const skills = loadAllSkills({
|
||||
bundledDir: config.skills.bundled_dir,
|
||||
managedDir: config.skills.managed_dir ?? defaultManagedDir,
|
||||
workspaceDir: config.skills.workspace_dir,
|
||||
});
|
||||
|
||||
for (const skill of skills) {
|
||||
skillRegistry.register(skill);
|
||||
}
|
||||
|
||||
if (skills.length > 0) {
|
||||
const available = skillRegistry.listAvailable().length;
|
||||
console.log(`Loaded ${skills.length} skill(s) (${available} available)`);
|
||||
}
|
||||
|
||||
// Initialize model router
|
||||
const modelRouter = createModelRouter(config);
|
||||
|
||||
// Load system prompt once for reuse
|
||||
const systemPrompt = loadSystemPrompt();
|
||||
// Load system prompt and append skill instructions
|
||||
let systemPrompt = loadSystemPrompt();
|
||||
const skillAdditions = skillRegistry.getSystemPromptAdditions();
|
||||
if (skillAdditions) {
|
||||
systemPrompt = `${systemPrompt}\n\n# Available Skills\n\n${skillAdditions}`;
|
||||
}
|
||||
|
||||
// Initialize gateway WebSocket server
|
||||
const gateway = new GatewayServer({
|
||||
@@ -296,6 +323,8 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
gateway,
|
||||
channelRegistry,
|
||||
mcpManager,
|
||||
skillRegistry,
|
||||
skillInstaller,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { SkillTier, SkillRequirements, SkillManifest, Skill } from './types.js';
|
||||
export { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
|
||||
export { SkillRegistry } from './registry.js';
|
||||
export { SkillInstaller } from './installer.js';
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, rmSync, mkdtempSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { SkillInstaller } from './installer.js';
|
||||
|
||||
describe('SkillInstaller', () => {
|
||||
// Objective: verify that the installer correctly copies, upgrades, and
|
||||
// removes skill directories in the managed area.
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
/** Create a temp directory and track it for cleanup. */
|
||||
function makeTmpDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Create a minimal source skill directory with SKILL.md and optional manifest. */
|
||||
function makeSourceSkill(
|
||||
parentDir: string,
|
||||
dirName: string,
|
||||
options: { manifest?: Record<string, unknown>; instructions?: string } = {},
|
||||
): string {
|
||||
const skillDir = join(parentDir, dirName);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), options.instructions ?? `# ${dirName}\nDefault instructions.`);
|
||||
if (options.manifest) {
|
||||
writeFileSync(join(skillDir, 'manifest.json'), JSON.stringify(options.manifest));
|
||||
}
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tmpDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
it('creates managedDir in constructor if it does not exist', () => {
|
||||
// Positive: the constructor should ensure the managed directory exists.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed', 'nested');
|
||||
|
||||
new SkillInstaller(managedDir);
|
||||
|
||||
expect(existsSync(managedDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('installs a skill from a source directory', () => {
|
||||
// Positive: install should copy SKILL.md and return a loaded Skill.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'my-skill', {
|
||||
manifest: { name: 'my-skill', description: 'A skill', version: '1.0.0' },
|
||||
instructions: '# My Skill\nDo the thing.',
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
const skill = installer.install(sourceDir);
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.name).toBe('my-skill');
|
||||
expect(skill!.instructions).toBe('# My Skill\nDo the thing.');
|
||||
expect(existsSync(join(managedDir, 'my-skill', 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('installed skill has tier "managed"', () => {
|
||||
// Positive: regardless of source manifest, installed skill tier must be 'managed'.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'tier-skill', {
|
||||
manifest: { name: 'tier-skill', description: 'Tier test', version: '1.0.0', tier: 'bundled' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
const skill = installer.install(sourceDir);
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.tier).toBe('managed');
|
||||
});
|
||||
|
||||
it('uses manifest.json name field for the installed directory name', () => {
|
||||
// Positive: directory in managed area should match manifest name, not source dirname.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'source-dirname', {
|
||||
manifest: { name: 'manifest-name', description: 'Named via manifest', version: '1.0.0' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceDir);
|
||||
|
||||
expect(existsSync(join(managedDir, 'manifest-name', 'SKILL.md'))).toBe(true);
|
||||
expect(existsSync(join(managedDir, 'source-dirname'))).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to basename when no manifest.json is present', () => {
|
||||
// Positive: without manifest.json, directory name should come from source basename.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'fallback-name');
|
||||
// No manifest.json written
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceDir);
|
||||
|
||||
expect(existsSync(join(managedDir, 'fallback-name', 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('upgrades an existing skill by replacing it', () => {
|
||||
// Positive: re-installing should overwrite the old version.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceV1 = makeSourceSkill(tmp, 'v1-source', {
|
||||
manifest: { name: 'upgradable', description: 'Version 1', version: '1.0.0' },
|
||||
instructions: '# V1',
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceV1);
|
||||
|
||||
// Create v2 source
|
||||
const sourceV2 = makeSourceSkill(tmp, 'v2-source', {
|
||||
manifest: { name: 'upgradable', description: 'Version 2', version: '2.0.0' },
|
||||
instructions: '# V2',
|
||||
});
|
||||
|
||||
const skill = installer.install(sourceV2);
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.version).toBe('2.0.0');
|
||||
expect(skill!.instructions).toBe('# V2');
|
||||
});
|
||||
|
||||
it('throws when source directory does not exist', () => {
|
||||
// Negative: install should throw a descriptive error for missing sources.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
|
||||
expect(() => installer.install('/tmp/does-not-exist-xyz-abc-123')).toThrow(
|
||||
'Source directory does not exist',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when source directory has no SKILL.md', () => {
|
||||
// Negative: install should throw when SKILL.md is missing.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const emptyDir = join(tmp, 'empty-skill');
|
||||
mkdirSync(emptyDir);
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
|
||||
expect(() => installer.install(emptyDir)).toThrow('does not contain SKILL.md');
|
||||
});
|
||||
|
||||
it('uninstalls a skill', () => {
|
||||
// Positive: uninstall should remove the directory and return true.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'removable', {
|
||||
manifest: { name: 'removable', description: 'Will be removed', version: '1.0.0' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceDir);
|
||||
|
||||
const result = installer.uninstall('removable');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(existsSync(join(managedDir, 'removable'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when uninstalling a nonexistent skill', () => {
|
||||
// Negative: uninstalling a name that was never installed should return false.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
|
||||
expect(installer.uninstall('ghost')).toBe(false);
|
||||
});
|
||||
|
||||
it('listInstalled lists all installed skills', () => {
|
||||
// Positive: every installed skill should appear in the list.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceA = makeSourceSkill(tmp, 'skill-a', {
|
||||
manifest: { name: 'skill-a', description: 'Skill A', version: '1.0.0' },
|
||||
});
|
||||
const sourceB = makeSourceSkill(tmp, 'skill-b', {
|
||||
manifest: { name: 'skill-b', description: 'Skill B', version: '1.0.0' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceA);
|
||||
installer.install(sourceB);
|
||||
|
||||
const installed = installer.listInstalled();
|
||||
|
||||
expect(installed).toHaveLength(2);
|
||||
expect(installed).toContain('skill-a');
|
||||
expect(installed).toContain('skill-b');
|
||||
});
|
||||
|
||||
it('isInstalled checks correctly', () => {
|
||||
// Positive/Negative: should return true for installed, false for not installed.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'check-me', {
|
||||
manifest: { name: 'check-me', description: 'Check me', version: '1.0.0' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceDir);
|
||||
|
||||
expect(installer.isInstalled('check-me')).toBe(true);
|
||||
expect(installer.isInstalled('not-installed')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mkdirSync, cpSync, rmSync, existsSync, readFileSync, readdirSync } from 'fs';
|
||||
import { resolve, basename } from 'path';
|
||||
import type { Skill } from './types.js';
|
||||
import { loadSkill } from './loader.js';
|
||||
|
||||
/**
|
||||
* SkillInstaller manages installing and removing skills in the managed
|
||||
* skills directory (e.g., ~/.flynn/workspace/skills/).
|
||||
*
|
||||
* Install copies a skill directory into managedDir, uninstall removes it.
|
||||
*/
|
||||
export class SkillInstaller {
|
||||
private managedDir: string;
|
||||
|
||||
constructor(managedDir: string) {
|
||||
this.managedDir = managedDir;
|
||||
mkdirSync(this.managedDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill from a source directory into the managed area.
|
||||
*
|
||||
* - sourceDir must exist and contain a SKILL.md file.
|
||||
* - Skill name is read from manifest.json if present, otherwise falls back
|
||||
* to the directory basename.
|
||||
* - If the skill is already installed, it is replaced (upgrade).
|
||||
*
|
||||
* Returns the loaded Skill, or null if loading failed after copy.
|
||||
*/
|
||||
install(sourceDir: string): Skill | null {
|
||||
// Validate source directory exists and contains SKILL.md
|
||||
if (!existsSync(sourceDir)) {
|
||||
throw new Error(`Source directory does not exist: ${sourceDir}`);
|
||||
}
|
||||
const skillMdPath = resolve(sourceDir, 'SKILL.md');
|
||||
if (!existsSync(skillMdPath)) {
|
||||
throw new Error(`Source directory does not contain SKILL.md: ${sourceDir}`);
|
||||
}
|
||||
|
||||
// Determine skill name from manifest.json, or fall back to directory basename
|
||||
let skillName = basename(sourceDir);
|
||||
const manifestPath = resolve(sourceDir, 'manifest.json');
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
const manifestContent = readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(manifestContent) as { name?: string };
|
||||
if (manifest.name) {
|
||||
skillName = manifest.name;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to basename if manifest can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
const destination = resolve(this.managedDir, skillName);
|
||||
|
||||
// Remove existing installation if present (upgrade path)
|
||||
if (existsSync(destination)) {
|
||||
rmSync(destination, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Copy source to managed directory
|
||||
cpSync(sourceDir, destination, { recursive: true });
|
||||
|
||||
// Load and return the installed skill
|
||||
const skill = loadSkill(destination, 'managed');
|
||||
console.log(`Skill '${skillName}' installed to ${destination}`);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a managed skill by name.
|
||||
*
|
||||
* Returns true if the skill was found and removed, false otherwise.
|
||||
*/
|
||||
uninstall(name: string): boolean {
|
||||
const dir = resolve(this.managedDir, name);
|
||||
if (!existsSync(dir)) {
|
||||
return false;
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
console.log(`Skill '${name}' uninstalled`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List basenames of all installed managed skills.
|
||||
*
|
||||
* A valid installed skill is a subdirectory containing a SKILL.md file.
|
||||
*/
|
||||
listInstalled(): string[] {
|
||||
if (!existsSync(this.managedDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = readdirSync(this.managedDir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter(entry => {
|
||||
if (!entry.isDirectory()) return false;
|
||||
const skillMd = resolve(this.managedDir, entry.name, 'SKILL.md');
|
||||
return existsSync(skillMd);
|
||||
})
|
||||
.map(entry => entry.name);
|
||||
}
|
||||
|
||||
/** Check whether a skill with the given name is installed. */
|
||||
isInstalled(name: string): boolean {
|
||||
return existsSync(resolve(this.managedDir, name, 'SKILL.md'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir, platform } from 'os';
|
||||
import { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
|
||||
import type { SkillRequirements } from './types.js';
|
||||
|
||||
describe('checkRequirements', () => {
|
||||
// Objective: verify that skill availability gating works for all requirement types.
|
||||
|
||||
it('returns available=true when no requirements are provided', () => {
|
||||
// Positive: undefined requirements should always pass.
|
||||
const result = checkRequirements(undefined);
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns available=true with empty requirements object', () => {
|
||||
// Positive: an empty object has no checks to fail.
|
||||
const result = checkRequirements({});
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes when OS matches current platform', () => {
|
||||
// Positive: listing the current platform should succeed.
|
||||
const requirements: SkillRequirements = { os: [platform()] };
|
||||
|
||||
const result = checkRequirements(requirements);
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fails when OS does not match current platform', () => {
|
||||
// Negative: a fake platform name should cause an OS mismatch.
|
||||
const requirements: SkillRequirements = { os: ['fakeos'] };
|
||||
|
||||
const result = checkRequirements(requirements);
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toContain('Unsupported platform');
|
||||
expect(result.reasons[0]).toContain('fakeos');
|
||||
});
|
||||
|
||||
it('passes when a required binary exists in PATH', () => {
|
||||
// Positive: 'node' is guaranteed to exist in the test runner.
|
||||
const requirements: SkillRequirements = { binaries: ['node'] };
|
||||
|
||||
const result = checkRequirements(requirements);
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fails when a required binary is missing from PATH', () => {
|
||||
// Negative: a nonsense binary name should not be found.
|
||||
const requirements: SkillRequirements = { binaries: ['nonexistent-binary-xyz123'] };
|
||||
|
||||
const result = checkRequirements(requirements);
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toContain("'nonexistent-binary-xyz123'");
|
||||
expect(result.reasons[0]).toContain('not found in PATH');
|
||||
});
|
||||
|
||||
it('passes when a required env var is set', () => {
|
||||
// Positive: temporarily set an env var and verify it passes.
|
||||
process.env.FLYNN_TEST_VAR = '1';
|
||||
|
||||
try {
|
||||
const requirements: SkillRequirements = { env: ['FLYNN_TEST_VAR'] };
|
||||
|
||||
const result = checkRequirements(requirements);
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
} finally {
|
||||
delete process.env.FLYNN_TEST_VAR;
|
||||
}
|
||||
});
|
||||
|
||||
it('fails when a required env var is missing', () => {
|
||||
// Negative: an unset env var should cause a failure.
|
||||
const requirements: SkillRequirements = { env: ['FLYNN_NONEXISTENT_VAR_XYZ'] };
|
||||
|
||||
const result = checkRequirements(requirements);
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toContain('FLYNN_NONEXISTENT_VAR_XYZ');
|
||||
expect(result.reasons[0]).toContain('not set');
|
||||
});
|
||||
|
||||
it('collects multiple failure reasons across requirement types', () => {
|
||||
// Negative: combining failures from OS, binary, and env should collect all reasons.
|
||||
const requirements: SkillRequirements = {
|
||||
os: ['fakeos'],
|
||||
binaries: ['nonexistent-binary-xyz123'],
|
||||
env: ['FLYNN_NONEXISTENT_VAR_XYZ'],
|
||||
};
|
||||
|
||||
const result = checkRequirements(requirements);
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reasons).toHaveLength(3);
|
||||
expect(result.reasons[0]).toContain('Unsupported platform');
|
||||
expect(result.reasons[1]).toContain('nonexistent-binary-xyz123');
|
||||
expect(result.reasons[2]).toContain('FLYNN_NONEXISTENT_VAR_XYZ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSkill', () => {
|
||||
// Objective: verify that a single skill directory is correctly loaded into a Skill object.
|
||||
let tmpDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('loads a skill with manifest.json and SKILL.md', () => {
|
||||
// Positive: a well-formed skill directory should produce a valid Skill.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const skillDir = join(tmpDir, 'my-skill');
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'my-skill', description: 'A test skill', version: '1.2.0' }),
|
||||
);
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '# My Skill\nDo something useful.');
|
||||
|
||||
const skill = loadSkill(skillDir, 'bundled');
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.name).toBe('my-skill');
|
||||
expect(skill!.manifest.description).toBe('A test skill');
|
||||
expect(skill!.manifest.version).toBe('1.2.0');
|
||||
expect(skill!.instructions).toBe('# My Skill\nDo something useful.');
|
||||
expect(skill!.available).toBe(true);
|
||||
expect(skill!.directory).toBe(skillDir);
|
||||
});
|
||||
|
||||
it('returns null when SKILL.md is missing', () => {
|
||||
// Negative: SKILL.md is required — without it the skill cannot be loaded.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const skillDir = join(tmpDir, 'no-instructions');
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'no-instructions', description: 'Missing MD', version: '1.0.0' }),
|
||||
);
|
||||
|
||||
const skill = loadSkill(skillDir, 'bundled');
|
||||
|
||||
expect(skill).toBeNull();
|
||||
});
|
||||
|
||||
it('infers manifest when manifest.json is missing', () => {
|
||||
// Positive: without manifest.json, name comes from dirname, description from first line.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const skillDir = join(tmpDir, 'inferred-skill');
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), 'A useful skill description\nMore details here.');
|
||||
|
||||
const skill = loadSkill(skillDir, 'workspace');
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.name).toBe('inferred-skill');
|
||||
expect(skill!.manifest.description).toBe('A useful skill description');
|
||||
expect(skill!.manifest.version).toBe('0.0.0');
|
||||
expect(skill!.manifest.tier).toBe('workspace');
|
||||
});
|
||||
|
||||
it('returns null when manifest.json has invalid JSON', () => {
|
||||
// Negative: unparseable JSON should cause the loader to bail.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const skillDir = join(tmpDir, 'bad-json');
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(join(skillDir, 'manifest.json'), '{ not valid json !!!');
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '# Bad JSON Skill');
|
||||
|
||||
const skill = loadSkill(skillDir, 'bundled');
|
||||
|
||||
expect(skill).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when manifest.json is missing required fields', () => {
|
||||
// Negative: manifest must have name, description, and version.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const skillDir = join(tmpDir, 'missing-fields');
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(join(skillDir, 'manifest.json'), JSON.stringify({ name: 'only-name' }));
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '# Missing Fields');
|
||||
|
||||
const skill = loadSkill(skillDir, 'bundled');
|
||||
|
||||
expect(skill).toBeNull();
|
||||
});
|
||||
|
||||
it('strips markdown heading markers from inferred description', () => {
|
||||
// Positive: a first line like "## My Heading" should become "My Heading".
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const skillDir = join(tmpDir, 'heading-skill');
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '## Heading Skill Title\nBody text.');
|
||||
|
||||
const skill = loadSkill(skillDir, 'bundled');
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.description).toBe('Heading Skill Title');
|
||||
});
|
||||
|
||||
it('sets tier from the argument, not from manifest content', () => {
|
||||
// Positive: tier in manifest should be overridden by the tier argument.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const skillDir = join(tmpDir, 'tier-test');
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'tier-test', description: 'Tier test', version: '1.0.0', tier: 'bundled' }),
|
||||
);
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '# Tier Test');
|
||||
|
||||
const skill = loadSkill(skillDir, 'managed');
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.tier).toBe('managed');
|
||||
});
|
||||
|
||||
it('marks skill unavailable when requirements are not met', () => {
|
||||
// Negative: unmet requirements should set available=false with reasons.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const skillDir = join(tmpDir, 'unavailable-skill');
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: 'unavailable-skill',
|
||||
description: 'Needs fake binary',
|
||||
version: '1.0.0',
|
||||
requirements: { binaries: ['nonexistent-binary-xyz123'] },
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '# Unavailable Skill');
|
||||
|
||||
const skill = loadSkill(skillDir, 'bundled');
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.available).toBe(false);
|
||||
expect(skill!.unavailableReasons).toBeDefined();
|
||||
expect(skill!.unavailableReasons!.length).toBeGreaterThan(0);
|
||||
expect(skill!.unavailableReasons![0]).toContain('nonexistent-binary-xyz123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverSkills', () => {
|
||||
// Objective: verify directory scanning discovers only valid skill subdirectories.
|
||||
let tmpDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns empty array for nonexistent directory', () => {
|
||||
// Negative: a path that does not exist should not throw, just return [].
|
||||
const skills = discoverSkills('/tmp/does-not-exist-xyz-abc-123', 'bundled');
|
||||
|
||||
expect(skills).toEqual([]);
|
||||
});
|
||||
|
||||
it('discovers multiple skills in a directory', () => {
|
||||
// Positive: each valid subdirectory with SKILL.md should be discovered.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
|
||||
const skillA = join(tmpDir, 'skill-a');
|
||||
mkdirSync(skillA);
|
||||
writeFileSync(join(skillA, 'SKILL.md'), '# Skill A');
|
||||
|
||||
const skillB = join(tmpDir, 'skill-b');
|
||||
mkdirSync(skillB);
|
||||
writeFileSync(join(skillB, 'SKILL.md'), '# Skill B');
|
||||
|
||||
const skills = discoverSkills(tmpDir, 'bundled');
|
||||
|
||||
expect(skills).toHaveLength(2);
|
||||
const names = skills.map((s) => s.manifest.name);
|
||||
expect(names).toContain('skill-a');
|
||||
expect(names).toContain('skill-b');
|
||||
});
|
||||
|
||||
it('skips non-directory entries', () => {
|
||||
// Positive: regular files at the base level should be ignored.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
|
||||
const skillDir = join(tmpDir, 'real-skill');
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '# Real Skill');
|
||||
|
||||
// Create a plain file at the base level — should be skipped
|
||||
writeFileSync(join(tmpDir, 'not-a-skill.txt'), 'just a file');
|
||||
|
||||
const skills = discoverSkills(tmpDir, 'bundled');
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].manifest.name).toBe('real-skill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAllSkills', () => {
|
||||
// Objective: verify that all three tier directories are scanned and merged.
|
||||
let tmpDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('loads from all three tier directories', () => {
|
||||
// Positive: each tier directory should contribute its skills.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const bundledDir = join(tmpDir, 'bundled');
|
||||
const managedDir = join(tmpDir, 'managed');
|
||||
const workspaceDir = join(tmpDir, 'workspace');
|
||||
|
||||
mkdirSync(join(bundledDir, 'skill-b'), { recursive: true });
|
||||
writeFileSync(join(bundledDir, 'skill-b', 'SKILL.md'), '# Bundled');
|
||||
|
||||
mkdirSync(join(managedDir, 'skill-m'), { recursive: true });
|
||||
writeFileSync(join(managedDir, 'skill-m', 'SKILL.md'), '# Managed');
|
||||
|
||||
mkdirSync(join(workspaceDir, 'skill-w'), { recursive: true });
|
||||
writeFileSync(join(workspaceDir, 'skill-w', 'SKILL.md'), '# Workspace');
|
||||
|
||||
const skills = loadAllSkills({ bundledDir, managedDir, workspaceDir });
|
||||
|
||||
expect(skills).toHaveLength(3);
|
||||
const tiers = skills.map((s) => s.manifest.tier);
|
||||
expect(tiers).toContain('bundled');
|
||||
expect(tiers).toContain('managed');
|
||||
expect(tiers).toContain('workspace');
|
||||
});
|
||||
|
||||
it('skips undefined and missing directories without error', () => {
|
||||
// Positive: undefined config entries and nonexistent paths should be gracefully skipped.
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
const bundledDir = join(tmpDir, 'bundled');
|
||||
|
||||
mkdirSync(join(bundledDir, 'skill-only'), { recursive: true });
|
||||
writeFileSync(join(bundledDir, 'skill-only', 'SKILL.md'), '# Only Bundled');
|
||||
|
||||
const skills = loadAllSkills({
|
||||
bundledDir,
|
||||
managedDir: undefined,
|
||||
workspaceDir: '/tmp/does-not-exist-xyz-abc-123',
|
||||
});
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].manifest.tier).toBe('bundled');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Skill loader — discovers and loads skills from disk.
|
||||
*
|
||||
* Skills are loaded synchronously at startup from up to three directories
|
||||
* (bundled, managed, workspace), each mapped to a SkillTier.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
||||
import { resolve, join, basename } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { platform } from 'os';
|
||||
import type { Skill, SkillManifest, SkillRequirements, SkillTier } from './types.js';
|
||||
|
||||
/**
|
||||
* Check whether a skill's system requirements are met.
|
||||
*
|
||||
* Validates OS platform, binary availability (via `which`/`where`),
|
||||
* and environment variable presence.
|
||||
*/
|
||||
export function checkRequirements(requirements?: SkillRequirements): { available: boolean; reasons: string[] } {
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (!requirements) {
|
||||
return { available: true, reasons: [] };
|
||||
}
|
||||
|
||||
// Check OS platform
|
||||
if (requirements.os && requirements.os.length > 0) {
|
||||
const currentPlatform = platform();
|
||||
if (!requirements.os.includes(currentPlatform)) {
|
||||
reasons.push(`Unsupported platform '${currentPlatform}' (requires: ${requirements.os.join(', ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check binaries in PATH
|
||||
if (requirements.binaries) {
|
||||
const whichCmd = platform() === 'win32' ? 'where' : 'which';
|
||||
for (const binary of requirements.binaries) {
|
||||
try {
|
||||
execSync(`${whichCmd} ${binary}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
reasons.push(`Required binary '${binary}' not found in PATH`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check environment variables
|
||||
if (requirements.env) {
|
||||
for (const envVar of requirements.env) {
|
||||
if (!process.env[envVar]) {
|
||||
reasons.push(`Required environment variable '${envVar}' is not set`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: reasons.length === 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single skill from a directory.
|
||||
*
|
||||
* Reads manifest.json and SKILL.md. If manifest.json is missing, infers a
|
||||
* minimal manifest from the directory name and SKILL.md first line.
|
||||
* Returns null if SKILL.md doesn't exist or the manifest is unparseable.
|
||||
*/
|
||||
export function loadSkill(directory: string, tier: SkillTier): Skill | null {
|
||||
const absDir = resolve(directory);
|
||||
const manifestPath = join(absDir, 'manifest.json');
|
||||
const instructionsPath = join(absDir, 'SKILL.md');
|
||||
|
||||
// SKILL.md is required — no instructions, no skill
|
||||
if (!existsSync(instructionsPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const instructions = readFileSync(instructionsPath, 'utf-8');
|
||||
|
||||
let manifest: SkillManifest;
|
||||
|
||||
if (existsSync(manifestPath)) {
|
||||
// Parse manifest.json
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
|
||||
// Validate required fields
|
||||
if (!raw.name || !raw.description || !raw.version) {
|
||||
console.warn(`Skill manifest at ${manifestPath} missing required fields (name, description, version)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
manifest = {
|
||||
...raw,
|
||||
tier, // Override tier from argument
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to parse manifest.json at ${manifestPath}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Infer minimal manifest from directory name and SKILL.md content
|
||||
const name = basename(absDir);
|
||||
const firstLine = instructions.split('\n').find((line) => line.trim().length > 0) ?? name;
|
||||
// Strip leading markdown heading markers for a cleaner description
|
||||
const description = firstLine.replace(/^#+\s*/, '').trim();
|
||||
|
||||
manifest = {
|
||||
name,
|
||||
description,
|
||||
version: '0.0.0',
|
||||
tier,
|
||||
};
|
||||
}
|
||||
|
||||
// Check system requirements
|
||||
const { available, reasons } = checkRequirements(manifest.requirements);
|
||||
|
||||
const skill: Skill = {
|
||||
manifest,
|
||||
instructions,
|
||||
directory: absDir,
|
||||
available,
|
||||
};
|
||||
|
||||
if (!available) {
|
||||
skill.unavailableReasons = reasons;
|
||||
}
|
||||
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all skills in a base directory.
|
||||
*
|
||||
* Lists subdirectories and attempts to load each as a skill.
|
||||
* Skips entries that aren't directories or fail to load.
|
||||
*/
|
||||
export function discoverSkills(baseDir: string, tier: SkillTier): Skill[] {
|
||||
const absBase = resolve(baseDir);
|
||||
|
||||
if (!existsSync(absBase)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = readdirSync(absBase);
|
||||
const skills: Skill[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(absBase, entry);
|
||||
|
||||
// Only process directories
|
||||
try {
|
||||
if (!statSync(entryPath).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skill = loadSkill(entryPath, tier);
|
||||
if (skill) {
|
||||
skills.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all skills from configured directories.
|
||||
*
|
||||
* Scans bundled, managed, and workspace directories (if provided and existing)
|
||||
* and returns a flat array of all discovered skills.
|
||||
*/
|
||||
export function loadAllSkills(config: {
|
||||
bundledDir?: string;
|
||||
managedDir?: string;
|
||||
workspaceDir?: string;
|
||||
}): Skill[] {
|
||||
const skills: Skill[] = [];
|
||||
|
||||
if (config.bundledDir) {
|
||||
skills.push(...discoverSkills(config.bundledDir, 'bundled'));
|
||||
}
|
||||
|
||||
if (config.managedDir) {
|
||||
skills.push(...discoverSkills(config.managedDir, 'managed'));
|
||||
}
|
||||
|
||||
if (config.workspaceDir) {
|
||||
skills.push(...discoverSkills(config.workspaceDir, 'workspace'));
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SkillRegistry } from './registry.js';
|
||||
import type { Skill } from './types.js';
|
||||
|
||||
/**
|
||||
* Helper to create a Skill with sensible defaults. The `name` convenience
|
||||
* field sets `manifest.name` so callers don't have to nest it every time.
|
||||
*/
|
||||
function makeSkill(overrides: Partial<Skill> & { name?: string } = {}): Skill {
|
||||
const name = overrides.name ?? overrides.manifest?.name ?? 'test-skill';
|
||||
return {
|
||||
manifest: {
|
||||
name,
|
||||
description: 'Test skill',
|
||||
version: '1.0.0',
|
||||
tier: 'bundled' as const,
|
||||
...overrides.manifest,
|
||||
},
|
||||
instructions: overrides.instructions ?? '# Test\nDo things.',
|
||||
directory: overrides.directory ?? `/fake/path/${name}`,
|
||||
available: overrides.available ?? true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SkillRegistry', () => {
|
||||
// Objective: verify the in-memory skill registry for registration, lookup,
|
||||
// listing, filtering, unregistration, and system prompt generation.
|
||||
|
||||
it('registers and retrieves a skill by name', () => {
|
||||
// Positive: a registered skill should be returned by get().
|
||||
const registry = new SkillRegistry();
|
||||
const skill = makeSkill({ name: 'git' });
|
||||
|
||||
registry.register(skill);
|
||||
|
||||
expect(registry.get('git')).toBe(skill);
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown skill', () => {
|
||||
// Negative: looking up a name that was never registered should yield undefined.
|
||||
const registry = new SkillRegistry();
|
||||
|
||||
expect(registry.get('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists all registered skills', () => {
|
||||
// Positive: list() should return every registered skill regardless of availability.
|
||||
const registry = new SkillRegistry();
|
||||
const skillA = makeSkill({ name: 'skill-a' });
|
||||
const skillB = makeSkill({ name: 'skill-b', available: false });
|
||||
|
||||
registry.register(skillA);
|
||||
registry.register(skillB);
|
||||
|
||||
const all = registry.list();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.map((s) => s.manifest.name)).toContain('skill-a');
|
||||
expect(all.map((s) => s.manifest.name)).toContain('skill-b');
|
||||
});
|
||||
|
||||
it('listAvailable returns only available skills', () => {
|
||||
// Positive: unavailable skills should be filtered out.
|
||||
const registry = new SkillRegistry();
|
||||
const available = makeSkill({ name: 'available', available: true });
|
||||
const unavailable = makeSkill({ name: 'unavailable', available: false });
|
||||
|
||||
registry.register(available);
|
||||
registry.register(unavailable);
|
||||
|
||||
const result = registry.listAvailable();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].manifest.name).toBe('available');
|
||||
});
|
||||
|
||||
it('replaces an existing skill with the same name', () => {
|
||||
// Positive: re-registering a name should overwrite (managed overrides bundled).
|
||||
const registry = new SkillRegistry();
|
||||
const original = makeSkill({ name: 'web', instructions: '# Original' });
|
||||
const replacement = makeSkill({ name: 'web', instructions: '# Replacement' });
|
||||
|
||||
registry.register(original);
|
||||
registry.register(replacement);
|
||||
|
||||
expect(registry.list()).toHaveLength(1);
|
||||
expect(registry.get('web')!.instructions).toBe('# Replacement');
|
||||
});
|
||||
|
||||
it('unregisters a skill by name', () => {
|
||||
// Positive: unregister should remove the skill and return true.
|
||||
const registry = new SkillRegistry();
|
||||
const skill = makeSkill({ name: 'removable' });
|
||||
registry.register(skill);
|
||||
|
||||
const result = registry.unregister('removable');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(registry.get('removable')).toBeUndefined();
|
||||
expect(registry.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns false when unregistering a nonexistent skill', () => {
|
||||
// Negative: unregistering a name that doesn't exist should return false.
|
||||
const registry = new SkillRegistry();
|
||||
|
||||
expect(registry.unregister('ghost')).toBe(false);
|
||||
});
|
||||
|
||||
it('getSystemPromptAdditions returns empty string with no skills', () => {
|
||||
// Negative: with nothing registered the prompt additions should be empty.
|
||||
const registry = new SkillRegistry();
|
||||
|
||||
expect(registry.getSystemPromptAdditions()).toBe('');
|
||||
});
|
||||
|
||||
it('getSystemPromptAdditions formats available skills correctly', () => {
|
||||
// Positive: each available skill should appear as a "## Skill: <name>" section.
|
||||
const registry = new SkillRegistry();
|
||||
registry.register(makeSkill({ name: 'alpha', instructions: 'Alpha instructions.' }));
|
||||
registry.register(makeSkill({ name: 'beta', instructions: 'Beta instructions.' }));
|
||||
|
||||
const prompt = registry.getSystemPromptAdditions();
|
||||
|
||||
expect(prompt).toContain('## Skill: alpha');
|
||||
expect(prompt).toContain('Alpha instructions.');
|
||||
expect(prompt).toContain('## Skill: beta');
|
||||
expect(prompt).toContain('Beta instructions.');
|
||||
});
|
||||
|
||||
it('getSystemPromptAdditions excludes unavailable skills', () => {
|
||||
// Positive: only available skills contribute to the prompt.
|
||||
const registry = new SkillRegistry();
|
||||
registry.register(makeSkill({ name: 'enabled', instructions: 'Enabled content.' }));
|
||||
registry.register(
|
||||
makeSkill({ name: 'disabled', instructions: 'Disabled content.', available: false }),
|
||||
);
|
||||
|
||||
const prompt = registry.getSystemPromptAdditions();
|
||||
|
||||
expect(prompt).toContain('## Skill: enabled');
|
||||
expect(prompt).not.toContain('## Skill: disabled');
|
||||
expect(prompt).not.toContain('Disabled content.');
|
||||
});
|
||||
|
||||
it('getSkillNames returns names of available skills only', () => {
|
||||
// Positive: unavailable skills should not appear in the name list.
|
||||
const registry = new SkillRegistry();
|
||||
registry.register(makeSkill({ name: 'active', available: true }));
|
||||
registry.register(makeSkill({ name: 'dormant', available: false }));
|
||||
|
||||
const names = registry.getSkillNames();
|
||||
|
||||
expect(names).toContain('active');
|
||||
expect(names).not.toContain('dormant');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Skill } from './types.js';
|
||||
|
||||
/**
|
||||
* SkillRegistry holds loaded skills and generates system prompt additions.
|
||||
*
|
||||
* Skills are keyed by name. Managed/workspace skills may override bundled
|
||||
* skills by registering with the same name.
|
||||
*/
|
||||
export class SkillRegistry {
|
||||
private skills: Map<string, Skill> = new Map();
|
||||
|
||||
/** Register a skill. Replaces any existing skill with the same name. */
|
||||
register(skill: Skill): void {
|
||||
this.skills.set(skill.manifest.name, skill);
|
||||
console.log(
|
||||
`Skill '${skill.manifest.name}' registered (${skill.manifest.tier}, ${skill.available ? 'available' : 'unavailable'})`
|
||||
);
|
||||
}
|
||||
|
||||
/** Unregister a skill by name. Returns true if the skill existed. */
|
||||
unregister(name: string): boolean {
|
||||
return this.skills.delete(name);
|
||||
}
|
||||
|
||||
/** Look up a skill by name. */
|
||||
get(name: string): Skill | undefined {
|
||||
return this.skills.get(name);
|
||||
}
|
||||
|
||||
/** Return all registered skills. */
|
||||
list(): Skill[] {
|
||||
return Array.from(this.skills.values());
|
||||
}
|
||||
|
||||
/** Return only skills whose requirements are met (available === true). */
|
||||
listAvailable(): Skill[] {
|
||||
return this.list().filter(skill => skill.available);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate system prompt additions from all available skills.
|
||||
*
|
||||
* Each skill's SKILL.md content is formatted as a markdown section:
|
||||
* ```
|
||||
* ## Skill: <name>
|
||||
* <instructions>
|
||||
* ```
|
||||
*
|
||||
* Returns an empty string if no skills are available.
|
||||
*/
|
||||
getSystemPromptAdditions(): string {
|
||||
const available = this.listAvailable();
|
||||
if (available.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return available
|
||||
.map(skill => `## Skill: ${skill.manifest.name}\n${skill.instructions}`)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/** Return the names of all available skills. */
|
||||
getSkillNames(): string[] {
|
||||
return this.listAvailable().map(skill => skill.manifest.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Skills system type definitions.
|
||||
*
|
||||
* Skills are modular capability packages that extend Flynn's abilities.
|
||||
* Each skill lives in its own directory with a manifest.json and SKILL.md.
|
||||
*/
|
||||
|
||||
/** Three tiers of skills: shipped with Flynn, installed via CLI, or user-created. */
|
||||
export type SkillTier = 'bundled' | 'managed' | 'workspace';
|
||||
|
||||
/** Requirements that must be met for a skill to be available. */
|
||||
export interface SkillRequirements {
|
||||
/** OS platforms the skill supports (e.g., ['linux', 'darwin']). Empty = all. */
|
||||
os?: string[];
|
||||
/** Binaries that must exist in PATH (e.g., ['git', 'docker']). */
|
||||
binaries?: string[];
|
||||
/** Environment variables that must be set (e.g., ['GITHUB_TOKEN']). */
|
||||
env?: string[];
|
||||
}
|
||||
|
||||
/** Manifest for a skill (manifest.json). */
|
||||
export interface SkillManifest {
|
||||
/** Unique skill name (e.g., 'git', 'web-search'). */
|
||||
name: string;
|
||||
/** Human-readable description. */
|
||||
description: string;
|
||||
/** Semantic version string. */
|
||||
version: string;
|
||||
/** Author name or identifier. */
|
||||
author?: string;
|
||||
/** Skill tier. */
|
||||
tier: SkillTier;
|
||||
/** System requirements for availability gating. */
|
||||
requirements?: SkillRequirements;
|
||||
/** Tool names this skill provides (informational — not enforced). */
|
||||
tools?: string[];
|
||||
/** npm/system dependencies needed. */
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
/** A loaded skill ready for use. */
|
||||
export interface Skill {
|
||||
/** The parsed manifest. */
|
||||
manifest: SkillManifest;
|
||||
/** Content of SKILL.md — injected into the system prompt. */
|
||||
instructions: string;
|
||||
/** Absolute path to the skill directory. */
|
||||
directory: string;
|
||||
/** Whether the skill's requirements are met on this system. */
|
||||
available: boolean;
|
||||
/** If not available, the reason(s) why. */
|
||||
unavailableReasons?: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user