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 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 23:42:44 -04:00
parent 0837ee4255
commit 7c9fc77d48
3 changed files with 246 additions and 0 deletions
+3
View File
@@ -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,
+142
View File
@@ -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);
});
});
+101
View File
@@ -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<string, unknown>,
): Promise<AdapterSkillSnapshot> {
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<AdapterSkillSnapshot> {
return buildOpenCodeSkillSnapshot(ctx.config);
}
export async function syncOpenCodeSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
return buildOpenCodeSkillSnapshot(ctx.config);
}