Refine portability export behavior and skill plans

This commit is contained in:
Dotta
2026-03-14 18:59:26 -05:00
parent 7e43020a28
commit b2c0f3f9a5
13 changed files with 1126 additions and 12 deletions

View File

@@ -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"],
},
}),
}));
});
});

View File

@@ -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,

View File

@@ -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,
};
}