import { Router } from "express"; import os from "node:os"; import type { Db } from "@paperclipai/db"; import { agentService } from "../services/agents.js"; import { resolveAdapterSkillConfig } from "@paperclipai/adapter-utils"; import { skillGroupService } from "../services/skill-registry-groups.js"; import { skillRegistryService } from "../services/skill-registry.js"; import { assertBoard } from "./authz.js"; /** * Resolves the agentSkillsDir for a given agentId by looking up the agent's * adapter type and calling resolveAdapterSkillConfig. Throws with a status * property so route handlers can forward the correct HTTP status code. */ async function resolveSkillsDirForAgent(db: Db, agentId: string): Promise { const agent = await agentService(db).getById(agentId); if (!agent) throw Object.assign(new Error("Agent not found"), { status: 404 }); const config = resolveAdapterSkillConfig(agent.adapterType); if (!config.supportsInstall || !config.skillDir) { throw Object.assign( new Error(config.unsupportedReason ?? "Adapter does not support skill install"), { status: 422 }, ); } return config.skillDir.replace(/^~/, os.homedir()); } /** * Resolves the agentSkillsDir for a given agentId by looking up the agent's * adapter type and calling resolveAdapterSkillConfig. Throws with a status * property so route handlers can forward the correct HTTP status code. */ async function resolveSkillsDirForAgent(db: Db, agentId: string): Promise { const agent = await agentService(db).getById(agentId); if (!agent) throw Object.assign(new Error("Agent not found"), { status: 404 }); const config = resolveAdapterSkillConfig(agent.adapterType); if (!config.supportsInstall || !config.skillDir) { throw Object.assign( new Error(config.unsupportedReason ?? "Adapter does not support skill install"), { status: 422 }, ); } return config.skillDir.replace(/^~/, os.homedir()); } /** * REST routes for skill groups. * * Accepts a db param for agent lookup (to resolve adapter-based skill directories). * All route handlers assert `board` access before delegating to skillGroupService. */ export function skillGroupRoutes(db: Db): Router { const router = Router(); const svc = skillGroupService(); function handleError(res: any, err: unknown) { const msg = err instanceof Error ? err.message : String(err); if ( msg.includes("Cannot delete built-in") || msg.includes("not found") || msg.includes("cycle") || msg.includes("required") ) { return res.status(400).json({ error: msg }); } if (msg.includes("already exists")) { return res.status(409).json({ error: msg }); } return res.status(500).json({ error: msg }); } // --- Group CRUD --- router.get("/skill-registry/groups", async (req, res) => { assertBoard(req); try { const groups = await svc.listGroups(); res.json(groups); } catch (err) { handleError(res, err); } }); // Import route must come BEFORE /:groupId to avoid "import" being captured as a groupId param router.post("/skill-registry/groups/import", async (req, res) => { assertBoard(req); try { const result = await svc.importGroup(req.body); res.status(201).json(result); } catch (err) { handleError(res, err); } }); router.post("/skill-registry/groups", async (req, res) => { assertBoard(req); try { const { name, description } = req.body as { name?: string; description?: string }; if (!name) { return res.status(400).json({ error: "name required" }); } const group = await svc.createGroup({ name, description }); res.status(201).json(group); } catch (err) { handleError(res, err); } }); router.get("/skill-registry/groups/:groupId", async (req, res) => { assertBoard(req); try { const group = await svc.getGroup(req.params.groupId); if (!group) { return res.status(404).json({ error: "Group not found" }); } res.json(group); } catch (err) { handleError(res, err); } }); router.patch("/skill-registry/groups/:groupId", async (req, res) => { assertBoard(req); try { const { name, description } = req.body as { name?: string; description?: string }; const group = await svc.updateGroup(req.params.groupId, { name, description }); res.json(group); } catch (err) { handleError(res, err); } }); router.delete("/skill-registry/groups/:groupId", async (req, res) => { assertBoard(req); try { await svc.deleteGroup(req.params.groupId); res.status(204).end(); } catch (err) { handleError(res, err); } }); // --- Members --- router.get("/skill-registry/groups/:groupId/members", async (req, res) => { assertBoard(req); try { const members = await svc.listMembers(req.params.groupId); res.json(members); } catch (err) { handleError(res, err); } }); router.post("/skill-registry/groups/:groupId/members", async (req, res) => { assertBoard(req); try { const { skillId } = req.body as { skillId?: string }; if (!skillId) { return res.status(400).json({ error: "skillId required" }); } await svc.addMember(req.params.groupId, skillId); res.status(201).json({ ok: true }); } catch (err) { handleError(res, err); } }); router.delete("/skill-registry/groups/:groupId/members/*skillId", async (req, res) => { assertBoard(req); try { const skillId = (req.params as any).skillId as string; await svc.removeMember(req.params.groupId, skillId); res.status(204).end(); } catch (err) { handleError(res, err); } }); // --- Export --- router.get("/skill-registry/groups/:groupId/export", async (req, res) => { assertBoard(req); try { const data = await svc.exportGroup(req.params.groupId); res.setHeader("Content-Disposition", `attachment; filename="${data.group.name}.json"`); res.json(data); } catch (err) { handleError(res, err); } }); // --- Agent group assignments --- router.get("/skill-registry/agents/:agentId/groups", async (req, res) => { assertBoard(req); try { const groups = await svc.listAgentGroups(req.params.agentId); res.json(groups); } catch (err) { handleError(res, err); } }); // Assign a group to an agent — resolves skill dir from agentId URL param server-side router.post("/skill-registry/agents/:agentId/groups", async (req, res) => { assertBoard(req); try { const { groupId } = req.body as { groupId?: string }; if (!groupId) { return res.status(400).json({ error: "groupId required" }); } const agentSkillsDir = await resolveSkillsDirForAgent(db, req.params.agentId); const result = await svc.assignGroup(groupId, req.params.agentId, agentSkillsDir); res.status(201).json(result); } catch (err: any) { const status = err.status ?? 500; return res.status(status).json({ error: err.message }); } }); // Remove a group from an agent — resolves skill dir from agentId URL param server-side router.delete("/skill-registry/agents/:agentId/groups/*groupId", async (req, res) => { assertBoard(req); try { const groupId = (req.params as any).groupId as string; const agentSkillsDir = await resolveSkillsDirForAgent(db, req.params.agentId); await svc.removeGroup(groupId, req.params.agentId, agentSkillsDir); res.status(204).end(); } catch (err: any) { const status = err.status ?? 500; return res.status(status).json({ error: err.message }); } }); // List installed skills for an agent. // For hermes_local agents, syncs native skills before returning the list. router.get("/skill-registry/agents/:agentId/skills", async (req, res) => { assertBoard(req); try { const agentId = req.params.agentId; const agent = await agentService(db).getById(agentId); if (!agent) { return res.status(404).json({ error: "Agent not found" }); } if (agent.adapterType === "hermes_local") { const registrySvc = skillRegistryService(); await registrySvc.syncHermesNativeSkills(agentId); } const skills = await svc.listAgentSkills(agentId); res.json(skills); } catch (err) { handleError(res, err); } }); return router; }