213 lines
5.7 KiB
TypeScript
213 lines
5.7 KiB
TypeScript
/**
|
|
* 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';
|
|
import { scanSkillDirectory } from './scanner.js';
|
|
import { auditLogger } from '../audit/index.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');
|
|
|
|
const scan = scanSkillDirectory(absDir);
|
|
auditLogger?.skillsScan({
|
|
skill_name: basename(absDir),
|
|
tier,
|
|
phase: 'load',
|
|
ok: scan.ok,
|
|
error_count: scan.issues.filter(i => i.severity === 'error').length,
|
|
warn_count: scan.issues.filter(i => i.severity === 'warn').length,
|
|
issue_codes: Array.from(new Set(scan.issues.map(i => i.code))),
|
|
});
|
|
const scanReasons = scan.issues.map((i) => `${i.code}: ${i.message}${i.path ? ` (${basename(i.path)})` : ''}`);
|
|
|
|
const inferManifest = (): SkillManifest => {
|
|
const name = basename(absDir);
|
|
const firstLine = instructions.split('\n').find((line) => line.trim().length > 0) ?? name;
|
|
const description = firstLine.replace(/^#+\s*/, '').trim();
|
|
return {
|
|
name,
|
|
description,
|
|
version: '0.0.0',
|
|
tier,
|
|
};
|
|
};
|
|
|
|
let manifest: SkillManifest = inferManifest();
|
|
|
|
if (existsSync(manifestPath)) {
|
|
// Parse manifest.json
|
|
try {
|
|
const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
|
|
if (raw && typeof raw === 'object' && raw.name && raw.description && raw.version) {
|
|
manifest = {
|
|
...(raw as SkillManifest),
|
|
tier, // Override tier from argument
|
|
};
|
|
}
|
|
} catch {
|
|
// Scanner will capture this and mark the skill unavailable.
|
|
}
|
|
} else {
|
|
manifest = inferManifest();
|
|
}
|
|
|
|
// Check system requirements
|
|
const { available, reasons } = checkRequirements(manifest.requirements);
|
|
|
|
const unavailableReasons = [...reasons];
|
|
if (!scan.ok) {
|
|
unavailableReasons.push(...scanReasons);
|
|
}
|
|
|
|
const skill: Skill = {
|
|
manifest,
|
|
instructions,
|
|
directory: absDir,
|
|
available: available && scan.ok,
|
|
};
|
|
|
|
if (!skill.available) {
|
|
skill.unavailableReasons = unavailableReasons;
|
|
}
|
|
|
|
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;
|
|
}
|