diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 56171c71..82f1f1ca 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -6,6 +6,7 @@ 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; @@ -182,29 +183,6 @@ function createCliEnv() { return env; } -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; -} - function collectTextFiles(root: string, current: string, files: Record) { for (const entry of readdirSync(current, { withFileTypes: true })) { const absolutePath = path.join(current, entry.name); @@ -218,71 +196,6 @@ function collectTextFiles(root: string, current: string, files: Record, 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; -} - async function stopServerProcess(child: ServerProcess | null) { if (!child || child.exitCode !== null) return; child.kill("SIGTERM"); diff --git a/cli/src/__tests__/company-import-zip.test.ts b/cli/src/__tests__/company-import-zip.test.ts index 0c48a24a..e2983e9a 100644 --- a/cli/src/__tests__/company-import-zip.test.ts +++ b/cli/src/__tests__/company-import-zip.test.ts @@ -3,96 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { resolveInlineSourceFromPath } from "../commands/client/company.js"; - -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; -} - -function createStoredZipArchive(files: Record, 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; -} +import { createStoredZipArchive } from "./helpers/zip.js"; const tempDirs: string[] = []; diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index 2345fb7d..d74674b2 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -304,7 +304,11 @@ describe("renderCompanyImportResult", () => { { slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" }, { slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" }, ], - projects: [], + 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"], }, @@ -318,7 +322,9 @@ describe("renderCompanyImportResult", () => { 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"); }); diff --git a/cli/src/__tests__/helpers/zip.ts b/cli/src/__tests__/helpers/zip.ts new file mode 100644 index 00000000..ef79b5be --- /dev/null +++ b/cli/src/__tests__/helpers/zip.ts @@ -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, 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; +} diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index ca0c3b92..4f8268ac 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -536,6 +536,18 @@ function summarizeImportAgentResults(agents: CompanyPortabilityImportResult["age 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": @@ -661,6 +673,7 @@ export function renderCompanyImportResult( `${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) { @@ -676,6 +689,15 @@ export function renderCompanyImportResult( 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(""); diff --git a/docs/guides/board-operator/importing-and-exporting.md b/docs/guides/board-operator/importing-and-exporting.md index 9923b0ae..02c8cc13 100644 --- a/docs/guides/board-operator/importing-and-exporting.md +++ b/docs/guides/board-operator/importing-and-exporting.md @@ -13,8 +13,8 @@ Exported packages follow the [Agent Companies specification](/companies/companie my-company/ ├── COMPANY.md # Company metadata ├── agents/ -│ ├── ceo/AGENTS.md # Agent instructions + frontmatter -│ └── cto/AGENTS.md +│ ├── ceo/AGENT.md # Agent instructions + frontmatter +│ └── cto/AGENT.md ├── projects/ │ └── main/PROJECT.md ├── skills/ @@ -25,7 +25,7 @@ my-company/ ``` - **COMPANY.md** defines company name, description, and metadata. -- **AGENTS.md** files contain agent identity, role, and instructions. +- **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. diff --git a/scripts/generate-company-assets.ts b/scripts/generate-company-assets.ts index ba5dd6e8..46c6abc7 100644 --- a/scripts/generate-company-assets.ts +++ b/scripts/generate-company-assets.ts @@ -149,8 +149,13 @@ function parseCompanyPackage(companyDir: string): CompanyPackage | null { const agents: CompanyPortabilityManifest["agents"] = []; if (fs.existsSync(agentsDir)) { for (const agentSlug of fs.readdirSync(agentsDir)) { - const agentMdPath = path.join(agentsDir, agentSlug, "AGENTS.md"); - if (!fs.existsSync(agentMdPath)) continue; + 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); @@ -164,7 +169,7 @@ function parseCompanyPackage(companyDir: string): CompanyPackage | null { agents.push({ slug: agentSlug, name: agentName, - path: `agents/${agentSlug}/AGENTS.md`, + path: `agents/${agentSlug}/${agentMdName}`, skills, role, title,