Add adapter skill sync for codex and claude
This commit is contained in:
38
server/src/__tests__/claude-local-skill-sync.test.ts
Normal file
38
server/src/__tests__/claude-local-skill-sync.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listClaudeSkills,
|
||||
syncClaudeSkills,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
describe("claude local skill sync", () => {
|
||||
it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
|
||||
const snapshot = await listClaudeSkills({
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(snapshot.mode).toBe("ephemeral");
|
||||
expect(snapshot.supported).toBe(true);
|
||||
expect(snapshot.desiredSkills).toContain("paperclip");
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
|
||||
});
|
||||
|
||||
it("respects an explicit desired skill list without mutating a persistent home", async () => {
|
||||
const snapshot = await syncClaudeSkills({
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
}, ["paperclip"]);
|
||||
|
||||
expect(snapshot.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("available");
|
||||
});
|
||||
});
|
||||
87
server/src/__tests__/codex-local-skill-sync.test.ts
Normal file
87
server/src/__tests__/codex-local-skill-sync.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listCodexSkills,
|
||||
syncCodexSkills,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("codex local skill sync", () => {
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Codex skills home", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-sync-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listCodexSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncCodexSkills(ctx, ["paperclip"]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncCodexSkills(configuredCtx, ["paperclip"]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncCodexSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { ServerAdapterModule } from "./types.js";
|
||||
import {
|
||||
execute as claudeExecute,
|
||||
listClaudeSkills,
|
||||
syncClaudeSkills,
|
||||
testEnvironment as claudeTestEnvironment,
|
||||
sessionCodec as claudeSessionCodec,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclipai/adapter-claude-local";
|
||||
import {
|
||||
execute as codexExecute,
|
||||
listCodexSkills,
|
||||
syncCodexSkills,
|
||||
testEnvironment as codexTestEnvironment,
|
||||
sessionCodec as codexSessionCodec,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
@@ -58,6 +62,8 @@ const claudeLocalAdapter: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
listSkills: listClaudeSkills,
|
||||
syncSkills: syncClaudeSkills,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
models: claudeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
@@ -68,6 +74,8 @@ const codexLocalAdapter: ServerAdapterModule = {
|
||||
type: "codex_local",
|
||||
execute: codexExecute,
|
||||
testEnvironment: codexTestEnvironment,
|
||||
listSkills: listCodexSkills,
|
||||
syncSkills: syncCodexSkills,
|
||||
sessionCodec: codexSessionCodec,
|
||||
models: codexModels,
|
||||
listModels: listCodexModels,
|
||||
|
||||
@@ -13,6 +13,11 @@ export type {
|
||||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSkillSyncMode,
|
||||
AdapterSkillState,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
AdapterSkillContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
ServerAdapterModule,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
|
||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentSkillSyncSchema,
|
||||
createAgentKeySchema,
|
||||
createAgentHireSchema,
|
||||
createAgentSchema,
|
||||
@@ -12,12 +13,17 @@ import {
|
||||
isUuidLike,
|
||||
resetAgentSessionSchema,
|
||||
testAdapterEnvironmentSchema,
|
||||
type AgentSkillSnapshot,
|
||||
type InstanceSchedulerHeartbeatAgent,
|
||||
updateAgentPermissionsSchema,
|
||||
updateAgentInstructionsPathSchema,
|
||||
wakeAgentSchema,
|
||||
updateAgentSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
writePaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
agentService,
|
||||
@@ -334,6 +340,20 @@ export function agentRoutes(db: Db) {
|
||||
return details;
|
||||
}
|
||||
|
||||
function buildUnsupportedSkillSnapshot(
|
||||
adapterType: string,
|
||||
desiredSkills: string[] = [],
|
||||
): AgentSkillSnapshot {
|
||||
return {
|
||||
adapterType,
|
||||
supported: false,
|
||||
mode: "unsupported",
|
||||
desiredSkills,
|
||||
entries: [],
|
||||
warnings: ["This adapter does not implement skill sync yet."],
|
||||
};
|
||||
}
|
||||
|
||||
function redactForRestrictedAgentView(agent: Awaited<ReturnType<typeof svc.getById>>) {
|
||||
if (!agent) return null;
|
||||
return {
|
||||
@@ -459,6 +479,119 @@ export function agentRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/agents/:id/skills", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanReadConfigurations(req, agent.companyId);
|
||||
|
||||
const adapter = findServerAdapter(agent.adapterType);
|
||||
if (!adapter?.listSkills) {
|
||||
const preference = readPaperclipSkillSyncPreference(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
);
|
||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, preference.desiredSkills));
|
||||
return;
|
||||
}
|
||||
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
agent.adapterConfig,
|
||||
);
|
||||
const snapshot = await adapter.listSkills({
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
adapterType: agent.adapterType,
|
||||
config: runtimeConfig,
|
||||
});
|
||||
res.json(snapshot);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/agents/:id/skills/sync",
|
||||
validate(agentSkillSyncSchema),
|
||||
async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanUpdateAgent(req, agent);
|
||||
|
||||
const desiredSkills = Array.from(
|
||||
new Set(
|
||||
(req.body.desiredSkills as string[])
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const nextAdapterConfig = writePaperclipSkillSyncPreference(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
desiredSkills,
|
||||
);
|
||||
const actor = getActorInfo(req);
|
||||
const updated = await svc.update(agent.id, {
|
||||
adapterConfig: nextAdapterConfig,
|
||||
}, {
|
||||
recordRevision: {
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
source: "skill-sync",
|
||||
},
|
||||
});
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const adapter = findServerAdapter(updated.adapterType);
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
updated.companyId,
|
||||
updated.adapterConfig,
|
||||
);
|
||||
const snapshot = adapter?.syncSkills
|
||||
? await adapter.syncSkills({
|
||||
agentId: updated.id,
|
||||
companyId: updated.companyId,
|
||||
adapterType: updated.adapterType,
|
||||
config: runtimeConfig,
|
||||
}, desiredSkills)
|
||||
: adapter?.listSkills
|
||||
? await adapter.listSkills({
|
||||
agentId: updated.id,
|
||||
companyId: updated.companyId,
|
||||
adapterType: updated.adapterType,
|
||||
config: runtimeConfig,
|
||||
})
|
||||
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
action: "agent.skills_synced",
|
||||
entityType: "agent",
|
||||
entityId: updated.id,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
details: {
|
||||
adapterType: updated.adapterType,
|
||||
desiredSkills,
|
||||
mode: snapshot.mode,
|
||||
supported: snapshot.supported,
|
||||
entryCount: snapshot.entries.length,
|
||||
warningCount: snapshot.warnings.length,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(snapshot);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/companies/:companyId/agents", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
Reference in New Issue
Block a user