#!/usr/bin/env bun import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; const PKG_ROOT = resolve(__dirname, ".."); const ASSETS_OUT = resolve(PKG_ROOT, "assets/skills"); const SOURCE_ROOT_DEFAULT = resolve(PKG_ROOT, "../../../../suaveplan-skills/plugins"); interface SkillEntry { readonly id: string; readonly version: string; readonly dir: string; readonly files: ReadonlyArray; } function walk(dir: string, out: string[] = []): string[] { let entries: string[]; try { entries = readdirSync(dir); } catch { return out; } for (const name of entries) { if (name === "node_modules" || name === ".git") continue; const p = join(dir, name); const st = statSync(p); if (st.isDirectory()) { walk(p, out); } else { out.push(p); } } return out; } function ensureDir(path: string): void { if (!existsSync(path)) mkdirSync(path, { recursive: true }); } function copyFile(from: string, to: string): void { ensureDir(dirname(to)); const bytes = readFileSync(from); writeFileSync(to, bytes); } function discoverSkills(sourceRoot: string): SkillEntry[] { if (!existsSync(sourceRoot)) { console.warn(`[build-skills-bundle] no source at ${sourceRoot} — skipping`); return []; } const skills: SkillEntry[] = []; const plugins = readdirSync(sourceRoot); for (const pluginName of plugins) { const pluginPath = join(sourceRoot, pluginName); if (!statSync(pluginPath).isDirectory()) continue; const skillsDir = join(pluginPath, "skills"); if (!existsSync(skillsDir)) continue; for (const skillName of readdirSync(skillsDir)) { const skillSource = join(skillsDir, skillName); if (!statSync(skillSource).isDirectory()) continue; const skillManifest = join(skillSource, "SKILL.md"); if (!existsSync(skillManifest)) continue; const targetDir = join(ASSETS_OUT, skillName); ensureDir(targetDir); const allFiles = walk(skillSource); const fileList: string[] = []; for (const file of allFiles) { const rel = relative(skillSource, file); copyFile(file, join(targetDir, rel)); fileList.push(rel.split("\\").join("/")); } skills.push({ id: skillName, version: "0.1.0", dir: skillName, files: fileList.sort(), }); } } return skills.sort((a, b) => a.id.localeCompare(b.id)); } function discoverExistingSkills(): SkillEntry[] { if (!existsSync(ASSETS_OUT)) { return []; } const skills: SkillEntry[] = []; for (const skillName of readdirSync(ASSETS_OUT)) { const skillPath = join(ASSETS_OUT, skillName); if (!statSync(skillPath).isDirectory()) continue; const skillManifest = join(skillPath, "SKILL.md"); if (!existsSync(skillManifest)) continue; const allFiles = walk(skillPath); const fileList: string[] = []; for (const file of allFiles) { const rel = relative(skillPath, file); fileList.push(rel.split("\\").join("/")); } skills.push({ id: skillName, version: "0.1.0", dir: skillName, files: fileList.sort(), }); } return skills.sort((a, b) => a.id.localeCompare(b.id)); } function writeManifest(skills: ReadonlyArray): void { ensureDir(ASSETS_OUT); const manifest = { schemaVersion: 1, skills }; writeFileSync( join(ASSETS_OUT, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n` ); } function main(): void { const sourceRoot = process.env["SUAVEPLAN_SKILLS_ROOT"] ?? SOURCE_ROOT_DEFAULT; let skills = discoverSkills(sourceRoot); if (skills.length === 0) { skills = discoverExistingSkills(); if (skills.length > 0) { console.log(`[build-skills-bundle] source missing; regenerated manifest for ${skills.length} existing skills`); } } writeManifest(skills); console.log(`[build-skills-bundle] bundled ${skills.length} skills into ${ASSETS_OUT}`); } main();