import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { getMozartDir } from "./paths.ts"; const SKILLS_SH_API = "https://skills.sh/api"; export interface SkillSearchResult { id: string; skillId: string; name: string; installs: number; source: string; description?: string; } export interface Skill { name: string; description: string; content: string; ref: string; localPath: string; } const REGISTRY_REF_RE = /^([^/]+\/[^@]+)@(.+)$/; export function parseRegistryRef(ref: string): { owner: string; repo: string; skillName: string } | null { const match = ref.match(REGISTRY_REF_RE); if (!match) return null; const [ownerRepo, skillName] = [match[1]!, match[2]!]; const slashIdx = ownerRepo.indexOf("/"); return { owner: ownerRepo.slice(0, slashIdx), repo: ownerRepo.slice(slashIdx + 1), skillName, }; } export function isValidRegistryRef(ref: string): boolean { return REGISTRY_REF_RE.test(ref); } /** * Resolve a local skill (SKILL local:) by searching for skills//SKILL.md * in the project directory structure, then copying it to the agent's skills dir. * * Search order: * 1. dirname(soulSourcePath)/skills// * 2. dirname(dirname(soulSourcePath))/skills// * 3. getMozartDir()/skills// (global cache, for spawned agents) * * On success, also populates the global cache so spawned agents can find it. */ export function resolveLocalSkill(name: string, soulSourcePath: string, agentSkillsDirPath: string): Skill { const candidates: string[] = []; let dir = dirname(soulSourcePath); for (let i = 0; i < 5; i++) { candidates.push(join(dir, "skills", name, "SKILL.md")); const parent = dirname(dir); if (parent === dir) break; dir = parent; } candidates.push(join(getMozartDir(), "skills", name, "SKILL.md")); let foundPath: string | null = null; for (const candidate of candidates) { if (existsSync(candidate)) { foundPath = candidate; break; } } if (!foundPath) { throw new Error(`Local skill '${name}' not found. Searched:\n${candidates.map((c) => ` - ${c}`).join("\n")}`); } const destDir = join(agentSkillsDirPath, name); const destPath = join(destDir, "SKILL.md"); mkdirSync(destDir, { recursive: true }); copyFileSync(foundPath, destPath); const globalDir = join(getMozartDir(), "skills", name); const globalPath = join(globalDir, "SKILL.md"); if (!existsSync(globalPath)) { mkdirSync(globalDir, { recursive: true }); copyFileSync(foundPath, globalPath); } const content = readFileSync(destPath, "utf-8"); const { description } = parseSkillMd(content, name); return { name, description, content, ref: `local:${name}`, localPath: destPath }; } function parseSkillMd(content: string, dirName: string): { name: string; description: string } { let description = ""; const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); if (frontmatterMatch) { const descMatch = frontmatterMatch[1]!.match(/^description:\s*(.+)$/m); if (descMatch) description = descMatch[1]!.trim().replace(/^["']|["']$/g, ""); } if (!description) { const lines = content.split("\n"); let pastHeader = false; for (const line of lines) { if (line.startsWith("# ")) { pastHeader = true; continue; } if (pastHeader && line.trim() && !line.startsWith("#")) { description = line.trim(); break; } } } return { name: dirName, description: description || "No description available", }; } export class SkillsManager { private cache = new Map(); private descCache = new Map(); private skillsDir: string; constructor(baseDir: string) { this.skillsDir = baseDir; mkdirSync(this.skillsDir, { recursive: true }); } async resolve(ref: string): Promise { const cached = this.cache.get(ref); if (cached) return cached; const parsed = parseRegistryRef(ref); if (!parsed) throw new Error(`Invalid skill reference '${ref}'. Must be owner/repo@skill-name format.`); const localDir = join(this.skillsDir, parsed.skillName); const localPath = join(localDir, "SKILL.md"); if (existsSync(localPath)) { const skill = this.loadFromDisk(ref, localPath, parsed.skillName); this.cache.set(ref, skill); return skill; } await this.fetchFromGitHub(parsed.owner, parsed.repo, parsed.skillName, localDir); if (!existsSync(localPath)) { throw new Error(`Failed to fetch skill '${ref}': SKILL.md not found after download.`); } const skill = this.loadFromDisk(ref, localPath, parsed.skillName); this.cache.set(ref, skill); return skill; } async resolveAll(refs: string[]): Promise { return Promise.all(refs.map((ref) => this.resolve(ref))); } private loadFromDisk(ref: string, localPath: string, fallbackName: string): Skill { const content = readFileSync(localPath, "utf-8"); const { name, description } = parseSkillMd(content, fallbackName); return { name, description, content, ref, localPath }; } private async fetchFromGitHub(owner: string, repo: string, skillName: string, localDir: string): Promise { mkdirSync(localDir, { recursive: true }); const branches = ["main", "master"]; const commonPaths = [`skills/${skillName}/SKILL.md`, `${skillName}/SKILL.md`]; for (const branch of branches) { for (const path of commonPaths) { const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`; try { const resp = await fetch(url); if (resp.ok) { await Bun.write(join(localDir, "SKILL.md"), await resp.text()); return; } } catch {} } } // Fallback: search the repo tree for {skillName}/SKILL.md at any depth const needle = `${skillName}/SKILL.md`; for (const branch of branches) { try { const treeResp = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`, { redirect: "follow", }); if (!treeResp.ok) continue; const tree = (await treeResp.json()) as { tree?: { path: string }[] }; const match = tree.tree?.find((f) => f.path.endsWith(needle)); if (!match) continue; const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${match.path}`; const resp = await fetch(rawUrl); if (resp.ok) { await Bun.write(join(localDir, "SKILL.md"), await resp.text()); return; } } catch {} } throw new Error(`Could not fetch skill from GitHub: ${owner}/${repo}@${skillName}`); } listInstalled(): Skill[] { const skills: Skill[] = []; if (!existsSync(this.skillsDir)) return skills; for (const entry of readdirSync(this.skillsDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const skillPath = join(this.skillsDir, entry.name, "SKILL.md"); if (!existsSync(skillPath)) continue; const content = readFileSync(skillPath, "utf-8"); const { name, description } = parseSkillMd(content, entry.name); skills.push({ name, description, content, ref: "", localPath: skillPath, }); } return skills; } saveSkill(dirName: string, content: string): Skill { const localDir = join(this.skillsDir, dirName); mkdirSync(localDir, { recursive: true }); const localPath = join(localDir, "SKILL.md"); writeFileSync(localPath, content); const { name, description } = parseSkillMd(content, dirName); const skill: Skill = { name, description, content, ref: "", localPath }; this.cache.set(dirName, skill); return skill; } removeInstalled(skillName: string): boolean { const localDir = join(this.skillsDir, skillName); if (!existsSync(localDir)) return false; rmSync(localDir, { recursive: true, force: true }); for (const [key, skill] of this.cache) { if (skill.localPath.startsWith(localDir)) { this.cache.delete(key); } } return true; } async searchStructured(query: string): Promise { const url = `${SKILLS_SH_API}/search?q=${encodeURIComponent(query)}`; const resp = await fetch(url); if (!resp.ok) throw new Error(`skills.sh search failed (HTTP ${resp.status})`); const data = (await resp.json()) as { skills?: SkillSearchResult[] }; return data.skills ?? []; } private async fetchSkillDescription(source: string, skillId: string): Promise { const cacheKey = `${source}@${skillId}`; const cached = this.descCache.get(cacheKey); if (cached !== undefined) return cached; const localPath = join(this.skillsDir, skillId, "SKILL.md"); if (existsSync(localPath)) { const desc = parseSkillMd(readFileSync(localPath, "utf-8"), skillId).description; this.descCache.set(cacheKey, desc); return desc; } const slashIdx = source.indexOf("/"); if (slashIdx === -1) return ""; const owner = source.slice(0, slashIdx); const repo = source.slice(slashIdx + 1); for (const branch of ["main", "master"]) { for (const path of [`skills/${skillId}/SKILL.md`, `${skillId}/SKILL.md`]) { try { const resp = await fetch(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`); if (resp.ok) { const desc = parseSkillMd(await resp.text(), skillId).description; this.descCache.set(cacheKey, desc); return desc; } } catch {} } } this.descCache.set(cacheKey, ""); return ""; } async searchEnriched(query: string, limit = 10): Promise { const results = await this.searchStructured(query); if (results.length === 0) return []; const top = results.slice(0, limit); const descResults = await Promise.allSettled( top.map((s) => Promise.race([ this.fetchSkillDescription(s.source, s.skillId), new Promise((resolve) => setTimeout(() => resolve(""), 5000)), ]), ), ); return top.map((s, i) => ({ ...s, description: descResults[i]?.status === "fulfilled" && descResults[i].value ? descResults[i].value : undefined, })); } async search(query: string): Promise { const results = await this.searchEnriched(query); if (results.length === 0) return "No results found."; return results .map((s) => { const desc = s.description ? ` — ${s.description}` : ""; return `- ${s.source}@${s.skillId} (${s.installs.toLocaleString()} installs)${desc}`; }) .join("\n"); } }