diff --git a/server/src/app.ts b/server/src/app.ts index e55edcf0..d5e9818f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -152,8 +152,8 @@ export async function createApp( ); api.use("/companies", companyRoutes(db, opts.storageService)); api.use(companySkillRoutes(db)); - api.use(skillRegistryRoutes()); - api.use(skillGroupRoutes()); + api.use(skillRegistryRoutes(db)); + api.use(skillGroupRoutes(db)); api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); diff --git a/server/src/routes/skill-registry-groups.ts b/server/src/routes/skill-registry-groups.ts index f16f88c7..2e9ea72f 100644 --- a/server/src/routes/skill-registry-groups.ts +++ b/server/src/routes/skill-registry-groups.ts @@ -1,21 +1,37 @@ import { Router } from "express"; import os from "node:os"; -import path from "node:path"; +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"; -/** Default skills directory when client doesn't provide one */ -function defaultSkillsDir(): string { - return path.join(os.homedir(), ".claude", "skills"); +/** + * 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. * - * Note: does NOT take a db param — skill groups use the libSQL registry.db. + * 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(): Router { +export function skillGroupRoutes(db: Db): Router { const router = Router(); const svc = skillGroupService(); @@ -168,37 +184,51 @@ export function skillGroupRoutes(): Router { } }); + // 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, agentSkillsDir } = req.body as { groupId?: string; agentSkillsDir?: string }; + const { groupId } = req.body as { groupId?: string }; if (!groupId) { return res.status(400).json({ error: "groupId required" }); } - const resolvedDir = agentSkillsDir || defaultSkillsDir(); - const result = await svc.assignGroup(groupId, req.params.agentId, resolvedDir); + 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) { - handleError(res, err); + } 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 { agentSkillsDir } = req.body as { agentSkillsDir?: string }; - const resolvedDir = agentSkillsDir || defaultSkillsDir(); - await svc.removeGroup(req.params.groupId, req.params.agentId, resolvedDir); + const agentSkillsDir = await resolveSkillsDirForAgent(db, req.params.agentId); + await svc.removeGroup(req.params.groupId, req.params.agentId, agentSkillsDir); res.status(204).end(); - } catch (err) { - handleError(res, err); + } 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 skills = await svc.listAgentSkills(req.params.agentId); + 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); diff --git a/server/src/routes/skill-registry.ts b/server/src/routes/skill-registry.ts index d92f9871..71d4f479 100644 --- a/server/src/routes/skill-registry.ts +++ b/server/src/routes/skill-registry.ts @@ -1,15 +1,40 @@ import { Router } from "express"; +import os from "node:os"; +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agentService } from "../services/agents.js"; +import { resolveAdapterSkillConfig } from "@paperclipai/adapter-utils"; import { skillRegistryService } from "../services/skill-registry.js"; import { skillRatingService } from "../services/skill-registry-ratings.js"; +import { getSkillRegistryDb } from "../services/skill-registry-db.js"; +import { agentSkills } from "../services/skill-registry-schema.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()); +} + /** * REST routes for the skill registry. * - * Note: does NOT take a db param — the skill registry manages its own libSQL database. + * Accepts a db param for agent lookup (to resolve adapter-based skill directories). * All route handlers assert `board` access before delegating to skillRegistryService. */ -export function skillRegistryRoutes(): Router { +export function skillRegistryRoutes(db: Db): Router { const router = Router(); const svc = skillRegistryService(); @@ -30,36 +55,68 @@ export function skillRegistryRoutes(): Router { res.json(versions); }); - // Install skill to agent directory + // Install skill to agent directory — resolves path from agentId server-side router.post("/skill-registry/skills/:sourceId/:slug/install", async (req, res) => { assertBoard(req); const skillId = `${req.params.sourceId}/${req.params.slug}`; - const { agentSkillsDir } = req.body as { agentSkillsDir: string }; - if (!agentSkillsDir) return res.status(400).json({ error: "agentSkillsDir required" }); - const result = await svc.install(skillId, agentSkillsDir); - res.json(result); + const { agentId } = req.body as { agentId: string }; + if (!agentId) return res.status(400).json({ error: "agentId required" }); + try { + const agentSkillsDir = await resolveSkillsDirForAgent(db, agentId); + const result = await svc.install(skillId, agentSkillsDir); + res.json(result); + } catch (err: any) { + const status = err.status ?? 500; + return res.status(status).json({ error: err.message }); + } }); - // Rollback to a specific version + // Rollback to a specific version — resolves path from agentId server-side router.post("/skill-registry/skills/:sourceId/:slug/rollback", async (req, res) => { assertBoard(req); const skillId = `${req.params.sourceId}/${req.params.slug}`; - const { versionId, agentSkillsDir } = req.body as { versionId: string; agentSkillsDir: string }; - if (!versionId || !agentSkillsDir) { - return res.status(400).json({ error: "versionId and agentSkillsDir required" }); + const { versionId, agentId } = req.body as { versionId: string; agentId: string }; + if (!versionId || !agentId) { + return res.status(400).json({ error: "versionId and agentId required" }); + } + try { + const agentSkillsDir = await resolveSkillsDirForAgent(db, agentId); + await svc.rollback(skillId, versionId, agentSkillsDir); + res.json({ ok: true }); + } catch (err: any) { + const status = err.status ?? 500; + return res.status(status).json({ error: err.message }); } - await svc.rollback(skillId, versionId, agentSkillsDir); - res.json({ ok: true }); }); - // Soft-delete a skill (also removes files from disk) - // agentSkillsDir is required as a query param until Plan 02 replaces it with agentId-based resolution + // Soft-delete a skill (also removes files from disk). + // agentId is passed as a query param (DELETE semantics — no body). + // Returns 403 if the skill is a native skill (source === 'native'). router.delete("/skill-registry/skills/:sourceId/:slug", async (req, res) => { assertBoard(req); const skillId = `${req.params.sourceId}/${req.params.slug}`; - const agentSkillsDir = (req.query.agentSkillsDir as string | undefined) ?? ""; - await svc.uninstall(skillId, agentSkillsDir); - res.json({ ok: true }); + const agentId = req.query.agentId as string | undefined; + if (!agentId) return res.status(400).json({ error: "agentId required" }); + + // Check native skill protection + const registryDb = await getSkillRegistryDb(); + const agentSkillRows = await registryDb + .select() + .from(agentSkills) + .where(and(eq(agentSkills.skillId, skillId), eq(agentSkills.agentId, agentId))); + const agentSkillRow = agentSkillRows[0]; + if (agentSkillRow?.source === "native") { + return res.status(403).json({ error: "Cannot remove native skills" }); + } + + try { + const agentSkillsDir = await resolveSkillsDirForAgent(db, agentId); + await svc.uninstall(skillId, agentSkillsDir); + res.json({ ok: true }); + } catch (err: any) { + const status = err.status ?? 500; + return res.status(status).json({ error: err.message }); + } }); // Submit a personal rating for a skill