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);
});
});

View File

@@ -506,7 +506,7 @@ export function agentRoutes(db: Db) {
agent.adapterConfig as Record<string, unknown>,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name);
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
return;
}
@@ -545,7 +545,7 @@ export function agentRoutes(db: Db) {
),
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name);
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
const desiredSkills = Array.from(new Set([...requiredSkills, ...requestedSkills]));
const nextAdapterConfig = writePaperclipSkillSyncPreference(
agent.adapterConfig as Record<string, unknown>,

View File

@@ -51,6 +51,67 @@ const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"
const execFileAsync = promisify(execFile);
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
function normalizeSkillSlug(value: string | null | undefined) {
return value ? normalizeAgentUrlKey(value) ?? null : null;
}
function normalizeSkillKey(value: string | null | undefined) {
if (!value) return null;
const segments = value
.split("/")
.map((segment) => normalizeSkillSlug(segment))
.filter((segment): segment is string => Boolean(segment));
return segments.length > 0 ? segments.join("/") : null;
}
function readSkillKey(frontmatter: Record<string, unknown>) {
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
const paperclip = isPlainRecord(metadata?.paperclip) ? metadata?.paperclip as Record<string, unknown> : null;
return normalizeSkillKey(
asString(frontmatter.key)
?? asString(frontmatter.skillKey)
?? asString(metadata?.skillKey)
?? asString(metadata?.canonicalKey)
?? asString(metadata?.paperclipSkillKey)
?? asString(paperclip?.skillKey)
?? asString(paperclip?.key),
);
}
function deriveManifestSkillKey(
frontmatter: Record<string, unknown>,
fallbackSlug: string,
metadata: Record<string, unknown> | null,
sourceType: string,
sourceLocator: string | null,
) {
const explicit = readSkillKey(frontmatter);
if (explicit) return explicit;
const slug = normalizeSkillSlug(asString(frontmatter.slug) ?? fallbackSlug) ?? "skill";
const sourceKind = asString(metadata?.sourceKind);
const owner = normalizeSkillSlug(asString(metadata?.owner));
const repo = normalizeSkillSlug(asString(metadata?.repo));
if ((sourceType === "github" || sourceKind === "github") && owner && repo) {
return `${owner}/${repo}/${slug}`;
}
if (sourceKind === "paperclip_bundled") {
return `paperclipai/paperclip/${slug}`;
}
if (sourceType === "url" || sourceKind === "url") {
try {
const host = normalizeSkillSlug(sourceLocator ? new URL(sourceLocator).host : null) ?? "url";
return `url/${host}/${slug}`;
} catch {
return `url/unknown/${slug}`;
}
}
return slug;
}
function skillPackageDir(key: string) {
return `skills/${key}`;
}
function isSensitiveEnvKey(key: string) {
const normalized = key.trim().toLowerCase();
return (
@@ -748,6 +809,8 @@ function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkill
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
const sourceEntry = await buildSkillSourceEntry(skill);
const frontmatter: Record<string, unknown> = {
key: skill.key,
slug: skill.slug,
name: skill.name,
description: skill.description ?? null,
};
@@ -761,7 +824,6 @@ async function buildReferencedSkillMarkdown(skill: CompanySkill) {
async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
const sourceEntry = await buildSkillSourceEntry(skill);
if (!sourceEntry) return markdown;
const parsed = parseFrontmatterMarkdown(markdown);
const metadata = isPlainRecord(parsed.frontmatter.metadata)
? { ...parsed.frontmatter.metadata }
@@ -769,9 +831,20 @@ async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
const existingSources = Array.isArray(metadata.sources)
? metadata.sources.filter((entry) => isPlainRecord(entry))
: [];
metadata.sources = [...existingSources, sourceEntry];
if (sourceEntry) {
metadata.sources = [...existingSources, sourceEntry];
}
metadata.skillKey = skill.key;
metadata.paperclipSkillKey = skill.key;
metadata.paperclip = {
...(isPlainRecord(metadata.paperclip) ? metadata.paperclip : {}),
skillKey: skill.key,
slug: skill.slug,
};
const frontmatter = {
...parsed.frontmatter,
key: skill.key,
slug: skill.slug,
metadata,
};
return buildMarkdown(frontmatter, parsed.body);
@@ -1043,7 +1116,7 @@ function readAgentSkillRefs(frontmatter: Record<string, unknown>) {
return Array.from(new Set(
skills
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => normalizeAgentUrlKey(entry) ?? entry.trim())
.map((entry) => normalizeSkillKey(entry) ?? entry.trim())
.filter(Boolean),
));
}
@@ -1256,8 +1329,10 @@ function buildManifestFromPackageFiles(
sourceKind: "catalog",
};
}
const key = deriveManifestSkillKey(frontmatter, slug, normalizedMetadata, sourceType, sourceLocator);
manifest.skills.push({
key,
slug,
name: asString(frontmatter.name) ?? slug,
path: skillPath,
@@ -1688,15 +1763,16 @@ export function companyPortabilityService(db: Db) {
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
for (const skill of companySkillRows) {
const packageDir = skillPackageDir(skill.key);
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
files[`skills/${skill.slug}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
continue;
}
for (const inventoryEntry of skill.fileInventory) {
const fileDetail = await companySkills.readFile(companyId, skill.id, inventoryEntry.path).catch(() => null);
if (!fileDetail) continue;
const filePath = `skills/${skill.slug}/${inventoryEntry.path}`;
const filePath = `${packageDir}/${inventoryEntry.path}`;
files[filePath] = inventoryEntry.path === "SKILL.md"
? await withSkillSourceMetadata(skill, fileDetail.content)
: fileDetail.content;
@@ -1908,7 +1984,13 @@ export function companyPortabilityService(db: Db) {
warnings.push("No agents selected for import.");
}
const availableSkillSlugs = new Set(source.manifest.skills.map((skill) => skill.slug));
const availableSkillKeys = new Set(source.manifest.skills.map((skill) => skill.key));
const availableSkillSlugs = new Map<string, CompanyPortabilitySkillManifestEntry[]>();
for (const skill of source.manifest.skills) {
const existing = availableSkillSlugs.get(skill.slug) ?? [];
existing.push(skill);
availableSkillSlugs.set(skill.slug, existing);
}
for (const agent of selectedAgents) {
const filePath = ensureMarkdownPath(agent.path);
@@ -1921,9 +2003,10 @@ export function companyPortabilityService(db: Db) {
if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "agent") {
warnings.push(`Agent markdown ${filePath} does not declare kind: agent in frontmatter.`);
}
for (const skillSlug of agent.skills) {
if (!availableSkillSlugs.has(skillSlug)) {
warnings.push(`Agent ${agent.slug} references skill ${skillSlug}, but that skill is not present in the package.`);
for (const skillRef of agent.skills) {
const slugMatches = availableSkillSlugs.get(skillRef) ?? [];
if (!availableSkillKeys.has(skillRef) && slugMatches.length !== 1) {
warnings.push(`Agent ${agent.slug} references skill ${skillRef}, but that skill is not present in the package.`);
}
}
}

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -31,6 +32,7 @@ import { secretService } from "./secrets.js";
type CompanySkillRow = typeof companySkills.$inferSelect;
type ImportedSkill = {
key: string;
slug: string;
name: string;
description: string | null;
@@ -52,6 +54,7 @@ type ParsedSkillImportSource = {
};
type SkillSourceMeta = {
skillKey?: string;
sourceKind?: string;
owner?: string;
repo?: string;
@@ -97,6 +100,86 @@ function normalizeSkillSlug(value: string | null | undefined) {
return value ? normalizeAgentUrlKey(value) ?? null : null;
}
function normalizeSkillKey(value: string | null | undefined) {
if (!value) return null;
const segments = value
.split("/")
.map((segment) => normalizeSkillSlug(segment))
.filter((segment): segment is string => Boolean(segment));
return segments.length > 0 ? segments.join("/") : null;
}
function hashSkillValue(value: string) {
return createHash("sha256").update(value).digest("hex").slice(0, 10);
}
function buildSkillRuntimeName(key: string, slug: string) {
if (key.startsWith("paperclipai/paperclip/")) return slug;
return `${slug}--${hashSkillValue(key)}`;
}
function readCanonicalSkillKey(frontmatter: Record<string, unknown>, metadata: Record<string, unknown> | null) {
const direct = normalizeSkillKey(
asString(frontmatter.key)
?? asString(frontmatter.skillKey)
?? asString(metadata?.skillKey)
?? asString(metadata?.canonicalKey)
?? asString(metadata?.paperclipSkillKey),
);
if (direct) return direct;
const paperclip = isPlainRecord(metadata?.paperclip) ? metadata?.paperclip as Record<string, unknown> : null;
return normalizeSkillKey(
asString(paperclip?.skillKey)
?? asString(paperclip?.key),
);
}
function deriveCanonicalSkillKey(
companyId: string,
input: Pick<ImportedSkill, "slug" | "sourceType" | "sourceLocator" | "metadata">,
) {
const slug = normalizeSkillSlug(input.slug) ?? "skill";
const metadata = isPlainRecord(input.metadata) ? input.metadata : null;
const explicitKey = readCanonicalSkillKey({}, metadata);
if (explicitKey) return explicitKey;
const sourceKind = asString(metadata?.sourceKind);
if (sourceKind === "paperclip_bundled") {
return `paperclipai/paperclip/${slug}`;
}
const owner = normalizeSkillSlug(asString(metadata?.owner));
const repo = normalizeSkillSlug(asString(metadata?.repo));
if ((input.sourceType === "github" || sourceKind === "github") && owner && repo) {
return `${owner}/${repo}/${slug}`;
}
if (input.sourceType === "url" || sourceKind === "url") {
const locator = asString(input.sourceLocator);
if (locator) {
try {
const url = new URL(locator);
const host = normalizeSkillSlug(url.host) ?? "url";
return `url/${host}/${hashSkillValue(locator)}/${slug}`;
} catch {
return `url/unknown/${hashSkillValue(locator)}/${slug}`;
}
}
}
if (input.sourceType === "local_path") {
if (sourceKind === "managed_local") {
return `company/${companyId}/${slug}`;
}
const locator = asString(input.sourceLocator);
if (locator) {
return `local/${hashSkillValue(path.resolve(locator))}/${slug}`;
}
}
return `company/${companyId}/${slug}`;
}
function classifyInventoryKind(relativePath: string): CompanySkillFileInventoryEntry["kind"] {
const normalized = normalizePortablePath(relativePath).toLowerCase();
if (normalized.endsWith("/skill.md") || normalized === "skill.md") return "skill";
@@ -417,7 +500,10 @@ function matchesRequestedSkill(relativeSkillPath: string, requestedSkillSlug: st
}
function deriveImportedSkillSlug(frontmatter: Record<string, unknown>, fallback: string) {
return normalizeSkillSlug(asString(frontmatter.name)) ?? normalizeAgentUrlKey(fallback) ?? "skill";
return normalizeSkillSlug(asString(frontmatter.slug))
?? normalizeSkillSlug(asString(frontmatter.name))
?? normalizeAgentUrlKey(fallback)
?? "skill";
}
function deriveImportedSkillSource(
@@ -425,6 +511,7 @@ function deriveImportedSkillSource(
fallbackSlug: string,
): Pick<ImportedSkill, "sourceType" | "sourceLocator" | "sourceRef" | "metadata"> {
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
const canonicalKey = readCanonicalSkillKey(frontmatter, metadata);
const rawSources = metadata && Array.isArray(metadata.sources) ? metadata.sources : [];
const sourceEntry = rawSources.find((entry) => isPlainRecord(entry)) as Record<string, unknown> | undefined;
const kind = asString(sourceEntry?.kind);
@@ -445,6 +532,7 @@ function deriveImportedSkillSource(
sourceLocator: url,
sourceRef: commit,
metadata: {
...(canonicalKey ? { skillKey: canonicalKey } : {}),
sourceKind: "github",
owner,
repo: repoName,
@@ -464,6 +552,7 @@ function deriveImportedSkillSource(
sourceLocator: url,
sourceRef: null,
metadata: {
...(canonicalKey ? { skillKey: canonicalKey } : {}),
sourceKind: "url",
},
};
@@ -475,12 +564,13 @@ function deriveImportedSkillSource(
sourceLocator: null,
sourceRef: null,
metadata: {
...(canonicalKey ? { skillKey: canonicalKey } : {}),
sourceKind: "catalog",
},
};
}
function readInlineSkillImports(files: Record<string, string>): ImportedSkill[] {
function readInlineSkillImports(companyId: string, files: Record<string, string>): ImportedSkill[] {
const normalizedFiles = normalizePackageFileMap(files);
const skillPaths = Object.keys(normalizedFiles).filter(
(entry) => path.posix.basename(entry).toLowerCase() === "skill.md",
@@ -507,6 +597,7 @@ function readInlineSkillImports(files: Record<string, string>): ImportedSkill[]
.sort((left, right) => left.path.localeCompare(right.path));
imports.push({
key: "",
slug,
name: asString(parsed.frontmatter.name) ?? slug,
description: asString(parsed.frontmatter.description),
@@ -520,6 +611,7 @@ function readInlineSkillImports(files: Record<string, string>): ImportedSkill[]
fileInventory: inventory,
metadata: source.metadata,
});
imports[imports.length - 1]!.key = deriveCanonicalSkillKey(companyId, imports[imports.length - 1]!);
}
return imports;
@@ -539,7 +631,7 @@ async function walkLocalFiles(root: string, current: string, out: string[]) {
}
}
async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[]> {
async function readLocalSkillImports(companyId: string, sourcePath: string): Promise<ImportedSkill[]> {
const resolvedPath = path.resolve(sourcePath);
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat) {
@@ -550,10 +642,24 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
const markdown = await fs.readFile(resolvedPath, "utf8");
const parsed = parseFrontmatterMarkdown(markdown);
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(path.dirname(resolvedPath)));
const skillKey = readCanonicalSkillKey(
parsed.frontmatter,
isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null,
);
const metadata = {
...(skillKey ? { skillKey } : {}),
sourceKind: "local_path",
};
const inventory: CompanySkillFileInventoryEntry[] = [
{ path: "SKILL.md", kind: "skill" },
];
return [{
key: deriveCanonicalSkillKey(companyId, {
slug,
sourceType: "local_path",
sourceLocator: path.dirname(resolvedPath),
metadata,
}),
slug,
name: asString(parsed.frontmatter.name) ?? slug,
description: asString(parsed.frontmatter.description),
@@ -565,7 +671,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
trustLevel: deriveTrustLevel(inventory),
compatibility: "compatible",
fileInventory: inventory,
metadata: { sourceKind: "local_path" },
metadata,
}];
}
@@ -583,6 +689,14 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
const markdown = await fs.readFile(path.join(root, skillPath), "utf8");
const parsed = parseFrontmatterMarkdown(markdown);
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.posix.basename(skillDir));
const skillKey = readCanonicalSkillKey(
parsed.frontmatter,
isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null,
);
const metadata = {
...(skillKey ? { skillKey } : {}),
sourceKind: "local_path",
};
const inventory = allFiles
.filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`))
.map((entry) => {
@@ -594,6 +708,12 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
})
.sort((left, right) => left.path.localeCompare(right.path));
imports.push({
key: deriveCanonicalSkillKey(companyId, {
slug,
sourceType: "local_path",
sourceLocator: path.join(root, skillDir),
metadata,
}),
slug,
name: asString(parsed.frontmatter.name) ?? slug,
description: asString(parsed.frontmatter.description),
@@ -605,7 +725,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
trustLevel: deriveTrustLevel(inventory),
compatibility: "compatible",
fileInventory: inventory,
metadata: { sourceKind: "local_path" },
metadata,
});
}
@@ -613,6 +733,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
}
async function readUrlSkillImports(
companyId: string,
sourceUrl: string,
requestedSkillSlug: string | null = null,
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
@@ -654,9 +775,22 @@ async function readUrlSkillImports(
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
const skillDir = path.posix.dirname(relativeSkillPath);
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
const skillKey = readCanonicalSkillKey(
parsedMarkdown.frontmatter,
isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null,
);
if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) {
continue;
}
const metadata = {
...(skillKey ? { skillKey } : {}),
sourceKind: "github",
owner: parsed.owner,
repo: parsed.repo,
ref: ref,
trackingRef,
repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir,
};
const inventory = filteredPaths
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
.map((entry) => ({
@@ -665,6 +799,12 @@ async function readUrlSkillImports(
}))
.sort((left, right) => left.path.localeCompare(right.path));
skills.push({
key: deriveCanonicalSkillKey(companyId, {
slug,
sourceType: "github",
sourceLocator: sourceUrl,
metadata,
}),
slug,
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
description: asString(parsedMarkdown.frontmatter.description),
@@ -675,14 +815,7 @@ async function readUrlSkillImports(
trustLevel: deriveTrustLevel(inventory),
compatibility: "compatible",
fileInventory: inventory,
metadata: {
sourceKind: "github",
owner: parsed.owner,
repo: parsed.repo,
ref: ref,
trackingRef,
repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir,
},
metadata,
});
}
if (skills.length === 0) {
@@ -701,9 +834,23 @@ async function readUrlSkillImports(
const urlObj = new URL(url);
const fileName = path.posix.basename(urlObj.pathname);
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, fileName.replace(/\.md$/i, ""));
const skillKey = readCanonicalSkillKey(
parsedMarkdown.frontmatter,
isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null,
);
const metadata = {
...(skillKey ? { skillKey } : {}),
sourceKind: "url",
};
const inventory: CompanySkillFileInventoryEntry[] = [{ path: "SKILL.md", kind: "skill" }];
return {
skills: [{
key: deriveCanonicalSkillKey(companyId, {
slug,
sourceType: "url",
sourceLocator: url,
metadata,
}),
slug,
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
description: asString(parsedMarkdown.frontmatter.description),
@@ -714,9 +861,7 @@ async function readUrlSkillImports(
trustLevel: deriveTrustLevel(inventory),
compatibility: "compatible",
fileInventory: inventory,
metadata: {
sourceKind: "url",
},
metadata,
}],
warnings,
};
@@ -760,6 +905,34 @@ function getSkillMeta(skill: CompanySkill): SkillSourceMeta {
return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {};
}
function resolveSkillReference(
skills: CompanySkill[],
reference: string,
): CompanySkill | null {
const normalizedReference = normalizeSkillKey(reference) ?? normalizeSkillSlug(reference);
if (!normalizedReference) return null;
const byKey = skills.find((skill) => skill.key === normalizedReference);
if (byKey) return byKey;
const bySlug = skills.filter((skill) => skill.slug === normalizedReference);
if (bySlug.length === 1) return bySlug[0] ?? null;
return null;
}
function resolveDesiredSkillKeys(
skills: CompanySkill[],
config: Record<string, unknown>,
) {
const preference = readPaperclipSkillSyncPreference(config);
return Array.from(new Set(
preference.desiredSkills
.map((reference) => resolveSkillReference(skills, reference)?.key ?? normalizeSkillKey(reference))
.filter((value): value is string => Boolean(value)),
));
}
function normalizeSkillDirectory(skill: CompanySkill) {
if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null;
const resolved = path.resolve(skill.sourceLocator);
@@ -886,6 +1059,7 @@ function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number)
return {
id: skill.id,
companyId: skill.companyId,
key: skill.key,
slug: skill.slug,
name: skill.name,
description: skill.description,
@@ -913,9 +1087,16 @@ export function companySkillService(db: Db) {
for (const skillsRoot of resolveBundledSkillsRoot()) {
const stats = await fs.stat(skillsRoot).catch(() => null);
if (!stats?.isDirectory()) continue;
const bundledSkills = await readLocalSkillImports(skillsRoot)
const bundledSkills = await readLocalSkillImports(companyId, skillsRoot)
.then((skills) => skills.map((skill) => ({
...skill,
key: deriveCanonicalSkillKey(companyId, {
...skill,
metadata: {
...(skill.metadata ?? {}),
sourceKind: "paperclip_bundled",
},
}),
metadata: {
...(skill.metadata ?? {}),
sourceKind: "paperclip_bundled",
@@ -933,8 +1114,8 @@ export function companySkillService(db: Db) {
const agentRows = await agents.list(companyId);
return rows.map((skill) => {
const attachedAgentCount = agentRows.filter((agent) => {
const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record<string, unknown>);
return preference.desiredSkills.includes(skill.slug);
const desiredSkills = resolveDesiredSkillKeys(rows, agent.adapterConfig as Record<string, unknown>);
return desiredSkills.includes(skill.key);
}).length;
return toCompanySkillListItem(skill, attachedAgentCount);
});
@@ -946,7 +1127,7 @@ export function companySkillService(db: Db) {
.select()
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.orderBy(asc(companySkills.name), asc(companySkills.slug));
.orderBy(asc(companySkills.name), asc(companySkills.key));
return rows.map((row) => toCompanySkill(row));
}
@@ -959,20 +1140,21 @@ export function companySkillService(db: Db) {
return row ? toCompanySkill(row) : null;
}
async function getBySlug(companyId: string, slug: string) {
async function getByKey(companyId: string, key: string) {
const row = await db
.select()
.from(companySkills)
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.slug, slug)))
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, key)))
.then((rows) => rows[0] ?? null);
return row ? toCompanySkill(row) : null;
}
async function usage(companyId: string, slug: string): Promise<CompanySkillUsageAgent[]> {
async function usage(companyId: string, key: string): Promise<CompanySkillUsageAgent[]> {
const skills = await listFull(companyId);
const agentRows = await agents.list(companyId);
const desiredAgents = agentRows.filter((agent) => {
const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record<string, unknown>);
return preference.desiredSkills.includes(slug);
const desiredSkills = resolveDesiredSkillKeys(skills, agent.adapterConfig as Record<string, unknown>);
return desiredSkills.includes(key);
});
return Promise.all(
@@ -998,7 +1180,7 @@ export function companySkillService(db: Db) {
paperclipRuntimeSkills: runtimeSkillEntries,
},
});
actualState = snapshot.entries.find((entry) => entry.name === slug)?.state
actualState = snapshot.entries.find((entry) => entry.key === key)?.state
?? (snapshot.supported ? "missing" : "unsupported");
} catch {
actualState = "unknown";
@@ -1021,7 +1203,7 @@ export function companySkillService(db: Db) {
await ensureBundledSkills(companyId);
const skill = await getById(id);
if (!skill || skill.companyId !== companyId) return null;
const usedByAgents = await usage(companyId, skill.slug);
const usedByAgents = await usage(companyId, skill.key);
return enrichSkill(skill, usedByAgents.length, usedByAgents);
}
@@ -1147,6 +1329,7 @@ export function companySkillService(db: Db) {
const parsed = parseFrontmatterMarkdown(markdown);
const imported = await upsertImportedSkills(companyId, [{
key: `company/${companyId}/${slug}`,
slug,
name: asString(parsed.frontmatter.name) ?? input.name,
description: asString(parsed.frontmatter.description) ?? input.description?.trim() ?? null,
@@ -1216,10 +1399,10 @@ export function companySkillService(db: Db) {
throw unprocessable("Skill source locator is missing.");
}
const result = await readUrlSkillImports(skill.sourceLocator, skill.slug);
const matching = result.skills.find((entry) => entry.slug === skill.slug) ?? result.skills[0] ?? null;
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug);
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
if (!matching) {
throw unprocessable(`Skill ${skill.slug} could not be re-imported from its source.`);
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
}
const imported = await upsertImportedSkills(companyId, [matching]);
@@ -1234,7 +1417,7 @@ export function companySkillService(db: Db) {
const packageDir = skill.packageDir ? normalizePortablePath(skill.packageDir) : null;
if (!packageDir) return null;
const catalogRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__catalog__");
const skillDir = path.resolve(catalogRoot, skill.slug);
const skillDir = path.resolve(catalogRoot, buildSkillRuntimeName(skill.key, skill.slug));
await fs.rm(skillDir, { recursive: true, force: true });
await fs.mkdir(skillDir, { recursive: true });
@@ -1254,7 +1437,7 @@ export function companySkillService(db: Db) {
async function materializeRuntimeSkillFiles(companyId: string, skill: CompanySkill) {
const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__");
const skillDir = path.resolve(runtimeRoot, skill.slug);
const skillDir = path.resolve(runtimeRoot, buildSkillRuntimeName(skill.key, skill.slug));
await fs.rm(skillDir, { recursive: true, force: true });
await fs.mkdir(skillDir, { recursive: true });
@@ -1275,7 +1458,7 @@ export function companySkillService(db: Db) {
.select()
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.orderBy(asc(companySkills.name), asc(companySkills.slug));
.orderBy(asc(companySkills.name), asc(companySkills.key));
const out: PaperclipSkillEntry[] = [];
for (const row of rows) {
@@ -1289,7 +1472,8 @@ export function companySkillService(db: Db) {
const required = sourceKind === "paperclip_bundled";
out.push({
name: skill.slug,
key: skill.key,
runtimeName: buildSkillRuntimeName(skill.key, skill.slug),
source,
required,
requiredReason: required
@@ -1298,14 +1482,14 @@ export function companySkillService(db: Db) {
});
}
out.sort((left, right) => left.name.localeCompare(right.name));
out.sort((left, right) => left.key.localeCompare(right.key));
return out;
}
async function importPackageFiles(companyId: string, files: Record<string, string>): Promise<CompanySkill[]> {
await ensureBundledSkills(companyId);
const normalizedFiles = normalizePackageFileMap(files);
const importedSkills = readInlineSkillImports(normalizedFiles);
const importedSkills = readInlineSkillImports(companyId, normalizedFiles);
if (importedSkills.length === 0) return [];
for (const skill of importedSkills) {
@@ -1322,7 +1506,7 @@ export function companySkillService(db: Db) {
async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise<CompanySkill[]> {
const out: CompanySkill[] = [];
for (const skill of imported) {
const existing = await getBySlug(companyId, skill.slug);
const existing = await getByKey(companyId, skill.key);
const existingMeta = existing ? getSkillMeta(existing) : {};
const incomingMeta = skill.metadata && isPlainRecord(skill.metadata) ? skill.metadata : {};
const incomingOwner = asString(incomingMeta.owner);
@@ -1339,8 +1523,13 @@ export function companySkillService(db: Db) {
continue;
}
const metadata = {
...(skill.metadata ?? {}),
skillKey: skill.key,
};
const values = {
companyId,
key: skill.key,
slug: skill.slug,
name: skill.name,
description: skill.description,
@@ -1351,7 +1540,7 @@ export function companySkillService(db: Db) {
trustLevel: skill.trustLevel,
compatibility: skill.compatibility,
fileInventory: serializeFileInventory(skill.fileInventory),
metadata: skill.metadata,
metadata,
updatedAt: new Date(),
};
const row = existing
@@ -1378,11 +1567,11 @@ export function companySkillService(db: Db) {
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
const { skills, warnings } = local
? {
skills: (await readLocalSkillImports(parsed.resolvedSource))
skills: (await readLocalSkillImports(companyId, parsed.resolvedSource))
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
warnings: parsed.warnings,
}
: await readUrlSkillImports(parsed.resolvedSource, parsed.requestedSkillSlug)
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
.then((result) => ({
skills: result.skills,
warnings: [...parsed.warnings, ...result.warnings],
@@ -1405,7 +1594,7 @@ export function companySkillService(db: Db) {
list,
listFull,
getById,
getBySlug,
getByKey,
detail,
updateStatus,
readFile,