Fix runtime skill injection across adapters

This commit is contained in:
Dotta
2026-03-15 07:05:01 -05:00
parent 82f253c310
commit 7675fd0856
27 changed files with 506 additions and 222 deletions

View File

@@ -16,6 +16,7 @@ describe("claude local skill sync", () => {
expect(snapshot.mode).toBe("ephemeral");
expect(snapshot.supported).toBe(true);
expect(snapshot.desiredSkills).toContain("paperclip");
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
});
@@ -31,8 +32,8 @@ describe("claude local skill sync", () => {
},
}, ["paperclip"]);
expect(snapshot.desiredSkills).toEqual(["paperclip"]);
expect(snapshot.desiredSkills).toContain("paperclip");
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("available");
expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("configured");
});
});

View File

@@ -39,7 +39,8 @@ describe("codex local skill sync", () => {
const before = await listCodexSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncCodexSkills(ctx, ["paperclip"]);
@@ -47,7 +48,7 @@ describe("codex local skill sync", () => {
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
cleanupDirs.add(codexHome);
@@ -80,8 +81,8 @@ describe("codex local skill sync", () => {
} as const;
const after = await syncCodexSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toThrow();
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -46,6 +46,13 @@ type CapturePayload = {
paperclipEnvKeys: string[];
};
async function createSkillDir(root: string, name: string) {
const skillDir = path.join(root, name);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
return skillDir;
}
describe("cursor execute", () => {
it("injects paperclip env vars and prompt note by default", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-"));
@@ -179,4 +186,77 @@ describe("cursor execute", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("injects company-library runtime skills into the Cursor skills home before execution", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "agent");
const runtimeSkillsRoot = path.join(root, "runtime-skills");
await fs.mkdir(workspace, { recursive: true });
await writeFakeCursorCommand(commandPath);
const paperclipDir = await createSkillDir(runtimeSkillsRoot, "paperclip");
const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart");
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-3",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Cursor Coder",
adapterType: "cursor",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
model: "auto",
paperclipRuntimeSkills: [
{
name: "paperclip",
source: paperclipDir,
required: true,
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
},
{
name: "ascii-heart",
source: asciiHeartDir,
},
],
paperclipSkillSync: {
desiredSkills: ["ascii-heart"],
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe(
await fs.realpath(asciiHeartDir),
);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -11,6 +11,13 @@ async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function createSkillDir(root: string, name: string) {
const skillDir = path.join(root, name);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
return skillDir;
}
describe("cursor local skill sync", () => {
const cleanupDirs = new Set<string>();
@@ -39,7 +46,8 @@ describe("cursor local skill sync", () => {
const before = await listCursorSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncCursorSkills(ctx, ["paperclip"]);
@@ -47,7 +55,53 @@ describe("cursor local skill sync", () => {
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 () => {
it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => {
const home = await makeTempDir("paperclip-cursor-runtime-skills-home-");
const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-");
cleanupDirs.add(home);
cleanupDirs.add(runtimeSkills);
const paperclipDir = await createSkillDir(runtimeSkills, "paperclip");
const asciiHeartDir = await createSkillDir(runtimeSkills, "ascii-heart");
const ctx = {
agentId: "agent-3",
companyId: "company-1",
adapterType: "cursor",
config: {
env: {
HOME: home,
},
paperclipRuntimeSkills: [
{
name: "paperclip",
source: paperclipDir,
required: true,
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
},
{
name: "ascii-heart",
source: asciiHeartDir,
},
],
paperclipSkillSync: {
desiredSkills: ["ascii-heart"],
},
},
} as const;
const before = await listCursorSkills(ctx);
expect(before.warnings).toEqual([]);
expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]);
expect(before.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("missing");
const after = await syncCursorSkills(ctx, ["ascii-heart"]);
expect(after.warnings).toEqual([]);
expect(after.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
});
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-cursor-skill-prune-");
cleanupDirs.add(home);
@@ -80,8 +134,8 @@ describe("cursor local skill sync", () => {
} 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();
expect(after.desiredSkills).toContain("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);
});
});

View File

@@ -39,7 +39,8 @@ describe("gemini local skill sync", () => {
const before = await listGeminiSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncGeminiSkills(ctx, ["paperclip"]);
@@ -47,7 +48,7 @@ describe("gemini local skill sync", () => {
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 () => {
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-gemini-skill-prune-");
cleanupDirs.add(home);
@@ -80,8 +81,8 @@ describe("gemini local skill sync", () => {
} 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();
expect(after.desiredSkills).toContain("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);
});
});

View File

@@ -40,7 +40,8 @@ describe("opencode local skill sync", () => {
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.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncOpenCodeSkills(ctx, ["paperclip"]);
@@ -48,7 +49,7 @@ describe("opencode local skill sync", () => {
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 () => {
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-opencode-skill-prune-");
cleanupDirs.add(home);
@@ -81,8 +82,8 @@ describe("opencode local skill sync", () => {
} 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();
expect(after.desiredSkills).toContain("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);
});
});

View File

@@ -39,7 +39,8 @@ describe("pi local skill sync", () => {
const before = await listPiSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncPiSkills(ctx, ["paperclip"]);
@@ -47,7 +48,7 @@ describe("pi local skill sync", () => {
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 () => {
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-pi-skill-prune-");
cleanupDirs.add(home);
@@ -80,8 +81,8 @@ describe("pi local skill sync", () => {
} 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();
expect(after.desiredSkills).toContain("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);
});
});