import { eq, isNull, and, desc, sql } from "drizzle-orm"; import { cp, mkdir, rm, readdir } from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { getSkillRegistryDb } from "./skill-registry-db.js"; import { skills, skillVersions, skillFiles, communityRatings, agentSkills } 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; /** Extended skill list item with community rating and usage stats from JOINs */ type SkillListItem = SkillRow & { averageRating: number | null; ratingCount: number | null; taskCount: number | null; avgCostUsd: number | null; lastUsedAt: number | null; }; 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(); const query = db .select({ // All skills columns id: skills.id, sourceId: skills.sourceId, name: skills.name, description: skills.description, sourceUrl: skills.sourceUrl, activeVersionId: skills.activeVersionId, removedAt: skills.removedAt, createdAt: skills.createdAt, updatedAt: skills.updatedAt, // Community rating fields from LEFT JOIN averageRating: communityRatings.averageRating, ratingCount: communityRatings.ratingCount, // Aggregated usage stats across all agents taskCount: sql`SUM(${agentSkills.taskCount})`, avgCostUsd: sql`AVG(${agentSkills.avgCostUsd})`, lastUsedAt: sql`MAX(${agentSkills.lastUsedAt})`, }) .from(skills) .leftJoin(communityRatings, eq(communityRatings.skillId, skills.id)) .leftJoin(agentSkills, eq(agentSkills.skillId, skills.id)) .groupBy(skills.id, communityRatings.id); if (opts?.includeRemoved) { return query as Promise; } return query.where(isNull(skills.removedAt)) as Promise; }, 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({ id: skills.id, sourceId: skills.sourceId, name: skills.name, description: skills.description, sourceUrl: skills.sourceUrl, activeVersionId: skills.activeVersionId, removedAt: skills.removedAt, createdAt: skills.createdAt, updatedAt: skills.updatedAt, averageRating: communityRatings.averageRating, ratingCount: communityRatings.ratingCount, taskCount: sql`SUM(${agentSkills.taskCount})`, avgCostUsd: sql`AVG(${agentSkills.avgCostUsd})`, lastUsedAt: sql`MAX(${agentSkills.lastUsedAt})`, }) .from(skills) .leftJoin(communityRatings, eq(communityRatings.skillId, skills.id)) .leftJoin(agentSkills, eq(agentSkills.skillId, skills.id)) .groupBy(skills.id, communityRatings.id) .where(and(...conditions)); return rows[0] as SkillListItem | undefined; }, 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, agentSkillsDir: string): Promise { const db = await getSkillRegistryDb(); // Remove skill files from disk before soft-deleting the registry row const slug = skillId.split("/").pop() ?? skillId; const targetDir = path.join(agentSkillsDir, slug); await rm(targetDir, { recursive: true, force: true }); 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)); }, /** * Sync native Hermes skills from ~/.hermes/skills/ into the registry. * Creates a minimal skills stub row and an agentSkills row with source='native' * for each subdirectory found. Idempotent — safe to call multiple times. * Returns silently if ~/.hermes/skills/ does not exist. */ async syncHermesNativeSkills(agentId: string): Promise { const hermesSkillsDir = path.join(os.homedir(), ".hermes", "skills"); let entries: string[]; try { entries = await readdir(hermesSkillsDir); } catch { // Directory doesn't exist — no native skills return; } const db = await getSkillRegistryDb(); const now = Date.now(); for (const entry of entries) { const skillId = `hermes-native/${entry}`; // Create a minimal stub in the skills table so JOINs work await db.insert(skills).values({ id: skillId, sourceId: "hermes-native", name: entry, description: null, sourceUrl: null, activeVersionId: null, removedAt: null, createdAt: now, updatedAt: now, }).onConflictDoNothing(); // Record in agent_skills with source = 'native' await db.insert(agentSkills).values({ agentId, skillId, installedAt: now, source: "native", }).onConflictDoNothing(); } }, async fetchAll( sources?: SkillSourceConfig[], ): Promise<{ fetched: number; errors: string[] }> { return fetchAllSources(sources); }, }; }