import { eq, and, inArray } from "drizzle-orm"; import { rm } from "node:fs/promises"; import path from "node:path"; import { getSkillRegistryDb } from "./skill-registry-db.js"; import { skillGroups, skillGroupMembers, skillGroupInheritance, agentSkillGroups, agentSkills, } from "./skill-registry-schema.js"; import { skillRegistryService } from "./skill-registry.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type GroupRow = typeof skillGroups.$inferSelect; type MemberRow = typeof skillGroupMembers.$inferSelect; type GroupExport = { version: "1"; group: { id: string; name: string; description: string | null; members: string[]; parents: string[]; }; }; type AssignResult = { installed: string[]; skipped: string[]; pendingPlugin: string[] }; // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- /** * Skill group service factory. * Manages its own libSQL database (does not accept a Postgres db param). * Use `getSkillRegistryDb()` for all persistence. */ export function skillGroupService() { // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** * BFS cycle detection: would adding parentGroupId as a parent of childGroupId * create a cycle? Returns true if childGroupId is reachable from parentGroupId * by walking up the inheritance chain. */ async function wouldCreateCycle( childGroupId: string, parentGroupId: string, ): Promise { const db = await getSkillRegistryDb(); const visited = new Set(); const queue: string[] = [parentGroupId]; while (queue.length > 0) { const current = queue.shift()!; if (current === childGroupId) return true; if (visited.has(current)) continue; visited.add(current); // Walk up: find parents of current const rows = await db .select() .from(skillGroupInheritance) .where(eq(skillGroupInheritance.childGroupId, current)); for (const row of rows) { if (!visited.has(row.parentGroupId)) { queue.push(row.parentGroupId); } } } return false; } return { // ------------------------------------------------------------------------- // Group CRUD // ------------------------------------------------------------------------- async listGroups(): Promise { const db = await getSkillRegistryDb(); // Order: built-in first, then alphabetical by name const rows = await db.select().from(skillGroups); return rows.sort((a, b) => { if (a.isBuiltin !== b.isBuiltin) return b.isBuiltin - a.isBuiltin; return a.name.localeCompare(b.name); }); }, async getGroup(groupId: string): Promise { const db = await getSkillRegistryDb(); const rows = await db .select() .from(skillGroups) .where(eq(skillGroups.id, groupId)); return rows[0]; }, async createGroup(input: { name: string; description?: string; }): Promise { const db = await getSkillRegistryDb(); const id = `custom/${input.name.toLowerCase().replace(/\s+/g, "-")}`; const now = Date.now(); const row: typeof skillGroups.$inferInsert = { id, name: input.name, description: input.description ?? null, isBuiltin: 0, createdAt: now, updatedAt: now, }; await db.insert(skillGroups).values(row); const inserted = await db .select() .from(skillGroups) .where(eq(skillGroups.id, id)); return inserted[0]!; }, async updateGroup( groupId: string, patch: { name?: string; description?: string }, ): Promise { const db = await getSkillRegistryDb(); const existing = await this.getGroup(groupId); if (!existing) throw new Error("Group not found"); const updates: Partial = { updatedAt: Date.now(), }; if (patch.name !== undefined) updates.name = patch.name; if (patch.description !== undefined) updates.description = patch.description; await db .update(skillGroups) .set(updates) .where(eq(skillGroups.id, groupId)); const updated = await db .select() .from(skillGroups) .where(eq(skillGroups.id, groupId)); return updated[0]!; }, async deleteGroup(groupId: string): Promise { const db = await getSkillRegistryDb(); const existing = await this.getGroup(groupId); if (!existing) throw new Error("Group not found"); if (existing.isBuiltin === 1) throw new Error("Cannot delete built-in group"); // Remove all membership rows await db .delete(skillGroupMembers) .where(eq(skillGroupMembers.groupId, groupId)); // Remove all inheritance rows (as parent or child) await db .delete(skillGroupInheritance) .where(eq(skillGroupInheritance.childGroupId, groupId)); await db .delete(skillGroupInheritance) .where(eq(skillGroupInheritance.parentGroupId, groupId)); // Remove all agent assignments await db .delete(agentSkillGroups) .where(eq(agentSkillGroups.groupId, groupId)); // Remove group itself await db.delete(skillGroups).where(eq(skillGroups.id, groupId)); }, // ------------------------------------------------------------------------- // Member management // ------------------------------------------------------------------------- async addMember(groupId: string, skillId: string): Promise { const db = await getSkillRegistryDb(); await db .insert(skillGroupMembers) .values({ groupId, skillId, addedAt: Date.now() }) .onConflictDoNothing(); }, async removeMember(groupId: string, skillId: string): Promise { const db = await getSkillRegistryDb(); await db .delete(skillGroupMembers) .where( and( eq(skillGroupMembers.groupId, groupId), eq(skillGroupMembers.skillId, skillId), ), ); }, async listMembers(groupId: string): Promise { const db = await getSkillRegistryDb(); return db .select() .from(skillGroupMembers) .where(eq(skillGroupMembers.groupId, groupId)); }, // ------------------------------------------------------------------------- // Inheritance management // ------------------------------------------------------------------------- async addParent( childGroupId: string, parentGroupId: string, ): Promise { const db = await getSkillRegistryDb(); const cycle = await wouldCreateCycle(childGroupId, parentGroupId); if (cycle) throw new Error("Adding this parent would create a cycle"); await db .insert(skillGroupInheritance) .values({ childGroupId, parentGroupId }) .onConflictDoNothing(); }, async removeParent( childGroupId: string, parentGroupId: string, ): Promise { const db = await getSkillRegistryDb(); await db .delete(skillGroupInheritance) .where( and( eq(skillGroupInheritance.childGroupId, childGroupId), eq(skillGroupInheritance.parentGroupId, parentGroupId), ), ); }, async listParents(groupId: string): Promise { const db = await getSkillRegistryDb(); const rows = await db .select() .from(skillGroupInheritance) .where(eq(skillGroupInheritance.childGroupId, groupId)); return rows.map((r) => r.parentGroupId); }, // ------------------------------------------------------------------------- // Effective skill resolution // ------------------------------------------------------------------------- /** * BFS walk through the group inheritance tree. * Collects all direct member skills from the group and all parent groups. * Uses a visited set to handle cycles safely. */ async resolveEffectiveSkills(groupId: string): Promise { const db = await getSkillRegistryDb(); const visited = new Set(); const skillIds = new Set(); async function walk(gid: string): Promise { if (visited.has(gid)) return; visited.add(gid); // Collect direct members const members = await db .select() .from(skillGroupMembers) .where(eq(skillGroupMembers.groupId, gid)); for (const m of members) skillIds.add(m.skillId); // Recurse into parents const parents = await db .select() .from(skillGroupInheritance) .where(eq(skillGroupInheritance.childGroupId, gid)); for (const p of parents) await walk(p.parentGroupId); } await walk(groupId); return Array.from(skillIds); }, // ------------------------------------------------------------------------- // Agent assignment // ------------------------------------------------------------------------- async assignGroup( groupId: string, agentId: string, agentSkillsDir: string, ): Promise { const db = await getSkillRegistryDb(); const installed: string[] = []; const skipped: string[] = []; const pendingPlugin: string[] = []; // Idempotent assignment await db .insert(agentSkillGroups) .values({ agentId, groupId, assignedAt: Date.now() }) .onConflictDoNothing(); const skillIds = await this.resolveEffectiveSkills(groupId); const svc = skillRegistryService(); for (const skillId of skillIds) { try { const result = await svc.install(skillId, agentSkillsDir); // Record in agent_skills await db .insert(agentSkills) .values({ agentId, skillId, installedAt: Date.now() }) .onConflictDoNothing(); if (result.type === "installed") { installed.push(skillId); } else { // pending_plugin_install pendingPlugin.push(result.command); } } catch (err) { // Don't block the entire assignment if one skill fails skipped.push(skillId); } } return { installed, skipped, pendingPlugin }; }, async removeGroup( groupId: string, agentId: string, agentSkillsDir: string, ): Promise { const db = await getSkillRegistryDb(); // Remove group assignment await db .delete(agentSkillGroups) .where( and( eq(agentSkillGroups.agentId, agentId), eq(agentSkillGroups.groupId, groupId), ), ); // Get remaining groups const remainingRows = await db .select() .from(agentSkillGroups) .where(eq(agentSkillGroups.agentId, agentId)); // Union all skills still required by remaining groups const stillNeeded = new Set(); for (const row of remainingRows) { const effective = await this.resolveEffectiveSkills(row.groupId); for (const sid of effective) stillNeeded.add(sid); } // Find skills that were contributed by the removed group const removedGroupSkills = await this.resolveEffectiveSkills(groupId); for (const skillId of removedGroupSkills) { // Skip if still needed by another remaining group if (stillNeeded.has(skillId)) continue; // Remove files from agent skills directory const slug = skillId.split("/").pop() ?? skillId; await rm(path.join(agentSkillsDir, slug), { recursive: true, force: true, }); // Remove from agent_skills if present await db .delete(agentSkills) .where( and( eq(agentSkills.agentId, agentId), eq(agentSkills.skillId, skillId), ), ); } }, async listAgentGroups(agentId: string): Promise { const db = await getSkillRegistryDb(); const assignments = await db .select() .from(agentSkillGroups) .where(eq(agentSkillGroups.agentId, agentId)); if (assignments.length === 0) return []; const groupIds = assignments.map((a) => a.groupId); return db .select() .from(skillGroups) .where(inArray(skillGroups.id, groupIds)); }, async listAgentSkills(agentId: string): Promise> { const db = await getSkillRegistryDb(); const rows = await db .select({ skillId: agentSkills.skillId, source: agentSkills.source, installedAt: agentSkills.installedAt, }) .from(agentSkills) .where(eq(agentSkills.agentId, agentId)); return rows.map((r) => ({ skillId: r.skillId, source: (r.source ?? "managed") as "managed" | "native", installedAt: r.installedAt, })); }, async getAgentEffectiveSkills(agentId: string): Promise { const groups = await this.listAgentGroups(agentId); const union = new Set(); for (const group of groups) { const skills = await this.resolveEffectiveSkills(group.id); for (const s of skills) union.add(s); } return Array.from(union); }, // ------------------------------------------------------------------------- // Import / Export // ------------------------------------------------------------------------- async exportGroup(groupId: string): Promise { const group = await this.getGroup(groupId); if (!group) throw new Error("Group not found"); const memberRows = await this.listMembers(groupId); const parentIds = await this.listParents(groupId); return { version: "1", group: { id: group.id, name: group.name, description: group.description, members: memberRows.map((m) => m.skillId), parents: parentIds, }, }; }, async importGroup(data: GroupExport): Promise { if (data.version !== "1") throw new Error("Unsupported export version"); const existing = await this.getGroup(data.group.id); if (existing) { throw new Error( `A group with id "${data.group.id}" already exists. Rename the group before importing.`, ); } // Create the group const db = await getSkillRegistryDb(); const now = Date.now(); await db.insert(skillGroups).values({ id: data.group.id, name: data.group.name, description: data.group.description, isBuiltin: 0, createdAt: now, updatedAt: now, }); // Insert members for (const skillId of data.group.members) { await this.addMember(data.group.id, skillId); } // Insert parents (with cycle check via addParent) for (const parentId of data.group.parents) { await this.addParent(data.group.id, parentId); } const newGroup = await this.getGroup(data.group.id); return newGroup!; }, }; }