Namespace company skill identities

Persist canonical namespaced skill keys, split adapter runtime names from skill keys, and update portability/import flows to carry the canonical identity end-to-end.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-16 18:27:20 -05:00
parent bb46423969
commit 5890b318c4
39 changed files with 9902 additions and 309 deletions

View File

@@ -5,6 +5,9 @@ import {
} from "@paperclipai/adapter-claude-local/server";
describe("claude local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
const snapshot = await listClaudeSkills({
agentId: "agent-1",
@@ -15,9 +18,9 @@ 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");
expect(snapshot.desiredSkills).toContain(paperclipKey);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
});
it("respects an explicit desired skill list without mutating a persistent home", async () => {
@@ -27,13 +30,13 @@ describe("claude local skill sync", () => {
adapterType: "claude_local",
config: {
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
}, ["paperclip"]);
}, [paperclipKey]);
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("configured");
expect(snapshot.desiredSkills).toContain(paperclipKey);
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured");
});
});

View File

@@ -31,6 +31,7 @@ async function createCustomSkill(root: string, skillName: string) {
}
describe("codex local adapter skill injection", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
@@ -57,14 +58,18 @@ describe("codex local adapter skill injection", () => {
},
{
skillsHome,
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
skillsEntries: [{
key: paperclipKey,
runtimeName: "paperclip",
source: path.join(currentRepo, "skills", "paperclip"),
}],
},
);
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
await fs.realpath(path.join(currentRepo, "skills", "paperclip")),
);
expect(logs.some((line) => line.includes('Repaired Codex skill "paperclip"'))).toBe(true);
expect(logs.some((line) => line.includes("Repaired Codex skill"))).toBe(true);
});
it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => {
@@ -81,7 +86,11 @@ describe("codex local adapter skill injection", () => {
await ensureCodexSkillsInjected(async () => {}, {
skillsHome,
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
skillsEntries: [{
key: paperclipKey,
runtimeName: "paperclip",
source: path.join(currentRepo, "skills", "paperclip"),
}],
});
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(

View File

@@ -12,6 +12,7 @@ async function makeTempDir(prefix: string): Promise<string> {
}
describe("codex local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
@@ -32,19 +33,19 @@ describe("codex local skill sync", () => {
CODEX_HOME: codexHome,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
const before = await listCodexSkills(ctx);
expect(before.mode).toBe("persistent");
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");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
const after = await syncCodexSkills(ctx, ["paperclip"]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
const after = await syncCodexSkills(ctx, [paperclipKey]);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
@@ -61,12 +62,12 @@ describe("codex local skill sync", () => {
CODEX_HOME: codexHome,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
await syncCodexSkills(configuredCtx, ["paperclip"]);
await syncCodexSkills(configuredCtx, [paperclipKey]);
const clearedCtx = {
...configuredCtx,
@@ -81,8 +82,8 @@ describe("codex local skill sync", () => {
} as const;
const after = await syncCodexSkills(clearedCtx, []);
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -63,6 +63,9 @@ vi.mock("../services/company-skills.js", () => ({
const { companyPortabilityService } = await import("../services/company-portability.js");
describe("company portability", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const companyPlaybookKey = "company/company-1/company-playbook";
beforeEach(() => {
vi.clearAllMocks();
companySvc.getById.mockResolvedValue({
@@ -86,7 +89,7 @@ describe("company portability", () => {
adapterConfig: {
promptTemplate: "You are ClaudeCoder.",
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
instructionsFilePath: "/tmp/ignored.md",
cwd: "/tmp/ignored",
@@ -153,6 +156,7 @@ describe("company portability", () => {
{
id: "skill-1",
companyId: "company-1",
key: paperclipKey,
slug: "paperclip",
name: "paperclip",
description: "Paperclip coordination skill",
@@ -178,6 +182,7 @@ describe("company portability", () => {
{
id: "skill-2",
companyId: "company-1",
key: companyPlaybookKey,
slug: "company-playbook",
name: "company-playbook",
description: "Internal company skill",
@@ -244,13 +249,13 @@ describe("company portability", () => {
expect(exported.files["COMPANY.md"]).toContain('schema: "agentcompanies/v1"');
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("You are ClaudeCoder.");
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("skills:");
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain('- "paperclip"');
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain(`- "${paperclipKey}"`);
expect(exported.files["agents/cmo/AGENTS.md"]).not.toContain("skills:");
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
expect(exported.files["skills/paperclip/SKILL.md"]).toContain('kind: "github-dir"');
expect(exported.files["skills/paperclip/references/api.md"]).toBeUndefined();
expect(exported.files["skills/company-playbook/SKILL.md"]).toContain("# Company Playbook");
expect(exported.files["skills/company-playbook/references/checklist.md"]).toContain("# Checklist");
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain("metadata:");
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain('kind: "github-dir"');
expect(exported.files[`skills/${paperclipKey}/references/api.md`]).toBeUndefined();
expect(exported.files[`skills/${companyPlaybookKey}/SKILL.md`]).toContain("# Company Playbook");
expect(exported.files[`skills/${companyPlaybookKey}/references/checklist.md`]).toContain("# Checklist");
const extension = exported.files[".paperclip.yaml"];
expect(extension).toContain('schema: "paperclip/v1"');
@@ -284,9 +289,9 @@ describe("company portability", () => {
expandReferencedSkills: true,
});
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("# Paperclip");
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
expect(exported.files["skills/paperclip/references/api.md"]).toContain("# API");
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain("# Paperclip");
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain("metadata:");
expect(exported.files[`skills/${paperclipKey}/references/api.md`]).toContain("# API");
});
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
@@ -392,7 +397,7 @@ describe("company portability", () => {
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.objectContaining({
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
}),
}));

View File

@@ -19,6 +19,7 @@ async function createSkillDir(root: string, name: string) {
}
describe("cursor local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
@@ -39,19 +40,19 @@ describe("cursor local skill sync", () => {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
const before = await listCursorSkills(ctx);
expect(before.mode).toBe("persistent");
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");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
const after = await syncCursorSkills(ctx, ["paperclip"]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
const after = await syncCursorSkills(ctx, [paperclipKey]);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
@@ -74,13 +75,15 @@ describe("cursor local skill sync", () => {
},
paperclipRuntimeSkills: [
{
name: "paperclip",
key: "paperclip",
runtimeName: "paperclip",
source: paperclipDir,
required: true,
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
},
{
name: "ascii-heart",
key: "ascii-heart",
runtimeName: "ascii-heart",
source: asciiHeartDir,
},
],
@@ -93,11 +96,11 @@ describe("cursor local skill sync", () => {
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");
expect(before.entries.find((entry) => entry.key === "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(after.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
});
@@ -114,12 +117,12 @@ describe("cursor local skill sync", () => {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
await syncCursorSkills(configuredCtx, ["paperclip"]);
await syncCursorSkills(configuredCtx, [paperclipKey]);
const clearedCtx = {
...configuredCtx,
@@ -134,8 +137,8 @@ describe("cursor local skill sync", () => {
} as const;
const after = await syncCursorSkills(clearedCtx, []);
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -12,6 +12,7 @@ async function makeTempDir(prefix: string): Promise<string> {
}
describe("gemini local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
@@ -32,19 +33,19 @@ describe("gemini local skill sync", () => {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
const before = await listGeminiSkills(ctx);
expect(before.mode).toBe("persistent");
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");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
const after = await syncGeminiSkills(ctx, ["paperclip"]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
const after = await syncGeminiSkills(ctx, [paperclipKey]);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
@@ -61,12 +62,12 @@ describe("gemini local skill sync", () => {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
await syncGeminiSkills(configuredCtx, ["paperclip"]);
await syncGeminiSkills(configuredCtx, [paperclipKey]);
const clearedCtx = {
...configuredCtx,
@@ -81,8 +82,8 @@ describe("gemini local skill sync", () => {
} as const;
const after = await syncGeminiSkills(clearedCtx, []);
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -12,6 +12,7 @@ async function makeTempDir(prefix: string): Promise<string> {
}
describe("opencode local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
@@ -32,7 +33,7 @@ describe("opencode local skill sync", () => {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
@@ -40,12 +41,12 @@ 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).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
const after = await syncOpenCodeSkills(ctx, ["paperclip"]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
const after = await syncOpenCodeSkills(ctx, [paperclipKey]);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
@@ -62,12 +63,12 @@ describe("opencode local skill sync", () => {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
await syncOpenCodeSkills(configuredCtx, ["paperclip"]);
await syncOpenCodeSkills(configuredCtx, [paperclipKey]);
const clearedCtx = {
...configuredCtx,
@@ -82,8 +83,8 @@ describe("opencode local skill sync", () => {
} as const;
const after = await syncOpenCodeSkills(clearedCtx, []);
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -30,7 +30,8 @@ describe("paperclip skill utils", () => {
const entries = await listPaperclipSkillEntries(moduleDir);
expect(entries.map((entry) => entry.name)).toEqual(["paperclip"]);
expect(entries.map((entry) => entry.key)).toEqual(["paperclipai/paperclip/paperclip"]);
expect(entries.map((entry) => entry.runtimeName)).toEqual(["paperclip"]);
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
});

View File

@@ -12,6 +12,7 @@ async function makeTempDir(prefix: string): Promise<string> {
}
describe("pi local skill sync", () => {
const paperclipKey = "paperclipai/paperclip/paperclip";
const cleanupDirs = new Set<string>();
afterEach(async () => {
@@ -32,19 +33,19 @@ describe("pi local skill sync", () => {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
const before = await listPiSkills(ctx);
expect(before.mode).toBe("persistent");
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");
expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
const after = await syncPiSkills(ctx, ["paperclip"]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
const after = await syncPiSkills(ctx, [paperclipKey]);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
@@ -61,12 +62,12 @@ describe("pi local skill sync", () => {
HOME: home,
},
paperclipSkillSync: {
desiredSkills: ["paperclip"],
desiredSkills: [paperclipKey],
},
},
} as const;
await syncPiSkills(configuredCtx, ["paperclip"]);
await syncPiSkills(configuredCtx, [paperclipKey]);
const clearedCtx = {
...configuredCtx,
@@ -81,8 +82,8 @@ describe("pi local skill sync", () => {
} as const;
const after = await syncPiSkills(clearedCtx, []);
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect(after.desiredSkills).toContain(paperclipKey);
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});