/** Selection helpers for filtering migration plan items before apply. */ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { markMigrationItemSkipped, summarizeMigrationItems } from "../../plugin-sdk/migration.js"; import type { MigrationItem, MigrationPlan } from "../../plugins/types.js"; import { MIGRATION_CONFLICT_REASON_PHRASES } from "./output.js"; // Selection tokens are shared with the command and prompt implementations. const MIGRATION_NOT_SELECTED_REASON = "not selected for migration"; export const MIGRATION_SELECTION_ACCEPT = "__openclaw_migrate_accept_recommended__"; export const MIGRATION_SELECTION_TOGGLE_ALL_ON = "__openclaw_migrate_toggle_all_on__"; export const MIGRATION_SELECTION_TOGGLE_ALL_OFF = "__openclaw_migrate_toggle_all_off__"; type InteractiveMigrationSelection = { action: "select"; selectedItemIds: Set }; function normalizeSelectionRef(value: string): string { return value.trim().toLowerCase(); } function readMigrationSkillName(item: MigrationItem): string | undefined { return normalizeOptionalString(item.details?.skillName); } function readMigrationSkillSourceLabel(item: MigrationItem): string | undefined { return normalizeOptionalString(item.details?.sourceLabel); } function readMigrationPluginName(item: MigrationItem): string | undefined { return normalizeOptionalString(item.details?.pluginName); } function readMigrationPluginConfigKey(item: MigrationItem): string | undefined { return normalizeOptionalString(item.details?.configKey); } function readMigrationPluginMarketplaceName(item: MigrationItem): string | undefined { return normalizeOptionalString(item.details?.marketplaceName); } function migrationSkillRefs(item: MigrationItem): string[] { const skillName = readMigrationSkillName(item); const idSuffix = item.id.startsWith("skill:") ? item.id.slice("skill:".length) : undefined; const sourceBase = item.source ? path.basename(item.source) : undefined; const targetBase = item.target ? path.basename(item.target) : undefined; return [item.id, idSuffix, skillName, sourceBase, targetBase].filter( (value): value is string => typeof value === "string" && value.trim().length > 0, ); } function migrationPluginRefs(item: MigrationItem): string[] { const pluginName = readMigrationPluginName(item); const configKey = readMigrationPluginConfigKey(item); const idSuffix = item.id.startsWith("plugin:") ? item.id.slice("plugin:".length) : undefined; const sourceBase = item.source ? path.basename(item.source) : undefined; const targetBase = item.target ? path.basename(item.target) : undefined; return [item.id, idSuffix, pluginName, configKey, sourceBase, targetBase].filter( (value): value is string => typeof value === "string" && value.trim().length > 0, ); } function formatSelectionRefList(values: readonly string[]): string { if (values.length === 0) { return "none"; } return values.map((value) => `"${value}"`).join(", "); } function buildSelectionIndex( items: readonly MigrationItem[], refsForItem: (item: MigrationItem) => readonly string[], ): Map> { const index = new Map>(); for (const item of items) { for (const ref of refsForItem(item)) { const normalized = normalizeSelectionRef(ref); if (!normalized) { continue; } const existing = index.get(normalized) ?? new Set(); existing.add(item.id); index.set(normalized, existing); } } return index; } function resolveSelectedMigrationItemIds(params: { items: readonly MigrationItem[]; selectedRefs: readonly string[]; refsForItem: (item: MigrationItem) => readonly string[]; formatSelectionLabel: (item: MigrationItem) => string; kindLabel: "skill" | "plugin"; availableLabel: "skills" | "plugins"; }): Set { const index = buildSelectionIndex(params.items, params.refsForItem); const selectedIds = new Set(); const unknownRefs: string[] = []; const ambiguousRefs: string[] = []; for (const ref of params.selectedRefs) { const normalized = normalizeSelectionRef(ref); if (!normalized) { continue; } const matches = index.get(normalized); if (!matches) { unknownRefs.push(ref); continue; } if (matches.size > 1) { ambiguousRefs.push(ref); continue; } const [id] = matches; if (id) { selectedIds.add(id); } } if (unknownRefs.length > 0 || ambiguousRefs.length > 0) { const available = params.items .map(params.formatSelectionLabel) .toSorted((a, b) => a.localeCompare(b)); const titleKind = expectDefined(params.kindLabel[0], "kind label entry at 0").toUpperCase() + params.kindLabel.slice(1); const parts: string[] = []; if (unknownRefs.length > 0) { parts.push( `No migratable ${params.kindLabel} matched ${formatSelectionRefList(unknownRefs)}.`, ); } if (ambiguousRefs.length > 0) { parts.push(`${titleKind} selection ${formatSelectionRefList(ambiguousRefs)} was ambiguous.`); } parts.push( `Available ${params.availableLabel}: ${available.length > 0 ? available.join(", ") : "none"}.`, ); throw new Error(parts.join(" ")); } return selectedIds; } /** Returns skill copy items that can still be selected or deselected. */ export function getSelectableMigrationSkillItems(plan: MigrationPlan): MigrationItem[] { return plan.items.filter( (item) => item.kind === "skill" && item.action === "copy" && (item.status === "planned" || item.status === "conflict"), ); } /** Returns plugin install items that can still be selected or deselected. */ export function getSelectableMigrationPluginItems(plan: MigrationPlan): MigrationItem[] { // Only source-installed curated Codex plugins become selectable install items. // Cached/manual-review plugin bundles are emitted as manual items, the aggregate // Codex plugin config write is a config item, and already skipped/applied/error // items are no longer user-actionable in the selector. Conflicts stay selectable // so the user can explicitly choose or deselect them before apply. return plan.items.filter( (item) => item.kind === "plugin" && item.action === "install" && (item.status === "planned" || item.status === "conflict"), ); } /** Formats the visible label for a plugin migration checkbox. */ export function formatMigrationPluginSelectionLabel(item: MigrationItem): string { return readMigrationPluginName(item) ?? item.id.replace(/^plugin:/u, ""); } /** Defaults migration checkboxes to planned item ids in plan order. */ export function getDefaultMigrationSelectionValues(items: readonly MigrationItem[]): string[] { return items.filter((item) => item.status === "planned").map((item) => item.id); } /** Formats the visible label for a skill migration checkbox. */ export function formatMigrationSkillSelectionLabel(item: MigrationItem): string { return readMigrationSkillName(item) ?? item.id.replace(/^skill:/u, ""); } function humanizeMigrationConflictReason(reason: string | undefined): string { if (!reason) { return "conflict"; } return MIGRATION_CONFLICT_REASON_PHRASES[reason] ?? reason; } /** Formats conflict helper text for a skill migration checkbox. */ export function formatMigrationSkillSelectionHint(item: MigrationItem): string | undefined { if (item.status !== "conflict") { return undefined; } const sourceLabel = readMigrationSkillSourceLabel(item); const reason = humanizeMigrationConflictReason(item.reason); return sourceLabel ? `${sourceLabel} ${reason}` : reason; } /** Formats conflict helper text for a plugin migration checkbox. */ export function formatMigrationPluginSelectionHint(item: MigrationItem): string | undefined { if (item.status !== "conflict") { return undefined; } const marketplace = readMigrationPluginMarketplaceName(item); const reason = humanizeMigrationConflictReason(item.reason); return marketplace ? `${marketplace} plugin ${reason}` : reason; } /** Keeps skill copies and their per-skill config patches inside the same selection. */ export function applyMigrationSelectedSkillItemIds( plan: MigrationPlan, selectedItemIds: ReadonlySet, ): MigrationPlan { const selectable = getSelectableMigrationSkillItems(plan); const selectableIds = new Set(selectable.map((item) => item.id)); const selectedSkillNames = new Set( selectable .filter((item) => selectedItemIds.has(item.id)) .map( (item) => readMigrationSkillName(item) ?? (item.source ? path.basename(item.source) : undefined), ) .filter((name) => name !== undefined), ); const items = plan.items.map((item) => { const configPath = item.kind === "config" ? item.details?.path : undefined; // Per-skill patches keep conflicts independent, so a deselected skill's // policy cannot mutate the target or block an otherwise valid import. if ( Array.isArray(configPath) && configPath.length === 3 && configPath[0] === "skills" && configPath[1] === "entries" && !selectedSkillNames.has(configPath[2]) && (item.status === "planned" || item.status === "conflict") ) { return markMigrationItemSkipped(item, MIGRATION_NOT_SELECTED_REASON); } if (!selectableIds.has(item.id) || selectedItemIds.has(item.id)) { return item; } return markMigrationItemSkipped(item, MIGRATION_NOT_SELECTED_REASON); }); return { ...plan, items, summary: summarizeMigrationItems(items), }; } /** Applies skill refs passed by CLI flags to a migration plan. */ export function applyMigrationSkillSelection( plan: MigrationPlan, selectedSkillRefs: readonly string[] | undefined, ): MigrationPlan { if (selectedSkillRefs === undefined) { return plan; } const selectable = getSelectableMigrationSkillItems(plan); const selectedIds = resolveSelectedMigrationItemIds({ items: selectable, selectedRefs: selectedSkillRefs, refsForItem: migrationSkillRefs, formatSelectionLabel: formatMigrationSkillSelectionLabel, kindLabel: "skill", availableLabel: "skills", }); return applyMigrationSelectedSkillItemIds(plan, selectedIds); } /** Applies plugin refs passed by CLI flags to a migration plan. */ export function applyMigrationPluginSelection( plan: MigrationPlan, selectedPluginRefs: readonly string[] | undefined, ): MigrationPlan { if (selectedPluginRefs === undefined) { return plan; } const selectable = getSelectableMigrationPluginItems(plan); const selectedIds = resolveSelectedMigrationItemIds({ items: selectable, selectedRefs: selectedPluginRefs, refsForItem: migrationPluginRefs, formatSelectionLabel: formatMigrationPluginSelectionLabel, kindLabel: "plugin", availableLabel: "plugins", }); return applyMigrationSelectedPluginItemIds(plan, selectedIds); } /** Marks unselected plugin items skipped and filters matching Codex plugin config writes. */ export function applyMigrationSelectedPluginItemIds( plan: MigrationPlan, selectedItemIds: ReadonlySet, ): MigrationPlan { const selectable = getSelectableMigrationPluginItems(plan); const selectableIds = new Set(selectable.map((item) => item.id)); const selectedConfigKeys = new Set( selectable .filter((item) => selectedItemIds.has(item.id)) .map(readMigrationPluginConfigKey) .filter((value): value is string => value !== undefined), ); const items = plan.items.map((item) => { const selectedConfigItem = applyCodexPluginConfigSelection(item, selectedConfigKeys); if (selectedConfigItem) { return selectedConfigItem; } if (!selectableIds.has(item.id) || selectedItemIds.has(item.id)) { return item; } return markMigrationItemSkipped(item, MIGRATION_NOT_SELECTED_REASON); }); return { ...plan, items, summary: summarizeMigrationItems(items), }; } function applyCodexPluginConfigSelection( item: MigrationItem, selectedConfigKeys: ReadonlySet, ): MigrationItem | undefined { // Nonmatching config shapes still pass through ordinary item-id selection. if (item.kind !== "config" || item.action !== "merge") { return undefined; } const value = item.details?.value; if (!isRecord(value)) { return undefined; } const config = value.config; if (!isRecord(config)) { return undefined; } const codexPlugins = config.codexPlugins; if (!isRecord(codexPlugins) || !isRecord(codexPlugins.plugins)) { return undefined; } const plugins = Object.fromEntries( Object.entries(codexPlugins.plugins).filter(([configKey]) => selectedConfigKeys.has(configKey)), ); if (Object.keys(plugins).length === 0) { return markMigrationItemSkipped(item, MIGRATION_NOT_SELECTED_REASON); } return { ...item, details: { ...item.details, value: { ...value, config: { ...config, codexPlugins: { ...codexPlugins, plugins, }, }, }, }, }; } /** Resolves checkbox values into migration item ids for either selector. */ export function resolveInteractiveMigrationSelection( items: readonly MigrationItem[], selectedValues: readonly string[], ): InteractiveMigrationSelection { const selectableIds = new Set(items.map((item) => item.id)); const selectedItemIds = new Set(selectedValues.filter((value) => selectableIds.has(value))); if (selectedItemIds.size > 0) { return { action: "select", selectedItemIds }; } const selectedValueSet = new Set(selectedValues); if (selectedValueSet.has(MIGRATION_SELECTION_TOGGLE_ALL_OFF)) { return { action: "select", selectedItemIds: new Set() }; } if (selectedValueSet.has(MIGRATION_SELECTION_TOGGLE_ALL_ON)) { return { action: "select", selectedItemIds: selectableIds }; } return { action: "select", selectedItemIds, }; } function isMigrationSelectionToggleValue(value: string): boolean { return ( value === MIGRATION_SELECTION_TOGGLE_ALL_ON || value === MIGRATION_SELECTION_TOGGLE_ALL_OFF ); } function selectedMigrationItemValues(selectedValues: readonly string[]): string[] { return selectedValues.filter((value) => !isMigrationSelectionToggleValue(value)); } function resolveMigrationSelectionBulkToggleValues( activatedValue: string | undefined, selectableValues: readonly string[], ): string[] | undefined { if (activatedValue === MIGRATION_SELECTION_TOGGLE_ALL_ON) { return [MIGRATION_SELECTION_TOGGLE_ALL_ON, ...selectableValues]; } if (activatedValue === MIGRATION_SELECTION_TOGGLE_ALL_OFF) { return [MIGRATION_SELECTION_TOGGLE_ALL_OFF]; } return undefined; } /** Reconciles all/none checkbox toggles for the skill-selection prompt. */ export function reconcileInteractiveMigrationSkillToggleValues( selectedValues: readonly string[], activatedValue: string | undefined, selectableValues: readonly string[], ): string[] { const bulkValues = resolveMigrationSelectionBulkToggleValues(activatedValue, selectableValues); if (bulkValues !== undefined) { return bulkValues; } if (activatedValue !== undefined && selectableValues.includes(activatedValue)) { return selectedMigrationItemValues(selectedValues); } return selectedValues.filter( (value) => value !== MIGRATION_SELECTION_TOGGLE_ALL_ON || !selectedValues.includes(MIGRATION_SELECTION_TOGGLE_ALL_OFF), ); } /** Reconciles Enter-key selection behavior for interactive migration prompts. */ export function reconcileInteractiveMigrationEnterValues( selectedValues: readonly string[], activatedValue: string | undefined, selectableValues: readonly string[], opts: { preserveDeselectedActivatedValue?: boolean } = {}, ): string[] { const bulkValues = resolveMigrationSelectionBulkToggleValues(activatedValue, selectableValues); if (bulkValues !== undefined) { return bulkValues; } if (activatedValue !== undefined && selectableValues.includes(activatedValue)) { const selectedSelectableValues = selectedMigrationItemValues(selectedValues); if (opts.preserveDeselectedActivatedValue && !selectedValues.includes(activatedValue)) { return selectedSelectableValues; } return uniqueStrings([...selectedSelectableValues, activatedValue]); } return [...selectedValues]; } /** Reconciles keyboard shortcuts for all/none migration prompt selections. */ export function reconcileInteractiveMigrationShortcutValues( previousValues: readonly string[], selectedValues: readonly string[], selectableValues: readonly string[], key: "a" | "i", ): string[] { const previousSelectable = previousValues.filter((value) => selectableValues.includes(value)); if (key === "a" && previousSelectable.length === selectableValues.length) { return [MIGRATION_SELECTION_TOGGLE_ALL_OFF]; } const selectedSelectable = selectedValues.filter((value) => selectableValues.includes(value)); if (selectedSelectable.length === selectableValues.length) { return [MIGRATION_SELECTION_TOGGLE_ALL_ON, ...selectableValues]; } if (selectedSelectable.length === 0) { return [MIGRATION_SELECTION_TOGGLE_ALL_OFF]; } return selectedSelectable; }