Add TUI import summaries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
220946b2a1
commit
ac376d0e5e
2 changed files with 536 additions and 7 deletions
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("resolveCompanyImportApiPath", () => {
|
||||||
it("uses company-scoped preview route for existing-company dry runs", () => {
|
it("uses company-scoped preview route for existing-company dry runs", () => {
|
||||||
|
|
@ -48,3 +53,204 @@ describe("resolveCompanyImportApiPath", () => {
|
||||||
).toThrow(/require a companyId/i);
|
).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Command } from "commander";
|
||||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
|
import pc from "picocolors";
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
CompanyPortabilityFileEntry,
|
CompanyPortabilityFileEntry,
|
||||||
|
|
@ -52,6 +53,36 @@ interface CompanyImportOptions extends BaseClientOptions {
|
||||||
dryRun?: boolean;
|
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> = {
|
const binaryContentTypeByExtension: Record<string, string> = {
|
||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
|
|
@ -84,8 +115,11 @@ function normalizeSelector(input: string): string {
|
||||||
return input.trim();
|
return input.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInclude(input: string | undefined): CompanyPortabilityInclude {
|
function parseInclude(
|
||||||
if (!input || !input.trim()) return { company: true, agents: true, projects: false, issues: false, skills: false };
|
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 values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
||||||
const include = {
|
const include = {
|
||||||
company: values.includes("company"),
|
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)));
|
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: {
|
export function resolveCompanyImportApiPath(input: {
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
targetMode: "new_company" | "existing_company";
|
targetMode: "new_company" | "existing_company";
|
||||||
|
|
@ -515,7 +807,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
.command("import")
|
.command("import")
|
||||||
.description("Import a portable markdown company package from local path, URL, or GitHub")
|
.description("Import a portable markdown company package from local path, URL, or GitHub")
|
||||||
.argument("<fromPathOrUrl>", "Source path or URL")
|
.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("--target <mode>", "Target mode: new | existing")
|
||||||
.option("-C, --company-id <id>", "Existing target company ID")
|
.option("-C, --company-id <id>", "Existing target company ID")
|
||||||
.option("--new-company-name <name>", "Name override for --target new")
|
.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) => {
|
.action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => {
|
||||||
try {
|
try {
|
||||||
const ctx = resolveCommandContext(opts);
|
const ctx = resolveCommandContext(opts);
|
||||||
|
const interactiveView = isInteractiveTerminal() && !ctx.json;
|
||||||
const from = fromPathOrUrl.trim();
|
const from = fromPathOrUrl.trim();
|
||||||
if (!from) {
|
if (!from) {
|
||||||
throw new Error("Source path or URL is required.");
|
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 agents = parseAgents(opts.agents);
|
||||||
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
|
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
|
||||||
if (!["rename", "skip", "replace"].includes(collision)) {
|
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 = {
|
const payload = {
|
||||||
source: sourcePayload,
|
source: sourcePayload,
|
||||||
include,
|
include,
|
||||||
|
|
@ -602,12 +898,39 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
|
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
const preview = await ctx.api.post<CompanyPortabilityPreviewResult>(importApiPath, payload);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imported = await ctx.api.post<CompanyPortabilityImportResult>(importApiPath, payload);
|
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) {
|
} catch (err) {
|
||||||
handleCommandError(err);
|
handleCommandError(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue