From 7c9fc77d488978e98633470ad60ca4b73f04deba Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 15 Apr 2026 23:42:44 -0400 Subject: [PATCH] Add listSkills and syncSkills support to opencode_k8s adapter Implement skill sync handlers that were missing, matching the approach used in the claude_k8s adapter. The adapter now surfaces available, configured, and external skills from /paperclip/.claude/skills in K8s pods, resolving desired skills from config and reporting missing ones. Co-Authored-By: Claude Opus 4.6 --- src/server/index.ts | 3 + src/server/skills.test.ts | 142 ++++++++++++++++++++++++++++++++++++++ src/server/skills.ts | 101 +++++++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 src/server/skills.test.ts create mode 100644 src/server/skills.ts diff --git a/src/server/index.ts b/src/server/index.ts index 1d3f442..72065d1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,6 +5,7 @@ import { execute } from "./execute.js"; import { testEnvironment } from "./test.js"; import { sessionCodec } from "./session.js"; import { getConfigSchema } from "./config-schema.js"; +import { listOpenCodeSkills, syncOpenCodeSkills } from "./skills.js"; export function createServerAdapter(): ServerAdapterModule { return { @@ -13,6 +14,8 @@ export function createServerAdapter(): ServerAdapterModule { testEnvironment, sessionCodec, models, + listSkills: listOpenCodeSkills, + syncSkills: syncOpenCodeSkills, supportsLocalAgentJwt: true, agentConfigurationDoc, getConfigSchema, diff --git a/src/server/skills.test.ts b/src/server/skills.test.ts new file mode 100644 index 0000000..bbc49f7 --- /dev/null +++ b/src/server/skills.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { AdapterSkillContext } from "@paperclipai/adapter-utils"; + +vi.mock("@paperclipai/adapter-utils/server-utils", () => ({ + readPaperclipRuntimeSkillEntries: vi.fn().mockResolvedValue([]), + resolvePaperclipDesiredSkillNames: vi.fn().mockReturnValue([]), + readInstalledSkillTargets: vi.fn().mockResolvedValue(new Map()), +})); + +import { listOpenCodeSkills, syncOpenCodeSkills } from "./skills.js"; +import { + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, + readInstalledSkillTargets, +} from "@paperclipai/adapter-utils/server-utils"; + +const mockedReadEntries = vi.mocked(readPaperclipRuntimeSkillEntries); +const mockedResolveDesired = vi.mocked(resolvePaperclipDesiredSkillNames); +const mockedReadInstalled = vi.mocked(readInstalledSkillTargets); + +const ctx: AdapterSkillContext = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "opencode_k8s", + config: {}, +}; + +describe("listOpenCodeSkills", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedReadEntries.mockResolvedValue([]); + mockedResolveDesired.mockReturnValue([]); + mockedReadInstalled.mockResolvedValue(new Map()); + }); + + it("returns empty snapshot when no skills available", async () => { + const snapshot = await listOpenCodeSkills(ctx); + expect(snapshot.adapterType).toBe("opencode_k8s"); + expect(snapshot.supported).toBe(true); + expect(snapshot.mode).toBe("ephemeral"); + expect(snapshot.entries).toEqual([]); + expect(snapshot.warnings).toEqual([]); + }); + + it("marks desired available skills as configured", async () => { + mockedReadEntries.mockResolvedValue([ + { key: "paperclip/skill-a", runtimeName: "skill-a", source: "/skills/skill-a", required: false }, + ]); + mockedResolveDesired.mockReturnValue(["paperclip/skill-a"]); + + const snapshot = await listOpenCodeSkills(ctx); + expect(snapshot.entries).toHaveLength(1); + expect(snapshot.entries[0].state).toBe("configured"); + expect(snapshot.entries[0].desired).toBe(true); + expect(snapshot.entries[0].origin).toBe("company_managed"); + }); + + it("marks required skills with paperclip_required origin", async () => { + mockedReadEntries.mockResolvedValue([ + { key: "paperclip/core", runtimeName: "core", source: "/skills/core", required: true, requiredReason: "Bundled" }, + ]); + mockedResolveDesired.mockReturnValue(["paperclip/core"]); + + const snapshot = await listOpenCodeSkills(ctx); + expect(snapshot.entries[0].origin).toBe("paperclip_required"); + expect(snapshot.entries[0].required).toBe(true); + }); + + it("adds warning for desired skill not in available entries", async () => { + mockedReadEntries.mockResolvedValue([]); + mockedResolveDesired.mockReturnValue(["missing/skill"]); + + const snapshot = await listOpenCodeSkills(ctx); + expect(snapshot.warnings).toHaveLength(1); + expect(snapshot.warnings[0]).toContain("missing/skill"); + expect(snapshot.entries).toHaveLength(1); + expect(snapshot.entries[0].state).toBe("missing"); + }); + + it("lists external user-installed skills", async () => { + mockedReadInstalled.mockResolvedValue( + new Map([["user-skill", { targetPath: "/paperclip/.claude/skills/user-skill", kind: "directory" }]]), + ); + + const snapshot = await listOpenCodeSkills(ctx); + expect(snapshot.entries).toHaveLength(1); + expect(snapshot.entries[0].state).toBe("external"); + expect(snapshot.entries[0].origin).toBe("user_installed"); + expect(snapshot.entries[0].managed).toBe(false); + }); + + it("does not duplicate externally installed skills that match available entries", async () => { + mockedReadEntries.mockResolvedValue([ + { key: "paperclip/skill-a", runtimeName: "skill-a", source: "/skills/skill-a", required: false }, + ]); + mockedReadInstalled.mockResolvedValue( + new Map([["skill-a", { targetPath: "/paperclip/.claude/skills/skill-a", kind: "directory" }]]), + ); + + const snapshot = await listOpenCodeSkills(ctx); + expect(snapshot.entries).toHaveLength(1); + expect(snapshot.entries[0].key).toBe("paperclip/skill-a"); + }); + + it("sorts entries alphabetically by key", async () => { + mockedReadEntries.mockResolvedValue([ + { key: "paperclip/zebra", runtimeName: "zebra", source: "/skills/zebra", required: false }, + { key: "paperclip/alpha", runtimeName: "alpha", source: "/skills/alpha", required: false }, + ]); + + const snapshot = await listOpenCodeSkills(ctx); + expect(snapshot.entries[0].key).toBe("paperclip/alpha"); + expect(snapshot.entries[1].key).toBe("paperclip/zebra"); + }); +}); + +describe("syncOpenCodeSkills", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedReadEntries.mockResolvedValue([]); + mockedResolveDesired.mockReturnValue([]); + mockedReadInstalled.mockResolvedValue(new Map()); + }); + + it("returns same snapshot as listOpenCodeSkills (ephemeral pass-through)", async () => { + mockedReadEntries.mockResolvedValue([ + { key: "paperclip/skill-a", runtimeName: "skill-a", source: "/skills/skill-a", required: false }, + ]); + mockedResolveDesired.mockReturnValue(["paperclip/skill-a"]); + + const listResult = await listOpenCodeSkills(ctx); + vi.clearAllMocks(); + mockedReadEntries.mockResolvedValue([ + { key: "paperclip/skill-a", runtimeName: "skill-a", source: "/skills/skill-a", required: false }, + ]); + mockedResolveDesired.mockReturnValue(["paperclip/skill-a"]); + mockedReadInstalled.mockResolvedValue(new Map()); + + const syncResult = await syncOpenCodeSkills(ctx, ["paperclip/skill-a"]); + expect(syncResult).toEqual(listResult); + }); +}); diff --git a/src/server/skills.ts b/src/server/skills.ts new file mode 100644 index 0000000..a4b083c --- /dev/null +++ b/src/server/skills.ts @@ -0,0 +1,101 @@ +import type { + AdapterSkillContext, + AdapterSkillSnapshot, + AdapterSkillEntry, +} from "@paperclipai/adapter-utils"; +import { + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, + readInstalledSkillTargets, +} from "@paperclipai/adapter-utils/server-utils"; +import path from "node:path"; + +const SKILLS_HOME = "/paperclip/.claude/skills"; + +async function buildOpenCodeSkillSnapshot( + config: Record, +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, import.meta.dirname ?? __dirname); + const availableByKey = new Map(availableEntries.map((e) => [e.key, e])); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const installed = await readInstalledSkillTargets(SKILLS_HOME); + + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + key: entry.key, + runtimeName: entry.runtimeName, + desired: desiredSet.has(entry.key), + managed: true, + state: desiredSet.has(entry.key) ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desiredSet.has(entry.key) + ? "Injected via prompt bundle into ephemeral K8s Job pods." + : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, + })); + + const warnings: string[] = []; + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: undefined, + targetPath: undefined, + detail: "Paperclip cannot find this skill in the runtime skills directory.", + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableEntries.some((e) => e.runtimeName === name)) continue; + entries.push({ + key: name, + runtimeName: name, + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: "~/.claude/skills", + readOnly: true, + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(SKILLS_HOME, name), + detail: "Installed outside Paperclip management in the Claude skills home.", + }); + } + + entries.sort((a, b) => a.key.localeCompare(b.key)); + + return { + adapterType: "opencode_k8s", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +export async function listOpenCodeSkills(ctx: AdapterSkillContext): Promise { + return buildOpenCodeSkillSnapshot(ctx.config); +} + +export async function syncOpenCodeSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildOpenCodeSkillSnapshot(ctx.config); +}