diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 4a2786cc..a559814b 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -81,6 +81,7 @@ describe("company portability", () => { id: "company-1", name: "Paperclip", description: null, + issuePrefix: "PAP", brandColor: "#5c5fff", requireBoardApprovalForNewAgents: true, }); @@ -275,11 +276,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/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/paperclipai/paperclip/paperclip/SKILL.md"]).toContain("metadata:"); + expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain('kind: "github-dir"'); + expect(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"]).toBeUndefined(); + expect(exported.files["skills/company/PAP/company-playbook/SKILL.md"]).toContain("# Company Playbook"); + expect(exported.files["skills/company/PAP/company-playbook/references/checklist.md"]).toContain("# Checklist"); const extension = exported.files[".paperclip.yaml"]; expect(extension).toContain('schema: "paperclip/v1"'); @@ -313,12 +314,12 @@ 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/paperclipai/paperclip/paperclip/SKILL.md"]).toContain("# Paperclip"); + expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain("metadata:"); + expect(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"]).toContain("# API"); }); - it("exports duplicate skill slugs with readable pretty path suffixes", async () => { + it("exports duplicate skill slugs into readable namespaced paths", async () => { const portability = companyPortabilityService({} as any); companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => { @@ -398,9 +399,9 @@ describe("company portability", () => { }, }); - 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"); + expect(exported.files["skills/local/release-changelog/SKILL.md"]).toContain("# Local Release Changelog"); + expect(exported.files["skills/paperclipai/paperclip/release-changelog/SKILL.md"]).toContain("metadata:"); + expect(exported.files["skills/paperclipai/paperclip/release-changelog/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 8bfac97d..f6913dac 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -115,17 +115,103 @@ function hashSkillValue(value: string) { return createHash("sha256").update(value).digest("hex").slice(0, 8); } +function normalizeExportPathSegment(value: string | null | undefined, preserveCase = false) { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const normalized = trimmed + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!normalized) return null; + return preserveCase ? normalized : normalized.toLowerCase(); +} + function readSkillSourceKind(skill: CompanySkill) { const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; return asString(metadata?.sourceKind); } -function deriveSkillExportDirCandidates(skill: CompanySkill, slug: string) { +function deriveLocalExportNamespace(skill: CompanySkill, slug: string) { + const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; + const candidates = [ + asString(metadata?.projectName), + asString(metadata?.workspaceName), + ]; + + if (skill.sourceLocator) { + const basename = path.basename(skill.sourceLocator); + candidates.push(basename.toLowerCase() === "skill.md" ? path.basename(path.dirname(skill.sourceLocator)) : basename); + } + + for (const value of candidates) { + const normalized = normalizeSkillSlug(value); + if (normalized && normalized !== slug) return normalized; + } + + return null; +} + +function derivePrimarySkillExportDir( + skill: CompanySkill, + slug: string, + companyIssuePrefix: string | null | undefined, +) { + const normalizedKey = normalizeSkillKey(skill.key); + const keySegments = normalizedKey?.split("/") ?? []; + const primaryNamespace = keySegments[0] ?? null; + + if (primaryNamespace === "company") { + const companySegment = normalizeExportPathSegment(companyIssuePrefix, true) + ?? normalizeExportPathSegment(keySegments[1], true) + ?? "company"; + return `skills/company/${companySegment}/${slug}`; + } + + if (primaryNamespace === "local") { + const localNamespace = deriveLocalExportNamespace(skill, slug); + return localNamespace + ? `skills/local/${localNamespace}/${slug}` + : `skills/local/${slug}`; + } + + if (primaryNamespace === "url") { + let derivedHost: string | null = keySegments[1] ?? null; + if (!derivedHost) { + try { + derivedHost = normalizeSkillSlug(skill.sourceLocator ? new URL(skill.sourceLocator).host : null); + } catch { + derivedHost = null; + } + } + const host = derivedHost ?? "url"; + return `skills/url/${host}/${slug}`; + } + + if (keySegments.length > 1) { + return `skills/${keySegments.join("/")}`; + } + + return `skills/${slug}`; +} + +function appendSkillExportDirSuffix(packageDir: string, suffix: string) { + const lastSeparator = packageDir.lastIndexOf("/"); + if (lastSeparator < 0) return `${packageDir}--${suffix}`; + return `${packageDir.slice(0, lastSeparator + 1)}${packageDir.slice(lastSeparator + 1)}--${suffix}`; +} + +function deriveSkillExportDirCandidates( + skill: CompanySkill, + slug: string, + companyIssuePrefix: string | null | undefined, +) { + const primaryDir = derivePrimarySkillExportDir(skill, slug, companyIssuePrefix); 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); + const pushSuffix = (value: string | null | undefined, preserveCase = false) => { + const normalized = normalizeExportPathSegment(value, preserveCase); if (normalized && normalized !== slug) { suffixes.add(normalized); } @@ -149,10 +235,7 @@ function deriveSkillExportDirCandidates(skill: CompanySkill, slug: string) { } 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); - } + pushSuffix(deriveLocalExportNamespace(skill, slug)); if (sourceKind === "managed_local") pushSuffix("company"); if (sourceKind === "project_scan") pushSuffix("project"); pushSuffix("local"); @@ -161,30 +244,25 @@ function deriveSkillExportDirCandidates(skill: CompanySkill, slug: string) { pushSuffix("skill"); } - return Array.from(suffixes, (suffix) => `skills/${slug}--${suffix}`); + return [primaryDir, ...Array.from(suffixes, (suffix) => appendSkillExportDirSuffix(primaryDir, 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); - } - +function buildSkillExportDirMap(skills: CompanySkill[], companyIssuePrefix: string | null | undefined) { 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}`]; + const candidates = deriveSkillExportDirCandidates(skill, slug, companyIssuePrefix); let packageDir = candidates.find((candidate) => !usedDirs.has(candidate)) ?? null; if (!packageDir) { - packageDir = `skills/${slug}--${hashSkillValue(skill.key)}`; + packageDir = appendSkillExportDirSuffix(candidates[0] ?? `skills/${slug}`, hashSkillValue(skill.key)); while (usedDirs.has(packageDir)) { - packageDir = `skills/${slug}--${hashSkillValue(`${skill.key}:${packageDir}`)}`; + packageDir = appendSkillExportDirSuffix( + candidates[0] ?? `skills/${slug}`, + hashSkillValue(`${skill.key}:${packageDir}`), + ); } } @@ -1805,7 +1883,7 @@ export function companyPortabilityService(db: Db) { const paperclipProjectsOut: Record> = {}; const paperclipTasksOut: Record> = {}; - const skillExportDirs = buildSkillExportDirMap(companySkillRows); + const skillExportDirs = buildSkillExportDirMap(companySkillRows, company.issuePrefix); for (const skill of companySkillRows) { const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`; if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {