import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const BUILTIN_SKILLS_DIR = join(import.meta.dir, '..', '..', 'skills'); interface SkillEntry { name: string; path: string; source: 'workspace' | 'builtin'; } interface SkillMeta { description?: string; always?: boolean; metadata?: string; // JSON string with nanobot-specific config } interface NanobotMeta { always?: boolean; description?: string; requires?: { bins?: string[]; env?: string[]; }; } export class SkillsLoader { private _workspace: string; private _workspaceSkills: string; private _builtinSkills: string; constructor(workspace: string, builtinSkillsDir?: string) { this._workspace = workspace; this._workspaceSkills = join(workspace, 'skills'); this._builtinSkills = builtinSkillsDir ?? BUILTIN_SKILLS_DIR; } listSkills(filterUnavailable = true): SkillEntry[] { const skills: SkillEntry[] = []; // Workspace skills take priority if (existsSync(this._workspaceSkills)) { for (const name of readdirSync(this._workspaceSkills)) { const skillFile = join(this._workspaceSkills, name, 'SKILL.md'); if (existsSync(skillFile)) { skills.push({ name, path: skillFile, source: 'workspace' }); } } } // Builtin skills — skip if workspace already has one with the same name if (existsSync(this._builtinSkills)) { for (const name of readdirSync(this._builtinSkills)) { const skillFile = join(this._builtinSkills, name, 'SKILL.md'); if (existsSync(skillFile) && !skills.some((s) => s.name === name)) { skills.push({ name, path: skillFile, source: 'builtin' }); } } } if (!filterUnavailable) return skills; return skills.filter((s) => this._isAvailable(this._getNanobotMeta(s.name))); } loadSkill(name: string): string | null { const workspacePath = join(this._workspaceSkills, name, 'SKILL.md'); if (existsSync(workspacePath)) return readFileSync(workspacePath, 'utf8'); const builtinPath = join(this._builtinSkills, name, 'SKILL.md'); if (existsSync(builtinPath)) return readFileSync(builtinPath, 'utf8'); return null; } loadSkillsForContext(names: string[]): string { const parts: string[] = []; for (const name of names) { const content = this.loadSkill(name); if (content) { parts.push(`### Skill: ${name}\n\n${this._stripFrontmatter(content)}`); } } return parts.join('\n\n---\n\n'); } buildSkillsSummary(): string { const all = this.listSkills(false); if (all.length === 0) return ''; const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); const lines = ['']; for (const s of all) { const meta = this._getNanobotMeta(s.name); const available = this._isAvailable(meta); const desc = esc(meta.description ?? s.name); lines.push(` `); lines.push(` ${esc(s.name)}`); lines.push(` ${desc}`); lines.push(` ${s.path}`); if (!available) { const missing = this._getMissing(meta); if (missing) lines.push(` ${esc(missing)}`); } lines.push(' '); } lines.push(''); return lines.join('\n'); } getAlwaysSkills(): string[] { return this.listSkills(true) .filter((s) => { const raw = this._getRawMeta(s.name); const nano = this._getNanobotMeta(s.name); return nano.always === true || raw?.always === true; }) .map((s) => s.name); } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- private _stripFrontmatter(content: string): string { if (!content.startsWith('---')) return content; const match = /^---\n[\s\S]*?\n---\n/.exec(content); return match ? content.slice(match[0].length).trimStart() : content; } private _getRawMeta(name: string): SkillMeta | null { const content = this.loadSkill(name); if (!content?.startsWith('---')) return null; const match = /^---\n([\s\S]*?)\n---/.exec(content); if (!match) return null; const meta: SkillMeta = {}; for (const line of match[1]!.split('\n')) { const colon = line.indexOf(':'); if (colon < 0) continue; const key = line.slice(0, colon).trim(); const val = line.slice(colon + 1).trim().replace(/^["']|["']$/g, ''); if (key === 'description') meta.description = val; if (key === 'always') meta.always = val === 'true'; if (key === 'metadata') meta.metadata = val; } return meta; } private _getNanobotMeta(name: string): NanobotMeta { const raw = this._getRawMeta(name); if (!raw?.metadata) return { description: raw?.description, always: raw?.always }; try { const parsed = JSON.parse(raw.metadata) as Record; const nano = (parsed['nanobot'] ?? parsed['openclaw'] ?? parsed) as NanobotMeta; return { description: raw.description, always: raw.always, ...nano }; } catch { return { description: raw.description, always: raw.always }; } } private _isAvailable(meta: NanobotMeta): boolean { const req = meta.requires; if (!req) return true; for (const bin of req.bins ?? []) { if (!this._which(bin)) return false; } for (const env of req.env ?? []) { if (!process.env[env]) return false; } return true; } private _getMissing(meta: NanobotMeta): string { const missing: string[] = []; const req = meta.requires; if (!req) return ''; for (const bin of req.bins ?? []) { if (!this._which(bin)) missing.push(`CLI: ${bin}`); } for (const env of req.env ?? []) { if (!process.env[env]) missing.push(`ENV: ${env}`); } return missing.join(', '); } private _which(bin: string): boolean { try { const result = Bun.spawnSync(['which', bin]); return result.exitCode === 0; } catch { return false; } } }