Refine portability export behavior and skill plans
This commit is contained in:
@@ -29,6 +29,12 @@ const issueSvc = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const companySkillSvc = {
|
||||
list: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
importPackageFiles: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("../services/companies.js", () => ({
|
||||
companyService: () => companySvc,
|
||||
}));
|
||||
@@ -49,6 +55,10 @@ vi.mock("../services/issues.js", () => ({
|
||||
issueService: () => issueSvc,
|
||||
}));
|
||||
|
||||
vi.mock("../services/company-skills.js", () => ({
|
||||
companySkillService: () => companySkillSvc,
|
||||
}));
|
||||
|
||||
const { companyPortabilityService } = await import("../services/company-portability.js");
|
||||
|
||||
describe("company portability", () => {
|
||||
@@ -74,6 +84,9 @@ describe("company portability", () => {
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You are ClaudeCoder.",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
instructionsFilePath: "/tmp/ignored.md",
|
||||
cwd: "/tmp/ignored",
|
||||
command: "/Users/dotta/.local/bin/claude",
|
||||
@@ -106,14 +119,113 @@ describe("company portability", () => {
|
||||
},
|
||||
metadata: null,
|
||||
},
|
||||
{
|
||||
id: "agent-2",
|
||||
name: "CMO",
|
||||
status: "idle",
|
||||
role: "cmo",
|
||||
title: "Chief Marketing Officer",
|
||||
icon: "globe",
|
||||
reportsTo: null,
|
||||
capabilities: "Owns marketing",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You are CMO.",
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
intervalSec: 3600,
|
||||
},
|
||||
},
|
||||
budgetMonthlyCents: 0,
|
||||
permissions: {
|
||||
canCreateAgents: false,
|
||||
},
|
||||
metadata: null,
|
||||
},
|
||||
]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
issueSvc.list.mockResolvedValue([]);
|
||||
issueSvc.getById.mockResolvedValue(null);
|
||||
issueSvc.getByIdentifier.mockResolvedValue(null);
|
||||
companySkillSvc.list.mockResolvedValue([
|
||||
{
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
slug: "paperclip",
|
||||
name: "paperclip",
|
||||
description: "Paperclip coordination skill",
|
||||
markdown: "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://github.com/paperclipai/paperclip/tree/master/skills/paperclip",
|
||||
sourceRef: "0123456789abcdef0123456789abcdef01234567",
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [
|
||||
{ path: "SKILL.md", kind: "skill" },
|
||||
{ path: "references/api.md", kind: "reference" },
|
||||
],
|
||||
metadata: {
|
||||
sourceKind: "github",
|
||||
owner: "paperclipai",
|
||||
repo: "paperclip",
|
||||
ref: "0123456789abcdef0123456789abcdef01234567",
|
||||
trackingRef: "master",
|
||||
repoSkillDir: "skills/paperclip",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "skill-2",
|
||||
companyId: "company-1",
|
||||
slug: "company-playbook",
|
||||
name: "company-playbook",
|
||||
description: "Internal company skill",
|
||||
markdown: "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: "/tmp/company-playbook",
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [
|
||||
{ path: "SKILL.md", kind: "skill" },
|
||||
{ path: "references/checklist.md", kind: "reference" },
|
||||
],
|
||||
metadata: {
|
||||
sourceKind: "local_path",
|
||||
},
|
||||
},
|
||||
]);
|
||||
companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => {
|
||||
if (skillId === "skill-2") {
|
||||
return {
|
||||
skillId,
|
||||
path: relativePath,
|
||||
kind: relativePath === "SKILL.md" ? "skill" : "reference",
|
||||
content: relativePath === "SKILL.md"
|
||||
? "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n"
|
||||
: "# Checklist\n",
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
editable: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
skillId,
|
||||
path: relativePath,
|
||||
kind: relativePath === "SKILL.md" ? "skill" : "reference",
|
||||
content: relativePath === "SKILL.md"
|
||||
? "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n"
|
||||
: "# API\n",
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
editable: false,
|
||||
};
|
||||
});
|
||||
companySkillSvc.importPackageFiles.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("exports a clean base package with sanitized Paperclip extension data", async () => {
|
||||
it("exports referenced skills as stubs by default with sanitized Paperclip extension data", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
@@ -128,6 +240,14 @@ describe("company portability", () => {
|
||||
expect(exported.files["COMPANY.md"]).toContain('name: "Paperclip"');
|
||||
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/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");
|
||||
|
||||
const extension = exported.files[".paperclip.yaml"];
|
||||
expect(extension).toContain('schema: "paperclip/v1"');
|
||||
@@ -147,6 +267,24 @@ describe("company portability", () => {
|
||||
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
|
||||
});
|
||||
|
||||
it("expands referenced skills when requested", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
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");
|
||||
});
|
||||
|
||||
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
@@ -201,4 +339,58 @@ describe("company portability", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports packaged skills and restores desired skill refs on agents", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
companySvc.create.mockResolvedValue({
|
||||
id: "company-imported",
|
||||
name: "Imported Paperclip",
|
||||
});
|
||||
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||
agentSvc.create.mockResolvedValue({
|
||||
id: "agent-created",
|
||||
name: "ClaudeCoder",
|
||||
});
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
await portability.importBundle({
|
||||
source: {
|
||||
type: "inline",
|
||||
rootPath: exported.rootPath,
|
||||
files: exported.files,
|
||||
},
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
target: {
|
||||
mode: "new_company",
|
||||
newCompanyName: "Imported Paperclip",
|
||||
},
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files);
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type {
|
||||
@@ -16,6 +17,8 @@ import type {
|
||||
CompanyPortabilityPreviewResult,
|
||||
CompanyPortabilityProjectManifestEntry,
|
||||
CompanyPortabilityIssueManifestEntry,
|
||||
CompanyPortabilitySkillManifestEntry,
|
||||
CompanySkill,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
ISSUE_PRIORITIES,
|
||||
@@ -24,9 +27,14 @@ import {
|
||||
deriveProjectUrlKey,
|
||||
normalizeAgentUrlKey,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
writePaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { companySkillService } from "./company-skills.js";
|
||||
import { companyService } from "./companies.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { projectService } from "./projects.js";
|
||||
@@ -475,6 +483,7 @@ const YAML_KEY_PRIORITY = [
|
||||
"kind",
|
||||
"slug",
|
||||
"reportsTo",
|
||||
"skills",
|
||||
"owner",
|
||||
"assignee",
|
||||
"project",
|
||||
@@ -594,6 +603,93 @@ function buildMarkdown(frontmatter: Record<string, unknown>, body: string) {
|
||||
return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`;
|
||||
}
|
||||
|
||||
function buildSkillSourceEntry(skill: CompanySkill) {
|
||||
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
||||
if (asString(metadata?.sourceKind) === "paperclip_bundled") {
|
||||
let commit: string | null = null;
|
||||
try {
|
||||
const resolved = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
commit = resolved || null;
|
||||
} catch {
|
||||
commit = null;
|
||||
}
|
||||
return {
|
||||
kind: "github-dir",
|
||||
repo: "paperclipai/paperclip",
|
||||
path: `skills/${skill.slug}`,
|
||||
commit,
|
||||
trackingRef: "master",
|
||||
url: `https://github.com/paperclipai/paperclip/tree/master/skills/${skill.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (skill.sourceType === "github") {
|
||||
const owner = asString(metadata?.owner);
|
||||
const repo = asString(metadata?.repo);
|
||||
const repoSkillDir = asString(metadata?.repoSkillDir);
|
||||
if (!owner || !repo || !repoSkillDir) return null;
|
||||
return {
|
||||
kind: "github-dir",
|
||||
repo: `${owner}/${repo}`,
|
||||
path: repoSkillDir,
|
||||
commit: skill.sourceRef ?? null,
|
||||
trackingRef: asString(metadata?.trackingRef),
|
||||
url: skill.sourceLocator,
|
||||
};
|
||||
}
|
||||
|
||||
if (skill.sourceType === "url" && skill.sourceLocator) {
|
||||
return {
|
||||
kind: "url",
|
||||
url: skill.sourceLocator,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkills: boolean) {
|
||||
if (expandReferencedSkills) return false;
|
||||
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
||||
if (asString(metadata?.sourceKind) === "paperclip_bundled") return true;
|
||||
return skill.sourceType === "github" || skill.sourceType === "url";
|
||||
}
|
||||
|
||||
function buildReferencedSkillMarkdown(skill: CompanySkill) {
|
||||
const sourceEntry = buildSkillSourceEntry(skill);
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
name: skill.name,
|
||||
description: skill.description ?? null,
|
||||
};
|
||||
if (sourceEntry) {
|
||||
frontmatter.metadata = {
|
||||
sources: [sourceEntry],
|
||||
};
|
||||
}
|
||||
return buildMarkdown(frontmatter, "");
|
||||
}
|
||||
|
||||
function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
|
||||
const sourceEntry = buildSkillSourceEntry(skill);
|
||||
if (!sourceEntry) return markdown;
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
const metadata = isPlainRecord(parsed.frontmatter.metadata)
|
||||
? { ...parsed.frontmatter.metadata }
|
||||
: {};
|
||||
const existingSources = Array.isArray(metadata.sources)
|
||||
? metadata.sources.filter((entry) => isPlainRecord(entry))
|
||||
: [];
|
||||
metadata.sources = [...existingSources, sourceEntry];
|
||||
const frontmatter = {
|
||||
...parsed.frontmatter,
|
||||
metadata,
|
||||
};
|
||||
return buildMarkdown(frontmatter, parsed.body);
|
||||
}
|
||||
|
||||
function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name: string }>) {
|
||||
const lines = ["# Agents", ""];
|
||||
if (agentSummaries.length === 0) {
|
||||
@@ -854,6 +950,17 @@ function readAgentEnvInputs(
|
||||
});
|
||||
}
|
||||
|
||||
function readAgentSkillRefs(frontmatter: Record<string, unknown>) {
|
||||
const skills = frontmatter.skills;
|
||||
if (!Array.isArray(skills)) return [];
|
||||
return Array.from(new Set(
|
||||
skills
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => normalizeAgentUrlKey(entry) ?? entry.trim())
|
||||
.filter(Boolean),
|
||||
));
|
||||
}
|
||||
|
||||
function buildManifestFromPackageFiles(
|
||||
files: Record<string, string>,
|
||||
opts?: { sourceLabel?: { companyId: string; companyName: string } | null },
|
||||
@@ -898,6 +1005,9 @@ function buildManifestFromPackageFiles(
|
||||
const referencedTaskPaths = includeEntries
|
||||
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
|
||||
.filter((entry) => entry.endsWith("/TASK.md") || entry === "TASK.md");
|
||||
const referencedSkillPaths = includeEntries
|
||||
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
|
||||
.filter((entry) => entry.endsWith("/SKILL.md") || entry === "SKILL.md");
|
||||
const discoveredAgentPaths = Object.keys(normalizedFiles).filter(
|
||||
(entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md",
|
||||
);
|
||||
@@ -907,9 +1017,13 @@ function buildManifestFromPackageFiles(
|
||||
const discoveredTaskPaths = Object.keys(normalizedFiles).filter(
|
||||
(entry) => entry.endsWith("/TASK.md") || entry === "TASK.md",
|
||||
);
|
||||
const discoveredSkillPaths = Object.keys(normalizedFiles).filter(
|
||||
(entry) => entry.endsWith("/SKILL.md") || entry === "SKILL.md",
|
||||
);
|
||||
const agentPaths = Array.from(new Set([...referencedAgentPaths, ...discoveredAgentPaths])).sort();
|
||||
const projectPaths = Array.from(new Set([...referencedProjectPaths, ...discoveredProjectPaths])).sort();
|
||||
const taskPaths = Array.from(new Set([...referencedTaskPaths, ...discoveredTaskPaths])).sort();
|
||||
const skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort();
|
||||
|
||||
const manifest: CompanyPortabilityManifest = {
|
||||
schemaVersion: 3,
|
||||
@@ -932,6 +1046,7 @@ function buildManifestFromPackageFiles(
|
||||
: readCompanyApprovalDefault(companyFrontmatter),
|
||||
},
|
||||
agents: [],
|
||||
skills: [],
|
||||
projects: [],
|
||||
issues: [],
|
||||
envInputs: [],
|
||||
@@ -963,6 +1078,7 @@ function buildManifestFromPackageFiles(
|
||||
slug,
|
||||
name: asString(frontmatter.name) ?? title ?? slug,
|
||||
path: agentPath,
|
||||
skills: readAgentSkillRefs(frontmatter),
|
||||
role: asString(extension.role) ?? "agent",
|
||||
title,
|
||||
icon: asString(extension.icon),
|
||||
@@ -986,6 +1102,89 @@ function buildManifestFromPackageFiles(
|
||||
}
|
||||
}
|
||||
|
||||
for (const skillPath of skillPaths) {
|
||||
const markdownRaw = normalizedFiles[skillPath];
|
||||
if (typeof markdownRaw !== "string") {
|
||||
warnings.push(`Referenced skill file is missing from package: ${skillPath}`);
|
||||
continue;
|
||||
}
|
||||
const skillDoc = parseFrontmatterMarkdown(markdownRaw);
|
||||
const frontmatter = skillDoc.frontmatter;
|
||||
const skillDir = path.posix.dirname(skillPath);
|
||||
const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(skillDir)) ?? "skill";
|
||||
const slug = asString(frontmatter.slug) ?? normalizeAgentUrlKey(asString(frontmatter.name) ?? "") ?? fallbackSlug;
|
||||
const inventory = Object.keys(normalizedFiles)
|
||||
.filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`))
|
||||
.map((entry) => ({
|
||||
path: entry === skillPath ? "SKILL.md" : entry.slice(skillDir.length + 1),
|
||||
kind: entry === skillPath
|
||||
? "skill"
|
||||
: entry.startsWith(`${skillDir}/references/`)
|
||||
? "reference"
|
||||
: entry.startsWith(`${skillDir}/scripts/`)
|
||||
? "script"
|
||||
: entry.startsWith(`${skillDir}/assets/`)
|
||||
? "asset"
|
||||
: entry.endsWith(".md")
|
||||
? "markdown"
|
||||
: "other",
|
||||
}));
|
||||
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
|
||||
const sources = metadata && Array.isArray(metadata.sources) ? metadata.sources : [];
|
||||
const primarySource = sources.find((entry) => isPlainRecord(entry)) as Record<string, unknown> | undefined;
|
||||
const sourceKind = asString(primarySource?.kind);
|
||||
let sourceType = "catalog";
|
||||
let sourceLocator: string | null = null;
|
||||
let sourceRef: string | null = null;
|
||||
let normalizedMetadata: Record<string, unknown> | null = null;
|
||||
|
||||
if (sourceKind === "github-dir" || sourceKind === "github-file") {
|
||||
const repo = asString(primarySource?.repo);
|
||||
const repoPath = asString(primarySource?.path);
|
||||
const commit = asString(primarySource?.commit);
|
||||
const trackingRef = asString(primarySource?.trackingRef);
|
||||
const [owner, repoName] = (repo ?? "").split("/");
|
||||
sourceType = "github";
|
||||
sourceLocator = asString(primarySource?.url)
|
||||
?? (repo ? `https://github.com/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` : null);
|
||||
sourceRef = commit;
|
||||
normalizedMetadata = owner && repoName
|
||||
? {
|
||||
sourceKind: "github",
|
||||
owner,
|
||||
repo: repoName,
|
||||
ref: commit,
|
||||
trackingRef,
|
||||
repoSkillDir: repoPath ?? `skills/${slug}`,
|
||||
}
|
||||
: null;
|
||||
} else if (sourceKind === "url") {
|
||||
sourceType = "url";
|
||||
sourceLocator = asString(primarySource?.url) ?? asString(primarySource?.rawUrl);
|
||||
normalizedMetadata = {
|
||||
sourceKind: "url",
|
||||
};
|
||||
} else if (metadata) {
|
||||
normalizedMetadata = {
|
||||
sourceKind: "catalog",
|
||||
};
|
||||
}
|
||||
|
||||
manifest.skills.push({
|
||||
slug,
|
||||
name: asString(frontmatter.name) ?? slug,
|
||||
path: skillPath,
|
||||
description: asString(frontmatter.description),
|
||||
sourceType,
|
||||
sourceLocator,
|
||||
sourceRef,
|
||||
trustLevel: null,
|
||||
compatibility: "compatible",
|
||||
metadata: normalizedMetadata,
|
||||
fileInventory: inventory,
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectPath of projectPaths) {
|
||||
const markdownRaw = normalizedFiles[projectPath];
|
||||
if (typeof markdownRaw !== "string") {
|
||||
@@ -1163,6 +1362,7 @@ export function companyPortabilityService(db: Db) {
|
||||
const access = accessService(db);
|
||||
const projects = projectService(db);
|
||||
const issues = issueService(db);
|
||||
const companySkills = companySkillService(db);
|
||||
|
||||
async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> {
|
||||
if (source.type === "inline") {
|
||||
@@ -1246,6 +1446,7 @@ export function companyPortabilityService(db: Db) {
|
||||
const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
|
||||
return (
|
||||
relative.endsWith(".md") ||
|
||||
relative.startsWith("skills/") ||
|
||||
relative === ".paperclip.yaml" ||
|
||||
relative === ".paperclip.yml"
|
||||
);
|
||||
@@ -1296,6 +1497,7 @@ export function companyPortabilityService(db: Db) {
|
||||
|
||||
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
||||
const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
||||
const companySkillRows = await companySkills.list(companyId);
|
||||
if (include.agents) {
|
||||
const skipped = allAgentRows.length - agentRows.length;
|
||||
if (skipped > 0) {
|
||||
@@ -1399,7 +1601,7 @@ export function companyPortabilityService(db: Db) {
|
||||
const projectSlugById = new Map<string, string>();
|
||||
const usedProjectSlugs = new Set<string>();
|
||||
for (const project of selectedProjectRows) {
|
||||
const baseSlug = deriveProjectUrlKey(project.name, project.id);
|
||||
const baseSlug = deriveProjectUrlKey(project.name, project.name);
|
||||
projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs));
|
||||
}
|
||||
|
||||
@@ -1431,6 +1633,22 @@ export function companyPortabilityService(db: Db) {
|
||||
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
|
||||
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const skill of companySkillRows) {
|
||||
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
||||
files[`skills/${skill.slug}/SKILL.md`] = 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}`;
|
||||
files[filePath] = inventoryEntry.path === "SKILL.md"
|
||||
? withSkillSourceMetadata(skill, fileDetail.content)
|
||||
: fileDetail.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (include.agents) {
|
||||
for (const agent of agentRows) {
|
||||
const slug = idToSlug.get(agent.id)!;
|
||||
@@ -1467,6 +1685,9 @@ export function companyPortabilityService(db: Db) {
|
||||
.filter((inputValue) => inputValue.agentSlug === slug),
|
||||
);
|
||||
const reportsToSlug = agent.reportsTo ? (idToSlug.get(agent.reportsTo) ?? null) : null;
|
||||
const desiredSkills = readPaperclipSkillSyncPreference(
|
||||
(agent.adapterConfig as Record<string, unknown>) ?? {},
|
||||
).desiredSkills;
|
||||
|
||||
const commandValue = asString(portableAdapterConfig.command);
|
||||
if (commandValue && isAbsoluteCommand(commandValue)) {
|
||||
@@ -1475,11 +1696,12 @@ export function companyPortabilityService(db: Db) {
|
||||
}
|
||||
|
||||
files[agentPath] = buildMarkdown(
|
||||
{
|
||||
stripEmptyValues({
|
||||
name: agent.name,
|
||||
title: agent.title ?? null,
|
||||
reportsTo: reportsToSlug,
|
||||
},
|
||||
skills: desiredSkills.length > 0 ? desiredSkills : undefined,
|
||||
}) as Record<string, unknown>,
|
||||
instructions.body,
|
||||
);
|
||||
|
||||
@@ -1627,6 +1849,8 @@ export function companyPortabilityService(db: Db) {
|
||||
warnings.push("No agents selected for import.");
|
||||
}
|
||||
|
||||
const availableSkillSlugs = new Set(source.manifest.skills.map((skill) => skill.slug));
|
||||
|
||||
for (const agent of selectedAgents) {
|
||||
const filePath = ensureMarkdownPath(agent.path);
|
||||
const markdown = source.files[filePath];
|
||||
@@ -1638,6 +1862,11 @@ 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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (include.projects) {
|
||||
@@ -1912,6 +2141,8 @@ export function companyPortabilityService(db: Db) {
|
||||
existingProjectSlugToId.set(existing.urlKey, existing.id);
|
||||
}
|
||||
|
||||
await companySkills.importPackageFiles(targetCompany.id, plan.source.files);
|
||||
|
||||
if (include.agents) {
|
||||
for (const planAgent of plan.preview.plan.agentPlans) {
|
||||
const manifestAgent = plan.selectedAgents.find((agent) => agent.slug === planAgent.slug);
|
||||
@@ -1936,6 +2167,11 @@ export function companyPortabilityService(db: Db) {
|
||||
...manifestAgent.adapterConfig,
|
||||
promptTemplate: markdown.body || asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "",
|
||||
} as Record<string, unknown>;
|
||||
const desiredSkills = manifestAgent.skills ?? [];
|
||||
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
|
||||
adapterConfig,
|
||||
desiredSkills,
|
||||
);
|
||||
delete adapterConfig.instructionsFilePath;
|
||||
const patch = {
|
||||
name: planAgent.plannedName,
|
||||
@@ -1945,7 +2181,7 @@ export function companyPortabilityService(db: Db) {
|
||||
capabilities: manifestAgent.capabilities,
|
||||
reportsTo: null,
|
||||
adapterType: manifestAgent.adapterType,
|
||||
adapterConfig,
|
||||
adapterConfig: adapterConfigWithSkills,
|
||||
runtimeConfig: manifestAgent.runtimeConfig,
|
||||
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
|
||||
permissions: manifestAgent.permissions,
|
||||
|
||||
@@ -34,6 +34,7 @@ type ImportedSkill = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
markdown: string;
|
||||
packageDir?: string | null;
|
||||
sourceType: CompanySkillSourceType;
|
||||
sourceLocator: string | null;
|
||||
sourceRef: string | null;
|
||||
@@ -72,6 +73,16 @@ function normalizePortablePath(input: string) {
|
||||
return input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function normalizePackageFileMap(files: Record<string, string>) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [rawPath, content] of Object.entries(files)) {
|
||||
const nextPath = normalizePortablePath(rawPath);
|
||||
if (!nextPath) continue;
|
||||
out[nextPath] = content;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeSkillSlug(value: string | null | undefined) {
|
||||
return value ? normalizeAgentUrlKey(value) ?? null : null;
|
||||
}
|
||||
@@ -399,6 +410,111 @@ function deriveImportedSkillSlug(frontmatter: Record<string, unknown>, fallback:
|
||||
return normalizeSkillSlug(asString(frontmatter.name)) ?? normalizeAgentUrlKey(fallback) ?? "skill";
|
||||
}
|
||||
|
||||
function deriveImportedSkillSource(
|
||||
frontmatter: Record<string, unknown>,
|
||||
fallbackSlug: string,
|
||||
): Pick<ImportedSkill, "sourceType" | "sourceLocator" | "sourceRef" | "metadata"> {
|
||||
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
|
||||
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);
|
||||
|
||||
if (kind === "github-dir" || kind === "github-file") {
|
||||
const repo = asString(sourceEntry?.repo);
|
||||
const repoPath = asString(sourceEntry?.path);
|
||||
const commit = asString(sourceEntry?.commit);
|
||||
const trackingRef = asString(sourceEntry?.trackingRef);
|
||||
const url = asString(sourceEntry?.url)
|
||||
?? (repo
|
||||
? `https://github.com/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}`
|
||||
: null);
|
||||
const [owner, repoName] = (repo ?? "").split("/");
|
||||
if (repo && owner && repoName) {
|
||||
return {
|
||||
sourceType: "github",
|
||||
sourceLocator: url,
|
||||
sourceRef: commit,
|
||||
metadata: {
|
||||
sourceKind: "github",
|
||||
owner,
|
||||
repo: repoName,
|
||||
ref: commit,
|
||||
trackingRef,
|
||||
repoSkillDir: repoPath ?? `skills/${fallbackSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "url") {
|
||||
const url = asString(sourceEntry?.url) ?? asString(sourceEntry?.rawUrl);
|
||||
if (url) {
|
||||
return {
|
||||
sourceType: "url",
|
||||
sourceLocator: url,
|
||||
sourceRef: null,
|
||||
metadata: {
|
||||
sourceKind: "url",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceType: "catalog",
|
||||
sourceLocator: null,
|
||||
sourceRef: null,
|
||||
metadata: {
|
||||
sourceKind: "catalog",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readInlineSkillImports(files: Record<string, string>): ImportedSkill[] {
|
||||
const normalizedFiles = normalizePackageFileMap(files);
|
||||
const skillPaths = Object.keys(normalizedFiles).filter(
|
||||
(entry) => path.posix.basename(entry).toLowerCase() === "skill.md",
|
||||
);
|
||||
const imports: ImportedSkill[] = [];
|
||||
|
||||
for (const skillPath of skillPaths) {
|
||||
const dir = path.posix.dirname(skillPath);
|
||||
const skillDir = dir === "." ? "" : dir;
|
||||
const slugFallback = path.posix.basename(skillDir || path.posix.dirname(skillPath));
|
||||
const markdown = normalizedFiles[skillPath]!;
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
const slug = deriveImportedSkillSlug(parsed.frontmatter, slugFallback);
|
||||
const source = deriveImportedSkillSource(parsed.frontmatter, slug);
|
||||
const inventory = Object.keys(normalizedFiles)
|
||||
.filter((entry) => entry === skillPath || (skillDir ? entry.startsWith(`${skillDir}/`) : false))
|
||||
.map((entry) => {
|
||||
const relative = entry === skillPath ? "SKILL.md" : entry.slice(skillDir.length + 1);
|
||||
return {
|
||||
path: normalizePortablePath(relative),
|
||||
kind: classifyInventoryKind(relative),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
|
||||
imports.push({
|
||||
slug,
|
||||
name: asString(parsed.frontmatter.name) ?? slug,
|
||||
description: asString(parsed.frontmatter.description),
|
||||
markdown,
|
||||
packageDir: skillDir,
|
||||
sourceType: source.sourceType,
|
||||
sourceLocator: source.sourceLocator,
|
||||
sourceRef: source.sourceRef,
|
||||
trustLevel: deriveTrustLevel(inventory),
|
||||
compatibility: "compatible",
|
||||
fileInventory: inventory,
|
||||
metadata: source.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
async function walkLocalFiles(root: string, current: string, out: string[]) {
|
||||
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
@@ -432,6 +548,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
|
||||
name: asString(parsed.frontmatter.name) ?? slug,
|
||||
description: asString(parsed.frontmatter.description),
|
||||
markdown,
|
||||
packageDir: path.dirname(resolvedPath),
|
||||
sourceType: "local_path",
|
||||
sourceLocator: path.dirname(resolvedPath),
|
||||
sourceRef: null,
|
||||
@@ -471,6 +588,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
|
||||
name: asString(parsed.frontmatter.name) ?? slug,
|
||||
description: asString(parsed.frontmatter.description),
|
||||
markdown,
|
||||
packageDir: path.join(root, skillDir),
|
||||
sourceType: "local_path",
|
||||
sourceLocator: path.join(root, skillDir),
|
||||
sourceRef: null,
|
||||
@@ -633,7 +751,7 @@ function getSkillMeta(skill: CompanySkill): SkillSourceMeta {
|
||||
}
|
||||
|
||||
function normalizeSkillDirectory(skill: CompanySkill) {
|
||||
if (skill.sourceType !== "local_path" || !skill.sourceLocator) return null;
|
||||
if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null;
|
||||
const resolved = path.resolve(skill.sourceLocator);
|
||||
if (path.basename(resolved).toLowerCase() === "skill.md") {
|
||||
return path.dirname(resolved);
|
||||
@@ -921,10 +1039,15 @@ export function companySkillService(db: Db) {
|
||||
const source = deriveSkillSourceInfo(skill);
|
||||
let content = "";
|
||||
|
||||
if (skill.sourceType === "local_path") {
|
||||
if (skill.sourceType === "local_path" || skill.sourceType === "catalog") {
|
||||
const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath);
|
||||
if (!absolutePath) throw notFound("Skill file not found");
|
||||
content = await fs.readFile(absolutePath, "utf8");
|
||||
if (absolutePath) {
|
||||
content = await fs.readFile(absolutePath, "utf8");
|
||||
} else if (normalizedPath === "SKILL.md") {
|
||||
content = skill.markdown;
|
||||
} else {
|
||||
throw notFound("Skill file not found");
|
||||
}
|
||||
} else if (skill.sourceType === "github") {
|
||||
const metadata = getSkillMeta(skill);
|
||||
const owner = asString(metadata.owner);
|
||||
@@ -1061,10 +1184,69 @@ export function companySkillService(db: Db) {
|
||||
return imported[0] ?? null;
|
||||
}
|
||||
|
||||
async function materializeCatalogSkillFiles(
|
||||
companyId: string,
|
||||
skill: ImportedSkill,
|
||||
normalizedFiles: Record<string, string>,
|
||||
) {
|
||||
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);
|
||||
await fs.rm(skillDir, { recursive: true, force: true });
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
|
||||
for (const entry of skill.fileInventory) {
|
||||
const sourcePath = entry.path === "SKILL.md"
|
||||
? `${packageDir}/SKILL.md`
|
||||
: `${packageDir}/${entry.path}`;
|
||||
const content = normalizedFiles[sourcePath];
|
||||
if (typeof content !== "string") continue;
|
||||
const targetPath = path.resolve(skillDir, entry.path);
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, content, "utf8");
|
||||
}
|
||||
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
async function importPackageFiles(companyId: string, files: Record<string, string>): Promise<CompanySkill[]> {
|
||||
await ensureBundledSkills(companyId);
|
||||
const normalizedFiles = normalizePackageFileMap(files);
|
||||
const importedSkills = readInlineSkillImports(normalizedFiles);
|
||||
if (importedSkills.length === 0) return [];
|
||||
|
||||
for (const skill of importedSkills) {
|
||||
if (skill.sourceType !== "catalog") continue;
|
||||
const materializedDir = await materializeCatalogSkillFiles(companyId, skill, normalizedFiles);
|
||||
if (materializedDir) {
|
||||
skill.sourceLocator = materializedDir;
|
||||
}
|
||||
}
|
||||
|
||||
return upsertImportedSkills(companyId, importedSkills);
|
||||
}
|
||||
|
||||
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 existingMeta = existing ? getSkillMeta(existing) : {};
|
||||
const incomingMeta = skill.metadata && isPlainRecord(skill.metadata) ? skill.metadata : {};
|
||||
const incomingOwner = asString(incomingMeta.owner);
|
||||
const incomingRepo = asString(incomingMeta.repo);
|
||||
const incomingKind = asString(incomingMeta.sourceKind);
|
||||
if (
|
||||
existing
|
||||
&& existingMeta.sourceKind === "paperclip_bundled"
|
||||
&& incomingKind === "github"
|
||||
&& incomingOwner === "paperclipai"
|
||||
&& incomingRepo === "paperclip"
|
||||
) {
|
||||
out.push(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
const values = {
|
||||
companyId,
|
||||
slug: skill.slug,
|
||||
@@ -1137,6 +1319,7 @@ export function companySkillService(db: Db) {
|
||||
updateFile,
|
||||
createLocalSkill,
|
||||
importFromSource,
|
||||
importPackageFiles,
|
||||
installUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user