From 56a34a8f8a2a928181590fda6848108467d574b2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 22:49:42 -0500 Subject: [PATCH] Add adapter skill sync for codex and claude --- packages/adapter-utils/src/index.ts | 5 + packages/adapter-utils/src/server-utils.ts | 43 +++++ packages/adapter-utils/src/types.ts | 38 ++++ .../claude-local/src/server/execute.ts | 41 ++-- .../adapters/claude-local/src/server/index.ts | 1 + .../claude-local/src/server/skills.ts | 83 ++++++++ .../codex-local/src/server/execute.ts | 19 +- .../adapters/codex-local/src/server/index.ts | 1 + .../adapters/codex-local/src/server/skills.ts | 179 ++++++++++++++++++ packages/shared/src/index.ts | 11 ++ packages/shared/src/types/adapter-skills.ts | 32 ++++ packages/shared/src/types/index.ts | 7 + .../shared/src/validators/adapter-skills.ts | 41 ++++ packages/shared/src/validators/index.ts | 8 + .../__tests__/claude-local-skill-sync.test.ts | 38 ++++ .../__tests__/codex-local-skill-sync.test.ts | 87 +++++++++ server/src/adapters/registry.ts | 8 + server/src/adapters/types.ts | 5 + server/src/routes/agents.ts | 133 +++++++++++++ ui/src/api/agents.ts | 5 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AgentDetail.tsx | 147 ++++++++++++++ 22 files changed, 907 insertions(+), 26 deletions(-) create mode 100644 packages/adapters/claude-local/src/server/skills.ts create mode 100644 packages/adapters/codex-local/src/server/skills.ts create mode 100644 packages/shared/src/types/adapter-skills.ts create mode 100644 packages/shared/src/validators/adapter-skills.ts create mode 100644 server/src/__tests__/claude-local-skill-sync.test.ts create mode 100644 server/src/__tests__/codex-local-skill-sync.test.ts diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 56579022..c3dab36f 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -12,6 +12,11 @@ export type { AdapterEnvironmentTestStatus, AdapterEnvironmentTestResult, AdapterEnvironmentTestContext, + AdapterSkillSyncMode, + AdapterSkillState, + AdapterSkillEntry, + AdapterSkillSnapshot, + AdapterSkillContext, AdapterSessionCodec, AdapterModel, HireApprovedPayload, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 52e52b4c..2648e4a8 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -330,6 +330,49 @@ export async function readPaperclipSkillMarkdown( } } +export function readPaperclipSkillSyncPreference(config: Record): { + explicit: boolean; + desiredSkills: string[]; +} { + const raw = config.paperclipSkillSync; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { explicit: false, desiredSkills: [] }; + } + const syncConfig = raw as Record; + const desiredValues = syncConfig.desiredSkills; + const desired = Array.isArray(desiredValues) + ? desiredValues + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + : []; + return { + explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"), + desiredSkills: Array.from(new Set(desired)), + }; +} + +export function writePaperclipSkillSyncPreference( + config: Record, + desiredSkills: string[], +): Record { + const next = { ...config }; + const raw = next.paperclipSkillSync; + const current = + typeof raw === "object" && raw !== null && !Array.isArray(raw) + ? { ...(raw as Record) } + : {}; + current.desiredSkills = Array.from( + new Set( + desiredSkills + .map((value) => value.trim()) + .filter(Boolean), + ), + ); + next.paperclipSkillSync = current; + return next; +} + export async function ensurePaperclipSkillSymlink( source: string, target: string, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index df0d075a..52afdb66 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -138,6 +138,42 @@ export interface AdapterEnvironmentTestResult { testedAt: string; } +export type AdapterSkillSyncMode = "unsupported" | "persistent" | "ephemeral"; + +export type AdapterSkillState = + | "available" + | "configured" + | "installed" + | "missing" + | "stale" + | "external"; + +export interface AdapterSkillEntry { + name: string; + desired: boolean; + managed: boolean; + state: AdapterSkillState; + sourcePath?: string | null; + targetPath?: string | null; + detail?: string | null; +} + +export interface AdapterSkillSnapshot { + adapterType: string; + supported: boolean; + mode: AdapterSkillSyncMode; + desiredSkills: string[]; + entries: AdapterSkillEntry[]; + warnings: string[]; +} + +export interface AdapterSkillContext { + agentId: string; + companyId: string; + adapterType: string; + config: Record; +} + export interface AdapterEnvironmentTestContext { companyId: string; adapterType: string; @@ -175,6 +211,8 @@ export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; + listSkills?: (ctx: AdapterSkillContext) => Promise; + syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise; sessionCodec?: AdapterSessionCodec; supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 13d92df8..86131912 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { parseObject, parseJson, buildPaperclipEnv, + listPaperclipSkillEntries, joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, @@ -27,40 +28,32 @@ import { isClaudeMaxTurnsResult, isClaudeUnknownSessionError, } from "./parse.js"; +import { resolveClaudeDesiredSkillNames } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), // published: /dist/server/ -> /skills/ - path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/ -]; - -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} /** * Create a tmpdir with `.claude/skills/` containing symlinks to skills from * the repo's `skills/` directory, so `--add-dir` makes Claude Code discover * them as proper registered skills. */ -async function buildSkillsDir(): Promise { +async function buildSkillsDir(config: Record): Promise { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-")); const target = path.join(tmp, ".claude", "skills"); await fs.mkdir(target, { recursive: true }); - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return tmp; - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - await fs.symlink( - path.join(skillsDir, entry.name), - path.join(target, entry.name), - ); - } + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const desiredNames = new Set( + resolveClaudeDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ), + ); + for (const entry of availableEntries) { + if (!desiredNames.has(entry.name)) continue; + await fs.symlink( + entry.source, + path.join(target, entry.name), + ); } return tmp; } @@ -337,7 +330,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise, availableSkillNames: string[]) { + const preference = readPaperclipSkillSyncPreference(config); + return preference.explicit ? preference.desiredSkills : availableSkillNames; +} + +async function buildClaudeSkillSnapshot(config: Record): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + const desiredSkills = resolveDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + name: entry.name, + desired: desiredSet.has(entry.name), + managed: true, + state: desiredSet.has(entry.name) ? "configured" : "available", + sourcePath: entry.source, + targetPath: null, + detail: desiredSet.has(entry.name) + ? "Will be mounted into the ephemeral Claude skill directory on the next run." + : null, + })); + const warnings: string[] = []; + + for (const desiredSkill of desiredSkills) { + if (availableByName.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + name: desiredSkill, + desired: true, + managed: true, + state: "missing", + sourcePath: undefined, + targetPath: undefined, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + entries.sort((left, right) => left.name.localeCompare(right.name)); + + return { + adapterType: "claude_local", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +export async function listClaudeSkills(ctx: AdapterSkillContext): Promise { + return buildClaudeSkillSnapshot(ctx.config); +} + +export async function syncClaudeSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildClaudeSkillSnapshot(ctx.config); +} + +export function resolveClaudeDesiredSkillNames( + config: Record, + availableSkillNames: string[], +) { + return resolveDesiredSkillNames(config, availableSkillNames); +} diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index d4b3da46..479126f0 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -22,6 +22,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js"; +import { resolveCodexDesiredSkillNames } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const CODEX_ROLLOUT_NOISE_RE = @@ -92,6 +93,7 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: type EnsureCodexSkillsInjectedOptions = { skillsHome?: string; skillsEntries?: Awaited>; + desiredSkillNames?: string[]; linkSkill?: (source: string, target: string) => Promise; }; @@ -99,7 +101,11 @@ export async function ensureCodexSkillsInjected( onLog: AdapterExecutionContext["onLog"], options: EnsureCodexSkillsInjectedOptions = {}, ) { - const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); + const allSkillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); + const desiredSkillNames = + options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name); + const desiredSet = new Set(desiredSkillNames); + const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name)); if (skillsEntries.length === 0) return; const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills"); @@ -213,13 +219,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? path.resolve(envConfig.CODEX_HOME.trim()) : null; + const desiredSkillNames = resolveCodexDesiredSkillNames( + config, + (await listPaperclipSkillEntries(__moduleDir)).map((entry) => entry.name), + ); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const preparedWorktreeCodexHome = configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog); const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome; await ensureCodexSkillsInjected( onLog, - effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {}, + effectiveCodexHome + ? { + skillsHome: path.join(effectiveCodexHome, "skills"), + desiredSkillNames, + } + : { desiredSkillNames }, ); const hasExplicitApiKey = typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 04c1e368..bbae3fb4 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -1,4 +1,5 @@ export { execute, ensureCodexSkillsInjected } from "./execute.js"; +export { listCodexSkills, syncCodexSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; diff --git a/packages/adapters/codex-local/src/server/skills.ts b/packages/adapters/codex-local/src/server/skills.ts new file mode 100644 index 00000000..12e30347 --- /dev/null +++ b/packages/adapters/codex-local/src/server/skills.ts @@ -0,0 +1,179 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + ensurePaperclipSkillSymlink, + listPaperclipSkillEntries, + readPaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; +import { resolveCodexHomeDir } from "./codex-home.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveCodexSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredCodexHome = asString(env.CODEX_HOME); + const home = configuredCodexHome ? path.resolve(configuredCodexHome) : resolveCodexHomeDir(process.env); + return path.join(home, "skills"); +} + +function resolveDesiredSkillNames(config: Record, availableSkillNames: string[]) { + const preference = readPaperclipSkillSyncPreference(config); + return preference.explicit ? preference.desiredSkills : availableSkillNames; +} + +async function readInstalledSkillTargets(skillsHome: string) { + const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); + const out = new Map(); + for (const entry of entries) { + const fullPath = path.join(skillsHome, entry.name); + if (entry.isSymbolicLink()) { + const linkedPath = await fs.readlink(fullPath).catch(() => null); + out.set(entry.name, { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }); + continue; + } + if (entry.isDirectory()) { + out.set(entry.name, { targetPath: fullPath, kind: "directory" }); + continue; + } + out.set(entry.name, { targetPath: fullPath, kind: "file" }); + } + return out; +} + +async function buildCodexSkillSnapshot(config: Record): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + const desiredSkills = resolveDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveCodexSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = []; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.name) ?? null; + const desired = desiredSet.has(available.name); + let state: AdapterSkillEntry["state"] = "available"; + let managed = false; + let detail: string | null = null; + + if (installedEntry?.targetPath === available.source) { + managed = true; + state = desired ? "installed" : "stale"; + } else if (installedEntry) { + state = "external"; + detail = desired + ? "Skill name is occupied by an external installation." + : "Installed outside Paperclip management."; + } else if (desired) { + state = "missing"; + detail = "Configured but not currently linked into the Codex skills home."; + } + + entries.push({ + name: available.name, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.name), + detail, + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByName.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + name: desiredSkill, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: path.join(skillsHome, desiredSkill), + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableByName.has(name)) continue; + entries.push({ + name, + desired: false, + managed: false, + state: "external", + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: "Installed outside Paperclip management.", + }); + } + + entries.sort((left, right) => left.name.localeCompare(right.name)); + + return { + adapterType: "codex_local", + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +export async function listCodexSkills(ctx: AdapterSkillContext): Promise { + return buildCodexSkillSnapshot(ctx.config); +} + +export async function syncCodexSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveCodexSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.name)) continue; + const target = path.join(skillsHome, available.name); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByName.get(name); + if (!available) continue; + if (desiredSet.has(name)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildCodexSkillSnapshot(ctx.config); +} + +export function resolveCodexDesiredSkillNames( + config: Record, + availableSkillNames: string[], +) { + return resolveDesiredSkillNames(config, availableSkillNames); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1a222f27..04c2e33b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -65,6 +65,11 @@ export { export type { Company, + AgentSkillSyncMode, + AgentSkillState, + AgentSkillEntry, + AgentSkillSnapshot, + AgentSkillSyncRequest, Agent, AgentPermissions, AgentKeyCreated, @@ -136,6 +141,12 @@ export { updateCompanySchema, type CreateCompany, type UpdateCompany, + agentSkillStateSchema, + agentSkillSyncModeSchema, + agentSkillEntrySchema, + agentSkillSnapshotSchema, + agentSkillSyncSchema, + type AgentSkillSync, createAgentSchema, createAgentHireSchema, updateAgentSchema, diff --git a/packages/shared/src/types/adapter-skills.ts b/packages/shared/src/types/adapter-skills.ts new file mode 100644 index 00000000..750ebaa4 --- /dev/null +++ b/packages/shared/src/types/adapter-skills.ts @@ -0,0 +1,32 @@ +export type AgentSkillSyncMode = "unsupported" | "persistent" | "ephemeral"; + +export type AgentSkillState = + | "available" + | "configured" + | "installed" + | "missing" + | "stale" + | "external"; + +export interface AgentSkillEntry { + name: string; + desired: boolean; + managed: boolean; + state: AgentSkillState; + sourcePath?: string | null; + targetPath?: string | null; + detail?: string | null; +} + +export interface AgentSkillSnapshot { + adapterType: string; + supported: boolean; + mode: AgentSkillSyncMode; + desiredSkills: string[]; + entries: AgentSkillEntry[]; + warnings: string[]; +} + +export interface AgentSkillSyncRequest { + desiredSkills: string[]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 07862c58..9404fca3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,4 +1,11 @@ export type { Company } from "./company.js"; +export type { + AgentSkillSyncMode, + AgentSkillState, + AgentSkillEntry, + AgentSkillSnapshot, + AgentSkillSyncRequest, +} from "./adapter-skills.js"; export type { Agent, AgentPermissions, diff --git a/packages/shared/src/validators/adapter-skills.ts b/packages/shared/src/validators/adapter-skills.ts new file mode 100644 index 00000000..07a71de5 --- /dev/null +++ b/packages/shared/src/validators/adapter-skills.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +export const agentSkillStateSchema = z.enum([ + "available", + "configured", + "installed", + "missing", + "stale", + "external", +]); + +export const agentSkillSyncModeSchema = z.enum([ + "unsupported", + "persistent", + "ephemeral", +]); + +export const agentSkillEntrySchema = z.object({ + name: z.string().min(1), + desired: z.boolean(), + managed: z.boolean(), + state: agentSkillStateSchema, + sourcePath: z.string().nullable().optional(), + targetPath: z.string().nullable().optional(), + detail: z.string().nullable().optional(), +}); + +export const agentSkillSnapshotSchema = z.object({ + adapterType: z.string().min(1), + supported: z.boolean(), + mode: agentSkillSyncModeSchema, + desiredSkills: z.array(z.string().min(1)), + entries: z.array(agentSkillEntrySchema), + warnings: z.array(z.string()), +}); + +export const agentSkillSyncSchema = z.object({ + desiredSkills: z.array(z.string().min(1)), +}); + +export type AgentSkillSync = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ad74a1e8..e432510b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -4,6 +4,14 @@ export { type CreateCompany, type UpdateCompany, } from "./company.js"; +export { + agentSkillStateSchema, + agentSkillSyncModeSchema, + agentSkillEntrySchema, + agentSkillSnapshotSchema, + agentSkillSyncSchema, + type AgentSkillSync, +} from "./adapter-skills.js"; export { portabilityIncludeSchema, portabilitySecretRequirementSchema, diff --git a/server/src/__tests__/claude-local-skill-sync.test.ts b/server/src/__tests__/claude-local-skill-sync.test.ts new file mode 100644 index 00000000..5a6f13e2 --- /dev/null +++ b/server/src/__tests__/claude-local-skill-sync.test.ts @@ -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"); + }); +}); diff --git a/server/src/__tests__/codex-local-skill-sync.test.ts b/server/src/__tests__/codex-local-skill-sync.test.ts new file mode 100644 index 00000000..79c1d895 --- /dev/null +++ b/server/src/__tests__/codex-local-skill-sync.test.ts @@ -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 { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("codex local skill sync", () => { + const cleanupDirs = new Set(); + + 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(); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 14cdf6d9..d5914b4d 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -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, diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index c5708d8a..a43a4f54 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -13,6 +13,11 @@ export type { AdapterEnvironmentTestStatus, AdapterEnvironmentTestResult, AdapterEnvironmentTestContext, + AdapterSkillSyncMode, + AdapterSkillState, + AdapterSkillEntry, + AdapterSkillSnapshot, + AdapterSkillContext, AdapterSessionCodec, AdapterModel, ServerAdapterModule, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 6c60b644..9e429df6 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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>) { 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, + ); + 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, + 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); diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 85486af9..cb2d61b8 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,5 +1,6 @@ import type { Agent, + AgentSkillSnapshot, AdapterEnvironmentTestResult, AgentKeyCreated, AgentRuntimeState, @@ -107,6 +108,10 @@ export const agentsApi = { terminate: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/terminate"), {}), remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)), listKeys: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/keys")), + skills: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/skills")), + syncSkills: (id: string, desiredSkills: string[], companyId?: string) => + api.post(agentPath(id, companyId, "/skills/sync"), { desiredSkills }), createKey: (id: string, name: string, companyId?: string) => api.post(agentPath(id, companyId, "/keys"), { name }), revokeKey: (agentId: string, keyId: string, companyId?: string) => diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index c500afdc..b1da418d 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -9,6 +9,7 @@ export const queryKeys = { detail: (id: string) => ["agents", "detail", id] as const, runtimeState: (id: string) => ["agents", "runtime-state", id] as const, taskSessions: (id: string) => ["agents", "task-sessions", id] as const, + skills: (id: string) => ["agents", "skills", id] as const, keys: (agentId: string) => ["agents", "keys", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, adapterModels: (companyId: string, adapterType: string) => diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 30921807..f2086ee8 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1045,6 +1045,8 @@ function ConfigurationTab({ }) { const queryClient = useQueryClient(); const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); + const [skillDraft, setSkillDraft] = useState([]); + const [skillDirty, setSkillDirty] = useState(false); const lastAgentRef = useRef(agent); const { data: adapterModels } = useQuery({ @@ -1056,6 +1058,12 @@ function ConfigurationTab({ enabled: Boolean(companyId), }); + const { data: skillSnapshot } = useQuery({ + queryKey: queryKeys.agents.skills(agent.id), + queryFn: () => agentsApi.skills(agent.id, companyId), + enabled: Boolean(companyId), + }); + const updateAgent = useMutation({ mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId), onMutate: () => { @@ -1071,6 +1079,17 @@ function ConfigurationTab({ }, }); + const syncSkills = useMutation({ + mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId), + onSuccess: (snapshot) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) }); + setSkillDraft(snapshot.desiredSkills); + setSkillDirty(false); + }, + }); + useEffect(() => { if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) { setAwaitingRefreshAfterSave(false); @@ -1078,6 +1097,12 @@ function ConfigurationTab({ lastAgentRef.current = agent; }, [agent, awaitingRefreshAfterSave]); + useEffect(() => { + if (!skillSnapshot) return; + setSkillDraft(skillSnapshot.desiredSkills); + setSkillDirty(false); + }, [skillSnapshot]); + const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave; useEffect(() => { @@ -1118,6 +1143,128 @@ function ConfigurationTab({ + +
+

Skills

+
+ {!skillSnapshot ? ( +

Loading skill sync state…

+ ) : !skillSnapshot.supported ? ( +
+

+ This adapter does not implement skill sync yet. +

+ {skillSnapshot.warnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : ( + <> +

+ {skillSnapshot.mode === "persistent" + ? "These skills are synced into the adapter's persistent skills home." + : "These skills are mounted ephemerally for each Claude run."} +

+ +
+ {skillSnapshot.entries + .filter((entry) => entry.managed) + .map((entry) => { + const checked = skillDraft.includes(entry.name); + return ( + + ); + })} +
+ + {skillSnapshot.entries.some((entry) => entry.state === "external") && ( +
+
+ External skills +
+ {skillSnapshot.entries + .filter((entry) => entry.state === "external") + .map((entry) => ( +
+ {entry.name} + {entry.detail ? ` - ${entry.detail}` : ""} +
+ ))} +
+ )} + + {skillSnapshot.warnings.length > 0 && ( +
+ {skillSnapshot.warnings.map((warning) => ( +
{warning}
+ ))} +
+ )} + + {syncSkills.isError && ( +

+ {syncSkills.error instanceof Error + ? syncSkills.error.message + : "Failed to sync skills"} +

+ )} + +
+ + {skillDirty && ( + + )} +
+ + )} +
+
); }