From 9ba47681c61caf04b4fd99100a85b4395facf2bc Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 18 Mar 2026 16:23:19 -0500 Subject: [PATCH] Use pretty export paths for skills Co-Authored-By: Paperclip --- .../src/__tests__/company-portability.test.ts | 101 ++++++++++++++++-- server/src/services/company-portability.ts | 88 ++++++++++++++- 2 files changed, 178 insertions(+), 11 deletions(-) diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index c3dd2537..4a2786cc 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -275,11 +275,11 @@ describe("company portability", () => { expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("skills:"); expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain(`- "${paperclipKey}"`); expect(exported.files["agents/cmo/AGENTS.md"]).not.toContain("skills:"); - 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"); + 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"); const extension = exported.files[".paperclip.yaml"]; expect(extension).toContain('schema: "paperclip/v1"'); @@ -313,9 +313,94 @@ describe("company portability", () => { expandReferencedSkills: true, }); - 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"); + 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"); + }); + + it("exports duplicate skill slugs with readable pretty path suffixes", async () => { + const portability = companyPortabilityService({} as any); + + companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => { + if (skillId === "skill-local") { + return { + skillId, + path: relativePath, + kind: "skill", + content: "---\nname: release-changelog\n---\n\n# Local Release Changelog\n", + language: "markdown", + markdown: true, + editable: true, + }; + } + + return { + skillId, + path: relativePath, + kind: "skill", + content: "---\nname: release-changelog\n---\n\n# Bundled Release Changelog\n", + language: "markdown", + markdown: true, + editable: false, + }; + }); + + companySkillSvc.listFull.mockResolvedValue([ + { + id: "skill-local", + companyId: "company-1", + key: "local/36dfd631da/release-changelog", + slug: "release-changelog", + name: "release-changelog", + description: "Local release changelog skill", + markdown: "---\nname: release-changelog\n---\n\n# Local Release Changelog\n", + sourceType: "local_path", + sourceLocator: "/tmp/release-changelog", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { + sourceKind: "local_path", + }, + }, + { + id: "skill-paperclip", + companyId: "company-1", + key: "paperclipai/paperclip/release-changelog", + slug: "release-changelog", + name: "release-changelog", + description: "Bundled release changelog skill", + markdown: "---\nname: release-changelog\n---\n\n# Bundled Release Changelog\n", + sourceType: "github", + sourceLocator: "https://github.com/paperclipai/paperclip/tree/master/skills/release-changelog", + sourceRef: "0123456789abcdef0123456789abcdef01234567", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { + sourceKind: "paperclip_bundled", + owner: "paperclipai", + repo: "paperclip", + ref: "0123456789abcdef0123456789abcdef01234567", + trackingRef: "master", + repoSkillDir: "skills/release-changelog", + }, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + expect(exported.files["skills/release-changelog--local/SKILL.md"]).toContain("# Local Release Changelog"); + expect(exported.files["skills/release-changelog--paperclip/SKILL.md"]).toContain("metadata:"); + expect(exported.files["skills/release-changelog--paperclip/SKILL.md"]).toContain("paperclipai/paperclip/release-changelog"); }); it("reads env inputs back from .paperclip.yaml during preview import", async () => { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index fa9d9105..8bfac97d 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import { execFile } from "node:child_process"; import path from "node:path"; @@ -110,8 +111,88 @@ function deriveManifestSkillKey( return slug; } -function skillPackageDir(key: string) { - return `skills/${key}`; +function hashSkillValue(value: string) { + return createHash("sha256").update(value).digest("hex").slice(0, 8); +} + +function readSkillSourceKind(skill: CompanySkill) { + const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; + return asString(metadata?.sourceKind); +} + +function deriveSkillExportDirCandidates(skill: CompanySkill, slug: string) { + const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; + const sourceKind = readSkillSourceKind(skill); + const suffixes = new Set(); + const pushSuffix = (value: string | null | undefined) => { + const normalized = normalizeSkillSlug(value); + if (normalized && normalized !== slug) { + suffixes.add(normalized); + } + }; + + if (sourceKind === "paperclip_bundled") { + pushSuffix("paperclip"); + } + + if (skill.sourceType === "github") { + pushSuffix(asString(metadata?.repo)); + pushSuffix(asString(metadata?.owner)); + pushSuffix("github"); + } else if (skill.sourceType === "url") { + try { + pushSuffix(skill.sourceLocator ? new URL(skill.sourceLocator).host : null); + } catch { + // Ignore URL parse failures and fall through to generic suffixes. + } + pushSuffix("url"); + } else if (skill.sourceType === "local_path") { + pushSuffix(asString(metadata?.projectName)); + pushSuffix(asString(metadata?.workspaceName)); + if (skill.sourceLocator) { + const basename = path.basename(skill.sourceLocator); + pushSuffix(basename.toLowerCase() === "skill.md" ? path.basename(path.dirname(skill.sourceLocator)) : basename); + } + if (sourceKind === "managed_local") pushSuffix("company"); + if (sourceKind === "project_scan") pushSuffix("project"); + pushSuffix("local"); + } else { + pushSuffix(sourceKind); + pushSuffix("skill"); + } + + return Array.from(suffixes, (suffix) => `skills/${slug}--${suffix}`); +} + +function buildSkillExportDirMap(skills: CompanySkill[]) { + const slugCounts = new Map(); + for (const skill of skills) { + const slug = normalizeSkillSlug(skill.slug) ?? "skill"; + slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1); + } + + const usedDirs = new Set(); + const keyToDir = new Map(); + const orderedSkills = [...skills].sort((left, right) => left.key.localeCompare(right.key)); + for (const skill of orderedSkills) { + const slug = normalizeSkillSlug(skill.slug) ?? "skill"; + const candidates = (slugCounts.get(slug) ?? 0) > 1 + ? deriveSkillExportDirCandidates(skill, slug) + : [`skills/${slug}`]; + + let packageDir = candidates.find((candidate) => !usedDirs.has(candidate)) ?? null; + if (!packageDir) { + packageDir = `skills/${slug}--${hashSkillValue(skill.key)}`; + while (usedDirs.has(packageDir)) { + packageDir = `skills/${slug}--${hashSkillValue(`${skill.key}:${packageDir}`)}`; + } + } + + usedDirs.add(packageDir); + keyToDir.set(skill.key, packageDir); + } + + return keyToDir; } function isSensitiveEnvKey(key: string) { @@ -1724,8 +1805,9 @@ export function companyPortabilityService(db: Db) { const paperclipProjectsOut: Record> = {}; const paperclipTasksOut: Record> = {}; + const skillExportDirs = buildSkillExportDirMap(companySkillRows); for (const skill of companySkillRows) { - const packageDir = skillPackageDir(skill.key); + const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`; if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) { files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill); continue;