diff --git a/server/src/__tests__/skill-registry-routes.test.ts b/server/src/__tests__/skill-registry-routes.test.ts index 9616bb82..e4aacf8d 100644 --- a/server/src/__tests__/skill-registry-routes.test.ts +++ b/server/src/__tests__/skill-registry-routes.test.ts @@ -102,38 +102,40 @@ describe("skill registry routes", () => { }); }); - // ---- Test 2: GET /api/skill-registry/skills/:id ---- + // ---- Test 2: GET /api/skill-registry/skills/:sourceId/:slug ---- - describe("GET /api/skill-registry/skills/:id", () => { + describe("GET /api/skill-registry/skills/:sourceId/:slug", () => { it("returns 200 with skill object when found", async () => { mockSkillRegistryService.getById.mockResolvedValue(skill1); - const res = await request(app).get("/api/skill-registry/skills/anthropic-official%2Fbash"); + const res = await request(app).get("/api/skill-registry/skills/anthropic-official/bash"); expect(res.status).toBe(200); expect(res.body).toEqual(skill1); + expect(mockSkillRegistryService.getById).toHaveBeenCalledWith("anthropic-official/bash"); }); it("returns 404 when skill not found", async () => { mockSkillRegistryService.getById.mockResolvedValue(undefined); - const res = await request(app).get("/api/skill-registry/skills/unknown%2Fskill"); + const res = await request(app).get("/api/skill-registry/skills/unknown/skill"); expect(res.status).toBe(404); expect(res.body).toEqual({ error: "Skill not found" }); }); }); - // ---- Test 3: GET /api/skill-registry/skills/:id/versions ---- + // ---- Test 3: GET /api/skill-registry/skills/:sourceId/:slug/versions ---- - describe("GET /api/skill-registry/skills/:id/versions", () => { + describe("GET /api/skill-registry/skills/:sourceId/:slug/versions", () => { it("returns 200 with version array", async () => { mockSkillRegistryService.getVersions.mockResolvedValue([version1]); - const res = await request(app).get("/api/skill-registry/skills/anthropic-official%2Fbash/versions"); + const res = await request(app).get("/api/skill-registry/skills/anthropic-official/bash/versions"); expect(res.status).toBe(200); expect(res.body).toEqual([version1]); + expect(mockSkillRegistryService.getVersions).toHaveBeenCalledWith("anthropic-official/bash"); }); }); @@ -150,9 +152,9 @@ describe("skill registry routes", () => { }); }); - // ---- Test 5: POST /api/skill-registry/skills/:id/install ---- + // ---- Test 5: POST /api/skill-registry/skills/:sourceId/:slug/install ---- - describe("POST /api/skill-registry/skills/:id/install", () => { + describe("POST /api/skill-registry/skills/:sourceId/:slug/install", () => { it("returns 200 with install result when agentSkillsDir provided", async () => { const installResult = { type: "installed", @@ -163,7 +165,7 @@ describe("skill registry routes", () => { mockSkillRegistryService.install.mockResolvedValue(installResult); const res = await request(app) - .post("/api/skill-registry/skills/anthropic-official%2Fbash/install") + .post("/api/skill-registry/skills/anthropic-official/bash/install") .send({ agentSkillsDir: "/agent/skills" }); expect(res.status).toBe(200); @@ -176,7 +178,7 @@ describe("skill registry routes", () => { it("returns 400 when agentSkillsDir is missing", async () => { const res = await request(app) - .post("/api/skill-registry/skills/anthropic-official%2Fbash/install") + .post("/api/skill-registry/skills/anthropic-official/bash/install") .send({}); expect(res.status).toBe(400); @@ -184,14 +186,14 @@ describe("skill registry routes", () => { }); }); - // ---- Test 6: POST /api/skill-registry/skills/:id/rollback ---- + // ---- Test 6: POST /api/skill-registry/skills/:sourceId/:slug/rollback ---- - describe("POST /api/skill-registry/skills/:id/rollback", () => { + describe("POST /api/skill-registry/skills/:sourceId/:slug/rollback", () => { it("returns 200 when versionId and agentSkillsDir provided", async () => { mockSkillRegistryService.rollback.mockResolvedValue(undefined); const res = await request(app) - .post("/api/skill-registry/skills/anthropic-official%2Fbash/rollback") + .post("/api/skill-registry/skills/anthropic-official/bash/rollback") .send({ versionId: "anthropic-official/bash@abc123", agentSkillsDir: "/agent/skills" }); expect(res.status).toBe(200); @@ -205,7 +207,7 @@ describe("skill registry routes", () => { it("returns 400 when versionId is missing", async () => { const res = await request(app) - .post("/api/skill-registry/skills/anthropic-official%2Fbash/rollback") + .post("/api/skill-registry/skills/anthropic-official/bash/rollback") .send({ agentSkillsDir: "/agent/skills" }); expect(res.status).toBe(400); @@ -214,7 +216,7 @@ describe("skill registry routes", () => { it("returns 400 when agentSkillsDir is missing", async () => { const res = await request(app) - .post("/api/skill-registry/skills/anthropic-official%2Fbash/rollback") + .post("/api/skill-registry/skills/anthropic-official/bash/rollback") .send({ versionId: "anthropic-official/bash@abc123" }); expect(res.status).toBe(400); @@ -222,13 +224,13 @@ describe("skill registry routes", () => { }); }); - // ---- Test 7: DELETE /api/skill-registry/skills/:id ---- + // ---- Test 7: DELETE /api/skill-registry/skills/:sourceId/:slug ---- - describe("DELETE /api/skill-registry/skills/:id", () => { + describe("DELETE /api/skill-registry/skills/:sourceId/:slug", () => { it("returns 200 after soft-delete", async () => { mockSkillRegistryService.uninstall.mockResolvedValue(undefined); - const res = await request(app).delete("/api/skill-registry/skills/anthropic-official%2Fbash"); + const res = await request(app).delete("/api/skill-registry/skills/anthropic-official/bash"); expect(res.status).toBe(200); expect(res.body).toEqual({ ok: true }); diff --git a/server/src/app.ts b/server/src/app.ts index 5535ab3d..4b85570e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -12,6 +12,7 @@ import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middlewa import { healthRoutes } from "./routes/health.js"; import { companyRoutes } from "./routes/companies.js"; import { companySkillRoutes } from "./routes/company-skills.js"; +import { skillRegistryRoutes } from "./routes/skill-registry.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; @@ -141,6 +142,7 @@ export async function createApp( ); api.use("/companies", companyRoutes(db, opts.storageService)); api.use(companySkillRoutes(db)); + api.use(skillRegistryRoutes()); api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); diff --git a/server/src/routes/skill-registry.ts b/server/src/routes/skill-registry.ts new file mode 100644 index 00000000..ec266509 --- /dev/null +++ b/server/src/routes/skill-registry.ts @@ -0,0 +1,79 @@ +import { Router } from "express"; +import { skillRegistryService } from "../services/skill-registry.js"; +import { assertBoard } from "./authz.js"; + +/** + * REST routes for the skill registry. + * + * Note: does NOT take a db param — the skill registry manages its own libSQL database. + * All route handlers assert `board` access before delegating to skillRegistryService. + */ +export function skillRegistryRoutes(): Router { + const router = Router(); + const svc = skillRegistryService(); + + // List all skills (soft-deleted excluded by default) + router.get("/skill-registry/skills", async (req, res) => { + assertBoard(req); + const includeRemoved = req.query.includeRemoved === "true"; + const list = await svc.list({ includeRemoved }); + res.json(list); + }); + + // Get versions for a skill — must be registered before the single-skill route + // to avoid /:id matching "versions" as the id segment + router.get("/skill-registry/skills/:sourceId/:slug/versions", async (req, res) => { + assertBoard(req); + const skillId = `${req.params.sourceId}/${req.params.slug}`; + const versions = await svc.getVersions(skillId); + res.json(versions); + }); + + // Install skill to agent directory + 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); + }); + + // Rollback to a specific version + 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" }); + } + await svc.rollback(skillId, versionId, agentSkillsDir); + res.json({ ok: true }); + }); + + // Soft-delete a skill + router.delete("/skill-registry/skills/:sourceId/:slug", async (req, res) => { + assertBoard(req); + const skillId = `${req.params.sourceId}/${req.params.slug}`; + await svc.uninstall(skillId); + res.json({ ok: true }); + }); + + // Get a single skill by id + router.get("/skill-registry/skills/:sourceId/:slug", async (req, res) => { + assertBoard(req); + const skillId = `${req.params.sourceId}/${req.params.slug}`; + const skill = await svc.getById(skillId); + if (!skill) return res.status(404).json({ error: "Skill not found" }); + res.json(skill); + }); + + // Trigger fetch from all configured sources + router.post("/skill-registry/fetch", async (req, res) => { + assertBoard(req); + const result = await svc.fetchAll(); + res.json(result); + }); + + return router; +}