ux(codex-migrate): polish preview/result output

Restructure the migrate codex CLI output:

- Split into separate Before (preview) and After (result) messages
  so each can be tuned independently. Both render through clack's
  log.message so they pick up the standard '|' gutter.
- Group items by kind (Skills, Plugins, Memory, Secrets, Archive,
  Manual review, Other) instead of one flat list. Hide config items
  from display and exclude them from the summary count.
- Drop the internal kind/action tag (e.g. 'manual/manual'), strip
  '<kind>:' id prefixes and trailing ':N' disambiguators, and use
  '•' for bullets.
- Mute parenthetical action text.
- In result mode: replace status text with emoji ( migrated,
   error, ⏭️ skipped, ⚠️ conflict), show '(Migrated)' on success,
  show humanized failure reasons for known codes (plugin_missing,
  marketplace_missing, etc.), say '(Skipped)' for user-deselected
  skill/plugin items but keep the real message on manual-review
  skips. Drop warnings from the result message.
- In preview mode: omit the 'Next' section and move warnings to
  the bottom. Use generic action descriptions ('Copy Codex skill
  into OpenClaw', 'Install Codex plugin into OpenClaw').
- Drop the redundant 'Codex cached plugin bundles remain
  manual-review only.' warning — covered by the source-installed
  warning above it.
This commit is contained in:
Sarah Fortune
2026-05-13 13:11:24 -07:00
parent cf571c1b58
commit d7d1fba74b
4 changed files with 176 additions and 53 deletions

View File

@@ -441,9 +441,6 @@ export async function buildCodexMigrationPlan(
"Codex app-backed plugins were planned without source app accessibility verification. Re-run with --verify-plugin-apps to force a fresh source app/list check before planning native plugin activation.",
]
: []),
...(source.plugins.some((plugin) => plugin.sourceKind === "cache")
? ["Codex cached plugin bundles remain manual-review only."]
: []),
...(source.pluginDiscoveryError
? [
`Codex app-server plugin inventory discovery failed: ${source.pluginDiscoveryError}. Cached plugin bundles, if any, are advisory only.`,

View File

@@ -1,4 +1,4 @@
import { cancel, isCancel } from "@clack/prompts";
import { cancel, isCancel, log } from "@clack/prompts";
import { formatCliCommand } from "../cli/command-format.js";
import { withProgress } from "../cli/progress.js";
import { promptYesNo } from "../cli/prompt.js";
@@ -13,7 +13,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { writeRuntimeJson } from "../runtime.js";
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
import { runMigrationApply } from "./migrate/apply.js";
import { formatMigrationPlan } from "./migrate/output.js";
import { formatMigrationPreview } from "./migrate/output.js";
import { createMigrationPlan, resolveMigrationProvider } from "./migrate/providers.js";
import {
applyMigrationPluginSelection,
@@ -331,7 +331,7 @@ export async function migratePlanCommand(
if (opts.json) {
writeRuntimeJson(runtime, redactMigrationPlan(plan));
} else if (opts.suppressPlanLog !== true) {
runtime.log(formatMigrationPlan(plan).join("\n"));
log.message(formatMigrationPreview(plan).join("\n"));
}
return plan;
}

View File

@@ -1,76 +1,202 @@
import { log } from "@clack/prompts";
import { redactMigrationPlan } from "../../plugin-sdk/migration.js";
import type { MigrationApplyResult, MigrationItem, MigrationPlan } from "../../plugins/types.js";
import { writeRuntimeJson } from "../../runtime.js";
import type { RuntimeEnv } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
import {
formatMigrationPluginSelectionLabel,
getSelectableMigrationPluginItems,
} from "./selection.js";
import type { MigrateApplyOptions } from "./types.js";
function formatCount(value: number, label: string): string {
return `${value} ${label}${value === 1 ? "" : "s"}`;
}
export function formatMigrationPlan(plan: MigrationPlan): string[] {
const lines = [
`${theme.heading("Migration plan:")} ${plan.providerId}`,
`Source: ${plan.source}`,
];
function formatPlanHeader(plan: MigrationPlan, heading: string): string[] {
const lines = [`${theme.heading(heading)} ${plan.providerId}`, `Source: ${plan.source}`];
if (plan.target) {
lines.push(`Target: ${plan.target}`);
}
const visible = plan.items.filter((item) => !HIDDEN_KINDS.has(item.kind));
const visibleConflicts = visible.filter((item) => item.status === "conflict").length;
const visibleSensitive = visible.filter((item) => item.sensitive === true).length;
lines.push(
[
formatCount(plan.summary.total, "item"),
formatCount(plan.summary.conflicts, "conflict"),
formatCount(plan.summary.sensitive, "sensitive item"),
formatCount(visible.length, "item"),
formatCount(visibleConflicts, "conflict"),
formatCount(visibleSensitive, "sensitive item"),
].join(", "),
);
if (plan.warnings && plan.warnings.length > 0) {
lines.push("");
lines.push(theme.warn("Warnings:"));
for (const warning of plan.warnings) {
lines.push(`- ${warning}`);
return lines;
}
type ItemGroup = {
kind: string;
heading: string;
};
const ITEM_GROUPS: ItemGroup[] = [
{ kind: "skill", heading: "Skills:" },
{ kind: "plugin", heading: "Plugins:" },
{ kind: "memory", heading: "Memory:" },
{ kind: "secret", heading: "Secrets:" },
{ kind: "archive", heading: "Archive:" },
{ kind: "manual", heading: "Manual review:" },
];
const HIDDEN_KINDS = new Set(["config"]);
const KNOWN_KINDS = new Set(ITEM_GROUPS.map((group) => group.kind));
type FormatMode = "preview" | "result";
function formatPlanItems(plan: MigrationPlan, mode: FormatMode): string[] {
const lines: string[] = [];
const buckets = new Map<string, MigrationItem[]>();
const other: MigrationItem[] = [];
for (const item of plan.items) {
if (HIDDEN_KINDS.has(item.kind)) {
continue;
}
if (KNOWN_KINDS.has(item.kind)) {
const list = buckets.get(item.kind) ?? [];
list.push(item);
buckets.set(item.kind, list);
} else {
other.push(item);
}
}
const visibleItems = plan.items.slice(0, 25);
const visibleItemIds = new Set(visibleItems.map((item) => item.id));
const pluginItems = getSelectableMigrationPluginItems(plan);
const hasPluginHiddenByTruncation = pluginItems.some((item) => !visibleItemIds.has(item.id));
if (plan.providerId === "codex" && hasPluginHiddenByTruncation) {
for (const group of ITEM_GROUPS) {
const items = buckets.get(group.kind);
if (!items || items.length === 0) {
continue;
}
lines.push("");
lines.push(theme.heading("Native Codex plugins:"));
for (const item of pluginItems) {
lines.push(`- ${formatMigrationPluginSelectionLabel(item)}`);
lines.push(theme.heading(group.heading));
for (const item of items) {
lines.push(formatMigrationItem(item, mode));
}
}
if (visibleItems.length > 0) {
if (other.length > 0) {
lines.push("");
lines.push(theme.heading("Items:"));
for (const item of visibleItems) {
lines.push(formatMigrationItem(item));
}
if (plan.items.length > visibleItems.length) {
lines.push(`- ... ${plan.items.length - visibleItems.length} more`);
}
}
if (plan.nextSteps && plan.nextSteps.length > 0) {
lines.push("");
lines.push(theme.heading("Next:"));
for (const step of plan.nextSteps) {
lines.push(`- ${step}`);
lines.push(theme.heading("Other:"));
for (const item of other) {
lines.push(formatMigrationItem(item, mode));
}
}
return lines;
}
function formatMigrationItem(item: MigrationItem): string {
const target = item.target ? ` -> ${item.target}` : "";
const message = item.message ? ` (${item.message})` : item.reason ? ` (${item.reason})` : "";
function formatPlanWarnings(plan: MigrationPlan): string[] {
if (!plan.warnings || plan.warnings.length === 0) {
return [];
}
const lines = ["", theme.warn("Warnings:")];
for (const warning of plan.warnings) {
lines.push(`${warning}`);
}
return lines;
}
export function formatMigrationPreview(plan: MigrationPlan): string[] {
return [
...formatPlanHeader(plan, "Migration preview:"),
...formatPlanItems(plan, "preview"),
...formatPlanWarnings(plan),
];
}
export function formatMigrationResult(plan: MigrationPlan): string[] {
const lines = [...formatPlanHeader(plan, "Migration plan:"), ...formatPlanItems(plan, "result")];
if (plan.nextSteps && plan.nextSteps.length > 0) {
lines.push("");
lines.push(theme.heading("Next:"));
for (const step of plan.nextSteps) {
lines.push(`${step}`);
}
}
return lines;
}
function formatItemDisplayName(item: MigrationItem): string {
const colonIndex = item.id.indexOf(":");
const withoutPrefix = colonIndex >= 0 ? item.id.slice(colonIndex + 1) : item.id;
return withoutPrefix.replace(/:\d+$/, "");
}
const REASON_CODE_MESSAGES: Record<string, string> = {
plugin_missing: "Plugin not found in the Codex marketplace.",
marketplace_missing: "Codex marketplace is unavailable.",
disabled: "Plugin is disabled in Codex.",
refresh_failed: "Failed to refresh the Codex plugin marketplace.",
auth_required: "Plugin requires additional authentication.",
already_active: "Plugin is already active in OpenClaw.",
installed: "Plugin is already installed in OpenClaw.",
plugin_install_failed: "Plugin installation failed.",
codex_subscription_required: "Plugin requires an active Codex subscription.",
"not selected for migration": "Skipped because it was not selected for migration.",
};
function humanizeReason(reason: string | undefined): string | undefined {
if (!reason) {
return undefined;
}
return REASON_CODE_MESSAGES[reason] ?? reason;
}
function formatItemMessage(item: MigrationItem, mode: FormatMode): string | undefined {
if (mode === "preview") {
if (item.kind === "skill" && item.action === "copy") {
return "Copy Codex skill into OpenClaw";
}
if (item.kind === "plugin" && item.action === "install") {
return "Install Codex plugin into OpenClaw";
}
return item.message ?? humanizeReason(item.reason);
}
if (
(item.kind === "skill" && item.action === "copy") ||
(item.kind === "plugin" && item.action === "install")
) {
if (item.status === "migrated") {
return "Migrated";
}
if (item.status === "skipped") {
return "Skipped";
}
if (item.status === "error" || item.status === "conflict") {
return humanizeReason(item.reason) ?? item.message;
}
return undefined;
}
if (item.status === "error" || item.status === "conflict") {
return humanizeReason(item.reason) ?? item.message;
}
return item.message ?? humanizeReason(item.reason);
}
const RESULT_STATUS_GLYPHS: Record<string, string> = {
migrated: "✅",
error: "❌",
skipped: "⏭️ ",
conflict: "⚠️ ",
};
function formatItemPrefix(item: MigrationItem, mode: FormatMode): string {
if (mode === "result") {
const glyph = RESULT_STATUS_GLYPHS[item.status];
if (glyph) {
return `${glyph} `;
}
return "• ";
}
return item.status === "planned" ? "• " : `${item.status}: `;
}
function formatMigrationItem(item: MigrationItem, mode: FormatMode): string {
const name = formatItemDisplayName(item);
const message = formatItemMessage(item, mode);
const messageSuffix = message ? ` ${theme.muted(`(${message})`)}` : "";
const sensitive = item.sensitive ? " [sensitive]" : "";
return `- ${item.status}: ${item.kind}/${item.action} ${item.id}${target}${sensitive}${message}`;
const prefix = formatItemPrefix(item, mode);
return `${prefix}${name}${sensitive}${messageSuffix}`;
}
export function assertConflictFreePlan(plan: MigrationPlan, providerId: string): void {
@@ -90,7 +216,7 @@ export function writeApplyResult(
writeRuntimeJson(runtime, redactMigrationPlan(result));
return;
}
runtime.log(formatMigrationPlan(result).join("\n"));
log.message(formatMigrationResult(result).join("\n"));
if (result.backupPath) {
runtime.log(`Backup: ${result.backupPath}`);
} else if (!opts.noBackup) {

View File

@@ -212,7 +212,7 @@ export async function runSetupMigrationImport(params: {
{ applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig },
{ createMigrationLogger, buildMigrationReportDir },
{ createPreMigrationBackup },
{ assertApplySucceeded, assertConflictFreePlan, formatMigrationPlan },
{ assertApplySucceeded, assertConflictFreePlan, formatMigrationPreview, formatMigrationResult },
{ resolveStateDir },
onboardHelpers,
] = await Promise.all([
@@ -273,7 +273,7 @@ export async function runSetupMigrationImport(params: {
logger: createMigrationLogger(params.runtime),
};
const plan = await provider.plan(ctx);
await params.prompter.note(formatMigrationPlan(plan).join("\n"), "Migration preview");
await params.prompter.note(formatMigrationPreview(plan).join("\n"), "Migration preview");
assertConflictFreePlan(plan, providerId);
const confirmed =
@@ -307,6 +307,6 @@ export async function runSetupMigrationImport(params: {
reportDir: result.reportDir ?? reportDir,
};
assertApplySucceeded(withReport);
await params.prompter.note(formatMigrationPlan(withReport).join("\n"), "Migration applied");
await params.prompter.note(formatMigrationResult(withReport).join("\n"), "Migration applied");
await params.prompter.outro("Migration complete. Run `openclaw doctor` next.");
}