Use pretty export paths for skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
ef60ea0446
commit
9ba47681c6
2 changed files with 178 additions and 11 deletions
|
|
@ -275,11 +275,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/${paperclipKey}/SKILL.md`]).toContain("metadata:");
|
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
|
||||||
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain('kind: "github-dir"');
|
expect(exported.files["skills/paperclip/SKILL.md"]).toContain('kind: "github-dir"');
|
||||||
expect(exported.files[`skills/${paperclipKey}/references/api.md`]).toBeUndefined();
|
expect(exported.files["skills/paperclip/references/api.md"]).toBeUndefined();
|
||||||
expect(exported.files[`skills/${companyPlaybookKey}/SKILL.md`]).toContain("# Company Playbook");
|
expect(exported.files["skills/company-playbook/SKILL.md"]).toContain("# Company Playbook");
|
||||||
expect(exported.files[`skills/${companyPlaybookKey}/references/checklist.md`]).toContain("# Checklist");
|
expect(exported.files["skills/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,9 +313,94 @@ describe("company portability", () => {
|
||||||
expandReferencedSkills: true,
|
expandReferencedSkills: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain("# Paperclip");
|
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("# Paperclip");
|
||||||
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain("metadata:");
|
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
|
||||||
expect(exported.files[`skills/${paperclipKey}/references/api.md`]).toContain("# API");
|
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 () => {
|
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
@ -110,8 +111,88 @@ function deriveManifestSkillKey(
|
||||||
return slug;
|
return slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
function skillPackageDir(key: string) {
|
function hashSkillValue(value: string) {
|
||||||
return `skills/${key}`;
|
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<string>();
|
||||||
|
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<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 keyToDir = new Map<string, string>();
|
||||||
|
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) {
|
function isSensitiveEnvKey(key: string) {
|
||||||
|
|
@ -1724,8 +1805,9 @@ 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);
|
||||||
for (const skill of 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))) {
|
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
||||||
files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
|
files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue