Add TUI import summaries

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-23 12:51:24 -05:00
parent 220946b2a1
commit ac376d0e5e
2 changed files with 536 additions and 7 deletions

View File

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

View File

@@ -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<string, string> = {
".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<keyof CompanyPortabilityInclude> {
return IMPORT_INCLUDE_OPTIONS
.map((option) => option.value)
.filter((value) => include[value]);
}
async function resolveImportIncludeSelection(
input: string | undefined,
opts?: { prompt?: boolean },
): Promise<CompanyPortabilityInclude> {
if (input?.trim()) {
return parseInclude(input, DEFAULT_IMPORT_INCLUDE);
}
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"),
};
}
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("<fromPathOrUrl>", "Source path or URL")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills")
.option("--target <mode>", "Target mode: new | existing")
.option("-C, --company-id <id>", "Existing target company ID")
.option("--new-company-name <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<CompanyPortabilityPreviewResult>(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<CompanyPortabilityImportResult>(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);
}