/** * Skill registry — discovers and catalogs available skills from the host * and manages user-created skills. * * Built-in skills: Discovered via pi SDK loadSkills(). Workspaces reference * skills by name from this pool. * * Package skills: Discovered from pi packages installed via `pi install` * (reads ~/.pi/agent/settings.json packages + skills arrays via the SDK's * SettingsManager + DefaultPackageManager). * * User skills: Stored in ~/.config/oppi/skills// with a * SKILL.md file. Saved from session workspaces via REST API. Merged * into the registry alongside built-ins (user skills can shadow built-ins). */ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch, type FSWatcher, } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { EventEmitter } from "node:events"; import { DefaultPackageManager, loadSkills, SettingsManager, type Skill, } from "@mariozechner/pi-coding-agent"; // ─── Types ─── export interface SkillInfo { /** Skill name (directory name, e.g. "searxng"). */ name: string; /** Human-readable description from SKILL.md frontmatter. */ description: string; /** Host filesystem path to the skill directory. */ path: string; } /** Extended skill info with SKILL.md content and file tree. */ export interface SkillDetail { skill: SkillInfo; /** Raw SKILL.md content. */ content: string; /** Relative file paths in the skill directory (excludes junk like __pycache__). */ files: string[]; } /** Map SDK Skill to our SkillInfo. */ function sdkSkillToInfo(skill: Skill): SkillInfo { return { name: skill.name, description: skill.description, path: skill.baseDir, }; } // ─── Skill Registry ─── /** Emitted when the skill catalog changes after a re-scan. */ export interface SkillsChangedEvent { added: string[]; removed: string[]; modified: string[]; } export class SkillRegistry extends EventEmitter { private skills: Map = new Map(); private scanDirs: string[]; /** Skill paths resolved from pi settings packages + skills arrays. */ private packageSkillPaths: string[] = []; private packageSkillsResolved = false; private watchers: FSWatcher[] = []; private debounceTimer: ReturnType | null = null; private debounceMs: number; constructor(extraDirs?: string[], opts?: { debounceMs?: number }) { super(); this.scanDirs = [join(homedir(), ".pi", "agent", "skills"), ...(extraDirs || [])]; this.debounceMs = opts?.debounceMs ?? 500; } /** * Resolve skill paths from pi settings (packages + skills arrays). * * Uses the SDK's SettingsManager + DefaultPackageManager to discover * skills from `pi install`-ed packages and settings-declared skill * directories — the same logic pi uses at startup. * * Call once before scan(). Skipped packages that aren't installed * yet (no auto-install in server context). */ async resolvePackageSkills(): Promise { if (this.packageSkillsResolved) return; this.packageSkillsResolved = true; try { const agentDir = join(homedir(), ".pi", "agent"); const settingsManager = SettingsManager.create(process.cwd(), agentDir); const packageManager = new DefaultPackageManager({ cwd: process.cwd(), agentDir, settingsManager, }); // Skip missing packages — don't auto-install in server context const resolved = await packageManager.resolve(async () => "skip"); // Collect enabled skill paths, excluding those already under scanDirs // (those are loaded by scan() directly to avoid double-loading). const paths = resolved.skills .filter((r) => r.enabled) .map((r) => r.path) .filter((p) => !this.scanDirs.some((dir) => p.startsWith(dir + "/"))); if (paths.length > 0) { this.packageSkillPaths = paths; console.log("[skills] Resolved skill paths from pi settings/packages", { count: paths.length, }); } } catch (err) { const message = err instanceof Error ? err.message : String(err); console.warn(`[skills] Failed to resolve package skills: ${message}`); } } /** * Scan host skill directories and build the registry. * Delegates discovery to the pi SDK loadSkills(), then maps results * to our SkillInfo shape. Idempotent — safe to call anytime. * Emits "skills:changed" if the catalog changed since the last scan. */ scan(): SkillsChangedEvent { const prevNames = new Set(this.skills.keys()); const prevDescriptions = new Map( Array.from(this.skills.entries()).map(([k, v]) => [k, v.description]), ); this.skills.clear(); // Use SDK loadSkills for each scan directory for (const dir of this.scanDirs) { if (!existsSync(dir)) continue; try { const result = loadSkills({ skillPaths: [dir], includeDefaults: false, }); for (const skill of result.skills) { // First dir wins on name collision (same as before) if (this.skills.has(skill.name)) continue; this.skills.set(skill.name, sdkSkillToInfo(skill)); } } catch (err) { const message = err instanceof Error ? err.message : String(err); console.warn("[skills] Failed to scan skill directory", { dir, message, }); } } // Load skills resolved from pi packages/settings. // These are individual file paths (SKILL.md or top-level .md) from // DefaultPackageManager.resolve(). Feed them to loadSkills which // handles both files and directories, validates frontmatter, etc. if (this.packageSkillPaths.length > 0) { try { const result = loadSkills({ skillPaths: this.packageSkillPaths, includeDefaults: false, }); for (const skill of result.skills) { if (this.skills.has(skill.name)) continue; this.skills.set(skill.name, sdkSkillToInfo(skill)); } } catch (err) { const message = err instanceof Error ? err.message : String(err); console.warn("[skills] Failed to load package skills", { message, }); } } // Compute diff const currentNames = new Set(this.skills.keys()); const added = [...currentNames].filter((n) => !prevNames.has(n)); const removed = [...prevNames].filter((n) => !currentNames.has(n)); const modified = [...currentNames].filter((n) => { if (!prevNames.has(n)) return false; // new, not modified return prevDescriptions.get(n) !== this.skills.get(n)?.description; }); const event: SkillsChangedEvent = { added, removed, modified }; if (added.length || removed.length || modified.length) { console.log("[skills] Catalog changed", { added: added.length, removed: removed.length, modified: modified.length, total: this.skills.size, }); this.emit("skills:changed", event); } return event; } /** * Start watching skill directories for changes. * Debounces rapid changes and re-scans automatically. */ watch(): void { this.stopWatching(); for (const dir of this.scanDirs) { if (!existsSync(dir)) continue; try { const watcher = watch(dir, { recursive: true }, () => { this.debouncedRescan(); }); this.watchers.push(watcher); } catch (err) { console.warn("[skills] Could not watch skill directory", { dir, error: String(err), }); } } if (this.watchers.length > 0) { console.log("[skills] Watching skill directories for changes", { watcherCount: this.watchers.length, }); } } /** Stop all file watchers. */ stopWatching(): void { for (const w of this.watchers) { try { w.close(); } catch { /* ignore */ } } this.watchers = []; if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } } private debouncedRescan(): void { if (this.debounceTimer) { clearTimeout(this.debounceTimer); } this.debounceTimer = setTimeout(() => { this.debounceTimer = null; this.scan(); }, this.debounceMs); } /** Get all available skills. */ list(): SkillInfo[] { return Array.from(this.skills.values()); } /** Get a single skill by name. */ get(name: string): SkillInfo | undefined { return this.skills.get(name); } /** Get the host path for a skill. */ getPath(name: string): string | undefined { return this.skills.get(name)?.path; } /** * Register user skills into the registry so they're discoverable * by name (used by workspace skill lists). * * User skills can shadow built-in skills (user skill takes precedence). */ registerUserSkills(skills: UserSkill[]): void { for (const us of skills) { const skillMd = join(us.path, "SKILL.md"); if (!existsSync(skillMd)) continue; try { const result = loadSkills({ skillPaths: [us.path], includeDefaults: false, }); for (const skill of result.skills) { this.skills.set(skill.name, sdkSkillToInfo(skill)); } } catch { // Skip malformed user skills } } } /** Get full skill detail: metadata + SKILL.md content + file tree. */ getDetail(name: string): SkillDetail | undefined { const skill = this.skills.get(name); if (!skill) return undefined; const skillMdPath = join(skill.path, "SKILL.md"); const content = existsSync(skillMdPath) ? readFileSync(skillMdPath, "utf-8") : ""; const files = this.listFiles(skill.path); return { skill, content, files }; } /** Read a file from a skill's directory. Returns content or undefined if not found/outside boundary. */ getFileContent(name: string, relPath: string): string | undefined { const skill = this.skills.get(name); if (!skill) return undefined; // Guard against path traversal const target = join(skill.path, relPath); let resolved: string; try { resolved = realpathSync(target); } catch { return undefined; } let realBase: string; try { realBase = realpathSync(skill.path); } catch { return undefined; } if (!resolved.startsWith(realBase + "/") && resolved !== realBase) { return undefined; } try { const stat = statSync(resolved); if (!stat.isFile()) return undefined; // 1MB safety limit if (stat.size > 1024 * 1024) return undefined; return readFileSync(resolved, "utf-8"); } catch { return undefined; } } // ─── Internal ─── /** Recursively list files in a skill directory (relative paths). */ private listFiles(baseDir: string): string[] { return listFilesRecursive(baseDir); } } // ─── User Skills ─── /** User-created skill metadata. */ export interface UserSkill { name: string; description: string; builtIn: false; createdAt: number; // epoch ms /** Total size in bytes (all files). */ sizeBytes: number; /** Host path to the skill directory. */ path: string; } /** Validation constraints. */ const SKILL_NAME_RE = /^[a-z][a-z0-9-]{0,63}$/; const MAX_SKILL_SIZE = 100 * 1024; // 100KB total const MAX_SKILL_FILES = 50; export class SkillValidationError extends Error { constructor( message: string, public readonly code: string, ) { super(message); this.name = "SkillValidationError"; } } /** * Manages user-created skills on disk. * * Storage layout: * // * SKILL.md * scripts/... * * Default baseDir: ~/.config/oppi/skills/ */ export class UserSkillStore { private baseDir: string; constructor(baseDir?: string) { this.baseDir = baseDir ?? join(homedir(), ".config", "oppi", "skills"); } /** Ensure the base directory exists. */ init(): void { if (!existsSync(this.baseDir)) { mkdirSync(this.baseDir, { recursive: true, mode: 0o700 }); } } /** List all user skills for a given user. */ listSkills(): UserSkill[] { const userDir = this.baseDir; if (!existsSync(userDir)) return []; const results: UserSkill[] = []; for (const entry of readdirSync(userDir)) { const skillDir = join(userDir, entry); try { if (!statSync(skillDir).isDirectory()) continue; } catch { continue; } const skill = this.readSkill(entry, skillDir); if (skill) results.push(skill); } return results; } /** Get a single user skill by name. Returns null if not found. */ getSkill(name: string): UserSkill | null { const skillDir = join(this.baseDir, name); if (!existsSync(skillDir)) return null; return this.readSkill(name, skillDir); } /** Get the host path for a user skill (for syncing into containers). */ getPath(name: string): string | null { const dir = join(this.baseDir, name); return existsSync(dir) ? dir : null; } /** * Save a skill from a source directory (typically a session workspace). * * Validates: * - Name format (lowercase, hyphens, 1-64 chars) * - SKILL.md exists in source * - Total size under 100KB * - Max 50 files * * @param name - Skill name (must match SKILL_NAME_RE) * @param sourceDir - Directory to copy from (must contain SKILL.md) * @returns The saved skill */ saveSkill(name: string, sourceDir: string): UserSkill { // Validate name if (!SKILL_NAME_RE.test(name)) { throw new SkillValidationError( `Invalid skill name "${name}" — must match ${SKILL_NAME_RE}`, "INVALID_NAME", ); } // Validate source exists if (!existsSync(sourceDir)) { throw new SkillValidationError( `Source directory not found: ${sourceDir}`, "SOURCE_NOT_FOUND", ); } // Validate SKILL.md exists const skillMd = join(sourceDir, "SKILL.md"); if (!existsSync(skillMd)) { throw new SkillValidationError(`SKILL.md not found in source directory`, "NO_SKILL_MD"); } // Validate size + file count const { totalSize, fileCount } = this.measureDir(sourceDir); if (totalSize > MAX_SKILL_SIZE) { throw new SkillValidationError( `Skill too large: ${(totalSize / 1024).toFixed(1)}KB (max ${MAX_SKILL_SIZE / 1024}KB)`, "TOO_LARGE", ); } if (fileCount > MAX_SKILL_FILES) { throw new SkillValidationError( `Too many files: ${fileCount} (max ${MAX_SKILL_FILES})`, "TOO_MANY_FILES", ); } // Ensure user dir exists const userDir = this.baseDir; if (!existsSync(userDir)) { mkdirSync(userDir, { recursive: true, mode: 0o700 }); } // Copy source → destination (overwrite if exists) const destDir = join(userDir, name); if (existsSync(destDir)) { rmSync(destDir, { recursive: true }); } cpSync(sourceDir, destDir, { recursive: true, dereference: true }); const skill = this.readSkill(name, destDir); if (!skill) { throw new SkillValidationError( `Failed to read saved skill — SKILL.md may be malformed`, "READ_FAILED", ); } console.log("[skills] Saved user skill for owner", { name, sizeKb: (totalSize / 1024).toFixed(1), fileCount, }); return skill; } /** Delete a user skill. Returns true if it existed. */ deleteSkill(name: string): boolean { const skillDir = join(this.baseDir, name); if (!existsSync(skillDir)) return false; rmSync(skillDir, { recursive: true }); console.log("[skills] Deleted user skill for owner", { name, }); return true; } /** List files in a user skill (relative paths). */ listFiles(name: string): string[] { const skillDir = join(this.baseDir, name); if (!existsSync(skillDir)) return []; return listFilesRecursive(skillDir); } /** Read a file from a user skill. Path-safe. */ readFile(name: string, relPath: string): string | undefined { const skillDir = join(this.baseDir, name); if (!existsSync(skillDir)) return undefined; const target = join(skillDir, relPath); let resolved: string; try { resolved = realpathSync(target); } catch { return undefined; } let realBase: string; try { realBase = realpathSync(skillDir); } catch { return undefined; } if (!resolved.startsWith(realBase + "/") && resolved !== realBase) { return undefined; // path traversal blocked } try { const stat = statSync(resolved); if (!stat.isFile()) return undefined; if (stat.size > 1024 * 1024) return undefined; // 1MB safety return readFileSync(resolved, "utf-8"); } catch { return undefined; } } // ─── Internal ─── private readSkill(name: string, dir: string): UserSkill | null { const skillMd = join(dir, "SKILL.md"); if (!existsSync(skillMd)) return null; const content = readFileSync(skillMd, "utf-8"); const description = extractDescription(content); if (!description) return null; const { totalSize } = this.measureDir(dir); const dirStat = statSync(dir); return { name, description, builtIn: false as const, createdAt: dirStat.mtimeMs, sizeBytes: totalSize, path: dir, }; } private measureDir(dir: string): { totalSize: number; fileCount: number } { let totalSize = 0; let fileCount = 0; const walk = (d: string): void => { let entries: string[]; try { entries = readdirSync(d); } catch { return; } for (const entry of entries) { const full = join(d, entry); try { const stat = statSync(full); if (stat.isDirectory()) { walk(full); } else if (stat.isFile()) { totalSize += stat.size; fileCount++; } } catch { /* skip */ } } }; walk(dir); return { totalSize, fileCount }; } } // ─── Shared Helpers ─── /** Extract description from SKILL.md YAML frontmatter. */ function extractDescription(content: string): string { const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); if (!fmMatch) return ""; const frontmatter = fmMatch[1]; const descMatch = frontmatter.match(/^description:\s*"?([^"\n]+)"?\s*$/m); if (!descMatch) return ""; return descMatch[1].trim(); } /** Extract an arbitrary field from SKILL.md YAML frontmatter. */ export function extractFrontmatterField(content: string, field: string): string | undefined { const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); if (!fmMatch) return undefined; const re = new RegExp(`^${field}:\\s*"?([^"\\n]+)"?\\s*$`, "m"); const match = fmMatch[1].match(re); return match ? match[1].trim() : undefined; } /** Recursively list files, skipping junk directories and binary extensions. */ function listFilesRecursive(baseDir: string, prefix = ""): string[] { const SKIP_DIRS = new Set([ "__pycache__", "node_modules", ".git", ".venv", ".mypy_cache", ".pytest_cache", ".ruff_cache", "__pypackages__", ]); const SKIP_EXTS = new Set([".pyc", ".pyo", ".o", ".so", ".dylib"]); const results: string[] = []; const dir = prefix ? join(baseDir, prefix) : baseDir; let entries: string[]; try { entries = readdirSync(dir); } catch { return results; } for (const entry of entries.sort()) { const rel = prefix ? `${prefix}/${entry}` : entry; const full = join(dir, entry); try { const stat = statSync(full); if (stat.isDirectory()) { if (!SKIP_DIRS.has(entry)) { results.push(...listFilesRecursive(baseDir, rel)); } } else if (stat.isFile()) { const ext = entry.substring(entry.lastIndexOf(".")); if (!SKIP_EXTS.has(ext)) results.push(rel); } } catch { /* skip */ } } return results; }