From 22ab1da17bf9848d83b9fc4958873c99e06fa73b Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 1 Apr 2026 01:11:25 +0200 Subject: [PATCH] feat(09-03): implement skillRegistryService with install, uninstall, rollback, list - install() copies cached files to agent .claude/skills// dir - install() returns pending_plugin_install for skills with file kind=plugin - uninstall() soft-deletes via removed_at timestamp - rollback() restores prior version from cache and updates active_version_id - list() filters soft-deleted by default; includeRemoved=true returns all - fetchAll() delegates to fetchAllSources for multi-source refresh --- server/src/services/skill-registry.ts | 145 ++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 server/src/services/skill-registry.ts diff --git a/server/src/services/skill-registry.ts b/server/src/services/skill-registry.ts new file mode 100644 index 00000000..6309f1aa --- /dev/null +++ b/server/src/services/skill-registry.ts @@ -0,0 +1,145 @@ +import { eq, isNull, and, desc } from "drizzle-orm"; +import { cp, mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { getSkillRegistryDb } from "./skill-registry-db.js"; +import { skills, skillVersions, skillFiles } from "./skill-registry-schema.js"; +import { fetchAllSources, type SkillSourceConfig } from "./skill-registry-fetcher.js"; +import { resolveSkillCacheDir } from "../home-paths.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type SkillRow = typeof skills.$inferSelect; +type VersionRow = typeof skillVersions.$inferSelect; +type SkillListItem = SkillRow; + +type InstallResult = + | { type: "installed"; skillId: string; versionId: string; targetDir: string } + | { type: "pending_plugin_install"; command: string; skillId: string; versionId: string }; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Skill registry service factory. + * Manages its own libSQL database (does not accept a Postgres db param). + * Use `getSkillRegistryDb()` for all persistence. + */ +export function skillRegistryService() { + return { + async list(opts?: { includeRemoved?: boolean }): Promise { + const db = await getSkillRegistryDb(); + if (opts?.includeRemoved) { + return db.select().from(skills); + } + return db.select().from(skills).where(isNull(skills.removedAt)); + }, + + async getById(skillId: string, opts?: { includeRemoved?: boolean }): Promise { + const db = await getSkillRegistryDb(); + const conditions: Parameters[0][] = [eq(skills.id, skillId)]; + if (!opts?.includeRemoved) conditions.push(isNull(skills.removedAt)); + const rows = await db.select().from(skills).where(and(...conditions)); + return rows[0]; + }, + + async getVersions(skillId: string): Promise { + const db = await getSkillRegistryDb(); + return db.select().from(skillVersions).where(eq(skillVersions.skillId, skillId)); + }, + + async install(skillId: string, agentSkillsDir: string): Promise { + const db = await getSkillRegistryDb(); + const skill = await this.getById(skillId); + if (!skill) throw new Error(`Skill not found: ${skillId}`); + + // Get latest version (most recently fetched) + const versions = await db + .select() + .from(skillVersions) + .where(eq(skillVersions.skillId, skillId)) + .orderBy(desc(skillVersions.fetchedAt)); + const latest = versions[0]; + if (!latest) throw new Error(`No versions found for skill: ${skillId}`); + + // Check if this is a marketplace plugin — identified by any file having kind="plugin" + const files = await db + .select() + .from(skillFiles) + .where(eq(skillFiles.versionId, latest.id)); + const isPlugin = files.some((f) => f.kind === "plugin"); + + if (isPlugin) { + // Return pending plugin install command instead of copying files + const slug = skillId.split("/").pop() ?? skillId; + return { + type: "pending_plugin_install" as const, + command: `/plugin install ${slug}@marketplace`, + skillId, + versionId: latest.id, + }; + } + + // Copy cached files to agent skills dir + const cacheDir = latest.cacheDir ?? resolveSkillCacheDir(skillId, latest.id); + const slug = skillId.split("/").pop() ?? skillId; + const targetDir = path.join(agentSkillsDir, slug); + await mkdir(targetDir, { recursive: true }); + await cp(cacheDir, targetDir, { recursive: true }); + + // Update active version + await db + .update(skills) + .set({ activeVersionId: latest.id, updatedAt: Date.now() }) + .where(eq(skills.id, skillId)); + + return { + type: "installed" as const, + skillId, + versionId: latest.id, + targetDir, + }; + }, + + async uninstall(skillId: string): Promise { + const db = await getSkillRegistryDb(); + await db + .update(skills) + .set({ removedAt: Date.now(), updatedAt: Date.now() }) + .where(eq(skills.id, skillId)); + }, + + async rollback(skillId: string, versionId: string, agentSkillsDir: string): Promise { + const db = await getSkillRegistryDb(); + const versionRows = await db + .select() + .from(skillVersions) + .where(eq(skillVersions.id, versionId)); + const version = versionRows[0]; + if (!version) throw new Error(`Version not found: ${versionId}`); + + const cacheDir = version.cacheDir ?? resolveSkillCacheDir(skillId, versionId); + const slug = skillId.split("/").pop() ?? skillId; + const targetDir = path.join(agentSkillsDir, slug); + + // Remove current files, restore from cache + await rm(targetDir, { recursive: true, force: true }); + await mkdir(targetDir, { recursive: true }); + await cp(cacheDir, targetDir, { recursive: true }); + + // Update active version to the rolled-back version + await db + .update(skills) + .set({ activeVersionId: versionId, updatedAt: Date.now() }) + .where(eq(skills.id, skillId)); + }, + + async fetchAll( + sources?: SkillSourceConfig[], + ): Promise<{ fetched: number; errors: string[] }> { + return fetchAllSources(sources); + }, + }; +}