Add skill sync for remaining local adapters

This commit is contained in:
Dotta
2026-03-14 19:22:23 -05:00
parent b2c0f3f9a5
commit e619e64433
18 changed files with 1284 additions and 262 deletions

View File

@@ -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 {
listCursorSkills,
syncCursorSkills,
} from "@paperclipai/adapter-cursor-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("cursor local skill sync", () => {
const cleanupDirs = new Set<string>();
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 Cursor skills home", async () => {
const home = await makeTempDir("paperclip-cursor-skill-sync-");
cleanupDirs.add(home);
const ctx = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "cursor",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
} as const;
const before = await listCursorSkills(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 syncCursorSkills(ctx, ["paperclip"]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-cursor-skill-prune-");
cleanupDirs.add(home);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "cursor",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
} as const;
await syncCursorSkills(configuredCtx, ["paperclip"]);
const clearedCtx = {
...configuredCtx,
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [],
},
},
} as const;
const after = await syncCursorSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).rejects.toThrow();
});
});

View File

@@ -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 {
listGeminiSkills,
syncGeminiSkills,
} from "@paperclipai/adapter-gemini-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("gemini local skill sync", () => {
const cleanupDirs = new Set<string>();
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 Gemini skills home", async () => {
const home = await makeTempDir("paperclip-gemini-skill-sync-");
cleanupDirs.add(home);
const ctx = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "gemini_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
} as const;
const before = await listGeminiSkills(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 syncGeminiSkills(ctx, ["paperclip"]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-gemini-skill-prune-");
cleanupDirs.add(home);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "gemini_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
} as const;
await syncGeminiSkills(configuredCtx, ["paperclip"]);
const clearedCtx = {
...configuredCtx,
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [],
},
},
} as const;
const after = await syncGeminiSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).rejects.toThrow();
});
});

View File

@@ -0,0 +1,88 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listOpenCodeSkills,
syncOpenCodeSkills,
} from "@paperclipai/adapter-opencode-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("opencode local skill sync", () => {
const cleanupDirs = new Set<string>();
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 shared Claude/OpenCode skills home", async () => {
const home = await makeTempDir("paperclip-opencode-skill-sync-");
cleanupDirs.add(home);
const ctx = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "opencode_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
} as const;
const before = await listOpenCodeSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncOpenCodeSkills(ctx, ["paperclip"]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-opencode-skill-prune-");
cleanupDirs.add(home);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "opencode_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
} as const;
await syncOpenCodeSkills(configuredCtx, ["paperclip"]);
const clearedCtx = {
...configuredCtx,
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [],
},
},
} as const;
const after = await syncOpenCodeSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).rejects.toThrow();
});
});

View File

@@ -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 {
listPiSkills,
syncPiSkills,
} from "@paperclipai/adapter-pi-local/server";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("pi local skill sync", () => {
const cleanupDirs = new Set<string>();
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 Pi skills home", async () => {
const home = await makeTempDir("paperclip-pi-skill-sync-");
cleanupDirs.add(home);
const ctx = {
agentId: "agent-1",
companyId: "company-1",
adapterType: "pi_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
} as const;
const before = await listPiSkills(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 syncPiSkills(ctx, ["paperclip"]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-pi-skill-prune-");
cleanupDirs.add(home);
const configuredCtx = {
agentId: "agent-2",
companyId: "company-1",
adapterType: "pi_local",
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
},
},
} as const;
await syncPiSkills(configuredCtx, ["paperclip"]);
const clearedCtx = {
...configuredCtx,
config: {
env: {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: [],
},
},
} as const;
const after = await syncPiSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).rejects.toThrow();
});
});

View File

@@ -17,18 +17,24 @@ import {
import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local";
import {
execute as cursorExecute,
listCursorSkills,
syncCursorSkills,
testEnvironment as cursorTestEnvironment,
sessionCodec as cursorSessionCodec,
} from "@paperclipai/adapter-cursor-local/server";
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
import {
execute as geminiExecute,
listGeminiSkills,
syncGeminiSkills,
testEnvironment as geminiTestEnvironment,
sessionCodec as geminiSessionCodec,
} from "@paperclipai/adapter-gemini-local/server";
import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local";
import {
execute as openCodeExecute,
listOpenCodeSkills,
syncOpenCodeSkills,
testEnvironment as openCodeTestEnvironment,
sessionCodec as openCodeSessionCodec,
listOpenCodeModels,
@@ -48,6 +54,8 @@ import { listCodexModels } from "./codex-models.js";
import { listCursorModels } from "./cursor-models.js";
import {
execute as piExecute,
listPiSkills,
syncPiSkills,
testEnvironment as piTestEnvironment,
sessionCodec as piSessionCodec,
listPiModels,
@@ -87,6 +95,8 @@ const cursorLocalAdapter: ServerAdapterModule = {
type: "cursor",
execute: cursorExecute,
testEnvironment: cursorTestEnvironment,
listSkills: listCursorSkills,
syncSkills: syncCursorSkills,
sessionCodec: cursorSessionCodec,
models: cursorModels,
listModels: listCursorModels,
@@ -98,6 +108,8 @@ const geminiLocalAdapter: ServerAdapterModule = {
type: "gemini_local",
execute: geminiExecute,
testEnvironment: geminiTestEnvironment,
listSkills: listGeminiSkills,
syncSkills: syncGeminiSkills,
sessionCodec: geminiSessionCodec,
models: geminiModels,
supportsLocalAgentJwt: true,
@@ -117,6 +129,8 @@ const openCodeLocalAdapter: ServerAdapterModule = {
type: "opencode_local",
execute: openCodeExecute,
testEnvironment: openCodeTestEnvironment,
listSkills: listOpenCodeSkills,
syncSkills: syncOpenCodeSkills,
sessionCodec: openCodeSessionCodec,
models: [],
listModels: listOpenCodeModels,
@@ -128,6 +142,8 @@ const piLocalAdapter: ServerAdapterModule = {
type: "pi_local",
execute: piExecute,
testEnvironment: piTestEnvironment,
listSkills: listPiSkills,
syncSkills: syncPiSkills,
sessionCodec: piSessionCodec,
models: [],
listModels: listPiModels,