Merge pull request #1655 from paperclipai/pr/pap-795-company-portability

feat(portability): improve company import and export flow
This commit is contained in:
Dotta
2026-03-23 19:45:05 -05:00
committed by GitHub
36 changed files with 5238 additions and 271 deletions

View File

@@ -1,11 +1,12 @@
import { execFile, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createStoredZipArchive } from "./helpers/zip.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
@@ -182,6 +183,19 @@ function createCliEnv() {
return env;
}
function collectTextFiles(root: string, current: string, files: Record<string, string>) {
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
collectTextFiles(root, absolutePath, files);
continue;
}
if (!entry.isFile()) continue;
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
files[relativePath] = readFileSync(absolutePath, "utf8");
}
}
async function stopServerProcess(child: ServerProcess | null) {
if (!child || child.exitCode !== null) return;
child.kill("SIGTERM");
@@ -345,6 +359,8 @@ describe("paperclipai company import/export e2e", () => {
},
);
const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`;
const sourceIssue = await api<{ id: string; title: string; identifier: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/issues`,
@@ -353,7 +369,7 @@ describe("paperclipai company import/export e2e", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "Validate company import/export",
description: "Round-trip the company package through the CLI.",
description: largeIssueDescription,
status: "todo",
projectId: sourceProject.id,
assigneeAgentId: sourceAgent.id,
@@ -397,6 +413,7 @@ describe("paperclipai company import/export e2e", () => {
`Imported ${sourceCompany.name}`,
"--include",
"company,agents,projects,issues",
"--yes",
],
{ apiBase, configPath },
);
@@ -470,6 +487,7 @@ describe("paperclipai company import/export e2e", () => {
"company,agents,projects,issues",
"--collision",
"rename",
"--yes",
],
{ apiBase, configPath },
);
@@ -494,5 +512,32 @@ describe("paperclipai company import/export e2e", () => {
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
expect(twiceImportedProjects).toHaveLength(2);
expect(twiceImportedIssues).toHaveLength(2);
const zipPath = path.join(tempRoot, "exported-company.zip");
const portableFiles: Record<string, string> = {};
collectTextFiles(exportDir, exportDir, portableFiles);
writeFileSync(zipPath, createStoredZipArchive(portableFiles, "paperclip-demo"));
const importedFromZip = await runCliJson<{
company: { id: string; name: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
zipPath,
"--target",
"new",
"--new-company-name",
`Zip Imported ${sourceCompany.name}`,
"--include",
"company,agents,projects,issues",
"--yes",
],
{ apiBase, configPath },
);
expect(importedFromZip.company.action).toBe("created");
expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
}, 60_000);
});

View File

@@ -0,0 +1,44 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveInlineSourceFromPath } from "../commands/client/company.js";
import { createStoredZipArchive } from "./helpers/zip.js";
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
describe("resolveInlineSourceFromPath", () => {
it("imports portable files from a zip archive instead of scanning the parent directory", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-zip-"));
tempDirs.push(tempDir);
const archivePath = path.join(tempDir, "paperclip-demo.zip");
const archive = createStoredZipArchive(
{
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
"notes/todo.txt": "ignore me\n",
},
"paperclip-demo",
);
await writeFile(archivePath, archive);
const resolved = await resolveInlineSourceFromPath(archivePath);
expect(resolved).toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
},
});
});
});

View File

@@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
import { resolveCompanyImportApiPath } from "../commands/client/company.js";
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
import {
buildCompanyDashboardUrl,
buildDefaultImportAdapterOverrides,
buildDefaultImportSelectionState,
buildImportSelectionCatalog,
buildSelectedFilesFromImportSelection,
renderCompanyImportPreview,
renderCompanyImportResult,
resolveCompanyImportApplyConfirmationMode,
resolveCompanyImportApiPath,
} from "../commands/client/company.js";
describe("resolveCompanyImportApiPath", () => {
it("uses company-scoped preview route for existing-company dry runs", () => {
@@ -48,3 +59,529 @@ describe("resolveCompanyImportApiPath", () => {
).toThrow(/require a companyId/i);
});
});
describe("resolveCompanyImportApplyConfirmationMode", () => {
it("skips confirmation when --yes is set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: true,
interactive: false,
json: false,
}),
).toBe("skip");
});
it("prompts in interactive text mode when --yes is not set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: true,
json: false,
}),
).toBe("prompt");
});
it("requires --yes for non-interactive apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: false,
})
).toThrow(/non-interactive terminal requires --yes/i);
});
it("requires --yes for json apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: true,
})
).toThrow(/with --json requires --yes/i);
});
});
describe("buildCompanyDashboardUrl", () => {
it("preserves the configured base path when building a dashboard URL", () => {
expect(buildCompanyDashboardUrl("https://paperclip.example/app/", "PAP")).toBe(
"https://paperclip.example/app/PAP/dashboard",
);
});
});
describe("renderCompanyImportPreview", () => {
it("summarizes the preview with counts, selection info, and truncated examples", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo", "cto", "eng-1", "eng-2", "eng-3", "eng-4", "eng-5"],
plan: {
companyAction: "update",
agentPlans: [
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
{ slug: "cto", action: "update", plannedName: "CTO", existingAgentId: "agent-2", reason: "replace strategy" },
{ slug: "eng-1", action: "skip", plannedName: "Engineer 1", existingAgentId: "agent-3", reason: "skip strategy" },
{ slug: "eng-2", action: "create", plannedName: "Engineer 2", existingAgentId: null, reason: null },
{ slug: "eng-3", action: "create", plannedName: "Engineer 3", existingAgentId: null, reason: null },
{ slug: "eng-4", action: "create", plannedName: "Engineer 4", existingAgentId: null, reason: null },
{ slug: "eng-5", action: "create", plannedName: "Engineer 5", existingAgentId: null, reason: null },
],
projectPlans: [
{ slug: "alpha", action: "create", plannedName: "Alpha", existingProjectId: null, reason: null },
],
issuePlans: [
{ slug: "kickoff", action: "create", plannedTitle: "Kickoff", reason: null },
],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T17:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
brandColor: null,
logoPath: null,
requireBoardApprovalForNewAgents: false,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
metadata: null,
},
],
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
},
files: {
"COMPANY.md": "# Source Co",
},
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
warnings: ["One warning"],
errors: ["One error"],
};
const rendered = renderCompanyImportPreview(preview, {
sourceLabel: "GitHub: https://github.com/paperclipai/companies/demo",
targetLabel: "Imported Co (company-123)",
infoMessages: ["Using claude-local adapter"],
});
expect(rendered).toContain("Include");
expect(rendered).toContain("company, projects, tasks, agents, skills");
expect(rendered).toContain("7 agents total");
expect(rendered).toContain("1 project total");
expect(rendered).toContain("1 task total");
expect(rendered).toContain("skills: 1 skill packaged");
expect(rendered).toContain("+1 more");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Warnings");
expect(rendered).toContain("Errors");
});
});
describe("renderCompanyImportResult", () => {
it("summarizes import results with created, updated, and skipped counts", () => {
const rendered = renderCompanyImportResult(
{
company: {
id: "company-123",
name: "Imported Co",
action: "updated",
},
agents: [
{ slug: "ceo", id: "agent-1", action: "created", name: "CEO", reason: null },
{ slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" },
{ slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" },
],
projects: [
{ slug: "app", id: "project-1", action: "created", name: "App", reason: null },
{ slug: "ops", id: "project-2", action: "updated", name: "Operations", reason: "replace strategy" },
{ slug: "archive", id: null, action: "skipped", name: "Archive", reason: "skip strategy" },
],
envInputs: [],
warnings: ["Review API keys"],
},
{
targetLabel: "Imported Co (company-123)",
companyUrl: "https://paperclip.example/PAP/dashboard",
infoMessages: ["Using claude-local adapter"],
},
);
expect(rendered).toContain("Company");
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("Agent results");
expect(rendered).toContain("Project results");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Review API keys");
});
});
describe("import selection catalog", () => {
it("defaults to everything and keeps project selection separate from task selection", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo"],
plan: {
companyAction: "create",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
brandColor: null,
logoPath: "images/company-logo.png",
requireBoardApprovalForNewAgents: false,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [{ path: "skills/skill-a/helper.md", kind: "doc" }],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
metadata: null,
},
],
envInputs: [],
},
files: {
"COMPANY.md": "# Source Co",
"README.md": "# Readme",
".paperclip.yaml": "schema: paperclip/v1\n",
"images/company-logo.png": {
encoding: "base64",
data: "",
contentType: "image/png",
},
"projects/alpha/PROJECT.md": "# Alpha",
"projects/alpha/notes.md": "project notes",
"projects/alpha/issues/kickoff/TASK.md": "# Kickoff",
"projects/alpha/issues/kickoff/details.md": "task details",
"agents/ceo/AGENT.md": "# CEO",
"agents/ceo/prompt.md": "prompt",
"skills/skill-a/SKILL.md": "# Skill A",
"skills/skill-a/helper.md": "helper",
},
envInputs: [],
warnings: [],
errors: [],
};
const catalog = buildImportSelectionCatalog(preview);
const state = buildDefaultImportSelectionState(catalog);
expect(state.company).toBe(true);
expect(state.projects.has("alpha")).toBe(true);
expect(state.issues.has("kickoff")).toBe(true);
expect(state.agents.has("ceo")).toBe(true);
expect(state.skills.has("skill-a")).toBe(true);
state.company = false;
state.issues.clear();
state.agents.clear();
state.skills.clear();
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
expect(selectedFiles).toContain(".paperclip.yaml");
expect(selectedFiles).toContain("projects/alpha/PROJECT.md");
expect(selectedFiles).toContain("projects/alpha/notes.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
});
});
describe("default adapter overrides", () => {
it("maps process-only imported agents to claude_local", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
targetCompanyId: null,
targetCompanyName: null,
collisionStrategy: "rename",
selectedAgentSlugs: ["legacy-agent", "explicit-agent"],
plan: {
companyAction: "none",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:20:00.000Z",
source: null,
includes: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
company: null,
sidebar: null,
agents: [
{
slug: "legacy-agent",
name: "Legacy Agent",
path: "agents/legacy-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
{
slug: "explicit-agent",
name: "Explicit Agent",
path: "agents/explicit-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {},
envInputs: [],
warnings: [],
errors: [],
};
expect(buildDefaultImportAdapterOverrides(preview)).toEqual({
"legacy-agent": {
adapterType: "claude_local",
},
});
});
});

View File

@@ -0,0 +1,87 @@
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
export function createStoredZipArchive(files: Record<string, string>, rootPath: string) {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = encoder.encode(content);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}

View File

@@ -169,7 +169,7 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T>;
}
function openUrl(url: string): boolean {
export function openUrl(url: string): boolean {
const platform = process.platform;
try {
if (platform === "darwin") {

View File

@@ -2,6 +2,7 @@ import { Command } from "commander";
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import * as p from "@clack/prompts";
import pc from "picocolors";
import type {
Company,
CompanyPortabilityFileEntry,
@@ -11,6 +12,8 @@ import type {
CompanyPortabilityImportResult,
} from "@paperclipai/shared";
import { ApiRequestError } from "../../client/http.js";
import { openUrl } from "../../client/board-auth.js";
import { binaryContentTypeByExtension, readZipArchive } from "./zip.js";
import {
addCommonClientOptions,
formatInlineRecord,
@@ -49,16 +52,61 @@ interface CompanyImportOptions extends BaseClientOptions {
agents?: string;
collision?: CompanyCollisionMode;
ref?: string;
paperclipUrl?: string;
yes?: boolean;
dryRun?: boolean;
}
const binaryContentTypeByExtension: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
const DEFAULT_EXPORT_INCLUDE: CompanyPortabilityInclude = {
company: true,
agents: true,
projects: false,
issues: false,
skills: false,
};
const DEFAULT_IMPORT_INCLUDE: CompanyPortabilityInclude = {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
};
const IMPORT_INCLUDE_OPTIONS: Array<{
value: keyof CompanyPortabilityInclude;
label: string;
hint: string;
}> = [
{ value: "company", label: "Company", hint: "name, branding, and company settings" },
{ value: "projects", label: "Projects", hint: "projects and workspace metadata" },
{ value: "issues", label: "Tasks", hint: "tasks and recurring routines" },
{ value: "agents", label: "Agents", hint: "agent records and org structure" },
{ value: "skills", label: "Skills", hint: "company skill packages and references" },
];
const IMPORT_PREVIEW_SAMPLE_LIMIT = 6;
type ImportSelectableGroup = "projects" | "issues" | "agents" | "skills";
type ImportSelectionCatalog = {
company: {
includedByDefault: boolean;
files: string[];
};
projects: Array<{ key: string; label: string; hint?: string; files: string[] }>;
issues: Array<{ key: string; label: string; hint?: string; files: string[] }>;
agents: Array<{ key: string; label: string; hint?: string; files: string[] }>;
skills: Array<{ key: string; label: string; hint?: string; files: string[] }>;
extensionPath: string | null;
};
type ImportSelectionState = {
company: boolean;
projects: Set<string>;
issues: Set<string>;
agents: Set<string>;
skills: Set<string>;
};
function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry {
@@ -84,8 +132,11 @@ function normalizeSelector(input: string): string {
return input.trim();
}
function parseInclude(input: string | undefined): CompanyPortabilityInclude {
if (!input || !input.trim()) return { company: true, agents: true, projects: false, issues: false, skills: false };
function parseInclude(
input: string | undefined,
fallback: CompanyPortabilityInclude = DEFAULT_EXPORT_INCLUDE,
): CompanyPortabilityInclude {
if (!input || !input.trim()) return { ...fallback };
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
const include = {
company: values.includes("company"),
@@ -114,6 +165,554 @@ function parseCsvValues(input: string | undefined): string[] {
return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean)));
}
function isInteractiveTerminal(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
function resolveImportInclude(input: string | undefined): CompanyPortabilityInclude {
return parseInclude(input, DEFAULT_IMPORT_INCLUDE);
}
function normalizePortablePath(filePath: string): string {
return filePath.replace(/\\/g, "/");
}
function shouldIncludePortableFile(filePath: string): boolean {
const baseName = path.basename(filePath);
const isMarkdown = baseName.endsWith(".md");
const isPaperclipYaml = baseName === ".paperclip.yaml" || baseName === ".paperclip.yml";
const contentType = binaryContentTypeByExtension[path.extname(baseName).toLowerCase()];
return isMarkdown || isPaperclipYaml || Boolean(contentType);
}
function findPortableExtensionPath(files: Record<string, CompanyPortabilityFileEntry>): string | null {
if (files[".paperclip.yaml"] !== undefined) return ".paperclip.yaml";
if (files[".paperclip.yml"] !== undefined) return ".paperclip.yml";
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
}
function collectFilesUnderDirectory(
files: Record<string, CompanyPortabilityFileEntry>,
directory: string,
opts?: { excludePrefixes?: string[] },
): string[] {
const normalizedDirectory = normalizePortablePath(directory).replace(/\/+$/, "");
if (!normalizedDirectory) return [];
const prefix = `${normalizedDirectory}/`;
const excluded = (opts?.excludePrefixes ?? []).map((entry) => normalizePortablePath(entry).replace(/\/+$/, "")).filter(Boolean);
return Object.keys(files)
.map(normalizePortablePath)
.filter((filePath) => filePath.startsWith(prefix))
.filter((filePath) => !excluded.some((excludePrefix) => filePath.startsWith(`${excludePrefix}/`)))
.sort((left, right) => left.localeCompare(right));
}
function collectEntityFiles(
files: Record<string, CompanyPortabilityFileEntry>,
entryPath: string,
opts?: { excludePrefixes?: string[] },
): string[] {
const normalizedPath = normalizePortablePath(entryPath);
const directory = normalizedPath.includes("/") ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) : "";
const selected = new Set<string>([normalizedPath]);
if (directory) {
for (const filePath of collectFilesUnderDirectory(files, directory, opts)) {
selected.add(filePath);
}
}
return Array.from(selected).sort((left, right) => left.localeCompare(right));
}
export function buildImportSelectionCatalog(preview: CompanyPortabilityPreviewResult): ImportSelectionCatalog {
const selectedAgentSlugs = new Set(preview.selectedAgentSlugs);
const companyFiles = new Set<string>();
const companyPath = preview.manifest.company?.path ? normalizePortablePath(preview.manifest.company.path) : null;
if (companyPath) {
companyFiles.add(companyPath);
}
const readmePath = Object.keys(preview.files).find((entry) => normalizePortablePath(entry) === "README.md");
if (readmePath) {
companyFiles.add(normalizePortablePath(readmePath));
}
const logoPath = preview.manifest.company?.logoPath ? normalizePortablePath(preview.manifest.company.logoPath) : null;
if (logoPath && preview.files[logoPath] !== undefined) {
companyFiles.add(logoPath);
}
return {
company: {
includedByDefault: preview.include.company && preview.manifest.company !== null,
files: Array.from(companyFiles).sort((left, right) => left.localeCompare(right)),
},
projects: preview.manifest.projects.map((project) => {
const projectPath = normalizePortablePath(project.path);
const projectDir = projectPath.includes("/") ? projectPath.slice(0, projectPath.lastIndexOf("/")) : "";
return {
key: project.slug,
label: project.name,
hint: project.slug,
files: collectEntityFiles(preview.files, projectPath, {
excludePrefixes: projectDir ? [`${projectDir}/issues`] : [],
}),
};
}),
issues: preview.manifest.issues.map((issue) => ({
key: issue.slug,
label: issue.title,
hint: issue.identifier ?? issue.slug,
files: collectEntityFiles(preview.files, normalizePortablePath(issue.path)),
})),
agents: preview.manifest.agents
.filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug))
.map((agent) => ({
key: agent.slug,
label: agent.name,
hint: agent.slug,
files: collectEntityFiles(preview.files, normalizePortablePath(agent.path)),
})),
skills: preview.manifest.skills.map((skill) => ({
key: skill.slug,
label: skill.name,
hint: skill.slug,
files: collectEntityFiles(preview.files, normalizePortablePath(skill.path)),
})),
extensionPath: findPortableExtensionPath(preview.files),
};
}
function toKeySet(items: Array<{ key: string }>): Set<string> {
return new Set(items.map((item) => item.key));
}
export function buildDefaultImportSelectionState(catalog: ImportSelectionCatalog): ImportSelectionState {
return {
company: catalog.company.includedByDefault,
projects: toKeySet(catalog.projects),
issues: toKeySet(catalog.issues),
agents: toKeySet(catalog.agents),
skills: toKeySet(catalog.skills),
};
}
function countSelected(state: ImportSelectionState, group: ImportSelectableGroup): number {
return state[group].size;
}
function countTotal(catalog: ImportSelectionCatalog, group: ImportSelectableGroup): number {
return catalog[group].length;
}
function summarizeGroupSelection(catalog: ImportSelectionCatalog, state: ImportSelectionState, group: ImportSelectableGroup): string {
return `${countSelected(state, group)}/${countTotal(catalog, group)} selected`;
}
function getGroupLabel(group: ImportSelectableGroup): string {
switch (group) {
case "projects":
return "Projects";
case "issues":
return "Tasks";
case "agents":
return "Agents";
case "skills":
return "Skills";
}
}
export function buildSelectedFilesFromImportSelection(
catalog: ImportSelectionCatalog,
state: ImportSelectionState,
): string[] {
const selected = new Set<string>();
if (state.company) {
for (const filePath of catalog.company.files) {
selected.add(normalizePortablePath(filePath));
}
}
for (const group of ["projects", "issues", "agents", "skills"] as const) {
const selectedKeys = state[group];
for (const item of catalog[group]) {
if (!selectedKeys.has(item.key)) continue;
for (const filePath of item.files) {
selected.add(normalizePortablePath(filePath));
}
}
}
if (selected.size > 0 && catalog.extensionPath) {
selected.add(normalizePortablePath(catalog.extensionPath));
}
return Array.from(selected).sort((left, right) => left.localeCompare(right));
}
export function buildDefaultImportAdapterOverrides(
preview: Pick<CompanyPortabilityPreviewResult, "manifest" | "selectedAgentSlugs">,
): Record<string, { adapterType: string }> | undefined {
const selectedAgentSlugs = new Set(preview.selectedAgentSlugs);
const overrides = Object.fromEntries(
preview.manifest.agents
.filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug))
.filter((agent) => agent.adapterType === "process")
.map((agent) => [
agent.slug,
{
// TODO: replace this temporary claude_local fallback with adapter selection in the import TUI.
adapterType: "claude_local",
},
]),
);
return Object.keys(overrides).length > 0 ? overrides : undefined;
}
function buildDefaultImportAdapterMessages(
overrides: Record<string, { adapterType: string }> | undefined,
): string[] {
if (!overrides) return [];
const adapterTypes = Array.from(new Set(Object.values(overrides).map((override) => override.adapterType)))
.map((adapterType) => adapterType.replace(/_/g, "-"));
const agentCount = Object.keys(overrides).length;
return [
`Using ${adapterTypes.join(", ")} adapter${adapterTypes.length === 1 ? "" : "s"} for ${agentCount} imported ${pluralize(agentCount, "agent")} without an explicit adapter.`,
];
}
async function promptForImportSelection(preview: CompanyPortabilityPreviewResult): Promise<string[]> {
const catalog = buildImportSelectionCatalog(preview);
const state = buildDefaultImportSelectionState(catalog);
while (true) {
const choice = await p.select<ImportSelectableGroup | "company" | "confirm">({
message: "Select what Paperclip should import",
options: [
{
value: "company",
label: state.company ? "Company: included" : "Company: skipped",
hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package",
},
{
value: "projects",
label: "Select Projects",
hint: summarizeGroupSelection(catalog, state, "projects"),
},
{
value: "issues",
label: "Select Tasks",
hint: summarizeGroupSelection(catalog, state, "issues"),
},
{
value: "agents",
label: "Select Agents",
hint: summarizeGroupSelection(catalog, state, "agents"),
},
{
value: "skills",
label: "Select Skills",
hint: summarizeGroupSelection(catalog, state, "skills"),
},
{
value: "confirm",
label: "Confirm",
hint: `${buildSelectedFilesFromImportSelection(catalog, state).length} files selected`,
},
],
initialValue: "confirm",
});
if (p.isCancel(choice)) {
p.cancel("Import cancelled.");
process.exit(0);
}
if (choice === "confirm") {
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
if (selectedFiles.length === 0) {
p.note("Select at least one import target before confirming.", "Nothing selected");
continue;
}
return selectedFiles;
}
if (choice === "company") {
if (catalog.company.files.length === 0) {
p.note("This package does not include company metadata to toggle.", "No company metadata");
continue;
}
state.company = !state.company;
continue;
}
const group = choice;
const groupItems = catalog[group];
if (groupItems.length === 0) {
p.note(`This package does not include any ${getGroupLabel(group).toLowerCase()}.`, `No ${getGroupLabel(group)}`);
continue;
}
const selection = await p.multiselect<string>({
message: `${getGroupLabel(group)} to import. Space toggles, enter returns to the main menu.`,
options: groupItems.map((item) => ({
value: item.key,
label: item.label,
hint: item.hint,
})),
initialValues: Array.from(state[group]),
});
if (p.isCancel(selection)) {
p.cancel("Import cancelled.");
process.exit(0);
}
state[group] = new Set(selection);
}
}
function summarizeInclude(include: CompanyPortabilityInclude): string {
const labels = IMPORT_INCLUDE_OPTIONS
.filter((option) => include[option.value])
.map((option) => option.label.toLowerCase());
return labels.length > 0 ? labels.join(", ") : "nothing selected";
}
function formatSourceLabel(source: { type: "inline"; rootPath?: string | null } | { type: "github"; url: string }): string {
if (source.type === "github") {
return `GitHub: ${source.url}`;
}
return `Local package: ${source.rootPath?.trim() || "(current folder)"}`;
}
function formatTargetLabel(
target: { mode: "existing_company"; companyId?: string | null } | { mode: "new_company"; newCompanyName?: string | null },
preview?: CompanyPortabilityPreviewResult,
): string {
if (target.mode === "existing_company") {
const targetName = preview?.targetCompanyName?.trim();
const targetId = preview?.targetCompanyId?.trim() || target.companyId?.trim() || "unknown-company";
return targetName ? `${targetName} (${targetId})` : targetId;
}
return target.newCompanyName?.trim() || preview?.manifest.company?.name || "new company";
}
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
return count === 1 ? singular : plural;
}
function summarizePlanCounts(
plans: Array<{ action: "create" | "update" | "skip" }>,
noun: string,
): string {
if (plans.length === 0) return `0 ${pluralize(0, noun)} selected`;
const createCount = plans.filter((plan) => plan.action === "create").length;
const updateCount = plans.filter((plan) => plan.action === "update").length;
const skipCount = plans.filter((plan) => plan.action === "skip").length;
const parts: string[] = [];
if (createCount > 0) parts.push(`${createCount} create`);
if (updateCount > 0) parts.push(`${updateCount} update`);
if (skipCount > 0) parts.push(`${skipCount} skip`);
return `${plans.length} ${pluralize(plans.length, noun)} total (${parts.join(", ")})`;
}
function summarizeImportAgentResults(agents: CompanyPortabilityImportResult["agents"]): string {
if (agents.length === 0) return "0 agents changed";
const created = agents.filter((agent) => agent.action === "created").length;
const updated = agents.filter((agent) => agent.action === "updated").length;
const skipped = agents.filter((agent) => agent.action === "skipped").length;
const parts: string[] = [];
if (created > 0) parts.push(`${created} created`);
if (updated > 0) parts.push(`${updated} updated`);
if (skipped > 0) parts.push(`${skipped} skipped`);
return `${agents.length} ${pluralize(agents.length, "agent")} total (${parts.join(", ")})`;
}
function summarizeImportProjectResults(projects: CompanyPortabilityImportResult["projects"]): string {
if (projects.length === 0) return "0 projects changed";
const created = projects.filter((project) => project.action === "created").length;
const updated = projects.filter((project) => project.action === "updated").length;
const skipped = projects.filter((project) => project.action === "skipped").length;
const parts: string[] = [];
if (created > 0) parts.push(`${created} created`);
if (updated > 0) parts.push(`${updated} updated`);
if (skipped > 0) parts.push(`${skipped} skipped`);
return `${projects.length} ${pluralize(projects.length, "project")} total (${parts.join(", ")})`;
}
function actionChip(action: string): string {
switch (action) {
case "create":
case "created":
return pc.green(action);
case "update":
case "updated":
return pc.yellow(action);
case "skip":
case "skipped":
case "none":
case "unchanged":
return pc.dim(action);
default:
return action;
}
}
function appendPreviewExamples(
lines: string[],
title: string,
entries: Array<{ action: string; label: string; reason?: string | null }>,
): void {
if (entries.length === 0) return;
lines.push("");
lines.push(pc.bold(title));
const shown = entries.slice(0, IMPORT_PREVIEW_SAMPLE_LIMIT);
for (const entry of shown) {
const reason = entry.reason?.trim() ? pc.dim(` (${entry.reason.trim()})`) : "";
lines.push(`- ${actionChip(entry.action)} ${entry.label}${reason}`);
}
if (entries.length > shown.length) {
lines.push(pc.dim(`- +${entries.length - shown.length} more`));
}
}
function appendMessageBlock(lines: string[], title: string, messages: string[]): void {
if (messages.length === 0) return;
lines.push("");
lines.push(pc.bold(title));
for (const message of messages) {
lines.push(`- ${message}`);
}
}
export function renderCompanyImportPreview(
preview: CompanyPortabilityPreviewResult,
meta: {
sourceLabel: string;
targetLabel: string;
infoMessages?: string[];
},
): string {
const lines: string[] = [
`${pc.bold("Source")} ${meta.sourceLabel}`,
`${pc.bold("Target")} ${meta.targetLabel}`,
`${pc.bold("Include")} ${summarizeInclude(preview.include)}`,
`${pc.bold("Mode")} ${preview.collisionStrategy} collisions`,
"",
pc.bold("Package"),
`- company: ${preview.manifest.company?.name ?? preview.manifest.source?.companyName ?? "not included"}`,
`- agents: ${preview.manifest.agents.length}`,
`- projects: ${preview.manifest.projects.length}`,
`- tasks: ${preview.manifest.issues.length}`,
`- skills: ${preview.manifest.skills.length}`,
];
if (preview.envInputs.length > 0) {
const requiredCount = preview.envInputs.filter((item) => item.requirement === "required").length;
lines.push(`- env inputs: ${preview.envInputs.length} (${requiredCount} required)`);
}
lines.push("");
lines.push(pc.bold("Plan"));
lines.push(`- company: ${actionChip(preview.plan.companyAction === "none" ? "unchanged" : preview.plan.companyAction)}`);
lines.push(`- agents: ${summarizePlanCounts(preview.plan.agentPlans, "agent")}`);
lines.push(`- projects: ${summarizePlanCounts(preview.plan.projectPlans, "project")}`);
lines.push(`- tasks: ${summarizePlanCounts(preview.plan.issuePlans, "task")}`);
if (preview.include.skills) {
lines.push(`- skills: ${preview.manifest.skills.length} ${pluralize(preview.manifest.skills.length, "skill")} packaged`);
}
appendPreviewExamples(
lines,
"Agent examples",
preview.plan.agentPlans.map((plan) => ({
action: plan.action,
label: `${plan.slug} -> ${plan.plannedName}`,
reason: plan.reason,
})),
);
appendPreviewExamples(
lines,
"Project examples",
preview.plan.projectPlans.map((plan) => ({
action: plan.action,
label: `${plan.slug} -> ${plan.plannedName}`,
reason: plan.reason,
})),
);
appendPreviewExamples(
lines,
"Task examples",
preview.plan.issuePlans.map((plan) => ({
action: plan.action,
label: `${plan.slug} -> ${plan.plannedTitle}`,
reason: plan.reason,
})),
);
appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []);
appendMessageBlock(lines, pc.yellow("Warnings"), preview.warnings);
appendMessageBlock(lines, pc.red("Errors"), preview.errors);
return lines.join("\n");
}
export function renderCompanyImportResult(
result: CompanyPortabilityImportResult,
meta: { targetLabel: string; companyUrl?: string; infoMessages?: string[] },
): string {
const lines: string[] = [
`${pc.bold("Target")} ${meta.targetLabel}`,
`${pc.bold("Company")} ${result.company.name} (${actionChip(result.company.action)})`,
`${pc.bold("Agents")} ${summarizeImportAgentResults(result.agents)}`,
`${pc.bold("Projects")} ${summarizeImportProjectResults(result.projects)}`,
];
if (meta.companyUrl) {
lines.splice(1, 0, `${pc.bold("URL")} ${meta.companyUrl}`);
}
appendPreviewExamples(
lines,
"Agent results",
result.agents.map((agent) => ({
action: agent.action,
label: `${agent.slug} -> ${agent.name}`,
reason: agent.reason,
})),
);
appendPreviewExamples(
lines,
"Project results",
result.projects.map((project) => ({
action: project.action,
label: `${project.slug} -> ${project.name}`,
reason: project.reason,
})),
);
if (result.envInputs.length > 0) {
lines.push("");
lines.push(pc.bold("Env inputs"));
lines.push(
`- ${result.envInputs.length} ${pluralize(result.envInputs.length, "input")} may need values after import`,
);
}
appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []);
appendMessageBlock(lines, pc.yellow("Warnings"), result.warnings);
return lines.join("\n");
}
function printCompanyImportView(title: string, body: string, opts?: { interactive?: boolean }): void {
if (opts?.interactive) {
p.note(body, title);
return;
}
console.log(pc.bold(title));
console.log(body);
}
export function resolveCompanyImportApiPath(input: {
dryRun: boolean;
targetMode: "new_company" | "existing_company";
@@ -132,6 +731,36 @@ export function resolveCompanyImportApiPath(input: {
return input.dryRun ? "/api/companies/import/preview" : "/api/companies/import";
}
export function buildCompanyDashboardUrl(apiBase: string, issuePrefix: string): string {
const url = new URL(apiBase);
const normalizedPrefix = issuePrefix.trim().replace(/^\/+|\/+$/g, "");
url.pathname = `${url.pathname.replace(/\/+$/, "")}/${normalizedPrefix}/dashboard`;
url.search = "";
url.hash = "";
return url.toString();
}
export function resolveCompanyImportApplyConfirmationMode(input: {
yes?: boolean;
interactive: boolean;
json: boolean;
}): "skip" | "prompt" {
if (input.yes) {
return "skip";
}
if (input.json) {
throw new Error(
"Applying a company import with --json requires --yes. Use --dry-run first to inspect the preview.",
);
}
if (!input.interactive) {
throw new Error(
"Applying a company import from a non-interactive terminal requires --yes. Use --dry-run first to inspect the preview.",
);
}
return "prompt";
}
export function isHttpUrl(input: string): boolean {
return /^https?:\/\//i.test(input.trim());
}
@@ -260,21 +889,29 @@ async function collectPackageFiles(
continue;
}
if (!entry.isFile()) continue;
const isMarkdown = entry.name.endsWith(".md");
const isPaperclipYaml = entry.name === ".paperclip.yaml" || entry.name === ".paperclip.yml";
const contentType = binaryContentTypeByExtension[path.extname(entry.name).toLowerCase()];
if (!isMarkdown && !isPaperclipYaml && !contentType) continue;
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
if (!shouldIncludePortableFile(relativePath)) continue;
files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath));
}
}
async function resolveInlineSourceFromPath(inputPath: string): Promise<{
export async function resolveInlineSourceFromPath(inputPath: string): Promise<{
rootPath: string;
files: Record<string, CompanyPortabilityFileEntry>;
}> {
const resolved = path.resolve(inputPath);
const resolvedStat = await stat(resolved);
if (resolvedStat.isFile() && path.extname(resolved).toLowerCase() === ".zip") {
const archive = await readZipArchive(await readFile(resolved));
const filteredFiles = Object.fromEntries(
Object.entries(archive.files).filter(([relativePath]) => shouldIncludePortableFile(relativePath)),
);
return {
rootPath: archive.rootPath ?? path.basename(resolved, ".zip"),
files: filteredFiles,
};
}
const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved);
const files: Record<string, CompanyPortabilityFileEntry> = {};
await collectPackageFiles(rootDir, rootDir, files);
@@ -515,23 +1152,29 @@ export function registerCompanyCommands(program: Command): void {
.command("import")
.description("Import a portable markdown company package from local path, URL, or GitHub")
.argument("<fromPathOrUrl>", "Source path or URL")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills")
.option("--target <mode>", "Target mode: new | existing")
.option("-C, --company-id <id>", "Existing target company ID")
.option("--new-company-name <name>", "Name override for --target new")
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
.option("--ref <value>", "Git ref to use for GitHub imports (branch, tag, or commit)")
.option("--paperclip-url <url>", "Alias for --api-base on this command")
.option("--yes", "Accept default selection and skip the pre-import confirmation prompt", false)
.option("--dry-run", "Run preview only without applying", false)
.action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => {
try {
if (!opts.apiBase?.trim() && opts.paperclipUrl?.trim()) {
opts.apiBase = opts.paperclipUrl.trim();
}
const ctx = resolveCommandContext(opts);
const interactiveView = isInteractiveTerminal() && !ctx.json;
const from = fromPathOrUrl.trim();
if (!from) {
throw new Error("Source path or URL is required.");
}
const include = parseInclude(opts.include);
const include = resolveImportInclude(opts.include);
const agents = parseAgents(opts.agents);
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
if (!["rename", "skip", "replace"].includes(collision)) {
@@ -587,27 +1230,139 @@ export function registerCompanyCommands(program: Command): void {
};
}
const payload = {
const sourceLabel = formatSourceLabel(sourcePayload);
const targetLabel = formatTargetLabel(targetPayload);
const previewApiPath = resolveCompanyImportApiPath({
dryRun: true,
targetMode: targetPayload.mode,
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
});
let selectedFiles: string[] | undefined;
if (interactiveView && !opts.yes && !opts.include?.trim()) {
const initialPreview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, {
source: sourcePayload,
include,
target: targetPayload,
agents,
collisionStrategy: collision,
});
if (!initialPreview) {
throw new Error("Import preview returned no data.");
}
selectedFiles = await promptForImportSelection(initialPreview);
}
const previewPayload = {
source: sourcePayload,
include,
target: targetPayload,
agents,
collisionStrategy: collision,
selectedFiles,
};
const importApiPath = resolveCompanyImportApiPath({
dryRun: Boolean(opts.dryRun),
targetMode: targetPayload.mode,
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
});
const preview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, previewPayload);
if (!preview) {
throw new Error("Import preview returned no data.");
}
const adapterOverrides = buildDefaultImportAdapterOverrides(preview);
const adapterMessages = buildDefaultImportAdapterMessages(adapterOverrides);
if (opts.dryRun) {
const preview = await ctx.api.post<CompanyPortabilityPreviewResult>(importApiPath, payload);
printOutput(preview, { json: ctx.json });
if (ctx.json) {
printOutput(preview, { json: true });
} else {
printCompanyImportView(
"Import Preview",
renderCompanyImportPreview(preview, {
sourceLabel,
targetLabel: formatTargetLabel(targetPayload, preview),
infoMessages: adapterMessages,
}),
{ interactive: interactiveView },
);
}
return;
}
const imported = await ctx.api.post<CompanyPortabilityImportResult>(importApiPath, payload);
printOutput(imported, { json: ctx.json });
if (!ctx.json) {
printCompanyImportView(
"Import Preview",
renderCompanyImportPreview(preview, {
sourceLabel,
targetLabel: formatTargetLabel(targetPayload, preview),
infoMessages: adapterMessages,
}),
{ interactive: interactiveView },
);
}
const confirmationMode = resolveCompanyImportApplyConfirmationMode({
yes: opts.yes,
interactive: interactiveView,
json: ctx.json,
});
if (confirmationMode === "prompt") {
const confirmed = await p.confirm({
message: "Apply this import? (y/N)",
initialValue: false,
});
if (p.isCancel(confirmed) || !confirmed) {
p.log.warn("Import cancelled.");
return;
}
}
const importApiPath = resolveCompanyImportApiPath({
dryRun: false,
targetMode: targetPayload.mode,
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
});
const imported = await ctx.api.post<CompanyPortabilityImportResult>(importApiPath, {
...previewPayload,
adapterOverrides,
});
if (!imported) {
throw new Error("Import request returned no data.");
}
let companyUrl: string | undefined;
if (!ctx.json) {
try {
const importedCompany = await ctx.api.get<Company>(`/api/companies/${imported.company.id}`);
const issuePrefix = importedCompany?.issuePrefix?.trim();
if (issuePrefix) {
companyUrl = buildCompanyDashboardUrl(ctx.api.apiBase, issuePrefix);
}
} catch {
companyUrl = undefined;
}
}
if (ctx.json) {
printOutput(imported, { json: true });
} else {
printCompanyImportView(
"Import Result",
renderCompanyImportResult(imported, {
targetLabel,
companyUrl,
infoMessages: adapterMessages,
}),
{ interactive: interactiveView },
);
if (interactiveView && companyUrl) {
const openImportedCompany = await p.confirm({
message: "Open the imported company in your browser?",
initialValue: true,
});
if (!p.isCancel(openImportedCompany) && openImportedCompany) {
if (openUrl(companyUrl)) {
p.log.info(`Opened ${companyUrl}`);
} else {
p.log.warn(`Could not open your browser automatically. Open this URL manually:\n${companyUrl}`);
}
}
}
}
} catch (err) {
handleCommandError(err);
}

View File

@@ -0,0 +1,129 @@
import { inflateRawSync } from "node:zlib";
import path from "node:path";
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
const textDecoder = new TextDecoder();
export const binaryContentTypeByExtension: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
function normalizeArchivePath(pathValue: string) {
return pathValue
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.join("/");
}
function readUint16(source: Uint8Array, offset: number) {
return source[offset]! | (source[offset + 1]! << 8);
}
function readUint32(source: Uint8Array, offset: number) {
return (
source[offset]! |
(source[offset + 1]! << 8) |
(source[offset + 2]! << 16) |
(source[offset + 3]! << 24)
) >>> 0;
}
function sharedArchiveRoot(paths: string[]) {
if (paths.length === 0) return null;
const firstSegments = paths
.map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean))
.filter((parts) => parts.length > 0);
if (firstSegments.length === 0) return null;
const candidate = firstSegments[0]![0]!;
return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate)
? candidate
: null;
}
function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry {
const contentType = binaryContentTypeByExtension[path.extname(pathValue).toLowerCase()];
if (!contentType) return textDecoder.decode(bytes);
return {
encoding: "base64",
data: Buffer.from(bytes).toString("base64"),
contentType,
};
}
async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) {
if (compressionMethod === 0) return bytes;
if (compressionMethod !== 8) {
throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported.");
}
return new Uint8Array(inflateRawSync(bytes));
}
export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{
rootPath: string | null;
files: Record<string, CompanyPortabilityFileEntry>;
}> {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
let offset = 0;
while (offset + 4 <= bytes.length) {
const signature = readUint32(bytes, offset);
if (signature === 0x02014b50 || signature === 0x06054b50) break;
if (signature !== 0x04034b50) {
throw new Error("Invalid zip archive: unsupported local file header.");
}
if (offset + 30 > bytes.length) {
throw new Error("Invalid zip archive: truncated local file header.");
}
const generalPurposeFlag = readUint16(bytes, offset + 6);
const compressionMethod = readUint16(bytes, offset + 8);
const compressedSize = readUint32(bytes, offset + 18);
const fileNameLength = readUint16(bytes, offset + 26);
const extraFieldLength = readUint16(bytes, offset + 28);
if ((generalPurposeFlag & 0x0008) !== 0) {
throw new Error("Unsupported zip archive: data descriptors are not supported.");
}
const nameOffset = offset + 30;
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
const bodyEnd = bodyOffset + compressedSize;
if (bodyEnd > bytes.length) {
throw new Error("Invalid zip archive: truncated file contents.");
}
const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength));
const archivePath = normalizeArchivePath(rawArchivePath);
const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/"));
if (archivePath && !isDirectoryEntry) {
const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd));
entries.push({
path: archivePath,
body: bytesToPortableFileEntry(archivePath, entryBytes),
});
}
offset = bodyEnd;
}
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
const files: Record<string, CompanyPortabilityFileEntry> = {};
for (const entry of entries) {
const normalizedPath =
rootPath && entry.path.startsWith(`${rootPath}/`)
? entry.path.slice(rootPath.length + 1)
: entry.path;
if (!normalizedPath) continue;
files[normalizedPath] = entry.body;
}
return { rootPath, files };
}

View File

@@ -28,7 +28,7 @@ These define the contract between server, CLI, and UI.
| File | What it defines |
|---|---|
| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, companies. |
| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, recurring routines, companies. |
| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. |
| `packages/shared/src/types/index.ts` | Re-exports portability types. |
| `packages/shared/src/validators/index.ts` | Re-exports portability validators. |
@@ -37,7 +37,8 @@ These define the contract between server, CLI, and UI.
| File | Responsibility |
|---|---|
| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, task recurrence parsing, and package README generation. References `agentcompanies/v1` version string. |
| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, recurring task <-> routine mapping, legacy recurrence migration, and package README generation. References `agentcompanies/v1` version string. |
| `server/src/services/routines.ts` | Paperclip routine runtime service. Portability now exports routines as recurring `TASK.md` entries and imports recurring tasks back through this service. |
| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. |
| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. |
@@ -106,7 +107,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`
| `PROJECT.md` frontmatter & body | `company-portability.ts` |
| `TASK.md` frontmatter & body | `company-portability.ts` |
| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` |
| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `CompanyExport.tsx`, `company.ts` (CLI) |
| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `routines.ts`, `CompanyExport.tsx`, `company.ts` (CLI) |
| `manifest.json` | `company-portability.ts` (generation), shared types (schema) |
| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) |
| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) |

View File

@@ -860,11 +860,15 @@ Export/import behavior in V1:
- export emits a clean vendor-neutral markdown package plus `.paperclip.yaml`
- projects and starter tasks are opt-in export content rather than default package content
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication)
- recurring `TASK.md` entries use `recurring: true` in the base package and Paperclip routine fidelity in `.paperclip.yaml`
- Paperclip imports recurring task packages as routines instead of downgrading them to one-time issues
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication) while preserving portable project repo/workspace metadata such as `repoUrl`, refs, and workspace-policy references keyed in `.paperclip.yaml`
- export never includes secret values; env inputs are reported as portable declarations instead
- import supports target modes:
- create a new company
- import into an existing company
- import recreates exported project workspaces and remaps portable workspace keys back to target-local workspace ids
- import forces imported agent timer heartbeats off so packages never start scheduled runs implicitly
- import supports collision strategies: `rename`, `skip`, `replace`
- import supports preview (dry-run) before apply
- GitHub imports warn on unpinned refs instead of blocking

View File

@@ -253,17 +253,7 @@ owner: cto
name: Monday Review
assignee: ceo
project: q2-launch
schedule:
timezone: America/Chicago
startsAt: 2026-03-16T09:00:00-05:00
recurrence:
frequency: weekly
interval: 1
weekdays:
- monday
time:
hour: 9
minute: 0
recurring: true
```
### Semantics
@@ -271,58 +261,30 @@ schedule:
- body content is the canonical markdown task description
- `assignee` should reference an agent slug inside the package
- `project` should reference a project slug when the task belongs to a `PROJECT.md`
- tasks are intentionally basic seed work: title, markdown body, assignee, and optional recurrence
- `recurring: true` marks the task as ongoing recurring work instead of a one-time starter task
- tasks are intentionally basic seed work: title, markdown body, assignee, project linkage, and optional `recurring: true`
- tools may also support optional fields like `priority`, `labels`, or `metadata`, but they should not require them in the base package
### Scheduling
### Recurring Tasks
The scheduling model is intentionally lightweight. It should cover common recurring patterns such as:
- the base package only needs to say whether a task is recurring
- vendors may attach the actual schedule / trigger / runtime fidelity in a vendor extension such as `.paperclip.yaml`
- this keeps `TASK.md` portable while still allowing richer runtime systems to round-trip their own automation details
- legacy packages may still use `schedule.recurrence` during transition, but exporters should prefer `recurring: true`
- every 6 hours
- every weekday at 9:00
- every Monday morning
- every month on the 1st
- every first Monday of the month
- every year on January 1
Suggested shape:
Example Paperclip extension:
```yaml
schedule:
timezone: America/Chicago
startsAt: 2026-03-14T09:00:00-05:00
recurrence:
frequency: hourly | daily | weekly | monthly | yearly
interval: 1
weekdays:
- monday
- wednesday
monthDays:
- 1
- 15
ordinalWeekdays:
- weekday: monday
ordinal: 1
months:
- 1
- 6
time:
hour: 9
minute: 0
until: 2026-12-31T23:59:59-06:00
count: 10
routines:
monday-review:
triggers:
- kind: schedule
cronExpression: "0 9 * * 1"
timezone: America/Chicago
```
Rules:
- `timezone` should use an IANA timezone like `America/Chicago`
- `startsAt` anchors the first occurrence
- `frequency` and `interval` are the only required recurrence fields
- `weekdays`, `monthDays`, `ordinalWeekdays`, and `months` are optional narrowing rules
- `ordinalWeekdays` uses `ordinal` values like `1`, `2`, `3`, `4`, or `-1` for “last”
- `time.hour` and `time.minute` keep common “morning / 9:00 / end of day” scheduling human-readable
- `until` and `count` are optional recurrence end bounds
- tools may accept richer calendar syntaxes such as RFC5545 `RRULE`, but exporters should prefer the structured form above
- vendors should ignore unknown recurring-task extensions they do not understand
- vendors importing legacy `schedule.recurrence` data may translate it into their own runtime trigger model, but new exports should prefer the simpler `recurring: true` base field
## 11. SKILL.md Compatibility
@@ -449,7 +411,7 @@ Suggested import UI behavior:
- selecting an agent auto-selects required docs and referenced skills
- selecting a team auto-selects its subtree
- selecting a project auto-selects its included tasks
- selecting a recurring task should surface its schedule before import
- selecting a recurring task should make it clear that the import target is a routine / automation, not a one-time task
- selecting referenced third-party content shows attribution, license, and fetch policy
## 15. Vendor Extensions
@@ -502,6 +464,12 @@ agents:
kind: plain
requirement: optional
default: claude
routines:
monday-review:
triggers:
- kind: schedule
cronExpression: "0 9 * * 1"
timezone: America/Chicago
```
Additional rules for Paperclip exporters:
@@ -520,7 +488,7 @@ A compliant exporter should:
- omit machine-local ids and timestamps
- omit secret values
- omit machine-specific paths
- preserve task descriptions and recurrence definitions when exporting tasks
- preserve task descriptions and recurring-task declarations when exporting tasks
- omit empty/default fields
- default to the vendor-neutral base package
- Paperclip exporters should emit `.paperclip.yaml` as a sidecar by default
@@ -569,11 +537,11 @@ Paperclip can map this spec to its runtime model like this:
- `TEAM.md` -> importable org subtree
- `AGENTS.md` -> agent identity and instructions
- `PROJECT.md` -> starter project definition
- `TASK.md` -> starter issue/task definition, or automation template when recurrence is present
- `TASK.md` -> starter issue/task definition, or recurring task template when `recurring: true`
- `SKILL.md` -> imported skill package
- `sources[]` -> provenance and pinned upstream refs
- Paperclip extension:
- `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, and other Paperclip-specific fidelity
- `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, routine triggers, and other Paperclip-specific fidelity
Inline Paperclip-only metadata that must live inside a shared markdown file should use:

View File

@@ -48,7 +48,8 @@
"guides/board-operator/managing-tasks",
"guides/board-operator/approvals",
"guides/board-operator/costs-and-budgets",
"guides/board-operator/activity-log"
"guides/board-operator/activity-log",
"guides/board-operator/importing-and-exporting"
]
},
{

View File

@@ -0,0 +1,203 @@
---
title: Importing & Exporting Companies
summary: Export companies to portable packages and import them from local paths or GitHub
---
Paperclip companies can be exported to portable markdown packages and imported from local directories or GitHub repositories. This lets you share company configurations, duplicate setups, and version-control your agent teams.
## Package Format
Exported packages follow the [Agent Companies specification](/companies/companies-spec) and use a markdown-first structure:
```text
my-company/
├── COMPANY.md # Company metadata
├── agents/
│ ├── ceo/AGENT.md # Agent instructions + frontmatter
│ └── cto/AGENT.md
├── projects/
│ └── main/PROJECT.md
├── skills/
│ └── review/SKILL.md
├── tasks/
│ └── onboarding/TASK.md
└── .paperclip.yaml # Adapter config, env inputs, routines
```
- **COMPANY.md** defines company name, description, and metadata.
- **AGENT.md** files contain agent identity, role, and instructions.
- **SKILL.md** files are compatible with the Agent Skills ecosystem.
- **.paperclip.yaml** holds Paperclip-specific config (adapter types, env inputs, budgets) as an optional sidecar.
## Exporting a Company
Export a company into a portable folder:
```sh
paperclipai company export <company-id> --out ./my-export
```
### Options
| Option | Description | Default |
|--------|-------------|---------|
| `--out <path>` | Output directory (required) | — |
| `--include <values>` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | `company,agents` |
| `--skills <values>` | Export only specific skill slugs | all |
| `--projects <values>` | Export only specific project shortnames or IDs | all |
| `--issues <values>` | Export specific issue identifiers or IDs | none |
| `--project-issues <values>` | Export issues belonging to specific projects | none |
| `--expand-referenced-skills` | Vendor skill file contents instead of keeping upstream references | `false` |
### Examples
```sh
# Export company with agents and projects
paperclipai company export abc123 --out ./backup --include company,agents,projects
# Export everything including tasks and skills
paperclipai company export abc123 --out ./full-export --include company,agents,projects,tasks,skills
# Export only specific skills
paperclipai company export abc123 --out ./skills-only --include skills --skills review,deploy
```
### What Gets Exported
- Company name, description, and metadata
- Agent names, roles, reporting structure, and instructions
- Project definitions and workspace config
- Task/issue descriptions (when included)
- Skill packages (as references or vendored content)
- Adapter type and env input declarations in `.paperclip.yaml`
Secret values, machine-local paths, and database IDs are **never** exported.
## Importing a Company
Import from a local directory, GitHub URL, or GitHub shorthand:
```sh
# From a local folder
paperclipai company import ./my-export
# From a GitHub URL
paperclipai company import https://github.com/org/repo
# From a GitHub subfolder
paperclipai company import https://github.com/org/repo/tree/main/companies/acme
# From GitHub shorthand
paperclipai company import org/repo
paperclipai company import org/repo/companies/acme
```
### Options
| Option | Description | Default |
|--------|-------------|---------|
| `--target <mode>` | `new` (create a new company) or `existing` (merge into existing) | inferred from context |
| `--company-id <id>` | Target company ID for `--target existing` | current context |
| `--new-company-name <name>` | Override company name for `--target new` | from package |
| `--include <values>` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | auto-detected |
| `--agents <list>` | Comma-separated agent slugs to import, or `all` | `all` |
| `--collision <mode>` | How to handle name conflicts: `rename`, `skip`, or `replace` | `rename` |
| `--ref <value>` | Git ref for GitHub imports (branch, tag, or commit) | default branch |
| `--dry-run` | Preview what would be imported without applying | `false` |
| `--yes` | Skip the interactive confirmation prompt | `false` |
| `--json` | Output result as JSON | `false` |
### Target Modes
- **`new`** — Creates a fresh company from the package. Good for duplicating a company template.
- **`existing`** — Merges the package into an existing company. Use `--company-id` to specify the target.
If `--target` is not specified, Paperclip infers it: if a `--company-id` is provided (or one exists in context), it defaults to `existing`; otherwise `new`.
### Collision Strategies
When importing into an existing company, agent or project names may conflict with existing ones:
- **`rename`** (default) — Appends a suffix to avoid conflicts (e.g., `ceo` becomes `ceo-2`).
- **`skip`** — Skips entities that already exist.
- **`replace`** — Overwrites existing entities. Only available for non-safe imports (not available through the CEO API).
### Interactive Selection
When running interactively (no `--yes` or `--json` flags), the import command shows a selection picker before applying. You can choose exactly which agents, projects, skills, and tasks to import using a checkbox interface.
### Preview Before Applying
Always preview first with `--dry-run`:
```sh
paperclipai company import org/repo --target existing --company-id abc123 --dry-run
```
The preview shows:
- **Package contents** — How many agents, projects, tasks, and skills are in the source
- **Import plan** — What will be created, renamed, skipped, or replaced
- **Env inputs** — Environment variables that may need values after import
- **Warnings** — Potential issues like missing skills or unresolved references
Imported agents always land with timer heartbeats disabled. Assignment/on-demand wake behavior from the package is preserved, but scheduled runs stay off until a board operator re-enables them.
### Common Workflows
**Clone a company template from GitHub:**
```sh
paperclipai company import org/company-templates/engineering-team \
--target new \
--new-company-name "My Engineering Team"
```
**Add agents from a package into your existing company:**
```sh
paperclipai company import ./shared-agents \
--target existing \
--company-id abc123 \
--include agents \
--collision rename
```
**Import a specific branch or tag:**
```sh
paperclipai company import org/repo --ref v2.0.0 --dry-run
```
**Non-interactive import (CI/scripts):**
```sh
paperclipai company import ./package \
--target new \
--yes \
--json
```
## API Endpoints
The CLI commands use these API endpoints under the hood:
| Action | Endpoint |
|--------|----------|
| Export company | `POST /api/companies/{companyId}/export` |
| Preview import (existing company) | `POST /api/companies/{companyId}/imports/preview` |
| Apply import (existing company) | `POST /api/companies/{companyId}/imports/apply` |
| Preview import (new company) | `POST /api/companies/import/preview` |
| Apply import (new company) | `POST /api/companies/import` |
CEO agents can also use the safe import routes (`/imports/preview` and `/imports/apply`) which enforce non-destructive rules: `replace` is rejected, collisions resolve with `rename` or `skip`, and issues are always created as new.
## GitHub Sources
Paperclip supports several GitHub URL formats:
- Full URL: `https://github.com/org/repo`
- Subfolder URL: `https://github.com/org/repo/tree/main/path/to/company`
- Shorthand: `org/repo`
- Shorthand with path: `org/repo/path/to/company`
Use `--ref` to pin to a specific branch, tag, or commit hash when importing from GitHub.

View File

@@ -253,9 +253,13 @@ export type {
CompanyPortabilityEnvInput,
CompanyPortabilityFileEntry,
CompanyPortabilityCompanyManifestEntry,
CompanyPortabilitySidebarOrder,
CompanyPortabilityAgentManifestEntry,
CompanyPortabilitySkillManifestEntry,
CompanyPortabilityProjectManifestEntry,
CompanyPortabilityProjectWorkspaceManifestEntry,
CompanyPortabilityIssueRoutineTriggerManifestEntry,
CompanyPortabilityIssueRoutineManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
@@ -484,6 +488,7 @@ export {
portabilityIncludeSchema,
portabilityEnvInputSchema,
portabilityCompanyManifestEntrySchema,
portabilitySidebarOrderSchema,
portabilityAgentManifestEntrySchema,
portabilityManifestSchema,
portabilitySourceSchema,

View File

@@ -33,6 +33,11 @@ export interface CompanyPortabilityCompanyManifestEntry {
requireBoardApprovalForNewAgents: boolean;
}
export interface CompanyPortabilitySidebarOrder {
agents: string[];
projects: string[];
}
export interface CompanyPortabilityProjectManifestEntry {
slug: string;
name: string;
@@ -44,18 +49,52 @@ export interface CompanyPortabilityProjectManifestEntry {
color: string | null;
status: string | null;
executionWorkspacePolicy: Record<string, unknown> | null;
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
metadata: Record<string, unknown> | null;
}
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
key: string;
name: string;
sourceType: string | null;
repoUrl: string | null;
repoRef: string | null;
defaultRef: string | null;
visibility: string | null;
setupCommand: string | null;
cleanupCommand: string | null;
metadata: Record<string, unknown> | null;
isPrimary: boolean;
}
export interface CompanyPortabilityIssueRoutineTriggerManifestEntry {
kind: string;
label: string | null;
enabled: boolean;
cronExpression: string | null;
timezone: string | null;
signingMode: string | null;
replayWindowSec: number | null;
}
export interface CompanyPortabilityIssueRoutineManifestEntry {
concurrencyPolicy: string | null;
catchUpPolicy: string | null;
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
}
export interface CompanyPortabilityIssueManifestEntry {
slug: string;
identifier: string | null;
title: string;
path: string;
projectSlug: string | null;
projectWorkspaceKey: string | null;
assigneeAgentSlug: string | null;
description: string | null;
recurrence: Record<string, unknown> | null;
recurring: boolean;
routine: CompanyPortabilityIssueRoutineManifestEntry | null;
legacyRecurrence: Record<string, unknown> | null;
status: string | null;
priority: string | null;
labelIds: string[];
@@ -110,6 +149,7 @@ export interface CompanyPortabilityManifest {
} | null;
includes: CompanyPortabilityInclude;
company: CompanyPortabilityCompanyManifestEntry | null;
sidebar: CompanyPortabilitySidebarOrder | null;
agents: CompanyPortabilityAgentManifestEntry[];
skills: CompanyPortabilitySkillManifestEntry[];
projects: CompanyPortabilityProjectManifestEntry[];
@@ -245,6 +285,13 @@ export interface CompanyPortabilityImportResult {
name: string;
reason: string | null;
}[];
projects: {
slug: string;
id: string | null;
action: "created" | "updated" | "skipped";
name: string;
reason: string | null;
}[];
envInputs: CompanyPortabilityEnvInput[];
warnings: string[];
}
@@ -258,4 +305,5 @@ export interface CompanyPortabilityExportRequest {
projectIssues?: string[];
selectedFiles?: string[];
expandReferencedSkills?: boolean;
sidebarOrder?: Partial<CompanyPortabilitySidebarOrder>;
}

View File

@@ -144,9 +144,13 @@ export type {
CompanyPortabilityEnvInput,
CompanyPortabilityFileEntry,
CompanyPortabilityCompanyManifestEntry,
CompanyPortabilitySidebarOrder,
CompanyPortabilityAgentManifestEntry,
CompanyPortabilitySkillManifestEntry,
CompanyPortabilityProjectManifestEntry,
CompanyPortabilityProjectWorkspaceManifestEntry,
CompanyPortabilityIssueRoutineTriggerManifestEntry,
CompanyPortabilityIssueRoutineManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,

View File

@@ -38,6 +38,11 @@ export const portabilityCompanyManifestEntrySchema = z.object({
requireBoardApprovalForNewAgents: z.boolean(),
});
export const portabilitySidebarOrderSchema = z.object({
agents: z.array(z.string().min(1)).default([]),
projects: z.array(z.string().min(1)).default([]),
});
export const portabilityAgentManifestEntrySchema = z.object({
slug: z.string().min(1),
name: z.string().min(1),
@@ -85,18 +90,50 @@ export const portabilityProjectManifestEntrySchema = z.object({
color: z.string().nullable(),
status: z.string().nullable(),
executionWorkspacePolicy: z.record(z.unknown()).nullable(),
workspaces: z.array(z.object({
key: z.string().min(1),
name: z.string().min(1),
sourceType: z.string().nullable(),
repoUrl: z.string().nullable(),
repoRef: z.string().nullable(),
defaultRef: z.string().nullable(),
visibility: z.string().nullable(),
setupCommand: z.string().nullable(),
cleanupCommand: z.string().nullable(),
metadata: z.record(z.unknown()).nullable(),
isPrimary: z.boolean(),
})).default([]),
metadata: z.record(z.unknown()).nullable(),
});
export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({
kind: z.string().min(1),
label: z.string().nullable(),
enabled: z.boolean(),
cronExpression: z.string().nullable(),
timezone: z.string().nullable(),
signingMode: z.string().nullable(),
replayWindowSec: z.number().int().nullable(),
});
export const portabilityIssueRoutineManifestEntrySchema = z.object({
concurrencyPolicy: z.string().nullable(),
catchUpPolicy: z.string().nullable(),
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
});
export const portabilityIssueManifestEntrySchema = z.object({
slug: z.string().min(1),
identifier: z.string().min(1).nullable(),
title: z.string().min(1),
path: z.string().min(1),
projectSlug: z.string().min(1).nullable(),
projectWorkspaceKey: z.string().min(1).nullable(),
assigneeAgentSlug: z.string().min(1).nullable(),
description: z.string().nullable(),
recurrence: z.record(z.unknown()).nullable(),
recurring: z.boolean().default(false),
routine: portabilityIssueRoutineManifestEntrySchema.nullable(),
legacyRecurrence: z.record(z.unknown()).nullable(),
status: z.string().nullable(),
priority: z.string().nullable(),
labelIds: z.array(z.string().min(1)).default([]),
@@ -123,6 +160,7 @@ export const portabilityManifestSchema = z.object({
skills: z.boolean(),
}),
company: portabilityCompanyManifestEntrySchema.nullable(),
sidebar: portabilitySidebarOrderSchema.nullable(),
agents: z.array(portabilityAgentManifestEntrySchema),
skills: z.array(portabilitySkillManifestEntrySchema).default([]),
projects: z.array(portabilityProjectManifestEntrySchema).default([]),
@@ -169,6 +207,7 @@ export const companyPortabilityExportSchema = z.object({
projectIssues: z.array(z.string().min(1)).optional(),
selectedFiles: z.array(z.string().min(1)).optional(),
expandReferencedSkills: z.boolean().optional(),
sidebarOrder: portabilitySidebarOrderSchema.partial().optional(),
});
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;

View File

@@ -60,6 +60,7 @@ export {
portabilityIncludeSchema,
portabilityEnvInputSchema,
portabilityCompanyManifestEntrySchema,
portabilitySidebarOrderSchema,
portabilityAgentManifestEntrySchema,
portabilitySkillManifestEntrySchema,
portabilityManifestSchema,

View File

@@ -0,0 +1,364 @@
#!/usr/bin/env npx tsx
/**
* Generate org chart images and READMEs for agent company packages.
*
* Reads company packages from a directory, builds manifest-like data,
* then uses the existing server-side SVG renderer (sharp, no browser)
* and README generator.
*
* Usage:
* npx tsx scripts/generate-company-assets.ts /path/to/companies-repo
*
* Processes each subdirectory that contains a COMPANY.md file.
*/
import * as fs from "fs";
import * as path from "path";
import { renderOrgChartPng, type OrgNode, type OrgChartOverlay } from "../server/src/routes/org-chart-svg.js";
import { generateReadme } from "../server/src/services/company-export-readme.js";
import type { CompanyPortabilityManifest } from "@paperclipai/shared";
// ── YAML frontmatter parser (minimal, no deps) ──────────────────
function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { data: {}, body: content };
const yamlStr = match[1];
const body = match[2];
const data: Record<string, unknown> = {};
let currentKey: string | null = null;
let currentValue: string | string[] | null = null;
let inList = false;
for (const line of yamlStr.split("\n")) {
// List item
if (inList && /^\s+-\s+/.test(line)) {
const val = line.replace(/^\s+-\s+/, "").trim();
(currentValue as string[]).push(val);
continue;
}
// Save previous key
if (currentKey !== null && currentValue !== null) {
data[currentKey] = currentValue;
}
inList = false;
// Key: value line
const kvMatch = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
if (kvMatch) {
currentKey = kvMatch[1];
let val = kvMatch[2].trim();
if (val === "" || val === ">") {
// Could be a multi-line value or list — peek ahead handled by next iterations
currentValue = "";
continue;
}
if (val === "null" || val === "~") {
currentValue = null;
data[currentKey] = null;
currentKey = null;
currentValue = null;
continue;
}
// Remove surrounding quotes
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
currentValue = val;
} else if (currentKey !== null && line.match(/^\s+-\s+/)) {
// Start of list
inList = true;
currentValue = [];
const val = line.replace(/^\s+-\s+/, "").trim();
(currentValue as string[]).push(val);
} else if (currentKey !== null && line.match(/^\s+\S/)) {
// Continuation of multi-line scalar
const trimmed = line.trim();
if (typeof currentValue === "string") {
currentValue = currentValue ? `${currentValue} ${trimmed}` : trimmed;
}
}
}
// Save last key
if (currentKey !== null && currentValue !== null) {
data[currentKey] = currentValue;
}
return { data, body };
}
// ── Slug to role mapping ─────────────────────────────────────────
const SLUG_TO_ROLE: Record<string, string> = {
ceo: "ceo",
cto: "cto",
cmo: "cmo",
cfo: "cfo",
coo: "coo",
};
function inferRole(slug: string, title: string | null): string {
// Check direct slug match first
if (SLUG_TO_ROLE[slug]) return SLUG_TO_ROLE[slug];
// Check title for C-suite
const t = (title || "").toLowerCase();
if (t.includes("chief executive")) return "ceo";
if (t.includes("chief technology")) return "cto";
if (t.includes("chief marketing")) return "cmo";
if (t.includes("chief financial")) return "cfo";
if (t.includes("chief operating")) return "coo";
if (t.includes("vp") || t.includes("vice president")) return "vp";
if (t.includes("manager")) return "manager";
if (t.includes("qa") || t.includes("quality")) return "engineer";
// Default to engineer
return "engineer";
}
// ── Parse a company package directory ────────────────────────────
interface CompanyPackage {
dir: string;
name: string;
description: string | null;
slug: string;
agents: CompanyPortabilityManifest["agents"];
skills: CompanyPortabilityManifest["skills"];
}
function parseCompanyPackage(companyDir: string): CompanyPackage | null {
const companyMdPath = path.join(companyDir, "COMPANY.md");
if (!fs.existsSync(companyMdPath)) return null;
const companyMd = fs.readFileSync(companyMdPath, "utf-8");
const { data: companyData } = parseFrontmatter(companyMd);
const name = (companyData.name as string) || path.basename(companyDir);
const description = (companyData.description as string) || null;
const slug = (companyData.slug as string) || path.basename(companyDir);
// Parse agents
const agentsDir = path.join(companyDir, "agents");
const agents: CompanyPortabilityManifest["agents"] = [];
if (fs.existsSync(agentsDir)) {
for (const agentSlug of fs.readdirSync(agentsDir)) {
const agentMdName = fs.existsSync(path.join(agentsDir, agentSlug, "AGENT.md"))
? "AGENT.md"
: fs.existsSync(path.join(agentsDir, agentSlug, "AGENTS.md"))
? "AGENTS.md"
: null;
if (!agentMdName) continue;
const agentMdPath = path.join(agentsDir, agentSlug, agentMdName);
const agentMd = fs.readFileSync(agentMdPath, "utf-8");
const { data: agentData } = parseFrontmatter(agentMd);
const agentName = (agentData.name as string) || agentSlug;
const title = (agentData.title as string) || null;
const reportsTo = agentData.reportsTo as string | null;
const skills = (agentData.skills as string[]) || [];
const role = inferRole(agentSlug, title);
agents.push({
slug: agentSlug,
name: agentName,
path: `agents/${agentSlug}/${agentMdName}`,
skills,
role,
title,
icon: null,
capabilities: null,
reportsToSlug: reportsTo || null,
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
});
}
}
// Parse skills
const skillsDir = path.join(companyDir, "skills");
const skills: CompanyPortabilityManifest["skills"] = [];
if (fs.existsSync(skillsDir)) {
for (const skillSlug of fs.readdirSync(skillsDir)) {
const skillMdPath = path.join(skillsDir, skillSlug, "SKILL.md");
if (!fs.existsSync(skillMdPath)) continue;
const skillMd = fs.readFileSync(skillMdPath, "utf-8");
const { data: skillData } = parseFrontmatter(skillMd);
const skillName = (skillData.name as string) || skillSlug;
const skillDesc = (skillData.description as string) || null;
// Extract source info from metadata
let sourceType = "local";
let sourceLocator: string | null = null;
const metadata = skillData.metadata as Record<string, unknown> | undefined;
if (metadata) {
// metadata.sources is parsed as a nested structure, but our simple parser
// doesn't handle it well. Check for github repo in the raw SKILL.md instead.
const repoMatch = skillMd.match(/repo:\s*(.+)/);
const pathMatch = skillMd.match(/path:\s*(.+)/);
if (repoMatch) {
sourceType = "github";
const repo = repoMatch[1].trim();
const filePath = pathMatch ? pathMatch[1].trim() : "";
sourceLocator = `https://github.com/${repo}/blob/main/${filePath}`;
}
}
skills.push({
key: skillSlug,
slug: skillSlug,
name: skillName,
path: `skills/${skillSlug}/SKILL.md`,
description: skillDesc,
sourceType,
sourceLocator,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [{ path: `skills/${skillSlug}/SKILL.md`, kind: "skill" }],
});
}
}
return { dir: companyDir, name, description, slug, agents, skills };
}
// ── Build OrgNode tree from agents ───────────────────────────────
const ROLE_LABELS: Record<string, string> = {
ceo: "Chief Executive",
cto: "Technology",
cmo: "Marketing",
cfo: "Finance",
coo: "Operations",
vp: "VP",
manager: "Manager",
engineer: "Engineer",
agent: "Agent",
};
function buildOrgTree(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
const bySlug = new Map(agents.map((a) => [a.slug, a]));
const childrenOf = new Map<string | null, typeof agents>();
for (const a of agents) {
const parent = a.reportsToSlug ?? null;
const list = childrenOf.get(parent) ?? [];
list.push(a);
childrenOf.set(parent, list);
}
const build = (parentSlug: string | null): OrgNode[] => {
const members = childrenOf.get(parentSlug) ?? [];
return members.map((m) => ({
id: m.slug,
name: m.name,
role: ROLE_LABELS[m.role] ?? m.role,
status: "active",
reports: build(m.slug),
}));
};
const roots = agents.filter((a) => !a.reportsToSlug || !bySlug.has(a.reportsToSlug));
const tree = build(null);
for (const root of roots) {
if (root.reportsToSlug && !bySlug.has(root.reportsToSlug)) {
tree.push({
id: root.slug,
name: root.name,
role: ROLE_LABELS[root.role] ?? root.role,
status: "active",
reports: build(root.slug),
});
}
}
return tree;
}
// ── Main ─────────────────────────────────────────────────────────
async function main() {
const companiesDir = process.argv[2];
if (!companiesDir) {
console.error("Usage: npx tsx scripts/generate-company-assets.ts <companies-dir>");
process.exit(1);
}
const resolvedDir = path.resolve(companiesDir);
if (!fs.existsSync(resolvedDir)) {
console.error(`Directory not found: ${resolvedDir}`);
process.exit(1);
}
const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
let processed = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const companyDir = path.join(resolvedDir, entry.name);
const pkg = parseCompanyPackage(companyDir);
if (!pkg) continue;
console.log(`\n── ${pkg.name} (${pkg.slug}) ──`);
console.log(` ${pkg.agents.length} agents, ${pkg.skills.length} skills`);
// Generate org chart PNG
if (pkg.agents.length > 0) {
const orgTree = buildOrgTree(pkg.agents);
console.log(` Org tree roots: ${orgTree.map((n) => n.name).join(", ")}`);
const overlay: OrgChartOverlay = {
companyName: pkg.name,
stats: `Agents: ${pkg.agents.length}, Skills: ${pkg.skills.length}`,
};
const pngBuffer = await renderOrgChartPng(orgTree, "warmth", overlay);
const imagesDir = path.join(companyDir, "images");
fs.mkdirSync(imagesDir, { recursive: true });
const pngPath = path.join(imagesDir, "org-chart.png");
fs.writeFileSync(pngPath, pngBuffer);
console.log(`${path.relative(resolvedDir, pngPath)} (${(pngBuffer.length / 1024).toFixed(1)}kb)`);
}
// Generate README
const manifest: CompanyPortabilityManifest = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
source: null,
includes: { company: true, agents: true, projects: false, issues: false, skills: true },
company: null,
agents: pkg.agents,
skills: pkg.skills,
projects: [],
issues: [],
envInputs: [],
};
const readme = generateReadme(manifest, {
companyName: pkg.name,
companyDescription: pkg.description,
});
const readmePath = path.join(companyDir, "README.md");
fs.writeFileSync(readmePath, readme);
console.log(`${path.relative(resolvedDir, readmePath)}`);
processed++;
}
console.log(`\n✓ Processed ${processed} companies.`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -1,3 +1,7 @@
import { execFileSync } from "node:child_process";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
@@ -25,6 +29,8 @@ const projectSvc = {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
createWorkspace: vi.fn(),
listWorkspaces: vi.fn(),
};
const issueSvc = {
@@ -34,6 +40,13 @@ const issueSvc = {
create: vi.fn(),
};
const routineSvc = {
list: vi.fn(),
getDetail: vi.fn(),
create: vi.fn(),
createTrigger: vi.fn(),
};
const companySkillSvc = {
list: vi.fn(),
listFull: vi.fn(),
@@ -71,6 +84,10 @@ vi.mock("../services/issues.js", () => ({
issueService: () => issueSvc,
}));
vi.mock("../services/routines.js", () => ({
routineService: () => routineSvc,
}));
vi.mock("../services/company-skills.js", () => ({
companySkillService: () => companySkillSvc,
}));
@@ -184,9 +201,62 @@ describe("company portability", () => {
},
]);
projectSvc.list.mockResolvedValue([]);
projectSvc.createWorkspace.mockResolvedValue(null);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([]);
issueSvc.getById.mockResolvedValue(null);
issueSvc.getByIdentifier.mockResolvedValue(null);
routineSvc.list.mockResolvedValue([]);
routineSvc.getDetail.mockImplementation(async (id: string) => {
const rows = await routineSvc.list();
return rows.find((row: { id: string }) => row.id === id) ?? null;
});
routineSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "routine-created",
companyId: "company-1",
projectId: input.projectId,
goalId: null,
parentIssueId: null,
title: input.title,
description: input.description ?? null,
assigneeAgentId: input.assigneeAgentId,
priority: input.priority ?? "medium",
status: input.status ?? "active",
concurrencyPolicy: input.concurrencyPolicy ?? "coalesce_if_active",
catchUpPolicy: input.catchUpPolicy ?? "skip_missed",
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
}));
routineSvc.createTrigger.mockImplementation(async (_routineId: string, input: Record<string, unknown>) => ({
id: "trigger-created",
companyId: "company-1",
routineId: "routine-created",
kind: input.kind,
label: input.label ?? null,
enabled: input.enabled ?? true,
cronExpression: input.kind === "schedule" ? input.cronExpression ?? null : null,
timezone: input.kind === "schedule" ? input.timezone ?? null : null,
nextRunAt: null,
lastFiredAt: null,
publicId: null,
secretId: null,
signingMode: input.kind === "webhook" ? input.signingMode ?? "bearer" : null,
replayWindowSec: input.kind === "webhook" ? input.replayWindowSec ?? 300 : null,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
}));
const companySkills = [
{
id: "skill-1",
@@ -370,6 +440,64 @@ describe("company portability", () => {
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
});
it("exports default sidebar order into the Paperclip extension and manifest", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-2",
companyId: "company-1",
name: "Zulu",
urlKey: "zulu",
description: null,
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
archivedAt: null,
workspaces: [],
},
{
id: "project-1",
companyId: "company-1",
name: "Alpha",
urlKey: "alpha",
description: null,
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
archivedAt: null,
workspaces: [],
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: true,
issues: false,
},
});
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain([
"sidebar:",
" agents:",
' - "claudecoder"',
' - "cmo"',
" projects:",
' - "alpha"',
' - "zulu"',
].join("\n"));
expect(exported.manifest.sidebar).toEqual({
agents: ["claudecoder", "cmo"],
projects: ["alpha", "zulu"],
});
});
it("expands referenced skills when requested", async () => {
const portability = companyPortabilityService({} as any);
@@ -599,6 +727,388 @@ describe("company portability", () => {
expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false);
});
it("exports portable project workspace metadata and remaps it on import", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: "2026-03-31",
color: "#123456",
status: "planned",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: "workspace-1",
workspaceStrategy: {
type: "project_primary",
},
},
workspaces: [
{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Main Repo",
sourceType: "git_repo",
cwd: "/Users/dotta/paperclip",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
defaultRef: "main",
visibility: "default",
setupCommand: "pnpm install",
cleanupCommand: "rm -rf .paperclip-tmp",
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: {
language: "typescript",
},
isPrimary: true,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
{
id: "workspace-2",
companyId: "company-1",
projectId: "project-1",
name: "Local Scratch",
sourceType: "local_path",
cwd: "/tmp/paperclip-local",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "advanced",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: false,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
],
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Write launch task",
description: "Task body",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: "agent-1",
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: {
mode: "shared_workspace",
},
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: false,
projects: true,
issues: true,
},
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("workspaces:");
expect(extension).toContain("main-repo:");
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
expect(extension).toContain('defaultProjectWorkspaceKey: "main-repo"');
expect(extension).toContain('projectWorkspaceKey: "main-repo"');
expect(extension).not.toContain("/Users/dotta/paperclip");
expect(extension).not.toContain("workspace-1");
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
projectSvc.create.mockResolvedValue({
id: "project-imported",
name: "Launch",
urlKey: "launch",
});
projectSvc.update.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
id: projectId,
name: "Launch",
urlKey: "launch",
...data,
}));
projectSvc.createWorkspace.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
id: "workspace-imported",
companyId: "company-imported",
projectId,
name: `${data.name ?? "Workspace"}`,
sourceType: `${data.sourceType ?? "git_repo"}`,
cwd: null,
repoUrl: typeof data.repoUrl === "string" ? data.repoUrl : null,
repoRef: typeof data.repoRef === "string" ? data.repoRef : null,
defaultRef: typeof data.defaultRef === "string" ? data.defaultRef : null,
visibility: `${data.visibility ?? "default"}`,
setupCommand: typeof data.setupCommand === "string" ? data.setupCommand : null,
cleanupCommand: typeof data.cleanupCommand === "string" ? data.cleanupCommand : null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
isPrimary: Boolean(data.isPrimary),
createdAt: new Date("2026-03-02T00:00:00Z"),
updatedAt: new Date("2026-03-02T00:00:00Z"),
}));
issueSvc.create.mockResolvedValue({
id: "issue-imported",
title: "Write launch task",
});
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: false,
projects: true,
issues: true,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
collisionStrategy: "rename",
}, "user-1");
expect(projectSvc.createWorkspace).toHaveBeenCalledWith("project-imported", expect.objectContaining({
name: "Main Repo",
sourceType: "git_repo",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
defaultRef: "main",
visibility: "default",
}));
expect(projectSvc.update).toHaveBeenCalledWith("project-imported", expect.objectContaining({
executionWorkspacePolicy: expect.objectContaining({
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: "workspace-imported",
}),
}));
expect(issueSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
projectId: "project-imported",
projectWorkspaceId: "workspace-imported",
title: "Write launch task",
}));
});
it("infers portable git metadata from a local checkout without task warning fan-out", async () => {
const portability = companyPortabilityService({} as any);
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-"));
execFileSync("git", ["init"], { cwd: repoDir, stdio: "ignore" });
execFileSync("git", ["checkout", "-b", "main"], { cwd: repoDir, stdio: "ignore" });
execFileSync("git", ["remote", "add", "origin", "https://github.com/paperclipai/paperclip.git"], {
cwd: repoDir,
stdio: "ignore",
});
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Paperclip App",
urlKey: "paperclip-app",
description: "Ship it",
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: "workspace-1",
},
workspaces: [
{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "paperclip",
sourceType: "local_path",
cwd: repoDir,
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
],
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Task one",
description: "Task body",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: true,
},
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
expect(extension).toContain('projectWorkspaceKey: "paperclip"');
expect(exported.warnings).not.toContainEqual(expect.stringContaining("does not have a portable repoUrl"));
expect(exported.warnings).not.toContainEqual(expect.stringContaining("reference workspace workspace-1"));
});
it("collapses repeated task workspace warnings into one summary per missing workspace", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: null,
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
workspaces: [
{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Local Scratch",
sourceType: "local_path",
cwd: "/tmp/local-only",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
createdAt: new Date("2026-03-01T00:00:00Z"),
updatedAt: new Date("2026-03-01T00:00:00Z"),
},
],
archivedAt: null,
},
]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Task one",
description: null,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
{
id: "issue-2",
identifier: "PAP-2",
title: "Task two",
description: null,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
{
id: "issue-3",
identifier: "PAP-3",
title: "Task three",
description: null,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: true,
},
});
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
expect(exported.warnings).toContain("Tasks pap-1, pap-2, pap-3 reference workspace workspace-1, but that workspace could not be exported portably.");
expect(exported.warnings.filter((warning) => warning.includes("workspace reference workspace-1 was omitted from export"))).toHaveLength(0);
expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1);
});
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any);
@@ -654,6 +1164,360 @@ describe("company portability", () => {
]);
});
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
executionWorkspacePolicy: null,
archivedAt: null,
},
]);
routineSvc.list.mockResolvedValue([
{
id: "routine-1",
companyId: "company-1",
projectId: "project-1",
goalId: null,
parentIssueId: null,
title: "Monday Review",
description: "Review pipeline health",
assigneeAgentId: "agent-1",
priority: "high",
status: "paused",
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "enqueue_missed_with_cap",
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
triggers: [
{
id: "trigger-1",
companyId: "company-1",
routineId: "routine-1",
kind: "schedule",
label: "Weekly cadence",
enabled: true,
cronExpression: "0 9 * * 1",
timezone: "America/Chicago",
nextRunAt: null,
lastFiredAt: null,
publicId: "public-1",
secretId: "secret-1",
signingMode: null,
replayWindowSec: null,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "trigger-2",
companyId: "company-1",
routineId: "routine-1",
kind: "webhook",
label: "External nudge",
enabled: false,
cronExpression: null,
timezone: null,
nextRunAt: null,
lastFiredAt: null,
publicId: "public-2",
secretId: "secret-2",
signingMode: "hmac_sha256",
replayWindowSec: 120,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
lastRun: null,
activeIssue: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: false,
},
});
expect(asTextFile(exported.files["tasks/monday-review/TASK.md"])).toContain('recurring: true');
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("routines:");
expect(extension).toContain("monday-review:");
expect(extension).toContain('cronExpression: "0 9 * * 1"');
expect(extension).toContain('signingMode: "hmac_sha256"');
expect(extension).not.toContain("secretId");
expect(extension).not.toContain("publicId");
expect(exported.manifest.issues).toEqual([
expect.objectContaining({
slug: "monday-review",
recurring: true,
status: "paused",
priority: "high",
routine: expect.objectContaining({
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "enqueue_missed_with_cap",
triggers: expect.arrayContaining([
expect.objectContaining({ kind: "schedule", cronExpression: "0 9 * * 1", timezone: "America/Chicago" }),
expect.objectContaining({ kind: "webhook", enabled: false, signingMode: "hmac_sha256", replayWindowSec: 120 }),
]),
}),
}),
]);
});
it("imports recurring task packages as routines instead of one-time issues", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
projectSvc.create.mockResolvedValue({
id: "project-created",
name: "Launch",
urlKey: "launch",
});
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
const files = {
"COMPANY.md": [
"---",
'schema: "agentcompanies/v1"',
'name: "Imported Paperclip"',
"---",
"",
].join("\n"),
"agents/claudecoder/AGENTS.md": [
"---",
'name: "ClaudeCoder"',
"---",
"",
"You write code.",
"",
].join("\n"),
"projects/launch/PROJECT.md": [
"---",
'name: "Launch"',
"---",
"",
].join("\n"),
"tasks/monday-review/TASK.md": [
"---",
'name: "Monday Review"',
'project: "launch"',
'assignee: "claudecoder"',
"recurring: true",
"---",
"",
"Review pipeline health.",
"",
].join("\n"),
".paperclip.yaml": [
'schema: "paperclip/v1"',
"routines:",
" monday-review:",
' status: "paused"',
' priority: "high"',
' concurrencyPolicy: "always_enqueue"',
' catchUpPolicy: "enqueue_missed_with_cap"',
" triggers:",
" - kind: schedule",
' cronExpression: "0 9 * * 1"',
' timezone: "America/Chicago"',
' - kind: webhook',
' enabled: false',
' signingMode: "hmac_sha256"',
' replayWindowSec: 120',
"",
].join("\n"),
};
const preview = await portability.previewImport({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.plan.issuePlans).toEqual([
expect.objectContaining({
slug: "monday-review",
reason: "Recurring task will be imported as a routine.",
}),
]);
await portability.importBundle({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
projectId: "project-created",
title: "Monday Review",
assigneeAgentId: "agent-created",
priority: "high",
status: "paused",
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "enqueue_missed_with_cap",
}), expect.any(Object));
expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2);
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
kind: "schedule",
cronExpression: "0 9 * * 1",
timezone: "America/Chicago",
}), expect.any(Object));
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
kind: "webhook",
enabled: false,
signingMode: "hmac_sha256",
replayWindowSec: 120,
}), expect.any(Object));
expect(issueSvc.create).not.toHaveBeenCalled();
});
it("migrates legacy schedule.recurrence imports into routine triggers", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockResolvedValue({
id: "agent-created",
name: "ClaudeCoder",
});
projectSvc.create.mockResolvedValue({
id: "project-created",
name: "Launch",
urlKey: "launch",
});
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
const files = {
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
"agents/claudecoder/AGENTS.md": ['---', 'name: "ClaudeCoder"', "---", "", "You write code.", ""].join("\n"),
"projects/launch/PROJECT.md": ['---', 'name: "Launch"', "---", ""].join("\n"),
"tasks/monday-review/TASK.md": [
"---",
'name: "Monday Review"',
'project: "launch"',
'assignee: "claudecoder"',
"schedule:",
' timezone: "America/Chicago"',
' startsAt: "2026-03-16T09:00:00-05:00"',
" recurrence:",
' frequency: "weekly"',
" interval: 1",
" weekdays:",
' - "monday"',
"---",
"",
"Review pipeline health.",
"",
].join("\n"),
};
const preview = await portability.previewImport({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.manifest.issues[0]).toEqual(expect.objectContaining({
recurring: true,
legacyRecurrence: expect.objectContaining({ frequency: "weekly" }),
}));
await portability.importBundle({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
kind: "schedule",
cronExpression: "0 9 * * 1",
timezone: "America/Chicago",
}), expect.any(Object));
expect(issueSvc.create).not.toHaveBeenCalled();
});
it("flags recurring task imports that are missing routine-required fields", async () => {
const portability = companyPortabilityService({} as any);
const preview = await portability.previewImport({
source: {
type: "inline",
rootPath: "paperclip-demo",
files: {
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
"tasks/monday-review/TASK.md": [
"---",
'name: "Monday Review"',
"recurring: true",
"---",
"",
"Review pipeline health.",
"",
].join("\n"),
},
},
include: { company: true, agents: false, projects: false, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
collisionStrategy: "rename",
});
expect(preview.errors).toContain("Recurring task monday-review must declare a project to import as a routine.");
expect(preview.errors).toContain("Recurring task monday-review must declare an assignee to import as a routine.");
});
it("imports a vendor-neutral package without .paperclip.yaml", async () => {
const portability = companyPortabilityService({} as any);
@@ -1026,6 +1890,61 @@ describe("company portability", () => {
});
});
it("disables timer heartbeats on imported agents", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: `agent-${String(input.name).toLowerCase()}`,
name: input.name,
adapterConfig: input.adapterConfig,
runtimeConfig: input.runtimeConfig,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
}, "user-1");
const createdClaude = agentSvc.create.mock.calls.find(([, input]) => input.name === "ClaudeCoder");
expect(createdClaude?.[1]).toMatchObject({
runtimeConfig: {
heartbeat: {
enabled: false,
},
},
});
});
it("imports only selected files and leaves unchecked company metadata alone", async () => {
const portability = companyPortabilityService({} as any);
@@ -1096,6 +2015,11 @@ describe("company portability", () => {
expect(agentSvc.create).toHaveBeenCalledTimes(1);
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
name: "CMO",
runtimeConfig: {
heartbeat: {
enabled: false,
},
},
}));
expect(result.company.action).toBe("unchanged");
expect(result.agents).toEqual([

View File

@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
discoverProjectWorkspaceSkillDirectories,
findMissingLocalSkillIds,
normalizeGitHubSkillDirectory,
parseSkillImportSourceInput,
readLocalSkillImportFromDirectory,
} from "../services/company-skills.js";
@@ -86,6 +87,13 @@ describe("company skill import source parsing", () => {
});
describe("project workspace skill discovery", () => {
it("normalizes GitHub skill directories for blob imports and legacy metadata", () => {
expect(normalizeGitHubSkillDirectory("retro/.", "retro")).toBe("retro");
expect(normalizeGitHubSkillDirectory("retro/SKILL.md", "retro")).toBe("retro");
expect(normalizeGitHubSkillDirectory("SKILL.md", "root-skill")).toBe("");
expect(normalizeGitHubSkillDirectory("", "fallback-skill")).toBe("fallback-skill");
});
it("finds bounded skill roots under supported workspace paths", async () => {
const workspace = await makeTempDir("paperclip-skill-workspace-");
await writeSkillDir(workspace, "Workspace Root");

View File

@@ -79,6 +79,8 @@ export async function createApp(
const app = express();
app.use(express.json({
// Company import/export payloads can inline full portable packages.
limit: "10mb",
verify: (req, _res, buf) => {
(req as unknown as { rawBody: Buffer }).rawBody = buf;
},

View File

@@ -10,6 +10,8 @@ export interface OrgNode {
role: string;
status: string;
reports: OrgNode[];
/** Populated by collapseTree: the flattened list of hidden descendants for avatar grid rendering. */
collapsedReports?: OrgNode[];
}
export type OrgChartStyle = "monochrome" | "nebula" | "circuit" | "warmth" | "schematic";
@@ -321,6 +323,12 @@ const CARD_PAD_X = 22;
const AVATAR_SIZE = 34;
const GAP_X = 24;
const GAP_Y = 56;
// ── Collapsed avatar grid constants ─────────────────────────────
const MINI_AVATAR_SIZE = 14;
const MINI_AVATAR_GAP = 6;
const MINI_AVATAR_PADDING = 10;
const MINI_AVATAR_MAX_COLS = 8; // max avatars per row in the grid
const PADDING = 48;
const LOGO_PADDING = 16;
@@ -330,11 +338,42 @@ function measureText(text: string, fontSize: number): number {
return text.length * fontSize * 0.58;
}
/** Calculate how many rows the avatar grid needs. */
function avatarGridRows(count: number): number {
return Math.ceil(count / MINI_AVATAR_MAX_COLS);
}
/** Width needed for the avatar grid. */
function avatarGridWidth(count: number): number {
const cols = Math.min(count, MINI_AVATAR_MAX_COLS);
return cols * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP + MINI_AVATAR_PADDING * 2;
}
/** Height of the avatar grid area. */
function avatarGridHeight(count: number): number {
if (count === 0) return 0;
const rows = avatarGridRows(count);
return rows * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP + MINI_AVATAR_PADDING * 2;
}
function cardWidth(node: OrgNode): number {
const { roleLabel } = getRoleInfo(node);
const { roleLabel: defaultRoleLabel } = getRoleInfo(node);
const roleLabel = node.role.startsWith("×") ? node.role : defaultRoleLabel;
const nameW = measureText(node.name, 14) + CARD_PAD_X * 2;
const roleW = measureText(roleLabel, 11) + CARD_PAD_X * 2;
return Math.max(CARD_MIN_W, Math.max(nameW, roleW));
let w = Math.max(CARD_MIN_W, Math.max(nameW, roleW));
// Widen for avatar grid if needed
if (node.collapsedReports && node.collapsedReports.length > 0) {
w = Math.max(w, avatarGridWidth(node.collapsedReports.length));
}
return w;
}
function cardHeight(node: OrgNode): number {
if (node.collapsedReports && node.collapsedReports.length > 0) {
return CARD_H + avatarGridHeight(node.collapsedReports.length);
}
return CARD_H;
}
// ── Tree layout (top-down, centered) ─────────────────────────────
@@ -354,18 +393,19 @@ function layoutTree(node: OrgNode, x: number, y: number): LayoutNode {
const sw = subtreeWidth(node);
const cardX = x + (sw - w) / 2;
const h = cardHeight(node);
const layoutNode: LayoutNode = {
node,
x: cardX,
y,
width: w,
height: CARD_H,
height: h,
children: [],
};
if (node.reports && node.reports.length > 0) {
let childX = x;
const childY = y + CARD_H + GAP_Y;
const childY = y + h + GAP_Y;
for (let i = 0; i < node.reports.length; i++) {
const child = node.reports[i];
const childSW = subtreeWidth(child);
@@ -394,7 +434,19 @@ function renderEmojiAvatar(cx: number, cy: number, radius: number, bgFill: strin
}
function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string {
const { roleLabel, bg, emojiSvg } = getRoleInfo(ln.node);
// Overflow placeholder card: just shows "+N more" text, no avatar
if (ln.node.role === "overflow") {
const cx = ln.x + ln.width / 2;
const cy = ln.y + ln.height / 2;
return `<g>
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.bgColor}" stroke="${theme.cardBorder}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="${cx}" y="${cy + 5}" text-anchor="middle" font-family="${theme.font}" font-size="13" font-weight="600" fill="${theme.roleColor}">${escapeXml(ln.node.name)}</text>
</g>`;
}
const { roleLabel: defaultRoleLabel, bg, emojiSvg } = getRoleInfo(ln.node);
// Use node.role directly when it's a collapse badge (e.g. "×15 reports")
const roleLabel = ln.node.role.startsWith("×") ? ln.node.role : defaultRoleLabel;
const cx = ln.x + ln.width / 2;
const avatarCY = ln.y + 27;
@@ -417,12 +469,33 @@ function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string {
const avatarBg = isLight ? bg : "rgba(255,255,255,0.06)";
const avatarStroke = isLight ? undefined : "rgba(255,255,255,0.08)";
// Render collapsed avatar grid if this node has hidden reports
let avatarGridSvg = "";
const collapsed = ln.node.collapsedReports;
if (collapsed && collapsed.length > 0) {
const gridTop = ln.y + CARD_H + MINI_AVATAR_PADDING;
const cols = Math.min(collapsed.length, MINI_AVATAR_MAX_COLS);
const gridTotalW = cols * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP;
const gridStartX = ln.x + (ln.width - gridTotalW) / 2;
for (let i = 0; i < collapsed.length; i++) {
const col = i % MINI_AVATAR_MAX_COLS;
const row = Math.floor(i / MINI_AVATAR_MAX_COLS);
const dotCx = gridStartX + col * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) + MINI_AVATAR_SIZE / 2;
const dotCy = gridTop + row * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) + MINI_AVATAR_SIZE / 2;
const { bg: dotBg } = getRoleInfo(collapsed[i]);
const dotFill = isLight ? dotBg : "rgba(255,255,255,0.1)";
avatarGridSvg += `<circle cx="${dotCx}" cy="${dotCy}" r="${MINI_AVATAR_SIZE / 2}" fill="${dotFill}" stroke="${theme.cardBorder}" stroke-width="0.5"/>`;
}
}
return `<g>
${shadowDef}
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.cardBg}" stroke="${theme.cardBorder}" stroke-width="1" ${shadowFilter}/>
${renderEmojiAvatar(cx, avatarCY, AVATAR_SIZE / 2, avatarBg, emojiSvg, avatarStroke)}
<text x="${cx}" y="${nameY}" text-anchor="middle" font-family="${theme.font}" font-size="14" font-weight="600" fill="${theme.nameColor}">${escapeXml(ln.node.name)}</text>
<text x="${cx}" y="${roleY}" text-anchor="middle" font-family="${theme.font}" font-size="11" font-weight="500" fill="${theme.roleColor}">${escapeXml(roleLabel)}</text>
${avatarGridSvg}
</g>`;
}
@@ -496,19 +569,154 @@ const PAPERCLIP_LOGO_SVG = `<g>
const TARGET_W = 1280;
const TARGET_H = 640;
export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): string {
export interface OrgChartOverlay {
/** Company name displayed top-left */
companyName?: string;
/** Summary stats displayed bottom-right, e.g. "Agents: 5, Skills: 8" */
stats?: string;
}
/** Count total nodes in a tree. */
function countNodes(nodes: OrgNode[]): number {
let count = 0;
for (const n of nodes) {
count += 1 + countNodes(n.reports ?? []);
}
return count;
}
/** Threshold: auto-collapse orgs larger than this. */
const COLLAPSE_THRESHOLD = 20;
/** Max cards that can fit across the 1280px image. */
const MAX_LEVEL_WIDTH = 8;
/** Max children shown per parent before truncation with "and N more". */
const MAX_CHILDREN_SHOWN = 6;
/** Flatten all descendants of a node into a single list. */
function flattenDescendants(nodes: OrgNode[]): OrgNode[] {
const result: OrgNode[] = [];
for (const n of nodes) {
result.push(n);
result.push(...flattenDescendants(n.reports ?? []));
}
return result;
}
/** Collect all nodes at a given depth in the tree. */
function nodesAtDepth(nodes: OrgNode[], depth: number): OrgNode[] {
if (depth === 0) return nodes;
const result: OrgNode[] = [];
for (const n of nodes) {
result.push(...nodesAtDepth(n.reports ?? [], depth - 1));
}
return result;
}
/**
* Estimate how many cards would be shown at the next level if we expand,
* considering truncation (each parent shows at most MAX_CHILDREN_SHOWN + 1 placeholder).
*/
function estimateNextLevelWidth(parentNodes: OrgNode[]): number {
let total = 0;
for (const p of parentNodes) {
const childCount = (p.reports ?? []).length;
if (childCount === 0) continue;
total += Math.min(childCount, MAX_CHILDREN_SHOWN + 1); // +1 for "and N more" placeholder
}
return total;
}
/**
* Collapse a node's children to avatar dots (for wide levels that can't expand).
*/
function collapseToAvatars(node: OrgNode): OrgNode {
const childCount = countNodes(node.reports ?? []);
if (childCount === 0) return node;
return {
...node,
role: `×${childCount} reports`,
collapsedReports: flattenDescendants(node.reports ?? []),
reports: [],
};
}
/**
* Truncate a node's children: keep first MAX_CHILDREN_SHOWN, replace rest with
* a summary "and N more" placeholder node (rendered as a count card).
*/
function truncateChildren(node: OrgNode): OrgNode {
const children = node.reports ?? [];
if (children.length <= MAX_CHILDREN_SHOWN) return node;
const kept = children.slice(0, MAX_CHILDREN_SHOWN);
const hiddenCount = children.length - MAX_CHILDREN_SHOWN;
const placeholder: OrgNode = {
id: `${node.id}-more`,
name: `+${hiddenCount} more`,
role: "overflow",
status: "active",
reports: [],
};
return { ...node, reports: [...kept, placeholder] };
}
/**
* Adaptive collapse: expands levels as long as they fit, truncates or collapses
* when a level is too wide.
*/
function smartCollapseTree(roots: OrgNode[]): OrgNode[] {
// Deep clone so we can mutate
const clone = (nodes: OrgNode[]): OrgNode[] =>
nodes.map((n) => ({ ...n, reports: clone(n.reports ?? []) }));
const tree = clone(roots);
// Walk levels from root down
for (let depth = 0; depth < 10; depth++) {
const parents = nodesAtDepth(tree, depth);
const parentsWithChildren = parents.filter((p) => (p.reports ?? []).length > 0);
if (parentsWithChildren.length === 0) break;
const nextWidth = estimateNextLevelWidth(parentsWithChildren);
if (nextWidth <= MAX_LEVEL_WIDTH) {
// Next level fits with truncation — truncate oversized parents, then continue deeper
for (const p of parentsWithChildren) {
if ((p.reports ?? []).length > MAX_CHILDREN_SHOWN) {
const truncated = truncateChildren(p);
p.reports = truncated.reports;
}
}
continue;
}
// Next level is too wide — collapse all children at this level to avatars
for (const p of parentsWithChildren) {
const collapsed = collapseToAvatars(p);
p.role = collapsed.role;
p.collapsedReports = collapsed.collapsedReports;
p.reports = [];
}
break;
}
return tree;
}
export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth", overlay?: OrgChartOverlay): string {
const theme = THEMES[style] || THEMES.warmth;
// Auto-collapse large orgs to keep the chart readable
const totalNodes = countNodes(orgTree);
const effectiveTree = totalNodes > COLLAPSE_THRESHOLD ? smartCollapseTree(orgTree) : orgTree;
let root: OrgNode;
if (orgTree.length === 1) {
root = orgTree[0];
if (effectiveTree.length === 1) {
root = effectiveTree[0];
} else {
root = {
id: "virtual-root",
name: "Organization",
role: "Root",
status: "active",
reports: orgTree,
reports: effectiveTree,
};
}
@@ -529,6 +737,14 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa
const logoX = TARGET_W - 110 - LOGO_PADDING;
const logoY = LOGO_PADDING;
// Optional overlay elements
const overlayNameSvg = overlay?.companyName
? `<text x="${LOGO_PADDING}" y="${LOGO_PADDING + 16}" font-family="'Inter', -apple-system, BlinkMacSystemFont, sans-serif" font-size="22" font-weight="700" fill="${theme.nameColor}">${svgEscape(overlay.companyName)}</text>`
: "";
const overlayStatsSvg = overlay?.stats
? `<text x="${TARGET_W - LOGO_PADDING}" y="${TARGET_H - LOGO_PADDING}" text-anchor="end" font-family="'Inter', -apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="500" fill="${theme.roleColor}">${svgEscape(overlay.stats)}</text>`
: "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${TARGET_W}" height="${TARGET_H}" viewBox="0 0 ${TARGET_W} ${TARGET_H}">
<defs>${theme.defs(TARGET_W, TARGET_H)}</defs>
<rect width="100%" height="100%" fill="${theme.bgColor}" rx="6"/>
@@ -536,6 +752,8 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa
<g transform="translate(${logoX}, ${logoY})" color="${theme.watermarkColor}">
${PAPERCLIP_LOGO_SVG}
</g>
${overlayNameSvg}
${overlayStatsSvg}
<g transform="translate(${offsetX}, ${offsetY}) scale(${scale})">
${renderConnectors(layout, theme)}
${renderCards(layout, theme)}
@@ -543,8 +761,12 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa
</svg>`;
}
export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): Promise<Buffer> {
const svg = renderOrgChartSvg(orgTree, style);
function svgEscape(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth", overlay?: OrgChartOverlay): Promise<Buffer> {
const svg = renderOrgChartSvg(orgTree, style, overlay);
const sharpModule = await import("sharp");
const sharp = sharpModule.default;
// Render at 2x density for retina quality, resize to exact target dimensions

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,8 @@ type RuntimeSkillEntryOptions = {
materializeMissing?: boolean;
};
const skillInventoryRefreshPromises = new Map<string, Promise<void>>();
const PROJECT_SCAN_DIRECTORY_ROOTS = [
"skills",
"skills/.curated",
@@ -188,6 +190,18 @@ function normalizeSkillKey(value: string | null | undefined) {
return segments.length > 0 ? segments.join("/") : null;
}
export function normalizeGitHubSkillDirectory(
value: string | null | undefined,
fallback: string,
) {
const normalized = normalizePortablePath(value ?? "");
if (!normalized) return normalizePortablePath(fallback);
if (path.posix.basename(normalized).toLowerCase() === "skill.md") {
return normalizePortablePath(path.posix.dirname(normalized));
}
return normalized;
}
function hashSkillValue(value: string) {
return createHash("sha256").update(value).digest("hex").slice(0, 10);
}
@@ -1017,7 +1031,10 @@ async function readUrlSkillImports(
repo: parsed.repo,
ref: ref,
trackingRef,
repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir,
repoSkillDir: normalizeGitHubSkillDirectory(
basePrefix ? `${basePrefix}${skillDir}` : skillDir,
slug,
),
};
const inventory = filteredPaths
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
@@ -1474,8 +1491,25 @@ export function companySkillService(db: Db) {
}
async function ensureSkillInventoryCurrent(companyId: string) {
await ensureBundledSkills(companyId);
await pruneMissingLocalPathSkills(companyId);
const existingRefresh = skillInventoryRefreshPromises.get(companyId);
if (existingRefresh) {
await existingRefresh;
return;
}
const refreshPromise = (async () => {
await ensureBundledSkills(companyId);
await pruneMissingLocalPathSkills(companyId);
})();
skillInventoryRefreshPromises.set(companyId, refreshPromise);
try {
await refreshPromise;
} finally {
if (skillInventoryRefreshPromises.get(companyId) === refreshPromise) {
skillInventoryRefreshPromises.delete(companyId);
}
}
}
async function list(companyId: string): Promise<CompanySkillListItem[]> {
@@ -1646,7 +1680,7 @@ export function companySkillService(db: Db) {
const owner = asString(metadata.owner);
const repo = asString(metadata.repo);
const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main";
const repoSkillDir = normalizePortablePath(asString(metadata.repoSkillDir) ?? skill.slug);
const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug);
if (!owner || !repo) {
throw unprocessable("Skill source metadata is incomplete.");
}

View File

@@ -1,5 +1,6 @@
import type {
Company,
CompanyPortabilityExportRequest,
CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult,
CompanyPortabilityImportRequest,
@@ -37,41 +38,17 @@ export const companiesApi = {
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
exportBundle: (
companyId: string,
data: {
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
agents?: string[];
skills?: string[];
projects?: string[];
issues?: string[];
projectIssues?: string[];
selectedFiles?: string[];
},
data: CompanyPortabilityExportRequest,
) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
exportPreview: (
companyId: string,
data: {
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
agents?: string[];
skills?: string[];
projects?: string[];
issues?: string[];
projectIssues?: string[];
selectedFiles?: string[];
},
data: CompanyPortabilityExportRequest,
) =>
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
exportPackage: (
companyId: string,
data: {
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
agents?: string[];
skills?: string[];
projects?: string[];
issues?: string[];
projectIssues?: string[];
selectedFiles?: string[];
},
data: CompanyPortabilityExportRequest,
) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
importPreview: (data: CompanyPortabilityPreviewRequest) =>

View File

@@ -160,6 +160,7 @@ export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
priority: "Priority",
assignee: "Assignee",
project: "Project",
recurring: "Recurring",
targetDate: "Target date",
};

View File

@@ -6,9 +6,11 @@ import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder";
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import {
@@ -17,28 +19,6 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { Agent } from "@paperclipai/shared";
/** BFS sort: roots first (no reportsTo), then their direct reports, etc. */
function sortByHierarchy(agents: Agent[]): Agent[] {
const byId = new Map(agents.map((a) => [a.id, a]));
const childrenOf = new Map<string | null, Agent[]>();
for (const a of agents) {
const parent = a.reportsTo && byId.has(a.reportsTo) ? a.reportsTo : null;
const list = childrenOf.get(parent) ?? [];
list.push(a);
childrenOf.set(parent, list);
}
const sorted: Agent[] = [];
const queue = childrenOf.get(null) ?? [];
while (queue.length > 0) {
const agent = queue.shift()!;
sorted.push(agent);
const children = childrenOf.get(agent.id);
if (children) queue.push(...children);
}
return sorted;
}
export function SidebarAgents() {
const [open, setOpen] = useState(true);
const { selectedCompanyId } = useCompany();
@@ -51,6 +31,10 @@ export function SidebarAgents() {
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
@@ -71,8 +55,14 @@ export function SidebarAgents() {
const filtered = (agents ?? []).filter(
(a: Agent) => a.status !== "terminated"
);
return sortByHierarchy(filtered);
return filtered;
}, [agents]);
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { orderedAgents } = useAgentOrder({
agents: visibleAgents,
companyId: selectedCompanyId,
userId: currentUserId,
});
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
const activeAgentId = agentMatch?.[1] ?? null;
@@ -109,7 +99,7 @@ export function SidebarAgents() {
<CollapsibleContent>
<div className="flex flex-col gap-0.5 mt-0.5">
{visibleAgents.map((agent: Agent) => {
{orderedAgents.map((agent: Agent) => {
const runCount = liveCountByAgent.get(agent.id) ?? 0;
return (
<NavLink

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Agent } from "@paperclipai/shared";
import {
AGENT_ORDER_UPDATED_EVENT,
getAgentOrderStorageKey,
readAgentOrder,
sortAgentsByStoredOrder,
writeAgentOrder,
} from "../lib/agent-order";
type UseAgentOrderParams = {
agents: Agent[];
companyId: string | null | undefined;
userId: string | null | undefined;
};
type AgentOrderUpdatedDetail = {
storageKey: string;
orderedIds: string[];
};
function areEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
function buildOrderIds(agents: Agent[], orderedIds: string[]) {
return sortAgentsByStoredOrder(agents, orderedIds).map((agent) => agent.id);
}
export function useAgentOrder({ agents, companyId, userId }: UseAgentOrderParams) {
const storageKey = useMemo(() => {
if (!companyId) return null;
return getAgentOrderStorageKey(companyId, userId);
}, [companyId, userId]);
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (!storageKey) return agents.map((agent) => agent.id);
return buildOrderIds(agents, readAgentOrder(storageKey));
});
useEffect(() => {
const nextIds = storageKey
? buildOrderIds(agents, readAgentOrder(storageKey))
: agents.map((agent) => agent.id);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [agents, storageKey]);
useEffect(() => {
if (!storageKey) return;
const syncFromIds = (ids: string[]) => {
const nextIds = buildOrderIds(agents, ids);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
};
const onStorage = (event: StorageEvent) => {
if (event.key !== storageKey) return;
syncFromIds(readAgentOrder(storageKey));
};
const onCustomEvent = (event: Event) => {
const detail = (event as CustomEvent<AgentOrderUpdatedDetail>).detail;
if (!detail || detail.storageKey !== storageKey) return;
syncFromIds(detail.orderedIds);
};
window.addEventListener("storage", onStorage);
window.addEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent);
};
}, [agents, storageKey]);
const orderedAgents = useMemo(
() => sortAgentsByStoredOrder(agents, orderedIds),
[agents, orderedIds],
);
const persistOrder = useCallback(
(ids: string[]) => {
const idSet = new Set(agents.map((agent) => agent.id));
const filtered = ids.filter((id) => idSet.has(id));
for (const agent of sortAgentsByStoredOrder(agents, [])) {
if (!filtered.includes(agent.id)) filtered.push(agent.id);
}
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (storageKey) {
writeAgentOrder(storageKey, filtered);
}
},
[agents, storageKey],
);
return {
orderedAgents,
orderedIds,
persistOrder,
};
}

106
ui/src/lib/agent-order.ts Normal file
View File

@@ -0,0 +1,106 @@
import type { Agent } from "@paperclipai/shared";
export const AGENT_ORDER_UPDATED_EVENT = "paperclip:agent-order-updated";
const AGENT_ORDER_STORAGE_PREFIX = "paperclip.agentOrder";
const ANONYMOUS_USER_ID = "anonymous";
type AgentOrderUpdatedDetail = {
storageKey: string;
orderedIds: string[];
};
function normalizeIdList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
function resolveUserId(userId: string | null | undefined): string {
if (!userId) return ANONYMOUS_USER_ID;
const trimmed = userId.trim();
return trimmed.length > 0 ? trimmed : ANONYMOUS_USER_ID;
}
export function getAgentOrderStorageKey(companyId: string, userId: string | null | undefined): string {
return `${AGENT_ORDER_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`;
}
export function readAgentOrder(storageKey: string): string[] {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return [];
return normalizeIdList(JSON.parse(raw));
} catch {
return [];
}
}
export function writeAgentOrder(storageKey: string, orderedIds: string[]) {
const normalized = normalizeIdList(orderedIds);
try {
localStorage.setItem(storageKey, JSON.stringify(normalized));
} catch {
// Ignore storage write failures in restricted browser contexts.
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent<AgentOrderUpdatedDetail>(AGENT_ORDER_UPDATED_EVENT, {
detail: { storageKey, orderedIds: normalized },
}),
);
}
}
export function sortAgentsByDefaultSidebarOrder(agents: Agent[]): Agent[] {
if (agents.length === 0) return [];
const byId = new Map(agents.map((agent) => [agent.id, agent]));
const childrenOf = new Map<string | null, Agent[]>();
for (const agent of agents) {
const parentId = agent.reportsTo && byId.has(agent.reportsTo) ? agent.reportsTo : null;
const siblings = childrenOf.get(parentId) ?? [];
siblings.push(agent);
childrenOf.set(parentId, siblings);
}
for (const siblings of childrenOf.values()) {
siblings.sort((left, right) => left.name.localeCompare(right.name));
}
const sorted: Agent[] = [];
const queue = [...(childrenOf.get(null) ?? [])];
while (queue.length > 0) {
const agent = queue.shift();
if (!agent) continue;
sorted.push(agent);
const children = childrenOf.get(agent.id);
if (children) queue.push(...children);
}
return sorted;
}
export function sortAgentsByStoredOrder(agents: Agent[], orderedIds: string[]): Agent[] {
if (agents.length === 0) return [];
const defaultSorted = sortAgentsByDefaultSidebarOrder(agents);
if (orderedIds.length === 0) return defaultSorted;
const byId = new Map(defaultSorted.map((agent) => [agent.id, agent]));
const sorted: Agent[] = [];
for (const id of orderedIds) {
const agent = byId.get(id);
if (!agent) continue;
sorted.push(agent);
byId.delete(id);
}
for (const agent of defaultSorted) {
if (byId.has(agent.id)) {
sorted.push(agent);
byId.delete(agent.id);
}
}
return sorted;
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { buildInitialExportCheckedFiles } from "./company-export-selection";
describe("buildInitialExportCheckedFiles", () => {
it("checks non-task files and recurring task packages by default", () => {
const checked = buildInitialExportCheckedFiles(
[
"README.md",
".paperclip.yaml",
"tasks/one-off/TASK.md",
"tasks/recurring/TASK.md",
"tasks/recurring/notes.md",
],
[
{ path: "tasks/one-off/TASK.md", recurring: false },
{ path: "tasks/recurring/TASK.md", recurring: true },
],
new Set<string>(),
);
expect(Array.from(checked).sort()).toEqual([
".paperclip.yaml",
"README.md",
"tasks/recurring/TASK.md",
"tasks/recurring/notes.md",
]);
});
it("preserves previous manual selections for one-time tasks", () => {
const checked = buildInitialExportCheckedFiles(
["README.md", "tasks/one-off/TASK.md"],
[{ path: "tasks/one-off/TASK.md", recurring: false }],
new Set(["tasks/one-off/TASK.md"]),
);
expect(Array.from(checked).sort()).toEqual([
"README.md",
"tasks/one-off/TASK.md",
]);
});
});

View File

@@ -0,0 +1,56 @@
import type { CompanyPortabilityIssueManifestEntry } from "@paperclipai/shared";
function isTaskPath(filePath: string): boolean {
return /(?:^|\/)tasks\//.test(filePath);
}
function buildRecurringTaskPrefixes(
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
): Set<string> {
const prefixes = new Set<string>();
for (const issue of issues) {
if (!issue.recurring) continue;
const filePath = issue.path.trim();
if (!filePath) continue;
prefixes.add(filePath);
const lastSlash = filePath.lastIndexOf("/");
if (lastSlash >= 0) {
prefixes.add(`${filePath.slice(0, lastSlash + 1)}`);
}
}
return prefixes;
}
function isRecurringTaskFile(filePath: string, recurringTaskPrefixes: Set<string>): boolean {
for (const prefix of recurringTaskPrefixes) {
if (filePath === prefix || filePath.startsWith(prefix)) return true;
}
return false;
}
export function buildInitialExportCheckedFiles(
filePaths: string[],
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
previousCheckedFiles: Set<string>,
): Set<string> {
const next = new Set<string>();
const recurringTaskPrefixes = buildRecurringTaskPrefixes(issues);
for (const filePath of filePaths) {
if (previousCheckedFiles.has(filePath)) {
next.add(filePath);
continue;
}
if (!isTaskPath(filePath) || isRecurringTaskFile(filePath, recurringTaskPrefixes)) {
next.add(filePath);
}
}
return next;
}

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import type { Agent, Project } from "@paperclipai/shared";
import {
buildPortableAgentSlugMap,
buildPortableProjectSlugMap,
buildPortableSidebarOrder,
} from "./company-portability-sidebar";
function makeAgent(id: string, name: string): Agent {
return {
id,
companyId: "company-1",
name,
role: "engineer",
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
urlKey: name.toLowerCase(),
};
}
function makeProject(id: string, name: string): Project {
return {
id,
companyId: "company-1",
goalId: null,
urlKey: name.toLowerCase(),
name,
description: null,
status: "planned",
leadAgentId: null,
targetDate: null,
color: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
archivedAt: null,
goalIds: [],
goals: [],
primaryWorkspace: null,
workspaces: [],
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/managed",
effectiveLocalFolder: "/tmp/managed",
origin: "managed_checkout",
},
createdAt: new Date(),
updatedAt: new Date(),
};
}
describe("company portability sidebar order", () => {
it("uses the same unique slug allocation as export and preserves the requested order", () => {
const alphaOne = makeAgent("agent-1", "Alpha");
const alphaTwo = makeAgent("agent-2", "Alpha");
const beta = makeAgent("agent-3", "Beta");
const launch = makeProject("project-1", "Launch");
const launchTwo = makeProject("project-2", "Launch");
expect(Array.from(buildPortableAgentSlugMap([alphaOne, alphaTwo, beta]).entries())).toEqual([
["agent-1", "alpha"],
["agent-2", "alpha-2"],
["agent-3", "beta"],
]);
expect(Array.from(buildPortableProjectSlugMap([launch, launchTwo]).entries())).toEqual([
["project-1", "launch"],
["project-2", "launch-2"],
]);
expect(buildPortableSidebarOrder({
agents: [alphaOne, alphaTwo, beta],
orderedAgents: [beta, alphaTwo, alphaOne],
projects: [launch, launchTwo],
orderedProjects: [launchTwo, launch],
})).toEqual({
agents: ["beta", "alpha-2", "alpha"],
projects: ["launch-2", "launch"],
});
});
});

View File

@@ -0,0 +1,61 @@
import type { Agent, CompanyPortabilitySidebarOrder, Project } from "@paperclipai/shared";
import { deriveProjectUrlKey, normalizeAgentUrlKey } from "@paperclipai/shared";
function uniqueSlug(base: string, used: Set<string>) {
if (!used.has(base)) {
used.add(base);
return base;
}
let index = 2;
while (true) {
const candidate = `${base}-${index}`;
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
index += 1;
}
}
export function buildPortableAgentSlugMap(agents: Agent[]): Map<string, string> {
const usedSlugs = new Set<string>();
const byId = new Map<string, string>();
const sortedAgents = [...agents].sort((left, right) => left.name.localeCompare(right.name));
for (const agent of sortedAgents) {
const baseSlug = normalizeAgentUrlKey(agent.name) ?? "agent";
byId.set(agent.id, uniqueSlug(baseSlug, usedSlugs));
}
return byId;
}
export function buildPortableProjectSlugMap(projects: Project[]): Map<string, string> {
const usedSlugs = new Set<string>();
const byId = new Map<string, string>();
const sortedProjects = [...projects].sort((left, right) => left.name.localeCompare(right.name));
for (const project of sortedProjects) {
const baseSlug = deriveProjectUrlKey(project.name, project.name);
byId.set(project.id, uniqueSlug(baseSlug, usedSlugs));
}
return byId;
}
export function buildPortableSidebarOrder(input: {
agents: Agent[];
orderedAgents: Agent[];
projects: Project[];
orderedProjects: Project[];
}): CompanyPortabilitySidebarOrder | undefined {
const agentSlugById = buildPortableAgentSlugMap(input.agents);
const projectSlugById = buildPortableProjectSlugMap(input.projects);
const sidebar = {
agents: input.orderedAgents.map((agent) => agentSlugById.get(agent.id)).filter((slug): slug is string => Boolean(slug)),
projects: input.orderedProjects.map((project) => projectSlugById.get(project.id)).filter((slug): slug is string => Boolean(slug)),
};
return sidebar.agents.length > 0 || sidebar.projects.length > 0 ? sidebar : undefined;
}

View File

@@ -40,10 +40,10 @@ createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<CompanyProvider>
<ToastProvider>
<LiveUpdatesProvider>
<BrowserRouter>
<BrowserRouter>
<CompanyProvider>
<ToastProvider>
<LiveUpdatesProvider>
<TooltipProvider>
<BreadcrumbProvider>
<SidebarProvider>
@@ -57,10 +57,10 @@ createRoot(document.getElementById("root")!).render(
</SidebarProvider>
</BreadcrumbProvider>
</TooltipProvider>
</BrowserRouter>
</LiveUpdatesProvider>
</ToastProvider>
</CompanyProvider>
</LiveUpdatesProvider>
</ToastProvider>
</CompanyProvider>
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>

View File

@@ -1,22 +1,32 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
Agent,
CompanyPortabilityFileEntry,
CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult,
CompanyPortabilityManifest,
Project,
} from "@paperclipai/shared";
import { useNavigate, useLocation } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { projectsApi } from "../api/projects";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { MarkdownBody } from "../components/MarkdownBody";
import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { createZipArchive } from "../lib/zip";
import { buildInitialExportCheckedFiles } from "../lib/company-export-selection";
import { useAgentOrder } from "../hooks/useAgentOrder";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { buildPortableSidebarOrder } from "../lib/company-portability-sidebar";
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
import {
Download,
@@ -34,11 +44,6 @@ import {
PackageFileTree,
} from "../components/PackageFileTree";
/** Returns true if the path looks like a task file (e.g. tasks/slug/TASK.md or projects/x/tasks/slug/TASK.md) */
function isTaskPath(filePath: string): boolean {
return /(?:^|\/)tasks\//.test(filePath);
}
/**
* Extract the set of agent/project/task slugs that are "checked" based on
* which file paths are in the checked set.
@@ -50,6 +55,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
agents: Set<string>;
projects: Set<string>;
tasks: Set<string>;
routines: Set<string>;
} {
const agents = new Set<string>();
const projects = new Set<string>();
@@ -62,7 +68,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
const taskMatch = p.match(/^tasks\/([^/]+)\//);
if (taskMatch) tasks.add(taskMatch[1]);
}
return { agents, projects, tasks };
return { agents, projects, tasks, routines: new Set(tasks) };
}
/**
@@ -77,16 +83,30 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
const out: string[] = [];
// Sections whose entries are slug-keyed and should be filtered
const filterableSections = new Set(["agents", "projects", "tasks"]);
const filterableSections = new Set(["agents", "projects", "tasks", "routines"]);
const sidebarSections = new Set(["agents", "projects"]);
let currentSection: string | null = null; // top-level key (e.g. "agents")
let currentEntry: string | null = null; // slug under that section
let includeEntry = true;
let currentSidebarList: string | null = null;
let currentSidebarHeaderLine: string | null = null;
let currentSidebarBuffer: string[] = [];
// Collect entries per section so we can omit empty section headers
let sectionHeaderLine: string | null = null;
let sectionBuffer: string[] = [];
function flushSidebarSection() {
if (currentSidebarHeaderLine !== null && currentSidebarBuffer.length > 0) {
sectionBuffer.push(currentSidebarHeaderLine);
sectionBuffer.push(...currentSidebarBuffer);
}
currentSidebarHeaderLine = null;
currentSidebarBuffer = [];
}
function flushSection() {
flushSidebarSection();
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
out.push(sectionHeaderLine);
out.push(...sectionBuffer);
@@ -109,6 +129,11 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
currentSection = key;
sectionHeaderLine = line;
continue;
} else if (key === "sidebar") {
currentSection = key;
currentSidebarList = null;
sectionHeaderLine = line;
continue;
} else {
currentSection = null;
out.push(line);
@@ -116,6 +141,32 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
}
}
if (currentSection === "sidebar") {
const sidebarMatch = line.match(/^ ([\w-]+):\s*$/);
if (sidebarMatch && !line.startsWith(" ")) {
flushSidebarSection();
const sidebarKey = sidebarMatch[1];
currentSidebarList = sidebarKey && sidebarSections.has(sidebarKey) ? sidebarKey : null;
currentSidebarHeaderLine = currentSidebarList ? line : null;
continue;
}
const sidebarEntryMatch = line.match(/^ - ["']?([^"'\n]+)["']?\s*$/);
if (sidebarEntryMatch && currentSidebarList) {
const slug = sidebarEntryMatch[1];
const sectionSlugs = slugs[currentSidebarList as keyof typeof slugs];
if (slug && sectionSlugs.has(slug)) {
currentSidebarBuffer.push(line);
}
continue;
}
if (currentSidebarList) {
currentSidebarBuffer.push(line);
continue;
}
}
// Inside a filterable section
if (currentSection && filterableSections.has(currentSection)) {
// 2-space indented key = entry slug (slugs may start with digits/hyphens)
@@ -532,6 +583,20 @@ export function CompanyExport() {
const { pushToast } = useToast();
const navigate = useNavigate();
const location = useLocation();
const { data: session, isFetched: isSessionFetched } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: agents = [], isFetched: areAgentsFetched } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects = [], isFetched: areProjectsFetched } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
@@ -541,6 +606,38 @@ export function CompanyExport() {
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
const savedExpandedRef = useRef<Set<string> | null>(null);
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const visibleAgents = useMemo(
() => agents.filter((agent: Agent) => agent.status !== "terminated"),
[agents],
);
const visibleProjects = useMemo(
() => projects.filter((project: Project) => !project.archivedAt),
[projects],
);
const { orderedAgents } = useAgentOrder({
agents: visibleAgents,
companyId: selectedCompanyId,
userId: currentUserId,
});
const { orderedProjects } = useProjectOrder({
projects: visibleProjects,
companyId: selectedCompanyId,
userId: currentUserId,
});
const sidebarOrder = useMemo(
() => buildPortableSidebarOrder({
agents: visibleAgents,
orderedAgents,
projects: visibleProjects,
orderedProjects,
}),
[orderedAgents, orderedProjects, visibleAgents, visibleProjects],
);
const sidebarOrderKey = useMemo(
() => JSON.stringify(sidebarOrder ?? null),
[sidebarOrder],
);
// Navigate-aware file selection: updates state + URL without page reload.
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
@@ -584,17 +681,17 @@ export function CompanyExport() {
mutationFn: () =>
companiesApi.exportPreview(selectedCompanyId!, {
include: { company: true, agents: true, projects: true, issues: true },
sidebarOrder,
}),
onSuccess: (result) => {
setExportData(result);
setCheckedFiles((prev) => {
const next = new Set<string>();
for (const filePath of Object.keys(result.files)) {
if (prev.has(filePath)) next.add(filePath);
else if (!isTaskPath(filePath)) next.add(filePath);
}
return next;
});
setCheckedFiles((prev) =>
buildInitialExportCheckedFiles(
Object.keys(result.files),
result.manifest.issues,
prev,
),
);
// Expand top-level dirs (except tasks — collapsed by default)
const tree = buildFileTree(result.files);
const topDirs = new Set<string>();
@@ -633,6 +730,7 @@ export function CompanyExport() {
companiesApi.exportPackage(selectedCompanyId!, {
include: { company: true, agents: true, projects: true, issues: true },
selectedFiles: Array.from(checkedFiles).sort(),
sidebarOrder,
}),
onSuccess: (result) => {
const resultCheckedFiles = new Set(Object.keys(result.files));
@@ -654,10 +752,11 @@ export function CompanyExport() {
useEffect(() => {
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
if (!isSessionFetched || !areAgentsFetched || !areProjectsFetched) return;
setExportData(null);
exportPreviewMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCompanyId]);
}, [selectedCompanyId, isSessionFetched, areAgentsFetched, areProjectsFetched, sidebarOrderKey]);
const tree = useMemo(
() => (exportData ? buildFileTree(exportData.files) : []),

View File

@@ -10,9 +10,12 @@ import type {
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { agentsApi } from "../api/agents";
import { queryKeys } from "../lib/queryKeys";
import { getAgentOrderStorageKey, writeAgentOrder } from "../lib/agent-order";
import { getProjectOrderStorageKey, writeProjectOrder } from "../lib/project-order";
import { MarkdownBody } from "../components/MarkdownBody";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
@@ -342,6 +345,45 @@ function prefixedName(prefix: string | null, originalName: string): string {
return `${prefix}-${originalName}`;
}
function applyImportedSidebarOrder(
preview: CompanyPortabilityPreviewResult | null,
result: {
company: { id: string };
agents: Array<{ slug: string; id: string | null }>;
projects: Array<{ slug: string; id: string | null }>;
},
userId: string | null | undefined,
) {
const sidebar = preview?.manifest.sidebar;
if (!sidebar) return;
if (!userId?.trim()) return;
const agentIdBySlug = new Map(
result.agents
.filter((agent): agent is { slug: string; id: string } => typeof agent.id === "string" && agent.id.length > 0)
.map((agent) => [agent.slug, agent.id]),
);
const projectIdBySlug = new Map(
result.projects
.filter((project): project is { slug: string; id: string } => typeof project.id === "string" && project.id.length > 0)
.map((project) => [project.slug, project.id]),
);
const orderedAgentIds = sidebar.agents
.map((slug) => agentIdBySlug.get(slug))
.filter((id): id is string => Boolean(id));
const orderedProjectIds = sidebar.projects
.map((slug) => projectIdBySlug.get(slug))
.filter((id): id is string => Boolean(id));
if (orderedAgentIds.length > 0) {
writeAgentOrder(getAgentOrderStorageKey(result.company.id, userId), orderedAgentIds);
}
if (orderedProjectIds.length > 0) {
writeProjectOrder(getProjectOrderStorageKey(result.company.id, userId), orderedProjectIds);
}
}
// ── Conflict resolution UI ───────────────────────────────────────────
function ConflictResolutionList({
@@ -611,6 +653,11 @@ export function CompanyImport() {
const { pushToast } = useToast();
const queryClient = useQueryClient();
const packageInputRef = useRef<HTMLInputElement | null>(null);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
// Source state
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
@@ -800,6 +847,18 @@ export function CompanyImport() {
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
const importedCompany = await companiesApi.get(result.company.id);
const refreshedSession = currentUserId
? null
: await queryClient.fetchQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const sidebarOrderUserId =
currentUserId
?? refreshedSession?.user?.id
?? refreshedSession?.session?.userId
?? null;
applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
setSelectedCompanyId(importedCompany.id);
pushToast({
tone: "success",