From 7c41ffad71c87782e08e8cfe4886e3e6d1c47c17 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 20:20:03 -0800 Subject: [PATCH] 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) --- src/config/schema.ts | 10 + src/daemon/index.ts | 33 +++- src/skills/index.ts | 4 + src/skills/installer.test.ts | 223 +++++++++++++++++++++ src/skills/installer.ts | 110 +++++++++++ src/skills/loader.test.ts | 369 +++++++++++++++++++++++++++++++++++ src/skills/loader.ts | 199 +++++++++++++++++++ src/skills/registry.test.ts | 156 +++++++++++++++ src/skills/registry.ts | 66 +++++++ src/skills/types.ts | 53 +++++ 10 files changed, 1221 insertions(+), 2 deletions(-) create mode 100644 src/skills/index.ts create mode 100644 src/skills/installer.test.ts create mode 100644 src/skills/installer.ts create mode 100644 src/skills/loader.test.ts create mode 100644 src/skills/loader.ts create mode 100644 src/skills/registry.test.ts create mode 100644 src/skills/registry.ts create mode 100644 src/skills/types.ts diff --git a/src/config/schema.ts b/src/config/schema.ts index 081d1d6..858750e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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: [] }), }); diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 3b2a7e1..5573ad6 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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 { 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 { gateway, channelRegistry, mcpManager, + skillRegistry, + skillInstaller, }; } diff --git a/src/skills/index.ts b/src/skills/index.ts new file mode 100644 index 0000000..4321930 --- /dev/null +++ b/src/skills/index.ts @@ -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'; diff --git a/src/skills/installer.test.ts b/src/skills/installer.test.ts new file mode 100644 index 0000000..6f2d320 --- /dev/null +++ b/src/skills/installer.test.ts @@ -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; 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); + }); +}); diff --git a/src/skills/installer.ts b/src/skills/installer.ts new file mode 100644 index 0000000..ad5e353 --- /dev/null +++ b/src/skills/installer.ts @@ -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')); + } +} diff --git a/src/skills/loader.test.ts b/src/skills/loader.test.ts new file mode 100644 index 0000000..8432d14 --- /dev/null +++ b/src/skills/loader.test.ts @@ -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'); + }); +}); diff --git a/src/skills/loader.ts b/src/skills/loader.ts new file mode 100644 index 0000000..46f03e1 --- /dev/null +++ b/src/skills/loader.ts @@ -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; +} diff --git a/src/skills/registry.test.ts b/src/skills/registry.test.ts new file mode 100644 index 0000000..e2b9f9b --- /dev/null +++ b/src/skills/registry.test.ts @@ -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 & { 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: " 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'); + }); +}); diff --git a/src/skills/registry.ts b/src/skills/registry.ts new file mode 100644 index 0000000..47c760f --- /dev/null +++ b/src/skills/registry.ts @@ -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 = 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: + * + * ``` + * + * 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); + } +} diff --git a/src/skills/types.ts b/src/skills/types.ts new file mode 100644 index 0000000..78f1ff8 --- /dev/null +++ b/src/skills/types.ts @@ -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[]; +}