[nexus] feat(19-02): adapter-aware route handlers with agentId resolution
- Add resolveSkillsDirForAgent helper to skill-registry.ts and skill-registry-groups.ts - Install route accepts agentId in body (not agentSkillsDir) - Uninstall route accepts agentId as query param; returns 403 for native skills - Rollback route accepts agentId in body (not agentSkillsDir) - Group assign/remove routes resolve dir from URL agentId param - List agent skills route calls syncHermesNativeSkills for hermes_local agents - skillRegistryRoutes(db) and skillGroupRoutes(db) factory signatures updated - app.ts passes db to both route factories
This commit is contained in:
parent
d0fc8a3348
commit
955dba52f4
3 changed files with 124 additions and 37 deletions
|
|
@ -152,8 +152,8 @@ export async function createApp(
|
||||||
);
|
);
|
||||||
api.use("/companies", companyRoutes(db, opts.storageService));
|
api.use("/companies", companyRoutes(db, opts.storageService));
|
||||||
api.use(companySkillRoutes(db));
|
api.use(companySkillRoutes(db));
|
||||||
api.use(skillRegistryRoutes());
|
api.use(skillRegistryRoutes(db));
|
||||||
api.use(skillGroupRoutes());
|
api.use(skillGroupRoutes(db));
|
||||||
api.use(agentRoutes(db));
|
api.use(agentRoutes(db));
|
||||||
api.use(assetRoutes(db, opts.storageService));
|
api.use(assetRoutes(db, opts.storageService));
|
||||||
api.use(projectRoutes(db));
|
api.use(projectRoutes(db));
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,37 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import os from "node:os";
|
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 { skillGroupService } from "../services/skill-registry-groups.js";
|
||||||
|
import { skillRegistryService } from "../services/skill-registry.js";
|
||||||
import { assertBoard } from "./authz.js";
|
import { assertBoard } from "./authz.js";
|
||||||
|
|
||||||
/** Default skills directory when client doesn't provide one */
|
/**
|
||||||
function defaultSkillsDir(): string {
|
* Resolves the agentSkillsDir for a given agentId by looking up the agent's
|
||||||
return path.join(os.homedir(), ".claude", "skills");
|
* 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<string> {
|
||||||
|
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.
|
* 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.
|
* All route handlers assert `board` access before delegating to skillGroupService.
|
||||||
*/
|
*/
|
||||||
export function skillGroupRoutes(): Router {
|
export function skillGroupRoutes(db: Db): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = skillGroupService();
|
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) => {
|
router.post("/skill-registry/agents/:agentId/groups", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
try {
|
try {
|
||||||
const { groupId, agentSkillsDir } = req.body as { groupId?: string; agentSkillsDir?: string };
|
const { groupId } = req.body as { groupId?: string };
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
return res.status(400).json({ error: "groupId required" });
|
return res.status(400).json({ error: "groupId required" });
|
||||||
}
|
}
|
||||||
const resolvedDir = agentSkillsDir || defaultSkillsDir();
|
const agentSkillsDir = await resolveSkillsDirForAgent(db, req.params.agentId);
|
||||||
const result = await svc.assignGroup(groupId, req.params.agentId, resolvedDir);
|
const result = await svc.assignGroup(groupId, req.params.agentId, agentSkillsDir);
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
handleError(res, err);
|
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) => {
|
router.delete("/skill-registry/agents/:agentId/groups/:groupId(*)", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
try {
|
try {
|
||||||
const { agentSkillsDir } = req.body as { agentSkillsDir?: string };
|
const agentSkillsDir = await resolveSkillsDirForAgent(db, req.params.agentId);
|
||||||
const resolvedDir = agentSkillsDir || defaultSkillsDir();
|
await svc.removeGroup(req.params.groupId, req.params.agentId, agentSkillsDir);
|
||||||
await svc.removeGroup(req.params.groupId, req.params.agentId, resolvedDir);
|
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
handleError(res, err);
|
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) => {
|
router.get("/skill-registry/agents/:agentId/skills", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
try {
|
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);
|
res.json(skills);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,40 @@
|
||||||
import { Router } from "express";
|
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 { skillRegistryService } from "../services/skill-registry.js";
|
||||||
import { skillRatingService } from "../services/skill-registry-ratings.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";
|
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<string> {
|
||||||
|
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.
|
* 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.
|
* All route handlers assert `board` access before delegating to skillRegistryService.
|
||||||
*/
|
*/
|
||||||
export function skillRegistryRoutes(): Router {
|
export function skillRegistryRoutes(db: Db): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = skillRegistryService();
|
const svc = skillRegistryService();
|
||||||
|
|
||||||
|
|
@ -30,36 +55,68 @@ export function skillRegistryRoutes(): Router {
|
||||||
res.json(versions);
|
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) => {
|
router.post("/skill-registry/skills/:sourceId/:slug/install", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const skillId = `${req.params.sourceId}/${req.params.slug}`;
|
const skillId = `${req.params.sourceId}/${req.params.slug}`;
|
||||||
const { agentSkillsDir } = req.body as { agentSkillsDir: string };
|
const { agentId } = req.body as { agentId: string };
|
||||||
if (!agentSkillsDir) return res.status(400).json({ error: "agentSkillsDir required" });
|
if (!agentId) return res.status(400).json({ error: "agentId required" });
|
||||||
const result = await svc.install(skillId, agentSkillsDir);
|
try {
|
||||||
res.json(result);
|
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) => {
|
router.post("/skill-registry/skills/:sourceId/:slug/rollback", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const skillId = `${req.params.sourceId}/${req.params.slug}`;
|
const skillId = `${req.params.sourceId}/${req.params.slug}`;
|
||||||
const { versionId, agentSkillsDir } = req.body as { versionId: string; agentSkillsDir: string };
|
const { versionId, agentId } = req.body as { versionId: string; agentId: string };
|
||||||
if (!versionId || !agentSkillsDir) {
|
if (!versionId || !agentId) {
|
||||||
return res.status(400).json({ error: "versionId and agentSkillsDir required" });
|
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)
|
// 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
|
// 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) => {
|
router.delete("/skill-registry/skills/:sourceId/:slug", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const skillId = `${req.params.sourceId}/${req.params.slug}`;
|
const skillId = `${req.params.sourceId}/${req.params.slug}`;
|
||||||
const agentSkillsDir = (req.query.agentSkillsDir as string | undefined) ?? "";
|
const agentId = req.query.agentId as string | undefined;
|
||||||
await svc.uninstall(skillId, agentSkillsDir);
|
if (!agentId) return res.status(400).json({ error: "agentId required" });
|
||||||
res.json({ ok: true });
|
|
||||||
|
// 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
|
// Submit a personal rating for a skill
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue