Filter out projects with archivedAt set when building the export bundle, so archived projects never appear in the exported package. Adds a warning when archived projects are skipped. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2696 lines
97 KiB
TypeScript
2696 lines
97 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import { execFile } from "node:child_process";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import type { Db } from "@paperclipai/db";
|
|
import type {
|
|
CompanyPortabilityAgentManifestEntry,
|
|
CompanyPortabilityCollisionStrategy,
|
|
CompanyPortabilityEnvInput,
|
|
CompanyPortabilityExport,
|
|
CompanyPortabilityExportResult,
|
|
CompanyPortabilityImport,
|
|
CompanyPortabilityImportResult,
|
|
CompanyPortabilityInclude,
|
|
CompanyPortabilityManifest,
|
|
CompanyPortabilityPreview,
|
|
CompanyPortabilityPreviewAgentPlan,
|
|
CompanyPortabilityPreviewResult,
|
|
CompanyPortabilityProjectManifestEntry,
|
|
CompanyPortabilityIssueManifestEntry,
|
|
CompanyPortabilitySkillManifestEntry,
|
|
CompanySkill,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
ISSUE_PRIORITIES,
|
|
ISSUE_STATUSES,
|
|
PROJECT_STATUSES,
|
|
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 { agentInstructionsService } from "./agent-instructions.js";
|
|
import { generateReadme } from "./company-export-readme.js";
|
|
import { companySkillService } from "./company-skills.js";
|
|
import { companyService } from "./companies.js";
|
|
import { issueService } from "./issues.js";
|
|
import { projectService } from "./projects.js";
|
|
|
|
const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
|
|
company: true,
|
|
agents: true,
|
|
projects: false,
|
|
issues: false,
|
|
};
|
|
|
|
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
|
|
const execFileAsync = promisify(execFile);
|
|
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
|
|
|
|
function normalizeSkillSlug(value: string | null | undefined) {
|
|
return value ? normalizeAgentUrlKey(value) ?? null : null;
|
|
}
|
|
|
|
function normalizeSkillKey(value: string | null | undefined) {
|
|
if (!value) return null;
|
|
const segments = value
|
|
.split("/")
|
|
.map((segment) => normalizeSkillSlug(segment))
|
|
.filter((segment): segment is string => Boolean(segment));
|
|
return segments.length > 0 ? segments.join("/") : null;
|
|
}
|
|
|
|
function readSkillKey(frontmatter: Record<string, unknown>) {
|
|
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
|
|
const paperclip = isPlainRecord(metadata?.paperclip) ? metadata?.paperclip as Record<string, unknown> : null;
|
|
return normalizeSkillKey(
|
|
asString(frontmatter.key)
|
|
?? asString(frontmatter.skillKey)
|
|
?? asString(metadata?.skillKey)
|
|
?? asString(metadata?.canonicalKey)
|
|
?? asString(metadata?.paperclipSkillKey)
|
|
?? asString(paperclip?.skillKey)
|
|
?? asString(paperclip?.key),
|
|
);
|
|
}
|
|
|
|
function deriveManifestSkillKey(
|
|
frontmatter: Record<string, unknown>,
|
|
fallbackSlug: string,
|
|
metadata: Record<string, unknown> | null,
|
|
sourceType: string,
|
|
sourceLocator: string | null,
|
|
) {
|
|
const explicit = readSkillKey(frontmatter);
|
|
if (explicit) return explicit;
|
|
const slug = normalizeSkillSlug(asString(frontmatter.slug) ?? fallbackSlug) ?? "skill";
|
|
const sourceKind = asString(metadata?.sourceKind);
|
|
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
|
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
|
if ((sourceType === "github" || sourceKind === "github") && owner && repo) {
|
|
return `${owner}/${repo}/${slug}`;
|
|
}
|
|
if (sourceKind === "paperclip_bundled") {
|
|
return `paperclipai/paperclip/${slug}`;
|
|
}
|
|
if (sourceType === "url" || sourceKind === "url") {
|
|
try {
|
|
const host = normalizeSkillSlug(sourceLocator ? new URL(sourceLocator).host : null) ?? "url";
|
|
return `url/${host}/${slug}`;
|
|
} catch {
|
|
return `url/unknown/${slug}`;
|
|
}
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
function hashSkillValue(value: string) {
|
|
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
}
|
|
|
|
function normalizeExportPathSegment(value: string | null | undefined, preserveCase = false) {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const normalized = trimmed
|
|
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
if (!normalized) return null;
|
|
return preserveCase ? normalized : normalized.toLowerCase();
|
|
}
|
|
|
|
function readSkillSourceKind(skill: CompanySkill) {
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
return asString(metadata?.sourceKind);
|
|
}
|
|
|
|
function deriveLocalExportNamespace(skill: CompanySkill, slug: string) {
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
const candidates = [
|
|
asString(metadata?.projectName),
|
|
asString(metadata?.workspaceName),
|
|
];
|
|
|
|
if (skill.sourceLocator) {
|
|
const basename = path.basename(skill.sourceLocator);
|
|
candidates.push(basename.toLowerCase() === "skill.md" ? path.basename(path.dirname(skill.sourceLocator)) : basename);
|
|
}
|
|
|
|
for (const value of candidates) {
|
|
const normalized = normalizeSkillSlug(value);
|
|
if (normalized && normalized !== slug) return normalized;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function derivePrimarySkillExportDir(
|
|
skill: CompanySkill,
|
|
slug: string,
|
|
companyIssuePrefix: string | null | undefined,
|
|
) {
|
|
const normalizedKey = normalizeSkillKey(skill.key);
|
|
const keySegments = normalizedKey?.split("/") ?? [];
|
|
const primaryNamespace = keySegments[0] ?? null;
|
|
|
|
if (primaryNamespace === "company") {
|
|
const companySegment = normalizeExportPathSegment(companyIssuePrefix, true)
|
|
?? normalizeExportPathSegment(keySegments[1], true)
|
|
?? "company";
|
|
return `skills/company/${companySegment}/${slug}`;
|
|
}
|
|
|
|
if (primaryNamespace === "local") {
|
|
const localNamespace = deriveLocalExportNamespace(skill, slug);
|
|
return localNamespace
|
|
? `skills/local/${localNamespace}/${slug}`
|
|
: `skills/local/${slug}`;
|
|
}
|
|
|
|
if (primaryNamespace === "url") {
|
|
let derivedHost: string | null = keySegments[1] ?? null;
|
|
if (!derivedHost) {
|
|
try {
|
|
derivedHost = normalizeSkillSlug(skill.sourceLocator ? new URL(skill.sourceLocator).host : null);
|
|
} catch {
|
|
derivedHost = null;
|
|
}
|
|
}
|
|
const host = derivedHost ?? "url";
|
|
return `skills/url/${host}/${slug}`;
|
|
}
|
|
|
|
if (keySegments.length > 1) {
|
|
return `skills/${keySegments.join("/")}`;
|
|
}
|
|
|
|
return `skills/${slug}`;
|
|
}
|
|
|
|
function appendSkillExportDirSuffix(packageDir: string, suffix: string) {
|
|
const lastSeparator = packageDir.lastIndexOf("/");
|
|
if (lastSeparator < 0) return `${packageDir}--${suffix}`;
|
|
return `${packageDir.slice(0, lastSeparator + 1)}${packageDir.slice(lastSeparator + 1)}--${suffix}`;
|
|
}
|
|
|
|
function deriveSkillExportDirCandidates(
|
|
skill: CompanySkill,
|
|
slug: string,
|
|
companyIssuePrefix: string | null | undefined,
|
|
) {
|
|
const primaryDir = derivePrimarySkillExportDir(skill, slug, companyIssuePrefix);
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
const sourceKind = readSkillSourceKind(skill);
|
|
const suffixes = new Set<string>();
|
|
const pushSuffix = (value: string | null | undefined, preserveCase = false) => {
|
|
const normalized = normalizeExportPathSegment(value, preserveCase);
|
|
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));
|
|
pushSuffix(deriveLocalExportNamespace(skill, slug));
|
|
if (sourceKind === "managed_local") pushSuffix("company");
|
|
if (sourceKind === "project_scan") pushSuffix("project");
|
|
pushSuffix("local");
|
|
} else {
|
|
pushSuffix(sourceKind);
|
|
pushSuffix("skill");
|
|
}
|
|
|
|
return [primaryDir, ...Array.from(suffixes, (suffix) => appendSkillExportDirSuffix(primaryDir, suffix))];
|
|
}
|
|
|
|
function buildSkillExportDirMap(skills: CompanySkill[], companyIssuePrefix: string | null | undefined) {
|
|
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 = deriveSkillExportDirCandidates(skill, slug, companyIssuePrefix);
|
|
|
|
let packageDir = candidates.find((candidate) => !usedDirs.has(candidate)) ?? null;
|
|
if (!packageDir) {
|
|
packageDir = appendSkillExportDirSuffix(candidates[0] ?? `skills/${slug}`, hashSkillValue(skill.key));
|
|
while (usedDirs.has(packageDir)) {
|
|
packageDir = appendSkillExportDirSuffix(
|
|
candidates[0] ?? `skills/${slug}`,
|
|
hashSkillValue(`${skill.key}:${packageDir}`),
|
|
);
|
|
}
|
|
}
|
|
|
|
usedDirs.add(packageDir);
|
|
keyToDir.set(skill.key, packageDir);
|
|
}
|
|
|
|
return keyToDir;
|
|
}
|
|
|
|
function isSensitiveEnvKey(key: string) {
|
|
const normalized = key.trim().toLowerCase();
|
|
return (
|
|
normalized === "token" ||
|
|
normalized.endsWith("_token") ||
|
|
normalized.endsWith("-token") ||
|
|
normalized.includes("api_key") ||
|
|
normalized.includes("api-key") ||
|
|
normalized.includes("access_token") ||
|
|
normalized.includes("access-token") ||
|
|
normalized.includes("auth_token") ||
|
|
normalized.includes("auth-token") ||
|
|
normalized.includes("authorization") ||
|
|
normalized.includes("bearer") ||
|
|
normalized.includes("secret") ||
|
|
normalized.includes("passwd") ||
|
|
normalized.includes("password") ||
|
|
normalized.includes("credential") ||
|
|
normalized.includes("jwt") ||
|
|
normalized.includes("private_key") ||
|
|
normalized.includes("private-key") ||
|
|
normalized.includes("cookie") ||
|
|
normalized.includes("connectionstring")
|
|
);
|
|
}
|
|
|
|
type ResolvedSource = {
|
|
manifest: CompanyPortabilityManifest;
|
|
files: Record<string, string>;
|
|
warnings: string[];
|
|
};
|
|
|
|
type MarkdownDoc = {
|
|
frontmatter: Record<string, unknown>;
|
|
body: string;
|
|
};
|
|
|
|
type CompanyPackageIncludeEntry = {
|
|
path: string;
|
|
};
|
|
|
|
type PaperclipExtensionDoc = {
|
|
schema?: string;
|
|
company?: Record<string, unknown> | null;
|
|
agents?: Record<string, Record<string, unknown>> | null;
|
|
projects?: Record<string, Record<string, unknown>> | null;
|
|
tasks?: Record<string, Record<string, unknown>> | null;
|
|
};
|
|
|
|
type ProjectLike = {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
leadAgentId: string | null;
|
|
targetDate: string | null;
|
|
color: string | null;
|
|
status: string;
|
|
executionWorkspacePolicy: Record<string, unknown> | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
};
|
|
|
|
type IssueLike = {
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
description: string | null;
|
|
projectId: string | null;
|
|
assigneeAgentId: string | null;
|
|
status: string;
|
|
priority: string;
|
|
labelIds?: string[];
|
|
billingCode: string | null;
|
|
executionWorkspaceSettings: Record<string, unknown> | null;
|
|
assigneeAdapterOverrides: Record<string, unknown> | null;
|
|
};
|
|
|
|
type ImportPlanInternal = {
|
|
preview: CompanyPortabilityPreviewResult;
|
|
source: ResolvedSource;
|
|
include: CompanyPortabilityInclude;
|
|
collisionStrategy: CompanyPortabilityCollisionStrategy;
|
|
selectedAgents: CompanyPortabilityAgentManifestEntry[];
|
|
};
|
|
|
|
type AgentLike = {
|
|
id: string;
|
|
name: string;
|
|
adapterConfig: Record<string, unknown>;
|
|
};
|
|
|
|
type EnvInputRecord = {
|
|
kind: "secret" | "plain";
|
|
requirement: "required" | "optional";
|
|
default?: string | null;
|
|
description?: string | null;
|
|
portability?: "portable" | "system_dependent";
|
|
};
|
|
|
|
const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [
|
|
{ path: ["heartbeat", "cooldownSec"], value: 10 },
|
|
{ path: ["heartbeat", "intervalSec"], value: 3600 },
|
|
{ path: ["heartbeat", "wakeOnOnDemand"], value: true },
|
|
{ path: ["heartbeat", "wakeOnAssignment"], value: true },
|
|
{ path: ["heartbeat", "wakeOnAutomation"], value: true },
|
|
{ path: ["heartbeat", "wakeOnDemand"], value: true },
|
|
{ path: ["heartbeat", "maxConcurrentRuns"], value: 3 },
|
|
];
|
|
|
|
const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; value: unknown }>> = {
|
|
codex_local: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
],
|
|
gemini_local: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
],
|
|
opencode_local: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
],
|
|
cursor: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
],
|
|
claude_local: [
|
|
{ path: ["timeoutSec"], value: 0 },
|
|
{ path: ["graceSec"], value: 15 },
|
|
{ path: ["maxTurnsPerRun"], value: 300 },
|
|
],
|
|
openclaw_gateway: [
|
|
{ path: ["timeoutSec"], value: 120 },
|
|
{ path: ["waitTimeoutMs"], value: 120000 },
|
|
{ path: ["sessionKeyStrategy"], value: "fixed" },
|
|
{ path: ["sessionKey"], value: "paperclip" },
|
|
{ path: ["role"], value: "operator" },
|
|
{ path: ["scopes"], value: ["operator.admin"] },
|
|
],
|
|
};
|
|
|
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function asString(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function toSafeSlug(input: string, fallback: string) {
|
|
return normalizeAgentUrlKey(input) ?? fallback;
|
|
}
|
|
|
|
function uniqueSlug(base: string, used: Set<string>) {
|
|
if (!used.has(base)) {
|
|
used.add(base);
|
|
return base;
|
|
}
|
|
let idx = 2;
|
|
while (true) {
|
|
const candidate = `${base}-${idx}`;
|
|
if (!used.has(candidate)) {
|
|
used.add(candidate);
|
|
return candidate;
|
|
}
|
|
idx += 1;
|
|
}
|
|
}
|
|
|
|
function uniqueNameBySlug(baseName: string, existingSlugs: Set<string>) {
|
|
const baseSlug = normalizeAgentUrlKey(baseName) ?? "agent";
|
|
if (!existingSlugs.has(baseSlug)) return baseName;
|
|
let idx = 2;
|
|
while (true) {
|
|
const candidateName = `${baseName} ${idx}`;
|
|
const candidateSlug = normalizeAgentUrlKey(candidateName) ?? `agent-${idx}`;
|
|
if (!existingSlugs.has(candidateSlug)) return candidateName;
|
|
idx += 1;
|
|
}
|
|
}
|
|
|
|
function uniqueProjectName(baseName: string, existingProjectSlugs: Set<string>) {
|
|
const baseSlug = deriveProjectUrlKey(baseName, baseName);
|
|
if (!existingProjectSlugs.has(baseSlug)) return baseName;
|
|
let idx = 2;
|
|
while (true) {
|
|
const candidateName = `${baseName} ${idx}`;
|
|
const candidateSlug = deriveProjectUrlKey(candidateName, candidateName);
|
|
if (!existingProjectSlugs.has(candidateSlug)) return candidateName;
|
|
idx += 1;
|
|
}
|
|
}
|
|
|
|
function normalizeInclude(input?: Partial<CompanyPortabilityInclude>): CompanyPortabilityInclude {
|
|
return {
|
|
company: input?.company ?? DEFAULT_INCLUDE.company,
|
|
agents: input?.agents ?? DEFAULT_INCLUDE.agents,
|
|
projects: input?.projects ?? DEFAULT_INCLUDE.projects,
|
|
issues: input?.issues ?? DEFAULT_INCLUDE.issues,
|
|
};
|
|
}
|
|
|
|
function normalizePortablePath(input: string) {
|
|
const normalized = input.replace(/\\/g, "/").replace(/^\.\/+/, "");
|
|
const parts: string[] = [];
|
|
for (const segment of normalized.split("/")) {
|
|
if (!segment || segment === ".") continue;
|
|
if (segment === "..") {
|
|
if (parts.length > 0) parts.pop();
|
|
continue;
|
|
}
|
|
parts.push(segment);
|
|
}
|
|
return parts.join("/");
|
|
}
|
|
|
|
function resolvePortablePath(fromPath: string, targetPath: string) {
|
|
const baseDir = path.posix.dirname(fromPath.replace(/\\/g, "/"));
|
|
return normalizePortablePath(path.posix.join(baseDir, targetPath.replace(/\\/g, "/")));
|
|
}
|
|
|
|
function normalizeFileMap(
|
|
files: Record<string, string>,
|
|
rootPath?: string | null,
|
|
): Record<string, string> {
|
|
const normalizedRoot = rootPath ? normalizePortablePath(rootPath) : null;
|
|
const out: Record<string, string> = {};
|
|
for (const [rawPath, content] of Object.entries(files)) {
|
|
let nextPath = normalizePortablePath(rawPath);
|
|
if (normalizedRoot && nextPath === normalizedRoot) {
|
|
continue;
|
|
}
|
|
if (normalizedRoot && nextPath.startsWith(`${normalizedRoot}/`)) {
|
|
nextPath = nextPath.slice(normalizedRoot.length + 1);
|
|
}
|
|
if (!nextPath) continue;
|
|
out[nextPath] = content;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function findPaperclipExtensionPath(files: Record<string, string>) {
|
|
if (typeof files[".paperclip.yaml"] === "string") return ".paperclip.yaml";
|
|
if (typeof files[".paperclip.yml"] === "string") return ".paperclip.yml";
|
|
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
|
|
}
|
|
|
|
function ensureMarkdownPath(pathValue: string) {
|
|
const normalized = pathValue.replace(/\\/g, "/");
|
|
if (!normalized.endsWith(".md")) {
|
|
throw unprocessable(`Manifest file path must end in .md: ${pathValue}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizePortableConfig(
|
|
value: unknown,
|
|
): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
const input = value as Record<string, unknown>;
|
|
const next: Record<string, unknown> = {};
|
|
|
|
for (const [key, entry] of Object.entries(input)) {
|
|
if (
|
|
key === "cwd" ||
|
|
key === "instructionsFilePath" ||
|
|
key === "instructionsBundleMode" ||
|
|
key === "instructionsRootPath" ||
|
|
key === "instructionsEntryFile" ||
|
|
key === "promptTemplate" ||
|
|
key === "bootstrapPromptTemplate" ||
|
|
key === "paperclipSkillSync"
|
|
) continue;
|
|
if (key === "env") continue;
|
|
next[key] = entry;
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
function isAbsoluteCommand(value: string) {
|
|
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value);
|
|
}
|
|
|
|
function extractPortableEnvInputs(
|
|
agentSlug: string,
|
|
envValue: unknown,
|
|
warnings: string[],
|
|
): CompanyPortabilityEnvInput[] {
|
|
if (!isPlainRecord(envValue)) return [];
|
|
const env = envValue as Record<string, unknown>;
|
|
const inputs: CompanyPortabilityEnvInput[] = [];
|
|
|
|
for (const [key, binding] of Object.entries(env)) {
|
|
if (key.toUpperCase() === "PATH") {
|
|
warnings.push(`Agent ${agentSlug} PATH override was omitted from export because it is system-dependent.`);
|
|
continue;
|
|
}
|
|
|
|
if (isPlainRecord(binding) && binding.type === "secret_ref") {
|
|
inputs.push({
|
|
key,
|
|
description: `Provide ${key} for agent ${agentSlug}`,
|
|
agentSlug,
|
|
kind: "secret",
|
|
requirement: "optional",
|
|
defaultValue: "",
|
|
portability: "portable",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (isPlainRecord(binding) && binding.type === "plain") {
|
|
const defaultValue = asString(binding.value);
|
|
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
|
? "system_dependent"
|
|
: "portable";
|
|
if (portability === "system_dependent") {
|
|
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
|
|
}
|
|
inputs.push({
|
|
key,
|
|
description: `Optional default for ${key} on agent ${agentSlug}`,
|
|
agentSlug,
|
|
kind: "plain",
|
|
requirement: "optional",
|
|
defaultValue: defaultValue ?? "",
|
|
portability,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (typeof binding === "string") {
|
|
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
|
|
if (portability === "system_dependent") {
|
|
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
|
|
}
|
|
inputs.push({
|
|
key,
|
|
description: `Optional default for ${key} on agent ${agentSlug}`,
|
|
agentSlug,
|
|
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
|
|
requirement: "optional",
|
|
defaultValue: binding,
|
|
portability,
|
|
});
|
|
}
|
|
}
|
|
|
|
return inputs;
|
|
}
|
|
|
|
function jsonEqual(left: unknown, right: unknown): boolean {
|
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
}
|
|
|
|
function isPathDefault(pathSegments: string[], value: unknown, rules: Array<{ path: string[]; value: unknown }>) {
|
|
return rules.some((rule) => jsonEqual(rule.path, pathSegments) && jsonEqual(rule.value, value));
|
|
}
|
|
|
|
function pruneDefaultLikeValue(
|
|
value: unknown,
|
|
opts: {
|
|
dropFalseBooleans: boolean;
|
|
path?: string[];
|
|
defaultRules?: Array<{ path: string[]; value: unknown }>;
|
|
},
|
|
): unknown {
|
|
const pathSegments = opts.path ?? [];
|
|
if (opts.defaultRules && isPathDefault(pathSegments, value, opts.defaultRules)) {
|
|
return undefined;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((entry) => pruneDefaultLikeValue(entry, { ...opts, path: pathSegments }));
|
|
}
|
|
if (isPlainRecord(value)) {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
const next = pruneDefaultLikeValue(entry, {
|
|
...opts,
|
|
path: [...pathSegments, key],
|
|
});
|
|
if (next === undefined) continue;
|
|
out[key] = next;
|
|
}
|
|
return out;
|
|
}
|
|
if (value === undefined) return undefined;
|
|
if (opts.dropFalseBooleans && value === false) return undefined;
|
|
return value;
|
|
}
|
|
|
|
function renderYamlScalar(value: unknown): string {
|
|
if (value === null) return "null";
|
|
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
if (typeof value === "string") return JSON.stringify(value);
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function isEmptyObject(value: unknown): boolean {
|
|
return isPlainRecord(value) && Object.keys(value).length === 0;
|
|
}
|
|
|
|
function isEmptyArray(value: unknown): boolean {
|
|
return Array.isArray(value) && value.length === 0;
|
|
}
|
|
|
|
function stripEmptyValues(value: unknown, opts?: { preserveEmptyStrings?: boolean }): unknown {
|
|
if (Array.isArray(value)) {
|
|
const next = value
|
|
.map((entry) => stripEmptyValues(entry, opts))
|
|
.filter((entry) => entry !== undefined);
|
|
return next.length > 0 ? next : undefined;
|
|
}
|
|
if (isPlainRecord(value)) {
|
|
const next: Record<string, unknown> = {};
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
const cleaned = stripEmptyValues(entry, opts);
|
|
if (cleaned === undefined) continue;
|
|
next[key] = cleaned;
|
|
}
|
|
return Object.keys(next).length > 0 ? next : undefined;
|
|
}
|
|
if (
|
|
value === undefined ||
|
|
value === null ||
|
|
(!opts?.preserveEmptyStrings && value === "") ||
|
|
isEmptyArray(value) ||
|
|
isEmptyObject(value)
|
|
) {
|
|
return undefined;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
const YAML_KEY_PRIORITY = [
|
|
"name",
|
|
"description",
|
|
"title",
|
|
"schema",
|
|
"kind",
|
|
"slug",
|
|
"reportsTo",
|
|
"skills",
|
|
"owner",
|
|
"assignee",
|
|
"project",
|
|
"schedule",
|
|
"version",
|
|
"license",
|
|
"authors",
|
|
"homepage",
|
|
"tags",
|
|
"includes",
|
|
"requirements",
|
|
"role",
|
|
"icon",
|
|
"capabilities",
|
|
"brandColor",
|
|
"adapter",
|
|
"runtime",
|
|
"permissions",
|
|
"budgetMonthlyCents",
|
|
"metadata",
|
|
] as const;
|
|
|
|
const YAML_KEY_PRIORITY_INDEX = new Map<string, number>(
|
|
YAML_KEY_PRIORITY.map((key, index) => [key, index]),
|
|
);
|
|
|
|
function compareYamlKeys(left: string, right: string) {
|
|
const leftPriority = YAML_KEY_PRIORITY_INDEX.get(left);
|
|
const rightPriority = YAML_KEY_PRIORITY_INDEX.get(right);
|
|
if (leftPriority !== undefined || rightPriority !== undefined) {
|
|
if (leftPriority === undefined) return 1;
|
|
if (rightPriority === undefined) return -1;
|
|
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
|
|
}
|
|
return left.localeCompare(right);
|
|
}
|
|
|
|
function orderedYamlEntries(value: Record<string, unknown>) {
|
|
return Object.entries(value).sort(([leftKey], [rightKey]) => compareYamlKeys(leftKey, rightKey));
|
|
}
|
|
|
|
function renderYamlBlock(value: unknown, indentLevel: number): string[] {
|
|
const indent = " ".repeat(indentLevel);
|
|
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) return [`${indent}[]`];
|
|
const lines: string[] = [];
|
|
for (const entry of value) {
|
|
const scalar =
|
|
entry === null ||
|
|
typeof entry === "string" ||
|
|
typeof entry === "boolean" ||
|
|
typeof entry === "number" ||
|
|
Array.isArray(entry) && entry.length === 0 ||
|
|
isEmptyObject(entry);
|
|
if (scalar) {
|
|
lines.push(`${indent}- ${renderYamlScalar(entry)}`);
|
|
continue;
|
|
}
|
|
lines.push(`${indent}-`);
|
|
lines.push(...renderYamlBlock(entry, indentLevel + 1));
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
if (isPlainRecord(value)) {
|
|
const entries = orderedYamlEntries(value);
|
|
if (entries.length === 0) return [`${indent}{}`];
|
|
const lines: string[] = [];
|
|
for (const [key, entry] of entries) {
|
|
const scalar =
|
|
entry === null ||
|
|
typeof entry === "string" ||
|
|
typeof entry === "boolean" ||
|
|
typeof entry === "number" ||
|
|
Array.isArray(entry) && entry.length === 0 ||
|
|
isEmptyObject(entry);
|
|
if (scalar) {
|
|
lines.push(`${indent}${key}: ${renderYamlScalar(entry)}`);
|
|
continue;
|
|
}
|
|
lines.push(`${indent}${key}:`);
|
|
lines.push(...renderYamlBlock(entry, indentLevel + 1));
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
return [`${indent}${renderYamlScalar(value)}`];
|
|
}
|
|
|
|
function renderFrontmatter(frontmatter: Record<string, unknown>) {
|
|
const lines: string[] = ["---"];
|
|
for (const [key, value] of orderedYamlEntries(frontmatter)) {
|
|
// Skip null/undefined values — don't export empty fields
|
|
if (value === null || value === undefined) continue;
|
|
const scalar =
|
|
typeof value === "string" ||
|
|
typeof value === "boolean" ||
|
|
typeof value === "number" ||
|
|
Array.isArray(value) && value.length === 0 ||
|
|
isEmptyObject(value);
|
|
if (scalar) {
|
|
lines.push(`${key}: ${renderYamlScalar(value)}`);
|
|
continue;
|
|
}
|
|
lines.push(`${key}:`);
|
|
lines.push(...renderYamlBlock(value, 1));
|
|
}
|
|
lines.push("---");
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function buildMarkdown(frontmatter: Record<string, unknown>, body: string) {
|
|
const cleanBody = body.replace(/\r\n/g, "\n").trim();
|
|
if (!cleanBody) {
|
|
return `${renderFrontmatter(frontmatter)}\n`;
|
|
}
|
|
return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`;
|
|
}
|
|
|
|
function normalizeSelectedFiles(selectedFiles?: string[]) {
|
|
if (!selectedFiles) return null;
|
|
return new Set(
|
|
selectedFiles
|
|
.map((entry) => normalizePortablePath(entry))
|
|
.filter((entry) => entry.length > 0),
|
|
);
|
|
}
|
|
|
|
function filterCompanyMarkdownIncludes(
|
|
companyPath: string,
|
|
markdown: string,
|
|
selectedFiles: Set<string>,
|
|
) {
|
|
const parsed = parseFrontmatterMarkdown(markdown);
|
|
const includeEntries = readIncludeEntries(parsed.frontmatter);
|
|
const filteredIncludes = includeEntries.filter((entry) =>
|
|
selectedFiles.has(resolvePortablePath(companyPath, entry.path)),
|
|
);
|
|
const nextFrontmatter: Record<string, unknown> = { ...parsed.frontmatter };
|
|
if (filteredIncludes.length > 0) {
|
|
nextFrontmatter.includes = filteredIncludes.map((entry) => entry.path);
|
|
} else {
|
|
delete nextFrontmatter.includes;
|
|
}
|
|
return buildMarkdown(nextFrontmatter, parsed.body);
|
|
}
|
|
|
|
function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: string[]): ResolvedSource {
|
|
const normalizedSelection = normalizeSelectedFiles(selectedFiles);
|
|
if (!normalizedSelection) return source;
|
|
|
|
const companyPath = source.manifest.company
|
|
? ensureMarkdownPath(source.manifest.company.path)
|
|
: Object.keys(source.files).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md") ?? null;
|
|
if (!companyPath) {
|
|
throw unprocessable("Company package is missing COMPANY.md");
|
|
}
|
|
|
|
const companyMarkdown = source.files[companyPath];
|
|
if (typeof companyMarkdown !== "string") {
|
|
throw unprocessable("Company package is missing COMPANY.md");
|
|
}
|
|
|
|
const effectiveFiles: Record<string, string> = {};
|
|
for (const [filePath, content] of Object.entries(source.files)) {
|
|
const normalizedPath = normalizePortablePath(filePath);
|
|
if (!normalizedSelection.has(normalizedPath)) continue;
|
|
effectiveFiles[normalizedPath] = content;
|
|
}
|
|
|
|
effectiveFiles[companyPath] = filterCompanyMarkdownIncludes(
|
|
companyPath,
|
|
companyMarkdown,
|
|
normalizedSelection,
|
|
);
|
|
|
|
const filtered = buildManifestFromPackageFiles(effectiveFiles, {
|
|
sourceLabel: source.manifest.source,
|
|
});
|
|
|
|
if (!normalizedSelection.has(companyPath)) {
|
|
filtered.manifest.company = null;
|
|
}
|
|
|
|
filtered.manifest.includes = {
|
|
company: filtered.manifest.company !== null,
|
|
agents: filtered.manifest.agents.length > 0,
|
|
projects: filtered.manifest.projects.length > 0,
|
|
issues: filtered.manifest.issues.length > 0,
|
|
};
|
|
|
|
return filtered;
|
|
}
|
|
|
|
async function resolveBundledSkillsCommit() {
|
|
if (!bundledSkillsCommitPromise) {
|
|
bundledSkillsCommitPromise = execFileAsync("git", ["rev-parse", "HEAD"], {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
})
|
|
.then(({ stdout }) => stdout.trim() || null)
|
|
.catch(() => null);
|
|
}
|
|
return bundledSkillsCommitPromise;
|
|
}
|
|
|
|
async function buildSkillSourceEntry(skill: CompanySkill) {
|
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
|
if (asString(metadata?.sourceKind) === "paperclip_bundled") {
|
|
const commit = await resolveBundledSkillsCommit();
|
|
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";
|
|
}
|
|
|
|
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
|
|
const sourceEntry = await buildSkillSourceEntry(skill);
|
|
const frontmatter: Record<string, unknown> = {
|
|
key: skill.key,
|
|
slug: skill.slug,
|
|
name: skill.name,
|
|
description: skill.description ?? null,
|
|
};
|
|
if (sourceEntry) {
|
|
frontmatter.metadata = {
|
|
sources: [sourceEntry],
|
|
};
|
|
}
|
|
return buildMarkdown(frontmatter, "");
|
|
}
|
|
|
|
async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
|
|
const sourceEntry = await buildSkillSourceEntry(skill);
|
|
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))
|
|
: [];
|
|
if (sourceEntry) {
|
|
metadata.sources = [...existingSources, sourceEntry];
|
|
}
|
|
metadata.skillKey = skill.key;
|
|
metadata.paperclipSkillKey = skill.key;
|
|
metadata.paperclip = {
|
|
...(isPlainRecord(metadata.paperclip) ? metadata.paperclip : {}),
|
|
skillKey: skill.key,
|
|
slug: skill.slug,
|
|
};
|
|
const frontmatter = {
|
|
...parsed.frontmatter,
|
|
key: skill.key,
|
|
slug: skill.slug,
|
|
metadata,
|
|
};
|
|
return buildMarkdown(frontmatter, parsed.body);
|
|
}
|
|
|
|
function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name: string }>) {
|
|
const lines = ["# Agents", ""];
|
|
if (agentSummaries.length === 0) {
|
|
lines.push("- _none_");
|
|
return lines.join("\n");
|
|
}
|
|
for (const agent of agentSummaries) {
|
|
lines.push(`- ${agent.slug} - ${agent.name}`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function parseYamlScalar(rawValue: string): unknown {
|
|
const trimmed = rawValue.trim();
|
|
if (trimmed === "") return "";
|
|
if (trimmed === "null" || trimmed === "~") return null;
|
|
if (trimmed === "true") return true;
|
|
if (trimmed === "false") return false;
|
|
if (trimmed === "[]") return [];
|
|
if (trimmed === "{}") return {};
|
|
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
if (
|
|
trimmed.startsWith("\"") ||
|
|
trimmed.startsWith("[") ||
|
|
trimmed.startsWith("{")
|
|
) {
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function prepareYamlLines(raw: string) {
|
|
return raw
|
|
.split("\n")
|
|
.map((line) => ({
|
|
indent: line.match(/^ */)?.[0].length ?? 0,
|
|
content: line.trim(),
|
|
}))
|
|
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
|
|
}
|
|
|
|
function parseYamlBlock(
|
|
lines: Array<{ indent: number; content: string }>,
|
|
startIndex: number,
|
|
indentLevel: number,
|
|
): { value: unknown; nextIndex: number } {
|
|
let index = startIndex;
|
|
while (index < lines.length && lines[index]!.content.length === 0) {
|
|
index += 1;
|
|
}
|
|
if (index >= lines.length || lines[index]!.indent < indentLevel) {
|
|
return { value: {}, nextIndex: index };
|
|
}
|
|
|
|
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
|
|
if (isArray) {
|
|
const values: unknown[] = [];
|
|
while (index < lines.length) {
|
|
const line = lines[index]!;
|
|
if (line.indent < indentLevel) break;
|
|
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
|
|
const remainder = line.content.slice(1).trim();
|
|
index += 1;
|
|
if (!remainder) {
|
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
|
values.push(nested.value);
|
|
index = nested.nextIndex;
|
|
continue;
|
|
}
|
|
const inlineObjectSeparator = remainder.indexOf(":");
|
|
if (
|
|
inlineObjectSeparator > 0 &&
|
|
!remainder.startsWith("\"") &&
|
|
!remainder.startsWith("{") &&
|
|
!remainder.startsWith("[")
|
|
) {
|
|
const key = remainder.slice(0, inlineObjectSeparator).trim();
|
|
const rawValue = remainder.slice(inlineObjectSeparator + 1).trim();
|
|
const nextObject: Record<string, unknown> = {
|
|
[key]: parseYamlScalar(rawValue),
|
|
};
|
|
if (index < lines.length && lines[index]!.indent > indentLevel) {
|
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
|
if (isPlainRecord(nested.value)) {
|
|
Object.assign(nextObject, nested.value);
|
|
}
|
|
index = nested.nextIndex;
|
|
}
|
|
values.push(nextObject);
|
|
continue;
|
|
}
|
|
values.push(parseYamlScalar(remainder));
|
|
}
|
|
return { value: values, nextIndex: index };
|
|
}
|
|
|
|
const record: Record<string, unknown> = {};
|
|
while (index < lines.length) {
|
|
const line = lines[index]!;
|
|
if (line.indent < indentLevel) break;
|
|
if (line.indent !== indentLevel) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const separatorIndex = line.content.indexOf(":");
|
|
if (separatorIndex <= 0) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const key = line.content.slice(0, separatorIndex).trim();
|
|
const remainder = line.content.slice(separatorIndex + 1).trim();
|
|
index += 1;
|
|
if (!remainder) {
|
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
|
record[key] = nested.value;
|
|
index = nested.nextIndex;
|
|
continue;
|
|
}
|
|
record[key] = parseYamlScalar(remainder);
|
|
}
|
|
|
|
return { value: record, nextIndex: index };
|
|
}
|
|
|
|
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
|
|
const prepared = prepareYamlLines(raw);
|
|
if (prepared.length === 0) return {};
|
|
const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent);
|
|
return isPlainRecord(parsed.value) ? parsed.value : {};
|
|
}
|
|
|
|
function parseYamlFile(raw: string): Record<string, unknown> {
|
|
return parseYamlFrontmatter(raw);
|
|
}
|
|
|
|
function buildYamlFile(value: Record<string, unknown>, opts?: { preserveEmptyStrings?: boolean }) {
|
|
const cleaned = stripEmptyValues(value, opts);
|
|
if (!isPlainRecord(cleaned)) return "{}\n";
|
|
return renderYamlBlock(cleaned, 0).join("\n") + "\n";
|
|
}
|
|
|
|
function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
|
|
const normalized = raw.replace(/\r\n/g, "\n");
|
|
if (!normalized.startsWith("---\n")) {
|
|
return { frontmatter: {}, body: normalized.trim() };
|
|
}
|
|
const closing = normalized.indexOf("\n---\n", 4);
|
|
if (closing < 0) {
|
|
return { frontmatter: {}, body: normalized.trim() };
|
|
}
|
|
const frontmatterRaw = normalized.slice(4, closing).trim();
|
|
const body = normalized.slice(closing + 5).trim();
|
|
return {
|
|
frontmatter: parseYamlFrontmatter(frontmatterRaw),
|
|
body,
|
|
};
|
|
}
|
|
|
|
async function fetchText(url: string) {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
async function fetchOptionalText(url: string) {
|
|
const response = await fetch(url);
|
|
if (response.status === 404) return null;
|
|
if (!response.ok) {
|
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
async function fetchJson<T>(url: string): Promise<T> {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
accept: "application/vnd.github+json",
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
|
|
const seen = new Set<string>();
|
|
const out: CompanyPortabilityManifest["envInputs"] = [];
|
|
for (const value of values) {
|
|
const key = `${value.agentSlug ?? ""}:${value.key.toUpperCase()}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
|
|
const env: Record<string, Record<string, unknown>> = {};
|
|
for (const input of inputs) {
|
|
const entry: Record<string, unknown> = {
|
|
kind: input.kind,
|
|
requirement: input.requirement,
|
|
};
|
|
if (input.defaultValue !== null) entry.default = input.defaultValue;
|
|
if (input.description) entry.description = input.description;
|
|
if (input.portability === "system_dependent") entry.portability = "system_dependent";
|
|
env[input.key] = entry;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function readCompanyApprovalDefault(_frontmatter: Record<string, unknown>) {
|
|
return true;
|
|
}
|
|
|
|
function readIncludeEntries(frontmatter: Record<string, unknown>): CompanyPackageIncludeEntry[] {
|
|
const includes = frontmatter.includes;
|
|
if (!Array.isArray(includes)) return [];
|
|
return includes.flatMap((entry) => {
|
|
if (typeof entry === "string") {
|
|
return [{ path: entry }];
|
|
}
|
|
if (isPlainRecord(entry)) {
|
|
const pathValue = asString(entry.path);
|
|
return pathValue ? [{ path: pathValue }] : [];
|
|
}
|
|
return [];
|
|
});
|
|
}
|
|
|
|
function readAgentEnvInputs(
|
|
extension: Record<string, unknown>,
|
|
agentSlug: string,
|
|
): CompanyPortabilityManifest["envInputs"] {
|
|
const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null;
|
|
const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null;
|
|
if (!env) return [];
|
|
|
|
return Object.entries(env).flatMap(([key, value]) => {
|
|
if (!isPlainRecord(value)) return [];
|
|
const record = value as EnvInputRecord;
|
|
return [{
|
|
key,
|
|
description: asString(record.description) ?? null,
|
|
agentSlug,
|
|
kind: record.kind === "plain" ? "plain" : "secret",
|
|
requirement: record.requirement === "required" ? "required" : "optional",
|
|
defaultValue: typeof record.default === "string" ? record.default : null,
|
|
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
|
|
}];
|
|
});
|
|
}
|
|
|
|
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) => normalizeSkillKey(entry) ?? entry.trim())
|
|
.filter(Boolean),
|
|
));
|
|
}
|
|
|
|
function buildManifestFromPackageFiles(
|
|
files: Record<string, string>,
|
|
opts?: { sourceLabel?: { companyId: string; companyName: string } | null },
|
|
): ResolvedSource {
|
|
const normalizedFiles = normalizeFileMap(files);
|
|
const companyPath =
|
|
normalizedFiles["COMPANY.md"]
|
|
?? undefined;
|
|
const resolvedCompanyPath = companyPath !== undefined
|
|
? "COMPANY.md"
|
|
: Object.keys(normalizedFiles).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md");
|
|
if (!resolvedCompanyPath) {
|
|
throw unprocessable("Company package is missing COMPANY.md");
|
|
}
|
|
|
|
const companyDoc = parseFrontmatterMarkdown(normalizedFiles[resolvedCompanyPath]!);
|
|
const companyFrontmatter = companyDoc.frontmatter;
|
|
const paperclipExtensionPath = findPaperclipExtensionPath(normalizedFiles);
|
|
const paperclipExtension = paperclipExtensionPath
|
|
? parseYamlFile(normalizedFiles[paperclipExtensionPath] ?? "")
|
|
: {};
|
|
const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {};
|
|
const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {};
|
|
const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {};
|
|
const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {};
|
|
const companyName =
|
|
asString(companyFrontmatter.name)
|
|
?? opts?.sourceLabel?.companyName
|
|
?? "Imported Company";
|
|
const companySlug =
|
|
asString(companyFrontmatter.slug)
|
|
?? normalizeAgentUrlKey(companyName)
|
|
?? "company";
|
|
|
|
const includeEntries = readIncludeEntries(companyFrontmatter);
|
|
const referencedAgentPaths = includeEntries
|
|
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
|
|
.filter((entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md");
|
|
const referencedProjectPaths = includeEntries
|
|
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
|
|
.filter((entry) => entry.endsWith("/PROJECT.md") || entry === "PROJECT.md");
|
|
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",
|
|
);
|
|
const discoveredProjectPaths = Object.keys(normalizedFiles).filter(
|
|
(entry) => entry.endsWith("/PROJECT.md") || entry === "PROJECT.md",
|
|
);
|
|
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,
|
|
generatedAt: new Date().toISOString(),
|
|
source: opts?.sourceLabel ?? null,
|
|
includes: {
|
|
company: true,
|
|
agents: true,
|
|
projects: projectPaths.length > 0,
|
|
issues: taskPaths.length > 0,
|
|
},
|
|
company: {
|
|
path: resolvedCompanyPath,
|
|
name: companyName,
|
|
description: asString(companyFrontmatter.description),
|
|
brandColor: asString(paperclipCompany.brandColor),
|
|
requireBoardApprovalForNewAgents:
|
|
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
|
|
? paperclipCompany.requireBoardApprovalForNewAgents
|
|
: readCompanyApprovalDefault(companyFrontmatter),
|
|
},
|
|
agents: [],
|
|
skills: [],
|
|
projects: [],
|
|
issues: [],
|
|
envInputs: [],
|
|
};
|
|
|
|
const warnings: string[] = [];
|
|
for (const agentPath of agentPaths) {
|
|
const markdownRaw = normalizedFiles[agentPath];
|
|
if (typeof markdownRaw !== "string") {
|
|
warnings.push(`Referenced agent file is missing from package: ${agentPath}`);
|
|
continue;
|
|
}
|
|
const agentDoc = parseFrontmatterMarkdown(markdownRaw);
|
|
const frontmatter = agentDoc.frontmatter;
|
|
const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(agentPath))) ?? "agent";
|
|
const slug = asString(frontmatter.slug) ?? fallbackSlug;
|
|
const extension = isPlainRecord(paperclipAgents[slug]) ? paperclipAgents[slug] : {};
|
|
const extensionAdapter = isPlainRecord(extension.adapter) ? extension.adapter : null;
|
|
const extensionRuntime = isPlainRecord(extension.runtime) ? extension.runtime : null;
|
|
const extensionPermissions = isPlainRecord(extension.permissions) ? extension.permissions : null;
|
|
const extensionMetadata = isPlainRecord(extension.metadata) ? extension.metadata : null;
|
|
const adapterConfig = isPlainRecord(extensionAdapter?.config)
|
|
? extensionAdapter.config
|
|
: {};
|
|
const runtimeConfig = extensionRuntime ?? {};
|
|
const title = asString(frontmatter.title);
|
|
|
|
manifest.agents.push({
|
|
slug,
|
|
name: asString(frontmatter.name) ?? title ?? slug,
|
|
path: agentPath,
|
|
skills: readAgentSkillRefs(frontmatter),
|
|
role: asString(extension.role) ?? "agent",
|
|
title,
|
|
icon: asString(extension.icon),
|
|
capabilities: asString(extension.capabilities),
|
|
reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo),
|
|
adapterType: asString(extensionAdapter?.type) ?? "process",
|
|
adapterConfig,
|
|
runtimeConfig,
|
|
permissions: extensionPermissions ?? {},
|
|
budgetMonthlyCents:
|
|
typeof extension.budgetMonthlyCents === "number" && Number.isFinite(extension.budgetMonthlyCents)
|
|
? Math.max(0, Math.floor(extension.budgetMonthlyCents))
|
|
: 0,
|
|
metadata: extensionMetadata,
|
|
});
|
|
|
|
manifest.envInputs.push(...readAgentEnvInputs(extension, slug));
|
|
|
|
if (frontmatter.kind && frontmatter.kind !== "agent") {
|
|
warnings.push(`Agent markdown ${agentPath} does not declare kind: agent in frontmatter.`);
|
|
}
|
|
}
|
|
|
|
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",
|
|
};
|
|
}
|
|
const key = deriveManifestSkillKey(frontmatter, slug, normalizedMetadata, sourceType, sourceLocator);
|
|
|
|
manifest.skills.push({
|
|
key,
|
|
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") {
|
|
warnings.push(`Referenced project file is missing from package: ${projectPath}`);
|
|
continue;
|
|
}
|
|
const projectDoc = parseFrontmatterMarkdown(markdownRaw);
|
|
const frontmatter = projectDoc.frontmatter;
|
|
const fallbackSlug = deriveProjectUrlKey(
|
|
asString(frontmatter.name) ?? path.posix.basename(path.posix.dirname(projectPath)) ?? "project",
|
|
projectPath,
|
|
);
|
|
const slug = asString(frontmatter.slug) ?? fallbackSlug;
|
|
const extension = isPlainRecord(paperclipProjects[slug]) ? paperclipProjects[slug] : {};
|
|
manifest.projects.push({
|
|
slug,
|
|
name: asString(frontmatter.name) ?? slug,
|
|
path: projectPath,
|
|
description: asString(frontmatter.description),
|
|
ownerAgentSlug: asString(frontmatter.owner),
|
|
leadAgentSlug: asString(extension.leadAgentSlug),
|
|
targetDate: asString(extension.targetDate),
|
|
color: asString(extension.color),
|
|
status: asString(extension.status),
|
|
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
|
|
? extension.executionWorkspacePolicy
|
|
: null,
|
|
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
|
});
|
|
if (frontmatter.kind && frontmatter.kind !== "project") {
|
|
warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`);
|
|
}
|
|
}
|
|
|
|
for (const taskPath of taskPaths) {
|
|
const markdownRaw = normalizedFiles[taskPath];
|
|
if (typeof markdownRaw !== "string") {
|
|
warnings.push(`Referenced task file is missing from package: ${taskPath}`);
|
|
continue;
|
|
}
|
|
const taskDoc = parseFrontmatterMarkdown(markdownRaw);
|
|
const frontmatter = taskDoc.frontmatter;
|
|
const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(taskPath))) ?? "task";
|
|
const slug = asString(frontmatter.slug) ?? fallbackSlug;
|
|
const extension = isPlainRecord(paperclipTasks[slug]) ? paperclipTasks[slug] : {};
|
|
const schedule = isPlainRecord(frontmatter.schedule) ? frontmatter.schedule : null;
|
|
const recurrence = schedule && isPlainRecord(schedule.recurrence)
|
|
? schedule.recurrence
|
|
: isPlainRecord(extension.recurrence)
|
|
? extension.recurrence
|
|
: null;
|
|
manifest.issues.push({
|
|
slug,
|
|
identifier: asString(extension.identifier),
|
|
title: asString(frontmatter.name) ?? asString(frontmatter.title) ?? slug,
|
|
path: taskPath,
|
|
projectSlug: asString(frontmatter.project),
|
|
assigneeAgentSlug: asString(frontmatter.assignee),
|
|
description: taskDoc.body || asString(frontmatter.description),
|
|
recurrence,
|
|
status: asString(extension.status),
|
|
priority: asString(extension.priority),
|
|
labelIds: Array.isArray(extension.labelIds)
|
|
? extension.labelIds.filter((entry): entry is string => typeof entry === "string")
|
|
: [],
|
|
billingCode: asString(extension.billingCode),
|
|
executionWorkspaceSettings: isPlainRecord(extension.executionWorkspaceSettings)
|
|
? extension.executionWorkspaceSettings
|
|
: null,
|
|
assigneeAdapterOverrides: isPlainRecord(extension.assigneeAdapterOverrides)
|
|
? extension.assigneeAdapterOverrides
|
|
: null,
|
|
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
|
});
|
|
if (frontmatter.kind && frontmatter.kind !== "task") {
|
|
warnings.push(`Task markdown ${taskPath} does not declare kind: task in frontmatter.`);
|
|
}
|
|
}
|
|
|
|
manifest.envInputs = dedupeEnvInputs(manifest.envInputs);
|
|
return {
|
|
manifest,
|
|
files: normalizedFiles,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
|
|
function parseGitHubSourceUrl(rawUrl: string) {
|
|
const url = new URL(rawUrl);
|
|
if (url.hostname !== "github.com") {
|
|
throw unprocessable("GitHub source must use github.com URL");
|
|
}
|
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
if (parts.length < 2) {
|
|
throw unprocessable("Invalid GitHub URL");
|
|
}
|
|
const owner = parts[0]!;
|
|
const repo = parts[1]!.replace(/\.git$/i, "");
|
|
let ref = "main";
|
|
let basePath = "";
|
|
let companyPath = "COMPANY.md";
|
|
if (parts[2] === "tree") {
|
|
ref = parts[3] ?? "main";
|
|
basePath = parts.slice(4).join("/");
|
|
} else if (parts[2] === "blob") {
|
|
ref = parts[3] ?? "main";
|
|
const blobPath = parts.slice(4).join("/");
|
|
if (!blobPath) {
|
|
throw unprocessable("Invalid GitHub blob URL");
|
|
}
|
|
companyPath = blobPath;
|
|
basePath = path.posix.dirname(blobPath);
|
|
if (basePath === ".") basePath = "";
|
|
}
|
|
return { owner, repo, ref, basePath, companyPath };
|
|
}
|
|
|
|
function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) {
|
|
const normalizedFilePath = filePath.replace(/^\/+/, "");
|
|
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${normalizedFilePath}`;
|
|
}
|
|
|
|
export function companyPortabilityService(db: Db) {
|
|
const companies = companyService(db);
|
|
const agents = agentService(db);
|
|
const instructions = agentInstructionsService();
|
|
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") {
|
|
return buildManifestFromPackageFiles(
|
|
normalizeFileMap(source.files, source.rootPath),
|
|
);
|
|
}
|
|
|
|
const parsed = parseGitHubSourceUrl(source.url);
|
|
let ref = parsed.ref;
|
|
const warnings: string[] = [];
|
|
const companyRelativePath = parsed.companyPath === "COMPANY.md"
|
|
? [parsed.basePath, "COMPANY.md"].filter(Boolean).join("/")
|
|
: parsed.companyPath;
|
|
let companyMarkdown: string | null = null;
|
|
try {
|
|
companyMarkdown = await fetchOptionalText(
|
|
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, companyRelativePath),
|
|
);
|
|
} catch (err) {
|
|
if (ref === "main") {
|
|
ref = "master";
|
|
warnings.push("GitHub ref main not found; falling back to master.");
|
|
companyMarkdown = await fetchOptionalText(
|
|
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, companyRelativePath),
|
|
);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
if (!companyMarkdown) {
|
|
throw unprocessable("GitHub company package is missing COMPANY.md");
|
|
}
|
|
|
|
const companyPath = parsed.companyPath === "COMPANY.md"
|
|
? "COMPANY.md"
|
|
: normalizePortablePath(path.posix.relative(parsed.basePath || ".", parsed.companyPath));
|
|
const files: Record<string, string> = {
|
|
[companyPath]: companyMarkdown,
|
|
};
|
|
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
|
`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
|
).catch(() => ({ tree: [] }));
|
|
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
|
|
const candidatePaths = (tree.tree ?? [])
|
|
.filter((entry) => entry.type === "blob")
|
|
.map((entry) => entry.path)
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.filter((entry) => {
|
|
if (basePrefix && !entry.startsWith(basePrefix)) return false;
|
|
const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
|
|
return (
|
|
relative.endsWith(".md") ||
|
|
relative.startsWith("skills/") ||
|
|
relative === ".paperclip.yaml" ||
|
|
relative === ".paperclip.yml"
|
|
);
|
|
});
|
|
for (const repoPath of candidatePaths) {
|
|
const relativePath = basePrefix ? repoPath.slice(basePrefix.length) : repoPath;
|
|
if (files[relativePath] !== undefined) continue;
|
|
files[normalizePortablePath(relativePath)] = await fetchText(
|
|
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath),
|
|
);
|
|
}
|
|
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
|
|
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
|
|
for (const includeEntry of includeEntries) {
|
|
const repoPath = [parsed.basePath, includeEntry.path].filter(Boolean).join("/");
|
|
const relativePath = normalizePortablePath(includeEntry.path);
|
|
if (files[relativePath] !== undefined) continue;
|
|
if (!(repoPath.endsWith(".md") || repoPath.endsWith(".yaml") || repoPath.endsWith(".yml"))) continue;
|
|
files[relativePath] = await fetchText(
|
|
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath),
|
|
);
|
|
}
|
|
|
|
const resolved = buildManifestFromPackageFiles(files);
|
|
resolved.warnings.unshift(...warnings);
|
|
return resolved;
|
|
}
|
|
|
|
async function exportBundle(
|
|
companyId: string,
|
|
input: CompanyPortabilityExport,
|
|
): Promise<CompanyPortabilityExportResult> {
|
|
const include = normalizeInclude({
|
|
...input.include,
|
|
projects: input.projects && input.projects.length > 0 ? true : input.include?.projects,
|
|
issues:
|
|
(input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)
|
|
? true
|
|
: input.include?.issues,
|
|
});
|
|
const company = await companies.getById(companyId);
|
|
if (!company) throw notFound("Company not found");
|
|
|
|
const files: Record<string, string> = {};
|
|
const warnings: string[] = [];
|
|
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
|
|
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
|
|
|
|
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
|
const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
|
const companySkillRows = await companySkills.listFull(companyId);
|
|
if (include.agents) {
|
|
const skipped = allAgentRows.length - agentRows.length;
|
|
if (skipped > 0) {
|
|
warnings.push(`Skipped ${skipped} terminated agent${skipped === 1 ? "" : "s"} from export.`);
|
|
}
|
|
}
|
|
|
|
const usedSlugs = new Set<string>();
|
|
const idToSlug = new Map<string, string>();
|
|
for (const agent of agentRows) {
|
|
const baseSlug = toSafeSlug(agent.name, "agent");
|
|
const slug = uniqueSlug(baseSlug, usedSlugs);
|
|
idToSlug.set(agent.id, slug);
|
|
}
|
|
|
|
const projectsSvc = projectService(db);
|
|
const issuesSvc = issueService(db);
|
|
const allProjectsRaw = include.projects || include.issues ? await projectsSvc.list(companyId) : [];
|
|
const allProjects = allProjectsRaw.filter((project) => !project.archivedAt);
|
|
if (include.projects) {
|
|
const skippedArchived = allProjectsRaw.length - allProjects.length;
|
|
if (skippedArchived > 0) {
|
|
warnings.push(`Skipped ${skippedArchived} archived project${skippedArchived === 1 ? "" : "s"} from export.`);
|
|
}
|
|
}
|
|
const projectById = new Map(allProjects.map((project) => [project.id, project]));
|
|
const projectByReference = new Map<string, typeof allProjects[number]>();
|
|
for (const project of allProjects) {
|
|
projectByReference.set(project.id, project);
|
|
projectByReference.set(project.urlKey, project);
|
|
}
|
|
|
|
const selectedProjects = new Map<string, typeof allProjects[number]>();
|
|
const normalizeProjectSelector = (selector: string) => selector.trim().toLowerCase();
|
|
for (const selector of input.projects ?? []) {
|
|
const match = projectByReference.get(selector) ?? projectByReference.get(normalizeProjectSelector(selector));
|
|
if (!match) {
|
|
warnings.push(`Project selector "${selector}" was not found and was skipped.`);
|
|
continue;
|
|
}
|
|
selectedProjects.set(match.id, match);
|
|
}
|
|
|
|
const selectedIssues = new Map<string, Awaited<ReturnType<typeof issuesSvc.getById>>>();
|
|
const resolveIssueBySelector = async (selector: string) => {
|
|
const trimmed = selector.trim();
|
|
if (!trimmed) return null;
|
|
return trimmed.includes("-")
|
|
? issuesSvc.getByIdentifier(trimmed)
|
|
: issuesSvc.getById(trimmed);
|
|
};
|
|
for (const selector of input.issues ?? []) {
|
|
const issue = await resolveIssueBySelector(selector);
|
|
if (!issue || issue.companyId !== companyId) {
|
|
warnings.push(`Issue selector "${selector}" was not found and was skipped.`);
|
|
continue;
|
|
}
|
|
selectedIssues.set(issue.id, issue);
|
|
if (issue.projectId) {
|
|
const parentProject = projectById.get(issue.projectId);
|
|
if (parentProject) selectedProjects.set(parentProject.id, parentProject);
|
|
}
|
|
}
|
|
|
|
for (const selector of input.projectIssues ?? []) {
|
|
const match = projectByReference.get(selector) ?? projectByReference.get(normalizeProjectSelector(selector));
|
|
if (!match) {
|
|
warnings.push(`Project-issues selector "${selector}" was not found and was skipped.`);
|
|
continue;
|
|
}
|
|
selectedProjects.set(match.id, match);
|
|
const projectIssues = await issuesSvc.list(companyId, { projectId: match.id });
|
|
for (const issue of projectIssues) {
|
|
selectedIssues.set(issue.id, issue);
|
|
}
|
|
}
|
|
|
|
if (include.projects && selectedProjects.size === 0) {
|
|
for (const project of allProjects) {
|
|
selectedProjects.set(project.id, project);
|
|
}
|
|
}
|
|
|
|
if (include.issues && selectedIssues.size === 0) {
|
|
const allIssues = await issuesSvc.list(companyId);
|
|
for (const issue of allIssues) {
|
|
selectedIssues.set(issue.id, issue);
|
|
if (issue.projectId) {
|
|
const parentProject = projectById.get(issue.projectId);
|
|
if (parentProject) selectedProjects.set(parentProject.id, parentProject);
|
|
}
|
|
}
|
|
}
|
|
|
|
const selectedProjectRows = Array.from(selectedProjects.values())
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
const selectedIssueRows = Array.from(selectedIssues.values())
|
|
.filter((issue): issue is NonNullable<typeof issue> => issue != null)
|
|
.sort((left, right) => (left.identifier ?? left.title).localeCompare(right.identifier ?? right.title));
|
|
|
|
const taskSlugByIssueId = new Map<string, string>();
|
|
const usedTaskSlugs = new Set<string>();
|
|
for (const issue of selectedIssueRows) {
|
|
const baseSlug = normalizeAgentUrlKey(issue.identifier ?? issue.title) ?? "task";
|
|
taskSlugByIssueId.set(issue.id, uniqueSlug(baseSlug, usedTaskSlugs));
|
|
}
|
|
|
|
const projectSlugById = new Map<string, string>();
|
|
const usedProjectSlugs = new Set<string>();
|
|
for (const project of selectedProjectRows) {
|
|
const baseSlug = deriveProjectUrlKey(project.name, project.name);
|
|
projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs));
|
|
}
|
|
|
|
const companyPath = "COMPANY.md";
|
|
const companyBodySections: string[] = [];
|
|
if (include.agents) {
|
|
const companyAgentSummaries = agentRows.map((agent) => ({
|
|
slug: idToSlug.get(agent.id) ?? "agent",
|
|
name: agent.name,
|
|
}));
|
|
companyBodySections.push(renderCompanyAgentsSection(companyAgentSummaries));
|
|
}
|
|
if (selectedProjectRows.length > 0) {
|
|
companyBodySections.push(
|
|
["# Projects", "", ...selectedProjectRows.map((project) => `- ${projectSlugById.get(project.id) ?? project.id} - ${project.name}`)].join("\n"),
|
|
);
|
|
}
|
|
files[companyPath] = buildMarkdown(
|
|
{
|
|
name: company.name,
|
|
description: company.description ?? null,
|
|
schema: "agentcompanies/v1",
|
|
slug: rootPath,
|
|
},
|
|
companyBodySections.join("\n\n").trim(),
|
|
);
|
|
|
|
const paperclipAgentsOut: Record<string, Record<string, unknown>> = {};
|
|
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
|
|
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
|
|
|
|
const skillExportDirs = buildSkillExportDirMap(companySkillRows, company.issuePrefix);
|
|
for (const skill of companySkillRows) {
|
|
const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`;
|
|
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
|
files[`${packageDir}/SKILL.md`] = await 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 = `${packageDir}/${inventoryEntry.path}`;
|
|
files[filePath] = inventoryEntry.path === "SKILL.md"
|
|
? await withSkillSourceMetadata(skill, fileDetail.content)
|
|
: fileDetail.content;
|
|
}
|
|
}
|
|
|
|
if (include.agents) {
|
|
for (const agent of agentRows) {
|
|
const slug = idToSlug.get(agent.id)!;
|
|
const exportedInstructions = await instructions.exportFiles(agent);
|
|
warnings.push(...exportedInstructions.warnings);
|
|
|
|
const envInputsStart = envInputs.length;
|
|
const exportedEnvInputs = extractPortableEnvInputs(
|
|
slug,
|
|
(agent.adapterConfig as Record<string, unknown>).env,
|
|
warnings,
|
|
);
|
|
envInputs.push(...exportedEnvInputs);
|
|
const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? [];
|
|
const portableAdapterConfig = pruneDefaultLikeValue(
|
|
normalizePortableConfig(agent.adapterConfig),
|
|
{
|
|
dropFalseBooleans: true,
|
|
defaultRules: adapterDefaultRules,
|
|
},
|
|
) as Record<string, unknown>;
|
|
const portableRuntimeConfig = pruneDefaultLikeValue(
|
|
normalizePortableConfig(agent.runtimeConfig),
|
|
{
|
|
dropFalseBooleans: true,
|
|
defaultRules: RUNTIME_DEFAULT_RULES,
|
|
},
|
|
) as Record<string, unknown>;
|
|
const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record<string, unknown>;
|
|
const agentEnvInputs = dedupeEnvInputs(
|
|
envInputs
|
|
.slice(envInputsStart)
|
|
.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)) {
|
|
warnings.push(`Agent ${slug} command ${commandValue} was omitted from export because it is system-dependent.`);
|
|
delete portableAdapterConfig.command;
|
|
}
|
|
for (const [relativePath, content] of Object.entries(exportedInstructions.files)) {
|
|
const targetPath = `agents/${slug}/${relativePath}`;
|
|
if (relativePath === exportedInstructions.entryFile) {
|
|
files[targetPath] = buildMarkdown(
|
|
stripEmptyValues({
|
|
name: agent.name,
|
|
title: agent.title ?? null,
|
|
reportsTo: reportsToSlug,
|
|
skills: desiredSkills.length > 0 ? desiredSkills : undefined,
|
|
}) as Record<string, unknown>,
|
|
content,
|
|
);
|
|
} else {
|
|
files[targetPath] = content;
|
|
}
|
|
}
|
|
|
|
const extension = stripEmptyValues({
|
|
role: agent.role !== "agent" ? agent.role : undefined,
|
|
icon: agent.icon ?? null,
|
|
capabilities: agent.capabilities ?? null,
|
|
adapter: {
|
|
type: agent.adapterType,
|
|
config: portableAdapterConfig,
|
|
},
|
|
runtime: portableRuntimeConfig,
|
|
permissions: portablePermissions,
|
|
budgetMonthlyCents: (agent.budgetMonthlyCents ?? 0) > 0 ? agent.budgetMonthlyCents : undefined,
|
|
metadata: (agent.metadata as Record<string, unknown> | null) ?? null,
|
|
});
|
|
if (isPlainRecord(extension) && agentEnvInputs.length > 0) {
|
|
extension.inputs = {
|
|
env: buildEnvInputMap(agentEnvInputs),
|
|
};
|
|
}
|
|
paperclipAgentsOut[slug] = isPlainRecord(extension) ? extension : {};
|
|
}
|
|
}
|
|
|
|
for (const project of selectedProjectRows) {
|
|
const slug = projectSlugById.get(project.id)!;
|
|
const projectPath = `projects/${slug}/PROJECT.md`;
|
|
files[projectPath] = buildMarkdown(
|
|
{
|
|
name: project.name,
|
|
description: project.description ?? null,
|
|
owner: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null,
|
|
},
|
|
project.description ?? "",
|
|
);
|
|
const extension = stripEmptyValues({
|
|
leadAgentSlug: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null,
|
|
targetDate: project.targetDate ?? null,
|
|
color: project.color ?? null,
|
|
status: project.status,
|
|
executionWorkspacePolicy: project.executionWorkspacePolicy ?? undefined,
|
|
});
|
|
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
|
|
}
|
|
|
|
for (const issue of selectedIssueRows) {
|
|
const taskSlug = taskSlugByIssueId.get(issue.id)!;
|
|
const projectSlug = issue.projectId ? (projectSlugById.get(issue.projectId) ?? null) : null;
|
|
// All tasks go in top-level tasks/ folder, never nested under projects/
|
|
const taskPath = `tasks/${taskSlug}/TASK.md`;
|
|
const assigneeSlug = issue.assigneeAgentId ? (idToSlug.get(issue.assigneeAgentId) ?? null) : null;
|
|
files[taskPath] = buildMarkdown(
|
|
{
|
|
name: issue.title,
|
|
project: projectSlug,
|
|
assignee: assigneeSlug,
|
|
},
|
|
issue.description ?? "",
|
|
);
|
|
const extension = stripEmptyValues({
|
|
identifier: issue.identifier,
|
|
status: issue.status,
|
|
priority: issue.priority,
|
|
labelIds: issue.labelIds ?? undefined,
|
|
billingCode: issue.billingCode ?? null,
|
|
executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined,
|
|
assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined,
|
|
});
|
|
paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {};
|
|
}
|
|
|
|
const paperclipExtensionPath = ".paperclip.yaml";
|
|
const paperclipAgents = Object.fromEntries(
|
|
Object.entries(paperclipAgentsOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0),
|
|
);
|
|
const paperclipProjects = Object.fromEntries(
|
|
Object.entries(paperclipProjectsOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0),
|
|
);
|
|
const paperclipTasks = Object.fromEntries(
|
|
Object.entries(paperclipTasksOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0),
|
|
);
|
|
files[paperclipExtensionPath] = buildYamlFile(
|
|
{
|
|
schema: "paperclip/v1",
|
|
company: stripEmptyValues({
|
|
brandColor: company.brandColor ?? null,
|
|
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false,
|
|
}),
|
|
agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined,
|
|
projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined,
|
|
tasks: Object.keys(paperclipTasks).length > 0 ? paperclipTasks : undefined,
|
|
},
|
|
{ preserveEmptyStrings: true },
|
|
);
|
|
|
|
const resolved = buildManifestFromPackageFiles(files, {
|
|
sourceLabel: {
|
|
companyId: company.id,
|
|
companyName: company.name,
|
|
},
|
|
});
|
|
resolved.manifest.includes = include;
|
|
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
|
resolved.warnings.unshift(...warnings);
|
|
|
|
// Generate README.md with Mermaid org chart
|
|
files["README.md"] = generateReadme(resolved.manifest, {
|
|
companyName: company.name,
|
|
companyDescription: company.description ?? null,
|
|
});
|
|
|
|
return {
|
|
rootPath,
|
|
manifest: resolved.manifest,
|
|
files,
|
|
warnings: resolved.warnings,
|
|
paperclipExtensionPath,
|
|
};
|
|
}
|
|
|
|
async function buildPreview(input: CompanyPortabilityPreview): Promise<ImportPlanInternal> {
|
|
const requestedInclude = normalizeInclude(input.include);
|
|
const source = applySelectedFilesToSource(await resolveSource(input.source), input.selectedFiles);
|
|
const manifest = source.manifest;
|
|
const include: CompanyPortabilityInclude = {
|
|
company: requestedInclude.company && manifest.company !== null,
|
|
agents: requestedInclude.agents && manifest.agents.length > 0,
|
|
projects: requestedInclude.projects && manifest.projects.length > 0,
|
|
issues: requestedInclude.issues && manifest.issues.length > 0,
|
|
};
|
|
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
|
|
const warnings = [...source.warnings];
|
|
const errors: string[] = [];
|
|
|
|
if (include.company && !manifest.company) {
|
|
errors.push("Manifest does not include company metadata.");
|
|
}
|
|
|
|
const selectedSlugs = include.agents
|
|
? (
|
|
input.agents && input.agents !== "all"
|
|
? Array.from(new Set(input.agents))
|
|
: manifest.agents.map((agent) => agent.slug)
|
|
)
|
|
: [];
|
|
|
|
const selectedAgents = include.agents
|
|
? manifest.agents.filter((agent) => selectedSlugs.includes(agent.slug))
|
|
: [];
|
|
const selectedMissing = selectedSlugs.filter((slug) => !manifest.agents.some((agent) => agent.slug === slug));
|
|
for (const missing of selectedMissing) {
|
|
errors.push(`Selected agent slug not found in manifest: ${missing}`);
|
|
}
|
|
|
|
if (include.agents && selectedAgents.length === 0) {
|
|
warnings.push("No agents selected for import.");
|
|
}
|
|
|
|
const availableSkillKeys = new Set(source.manifest.skills.map((skill) => skill.key));
|
|
const availableSkillSlugs = new Map<string, CompanyPortabilitySkillManifestEntry[]>();
|
|
for (const skill of source.manifest.skills) {
|
|
const existing = availableSkillSlugs.get(skill.slug) ?? [];
|
|
existing.push(skill);
|
|
availableSkillSlugs.set(skill.slug, existing);
|
|
}
|
|
|
|
for (const agent of selectedAgents) {
|
|
const filePath = ensureMarkdownPath(agent.path);
|
|
const markdown = source.files[filePath];
|
|
if (typeof markdown !== "string") {
|
|
errors.push(`Missing markdown file for agent ${agent.slug}: ${filePath}`);
|
|
continue;
|
|
}
|
|
const parsed = parseFrontmatterMarkdown(markdown);
|
|
if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "agent") {
|
|
warnings.push(`Agent markdown ${filePath} does not declare kind: agent in frontmatter.`);
|
|
}
|
|
for (const skillRef of agent.skills) {
|
|
const slugMatches = availableSkillSlugs.get(skillRef) ?? [];
|
|
if (!availableSkillKeys.has(skillRef) && slugMatches.length !== 1) {
|
|
warnings.push(`Agent ${agent.slug} references skill ${skillRef}, but that skill is not present in the package.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (include.projects) {
|
|
for (const project of manifest.projects) {
|
|
const markdown = source.files[ensureMarkdownPath(project.path)];
|
|
if (typeof markdown !== "string") {
|
|
errors.push(`Missing markdown file for project ${project.slug}: ${project.path}`);
|
|
continue;
|
|
}
|
|
const parsed = parseFrontmatterMarkdown(markdown);
|
|
if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "project") {
|
|
warnings.push(`Project markdown ${project.path} does not declare kind: project in frontmatter.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (include.issues) {
|
|
for (const issue of manifest.issues) {
|
|
const markdown = source.files[ensureMarkdownPath(issue.path)];
|
|
if (typeof markdown !== "string") {
|
|
errors.push(`Missing markdown file for task ${issue.slug}: ${issue.path}`);
|
|
continue;
|
|
}
|
|
const parsed = parseFrontmatterMarkdown(markdown);
|
|
if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "task") {
|
|
warnings.push(`Task markdown ${issue.path} does not declare kind: task in frontmatter.`);
|
|
}
|
|
if (issue.recurrence) {
|
|
warnings.push(`Task ${issue.slug} has recurrence metadata; Paperclip will import it as a one-time issue for now.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const envInput of manifest.envInputs) {
|
|
if (envInput.portability === "system_dependent") {
|
|
warnings.push(`Environment input ${envInput.key}${envInput.agentSlug ? ` for ${envInput.agentSlug}` : ""} is system-dependent and may need manual adjustment after import.`);
|
|
}
|
|
}
|
|
|
|
let targetCompanyId: string | null = null;
|
|
let targetCompanyName: string | null = null;
|
|
|
|
if (input.target.mode === "existing_company") {
|
|
const targetCompany = await companies.getById(input.target.companyId);
|
|
if (!targetCompany) throw notFound("Target company not found");
|
|
targetCompanyId = targetCompany.id;
|
|
targetCompanyName = targetCompany.name;
|
|
}
|
|
|
|
const agentPlans: CompanyPortabilityPreviewAgentPlan[] = [];
|
|
const existingSlugToAgent = new Map<string, { id: string; name: string }>();
|
|
const existingSlugs = new Set<string>();
|
|
const projectPlans: CompanyPortabilityPreviewResult["plan"]["projectPlans"] = [];
|
|
const issuePlans: CompanyPortabilityPreviewResult["plan"]["issuePlans"] = [];
|
|
const existingProjectSlugToProject = new Map<string, { id: string; name: string }>();
|
|
const existingProjectSlugs = new Set<string>();
|
|
|
|
if (input.target.mode === "existing_company") {
|
|
const existingAgents = await agents.list(input.target.companyId);
|
|
for (const existing of existingAgents) {
|
|
const slug = normalizeAgentUrlKey(existing.name) ?? existing.id;
|
|
if (!existingSlugToAgent.has(slug)) existingSlugToAgent.set(slug, existing);
|
|
existingSlugs.add(slug);
|
|
}
|
|
const existingProjects = await projects.list(input.target.companyId);
|
|
for (const existing of existingProjects) {
|
|
if (!existingProjectSlugToProject.has(existing.urlKey)) {
|
|
existingProjectSlugToProject.set(existing.urlKey, { id: existing.id, name: existing.name });
|
|
}
|
|
existingProjectSlugs.add(existing.urlKey);
|
|
}
|
|
}
|
|
|
|
for (const manifestAgent of selectedAgents) {
|
|
const existing = existingSlugToAgent.get(manifestAgent.slug) ?? null;
|
|
if (!existing) {
|
|
agentPlans.push({
|
|
slug: manifestAgent.slug,
|
|
action: "create",
|
|
plannedName: manifestAgent.name,
|
|
existingAgentId: null,
|
|
reason: null,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (collisionStrategy === "replace") {
|
|
agentPlans.push({
|
|
slug: manifestAgent.slug,
|
|
action: "update",
|
|
plannedName: existing.name,
|
|
existingAgentId: existing.id,
|
|
reason: "Existing slug matched; replace strategy.",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (collisionStrategy === "skip") {
|
|
agentPlans.push({
|
|
slug: manifestAgent.slug,
|
|
action: "skip",
|
|
plannedName: existing.name,
|
|
existingAgentId: existing.id,
|
|
reason: "Existing slug matched; skip strategy.",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const renamed = uniqueNameBySlug(manifestAgent.name, existingSlugs);
|
|
existingSlugs.add(normalizeAgentUrlKey(renamed) ?? manifestAgent.slug);
|
|
agentPlans.push({
|
|
slug: manifestAgent.slug,
|
|
action: "create",
|
|
plannedName: renamed,
|
|
existingAgentId: existing.id,
|
|
reason: "Existing slug matched; rename strategy.",
|
|
});
|
|
}
|
|
|
|
if (include.projects) {
|
|
for (const manifestProject of manifest.projects) {
|
|
const existing = existingProjectSlugToProject.get(manifestProject.slug) ?? null;
|
|
if (!existing) {
|
|
projectPlans.push({
|
|
slug: manifestProject.slug,
|
|
action: "create",
|
|
plannedName: manifestProject.name,
|
|
existingProjectId: null,
|
|
reason: null,
|
|
});
|
|
continue;
|
|
}
|
|
if (collisionStrategy === "replace") {
|
|
projectPlans.push({
|
|
slug: manifestProject.slug,
|
|
action: "update",
|
|
plannedName: existing.name,
|
|
existingProjectId: existing.id,
|
|
reason: "Existing slug matched; replace strategy.",
|
|
});
|
|
continue;
|
|
}
|
|
if (collisionStrategy === "skip") {
|
|
projectPlans.push({
|
|
slug: manifestProject.slug,
|
|
action: "skip",
|
|
plannedName: existing.name,
|
|
existingProjectId: existing.id,
|
|
reason: "Existing slug matched; skip strategy.",
|
|
});
|
|
continue;
|
|
}
|
|
const renamed = uniqueProjectName(manifestProject.name, existingProjectSlugs);
|
|
existingProjectSlugs.add(deriveProjectUrlKey(renamed, renamed));
|
|
projectPlans.push({
|
|
slug: manifestProject.slug,
|
|
action: "create",
|
|
plannedName: renamed,
|
|
existingProjectId: existing.id,
|
|
reason: "Existing slug matched; rename strategy.",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Apply user-specified name overrides (keyed by slug)
|
|
if (input.nameOverrides) {
|
|
for (const ap of agentPlans) {
|
|
const override = input.nameOverrides[ap.slug];
|
|
if (override) {
|
|
ap.plannedName = override;
|
|
}
|
|
}
|
|
for (const pp of projectPlans) {
|
|
const override = input.nameOverrides[pp.slug];
|
|
if (override) {
|
|
pp.plannedName = override;
|
|
}
|
|
}
|
|
for (const ip of issuePlans) {
|
|
const override = input.nameOverrides[ip.slug];
|
|
if (override) {
|
|
ip.plannedTitle = override;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Warn about agents that will be overwritten/updated
|
|
for (const ap of agentPlans) {
|
|
if (ap.action === "update") {
|
|
warnings.push(`Existing agent "${ap.plannedName}" (${ap.slug}) will be overwritten by import.`);
|
|
}
|
|
}
|
|
|
|
// Warn about projects that will be overwritten/updated
|
|
for (const pp of projectPlans) {
|
|
if (pp.action === "update") {
|
|
warnings.push(`Existing project "${pp.plannedName}" (${pp.slug}) will be overwritten by import.`);
|
|
}
|
|
}
|
|
|
|
if (include.issues) {
|
|
for (const manifestIssue of manifest.issues) {
|
|
issuePlans.push({
|
|
slug: manifestIssue.slug,
|
|
action: "create",
|
|
plannedTitle: manifestIssue.title,
|
|
reason: manifestIssue.recurrence ? "Recurrence will not be activated on import." : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
const preview: CompanyPortabilityPreviewResult = {
|
|
include,
|
|
targetCompanyId,
|
|
targetCompanyName,
|
|
collisionStrategy,
|
|
selectedAgentSlugs: selectedAgents.map((agent) => agent.slug),
|
|
plan: {
|
|
companyAction: input.target.mode === "new_company"
|
|
? "create"
|
|
: include.company
|
|
? "update"
|
|
: "none",
|
|
agentPlans,
|
|
projectPlans,
|
|
issuePlans,
|
|
},
|
|
manifest,
|
|
files: source.files,
|
|
envInputs: manifest.envInputs ?? [],
|
|
warnings,
|
|
errors,
|
|
};
|
|
|
|
return {
|
|
preview,
|
|
source,
|
|
include,
|
|
collisionStrategy,
|
|
selectedAgents,
|
|
};
|
|
}
|
|
|
|
async function previewImport(input: CompanyPortabilityPreview): Promise<CompanyPortabilityPreviewResult> {
|
|
const plan = await buildPreview(input);
|
|
return plan.preview;
|
|
}
|
|
|
|
async function importBundle(
|
|
input: CompanyPortabilityImport,
|
|
actorUserId: string | null | undefined,
|
|
): Promise<CompanyPortabilityImportResult> {
|
|
const plan = await buildPreview(input);
|
|
if (plan.preview.errors.length > 0) {
|
|
throw unprocessable(`Import preview has errors: ${plan.preview.errors.join("; ")}`);
|
|
}
|
|
|
|
const sourceManifest = plan.source.manifest;
|
|
const warnings = [...plan.preview.warnings];
|
|
const include = plan.include;
|
|
|
|
let targetCompany: { id: string; name: string } | null = null;
|
|
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
|
|
|
|
if (input.target.mode === "new_company") {
|
|
const companyName =
|
|
asString(input.target.newCompanyName) ??
|
|
sourceManifest.company?.name ??
|
|
sourceManifest.source?.companyName ??
|
|
"Imported Company";
|
|
const created = await companies.create({
|
|
name: companyName,
|
|
description: include.company ? (sourceManifest.company?.description ?? null) : null,
|
|
brandColor: include.company ? (sourceManifest.company?.brandColor ?? null) : null,
|
|
requireBoardApprovalForNewAgents: include.company
|
|
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
|
|
: true,
|
|
});
|
|
await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active");
|
|
targetCompany = created;
|
|
companyAction = "created";
|
|
} else {
|
|
targetCompany = await companies.getById(input.target.companyId);
|
|
if (!targetCompany) throw notFound("Target company not found");
|
|
if (include.company && sourceManifest.company) {
|
|
const updated = await companies.update(targetCompany.id, {
|
|
name: sourceManifest.company.name,
|
|
description: sourceManifest.company.description,
|
|
brandColor: sourceManifest.company.brandColor,
|
|
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
|
|
});
|
|
targetCompany = updated ?? targetCompany;
|
|
companyAction = "updated";
|
|
}
|
|
}
|
|
|
|
if (!targetCompany) throw notFound("Target company not found");
|
|
|
|
const resultAgents: CompanyPortabilityImportResult["agents"] = [];
|
|
const importedSlugToAgentId = new Map<string, string>();
|
|
const existingSlugToAgentId = new Map<string, string>();
|
|
const existingAgents = await agents.list(targetCompany.id);
|
|
for (const existing of existingAgents) {
|
|
existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id);
|
|
}
|
|
const importedSlugToProjectId = new Map<string, string>();
|
|
const existingProjectSlugToId = new Map<string, string>();
|
|
const existingProjects = await projects.list(targetCompany.id);
|
|
for (const existing of existingProjects) {
|
|
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);
|
|
if (!manifestAgent) continue;
|
|
if (planAgent.action === "skip") {
|
|
resultAgents.push({
|
|
slug: planAgent.slug,
|
|
id: planAgent.existingAgentId,
|
|
action: "skipped",
|
|
name: planAgent.plannedName,
|
|
reason: planAgent.reason,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const bundlePrefix = `agents/${manifestAgent.slug}/`;
|
|
const bundleFiles = Object.fromEntries(
|
|
Object.entries(plan.source.files)
|
|
.filter(([filePath]) => filePath.startsWith(bundlePrefix))
|
|
.map(([filePath, content]) => [normalizePortablePath(filePath.slice(bundlePrefix.length)), content]),
|
|
);
|
|
const markdownRaw = bundleFiles["AGENTS.md"] ?? plan.source.files[manifestAgent.path];
|
|
const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
|
|
if (!markdownRaw && fallbackPromptTemplate) {
|
|
bundleFiles["AGENTS.md"] = fallbackPromptTemplate;
|
|
}
|
|
if (!markdownRaw && !fallbackPromptTemplate) {
|
|
warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported with an empty managed bundle.`);
|
|
}
|
|
|
|
// Apply adapter overrides from request if present
|
|
const adapterOverride = input.adapterOverrides?.[planAgent.slug];
|
|
const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
|
|
const baseAdapterConfig = adapterOverride?.adapterConfig
|
|
? { ...adapterOverride.adapterConfig }
|
|
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
|
|
|
|
const desiredSkills = manifestAgent.skills ?? [];
|
|
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
|
|
baseAdapterConfig,
|
|
desiredSkills,
|
|
);
|
|
delete adapterConfigWithSkills.promptTemplate;
|
|
delete adapterConfigWithSkills.bootstrapPromptTemplate;
|
|
delete adapterConfigWithSkills.instructionsFilePath;
|
|
delete adapterConfigWithSkills.instructionsBundleMode;
|
|
delete adapterConfigWithSkills.instructionsRootPath;
|
|
delete adapterConfigWithSkills.instructionsEntryFile;
|
|
const patch = {
|
|
name: planAgent.plannedName,
|
|
role: manifestAgent.role,
|
|
title: manifestAgent.title,
|
|
icon: manifestAgent.icon,
|
|
capabilities: manifestAgent.capabilities,
|
|
reportsTo: null,
|
|
adapterType: effectiveAdapterType,
|
|
adapterConfig: adapterConfigWithSkills,
|
|
runtimeConfig: manifestAgent.runtimeConfig,
|
|
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
|
|
permissions: manifestAgent.permissions,
|
|
metadata: manifestAgent.metadata,
|
|
};
|
|
|
|
if (planAgent.action === "update" && planAgent.existingAgentId) {
|
|
let updated = await agents.update(planAgent.existingAgentId, patch);
|
|
if (!updated) {
|
|
warnings.push(`Skipped update for missing agent ${planAgent.existingAgentId}.`);
|
|
resultAgents.push({
|
|
slug: planAgent.slug,
|
|
id: null,
|
|
action: "skipped",
|
|
name: planAgent.plannedName,
|
|
reason: "Existing target agent not found.",
|
|
});
|
|
continue;
|
|
}
|
|
try {
|
|
const materialized = await instructions.materializeManagedBundle(updated, bundleFiles, {
|
|
clearLegacyPromptTemplate: true,
|
|
replaceExisting: true,
|
|
});
|
|
updated = await agents.update(updated.id, { adapterConfig: materialized.adapterConfig }) ?? updated;
|
|
} catch (err) {
|
|
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
importedSlugToAgentId.set(planAgent.slug, updated.id);
|
|
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
|
|
resultAgents.push({
|
|
slug: planAgent.slug,
|
|
id: updated.id,
|
|
action: "updated",
|
|
name: updated.name,
|
|
reason: planAgent.reason,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let created = await agents.create(targetCompany.id, patch);
|
|
try {
|
|
const materialized = await instructions.materializeManagedBundle(created, bundleFiles, {
|
|
clearLegacyPromptTemplate: true,
|
|
replaceExisting: true,
|
|
});
|
|
created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
|
|
} catch (err) {
|
|
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
importedSlugToAgentId.set(planAgent.slug, created.id);
|
|
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
|
|
resultAgents.push({
|
|
slug: planAgent.slug,
|
|
id: created.id,
|
|
action: "created",
|
|
name: created.name,
|
|
reason: planAgent.reason,
|
|
});
|
|
}
|
|
|
|
// Apply reporting links once all imported agent ids are available.
|
|
for (const manifestAgent of plan.selectedAgents) {
|
|
const agentId = importedSlugToAgentId.get(manifestAgent.slug);
|
|
if (!agentId) continue;
|
|
const managerSlug = manifestAgent.reportsToSlug;
|
|
if (!managerSlug) continue;
|
|
const managerId = importedSlugToAgentId.get(managerSlug) ?? existingSlugToAgentId.get(managerSlug) ?? null;
|
|
if (!managerId || managerId === agentId) continue;
|
|
try {
|
|
await agents.update(agentId, { reportsTo: managerId });
|
|
} catch {
|
|
warnings.push(`Could not assign manager ${managerSlug} for imported agent ${manifestAgent.slug}.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (include.projects) {
|
|
for (const planProject of plan.preview.plan.projectPlans) {
|
|
const manifestProject = sourceManifest.projects.find((project) => project.slug === planProject.slug);
|
|
if (!manifestProject) continue;
|
|
if (planProject.action === "skip") continue;
|
|
|
|
const projectLeadAgentId = manifestProject.leadAgentSlug
|
|
? importedSlugToAgentId.get(manifestProject.leadAgentSlug)
|
|
?? existingSlugToAgentId.get(manifestProject.leadAgentSlug)
|
|
?? null
|
|
: null;
|
|
const projectPatch = {
|
|
name: planProject.plannedName,
|
|
description: manifestProject.description,
|
|
leadAgentId: projectLeadAgentId,
|
|
targetDate: manifestProject.targetDate,
|
|
color: manifestProject.color,
|
|
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
|
|
? manifestProject.status as typeof PROJECT_STATUSES[number]
|
|
: "backlog",
|
|
executionWorkspacePolicy: manifestProject.executionWorkspacePolicy,
|
|
};
|
|
|
|
if (planProject.action === "update" && planProject.existingProjectId) {
|
|
const updated = await projects.update(planProject.existingProjectId, projectPatch);
|
|
if (!updated) {
|
|
warnings.push(`Skipped update for missing project ${planProject.existingProjectId}.`);
|
|
continue;
|
|
}
|
|
importedSlugToProjectId.set(planProject.slug, updated.id);
|
|
existingProjectSlugToId.set(updated.urlKey, updated.id);
|
|
continue;
|
|
}
|
|
|
|
const created = await projects.create(targetCompany.id, projectPatch);
|
|
importedSlugToProjectId.set(planProject.slug, created.id);
|
|
existingProjectSlugToId.set(created.urlKey, created.id);
|
|
}
|
|
}
|
|
|
|
if (include.issues) {
|
|
for (const manifestIssue of sourceManifest.issues) {
|
|
const markdownRaw = plan.source.files[manifestIssue.path];
|
|
const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null;
|
|
const description = parsed?.body || manifestIssue.description || null;
|
|
const assigneeAgentId = manifestIssue.assigneeAgentSlug
|
|
? importedSlugToAgentId.get(manifestIssue.assigneeAgentSlug)
|
|
?? existingSlugToAgentId.get(manifestIssue.assigneeAgentSlug)
|
|
?? null
|
|
: null;
|
|
const projectId = manifestIssue.projectSlug
|
|
? importedSlugToProjectId.get(manifestIssue.projectSlug)
|
|
?? existingProjectSlugToId.get(manifestIssue.projectSlug)
|
|
?? null
|
|
: null;
|
|
await issues.create(targetCompany.id, {
|
|
projectId,
|
|
title: manifestIssue.title,
|
|
description,
|
|
assigneeAgentId,
|
|
status: manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any)
|
|
? manifestIssue.status as typeof ISSUE_STATUSES[number]
|
|
: "backlog",
|
|
priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any)
|
|
? manifestIssue.priority as typeof ISSUE_PRIORITIES[number]
|
|
: "medium",
|
|
billingCode: manifestIssue.billingCode,
|
|
assigneeAdapterOverrides: manifestIssue.assigneeAdapterOverrides,
|
|
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings,
|
|
labelIds: [],
|
|
});
|
|
if (manifestIssue.recurrence) {
|
|
warnings.push(`Imported task ${manifestIssue.slug} as a one-time issue; recurrence metadata was not activated.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
company: {
|
|
id: targetCompany.id,
|
|
name: targetCompany.name,
|
|
action: companyAction,
|
|
},
|
|
agents: resultAgents,
|
|
envInputs: sourceManifest.envInputs ?? [],
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
return {
|
|
exportBundle,
|
|
previewImport,
|
|
importBundle,
|
|
};
|
|
}
|