/** * 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; }