Add ServerAdapterModule capabilities from fork's adapter-utils 0.3.1
Bring the K8s adapter up to parity with the fork's ServerAdapterModule contract by adding sessionManagement, listSkills/syncSkills, listModels with Bedrock detection, and promptBundleKey support in the session codec. - Declare sessionManagement with nativeContextManagement: "confirmed" so Paperclip skips threshold-based session compaction (Claude manages its own context) - Add ephemeral skill management (listSkills/syncSkills) mirroring claude_local — reports skill state without runtime persistence since skills are injected via prompt bundle into ephemeral Job pods - Add listModels() with Bedrock environment detection, returning region-qualified model IDs when CLAUDE_CODE_USE_BEDROCK or ANTHROPIC_BEDROCK_BASE_URL are set - Extend session codec to round-trip promptBundleKey field - Remove the `as ServerAdapterModule` cast — the return type now satisfies the full interface Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+19
-2
@@ -1,9 +1,22 @@
|
|||||||
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
|
import type { ServerAdapterModule, AdapterSessionManagement } from "@paperclipai/adapter-utils";
|
||||||
import { type, models, agentConfigurationDoc } from "../index.js";
|
import { type, models, agentConfigurationDoc } from "../index.js";
|
||||||
import { execute } from "./execute.js";
|
import { execute } from "./execute.js";
|
||||||
import { testEnvironment } from "./test.js";
|
import { testEnvironment } from "./test.js";
|
||||||
import { sessionCodec } from "./session.js";
|
import { sessionCodec } from "./session.js";
|
||||||
import { getConfigSchema } from "./config-schema.js";
|
import { getConfigSchema } from "./config-schema.js";
|
||||||
|
import { listK8sSkills, syncK8sSkills } from "./skills.js";
|
||||||
|
import { listK8sModels } from "./models.js";
|
||||||
|
|
||||||
|
const sessionManagement: AdapterSessionManagement = {
|
||||||
|
supportsSessionResume: true,
|
||||||
|
nativeContextManagement: "confirmed",
|
||||||
|
defaultSessionCompaction: {
|
||||||
|
enabled: true,
|
||||||
|
maxSessionRuns: 0,
|
||||||
|
maxRawInputTokens: 0,
|
||||||
|
maxSessionAgeHours: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function createServerAdapter(): ServerAdapterModule {
|
export function createServerAdapter(): ServerAdapterModule {
|
||||||
return {
|
return {
|
||||||
@@ -11,11 +24,15 @@ export function createServerAdapter(): ServerAdapterModule {
|
|||||||
execute,
|
execute,
|
||||||
testEnvironment,
|
testEnvironment,
|
||||||
sessionCodec,
|
sessionCodec,
|
||||||
|
sessionManagement,
|
||||||
models,
|
models,
|
||||||
|
listModels: listK8sModels,
|
||||||
|
listSkills: listK8sSkills,
|
||||||
|
syncSkills: syncK8sSkills,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
agentConfigurationDoc,
|
agentConfigurationDoc,
|
||||||
getConfigSchema,
|
getConfigSchema,
|
||||||
} as ServerAdapterModule;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { execute, testEnvironment, sessionCodec };
|
export { execute, testEnvironment, sessionCodec };
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { listK8sModels } from "./models.js";
|
||||||
|
|
||||||
|
describe("listK8sModels", () => {
|
||||||
|
const savedEnv: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedEnv.CLAUDE_CODE_USE_BEDROCK = process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||||
|
savedEnv.ANTHROPIC_BEDROCK_BASE_URL = process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
||||||
|
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||||
|
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const [key, value] of Object.entries(savedEnv)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns direct API models by default", async () => {
|
||||||
|
const models = await listK8sModels();
|
||||||
|
expect(models.some((m) => m.id === "claude-opus-4-6")).toBe(true);
|
||||||
|
expect(models.every((m) => !m.id.includes("anthropic."))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Bedrock models when CLAUDE_CODE_USE_BEDROCK=1", async () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
|
const models = await listK8sModels();
|
||||||
|
expect(models.some((m) => m.id.includes("anthropic."))).toBe(true);
|
||||||
|
expect(models.every((m) => !m.id.startsWith("claude-"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Bedrock models when CLAUDE_CODE_USE_BEDROCK=true", async () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK = "true";
|
||||||
|
const models = await listK8sModels();
|
||||||
|
expect(models.some((m) => m.id.includes("anthropic."))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Bedrock models when ANTHROPIC_BEDROCK_BASE_URL is set", async () => {
|
||||||
|
process.env.ANTHROPIC_BEDROCK_BASE_URL = "https://bedrock.us-east-1.amazonaws.com";
|
||||||
|
const models = await listK8sModels();
|
||||||
|
expect(models.some((m) => m.id.includes("anthropic."))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores blank ANTHROPIC_BEDROCK_BASE_URL", async () => {
|
||||||
|
process.env.ANTHROPIC_BEDROCK_BASE_URL = " ";
|
||||||
|
const models = await listK8sModels();
|
||||||
|
expect(models.some((m) => m.id === "claude-opus-4-6")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||||
|
import { models as DIRECT_MODELS } from "../index.js";
|
||||||
|
|
||||||
|
const BEDROCK_MODELS: AdapterModel[] = [
|
||||||
|
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
|
||||||
|
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" },
|
||||||
|
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isBedrockEnv(): boolean {
|
||||||
|
return (
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
|
||||||
|
(typeof process.env.ANTHROPIC_BEDROCK_BASE_URL === "string" &&
|
||||||
|
process.env.ANTHROPIC_BEDROCK_BASE_URL.trim().length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listK8sModels(): Promise<AdapterModel[]> {
|
||||||
|
return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
|
||||||
|
}
|
||||||
@@ -92,6 +92,37 @@ describe("sessionCodec", () => {
|
|||||||
repoRef: "main",
|
repoRef: "main",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps promptBundleKey", () => {
|
||||||
|
expect(deserialize({ sessionId: "s", promptBundleKey: "bundle-1" })?.promptBundleKey).toBe("bundle-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps prompt_bundle_key as fallback", () => {
|
||||||
|
expect(deserialize({ sessionId: "s", prompt_bundle_key: "bundle-2" })?.promptBundleKey).toBe("bundle-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers promptBundleKey over prompt_bundle_key", () => {
|
||||||
|
const result = deserialize({ sessionId: "s", promptBundleKey: "a", prompt_bundle_key: "b" });
|
||||||
|
expect(result?.promptBundleKey).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits promptBundleKey when empty", () => {
|
||||||
|
const result = deserialize({ sessionId: "s", promptBundleKey: "" });
|
||||||
|
expect(result).not.toHaveProperty("promptBundleKey");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes promptBundleKey in full round-trip", () => {
|
||||||
|
const result = deserialize({
|
||||||
|
sessionId: "sess_abc",
|
||||||
|
cwd: "/work",
|
||||||
|
promptBundleKey: "bundle-key-123",
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
sessionId: "sess_abc",
|
||||||
|
cwd: "/work",
|
||||||
|
promptBundleKey: "bundle-key-123",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("serialize", () => {
|
describe("serialize", () => {
|
||||||
@@ -125,6 +156,16 @@ describe("sessionCodec", () => {
|
|||||||
expect(serialize(input)).toEqual(input);
|
expect(serialize(input)).toEqual(input);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes promptBundleKey", () => {
|
||||||
|
const result = serialize({ sessionId: "s", promptBundleKey: "bundle-1" });
|
||||||
|
expect(result?.promptBundleKey).toBe("bundle-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits promptBundleKey when empty", () => {
|
||||||
|
const result = serialize({ sessionId: "s", promptBundleKey: "" });
|
||||||
|
expect(result).not.toHaveProperty("promptBundleKey");
|
||||||
|
});
|
||||||
|
|
||||||
it("omits undefined fields", () => {
|
it("omits undefined fields", () => {
|
||||||
const result = serialize({ sessionId: "sess_abc" });
|
const result = serialize({ sessionId: "sess_abc" });
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
|||||||
+20
-17
@@ -4,44 +4,47 @@ function readNonEmptyString(value: unknown): string | null {
|
|||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractSessionFields(record: Record<string, unknown>) {
|
||||||
|
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
|
||||||
|
const cwd =
|
||||||
|
readNonEmptyString(record.cwd) ??
|
||||||
|
readNonEmptyString(record.workdir) ??
|
||||||
|
readNonEmptyString(record.folder);
|
||||||
|
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||||
|
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||||
|
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||||
|
const promptBundleKey =
|
||||||
|
readNonEmptyString(record.promptBundleKey) ?? readNonEmptyString(record.prompt_bundle_key);
|
||||||
|
return { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey };
|
||||||
|
}
|
||||||
|
|
||||||
export const sessionCodec: AdapterSessionCodec = {
|
export const sessionCodec: AdapterSessionCodec = {
|
||||||
deserialize(raw: unknown) {
|
deserialize(raw: unknown) {
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||||
const record = raw as Record<string, unknown>;
|
const { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey } =
|
||||||
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
|
extractSessionFields(raw as Record<string, unknown>);
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
const cwd =
|
|
||||||
readNonEmptyString(record.cwd) ??
|
|
||||||
readNonEmptyString(record.workdir) ??
|
|
||||||
readNonEmptyString(record.folder);
|
|
||||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
|
||||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
|
||||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
...(cwd ? { cwd } : {}),
|
...(cwd ? { cwd } : {}),
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(repoUrl ? { repoUrl } : {}),
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
...(repoRef ? { repoRef } : {}),
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
serialize(params: Record<string, unknown> | null) {
|
serialize(params: Record<string, unknown> | null) {
|
||||||
if (!params) return null;
|
if (!params) return null;
|
||||||
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
const { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey } =
|
||||||
|
extractSessionFields(params);
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
const cwd =
|
|
||||||
readNonEmptyString(params.cwd) ??
|
|
||||||
readNonEmptyString(params.workdir) ??
|
|
||||||
readNonEmptyString(params.folder);
|
|
||||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
|
||||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
|
||||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
...(cwd ? { cwd } : {}),
|
...(cwd ? { cwd } : {}),
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(repoUrl ? { repoUrl } : {}),
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
...(repoRef ? { repoRef } : {}),
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getDisplayId(params: Record<string, unknown> | null) {
|
getDisplayId(params: Record<string, unknown> | null) {
|
||||||
|
|||||||
@@ -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 { listK8sSkills, syncK8sSkills } 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: "claude_k8s",
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("listK8sSkills", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedReadEntries.mockResolvedValue([]);
|
||||||
|
mockedResolveDesired.mockReturnValue([]);
|
||||||
|
mockedReadInstalled.mockResolvedValue(new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty snapshot when no skills available", async () => {
|
||||||
|
const snapshot = await listK8sSkills(ctx);
|
||||||
|
expect(snapshot.adapterType).toBe("claude_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 listK8sSkills(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 listK8sSkills(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 listK8sSkills(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" }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await listK8sSkills(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" }]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await listK8sSkills(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 listK8sSkills(ctx);
|
||||||
|
expect(snapshot.entries[0].key).toBe("paperclip/alpha");
|
||||||
|
expect(snapshot.entries[1].key).toBe("paperclip/zebra");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncK8sSkills", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedReadEntries.mockResolvedValue([]);
|
||||||
|
mockedResolveDesired.mockReturnValue([]);
|
||||||
|
mockedReadInstalled.mockResolvedValue(new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns same snapshot as listK8sSkills (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 listK8sSkills(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 syncK8sSkills(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 buildK8sSkillSnapshot(
|
||||||
|
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: "claude_k8s",
|
||||||
|
supported: true,
|
||||||
|
mode: "ephemeral",
|
||||||
|
desiredSkills,
|
||||||
|
entries,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listK8sSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||||
|
return buildK8sSkillSnapshot(ctx.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncK8sSkills(
|
||||||
|
ctx: AdapterSkillContext,
|
||||||
|
_desiredSkills: string[],
|
||||||
|
): Promise<AdapterSkillSnapshot> {
|
||||||
|
return buildK8sSkillSnapshot(ctx.config);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user