import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises"; import type { Dirent } from "node:fs"; import { join, resolve } from "node:path"; import { homedir } from "node:os"; import type { SkillItem } from "./types.js"; import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js"; import { exists } from "./utils.js"; import { validateDescription, validateSkillName } from "./validators.js"; import { ApiError } from "./errors.js"; import { projectSkillsDir } from "./workspace-files.js"; async function findWorkspaceRoots(workspaceRoot: string): Promise { const roots: string[] = []; let current = resolve(workspaceRoot); while (true) { roots.push(current); const gitPath = join(current, ".git"); if (await exists(gitPath)) break; const parent = resolve(current, ".."); if (parent === current) break; current = parent; } return roots; } const extractTriggerFromBody = (body: string) => { const lines = body.split(/\r?\n/); let inWhenSection = false; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (/^#{1,6}\s+/.test(trimmed)) { const heading = trimmed.replace(/^#{1,6}\s+/, "").trim(); inWhenSection = /^when to use$/i.test(heading); continue; } if (!inWhenSection) continue; const cleaned = trimmed .replace(/^[-*+]\s+/, "") .replace(/^\d+[.)]\s+/, "") .trim(); if (cleaned) return cleaned; } return ""; }; async function parseSkillEntry( skillPath: string, entryName: string, scope: "project" | "global", ): Promise { const content = await readFile(skillPath, "utf8"); const { data, body } = parseFrontmatter(content); const name = typeof data.name === "string" ? data.name : entryName; const description = typeof data.description === "string" ? data.description : ""; const trigger = typeof data.trigger === "string" ? data.trigger : typeof data.when === "string" ? data.when : extractTriggerFromBody(body); try { validateSkillName(name); validateDescription(description); } catch { return null; } if (name !== entryName) return null; return { name, description, path: skillPath, scope, trigger: trigger.trim() || undefined, }; } async function listSkillsInDir(dir: string, scope: "project" | "global"): Promise { if (!(await exists(dir))) return []; const entries = await readdir(dir, { withFileTypes: true }); const items: SkillItem[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const skillPath = join(dir, entry.name, "SKILL.md"); if (await exists(skillPath)) { // Direct skill: //SKILL.md const item = await parseSkillEntry(skillPath, entry.name, scope); if (item) items.push(item); } else { // Domain/category folder: ///SKILL.md – scan one level deeper. // This supports the convention where global skills are organised as // skills///SKILL.md // in addition to the flat skills//SKILL.md layout. const domainDir = join(dir, entry.name); let subEntries: Dirent[]; try { subEntries = await readdir(domainDir, { withFileTypes: true }); } catch { continue; } for (const subEntry of subEntries) { if (!subEntry.isDirectory()) continue; const subSkillPath = join(domainDir, subEntry.name, "SKILL.md"); if (!(await exists(subSkillPath))) continue; const item = await parseSkillEntry(subSkillPath, subEntry.name, scope); if (item) items.push(item); } } } return items; } export async function listSkills(workspaceRoot: string, includeGlobal: boolean): Promise { const roots = await findWorkspaceRoots(workspaceRoot); const items: SkillItem[] = []; for (const root of roots) { const opencodeDir = join(root, ".opencode", "skills"); const claudeDir = join(root, ".claude", "skills"); items.push(...(await listSkillsInDir(opencodeDir, "project"))); items.push(...(await listSkillsInDir(claudeDir, "project"))); } if (includeGlobal) { const globalOpenWork = join(homedir(), ".config", "opencode", "skills"); const globalClaude = join(homedir(), ".claude", "skills"); const globalAgents = join(homedir(), ".agents", "skills"); const globalAgentLegacy = join(homedir(), ".agent", "skills"); items.push(...(await listSkillsInDir(globalOpenWork, "global"))); items.push(...(await listSkillsInDir(globalClaude, "global"))); items.push(...(await listSkillsInDir(globalAgents, "global"))); items.push(...(await listSkillsInDir(globalAgentLegacy, "global"))); } const seen = new Set(); return items.filter((item) => { if (seen.has(item.name)) return false; seen.add(item.name); return true; }); } export async function upsertSkill( workspaceRoot: string, payload: { name: string; content: string; description?: string }, ): Promise<{ path: string; action: "added" | "updated" }> { const name = payload.name.trim(); validateSkillName(name); if (!payload.content) { throw new ApiError(400, "invalid_skill_content", "Skill content is required"); } let content = payload.content; const { data, body } = parseFrontmatter(payload.content); if (Object.keys(data).length > 0) { const frontmatterName = typeof data.name === "string" ? data.name : ""; const frontmatterDescription = typeof data.description === "string" ? data.description : ""; if (frontmatterName && frontmatterName !== name) { throw new ApiError(400, "invalid_skill_name", "Skill frontmatter name must match payload name"); } validateDescription(frontmatterDescription || payload.description); const nextDescription = frontmatterDescription || payload.description || ""; const frontmatter = buildFrontmatter({ ...data, name, description: nextDescription, }); content = frontmatter + body.replace(/^\n/, ""); } else { validateDescription(payload.description); const frontmatter = buildFrontmatter({ name, description: payload.description }); content = frontmatter + payload.content.replace(/^\n/, ""); } const baseDir = projectSkillsDir(workspaceRoot); const skillDir = join(baseDir, name); await mkdir(skillDir, { recursive: true }); const skillPath = join(skillDir, "SKILL.md"); const existed = await exists(skillPath); await writeFile(skillPath, content.endsWith("\n") ? content : content + "\n", "utf8"); return { path: skillPath, action: existed ? "updated" : "added" }; } export async function deleteSkill(workspaceRoot: string, name: string): Promise<{ path: string }> { const trimmed = name.trim(); validateSkillName(trimmed); const baseDir = projectSkillsDir(workspaceRoot); const skillDir = join(baseDir, trimmed); const skillPath = join(skillDir, "SKILL.md"); if (!(await exists(skillPath))) { throw new ApiError(404, "skill_not_found", `Skill not found: ${trimmed}`); } await rm(skillDir, { recursive: true, force: true }); return { path: skillDir }; }