Add nested import picker

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-23 13:13:34 -05:00
parent a339b488ae
commit 1246ccf250
2 changed files with 467 additions and 33 deletions

View File

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

View File

@@ -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<string>;
issues: Set<string>;
agents: Set<string>;
skills: Set<string>;
};
const binaryContentTypeByExtension: Record<string, string> = {
".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<keyof CompanyPortabilityInclude> {
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<CompanyPortabilityInclude> {
if (input?.trim()) {
return parseInclude(input, DEFAULT_IMPORT_INCLUDE);
function normalizePortablePath(filePath: string): string {
return filePath.replace(/\\/g, "/");
}
function findPortableExtensionPath(files: Record<string, CompanyPortabilityFileEntry>): string | null {
if (files[".paperclip.yaml"] !== undefined) return ".paperclip.yaml";
if (files[".paperclip.yml"] !== undefined) return ".paperclip.yml";
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
}
function collectFilesUnderDirectory(
files: Record<string, CompanyPortabilityFileEntry>,
directory: string,
opts?: { excludePrefixes?: string[] },
): string[] {
const normalizedDirectory = normalizePortablePath(directory).replace(/\/+$/, "");
if (!normalizedDirectory) return [];
const prefix = `${normalizedDirectory}/`;
const excluded = (opts?.excludePrefixes ?? []).map((entry) => normalizePortablePath(entry).replace(/\/+$/, "")).filter(Boolean);
return Object.keys(files)
.map(normalizePortablePath)
.filter((filePath) => filePath.startsWith(prefix))
.filter((filePath) => !excluded.some((excludePrefix) => filePath.startsWith(`${excludePrefix}/`)))
.sort((left, right) => left.localeCompare(right));
}
function collectEntityFiles(
files: Record<string, CompanyPortabilityFileEntry>,
entryPath: string,
opts?: { excludePrefixes?: string[] },
): string[] {
const normalizedPath = normalizePortablePath(entryPath);
const directory = normalizedPath.includes("/") ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) : "";
const selected = new Set<string>([normalizedPath]);
if (directory) {
for (const filePath of collectFilesUnderDirectory(files, directory, opts)) {
selected.add(filePath);
}
}
return Array.from(selected).sort((left, right) => left.localeCompare(right));
}
export function buildImportSelectionCatalog(preview: CompanyPortabilityPreviewResult): ImportSelectionCatalog {
const selectedAgentSlugs = new Set(preview.selectedAgentSlugs);
const companyFiles = new Set<string>();
const companyPath = preview.manifest.company?.path ? normalizePortablePath(preview.manifest.company.path) : null;
if (companyPath) {
companyFiles.add(companyPath);
}
const readmePath = Object.keys(preview.files).find((entry) => normalizePortablePath(entry) === "README.md");
if (readmePath) {
companyFiles.add(normalizePortablePath(readmePath));
}
const logoPath = preview.manifest.company?.logoPath ? normalizePortablePath(preview.manifest.company.logoPath) : null;
if (logoPath && preview.files[logoPath] !== undefined) {
companyFiles.add(logoPath);
}
if (!opts?.prompt || !isInteractiveTerminal()) {
return { ...DEFAULT_IMPORT_INCLUDE };
}
const selection = await p.multiselect<keyof CompanyPortabilityInclude>({
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<string> {
return new Set(items.map((item) => item.key));
}
export function buildDefaultImportSelectionState(catalog: ImportSelectionCatalog): ImportSelectionState {
return {
company: catalog.company.includedByDefault,
projects: toKeySet(catalog.projects),
issues: toKeySet(catalog.issues),
agents: toKeySet(catalog.agents),
skills: toKeySet(catalog.skills),
};
}
function countSelected(state: ImportSelectionState, group: ImportSelectableGroup): number {
return state[group].size;
}
function countTotal(catalog: ImportSelectionCatalog, group: ImportSelectableGroup): number {
return catalog[group].length;
}
function summarizeGroupSelection(catalog: ImportSelectionCatalog, state: ImportSelectionState, group: ImportSelectableGroup): string {
return `${countSelected(state, group)}/${countTotal(catalog, group)} selected`;
}
function getGroupLabel(group: ImportSelectableGroup): string {
switch (group) {
case "projects":
return "Projects";
case "issues":
return "Tasks";
case "agents":
return "Agents";
case "skills":
return "Skills";
}
}
export function buildSelectedFilesFromImportSelection(
catalog: ImportSelectionCatalog,
state: ImportSelectionState,
): string[] {
const selected = new Set<string>();
if (state.company) {
for (const filePath of catalog.company.files) {
selected.add(normalizePortablePath(filePath));
}
}
for (const group of ["projects", "issues", "agents", "skills"] as const) {
const selectedKeys = state[group];
for (const item of catalog[group]) {
if (!selectedKeys.has(item.key)) continue;
for (const filePath of item.files) {
selected.add(normalizePortablePath(filePath));
}
}
}
if (selected.size > 0 && catalog.extensionPath) {
selected.add(normalizePortablePath(catalog.extensionPath));
}
return Array.from(selected).sort((left, right) => left.localeCompare(right));
}
async function promptForImportSelection(preview: CompanyPortabilityPreviewResult): Promise<string[]> {
const catalog = buildImportSelectionCatalog(preview);
const state = buildDefaultImportSelectionState(catalog);
while (true) {
const choice = await p.select<ImportSelectableGroup | "company" | "confirm">({
message: "Select what Paperclip should import",
options: [
{
value: "company",
label: state.company ? "Company: included" : "Company: skipped",
hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package",
},
{
value: "projects",
label: "Select Projects",
hint: summarizeGroupSelection(catalog, state, "projects"),
},
{
value: "issues",
label: "Select Tasks",
hint: summarizeGroupSelection(catalog, state, "issues"),
},
{
value: "agents",
label: "Select Agents",
hint: summarizeGroupSelection(catalog, state, "agents"),
},
{
value: "skills",
label: "Select Skills",
hint: summarizeGroupSelection(catalog, state, "skills"),
},
{
value: "confirm",
label: "Confirm",
hint: `${buildSelectedFilesFromImportSelection(catalog, state).length} files selected`,
},
],
initialValue: "confirm",
});
if (p.isCancel(choice)) {
p.cancel("Import cancelled.");
process.exit(0);
}
if (choice === "confirm") {
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
if (selectedFiles.length === 0) {
p.note("Select at least one import target before confirming.", "Nothing selected");
continue;
}
return selectedFiles;
}
if (choice === "company") {
if (catalog.company.files.length === 0) {
p.note("This package does not include company metadata to toggle.", "No company metadata");
continue;
}
state.company = !state.company;
continue;
}
const group = choice;
const groupItems = catalog[group];
if (groupItems.length === 0) {
p.note(`This package does not include any ${getGroupLabel(group).toLowerCase()}.`, `No ${getGroupLabel(group)}`);
continue;
}
const selection = await p.multiselect<string>({
message: `${getGroupLabel(group)} to import. 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 <list>", "Comma-separated agent slugs to import, or all", "all")
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
.option("--ref <value>", "Git ref to use for GitHub imports (branch, tag, or commit)")
.option("--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<CompanyPortabilityPreviewResult>(previewApiPath, {
source: sourcePayload,
include,
target: targetPayload,
agents,
collisionStrategy: collision,
});
if (!initialPreview) {
throw new Error("Import preview returned no data.");
}
selectedFiles = await promptForImportSelection(initialPreview);
}
const 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),