Preserve namespaced skill export paths
Keep readable namespaced skill export folders while replacing managed company UUID segments with the company issue prefix for export-only paths. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -81,6 +81,7 @@ describe("company portability", () => {
|
|||||||
id: "company-1",
|
id: "company-1",
|
||||||
name: "Paperclip",
|
name: "Paperclip",
|
||||||
description: null,
|
description: null,
|
||||||
|
issuePrefix: "PAP",
|
||||||
brandColor: "#5c5fff",
|
brandColor: "#5c5fff",
|
||||||
requireBoardApprovalForNewAgents: true,
|
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("skills:");
|
||||||
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain(`- "${paperclipKey}"`);
|
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain(`- "${paperclipKey}"`);
|
||||||
expect(exported.files["agents/cmo/AGENTS.md"]).not.toContain("skills:");
|
expect(exported.files["agents/cmo/AGENTS.md"]).not.toContain("skills:");
|
||||||
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
|
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain("metadata:");
|
||||||
expect(exported.files["skills/paperclip/SKILL.md"]).toContain('kind: "github-dir"');
|
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain('kind: "github-dir"');
|
||||||
expect(exported.files["skills/paperclip/references/api.md"]).toBeUndefined();
|
expect(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"]).toBeUndefined();
|
||||||
expect(exported.files["skills/company-playbook/SKILL.md"]).toContain("# Company Playbook");
|
expect(exported.files["skills/company/PAP/company-playbook/SKILL.md"]).toContain("# Company Playbook");
|
||||||
expect(exported.files["skills/company-playbook/references/checklist.md"]).toContain("# Checklist");
|
expect(exported.files["skills/company/PAP/company-playbook/references/checklist.md"]).toContain("# Checklist");
|
||||||
|
|
||||||
const extension = exported.files[".paperclip.yaml"];
|
const extension = exported.files[".paperclip.yaml"];
|
||||||
expect(extension).toContain('schema: "paperclip/v1"');
|
expect(extension).toContain('schema: "paperclip/v1"');
|
||||||
@@ -313,12 +314,12 @@ describe("company portability", () => {
|
|||||||
expandReferencedSkills: true,
|
expandReferencedSkills: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("# Paperclip");
|
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain("# Paperclip");
|
||||||
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
|
expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toContain("metadata:");
|
||||||
expect(exported.files["skills/paperclip/references/api.md"]).toContain("# API");
|
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);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => {
|
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/local/release-changelog/SKILL.md"]).toContain("# Local Release Changelog");
|
||||||
expect(exported.files["skills/release-changelog--paperclip/SKILL.md"]).toContain("metadata:");
|
expect(exported.files["skills/paperclipai/paperclip/release-changelog/SKILL.md"]).toContain("metadata:");
|
||||||
expect(exported.files["skills/release-changelog--paperclip/SKILL.md"]).toContain("paperclipai/paperclip/release-changelog");
|
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 () => {
|
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||||
|
|||||||
@@ -115,17 +115,103 @@ function hashSkillValue(value: string) {
|
|||||||
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
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) {
|
function readSkillSourceKind(skill: CompanySkill) {
|
||||||
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
||||||
return asString(metadata?.sourceKind);
|
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 metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
||||||
const sourceKind = readSkillSourceKind(skill);
|
const sourceKind = readSkillSourceKind(skill);
|
||||||
const suffixes = new Set<string>();
|
const suffixes = new Set<string>();
|
||||||
const pushSuffix = (value: string | null | undefined) => {
|
const pushSuffix = (value: string | null | undefined, preserveCase = false) => {
|
||||||
const normalized = normalizeSkillSlug(value);
|
const normalized = normalizeExportPathSegment(value, preserveCase);
|
||||||
if (normalized && normalized !== slug) {
|
if (normalized && normalized !== slug) {
|
||||||
suffixes.add(normalized);
|
suffixes.add(normalized);
|
||||||
}
|
}
|
||||||
@@ -149,10 +235,7 @@ function deriveSkillExportDirCandidates(skill: CompanySkill, slug: string) {
|
|||||||
} else if (skill.sourceType === "local_path") {
|
} else if (skill.sourceType === "local_path") {
|
||||||
pushSuffix(asString(metadata?.projectName));
|
pushSuffix(asString(metadata?.projectName));
|
||||||
pushSuffix(asString(metadata?.workspaceName));
|
pushSuffix(asString(metadata?.workspaceName));
|
||||||
if (skill.sourceLocator) {
|
pushSuffix(deriveLocalExportNamespace(skill, slug));
|
||||||
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 === "managed_local") pushSuffix("company");
|
||||||
if (sourceKind === "project_scan") pushSuffix("project");
|
if (sourceKind === "project_scan") pushSuffix("project");
|
||||||
pushSuffix("local");
|
pushSuffix("local");
|
||||||
@@ -161,30 +244,25 @@ function deriveSkillExportDirCandidates(skill: CompanySkill, slug: string) {
|
|||||||
pushSuffix("skill");
|
pushSuffix("skill");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(suffixes, (suffix) => `skills/${slug}--${suffix}`);
|
return [primaryDir, ...Array.from(suffixes, (suffix) => appendSkillExportDirSuffix(primaryDir, suffix))];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSkillExportDirMap(skills: CompanySkill[]) {
|
function buildSkillExportDirMap(skills: CompanySkill[], companyIssuePrefix: string | null | undefined) {
|
||||||
const slugCounts = new Map<string, number>();
|
|
||||||
for (const skill of skills) {
|
|
||||||
const slug = normalizeSkillSlug(skill.slug) ?? "skill";
|
|
||||||
slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const usedDirs = new Set<string>();
|
const usedDirs = new Set<string>();
|
||||||
const keyToDir = new Map<string, string>();
|
const keyToDir = new Map<string, string>();
|
||||||
const orderedSkills = [...skills].sort((left, right) => left.key.localeCompare(right.key));
|
const orderedSkills = [...skills].sort((left, right) => left.key.localeCompare(right.key));
|
||||||
for (const skill of orderedSkills) {
|
for (const skill of orderedSkills) {
|
||||||
const slug = normalizeSkillSlug(skill.slug) ?? "skill";
|
const slug = normalizeSkillSlug(skill.slug) ?? "skill";
|
||||||
const candidates = (slugCounts.get(slug) ?? 0) > 1
|
const candidates = deriveSkillExportDirCandidates(skill, slug, companyIssuePrefix);
|
||||||
? deriveSkillExportDirCandidates(skill, slug)
|
|
||||||
: [`skills/${slug}`];
|
|
||||||
|
|
||||||
let packageDir = candidates.find((candidate) => !usedDirs.has(candidate)) ?? null;
|
let packageDir = candidates.find((candidate) => !usedDirs.has(candidate)) ?? null;
|
||||||
if (!packageDir) {
|
if (!packageDir) {
|
||||||
packageDir = `skills/${slug}--${hashSkillValue(skill.key)}`;
|
packageDir = appendSkillExportDirSuffix(candidates[0] ?? `skills/${slug}`, hashSkillValue(skill.key));
|
||||||
while (usedDirs.has(packageDir)) {
|
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<string, Record<string, unknown>> = {};
|
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
|
||||||
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
|
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
const skillExportDirs = buildSkillExportDirMap(companySkillRows);
|
const skillExportDirs = buildSkillExportDirMap(companySkillRows, company.issuePrefix);
|
||||||
for (const skill of companySkillRows) {
|
for (const skill of companySkillRows) {
|
||||||
const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`;
|
const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`;
|
||||||
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
||||||
|
|||||||
Reference in New Issue
Block a user