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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user