Address Greptile review on portability PR

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-23 17:23:59 -05:00
parent a3f568dec7
commit 92c29f27c3
7 changed files with 129 additions and 185 deletions

View File

@@ -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<void>;
@@ -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<string, string>) {
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<string, s
}
}
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;
}
async function stopServerProcess(child: ServerProcess | null) {
if (!child || child.exitCode !== null) return;
child.kill("SIGTERM");

View File

@@ -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<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;
}
import { createStoredZipArchive } from "./helpers/zip.js";
const tempDirs: string[] = [];

View File

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

View File

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

View File

@@ -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("");

View File

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

View File

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