mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Merge pull request #1655 from paperclipai/pr/pap-795-company-portability
feat(portability): improve company import and export flow
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
44
cli/src/__tests__/company-import-zip.test.ts
Normal file
44
cli/src/__tests__/company-import-zip.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
87
cli/src/__tests__/helpers/zip.ts
Normal file
87
cli/src/__tests__/helpers/zip.ts
Normal 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;
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
129
cli/src/commands/client/zip.ts
Normal file
129
cli/src/commands/client/zip.ts
Normal 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 };
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
203
docs/guides/board-operator/importing-and-exporting.md
Normal file
203
docs/guides/board-operator/importing-and-exporting.md
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -144,9 +144,13 @@ export type {
|
||||
CompanyPortabilityEnvInput,
|
||||
CompanyPortabilityFileEntry,
|
||||
CompanyPortabilityCompanyManifestEntry,
|
||||
CompanyPortabilitySidebarOrder,
|
||||
CompanyPortabilityAgentManifestEntry,
|
||||
CompanyPortabilitySkillManifestEntry,
|
||||
CompanyPortabilityProjectManifestEntry,
|
||||
CompanyPortabilityProjectWorkspaceManifestEntry,
|
||||
CompanyPortabilityIssueRoutineTriggerManifestEntry,
|
||||
CompanyPortabilityIssueRoutineManifestEntry,
|
||||
CompanyPortabilityIssueManifestEntry,
|
||||
CompanyPortabilityManifest,
|
||||
CompanyPortabilityExportResult,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -60,6 +60,7 @@ export {
|
||||
portabilityIncludeSchema,
|
||||
portabilityEnvInputSchema,
|
||||
portabilityCompanyManifestEntrySchema,
|
||||
portabilitySidebarOrderSchema,
|
||||
portabilityAgentManifestEntrySchema,
|
||||
portabilitySkillManifestEntrySchema,
|
||||
portabilityManifestSchema,
|
||||
|
||||
364
scripts/generate-company-assets.ts
Normal file
364
scripts/generate-company-assets.ts
Normal 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);
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -160,6 +160,7 @@ export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
||||
priority: "Priority",
|
||||
assignee: "Assignee",
|
||||
project: "Project",
|
||||
recurring: "Recurring",
|
||||
targetDate: "Target date",
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
104
ui/src/hooks/useAgentOrder.ts
Normal file
104
ui/src/hooks/useAgentOrder.ts
Normal 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
106
ui/src/lib/agent-order.ts
Normal 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;
|
||||
}
|
||||
41
ui/src/lib/company-export-selection.test.ts
Normal file
41
ui/src/lib/company-export-selection.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
56
ui/src/lib/company-export-selection.ts
Normal file
56
ui/src/lib/company-export-selection.ts
Normal 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;
|
||||
}
|
||||
100
ui/src/lib/company-portability-sidebar.test.ts
Normal file
100
ui/src/lib/company-portability-sidebar.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
61
ui/src/lib/company-portability-sidebar.ts
Normal file
61
ui/src/lib/company-portability-sidebar.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) : []),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user