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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user