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