From d8b408625e1f1c3a91e9e095a4df82fb31cecece Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 10:07:53 -0500 Subject: [PATCH 01/22] fix providers --- ui/src/main.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 5486bd12..1292810d 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -40,10 +40,10 @@ createRoot(document.getElementById("root")!).render( - - - - + + + + @@ -57,10 +57,10 @@ createRoot(document.getElementById("root")!).render( - - - - + + + + From 8fa4b6a5fb1c43287d7583a4188f0d4cf9541d40 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 10:08:11 -0500 Subject: [PATCH 02/22] added a script to generate company assets --- scripts/generate-company-assets.ts | 355 +++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 scripts/generate-company-assets.ts diff --git a/scripts/generate-company-assets.ts b/scripts/generate-company-assets.ts new file mode 100644 index 00000000..b5d4e817 --- /dev/null +++ b/scripts/generate-company-assets.ts @@ -0,0 +1,355 @@ +#!/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 } 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; 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 = {}; + + 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 = { + 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 agentMdPath = path.join(agentsDir, agentSlug, "AGENTS.md"); + if (!fs.existsSync(agentMdPath)) continue; + + 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}/AGENTS.md`, + 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 | 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 = { + 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(); + 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 "); + 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 pngBuffer = await renderOrgChartPng(orgTree, "warmth"); + 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); +}); From 2e76a2a5541e260ebaaad3d77a58c192704ca6e1 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 11:14:01 -0500 Subject: [PATCH 03/22] Add routine support to recurring task portability Co-Authored-By: Paperclip --- doc/AGENTCOMPANIES_SPEC_INVENTORY.md | 7 +- doc/SPEC-implementation.md | 5 +- docs/companies/companies-spec.md | 86 +- packages/shared/src/index.ts | 3 + .../shared/src/types/company-portability.ts | 36 +- packages/shared/src/types/index.ts | 3 + .../src/validators/company-portability.ts | 34 +- .../src/__tests__/company-portability.test.ts | 614 +++++++++++++ server/src/services/company-portability.ts | 812 +++++++++++++++++- ui/src/components/PackageFileTree.tsx | 1 + ui/src/pages/CompanyExport.tsx | 5 +- 11 files changed, 1520 insertions(+), 86 deletions(-) diff --git a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md index a3376a89..99de314d 100644 --- a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md +++ b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md @@ -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) | diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index fd2c4842..21406e18 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -860,11 +860,14 @@ 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 supports collision strategies: `rename`, `skip`, `replace` - import supports preview (dry-run) before apply - GitHub imports warn on unpinned refs instead of blocking diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md index 17fa8ef3..5f1327db 100644 --- a/docs/companies/companies-spec.md +++ b/docs/companies/companies-spec.md @@ -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: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6a105d79..85494986 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -256,6 +256,9 @@ export type { CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, + CompanyPortabilityProjectWorkspaceManifestEntry, + CompanyPortabilityIssueRoutineTriggerManifestEntry, + CompanyPortabilityIssueRoutineManifestEntry, CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 26088831..26400c57 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -44,18 +44,52 @@ export interface CompanyPortabilityProjectManifestEntry { color: string | null; status: string | null; executionWorkspacePolicy: Record | null; + workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[]; metadata: Record | 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 | 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 | null; + recurring: boolean; + routine: CompanyPortabilityIssueRoutineManifestEntry | null; + legacyRecurrence: Record | null; status: string | null; priority: string | null; labelIds: string[]; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e6ae5202..028ae787 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -147,6 +147,9 @@ export type { CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, + CompanyPortabilityProjectWorkspaceManifestEntry, + CompanyPortabilityIssueRoutineTriggerManifestEntry, + CompanyPortabilityIssueRoutineManifestEntry, CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index cae50e89..72359bb8 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -85,18 +85,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([]), diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 0e593369..5cf46fa2 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -25,6 +25,8 @@ const projectSvc = { list: vi.fn(), create: vi.fn(), update: vi.fn(), + createWorkspace: vi.fn(), + listWorkspaces: vi.fn(), }; const issueSvc = { @@ -34,6 +36,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 +80,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 +197,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) => ({ + 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) => ({ + 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", @@ -599,6 +665,200 @@ 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) => ({ + id: projectId, + name: "Launch", + urlKey: "launch", + ...data, + })); + projectSvc.createWorkspace.mockImplementation(async (projectId: string, data: Record) => ({ + 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 | 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("reads env inputs back from .paperclip.yaml during preview import", async () => { const portability = companyPortabilityService({} as any); @@ -654,6 +914,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); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 704085dd..19690dc3 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -20,6 +20,9 @@ import type { CompanyPortabilityPreviewAgentPlan, CompanyPortabilityPreviewResult, CompanyPortabilityProjectManifestEntry, + CompanyPortabilityProjectWorkspaceManifestEntry, + CompanyPortabilityIssueRoutineManifestEntry, + CompanyPortabilityIssueRoutineTriggerManifestEntry, CompanyPortabilityIssueManifestEntry, CompanyPortabilitySkillManifestEntry, CompanySkill, @@ -28,6 +31,11 @@ import { ISSUE_PRIORITIES, ISSUE_STATUSES, PROJECT_STATUSES, + ROUTINE_CATCH_UP_POLICIES, + ROUTINE_CONCURRENCY_POLICIES, + ROUTINE_STATUSES, + ROUTINE_TRIGGER_KINDS, + ROUTINE_TRIGGER_SIGNING_MODES, deriveProjectUrlKey, normalizeAgentUrlKey, } from "@paperclipai/shared"; @@ -45,8 +53,10 @@ import { generateReadme } from "./company-export-readme.js"; import { renderOrgChartPng, type OrgNode } from "../routes/org-chart-svg.js"; import { companySkillService } from "./company-skills.js"; import { companyService } from "./companies.js"; +import { validateCron } from "./cron.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; +import { routineService } from "./routines.js"; /** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */ function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] { @@ -395,6 +405,7 @@ type PaperclipExtensionDoc = { agents?: Record> | null; projects?: Record> | null; tasks?: Record> | null; + routines?: Record> | null; }; type ProjectLike = { @@ -406,6 +417,20 @@ type ProjectLike = { color: string | null; status: string; executionWorkspacePolicy: Record | null; + workspaces?: Array<{ + id: string; + name: string; + sourceType: string; + cwd: string | null; + repoUrl: string | null; + repoRef: string | null; + defaultRef: string | null; + visibility: string; + setupCommand: string | null; + cleanupCommand: string | null; + metadata?: Record | null; + isPrimary: boolean; + }>; metadata?: Record | null; }; @@ -415,6 +440,7 @@ type IssueLike = { title: string; description: string | null; projectId: string | null; + projectWorkspaceId: string | null; assigneeAgentId: string | null; status: string; priority: string; @@ -424,6 +450,8 @@ type IssueLike = { assigneeAdapterOverrides: Record | null; }; +type RoutineLike = NonNullable["getDetail"]>>>; + type ImportPlanInternal = { preview: CompanyPortabilityPreviewResult; source: ResolvedSource; @@ -515,6 +543,506 @@ function asString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function asBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function asInteger(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) ? value : null; +} + +function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIssueRoutineTriggerManifestEntry | null { + if (!isPlainRecord(value)) return null; + const kind = asString(value.kind); + if (!kind) return null; + return { + kind, + label: asString(value.label), + enabled: asBoolean(value.enabled) ?? true, + cronExpression: asString(value.cronExpression), + timezone: asString(value.timezone), + signingMode: asString(value.signingMode), + replayWindowSec: asInteger(value.replayWindowSec), + }; +} + +function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRoutineManifestEntry | null { + if (!isPlainRecord(value)) return null; + const triggers = Array.isArray(value.triggers) + ? value.triggers + .map((entry) => normalizeRoutineTriggerExtension(entry)) + .filter((entry): entry is CompanyPortabilityIssueRoutineTriggerManifestEntry => entry !== null) + : []; + const routine = { + concurrencyPolicy: asString(value.concurrencyPolicy), + catchUpPolicy: asString(value.catchUpPolicy), + triggers, + }; + return stripEmptyValues(routine) ? routine : null; +} + +function buildRoutineManifestFromLiveRoutine(routine: RoutineLike): CompanyPortabilityIssueRoutineManifestEntry { + return { + concurrencyPolicy: routine.concurrencyPolicy, + catchUpPolicy: routine.catchUpPolicy, + triggers: routine.triggers.map((trigger) => ({ + kind: trigger.kind, + label: trigger.label ?? null, + enabled: Boolean(trigger.enabled), + cronExpression: trigger.kind === "schedule" ? trigger.cronExpression ?? null : null, + timezone: trigger.kind === "schedule" ? trigger.timezone ?? null : null, + signingMode: trigger.kind === "webhook" ? trigger.signingMode ?? null : null, + replayWindowSec: trigger.kind === "webhook" ? trigger.replayWindowSec ?? null : null, + })), + }; +} + +function containsAbsolutePathFragment(value: string) { + return /(^|\s)(\/[^/\s]|[A-Za-z]:[\\/])/.test(value); +} + +function containsSystemDependentPathValue(value: unknown): boolean { + if (typeof value === "string") { + return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || containsAbsolutePathFragment(value); + } + if (Array.isArray(value)) { + return value.some((entry) => containsSystemDependentPathValue(entry)); + } + if (isPlainRecord(value)) { + return Object.values(value).some((entry) => containsSystemDependentPathValue(entry)); + } + return false; +} + +function clonePortableRecord(value: unknown) { + if (!isPlainRecord(value)) return null; + return structuredClone(value) as Record; +} + +function normalizePortableProjectWorkspaceExtension( + workspaceKey: string, + value: unknown, +): CompanyPortabilityProjectWorkspaceManifestEntry | null { + if (!isPlainRecord(value)) return null; + const normalizedKey = normalizeAgentUrlKey(workspaceKey) ?? workspaceKey.trim(); + if (!normalizedKey) return null; + return { + key: normalizedKey, + name: asString(value.name) ?? normalizedKey, + sourceType: asString(value.sourceType), + repoUrl: asString(value.repoUrl), + repoRef: asString(value.repoRef), + defaultRef: asString(value.defaultRef), + visibility: asString(value.visibility), + setupCommand: asString(value.setupCommand), + cleanupCommand: asString(value.cleanupCommand), + metadata: isPlainRecord(value.metadata) ? value.metadata : null, + isPrimary: asBoolean(value.isPrimary) ?? false, + }; +} + +function derivePortableProjectWorkspaceKey( + workspace: NonNullable[number], + usedKeys: Set, +) { + const baseKey = + normalizeAgentUrlKey(workspace.name) + ?? normalizeAgentUrlKey(asString(workspace.repoUrl)?.split("/").pop()?.replace(/\.git$/i, "") ?? "") + ?? "workspace"; + return uniqueSlug(baseKey, usedKeys); +} + +function exportPortableProjectExecutionWorkspacePolicy( + projectSlug: string, + policy: unknown, + workspaceKeyById: Map, + warnings: string[], +) { + const next = clonePortableRecord(policy); + if (!next) return null; + const defaultWorkspaceId = asString(next.defaultProjectWorkspaceId); + if (defaultWorkspaceId) { + const defaultWorkspaceKey = workspaceKeyById.get(defaultWorkspaceId); + if (defaultWorkspaceKey) { + next.defaultProjectWorkspaceKey = defaultWorkspaceKey; + } else { + warnings.push(`Project ${projectSlug} default workspace ${defaultWorkspaceId} was omitted from export because that workspace is not portable.`); + } + delete next.defaultProjectWorkspaceId; + } + const cleaned = stripEmptyValues(next); + return isPlainRecord(cleaned) ? cleaned : null; +} + +function importPortableProjectExecutionWorkspacePolicy( + projectSlug: string, + policy: Record | null | undefined, + workspaceIdByKey: Map, + warnings: string[], +) { + const next = clonePortableRecord(policy); + if (!next) return null; + const defaultWorkspaceKey = asString(next.defaultProjectWorkspaceKey); + if (defaultWorkspaceKey) { + const defaultWorkspaceId = workspaceIdByKey.get(defaultWorkspaceKey); + if (defaultWorkspaceId) { + next.defaultProjectWorkspaceId = defaultWorkspaceId; + } else { + warnings.push(`Project ${projectSlug} references missing workspace key ${defaultWorkspaceKey}; imported execution workspace policy without a default workspace.`); + } + } + delete next.defaultProjectWorkspaceKey; + const cleaned = stripEmptyValues(next); + return isPlainRecord(cleaned) ? cleaned : null; +} + +function stripPortableProjectExecutionWorkspaceRefs(policy: Record | null | undefined) { + const next = clonePortableRecord(policy); + if (!next) return null; + delete next.defaultProjectWorkspaceId; + delete next.defaultProjectWorkspaceKey; + const cleaned = stripEmptyValues(next); + return isPlainRecord(cleaned) ? cleaned : null; +} + +function buildPortableProjectWorkspaces( + projectSlug: string, + workspaces: ProjectLike["workspaces"] | undefined, + warnings: string[], +) { + const exportedWorkspaces: Record> = {}; + const manifestWorkspaces: CompanyPortabilityProjectWorkspaceManifestEntry[] = []; + const workspaceKeyById = new Map(); + const usedKeys = new Set(); + + for (const workspace of workspaces ?? []) { + const repoUrl = asString(workspace.repoUrl); + if (!repoUrl) { + warnings.push(`Project ${projectSlug} workspace ${workspace.name} was omitted from export because it does not have a portable repoUrl.`); + continue; + } + + const workspaceKey = derivePortableProjectWorkspaceKey(workspace, usedKeys); + workspaceKeyById.set(workspace.id, workspaceKey); + + let setupCommand = asString(workspace.setupCommand); + if (setupCommand && containsAbsolutePathFragment(setupCommand)) { + warnings.push(`Project ${projectSlug} workspace ${workspaceKey} setupCommand was omitted from export because it is system-dependent.`); + setupCommand = null; + } + + let cleanupCommand = asString(workspace.cleanupCommand); + if (cleanupCommand && containsAbsolutePathFragment(cleanupCommand)) { + warnings.push(`Project ${projectSlug} workspace ${workspaceKey} cleanupCommand was omitted from export because it is system-dependent.`); + cleanupCommand = null; + } + + const metadata = isPlainRecord(workspace.metadata) && !containsSystemDependentPathValue(workspace.metadata) + ? workspace.metadata + : null; + if (isPlainRecord(workspace.metadata) && metadata == null) { + warnings.push(`Project ${projectSlug} workspace ${workspaceKey} metadata was omitted from export because it contains system-dependent paths.`); + } + + const portableWorkspace = stripEmptyValues({ + name: workspace.name, + sourceType: workspace.sourceType, + repoUrl, + repoRef: asString(workspace.repoRef), + defaultRef: asString(workspace.defaultRef), + visibility: asString(workspace.visibility), + setupCommand, + cleanupCommand, + metadata, + isPrimary: workspace.isPrimary ? true : undefined, + }); + if (!isPlainRecord(portableWorkspace)) continue; + + exportedWorkspaces[workspaceKey] = portableWorkspace; + manifestWorkspaces.push({ + key: workspaceKey, + name: workspace.name, + sourceType: asString(workspace.sourceType), + repoUrl, + repoRef: asString(workspace.repoRef), + defaultRef: asString(workspace.defaultRef), + visibility: asString(workspace.visibility), + setupCommand, + cleanupCommand, + metadata, + isPrimary: workspace.isPrimary, + }); + } + + return { + extension: Object.keys(exportedWorkspaces).length > 0 ? exportedWorkspaces : undefined, + manifest: manifestWorkspaces, + workspaceKeyById, + }; +} + +const WEEKDAY_TO_CRON: Record = { + sunday: "0", + monday: "1", + tuesday: "2", + wednesday: "3", + thursday: "4", + friday: "5", + saturday: "6", +}; + +function readZonedDateParts(startsAt: string, timeZone: string) { + try { + const date = new Date(startsAt); + if (Number.isNaN(date.getTime())) return null; + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + weekday: "long", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + }); + const parts = Object.fromEntries( + formatter + .formatToParts(date) + .filter((entry) => entry.type !== "literal") + .map((entry) => [entry.type, entry.value]), + ) as Record; + const weekday = WEEKDAY_TO_CRON[parts.weekday?.toLowerCase() ?? ""]; + const month = Number(parts.month); + const day = Number(parts.day); + const hour = Number(parts.hour); + const minute = Number(parts.minute); + if (!weekday || !Number.isFinite(month) || !Number.isFinite(day) || !Number.isFinite(hour) || !Number.isFinite(minute)) { + return null; + } + return { weekday, month, day, hour, minute }; + } catch { + return null; + } +} + +function normalizeCronList(values: string[]) { + return Array.from(new Set(values)).sort((left, right) => Number(left) - Number(right)).join(","); +} + +function buildLegacyRoutineTriggerFromRecurrence( + issue: Pick, + scheduleValue: unknown, +) { + const warnings: string[] = []; + const errors: string[] = []; + if (!issue.legacyRecurrence || !isPlainRecord(issue.legacyRecurrence)) { + return { trigger: null, warnings, errors }; + } + + const schedule = isPlainRecord(scheduleValue) ? scheduleValue : null; + const frequency = asString(issue.legacyRecurrence.frequency); + const interval = asInteger(issue.legacyRecurrence.interval) ?? 1; + if (!frequency) { + errors.push(`Recurring task ${issue.slug} uses legacy recurrence without frequency; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + if (interval < 1) { + errors.push(`Recurring task ${issue.slug} uses legacy recurrence with an invalid interval; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + + const timezone = asString(schedule?.timezone) ?? "UTC"; + const startsAt = asString(schedule?.startsAt); + const zonedStartsAt = startsAt ? readZonedDateParts(startsAt, timezone) : null; + if (startsAt && !zonedStartsAt) { + errors.push(`Recurring task ${issue.slug} has an invalid legacy startsAt/timezone combination; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + + const time = isPlainRecord(issue.legacyRecurrence.time) ? issue.legacyRecurrence.time : null; + const hour = asInteger(time?.hour) ?? zonedStartsAt?.hour ?? 0; + const minute = asInteger(time?.minute) ?? zonedStartsAt?.minute ?? 0; + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + errors.push(`Recurring task ${issue.slug} uses legacy recurrence with an invalid time; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + + if (issue.legacyRecurrence.until != null || issue.legacyRecurrence.count != null) { + warnings.push(`Recurring task ${issue.slug} uses legacy recurrence end bounds; Paperclip will import the routine trigger without those limits.`); + } + + let cronExpression: string | null = null; + + if (frequency === "hourly") { + const hourField = interval === 1 + ? "*" + : zonedStartsAt + ? `${zonedStartsAt.hour}-23/${interval}` + : `*/${interval}`; + cronExpression = `${minute} ${hourField} * * *`; + } else if (frequency === "daily") { + if (Array.isArray(issue.legacyRecurrence.weekdays) || Array.isArray(issue.legacyRecurrence.monthDays) || Array.isArray(issue.legacyRecurrence.months)) { + errors.push(`Recurring task ${issue.slug} uses unsupported legacy daily recurrence constraints; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const dayField = interval === 1 ? "*" : `*/${interval}`; + cronExpression = `${minute} ${hour} ${dayField} * *`; + } else if (frequency === "weekly") { + if (interval !== 1) { + errors.push(`Recurring task ${issue.slug} uses legacy weekly recurrence with interval > 1; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const weekdays = Array.isArray(issue.legacyRecurrence.weekdays) + ? issue.legacyRecurrence.weekdays + .map((entry) => asString(entry)) + .filter((entry): entry is string => Boolean(entry)) + : []; + const cronWeekdays = weekdays + .map((entry) => WEEKDAY_TO_CRON[entry.toLowerCase()]) + .filter((entry): entry is string => Boolean(entry)); + if (cronWeekdays.length === 0 && zonedStartsAt?.weekday) { + cronWeekdays.push(zonedStartsAt.weekday); + } + if (cronWeekdays.length === 0) { + errors.push(`Recurring task ${issue.slug} uses legacy weekly recurrence without weekdays; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + cronExpression = `${minute} ${hour} * * ${normalizeCronList(cronWeekdays)}`; + } else if (frequency === "monthly") { + if (interval !== 1) { + errors.push(`Recurring task ${issue.slug} uses legacy monthly recurrence with interval > 1; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + if (Array.isArray(issue.legacyRecurrence.ordinalWeekdays) && issue.legacyRecurrence.ordinalWeekdays.length > 0) { + errors.push(`Recurring task ${issue.slug} uses legacy ordinal monthly recurrence; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const monthDays = Array.isArray(issue.legacyRecurrence.monthDays) + ? issue.legacyRecurrence.monthDays + .map((entry) => asInteger(entry)) + .filter((entry): entry is number => entry != null && entry >= 1 && entry <= 31) + : []; + if (monthDays.length === 0 && zonedStartsAt?.day) { + monthDays.push(zonedStartsAt.day); + } + if (monthDays.length === 0) { + errors.push(`Recurring task ${issue.slug} uses legacy monthly recurrence without monthDays; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const months = Array.isArray(issue.legacyRecurrence.months) + ? issue.legacyRecurrence.months + .map((entry) => asInteger(entry)) + .filter((entry): entry is number => entry != null && entry >= 1 && entry <= 12) + : []; + const monthField = months.length > 0 ? normalizeCronList(months.map(String)) : "*"; + cronExpression = `${minute} ${hour} ${normalizeCronList(monthDays.map(String))} ${monthField} *`; + } else if (frequency === "yearly") { + if (interval !== 1) { + errors.push(`Recurring task ${issue.slug} uses legacy yearly recurrence with interval > 1; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const months = Array.isArray(issue.legacyRecurrence.months) + ? issue.legacyRecurrence.months + .map((entry) => asInteger(entry)) + .filter((entry): entry is number => entry != null && entry >= 1 && entry <= 12) + : []; + if (months.length === 0 && zonedStartsAt?.month) { + months.push(zonedStartsAt.month); + } + const monthDays = Array.isArray(issue.legacyRecurrence.monthDays) + ? issue.legacyRecurrence.monthDays + .map((entry) => asInteger(entry)) + .filter((entry): entry is number => entry != null && entry >= 1 && entry <= 31) + : []; + if (monthDays.length === 0 && zonedStartsAt?.day) { + monthDays.push(zonedStartsAt.day); + } + if (months.length === 0 || monthDays.length === 0) { + errors.push(`Recurring task ${issue.slug} uses legacy yearly recurrence without month/monthDay anchors; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + cronExpression = `${minute} ${hour} ${normalizeCronList(monthDays.map(String))} ${normalizeCronList(months.map(String))} *`; + } else { + errors.push(`Recurring task ${issue.slug} uses unsupported legacy recurrence frequency "${frequency}"; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + + return { + trigger: { + kind: "schedule", + label: "Migrated legacy recurrence", + enabled: true, + cronExpression, + timezone, + signingMode: null, + replayWindowSec: null, + } satisfies CompanyPortabilityIssueRoutineTriggerManifestEntry, + warnings, + errors, + }; +} + +function resolvePortableRoutineDefinition( + issue: Pick, + scheduleValue: unknown, +) { + const warnings: string[] = []; + const errors: string[] = []; + if (!issue.recurring) { + return { routine: null, warnings, errors }; + } + + const routine = issue.routine + ? { + concurrencyPolicy: issue.routine.concurrencyPolicy, + catchUpPolicy: issue.routine.catchUpPolicy, + triggers: [...issue.routine.triggers], + } + : { + concurrencyPolicy: null, + catchUpPolicy: null, + triggers: [] as CompanyPortabilityIssueRoutineTriggerManifestEntry[], + }; + + if (routine.concurrencyPolicy && !ROUTINE_CONCURRENCY_POLICIES.includes(routine.concurrencyPolicy as any)) { + errors.push(`Recurring task ${issue.slug} uses unsupported routine concurrencyPolicy "${routine.concurrencyPolicy}".`); + } + if (routine.catchUpPolicy && !ROUTINE_CATCH_UP_POLICIES.includes(routine.catchUpPolicy as any)) { + errors.push(`Recurring task ${issue.slug} uses unsupported routine catchUpPolicy "${routine.catchUpPolicy}".`); + } + + for (const trigger of routine.triggers) { + if (!ROUTINE_TRIGGER_KINDS.includes(trigger.kind as any)) { + errors.push(`Recurring task ${issue.slug} uses unsupported trigger kind "${trigger.kind}".`); + continue; + } + if (trigger.kind === "schedule") { + if (!trigger.cronExpression || !trigger.timezone) { + errors.push(`Recurring task ${issue.slug} has a schedule trigger missing cronExpression/timezone.`); + continue; + } + const cronError = validateCron(trigger.cronExpression); + if (cronError) { + errors.push(`Recurring task ${issue.slug} has an invalid schedule trigger: ${cronError}`); + } + continue; + } + if (trigger.kind === "webhook" && trigger.signingMode && !ROUTINE_TRIGGER_SIGNING_MODES.includes(trigger.signingMode as any)) { + errors.push(`Recurring task ${issue.slug} uses unsupported webhook signingMode "${trigger.signingMode}".`); + } + } + + if (routine.triggers.length === 0 && issue.legacyRecurrence) { + const migrated = buildLegacyRoutineTriggerFromRecurrence(issue, scheduleValue); + warnings.push(...migrated.warnings); + errors.push(...migrated.errors); + if (migrated.trigger) { + routine.triggers.push(migrated.trigger); + } + } + + return { routine, warnings, errors }; +} + function toSafeSlug(input: string, fallback: string) { return normalizeAgentUrlKey(input) ?? fallback; } @@ -701,14 +1229,14 @@ function collectSelectedExportSlugs(selectedFiles: Set) { const taskMatch = filePath.match(/^tasks\/([^/]+)\//); if (taskMatch) tasks.add(taskMatch[1]!); } - return { agents, projects, tasks }; + return { agents, projects, tasks, routines: new Set(tasks) }; } function filterPortableExtensionYaml(yaml: string, selectedFiles: Set) { const selected = collectSelectedExportSlugs(selectedFiles); const lines = yaml.split("\n"); const out: string[] = []; - const filterableSections = new Set(["agents", "projects", "tasks"]); + const filterableSections = new Set(["agents", "projects", "tasks", "routines"]); let currentSection: string | null = null; let currentEntry: string | null = null; @@ -1604,6 +2132,7 @@ function buildManifestFromPackageFiles( const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {}; const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {}; const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {}; + const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {}; const companyName = asString(companyFrontmatter.name) ?? opts?.sourceLabel?.companyName @@ -1644,7 +2173,7 @@ function buildManifestFromPackageFiles( const skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort(); const manifest: CompanyPortabilityManifest = { - schemaVersion: 3, + schemaVersion: 4, generatedAt: new Date().toISOString(), source: opts?.sourceLabel ?? null, includes: { @@ -1824,6 +2353,10 @@ function buildManifestFromPackageFiles( ); const slug = asString(frontmatter.slug) ?? fallbackSlug; const extension = isPlainRecord(paperclipProjects[slug]) ? paperclipProjects[slug] : {}; + const workspaceExtensions = isPlainRecord(extension.workspaces) ? extension.workspaces : {}; + const workspaces = Object.entries(workspaceExtensions) + .map(([workspaceKey, entry]) => normalizePortableProjectWorkspaceExtension(workspaceKey, entry)) + .filter((entry): entry is CompanyPortabilityProjectWorkspaceManifestEntry => entry !== null); manifest.projects.push({ slug, name: asString(frontmatter.name) ?? slug, @@ -1837,6 +2370,7 @@ function buildManifestFromPackageFiles( executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy) ? extension.executionWorkspacePolicy : null, + workspaces, metadata: isPlainRecord(extension.metadata) ? extension.metadata : null, }); if (frontmatter.kind && frontmatter.kind !== "project") { @@ -1855,23 +2389,32 @@ function buildManifestFromPackageFiles( const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(taskPath))) ?? "task"; const slug = asString(frontmatter.slug) ?? fallbackSlug; const extension = isPlainRecord(paperclipTasks[slug]) ? paperclipTasks[slug] : {}; + const routineExtension = normalizeRoutineExtension(paperclipRoutines[slug]); + const routineExtensionRaw = isPlainRecord(paperclipRoutines[slug]) ? paperclipRoutines[slug] : {}; const schedule = isPlainRecord(frontmatter.schedule) ? frontmatter.schedule : null; - const recurrence = schedule && isPlainRecord(schedule.recurrence) + const legacyRecurrence = schedule && isPlainRecord(schedule.recurrence) ? schedule.recurrence : isPlainRecord(extension.recurrence) ? extension.recurrence : null; + const recurring = + asBoolean(frontmatter.recurring) === true + || routineExtension !== null + || legacyRecurrence !== null; manifest.issues.push({ slug, identifier: asString(extension.identifier), title: asString(frontmatter.name) ?? asString(frontmatter.title) ?? slug, path: taskPath, projectSlug: asString(frontmatter.project), + projectWorkspaceKey: asString(extension.projectWorkspaceKey), assigneeAgentSlug: asString(frontmatter.assignee), description: taskDoc.body || asString(frontmatter.description), - recurrence, - status: asString(extension.status), - priority: asString(extension.priority), + recurring, + routine: routineExtension, + legacyRecurrence, + status: asString(extension.status) ?? asString(routineExtensionRaw.status), + priority: asString(extension.priority) ?? asString(routineExtensionRaw.priority), labelIds: Array.isArray(extension.labelIds) ? extension.labelIds.filter((entry): entry is string => typeof entry === "string") : [], @@ -2134,8 +2677,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const projectsSvc = projectService(db); const issuesSvc = issueService(db); + const routinesSvc = routineService(db); const allProjectsRaw = include.projects || include.issues ? await projectsSvc.list(companyId) : []; const allProjects = allProjectsRaw.filter((project) => !project.archivedAt); + const allRoutines = include.issues ? await routinesSvc.list(companyId) : []; const projectById = new Map(allProjects.map((project) => [project.id, project])); const projectByReference = new Map(); for (const project of allProjects) { @@ -2155,6 +2700,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } const selectedIssues = new Map>>(); + const selectedRoutines = new Map(); + const routineById = new Map(allRoutines.map((routine) => [routine.id, routine])); const resolveIssueBySelector = async (selector: string) => { const trimmed = selector.trim(); if (!trimmed) return null; @@ -2165,6 +2712,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const selector of input.issues ?? []) { const issue = await resolveIssueBySelector(selector); if (!issue || issue.companyId !== companyId) { + const routine = routineById.get(selector.trim()); + if (routine) { + selectedRoutines.set(routine.id, routine); + if (routine.projectId) { + const parentProject = projectById.get(routine.projectId); + if (parentProject) selectedProjects.set(parentProject.id, parentProject); + } + continue; + } warnings.push(`Issue selector "${selector}" was not found and was skipped.`); continue; } @@ -2186,6 +2742,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const issue of projectIssues) { selectedIssues.set(issue.id, issue); } + for (const routine of allRoutines.filter((entry) => entry.projectId === match.id)) { + selectedRoutines.set(routine.id, routine); + } } if (include.projects && selectedProjects.size === 0) { @@ -2203,6 +2762,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (parentProject) selectedProjects.set(parentProject.id, parentProject); } } + if (selectedRoutines.size === 0) { + for (const routine of allRoutines) { + selectedRoutines.set(routine.id, routine); + if (routine.projectId) { + const parentProject = projectById.get(routine.projectId); + if (parentProject) selectedProjects.set(parentProject.id, parentProject); + } + } + } } const selectedProjectRows = Array.from(selectedProjects.values()) @@ -2210,15 +2778,26 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const selectedIssueRows = Array.from(selectedIssues.values()) .filter((issue): issue is NonNullable => issue != null) .sort((left, right) => (left.identifier ?? left.title).localeCompare(right.identifier ?? right.title)); + const selectedRoutineSummaries = Array.from(selectedRoutines.values()) + .sort((left, right) => left.title.localeCompare(right.title)); + const selectedRoutineRows = ( + await Promise.all(selectedRoutineSummaries.map((routine) => routinesSvc.getDetail(routine.id))) + ).filter((routine): routine is RoutineLike => routine !== null); const taskSlugByIssueId = new Map(); + const taskSlugByRoutineId = new Map(); const usedTaskSlugs = new Set(); for (const issue of selectedIssueRows) { const baseSlug = normalizeAgentUrlKey(issue.identifier ?? issue.title) ?? "task"; taskSlugByIssueId.set(issue.id, uniqueSlug(baseSlug, usedTaskSlugs)); } + for (const routine of selectedRoutineRows) { + const baseSlug = normalizeAgentUrlKey(routine.title) ?? "task"; + taskSlugByRoutineId.set(routine.id, uniqueSlug(baseSlug, usedTaskSlugs)); + } const projectSlugById = new Map(); + const projectWorkspaceKeyByProjectId = new Map>(); const usedProjectSlugs = new Set(); for (const project of selectedProjectRows) { const baseSlug = deriveProjectUrlKey(project.name, project.name); @@ -2259,6 +2838,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const paperclipAgentsOut: Record> = {}; const paperclipProjectsOut: Record> = {}; const paperclipTasksOut: Record> = {}; + const paperclipRoutinesOut: Record> = {}; const skillByReference = new Map(); for (const skill of companySkillRows) { @@ -2391,6 +2971,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const project of selectedProjectRows) { const slug = projectSlugById.get(project.id)!; const projectPath = `projects/${slug}/PROJECT.md`; + const portableWorkspaces = buildPortableProjectWorkspaces(slug, project.workspaces, warnings); + projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById); files[projectPath] = buildMarkdown( { name: project.name, @@ -2404,7 +2986,13 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { targetDate: project.targetDate ?? null, color: project.color ?? null, status: project.status, - executionWorkspacePolicy: project.executionWorkspacePolicy ?? undefined, + executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy( + slug, + project.executionWorkspacePolicy, + portableWorkspaces.workspaceKeyById, + warnings, + ) ?? undefined, + workspaces: portableWorkspaces.extension, }); paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {}; } @@ -2415,6 +3003,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // All tasks go in top-level tasks/ folder, never nested under projects/ const taskPath = `tasks/${taskSlug}/TASK.md`; const assigneeSlug = issue.assigneeAgentId ? (idToSlug.get(issue.assigneeAgentId) ?? null) : null; + const projectWorkspaceKey = issue.projectId && issue.projectWorkspaceId + ? projectWorkspaceKeyByProjectId.get(issue.projectId)?.get(issue.projectWorkspaceId) ?? null + : null; + if (issue.projectWorkspaceId && !projectWorkspaceKey) { + warnings.push(`Task ${taskSlug} workspace reference ${issue.projectWorkspaceId} was omitted from export because that workspace is not portable.`); + } files[taskPath] = buildMarkdown( { name: issue.title, @@ -2429,12 +3023,47 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { priority: issue.priority, labelIds: issue.labelIds ?? undefined, billingCode: issue.billingCode ?? null, + projectWorkspaceKey: projectWorkspaceKey ?? undefined, executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined, assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined, }); paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {}; } + for (const routine of selectedRoutineRows) { + const taskSlug = taskSlugByRoutineId.get(routine.id)!; + const projectSlug = projectSlugById.get(routine.projectId) ?? null; + const taskPath = `tasks/${taskSlug}/TASK.md`; + const assigneeSlug = idToSlug.get(routine.assigneeAgentId) ?? null; + files[taskPath] = buildMarkdown( + { + name: routine.title, + project: projectSlug, + assignee: assigneeSlug, + recurring: true, + }, + routine.description ?? "", + ); + const extension = stripEmptyValues({ + status: routine.status !== "active" ? routine.status : undefined, + priority: routine.priority !== "medium" ? routine.priority : undefined, + concurrencyPolicy: routine.concurrencyPolicy !== "coalesce_if_active" ? routine.concurrencyPolicy : undefined, + catchUpPolicy: routine.catchUpPolicy !== "skip_missed" ? routine.catchUpPolicy : undefined, + triggers: routine.triggers.map((trigger) => stripEmptyValues({ + kind: trigger.kind, + label: trigger.label ?? null, + enabled: trigger.enabled ? undefined : false, + cronExpression: trigger.kind === "schedule" ? trigger.cronExpression ?? null : undefined, + timezone: trigger.kind === "schedule" ? trigger.timezone ?? null : undefined, + signingMode: trigger.kind === "webhook" && trigger.signingMode !== "bearer" ? trigger.signingMode ?? null : undefined, + replayWindowSec: trigger.kind === "webhook" && trigger.replayWindowSec !== 300 + ? trigger.replayWindowSec ?? null + : undefined, + })), + }); + paperclipRoutinesOut[taskSlug] = isPlainRecord(extension) ? extension : {}; + } + const paperclipExtensionPath = ".paperclip.yaml"; const paperclipAgents = Object.fromEntries( Object.entries(paperclipAgentsOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), @@ -2445,6 +3074,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const paperclipTasks = Object.fromEntries( Object.entries(paperclipTasksOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), ); + const paperclipRoutines = Object.fromEntries( + Object.entries(paperclipRoutinesOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), + ); files[paperclipExtensionPath] = buildYamlFile( { schema: "paperclip/v1", @@ -2456,6 +3088,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined, projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined, tasks: Object.keys(paperclipTasks).length > 0 ? paperclipTasks : undefined, + routines: Object.keys(paperclipRoutines).length > 0 ? paperclipRoutines : undefined, }, { preserveEmptyStrings: true }, ); @@ -2644,6 +3277,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } if (include.issues) { + const projectBySlug = new Map(manifest.projects.map((project) => [project.slug, project])); for (const issue of manifest.issues) { const markdown = readPortableTextFile(source.files, ensureMarkdownPath(issue.path)); if (typeof markdown !== "string") { @@ -2654,8 +3288,24 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "task") { warnings.push(`Task markdown ${issue.path} does not declare kind: task in frontmatter.`); } - if (issue.recurrence) { - warnings.push(`Task ${issue.slug} has recurrence metadata; Paperclip will import it as a one-time issue for now.`); + if (issue.projectWorkspaceKey) { + const project = issue.projectSlug ? projectBySlug.get(issue.projectSlug) ?? null : null; + if (!project) { + warnings.push(`Task ${issue.slug} references workspace key ${issue.projectWorkspaceKey}, but its project is not present in the package.`); + } else if (!project.workspaces.some((workspace) => workspace.key === issue.projectWorkspaceKey)) { + warnings.push(`Task ${issue.slug} references missing project workspace key ${issue.projectWorkspaceKey}.`); + } + } + if (issue.recurring) { + if (!issue.projectSlug) { + errors.push(`Recurring task ${issue.slug} must declare a project to import as a routine.`); + } + if (!issue.assigneeAgentSlug) { + errors.push(`Recurring task ${issue.slug} must declare an assignee to import as a routine.`); + } + const resolvedRoutine = resolvePortableRoutineDefinition(issue, parsed.frontmatter.schedule); + warnings.push(...resolvedRoutine.warnings); + errors.push(...resolvedRoutine.errors); } } } @@ -2847,7 +3497,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { slug: manifestIssue.slug, action: "create", plannedTitle: manifestIssue.title, - reason: manifestIssue.recurrence ? "Recurrence will not be activated on import." : null, + reason: manifestIssue.recurring ? "Recurring task will be imported as a routine." : null, }); } } @@ -3024,6 +3674,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id); } const importedSlugToProjectId = new Map(); + const importedProjectWorkspaceIdByProjectSlug = new Map>(); const existingProjectSlugToId = new Map(); const existingProjects = await projects.list(targetCompany.id); for (const existing of existingProjects) { @@ -3202,6 +3853,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ?? existingSlugToAgentId.get(manifestProject.leadAgentSlug) ?? null : null; + const projectWorkspaceIdByKey = new Map(); const projectPatch = { name: planProject.plannedName, description: manifestProject.description, @@ -3211,27 +3863,65 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any) ? manifestProject.status as typeof PROJECT_STATUSES[number] : "backlog", - executionWorkspacePolicy: manifestProject.executionWorkspacePolicy, + executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy), }; + let projectId: string | null = null; if (planProject.action === "update" && planProject.existingProjectId) { const updated = await projects.update(planProject.existingProjectId, projectPatch); if (!updated) { warnings.push(`Skipped update for missing project ${planProject.existingProjectId}.`); continue; } + projectId = updated.id; importedSlugToProjectId.set(planProject.slug, updated.id); existingProjectSlugToId.set(updated.urlKey, updated.id); - continue; + } else { + const created = await projects.create(targetCompany.id, projectPatch); + projectId = created.id; + importedSlugToProjectId.set(planProject.slug, created.id); + existingProjectSlugToId.set(created.urlKey, created.id); } - const created = await projects.create(targetCompany.id, projectPatch); - importedSlugToProjectId.set(planProject.slug, created.id); - existingProjectSlugToId.set(created.urlKey, created.id); + if (!projectId) continue; + + for (const workspace of manifestProject.workspaces) { + const createdWorkspace = await projects.createWorkspace(projectId, { + name: workspace.name, + sourceType: workspace.sourceType ?? undefined, + repoUrl: workspace.repoUrl ?? undefined, + repoRef: workspace.repoRef ?? undefined, + defaultRef: workspace.defaultRef ?? undefined, + visibility: workspace.visibility ?? undefined, + setupCommand: workspace.setupCommand ?? undefined, + cleanupCommand: workspace.cleanupCommand ?? undefined, + metadata: workspace.metadata ?? undefined, + isPrimary: workspace.isPrimary, + }); + if (!createdWorkspace) { + warnings.push(`Project ${planProject.slug} workspace ${workspace.key} could not be created during import.`); + continue; + } + projectWorkspaceIdByKey.set(workspace.key, createdWorkspace.id); + } + importedProjectWorkspaceIdByProjectSlug.set(planProject.slug, projectWorkspaceIdByKey); + + const hydratedProjectExecutionWorkspacePolicy = importPortableProjectExecutionWorkspacePolicy( + planProject.slug, + manifestProject.executionWorkspacePolicy, + projectWorkspaceIdByKey, + warnings, + ); + if (hydratedProjectExecutionWorkspacePolicy) { + await projects.update(projectId, { + executionWorkspacePolicy: hydratedProjectExecutionWorkspacePolicy, + }); + } } } if (include.issues) { + const routines = routineService(db); for (const manifestIssue of sourceManifest.issues) { const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path); const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null; @@ -3246,8 +3936,95 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ?? existingProjectSlugToId.get(manifestIssue.projectSlug) ?? null : null; + const projectWorkspaceId = manifestIssue.projectSlug && manifestIssue.projectWorkspaceKey + ? importedProjectWorkspaceIdByProjectSlug.get(manifestIssue.projectSlug)?.get(manifestIssue.projectWorkspaceKey) ?? null + : null; + if (manifestIssue.projectWorkspaceKey && !projectWorkspaceId) { + warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`); + } + if (manifestIssue.recurring) { + if (!projectId || !assigneeAgentId) { + throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project or assignee required to create a routine.`); + } + const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule); + if (resolvedRoutine.errors.length > 0) { + throw unprocessable(`Recurring task ${manifestIssue.slug} could not be imported as a routine: ${resolvedRoutine.errors.join("; ")}`); + } + warnings.push(...resolvedRoutine.warnings); + const routineDefinition = resolvedRoutine.routine ?? { + concurrencyPolicy: null, + catchUpPolicy: null, + triggers: [], + }; + const createdRoutine = await routines.create(targetCompany.id, { + projectId, + goalId: null, + parentIssueId: null, + title: manifestIssue.title, + description, + assigneeAgentId, + priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any) + ? manifestIssue.priority as typeof ISSUE_PRIORITIES[number] + : "medium", + status: manifestIssue.status && ROUTINE_STATUSES.includes(manifestIssue.status as any) + ? manifestIssue.status as typeof ROUTINE_STATUSES[number] + : "active", + concurrencyPolicy: + routineDefinition.concurrencyPolicy && ROUTINE_CONCURRENCY_POLICIES.includes(routineDefinition.concurrencyPolicy as any) + ? routineDefinition.concurrencyPolicy as typeof ROUTINE_CONCURRENCY_POLICIES[number] + : "coalesce_if_active", + catchUpPolicy: + routineDefinition.catchUpPolicy && ROUTINE_CATCH_UP_POLICIES.includes(routineDefinition.catchUpPolicy as any) + ? routineDefinition.catchUpPolicy as typeof ROUTINE_CATCH_UP_POLICIES[number] + : "skip_missed", + }, { + agentId: null, + userId: actorUserId ?? null, + }); + for (const trigger of routineDefinition.triggers) { + if (trigger.kind === "schedule") { + await routines.createTrigger(createdRoutine.id, { + kind: "schedule", + label: trigger.label, + enabled: trigger.enabled, + cronExpression: trigger.cronExpression!, + timezone: trigger.timezone!, + }, { + agentId: null, + userId: actorUserId ?? null, + }); + continue; + } + if (trigger.kind === "webhook") { + await routines.createTrigger(createdRoutine.id, { + kind: "webhook", + label: trigger.label, + enabled: trigger.enabled, + signingMode: + trigger.signingMode && ROUTINE_TRIGGER_SIGNING_MODES.includes(trigger.signingMode as any) + ? trigger.signingMode as typeof ROUTINE_TRIGGER_SIGNING_MODES[number] + : "bearer", + replayWindowSec: trigger.replayWindowSec ?? 300, + }, { + agentId: null, + userId: actorUserId ?? null, + }); + continue; + } + await routines.createTrigger(createdRoutine.id, { + kind: "api", + label: trigger.label, + enabled: trigger.enabled, + }, { + agentId: null, + userId: actorUserId ?? null, + }); + } + continue; + } await issues.create(targetCompany.id, { projectId, + projectWorkspaceId, title: manifestIssue.title, description, assigneeAgentId, @@ -3262,9 +4039,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings, labelIds: [], }); - if (manifestIssue.recurrence) { - warnings.push(`Imported task ${manifestIssue.slug} as a one-time issue; recurrence metadata was not activated.`); - } } } diff --git a/ui/src/components/PackageFileTree.tsx b/ui/src/components/PackageFileTree.tsx index 5429328d..53e00195 100644 --- a/ui/src/components/PackageFileTree.tsx +++ b/ui/src/components/PackageFileTree.tsx @@ -160,6 +160,7 @@ export const FRONTMATTER_FIELD_LABELS: Record = { priority: "Priority", assignee: "Assignee", project: "Project", + recurring: "Recurring", targetDate: "Target date", }; diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 5ed8f640..e0a3f4e7 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -50,6 +50,7 @@ function checkedSlugs(checkedFiles: Set): { agents: Set; projects: Set; tasks: Set; + routines: Set; } { const agents = new Set(); const projects = new Set(); @@ -62,7 +63,7 @@ function checkedSlugs(checkedFiles: Set): { const taskMatch = p.match(/^tasks\/([^/]+)\//); if (taskMatch) tasks.add(taskMatch[1]); } - return { agents, projects, tasks }; + return { agents, projects, tasks, routines: new Set(tasks) }; } /** @@ -77,7 +78,7 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set): 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"]); let currentSection: string | null = null; // top-level key (e.g. "agents") let currentEntry: string | null = null; // slug under that section From c41dd2e39361c554f2f12692f20d100dd9b6640e Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 12:35:08 -0500 Subject: [PATCH 04/22] Reduce portability warning fan-out Infer portable repo metadata from local git workspaces when repoUrl is missing, and collapse repeated task workspace export warnings into a single summary per missing workspace. Co-Authored-By: Paperclip --- .../src/__tests__/company-portability.test.ts | 192 ++++++++++++++++++ server/src/services/company-portability.ts | 117 ++++++++++- 2 files changed, 299 insertions(+), 10 deletions(-) diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 5cf46fa2..fb9a4497 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -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"; @@ -859,6 +863,194 @@ describe("company portability", () => { })); }); + 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); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 19690dc3..21beeb2f 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -705,7 +705,60 @@ function stripPortableProjectExecutionWorkspaceRefs(policy: Record 0 ? trimmed : null; +} + +async function inferPortableWorkspaceGitMetadata(workspace: NonNullable[number]) { + const cwd = asString(workspace.cwd); + if (!cwd) { + return { + repoUrl: null, + repoRef: null, + defaultRef: null, + }; + } + + let repoUrl: string | null = null; + try { + repoUrl = await readGitOutput(cwd, ["remote", "get-url", "origin"]); + } catch { + try { + const firstRemote = await readGitOutput(cwd, ["remote"]); + const remoteName = firstRemote?.split("\n").map((entry) => entry.trim()).find(Boolean) ?? null; + if (remoteName) { + repoUrl = await readGitOutput(cwd, ["remote", "get-url", remoteName]); + } + } catch { + repoUrl = null; + } + } + + let repoRef: string | null = null; + try { + repoRef = await readGitOutput(cwd, ["branch", "--show-current"]); + } catch { + repoRef = null; + } + + let defaultRef: string | null = null; + try { + const remoteHead = await readGitOutput(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]); + defaultRef = remoteHead?.startsWith("origin/") ? remoteHead.slice("origin/".length) : remoteHead; + } catch { + defaultRef = null; + } + + return { + repoUrl, + repoRef, + defaultRef, + }; +} + +async function buildPortableProjectWorkspaces( projectSlug: string, workspaces: ProjectLike["workspaces"] | undefined, warnings: string[], @@ -713,17 +766,43 @@ function buildPortableProjectWorkspaces( const exportedWorkspaces: Record> = {}; const manifestWorkspaces: CompanyPortabilityProjectWorkspaceManifestEntry[] = []; const workspaceKeyById = new Map(); + const workspaceKeyBySignature = new Map(); + const manifestWorkspaceByKey = new Map(); const usedKeys = new Set(); for (const workspace of workspaces ?? []) { - const repoUrl = asString(workspace.repoUrl); + const inferredGitMetadata = + !asString(workspace.repoUrl) || !asString(workspace.repoRef) || !asString(workspace.defaultRef) + ? await inferPortableWorkspaceGitMetadata(workspace) + : { repoUrl: null, repoRef: null, defaultRef: null }; + const repoUrl = asString(workspace.repoUrl) ?? inferredGitMetadata.repoUrl; if (!repoUrl) { warnings.push(`Project ${projectSlug} workspace ${workspace.name} was omitted from export because it does not have a portable repoUrl.`); continue; } + const repoRef = asString(workspace.repoRef) ?? inferredGitMetadata.repoRef; + const defaultRef = asString(workspace.defaultRef) ?? inferredGitMetadata.defaultRef ?? repoRef; + const workspaceSignature = JSON.stringify({ + name: workspace.name, + repoUrl, + repoRef, + defaultRef, + }); + const existingWorkspaceKey = workspaceKeyBySignature.get(workspaceSignature); + if (existingWorkspaceKey) { + workspaceKeyById.set(workspace.id, existingWorkspaceKey); + const existingManifestWorkspace = manifestWorkspaceByKey.get(existingWorkspaceKey); + if (existingManifestWorkspace && workspace.isPrimary) { + existingManifestWorkspace.isPrimary = true; + const existingExtensionWorkspace = exportedWorkspaces[existingWorkspaceKey]; + if (isPlainRecord(existingExtensionWorkspace)) existingExtensionWorkspace.isPrimary = true; + } + continue; + } const workspaceKey = derivePortableProjectWorkspaceKey(workspace, usedKeys); workspaceKeyById.set(workspace.id, workspaceKey); + workspaceKeyBySignature.set(workspaceSignature, workspaceKey); let setupCommand = asString(workspace.setupCommand); if (setupCommand && containsAbsolutePathFragment(setupCommand)) { @@ -748,8 +827,8 @@ function buildPortableProjectWorkspaces( name: workspace.name, sourceType: workspace.sourceType, repoUrl, - repoRef: asString(workspace.repoRef), - defaultRef: asString(workspace.defaultRef), + repoRef, + defaultRef, visibility: asString(workspace.visibility), setupCommand, cleanupCommand, @@ -759,19 +838,21 @@ function buildPortableProjectWorkspaces( if (!isPlainRecord(portableWorkspace)) continue; exportedWorkspaces[workspaceKey] = portableWorkspace; - manifestWorkspaces.push({ + const manifestWorkspace = { key: workspaceKey, name: workspace.name, sourceType: asString(workspace.sourceType), repoUrl, - repoRef: asString(workspace.repoRef), - defaultRef: asString(workspace.defaultRef), + repoRef, + defaultRef, visibility: asString(workspace.visibility), setupCommand, cleanupCommand, metadata, isPrimary: workspace.isPrimary, - }); + }; + manifestWorkspaces.push(manifestWorkspace); + manifestWorkspaceByKey.set(workspaceKey, manifestWorkspace); } return { @@ -2838,6 +2919,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const paperclipAgentsOut: Record> = {}; const paperclipProjectsOut: Record> = {}; const paperclipTasksOut: Record> = {}; + const unportableTaskWorkspaceRefs = new Map(); const paperclipRoutinesOut: Record> = {}; const skillByReference = new Map(); @@ -2971,7 +3053,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const project of selectedProjectRows) { const slug = projectSlugById.get(project.id)!; const projectPath = `projects/${slug}/PROJECT.md`; - const portableWorkspaces = buildPortableProjectWorkspaces(slug, project.workspaces, warnings); + const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings); projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById); files[projectPath] = buildMarkdown( { @@ -3007,7 +3089,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ? projectWorkspaceKeyByProjectId.get(issue.projectId)?.get(issue.projectWorkspaceId) ?? null : null; if (issue.projectWorkspaceId && !projectWorkspaceKey) { - warnings.push(`Task ${taskSlug} workspace reference ${issue.projectWorkspaceId} was omitted from export because that workspace is not portable.`); + const aggregateKey = `${issue.projectId ?? "no-project"}:${issue.projectWorkspaceId}`; + const existing = unportableTaskWorkspaceRefs.get(aggregateKey); + if (existing) { + existing.taskSlugs.push(taskSlug); + } else { + unportableTaskWorkspaceRefs.set(aggregateKey, { + workspaceId: issue.projectWorkspaceId, + taskSlugs: [taskSlug], + }); + } } files[taskPath] = buildMarkdown( { @@ -3030,6 +3121,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {}; } + for (const { workspaceId, taskSlugs } of unportableTaskWorkspaceRefs.values()) { + const preview = taskSlugs.slice(0, 4).join(", "); + const remainder = taskSlugs.length > 4 ? ` and ${taskSlugs.length - 4} more` : ""; + warnings.push(`Tasks ${preview}${remainder} reference workspace ${workspaceId}, but that workspace could not be exported portably.`); + } + for (const routine of selectedRoutineRows) { const taskSlug = taskSlugByRoutineId.get(routine.id)!; const projectSlug = projectSlugById.get(routine.projectId) ?? null; From 220946b2a1204eeccf8f7bb81164acbf3f973ebe Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 12:42:20 -0500 Subject: [PATCH 05/22] Default recurring task exports to checked Co-Authored-By: Paperclip --- ui/src/lib/company-export-selection.test.ts | 41 +++++++++++++++ ui/src/lib/company-export-selection.ts | 56 +++++++++++++++++++++ ui/src/pages/CompanyExport.tsx | 21 +++----- 3 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 ui/src/lib/company-export-selection.test.ts create mode 100644 ui/src/lib/company-export-selection.ts diff --git a/ui/src/lib/company-export-selection.test.ts b/ui/src/lib/company-export-selection.test.ts new file mode 100644 index 00000000..91828e4a --- /dev/null +++ b/ui/src/lib/company-export-selection.test.ts @@ -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(), + ); + + 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", + ]); + }); +}); diff --git a/ui/src/lib/company-export-selection.ts b/ui/src/lib/company-export-selection.ts new file mode 100644 index 00000000..2b4d59be --- /dev/null +++ b/ui/src/lib/company-export-selection.ts @@ -0,0 +1,56 @@ +import type { CompanyPortabilityIssueManifestEntry } from "@paperclipai/shared"; + +function isTaskPath(filePath: string): boolean { + return /(?:^|\/)tasks\//.test(filePath); +} + +function buildRecurringTaskPrefixes( + issues: Array>, +): Set { + const prefixes = new Set(); + + 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): boolean { + for (const prefix of recurringTaskPrefixes) { + if (filePath === prefix || filePath.startsWith(prefix)) return true; + } + return false; +} + +export function buildInitialExportCheckedFiles( + filePaths: string[], + issues: Array>, + previousCheckedFiles: Set, +): Set { + const next = new Set(); + 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; +} diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index e0a3f4e7..298785d9 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -17,6 +17,7 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { MarkdownBody } from "../components/MarkdownBody"; import { cn } from "../lib/utils"; import { createZipArchive } from "../lib/zip"; +import { buildInitialExportCheckedFiles } from "../lib/company-export-selection"; import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files"; import { Download, @@ -34,11 +35,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. @@ -588,14 +584,13 @@ export function CompanyExport() { }), onSuccess: (result) => { setExportData(result); - setCheckedFiles((prev) => { - const next = new Set(); - 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(); From ac376d0e5ee5de686754ed85539dc191dcece92d Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 12:51:24 -0500 Subject: [PATCH 06/22] Add TUI import summaries Co-Authored-By: Paperclip --- cli/src/__tests__/company.test.ts | 208 +++++++++++++++++- cli/src/commands/client/company.ts | 335 ++++++++++++++++++++++++++++- 2 files changed, 536 insertions(+), 7 deletions(-) diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index acb05d86..7f88adac 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveCompanyImportApiPath } from "../commands/client/company.js"; +import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared"; +import { + renderCompanyImportPreview, + renderCompanyImportResult, + resolveCompanyImportApiPath, +} from "../commands/client/company.js"; describe("resolveCompanyImportApiPath", () => { it("uses company-scoped preview route for existing-company dry runs", () => { @@ -48,3 +53,204 @@ describe("resolveCompanyImportApiPath", () => { ).toThrow(/require a companyId/i); }); }); + +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, + }, + 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)", + }); + + 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("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" }, + ], + envInputs: [], + warnings: ["Review API keys"], + }, + { targetLabel: "Imported Co (company-123)" }, + ); + + expect(rendered).toContain("Company"); + expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)"); + expect(rendered).toContain("Agent results"); + expect(rendered).toContain("Review API keys"); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 9ad14fd5..c6bdea2f 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -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, @@ -52,6 +53,36 @@ interface CompanyImportOptions extends BaseClientOptions { dryRun?: boolean; } +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; + const binaryContentTypeByExtension: Record = { ".gif": "image/gif", ".jpeg": "image/jpeg", @@ -84,8 +115,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 +148,264 @@ 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 includeToValues(include: CompanyPortabilityInclude): Array { + return IMPORT_INCLUDE_OPTIONS + .map((option) => option.value) + .filter((value) => include[value]); +} + +async function resolveImportIncludeSelection( + input: string | undefined, + opts?: { prompt?: boolean }, +): Promise { + if (input?.trim()) { + return parseInclude(input, DEFAULT_IMPORT_INCLUDE); + } + + if (!opts?.prompt || !isInteractiveTerminal()) { + return { ...DEFAULT_IMPORT_INCLUDE }; + } + + const selection = await p.multiselect({ + message: "What should Paperclip import?", + options: IMPORT_INCLUDE_OPTIONS, + initialValues: includeToValues(DEFAULT_IMPORT_INCLUDE), + required: true, + }); + + if (p.isCancel(selection)) { + p.cancel("Import cancelled."); + process.exit(0); + } + + const values = new Set(selection); + return { + company: values.has("company"), + agents: values.has("agents"), + projects: values.has("projects"), + issues: values.has("issues"), + skills: values.has("skills"), + }; +} + +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 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; + }, +): 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.yellow("Warnings"), preview.warnings); + appendMessageBlock(lines, pc.red("Errors"), preview.errors); + + return lines.join("\n"); +} + +export function renderCompanyImportResult( + result: CompanyPortabilityImportResult, + meta: { targetLabel: 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)}`, + ]; + + appendPreviewExamples( + lines, + "Agent results", + result.agents.map((agent) => ({ + action: agent.action, + label: `${agent.slug} -> ${agent.name}`, + reason: agent.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.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"; @@ -515,7 +807,7 @@ export function registerCompanyCommands(program: Command): void { .command("import") .description("Import a portable markdown company package from local path, URL, or GitHub") .argument("", "Source path or URL") - .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents") + .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills") .option("--target ", "Target mode: new | existing") .option("-C, --company-id ", "Existing target company ID") .option("--new-company-name ", "Name override for --target new") @@ -526,12 +818,13 @@ export function registerCompanyCommands(program: Command): void { .action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => { try { 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 = await resolveImportIncludeSelection(opts.include, { prompt: interactiveView }); const agents = parseAgents(opts.agents); const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode; if (!["rename", "skip", "replace"].includes(collision)) { @@ -587,6 +880,9 @@ export function registerCompanyCommands(program: Command): void { }; } + const sourceLabel = formatSourceLabel(sourcePayload); + const targetLabel = formatTargetLabel(targetPayload); + const payload = { source: sourcePayload, include, @@ -602,12 +898,39 @@ export function registerCompanyCommands(program: Command): void { if (opts.dryRun) { const preview = await ctx.api.post(importApiPath, payload); - printOutput(preview, { json: ctx.json }); + if (!preview) { + throw new Error("Import preview returned no data."); + } + if (ctx.json) { + printOutput(preview, { json: true }); + } else { + printCompanyImportView( + "Import Preview", + renderCompanyImportPreview(preview, { + sourceLabel, + targetLabel: formatTargetLabel(targetPayload, preview), + }), + { interactive: interactiveView }, + ); + } return; } const imported = await ctx.api.post(importApiPath, payload); - printOutput(imported, { json: ctx.json }); + if (!imported) { + throw new Error("Import request returned no data."); + } + if (ctx.json) { + printOutput(imported, { json: true }); + } else { + printCompanyImportView( + "Import Result", + renderCompanyImportResult(imported, { + targetLabel, + }), + { interactive: interactiveView }, + ); + } } catch (err) { handleCommandError(err); } From a339b488aec6659b058bc45fb296fc65582973a9 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 12:55:34 -0500 Subject: [PATCH 07/22] fix: dedupe company skill inventory refreshes Co-Authored-By: Paperclip --- server/src/services/company-skills.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 19aeab04..43de8ee6 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -99,6 +99,8 @@ type RuntimeSkillEntryOptions = { materializeMissing?: boolean; }; +const skillInventoryRefreshPromises = new Map>(); + const PROJECT_SCAN_DIRECTORY_ROOTS = [ "skills", "skills/.curated", @@ -1474,8 +1476,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 { From 1246ccf2503fa1d2fadac2e1d2830e22d5d4ea48 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 13:13:34 -0500 Subject: [PATCH 08/22] Add nested import picker Co-Authored-By: Paperclip --- cli/src/__tests__/company.test.ts | 167 +++++++++++++++ cli/src/commands/client/company.ts | 333 ++++++++++++++++++++++++++--- 2 files changed, 467 insertions(+), 33 deletions(-) diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index 7f88adac..0958b690 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared"; import { + buildDefaultImportSelectionState, + buildImportSelectionCatalog, + buildSelectedFilesFromImportSelection, renderCompanyImportPreview, renderCompanyImportResult, resolveCompanyImportApiPath, @@ -254,3 +257,167 @@ describe("renderCompanyImportResult", () => { 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, + }, + 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"); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index c6bdea2f..52a06e78 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -50,6 +50,7 @@ interface CompanyImportOptions extends BaseClientOptions { agents?: string; collision?: CompanyCollisionMode; ref?: string; + yes?: boolean; dryRun?: boolean; } @@ -83,6 +84,28 @@ const IMPORT_INCLUDE_OPTIONS: Array<{ 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; + issues: Set; + agents: Set; + skills: Set; +}; + const binaryContentTypeByExtension: Record = { ".gif": "image/gif", ".jpeg": "image/jpeg", @@ -152,46 +175,268 @@ function isInteractiveTerminal(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } -function includeToValues(include: CompanyPortabilityInclude): Array { - return IMPORT_INCLUDE_OPTIONS - .map((option) => option.value) - .filter((value) => include[value]); +function resolveImportInclude(input: string | undefined): CompanyPortabilityInclude { + return parseInclude(input, DEFAULT_IMPORT_INCLUDE); } -async function resolveImportIncludeSelection( - input: string | undefined, - opts?: { prompt?: boolean }, -): Promise { - if (input?.trim()) { - return parseInclude(input, DEFAULT_IMPORT_INCLUDE); +function normalizePortablePath(filePath: string): string { + return filePath.replace(/\\/g, "/"); +} + +function findPortableExtensionPath(files: Record): 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, + 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, + entryPath: string, + opts?: { excludePrefixes?: string[] }, +): string[] { + const normalizedPath = normalizePortablePath(entryPath); + const directory = normalizedPath.includes("/") ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) : ""; + const selected = new Set([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(); + 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); } - if (!opts?.prompt || !isInteractiveTerminal()) { - return { ...DEFAULT_IMPORT_INCLUDE }; - } - - const selection = await p.multiselect({ - message: "What should Paperclip import?", - options: IMPORT_INCLUDE_OPTIONS, - initialValues: includeToValues(DEFAULT_IMPORT_INCLUDE), - required: true, - }); - - if (p.isCancel(selection)) { - p.cancel("Import cancelled."); - process.exit(0); - } - - const values = new Set(selection); return { - company: values.has("company"), - agents: values.has("agents"), - projects: values.has("projects"), - issues: values.has("issues"), - skills: values.has("skills"), + 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 { + 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(); + + 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)); +} + +async function promptForImportSelection(preview: CompanyPortabilityPreviewResult): Promise { + const catalog = buildImportSelectionCatalog(preview); + const state = buildDefaultImportSelectionState(catalog); + + while (true) { + const choice = await p.select({ + 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({ + message: `${getGroupLabel(group)} to import. Press enter to go back.`, + 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]) @@ -814,6 +1059,7 @@ export function registerCompanyCommands(program: Command): void { .option("--agents ", "Comma-separated agent slugs to import, or all", "all") .option("--collision ", "Collision strategy: rename | skip | replace", "rename") .option("--ref ", "Git ref to use for GitHub imports (branch, tag, or commit)") + .option("--yes", "Accept the default import selection without opening the TUI", false) .option("--dry-run", "Run preview only without applying", false) .action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => { try { @@ -824,7 +1070,7 @@ export function registerCompanyCommands(program: Command): void { throw new Error("Source path or URL is required."); } - const include = await resolveImportIncludeSelection(opts.include, { prompt: interactiveView }); + const include = resolveImportInclude(opts.include); const agents = parseAgents(opts.agents); const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode; if (!["rename", "skip", "replace"].includes(collision)) { @@ -882,6 +1128,26 @@ export function registerCompanyCommands(program: Command): void { 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(previewApiPath, { + source: sourcePayload, + include, + target: targetPayload, + agents, + collisionStrategy: collision, + }); + if (!initialPreview) { + throw new Error("Import preview returned no data."); + } + selectedFiles = await promptForImportSelection(initialPreview); + } const payload = { source: sourcePayload, @@ -889,6 +1155,7 @@ export function registerCompanyCommands(program: Command): void { target: targetPayload, agents, collisionStrategy: collision, + selectedFiles, }; const importApiPath = resolveCompanyImportApiPath({ dryRun: Boolean(opts.dryRun), From 06f5632d1a7b620a53036ee3f569a2e27f98e0cb Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 13:21:10 -0500 Subject: [PATCH 09/22] Polish import adapter defaults Co-Authored-By: Paperclip --- cli/src/__tests__/company.test.ts | 96 +++++++++++++++++++++++++++++- cli/src/commands/client/company.ts | 72 ++++++++++++++++++---- 2 files changed, 154 insertions(+), 14 deletions(-) diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index 0958b690..75174dfa 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared"; import { + buildDefaultImportAdapterOverrides, buildDefaultImportSelectionState, buildImportSelectionCatalog, buildSelectedFilesFromImportSelection, @@ -217,6 +218,7 @@ describe("renderCompanyImportPreview", () => { 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"); @@ -226,6 +228,7 @@ describe("renderCompanyImportPreview", () => { 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"); }); @@ -248,12 +251,16 @@ describe("renderCompanyImportResult", () => { envInputs: [], warnings: ["Review API keys"], }, - { targetLabel: "Imported Co (company-123)" }, + { + targetLabel: "Imported Co (company-123)", + infoMessages: ["Using claude-local adapter"], + }, ); expect(rendered).toContain("Company"); expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)"); expect(rendered).toContain("Agent results"); + expect(rendered).toContain("Using claude-local adapter"); expect(rendered).toContain("Review API keys"); }); }); @@ -421,3 +428,90 @@ describe("import selection catalog", () => { 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, + 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", + }, + }); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 52a06e78..242dc70a 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -50,6 +50,7 @@ interface CompanyImportOptions extends BaseClientOptions { agents?: string; collision?: CompanyCollisionMode; ref?: string; + paperclipUrl?: string; yes?: boolean; dryRun?: boolean; } @@ -346,6 +347,37 @@ export function buildSelectedFilesFromImportSelection( return Array.from(selected).sort((left, right) => left.localeCompare(right)); } +export function buildDefaultImportAdapterOverrides( + preview: Pick, +): Record | 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 | 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 { const catalog = buildImportSelectionCatalog(preview); const state = buildDefaultImportSelectionState(catalog); @@ -419,7 +451,7 @@ async function promptForImportSelection(preview: CompanyPortabilityPreviewResult } const selection = await p.multiselect({ - message: `${getGroupLabel(group)} to import. Press enter to go back.`, + message: `${getGroupLabel(group)} to import. Space toggles, enter returns to the main menu.`, options: groupItems.map((item) => ({ value: item.key, label: item.label, @@ -544,6 +576,7 @@ export function renderCompanyImportPreview( meta: { sourceLabel: string; targetLabel: string; + infoMessages?: string[]; }, ): string { const lines: string[] = [ @@ -603,6 +636,7 @@ export function renderCompanyImportPreview( })), ); + appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []); appendMessageBlock(lines, pc.yellow("Warnings"), preview.warnings); appendMessageBlock(lines, pc.red("Errors"), preview.errors); @@ -611,7 +645,7 @@ export function renderCompanyImportPreview( export function renderCompanyImportResult( result: CompanyPortabilityImportResult, - meta: { targetLabel: string }, + meta: { targetLabel: string; infoMessages?: string[] }, ): string { const lines: string[] = [ `${pc.bold("Target")} ${meta.targetLabel}`, @@ -637,6 +671,7 @@ export function renderCompanyImportResult( ); } + appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []); appendMessageBlock(lines, pc.yellow("Warnings"), result.warnings); return lines.join("\n"); @@ -1059,10 +1094,14 @@ export function registerCompanyCommands(program: Command): void { .option("--agents ", "Comma-separated agent slugs to import, or all", "all") .option("--collision ", "Collision strategy: rename | skip | replace", "rename") .option("--ref ", "Git ref to use for GitHub imports (branch, tag, or commit)") + .option("--paperclip-url ", "Alias for --api-base on this command") .option("--yes", "Accept the default import selection without opening the TUI", 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(); @@ -1149,7 +1188,7 @@ export function registerCompanyCommands(program: Command): void { selectedFiles = await promptForImportSelection(initialPreview); } - const payload = { + const previewPayload = { source: sourcePayload, include, target: targetPayload, @@ -1157,17 +1196,14 @@ export function registerCompanyCommands(program: Command): void { 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(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(importApiPath, payload); - if (!preview) { - throw new Error("Import preview returned no data."); - } if (ctx.json) { printOutput(preview, { json: true }); } else { @@ -1176,6 +1212,7 @@ export function registerCompanyCommands(program: Command): void { renderCompanyImportPreview(preview, { sourceLabel, targetLabel: formatTargetLabel(targetPayload, preview), + infoMessages: adapterMessages, }), { interactive: interactiveView }, ); @@ -1183,7 +1220,15 @@ export function registerCompanyCommands(program: Command): void { return; } - const imported = await ctx.api.post(importApiPath, payload); + const importApiPath = resolveCompanyImportApiPath({ + dryRun: false, + targetMode: targetPayload.mode, + companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null, + }); + const imported = await ctx.api.post(importApiPath, { + ...previewPayload, + adapterOverrides, + }); if (!imported) { throw new Error("Import request returned no data."); } @@ -1194,6 +1239,7 @@ export function registerCompanyCommands(program: Command): void { "Import Result", renderCompanyImportResult(imported, { targetLabel, + infoMessages: adapterMessages, }), { interactive: interactiveView }, ); From c02dc73d3c1372d03da72e28dfeb0d26dcc2773a Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 13:47:22 -0500 Subject: [PATCH 10/22] Confirm company imports after preview Co-Authored-By: Paperclip --- cli/src/__tests__/company.test.ts | 43 +++++++++++++++++++++++++ cli/src/commands/client/company.ts | 51 +++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index 75174dfa..bcc3137c 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -7,6 +7,7 @@ import { buildSelectedFilesFromImportSelection, renderCompanyImportPreview, renderCompanyImportResult, + resolveCompanyImportApplyConfirmationMode, resolveCompanyImportApiPath, } from "../commands/client/company.js"; @@ -58,6 +59,48 @@ describe("resolveCompanyImportApiPath", () => { }); }); +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("renderCompanyImportPreview", () => { it("summarizes the preview with counts, selection info, and truncated examples", () => { const preview: CompanyPortabilityPreviewResult = { diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 242dc70a..dbf52890 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -704,6 +704,27 @@ export function resolveCompanyImportApiPath(input: { return input.dryRun ? "/api/companies/import/preview" : "/api/companies/import"; } +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()); } @@ -1095,7 +1116,7 @@ export function registerCompanyCommands(program: Command): void { .option("--collision ", "Collision strategy: rename | skip | replace", "rename") .option("--ref ", "Git ref to use for GitHub imports (branch, tag, or commit)") .option("--paperclip-url ", "Alias for --api-base on this command") - .option("--yes", "Accept the default import selection without opening the TUI", false) + .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 { @@ -1220,6 +1241,34 @@ export function registerCompanyCommands(program: Command): void { return; } + 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, From 2a6e1cf1fc9379f36efcd619bfaebff91244be78 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 13:51:40 -0500 Subject: [PATCH 11/22] Fix imported GitHub skill file paths Normalize GitHub skill directories for blob/file imports and when reading legacy stored metadata so imported SKILL.md files resolve correctly. Co-Authored-By: Paperclip --- server/src/__tests__/company-skills.test.ts | 8 ++++++++ server/src/services/company-skills.ts | 19 +++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index 17da7804..bcc173d8 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -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"); diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 43de8ee6..2b97da20 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -190,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); } @@ -1019,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}/`)) @@ -1665,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."); } From 56a39fea3da009b12bc7d148b737143e390507d8 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 15:13:45 -0500 Subject: [PATCH 12/22] Add importing & exporting company guide Documents the `paperclipai company export` and `paperclipai company import` CLI commands, covering package format, all options, target modes, collision strategies, GitHub sources, interactive selection, and API endpoints. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- docs/docs.json | 3 +- .../board-operator/importing-and-exporting.md | 201 ++++++++++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 docs/guides/board-operator/importing-and-exporting.md diff --git a/docs/docs.json b/docs/docs.json index 96b9f696..a13a1e77 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" ] }, { diff --git a/docs/guides/board-operator/importing-and-exporting.md b/docs/guides/board-operator/importing-and-exporting.md new file mode 100644 index 00000000..ae4f36a6 --- /dev/null +++ b/docs/guides/board-operator/importing-and-exporting.md @@ -0,0 +1,201 @@ +--- +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/AGENTS.md # Agent instructions + frontmatter +│ └── cto/AGENTS.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. +- **AGENTS.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 --out ./my-export +``` + +### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--out ` | Output directory (required) | — | +| `--include ` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | `company,agents` | +| `--skills ` | Export only specific skill slugs | all | +| `--projects ` | Export only specific project shortnames or IDs | all | +| `--issues ` | Export specific issue identifiers or IDs | none | +| `--project-issues ` | 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 ` | `new` (create a new company) or `existing` (merge into existing) | inferred from context | +| `--company-id ` | Target company ID for `--target existing` | current context | +| `--new-company-name ` | Override company name for `--target new` | from package | +| `--include ` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | auto-detected | +| `--agents ` | Comma-separated agent slugs to import, or `all` | `all` | +| `--collision ` | How to handle name conflicts: `rename`, `skip`, or `replace` | `rename` | +| `--ref ` | 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 + +### 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. From 66d84ccfa3806843e1659fc7e740d784279dd65a Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 15:32:07 -0500 Subject: [PATCH 13/22] Add companies.sh import wrapper Co-Authored-By: Paperclip --- cli/src/__tests__/companies-sh.test.ts | 75 +++++++++++ companies.sh | 117 ++++++++++++++++++ docs/cli/control-plane-commands.md | 3 + .../board-operator/importing-and-exporting.md | 7 ++ 4 files changed, 202 insertions(+) create mode 100644 cli/src/__tests__/companies-sh.test.ts create mode 100755 companies.sh diff --git a/cli/src/__tests__/companies-sh.test.ts b/cli/src/__tests__/companies-sh.test.ts new file mode 100644 index 00000000..d0d5e203 --- /dev/null +++ b/cli/src/__tests__/companies-sh.test.ts @@ -0,0 +1,75 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const scriptPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../companies.sh"); + +function runEcho(args: string[]) { + return execFileSync("bash", [scriptPath, ...args], { + cwd: path.dirname(scriptPath), + env: { + ...process.env, + COMPANIES_SH_ECHO: "1", + }, + encoding: "utf8", + }).trim(); +} + +describe("companies.sh", () => { + it("passes through positional source imports with current company import ergonomics", () => { + expect(runEcho([ + "paperclipai/companies/engineering", + "--target", "existing", + "-C", "company-123", + "--dry-run", + ])).toBe( + "pnpm paperclipai company import paperclipai/companies/engineering --target existing -C company-123 --dry-run", + ); + }); + + it("accepts the optional import verb", () => { + expect(runEcho([ + "import", + "./exports/acme", + "--include", "agents,skills", + "--collision", "rename", + ])).toBe( + "pnpm paperclipai company import ./exports/acme --include agents\\,skills --collision rename", + ); + }); + + it("normalizes legacy --from usage into the positional source argument", () => { + expect(runEcho([ + "--from", "https://github.com/org/repo/tree/main/acme", + "--ref", "release/2026-03-23", + "--yes", + ])).toBe( + "pnpm paperclipai company import https://github.com/org/repo/tree/main/acme --ref release/2026-03-23 --yes", + ); + }); + + it("supports --from=value compatibility", () => { + expect(runEcho([ + "--from=org/repo/company-template", + "--paperclip-url", "http://localhost:3100", + "--json", + ])).toBe( + "pnpm paperclipai company import org/repo/company-template --paperclip-url http://localhost:3100 --json", + ); + }); + + it("fails when no source path or URL is provided", () => { + const result = spawnSync("bash", [scriptPath, "--dry-run"], { + cwd: path.dirname(scriptPath), + env: { + ...process.env, + COMPANIES_SH_ECHO: "1", + }, + encoding: "utf8", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("source path or URL is required"); + }); +}); diff --git a/companies.sh b/companies.sh new file mode 100755 index 00000000..da1aa6ff --- /dev/null +++ b/companies.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat <<'EOF' +Usage: + ./companies.sh [paperclipai company import flags...] + ./companies.sh import [paperclipai company import flags...] + ./companies.sh --from [paperclipai company import flags...] + +Thin wrapper around: + pnpm paperclipai company import ... + +Notes: + - Accepts the source as the first positional argument, like `paperclipai company import` + - Still accepts legacy `--from ` for compatibility + - Runs from the repo root so it can be invoked from anywhere + +Examples: + ./companies.sh org/repo/company-template --dry-run + ./companies.sh import ./exports/acme --target existing -C company-123 + ./companies.sh --from https://github.com/org/repo/tree/main/acme --ref main +EOF +} + +fail() { + printf 'companies.sh: %s\n' "$*" >&2 + exit 1 +} + +source_arg="" +expect_legacy_source=0 +pass_through=() + +if [[ $# -gt 0 && "$1" == "import" ]]; then + shift +fi + +while [[ $# -gt 0 ]]; do + arg="$1" + shift + + if [[ "$expect_legacy_source" -eq 1 ]]; then + [[ -n "$arg" ]] || fail "--from requires a value" + [[ -z "$source_arg" ]] || fail "source path or URL was provided more than once" + source_arg="$arg" + expect_legacy_source=0 + continue + fi + + case "$arg" in + help|-h|--help) + usage + exit 0 + ;; + --from) + expect_legacy_source=1 + ;; + --from=*) + value="${arg#--from=}" + [[ -n "$value" ]] || fail "--from requires a value" + [[ -z "$source_arg" ]] || fail "source path or URL was provided more than once" + source_arg="$value" + ;; + --include|--target|-C|--company-id|--new-company-name|--agents|--collision|--ref|--paperclip-url|--api-base) + [[ $# -gt 0 ]] || fail "$arg requires a value" + pass_through+=("$arg" "$1") + shift + ;; + --yes|--dry-run|--json) + pass_through+=("$arg") + ;; + --) + if [[ $# -gt 0 ]]; then + if [[ -z "$source_arg" ]]; then + source_arg="$1" + shift + else + fail "unexpected extra positional argument: $1" + fi + fi + while [[ $# -gt 0 ]]; do + pass_through+=("$1") + shift + done + ;; + -*) + pass_through+=("$arg") + ;; + *) + if [[ -z "$source_arg" ]]; then + source_arg="$arg" + else + fail "unexpected extra positional argument: $arg" + fi + ;; + esac +done + +[[ "$expect_legacy_source" -eq 0 ]] || fail "--from requires a value" +[[ -n "$source_arg" ]] || fail "source path or URL is required" + +cmd=(pnpm paperclipai company import "$source_arg") +if [[ "${#pass_through[@]}" -gt 0 ]]; then + cmd+=("${pass_through[@]}") +fi + +if [[ "${COMPANIES_SH_ECHO:-}" == "1" ]]; then + printf '%q ' "${cmd[@]}" + printf '\n' + exit 0 +fi + +cd "$repo_root" +exec "${cmd[@]}" diff --git a/docs/cli/control-plane-commands.md b/docs/cli/control-plane-commands.md index 80eb0edb..cdb52f5e 100644 --- a/docs/cli/control-plane-commands.md +++ b/docs/cli/control-plane-commands.md @@ -54,6 +54,9 @@ pnpm paperclipai company import \ --target new \ --new-company-name "Acme Imported" \ --include company,agents + +# Repo helper wrapper with the same source-first ergonomics +./companies.sh org/repo/company-template --target new --dry-run ``` ## Agent Commands diff --git a/docs/guides/board-operator/importing-and-exporting.md b/docs/guides/board-operator/importing-and-exporting.md index ae4f36a6..0bd468b8 100644 --- a/docs/guides/board-operator/importing-and-exporting.md +++ b/docs/guides/board-operator/importing-and-exporting.md @@ -92,6 +92,13 @@ paperclipai company import org/repo paperclipai company import org/repo/companies/acme ``` +If you are working inside the Paperclip repo, `./companies.sh` is a thin wrapper around `paperclipai company import`. It accepts the same source-first form and still supports legacy `--from ` compatibility: + +```sh +./companies.sh org/repo/companies/acme --dry-run +./companies.sh --from ./my-export --target existing --company-id abc123 +``` + ### Options | Option | Description | Default | From 9786ebb7ba9dc4635da61b94fd1e7adb4f3d7d53 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 15:37:21 -0500 Subject: [PATCH 14/22] Revert "Add companies.sh import wrapper" This reverts commit 17876ec1dc65a9150488874d79fc2fcc087c13ae. --- cli/src/__tests__/companies-sh.test.ts | 75 ----------- companies.sh | 117 ------------------ docs/cli/control-plane-commands.md | 3 - .../board-operator/importing-and-exporting.md | 7 -- 4 files changed, 202 deletions(-) delete mode 100644 cli/src/__tests__/companies-sh.test.ts delete mode 100755 companies.sh diff --git a/cli/src/__tests__/companies-sh.test.ts b/cli/src/__tests__/companies-sh.test.ts deleted file mode 100644 index d0d5e203..00000000 --- a/cli/src/__tests__/companies-sh.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { execFileSync, spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; - -const scriptPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../companies.sh"); - -function runEcho(args: string[]) { - return execFileSync("bash", [scriptPath, ...args], { - cwd: path.dirname(scriptPath), - env: { - ...process.env, - COMPANIES_SH_ECHO: "1", - }, - encoding: "utf8", - }).trim(); -} - -describe("companies.sh", () => { - it("passes through positional source imports with current company import ergonomics", () => { - expect(runEcho([ - "paperclipai/companies/engineering", - "--target", "existing", - "-C", "company-123", - "--dry-run", - ])).toBe( - "pnpm paperclipai company import paperclipai/companies/engineering --target existing -C company-123 --dry-run", - ); - }); - - it("accepts the optional import verb", () => { - expect(runEcho([ - "import", - "./exports/acme", - "--include", "agents,skills", - "--collision", "rename", - ])).toBe( - "pnpm paperclipai company import ./exports/acme --include agents\\,skills --collision rename", - ); - }); - - it("normalizes legacy --from usage into the positional source argument", () => { - expect(runEcho([ - "--from", "https://github.com/org/repo/tree/main/acme", - "--ref", "release/2026-03-23", - "--yes", - ])).toBe( - "pnpm paperclipai company import https://github.com/org/repo/tree/main/acme --ref release/2026-03-23 --yes", - ); - }); - - it("supports --from=value compatibility", () => { - expect(runEcho([ - "--from=org/repo/company-template", - "--paperclip-url", "http://localhost:3100", - "--json", - ])).toBe( - "pnpm paperclipai company import org/repo/company-template --paperclip-url http://localhost:3100 --json", - ); - }); - - it("fails when no source path or URL is provided", () => { - const result = spawnSync("bash", [scriptPath, "--dry-run"], { - cwd: path.dirname(scriptPath), - env: { - ...process.env, - COMPANIES_SH_ECHO: "1", - }, - encoding: "utf8", - }); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("source path or URL is required"); - }); -}); diff --git a/companies.sh b/companies.sh deleted file mode 100755 index da1aa6ff..00000000 --- a/companies.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -usage() { - cat <<'EOF' -Usage: - ./companies.sh [paperclipai company import flags...] - ./companies.sh import [paperclipai company import flags...] - ./companies.sh --from [paperclipai company import flags...] - -Thin wrapper around: - pnpm paperclipai company import ... - -Notes: - - Accepts the source as the first positional argument, like `paperclipai company import` - - Still accepts legacy `--from ` for compatibility - - Runs from the repo root so it can be invoked from anywhere - -Examples: - ./companies.sh org/repo/company-template --dry-run - ./companies.sh import ./exports/acme --target existing -C company-123 - ./companies.sh --from https://github.com/org/repo/tree/main/acme --ref main -EOF -} - -fail() { - printf 'companies.sh: %s\n' "$*" >&2 - exit 1 -} - -source_arg="" -expect_legacy_source=0 -pass_through=() - -if [[ $# -gt 0 && "$1" == "import" ]]; then - shift -fi - -while [[ $# -gt 0 ]]; do - arg="$1" - shift - - if [[ "$expect_legacy_source" -eq 1 ]]; then - [[ -n "$arg" ]] || fail "--from requires a value" - [[ -z "$source_arg" ]] || fail "source path or URL was provided more than once" - source_arg="$arg" - expect_legacy_source=0 - continue - fi - - case "$arg" in - help|-h|--help) - usage - exit 0 - ;; - --from) - expect_legacy_source=1 - ;; - --from=*) - value="${arg#--from=}" - [[ -n "$value" ]] || fail "--from requires a value" - [[ -z "$source_arg" ]] || fail "source path or URL was provided more than once" - source_arg="$value" - ;; - --include|--target|-C|--company-id|--new-company-name|--agents|--collision|--ref|--paperclip-url|--api-base) - [[ $# -gt 0 ]] || fail "$arg requires a value" - pass_through+=("$arg" "$1") - shift - ;; - --yes|--dry-run|--json) - pass_through+=("$arg") - ;; - --) - if [[ $# -gt 0 ]]; then - if [[ -z "$source_arg" ]]; then - source_arg="$1" - shift - else - fail "unexpected extra positional argument: $1" - fi - fi - while [[ $# -gt 0 ]]; do - pass_through+=("$1") - shift - done - ;; - -*) - pass_through+=("$arg") - ;; - *) - if [[ -z "$source_arg" ]]; then - source_arg="$arg" - else - fail "unexpected extra positional argument: $arg" - fi - ;; - esac -done - -[[ "$expect_legacy_source" -eq 0 ]] || fail "--from requires a value" -[[ -n "$source_arg" ]] || fail "source path or URL is required" - -cmd=(pnpm paperclipai company import "$source_arg") -if [[ "${#pass_through[@]}" -gt 0 ]]; then - cmd+=("${pass_through[@]}") -fi - -if [[ "${COMPANIES_SH_ECHO:-}" == "1" ]]; then - printf '%q ' "${cmd[@]}" - printf '\n' - exit 0 -fi - -cd "$repo_root" -exec "${cmd[@]}" diff --git a/docs/cli/control-plane-commands.md b/docs/cli/control-plane-commands.md index cdb52f5e..80eb0edb 100644 --- a/docs/cli/control-plane-commands.md +++ b/docs/cli/control-plane-commands.md @@ -54,9 +54,6 @@ pnpm paperclipai company import \ --target new \ --new-company-name "Acme Imported" \ --include company,agents - -# Repo helper wrapper with the same source-first ergonomics -./companies.sh org/repo/company-template --target new --dry-run ``` ## Agent Commands diff --git a/docs/guides/board-operator/importing-and-exporting.md b/docs/guides/board-operator/importing-and-exporting.md index 0bd468b8..ae4f36a6 100644 --- a/docs/guides/board-operator/importing-and-exporting.md +++ b/docs/guides/board-operator/importing-and-exporting.md @@ -92,13 +92,6 @@ paperclipai company import org/repo paperclipai company import org/repo/companies/acme ``` -If you are working inside the Paperclip repo, `./companies.sh` is a thin wrapper around `paperclipai company import`. It accepts the same source-first form and still supports legacy `--from ` compatibility: - -```sh -./companies.sh org/repo/companies/acme --dry-run -./companies.sh --from ./my-export --target existing --company-id abc123 -``` - ### Options | Option | Description | Default | From dcead97650a8ad2647fe774cd03ed51a21554e5d Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 15:47:25 -0500 Subject: [PATCH 15/22] Fix company zip imports Co-Authored-By: Paperclip --- .../company-import-export-e2e.test.ts | 136 +++++++++++++++++- cli/src/__tests__/company-import-zip.test.ts | 133 +++++++++++++++++ cli/src/commands/client/company.ts | 27 +++- cli/src/commands/client/zip.ts | 129 +++++++++++++++++ server/src/app.ts | 2 + 5 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 cli/src/__tests__/company-import-zip.test.ts create mode 100644 cli/src/commands/client/zip.ts diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 9c141a13..56171c71 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -1,5 +1,5 @@ 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"; @@ -182,6 +182,107 @@ 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); + 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"); + } +} + +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; +} + async function stopServerProcess(child: ServerProcess | null) { if (!child || child.exitCode !== null) return; child.kill("SIGTERM"); @@ -345,6 +446,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 +456,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 +500,7 @@ describe("paperclipai company import/export e2e", () => { `Imported ${sourceCompany.name}`, "--include", "company,agents,projects,issues", + "--yes", ], { apiBase, configPath }, ); @@ -470,6 +574,7 @@ describe("paperclipai company import/export e2e", () => { "company,agents,projects,issues", "--collision", "rename", + "--yes", ], { apiBase, configPath }, ); @@ -494,5 +599,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 = {}; + 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); }); diff --git a/cli/src/__tests__/company-import-zip.test.ts b/cli/src/__tests__/company-import-zip.test.ts new file mode 100644 index 00000000..0c48a24a --- /dev/null +++ b/cli/src/__tests__/company-import-zip.test.ts @@ -0,0 +1,133 @@ +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"; + +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; +} + +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", + }, + }); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index dbf52890..013f8e56 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -12,6 +12,7 @@ import type { CompanyPortabilityImportResult, } from "@paperclipai/shared"; import { ApiRequestError } from "../../client/http.js"; +import { readZipArchive } from "./zip.js"; import { addCommonClientOptions, formatInlineRecord, @@ -184,6 +185,14 @@ 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 | null { if (files[".paperclip.yaml"] !== undefined) return ".paperclip.yaml"; if (files[".paperclip.yml"] !== undefined) return ".paperclip.yml"; @@ -853,21 +862,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; }> { 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 = {}; await collectPackageFiles(rootDir, rootDir, files); diff --git a/cli/src/commands/client/zip.ts b/cli/src/commands/client/zip.ts new file mode 100644 index 00000000..ff1eb669 --- /dev/null +++ b/cli/src/commands/client/zip.ts @@ -0,0 +1,129 @@ +import { inflateRawSync } from "node:zlib"; +import path from "node:path"; +import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; + +const textDecoder = new TextDecoder(); + +const binaryContentTypeByExtension: Record = { + ".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; +}> { + 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 = {}; + 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 }; +} diff --git a/server/src/app.ts b/server/src/app.ts index 55a4e53b..5535ab3d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; }, From f9927bdaaa7e536e64d694e4c78fb86295a57897 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:30:28 -0500 Subject: [PATCH 16/22] Disable imported timer heartbeats Prevent company imports from re-enabling scheduler heartbeats on imported agents and cover both new-company and existing-company import flows in portability tests. Co-Authored-By: Paperclip --- .../src/__tests__/company-portability.test.ts | 60 +++++++++++++++++++ server/src/services/company-portability.ts | 10 +++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index fb9a4497..fdf0f9b9 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1832,6 +1832,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) => ({ + 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); @@ -1902,6 +1957,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([ diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 21beeb2f..fb8f2b2e 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -619,6 +619,14 @@ function clonePortableRecord(value: unknown) { return structuredClone(value) as Record; } +function disableImportedTimerHeartbeat(runtimeConfig: unknown) { + const next = clonePortableRecord(runtimeConfig) ?? {}; + const heartbeat = isPlainRecord(next.heartbeat) ? { ...next.heartbeat } : {}; + heartbeat.enabled = false; + next.heartbeat = heartbeat; + return next; +} + function normalizePortableProjectWorkspaceExtension( workspaceKey: string, value: unknown, @@ -3853,7 +3861,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { reportsTo: null, adapterType: effectiveAdapterType, adapterConfig: adapterConfigWithSkills, - runtimeConfig: manifestAgent.runtimeConfig, + runtimeConfig: disableImportedTimerHeartbeat(manifestAgent.runtimeConfig), budgetMonthlyCents: manifestAgent.budgetMonthlyCents, permissions: manifestAgent.permissions, metadata: manifestAgent.metadata, From b5fde733b0814c032c55548081f7022a42cbf0c4 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:41:52 -0500 Subject: [PATCH 17/22] Open imported company after import Co-Authored-By: Paperclip --- cli/src/__tests__/company.test.ts | 21 +++++++++++++++ cli/src/client/board-auth.ts | 2 +- cli/src/commands/client/company.ts | 42 +++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index bcc3137c..2345fb7d 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared"; import { + buildCompanyDashboardUrl, buildDefaultImportAdapterOverrides, buildDefaultImportSelectionState, buildImportSelectionCatalog, @@ -101,6 +102,14 @@ describe("resolveCompanyImportApplyConfirmationMode", () => { }); }); +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 = { @@ -155,6 +164,10 @@ describe("renderCompanyImportPreview", () => { logoPath: null, requireBoardApprovalForNewAgents: false, }, + sidebar: { + agents: ["ceo"], + projects: ["alpha"], + }, agents: [ { slug: "ceo", @@ -291,16 +304,19 @@ 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: [], 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("Agent results"); expect(rendered).toContain("Using claude-local adapter"); @@ -350,6 +366,10 @@ describe("import selection catalog", () => { logoPath: "images/company-logo.png", requireBoardApprovalForNewAgents: false, }, + sidebar: { + agents: ["ceo"], + projects: ["alpha"], + }, agents: [ { slug: "ceo", @@ -504,6 +524,7 @@ describe("default adapter overrides", () => { skills: false, }, company: null, + sidebar: null, agents: [ { slug: "legacy-agent", diff --git a/cli/src/client/board-auth.ts b/cli/src/client/board-auth.ts index 5ed7cd7a..7c1121ec 100644 --- a/cli/src/client/board-auth.ts +++ b/cli/src/client/board-auth.ts @@ -169,7 +169,7 @@ async function requestJson(url: string, init?: RequestInit): Promise { return response.json() as Promise; } -function openUrl(url: string): boolean { +export function openUrl(url: string): boolean { const platform = process.platform; try { if (platform === "darwin") { diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 013f8e56..ca0c3b92 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -12,6 +12,7 @@ import type { CompanyPortabilityImportResult, } from "@paperclipai/shared"; import { ApiRequestError } from "../../client/http.js"; +import { openUrl } from "../../client/board-auth.js"; import { readZipArchive } from "./zip.js"; import { addCommonClientOptions, @@ -654,7 +655,7 @@ export function renderCompanyImportPreview( export function renderCompanyImportResult( result: CompanyPortabilityImportResult, - meta: { targetLabel: string; infoMessages?: string[] }, + meta: { targetLabel: string; companyUrl?: string; infoMessages?: string[] }, ): string { const lines: string[] = [ `${pc.bold("Target")} ${meta.targetLabel}`, @@ -662,6 +663,10 @@ export function renderCompanyImportResult( `${pc.bold("Agents")} ${summarizeImportAgentResults(result.agents)}`, ]; + if (meta.companyUrl) { + lines.splice(1, 0, `${pc.bold("URL")} ${meta.companyUrl}`); + } + appendPreviewExamples( lines, "Agent results", @@ -713,6 +718,15 @@ 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; @@ -1298,6 +1312,18 @@ export function registerCompanyCommands(program: Command): void { if (!imported) { throw new Error("Import request returned no data."); } + let companyUrl: string | undefined; + if (!ctx.json) { + try { + const importedCompany = await ctx.api.get(`/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 { @@ -1305,10 +1331,24 @@ export function registerCompanyCommands(program: Command): void { "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); From 159c5b43605ec523b5f63dfa6b42873f42dae857 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:49:46 -0500 Subject: [PATCH 18/22] Preserve sidebar order in company portability Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 2 + .../shared/src/types/company-portability.ts | 14 ++ packages/shared/src/types/index.ts | 1 + .../src/validators/company-portability.ts | 7 + packages/shared/src/validators/index.ts | 1 + .../src/__tests__/company-portability.test.ts | 58 +++++ server/src/services/company-portability.ts | 199 ++++++++++++------ ui/src/api/companies.ts | 31 +-- ui/src/components/SidebarAgents.tsx | 38 ++-- ui/src/hooks/useAgentOrder.ts | 104 +++++++++ ui/src/lib/agent-order.ts | 106 ++++++++++ .../lib/company-portability-sidebar.test.ts | 100 +++++++++ ui/src/lib/company-portability-sidebar.ts | 61 ++++++ ui/src/pages/CompanyExport.tsx | 107 +++++++++- ui/src/pages/CompanyImport.tsx | 47 +++++ 15 files changed, 758 insertions(+), 118 deletions(-) create mode 100644 ui/src/hooks/useAgentOrder.ts create mode 100644 ui/src/lib/agent-order.ts create mode 100644 ui/src/lib/company-portability-sidebar.test.ts create mode 100644 ui/src/lib/company-portability-sidebar.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 85494986..47f85d82 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -253,6 +253,7 @@ export type { CompanyPortabilityEnvInput, CompanyPortabilityFileEntry, CompanyPortabilityCompanyManifestEntry, + CompanyPortabilitySidebarOrder, CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, @@ -487,6 +488,7 @@ export { portabilityIncludeSchema, portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, + portabilitySidebarOrderSchema, portabilityAgentManifestEntrySchema, portabilityManifestSchema, portabilitySourceSchema, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 26400c57..63016e93 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -33,6 +33,11 @@ export interface CompanyPortabilityCompanyManifestEntry { requireBoardApprovalForNewAgents: boolean; } +export interface CompanyPortabilitySidebarOrder { + agents: string[]; + projects: string[]; +} + export interface CompanyPortabilityProjectManifestEntry { slug: string; name: string; @@ -144,6 +149,7 @@ export interface CompanyPortabilityManifest { } | null; includes: CompanyPortabilityInclude; company: CompanyPortabilityCompanyManifestEntry | null; + sidebar: CompanyPortabilitySidebarOrder | null; agents: CompanyPortabilityAgentManifestEntry[]; skills: CompanyPortabilitySkillManifestEntry[]; projects: CompanyPortabilityProjectManifestEntry[]; @@ -279,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[]; } @@ -292,4 +305,5 @@ export interface CompanyPortabilityExportRequest { projectIssues?: string[]; selectedFiles?: string[]; expandReferencedSkills?: boolean; + sidebarOrder?: Partial; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 028ae787..dd615c4c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -144,6 +144,7 @@ export type { CompanyPortabilityEnvInput, CompanyPortabilityFileEntry, CompanyPortabilityCompanyManifestEntry, + CompanyPortabilitySidebarOrder, CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 72359bb8..7cbd4884 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -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), @@ -155,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([]), @@ -201,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; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 20a2ce9b..3f33bceb 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -60,6 +60,7 @@ export { portabilityIncludeSchema, portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, + portabilitySidebarOrderSchema, portabilityAgentManifestEntrySchema, portabilitySkillManifestEntrySchema, portabilityManifestSchema, diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index fdf0f9b9..bf6cfabe 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -440,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); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index fb8f2b2e..a1f6a7c8 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -24,6 +24,7 @@ import type { CompanyPortabilityIssueRoutineManifestEntry, CompanyPortabilityIssueRoutineTriggerManifestEntry, CompanyPortabilityIssueManifestEntry, + CompanyPortabilitySidebarOrder, CompanyPortabilitySkillManifestEntry, CompanySkill, } from "@paperclipai/shared"; @@ -1321,76 +1322,100 @@ function collectSelectedExportSlugs(selectedFiles: Set) { return { agents, projects, tasks, routines: new Set(tasks) }; } +function normalizePortableSlugList(value: unknown) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const normalized: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") continue; + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; +} + +function normalizePortableSidebarOrder(value: unknown): CompanyPortabilitySidebarOrder | null { + if (!isPlainRecord(value)) return null; + const sidebar = { + agents: normalizePortableSlugList(value.agents), + projects: normalizePortableSlugList(value.projects), + }; + return sidebar.agents.length > 0 || sidebar.projects.length > 0 ? sidebar : null; +} + +function sortAgentsBySidebarOrder(agents: T[]) { + if (agents.length === 0) return []; + + const byId = new Map(agents.map((agent) => [agent.id, agent])); + const childrenOf = new Map(); + 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: T[] = []; + 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; +} + function filterPortableExtensionYaml(yaml: string, selectedFiles: Set) { const selected = collectSelectedExportSlugs(selectedFiles); - const lines = yaml.split("\n"); - const out: string[] = []; - const filterableSections = new Set(["agents", "projects", "tasks", "routines"]); - - let currentSection: string | null = null; - let currentEntry: string | null = null; - let includeEntry = true; - let sectionHeaderLine: string | null = null; - let sectionBuffer: string[] = []; - - const flushSection = () => { - if (sectionHeaderLine !== null && sectionBuffer.length > 0) { - out.push(sectionHeaderLine); - out.push(...sectionBuffer); + const parsed = parseYamlFile(yaml); + for (const section of ["agents", "projects", "tasks", "routines"] as const) { + const sectionValue = parsed[section]; + if (!isPlainRecord(sectionValue)) continue; + const sectionSlugs = selected[section]; + const filteredEntries = Object.fromEntries( + Object.entries(sectionValue).filter(([slug]) => sectionSlugs.has(slug)), + ); + if (Object.keys(filteredEntries).length > 0) { + parsed[section] = filteredEntries; + } else { + delete parsed[section]; } - sectionHeaderLine = null; - sectionBuffer = []; - }; - - for (const line of lines) { - const topMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/); - if (topMatch && !line.startsWith(" ")) { - flushSection(); - currentEntry = null; - includeEntry = true; - - const key = topMatch[1]!; - if (filterableSections.has(key)) { - currentSection = key; - sectionHeaderLine = line; - continue; - } - - currentSection = null; - out.push(line); - continue; - } - - if (currentSection && filterableSections.has(currentSection)) { - const entryMatch = line.match(/^ ([\w][\w-]*):\s*(.*)$/); - if (entryMatch && !line.startsWith(" ")) { - const slug = entryMatch[1]!; - currentEntry = slug; - const sectionSlugs = selected[currentSection as keyof typeof selected]; - includeEntry = sectionSlugs.has(slug); - if (includeEntry) sectionBuffer.push(line); - continue; - } - - if (currentEntry !== null) { - if (includeEntry) sectionBuffer.push(line); - continue; - } - - sectionBuffer.push(line); - continue; - } - - out.push(line); } - flushSection(); - let filtered = out.join("\n"); - const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m); - if (logoPathMatch && !selectedFiles.has(logoPathMatch[1]!)) { - filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, ""); + const companySection = parsed.company; + if (isPlainRecord(companySection)) { + const logoPath = asString(companySection.logoPath) ?? asString(companySection.logo); + if (logoPath && !selectedFiles.has(logoPath)) { + delete companySection.logoPath; + delete companySection.logo; + } } - return filtered; + + const sidebarOrder = normalizePortableSidebarOrder(parsed.sidebar); + if (sidebarOrder) { + const filteredSidebar = stripEmptyValues({ + agents: sidebarOrder.agents.filter((slug) => selected.agents.has(slug)), + projects: sidebarOrder.projects.filter((slug) => selected.projects.has(slug)), + }); + if (isPlainRecord(filteredSidebar)) { + parsed.sidebar = filteredSidebar; + } else { + delete parsed.sidebar; + } + } else { + delete parsed.sidebar; + } + + return buildYamlFile(parsed, { preserveEmptyStrings: true }); } function filterExportFiles( @@ -2218,6 +2243,7 @@ function buildManifestFromPackageFiles( ? parseYamlFile(readPortableTextFile(normalizedFiles, paperclipExtensionPath) ?? "") : {}; const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {}; + const paperclipSidebar = normalizePortableSidebarOrder(paperclipExtension.sidebar); const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {}; const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {}; const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {}; @@ -2283,6 +2309,7 @@ function buildManifestFromPackageFiles( ? paperclipCompany.requireBoardApprovalForNewAgents : readCompanyApprovalDefault(companyFrontmatter), }, + sidebar: paperclipSidebar, agents: [], skills: [], projects: [], @@ -2711,6 +2738,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const files: Record = {}; const warnings: string[] = []; const envInputs: CompanyPortabilityManifest["envInputs"] = []; + const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder); const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package"; let companyLogoPath: string | null = null; @@ -2892,6 +2920,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const baseSlug = deriveProjectUrlKey(project.name, project.name); projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs)); } + const sidebarOrder = requestedSidebarOrder ?? stripEmptyValues({ + agents: sortAgentsBySidebarOrder(Array.from(selectedAgents.values())) + .map((agent) => idToSlug.get(agent.id)) + .filter((slug): slug is string => Boolean(slug)), + projects: selectedProjectRows + .map((project) => projectSlugById.get(project.id)) + .filter((slug): slug is string => Boolean(slug)), + }); const companyPath = "COMPANY.md"; files[companyPath] = buildMarkdown( @@ -3190,6 +3226,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { logoPath: companyLogoPath, requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false, }), + sidebar: stripEmptyValues(sidebarOrder), agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined, projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined, tasks: Object.keys(paperclipTasks).length > 0 ? paperclipTasks : undefined, @@ -3772,6 +3809,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } const resultAgents: CompanyPortabilityImportResult["agents"] = []; + const resultProjects: CompanyPortabilityImportResult["projects"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); const existingAgents = await agents.list(targetCompany.id); @@ -3951,7 +3989,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const planProject of plan.preview.plan.projectPlans) { const manifestProject = sourceManifest.projects.find((project) => project.slug === planProject.slug); if (!manifestProject) continue; - if (planProject.action === "skip") continue; + if (planProject.action === "skip") { + resultProjects.push({ + slug: planProject.slug, + id: planProject.existingProjectId, + action: "skipped", + name: planProject.plannedName, + reason: planProject.reason, + }); + continue; + } const projectLeadAgentId = manifestProject.leadAgentSlug ? importedSlugToAgentId.get(manifestProject.leadAgentSlug) @@ -3976,16 +4023,37 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const updated = await projects.update(planProject.existingProjectId, projectPatch); if (!updated) { warnings.push(`Skipped update for missing project ${planProject.existingProjectId}.`); + resultProjects.push({ + slug: planProject.slug, + id: null, + action: "skipped", + name: planProject.plannedName, + reason: "Existing target project not found.", + }); continue; } projectId = updated.id; importedSlugToProjectId.set(planProject.slug, updated.id); existingProjectSlugToId.set(updated.urlKey, updated.id); + resultProjects.push({ + slug: planProject.slug, + id: updated.id, + action: "updated", + name: updated.name, + reason: planProject.reason, + }); } else { const created = await projects.create(targetCompany.id, projectPatch); projectId = created.id; importedSlugToProjectId.set(planProject.slug, created.id); existingProjectSlugToId.set(created.urlKey, created.id); + resultProjects.push({ + slug: planProject.slug, + id: created.id, + action: "created", + name: created.name, + reason: planProject.reason, + }); } if (!projectId) continue; @@ -4154,6 +4222,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { action: companyAction, }, agents: resultAgents, + projects: resultProjects, envInputs: sourceManifest.envInputs ?? [], warnings, }; diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index 60a41742..82d2e54e 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -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(`/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(`/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(`/companies/${companyId}/exports`, data), importPreview: (data: CompanyPortabilityPreviewRequest) => diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 0a7e8c18..0b7c7d95 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -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(); - 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() {
- {visibleAgents.map((agent: Agent) => { + {orderedAgents.map((agent: Agent) => { const runCount = liveCountByAgent.get(agent.id) ?? 0; return ( 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(() => { + 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).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, + }; +} diff --git a/ui/src/lib/agent-order.ts b/ui/src/lib/agent-order.ts new file mode 100644 index 00000000..831624df --- /dev/null +++ b/ui/src/lib/agent-order.ts @@ -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(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(); + 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; +} diff --git a/ui/src/lib/company-portability-sidebar.test.ts b/ui/src/lib/company-portability-sidebar.test.ts new file mode 100644 index 00000000..1bc7b06d --- /dev/null +++ b/ui/src/lib/company-portability-sidebar.test.ts @@ -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"], + }); + }); +}); diff --git a/ui/src/lib/company-portability-sidebar.ts b/ui/src/lib/company-portability-sidebar.ts new file mode 100644 index 00000000..7790086d --- /dev/null +++ b/ui/src/lib/company-portability-sidebar.ts @@ -0,0 +1,61 @@ +import type { Agent, CompanyPortabilitySidebarOrder, Project } from "@paperclipai/shared"; +import { deriveProjectUrlKey, normalizeAgentUrlKey } from "@paperclipai/shared"; + +function uniqueSlug(base: string, used: Set) { + 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 { + const usedSlugs = new Set(); + const byId = new Map(); + 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 { + const usedSlugs = new Set(); + const byId = new Map(); + 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; +} diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 298785d9..e82aeafb 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -1,23 +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, @@ -75,15 +84,29 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set): string { // Sections whose entries are slug-keyed and should be filtered 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); @@ -106,6 +129,11 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set): string { currentSection = key; sectionHeaderLine = line; continue; + } else if (key === "sidebar") { + currentSection = key; + currentSidebarList = null; + sectionHeaderLine = line; + continue; } else { currentSection = null; out.push(line); @@ -113,6 +141,32 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set): 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) @@ -529,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(null); const [selectedFile, setSelectedFile] = useState(null); @@ -538,6 +606,38 @@ export function CompanyExport() { const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE); const savedExpandedRef = useRef | 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). @@ -581,6 +681,7 @@ export function CompanyExport() { mutationFn: () => companiesApi.exportPreview(selectedCompanyId!, { include: { company: true, agents: true, projects: true, issues: true }, + sidebarOrder, }), onSuccess: (result) => { setExportData(result); @@ -629,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)); @@ -650,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) : []), diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index c185615d..d8108f61 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -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,44 @@ 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; + + 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 +652,11 @@ export function CompanyImport() { const { pushToast } = useToast(); const queryClient = useQueryClient(); const packageInputRef = useRef(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 +846,7 @@ export function CompanyImport() { onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); const importedCompany = await companiesApi.get(result.company.id); + applyImportedSidebarOrder(importPreview, result, currentUserId); setSelectedCompanyId(importedCompany.id); pushToast({ tone: "success", From 6f1ce3bd60ccfa04227402e987a1201a383dac15 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:55:16 -0500 Subject: [PATCH 19/22] Document imported heartbeat defaults Co-Authored-By: Paperclip --- doc/SPEC-implementation.md | 1 + docs/guides/board-operator/importing-and-exporting.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 21406e18..b51a0447 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -868,6 +868,7 @@ Export/import behavior in V1: - 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 diff --git a/docs/guides/board-operator/importing-and-exporting.md b/docs/guides/board-operator/importing-and-exporting.md index ae4f36a6..9923b0ae 100644 --- a/docs/guides/board-operator/importing-and-exporting.md +++ b/docs/guides/board-operator/importing-and-exporting.md @@ -140,6 +140,8 @@ The preview shows: - **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:** From a3f568dec787bd512419bf11507adad230f72e5a Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 16:55:20 -0500 Subject: [PATCH 20/22] Improve generated company org chart assets Co-Authored-By: Paperclip --- scripts/generate-company-assets.ts | 8 +- server/src/routes/org-chart-svg.ts | 244 +++++++++++++++++++++++++++-- 2 files changed, 239 insertions(+), 13 deletions(-) diff --git a/scripts/generate-company-assets.ts b/scripts/generate-company-assets.ts index b5d4e817..ba5dd6e8 100644 --- a/scripts/generate-company-assets.ts +++ b/scripts/generate-company-assets.ts @@ -13,7 +13,7 @@ */ import * as fs from "fs"; import * as path from "path"; -import { renderOrgChartPng, type OrgNode } from "../server/src/routes/org-chart-svg.js"; +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"; @@ -313,7 +313,11 @@ async function main() { const orgTree = buildOrgTree(pkg.agents); console.log(` Org tree roots: ${orgTree.map((n) => n.name).join(", ")}`); - const pngBuffer = await renderOrgChartPng(orgTree, "warmth"); + 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"); diff --git a/server/src/routes/org-chart-svg.ts b/server/src/routes/org-chart-svg.ts index cf8d1951..af3bddaf 100644 --- a/server/src/routes/org-chart-svg.ts +++ b/server/src/routes/org-chart-svg.ts @@ -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 ` + + ${escapeXml(ln.node.name)} + `; + } + + 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 += ``; + } + } + return ` ${shadowDef} ${renderEmojiAvatar(cx, avatarCY, AVATAR_SIZE / 2, avatarBg, emojiSvg, avatarStroke)} ${escapeXml(ln.node.name)} ${escapeXml(roleLabel)} + ${avatarGridSvg} `; } @@ -496,19 +569,154 @@ const PAPERCLIP_LOGO_SVG = ` 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 + ? `${svgEscape(overlay.companyName)}` + : ""; + const overlayStatsSvg = overlay?.stats + ? `${svgEscape(overlay.stats)}` + : ""; + return ` ${theme.defs(TARGET_W, TARGET_H)} @@ -536,6 +752,8 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa ${PAPERCLIP_LOGO_SVG} + ${overlayNameSvg} + ${overlayStatsSvg} ${renderConnectors(layout, theme)} ${renderCards(layout, theme)} @@ -543,8 +761,12 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa `; } -export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): Promise { - const svg = renderOrgChartSvg(orgTree, style); +function svgEscape(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth", overlay?: OrgChartOverlay): Promise { + 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 From 92c29f27c38db45a5e0dffa00dfae1c278d1db63 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 17:23:59 -0500 Subject: [PATCH 21/22] Address Greptile review on portability PR Co-Authored-By: Paperclip --- .../company-import-export-e2e.test.ts | 89 +----------------- cli/src/__tests__/company-import-zip.test.ts | 91 +------------------ cli/src/__tests__/company.test.ts | 8 +- cli/src/__tests__/helpers/zip.ts | 87 ++++++++++++++++++ cli/src/commands/client/company.ts | 22 +++++ .../board-operator/importing-and-exporting.md | 6 +- scripts/generate-company-assets.ts | 11 ++- 7 files changed, 129 insertions(+), 185 deletions(-) create mode 100644 cli/src/__tests__/helpers/zip.ts 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, From 87b3cacc8fc8380c52fff04cbc3a4aa674bdbd47 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 19:42:58 -0500 Subject: [PATCH 22/22] Address valid Greptile portability follow-ups --- cli/src/commands/client/company.ts | 11 +---------- cli/src/commands/client/zip.ts | 2 +- ui/src/pages/CompanyImport.tsx | 14 +++++++++++++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 4f8268ac..ac4fdc1c 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -13,7 +13,7 @@ import type { } from "@paperclipai/shared"; import { ApiRequestError } from "../../client/http.js"; import { openUrl } from "../../client/board-auth.js"; -import { readZipArchive } from "./zip.js"; +import { binaryContentTypeByExtension, readZipArchive } from "./zip.js"; import { addCommonClientOptions, formatInlineRecord, @@ -109,15 +109,6 @@ type ImportSelectionState = { skills: Set; }; -const binaryContentTypeByExtension: Record = { - ".gif": "image/gif", - ".jpeg": "image/jpeg", - ".jpg": "image/jpeg", - ".png": "image/png", - ".svg": "image/svg+xml", - ".webp": "image/webp", -}; - function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry { const contentType = binaryContentTypeByExtension[path.extname(filePath).toLowerCase()]; if (!contentType) return contents.toString("utf8"); diff --git a/cli/src/commands/client/zip.ts b/cli/src/commands/client/zip.ts index ff1eb669..b75935e9 100644 --- a/cli/src/commands/client/zip.ts +++ b/cli/src/commands/client/zip.ts @@ -4,7 +4,7 @@ import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; const textDecoder = new TextDecoder(); -const binaryContentTypeByExtension: Record = { +export const binaryContentTypeByExtension: Record = { ".gif": "image/gif", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index d8108f61..60765138 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -356,6 +356,7 @@ function applyImportedSidebarOrder( ) { const sidebar = preview?.manifest.sidebar; if (!sidebar) return; + if (!userId?.trim()) return; const agentIdBySlug = new Map( result.agents @@ -846,7 +847,18 @@ export function CompanyImport() { onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); const importedCompany = await companiesApi.get(result.company.id); - applyImportedSidebarOrder(importPreview, result, currentUserId); + 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",