From 81349cdc2a9d5143fd0991ed858b739e7d96e05c Mon Sep 17 00:00:00 2001 From: Kevin Lin Date: Tue, 5 May 2026 16:41:26 -0700 Subject: [PATCH] feat: improve Codex skill migration selection (#77597) * feat: improve Codex skill migration selection * docs: add Codex migration changelog entry * fix codex skill migration bulk toggles * fix codex migration skip selection * fix codex migration skip option order * fix: handle codex migration shortcut toggles * fix codex migration shortcut reconciliation * fix: unblock Codex migration CI --- CHANGELOG.md | 1 + docs/cli/migrate.md | 7 +- package.json | 1 + pnpm-lock.yaml | 3 + src/commands/migrate.test.ts | 175 ++++++++++++++- src/commands/migrate.ts | 54 +++-- src/commands/migrate/selection.test.ts | 152 +++++++++++++ src/commands/migrate/selection.ts | 95 ++++++++ .../migrate/skill-selection-prompt.ts | 203 ++++++++++++++++++ 9 files changed, 672 insertions(+), 19 deletions(-) create mode 100644 src/commands/migrate/skill-selection-prompt.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b46cc428c..52b4db947a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc. - Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc. - Google Meet: preserve `realtime.introMessage: ""` so realtime Chrome joins can stay silent instead of restoring the default spoken intro. Thanks @vincentkoc. +- CLI/migrate: add bulk on/off and skip controls to interactive Codex skill migration, leaving conflicting skill copies unchecked by default. (#77597) Thanks @kevinslin. - OpenAI/Codex media: advertise Codex audio transcription in runtime and manifest metadata and route active Codex chat models to the OpenAI transcription default instead of sending chat model ids to audio transcription. Thanks @vincentkoc. - Models/auth: add `openclaw models auth list [--provider ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. - Cron CLI: add `openclaw cron list --agent `, normalize the requested agent id, and include jobs without a stored agent id under the configured default agent while keeping `cron list` unfiltered when no agent is supplied. Fixes #77118. Thanks @zhanggttry. diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index cdf2db4a1e7..85e0af66520 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -119,9 +119,10 @@ your personal Codex CLI state by default. Running `openclaw migrate codex` in an interactive terminal previews the full plan, then opens a checkbox selector for skill copy items before the final -apply confirmation. All skills start selected; uncheck any skill you do not want -copied into this agent. For scripted or exact runs, pass `--skill ` once -per skill, for example: +apply confirmation. Use `Toggle all on` or `Toggle all off` for bulk selection; +planned skills start checked, conflict skills start unchecked, and `Skip for now` +leaves skills unchanged without applying. For scripted or exact runs, pass +`--skill ` once per skill, for example: ```bash openclaw migrate codex --dry-run --skill gog-vault77-google-workspace diff --git a/package.json b/package.json index 0688ff9732c..f8a986fc443 100644 --- a/package.json +++ b/package.json @@ -1674,6 +1674,7 @@ "@aws-sdk/client-bedrock-runtime": "3.1042.0", "@aws-sdk/credential-provider-node": "3.972.39", "@aws/bedrock-token-generator": "^1.1.0", + "@clack/core": "^1.3.0", "@clack/prompts": "^1.3.0", "@google/genai": "^1.51.0", "@grammyjs/runner": "^2.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c5587f9114..8c76fc38871 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,9 @@ importers: '@aws/bedrock-token-generator': specifier: ^1.1.0 version: 1.1.0 + '@clack/core': + specifier: ^1.3.0 + version: 1.3.0 '@clack/prompts': specifier: ^1.3.0 version: 1.3.0 diff --git a/src/commands/migrate.test.ts b/src/commands/migrate.test.ts index c2926d83140..2c14af49500 100644 --- a/src/commands/migrate.test.ts +++ b/src/commands/migrate.test.ts @@ -34,7 +34,10 @@ vi.mock("../cli/prompt.js", () => ({ vi.mock("@clack/prompts", () => ({ cancel: mocks.clackCancel, isCancel: mocks.clackIsCancel, - multiselect: mocks.multiselect, +})); + +vi.mock("./migrate/skill-selection-prompt.js", () => ({ + promptMigrationSkillSelectionValues: mocks.multiselect, })); vi.mock("../plugins/migration-provider-runtime.js", () => ({ @@ -47,6 +50,11 @@ vi.mock("./backup.js", () => ({ backupCreateCommand: mocks.backupCreateCommand, })); +const { + MIGRATION_SKILL_SELECTION_SKIP, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, +} = await import("./migrate/selection.js"); const { migrateApplyCommand, migrateDefaultCommand } = await import("./migrate.js"); function plan(overrides: Partial = {}): MigrationPlan { @@ -215,10 +223,22 @@ describe("migrateApplyCommand", () => { message: expect.stringContaining("Select Codex skills"), initialValues: ["skill:alpha", "skill:beta"], required: false, - options: expect.arrayContaining([ + options: [ + expect.objectContaining({ + value: MIGRATION_SKILL_SELECTION_SKIP, + label: "Skip for now", + }), + expect.objectContaining({ + value: MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + label: "Toggle all on", + }), + expect.objectContaining({ + value: MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + label: "Toggle all off", + }), expect.objectContaining({ value: "skill:alpha", label: "alpha" }), expect.objectContaining({ value: "skill:beta", label: "beta" }), - ]), + ], }), ); expect(mocks.promptYesNo).toHaveBeenCalledWith("Apply this migration now?", false); @@ -237,6 +257,155 @@ describe("migrateApplyCommand", () => { ); }); + it("leaves conflicting Codex skills unchecked by default", async () => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + const planned = codexSkillPlan({ + summary: { + total: 3, + planned: 2, + migrated: 0, + skipped: 0, + conflicts: 1, + errors: 0, + sensitive: 0, + }, + items: [ + { + id: "skill:alpha", + kind: "skill", + action: "copy", + status: "planned", + details: { skillName: "alpha" }, + }, + { + id: "skill:beta", + kind: "skill", + action: "copy", + status: "conflict", + reason: "target exists", + details: { skillName: "beta" }, + }, + { + id: "archive:config.toml", + kind: "archive", + action: "archive", + status: "planned", + }, + ], + }); + mocks.provider.plan.mockResolvedValue(planned); + mocks.multiselect.mockResolvedValue(["skill:alpha"]); + mocks.promptYesNo.mockResolvedValue(false); + + await migrateDefaultCommand(runtime, { provider: "codex" }); + + expect(mocks.multiselect).toHaveBeenCalledWith( + expect.objectContaining({ + initialValues: ["skill:alpha"], + options: expect.arrayContaining([ + expect.objectContaining({ value: "skill:beta", label: "beta" }), + ]), + }), + ); + expect(mocks.promptYesNo).toHaveBeenCalledWith("Apply this migration now?", false); + expect(mocks.provider.apply).not.toHaveBeenCalled(); + }); + + it("skips interactive Codex skill migration when Skip for now is selected", async () => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + const planned = codexSkillPlan(); + mocks.provider.plan.mockResolvedValue(planned); + mocks.multiselect.mockResolvedValue([MIGRATION_SKILL_SELECTION_SKIP]); + + const result = await migrateDefaultCommand(runtime, { provider: "codex" }); + + expect(result).toBe(planned); + expect(mocks.promptYesNo).not.toHaveBeenCalled(); + expect(mocks.backupCreateCommand).not.toHaveBeenCalled(); + expect(mocks.provider.apply).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("Codex skill migration skipped for now."); + }); + + it("applies Toggle all off before interactive Codex migration apply", async () => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + const planned = codexSkillPlan(); + mocks.provider.plan.mockResolvedValue(planned); + mocks.multiselect.mockResolvedValue([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]); + mocks.promptYesNo.mockResolvedValue(true); + mocks.provider.apply.mockImplementation(async (_ctx, selectedPlan: MigrationPlan) => ({ + ...selectedPlan, + summary: { ...selectedPlan.summary, planned: 0, migrated: 1 }, + items: selectedPlan.items.map((item) => + item.status === "planned" ? { ...item, status: "migrated" as const } : item, + ), + })); + + await migrateDefaultCommand(runtime, { provider: "codex" }); + + const appliedPlan = mocks.provider.apply.mock.calls[0]?.[1] as MigrationPlan; + expect(appliedPlan.summary).toMatchObject({ planned: 1, skipped: 2, conflicts: 0 }); + expect(appliedPlan.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "skill:alpha", + status: "skipped", + reason: "not selected for migration", + }), + expect.objectContaining({ + id: "skill:beta", + status: "skipped", + reason: "not selected for migration", + }), + expect.objectContaining({ id: "archive:config.toml", status: "planned" }), + ]), + ); + }); + + it("applies Toggle all on unless Toggle all off is also selected", async () => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + const planned = codexSkillPlan(); + mocks.provider.plan.mockResolvedValue(planned); + mocks.multiselect.mockResolvedValue([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON]); + mocks.promptYesNo.mockResolvedValue(true); + mocks.provider.apply.mockImplementation(async (_ctx, selectedPlan: MigrationPlan) => ({ + ...selectedPlan, + summary: { ...selectedPlan.summary, planned: 0, migrated: 3 }, + items: selectedPlan.items.map((item) => + item.status === "planned" ? { ...item, status: "migrated" as const } : item, + ), + })); + + await migrateDefaultCommand(runtime, { provider: "codex" }); + + let appliedPlan = mocks.provider.apply.mock.calls[0]?.[1] as MigrationPlan; + expect(appliedPlan.summary).toMatchObject({ planned: 3, skipped: 0, conflicts: 0 }); + + mocks.provider.plan.mockResolvedValue(planned); + mocks.multiselect.mockResolvedValue([ + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + ]); + mocks.promptYesNo.mockResolvedValue(true); + mocks.provider.apply.mockClear(); + + await migrateDefaultCommand(runtime, { provider: "codex" }); + + appliedPlan = mocks.provider.apply.mock.calls[0]?.[1] as MigrationPlan; + expect(appliedPlan.summary).toMatchObject({ planned: 1, skipped: 2, conflicts: 0 }); + }); + it("does not apply when interactive apply confirmation is declined", async () => { Object.defineProperty(process.stdin, "isTTY", { configurable: true, diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 598ce57b023..3015e25938f 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -1,4 +1,4 @@ -import { cancel, isCancel, multiselect } from "@clack/prompts"; +import { cancel, isCancel } from "@clack/prompts"; import { promptYesNo } from "../cli/prompt.js"; import { getRuntimeConfig } from "../config/config.js"; import { redactMigrationPlan } from "../plugin-sdk/migration.js"; @@ -18,9 +18,15 @@ import { applyMigrationSkillSelection, formatMigrationSkillSelectionHint, formatMigrationSkillSelectionLabel, + getDefaultMigrationSkillSelectionValues, getMigrationSkillSelectionValue, getSelectableMigrationSkillItems, + MIGRATION_SKILL_SELECTION_SKIP, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + resolveInteractiveMigrationSkillSelection, } from "./migrate/selection.js"; +import { promptMigrationSkillSelectionValues } from "./migrate/skill-selection-prompt.js"; import type { MigrateApplyOptions, MigrateCommonOptions, @@ -51,26 +57,48 @@ async function promptCodexMigrationSkillSelection( if (skillItems.length === 0) { return plan; } - const selected = await multiselect({ + const selected = await promptMigrationSkillSelectionValues({ message: stylePromptMessage("Select Codex skills to migrate into this agent"), - options: skillItems.map((item) => { - const hint = formatMigrationSkillSelectionHint(item); - return { - value: getMigrationSkillSelectionValue(item), - label: formatMigrationSkillSelectionLabel(item), - hint: hint === undefined ? undefined : stylePromptHint(hint), - }; - }), - initialValues: skillItems.map(getMigrationSkillSelectionValue), + options: [ + { + value: MIGRATION_SKILL_SELECTION_SKIP, + label: "Skip for now", + }, + { + value: MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + label: "Toggle all on", + }, + { + value: MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + label: "Toggle all off", + }, + ...skillItems.map((item) => { + const hint = formatMigrationSkillSelectionHint(item); + return { + value: getMigrationSkillSelectionValue(item), + label: formatMigrationSkillSelectionLabel(item), + hint: hint === undefined ? undefined : stylePromptHint(hint), + }; + }), + ], + initialValues: getDefaultMigrationSkillSelectionValues(skillItems), required: false, + selectableValues: skillItems.map(getMigrationSkillSelectionValue), }); if (isCancel(selected)) { cancel(stylePromptTitle("Migration cancelled.") ?? "Migration cancelled."); runtime.log("Migration cancelled."); return null; } - const selectedPlan = applyMigrationSelectedSkillItemIds(plan, new Set(selected)); - runtime.log(`Selected ${selected.length} of ${skillItems.length} Codex skills for migration.`); + const selection = resolveInteractiveMigrationSkillSelection(skillItems, selected ?? []); + if (selection.action === "skip") { + runtime.log("Codex skill migration skipped for now."); + return null; + } + const selectedPlan = applyMigrationSelectedSkillItemIds(plan, selection.selectedItemIds); + runtime.log( + `Selected ${selection.selectedItemIds.size} of ${skillItems.length} Codex skills for migration.`, + ); return selectedPlan; } diff --git a/src/commands/migrate/selection.test.ts b/src/commands/migrate/selection.test.ts index 392e8cff1d7..d4567057da1 100644 --- a/src/commands/migrate/selection.test.ts +++ b/src/commands/migrate/selection.test.ts @@ -3,7 +3,14 @@ import type { MigrationItem, MigrationPlan } from "../../plugins/types.js"; import { applyMigrationSelectedSkillItemIds, applyMigrationSkillSelection, + getDefaultMigrationSkillSelectionValues, + MIGRATION_SKILL_SELECTION_SKIP, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, MIGRATION_SKILL_NOT_SELECTED_REASON, + reconcileInteractiveMigrationShortcutValues, + reconcileInteractiveMigrationSkillToggleValues, + resolveInteractiveMigrationSkillSelection, } from "./selection.js"; function skillItem(params: { @@ -136,6 +143,151 @@ describe("applyMigrationSkillSelection", () => { expect(selected.items.every((item) => item.status === "skipped")).toBe(true); }); + it("defaults interactive selection to planned skills only", () => { + expect( + getDefaultMigrationSkillSelectionValues([ + skillItem({ id: "skill:alpha", name: "alpha" }), + skillItem({ + id: "skill:beta", + name: "beta", + status: "conflict", + reason: "target exists", + }), + ]), + ).toEqual(["skill:alpha"]); + }); + + it("resolves interactive special options with skip and toggle-off precedence", () => { + const items = [ + skillItem({ id: "skill:alpha", name: "alpha" }), + skillItem({ + id: "skill:beta", + name: "beta", + status: "conflict", + reason: "target exists", + }), + ]; + + expect( + resolveInteractiveMigrationSkillSelection(items, [ + MIGRATION_SKILL_SELECTION_SKIP, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + ]), + ).toEqual({ action: "skip" }); + expect( + resolveInteractiveMigrationSkillSelection(items, [ + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + ]), + ).toEqual({ action: "select", selectedItemIds: new Set() }); + expect( + resolveInteractiveMigrationSkillSelection(items, [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON]), + ).toEqual({ + action: "select", + selectedItemIds: new Set(["skill:alpha", "skill:beta"]), + }); + expect( + resolveInteractiveMigrationSkillSelection(items, [ + MIGRATION_SKILL_SELECTION_SKIP, + "skill:alpha", + ]), + ).toEqual({ + action: "select", + selectedItemIds: new Set(["skill:alpha"]), + }); + }); + + it("reconciles live interactive bulk toggle checkbox state", () => { + const selectable = ["skill:alpha", "skill:beta"]; + + expect( + reconcileInteractiveMigrationSkillToggleValues( + [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON], + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + selectable, + ), + ).toEqual([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, "skill:alpha", "skill:beta"]); + + expect( + reconcileInteractiveMigrationSkillToggleValues( + [ + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + "skill:alpha", + "skill:beta", + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + ], + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + selectable, + ), + ).toEqual([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]); + + expect( + reconcileInteractiveMigrationSkillToggleValues( + [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, "skill:alpha"], + "skill:alpha", + selectable, + ), + ).toEqual(["skill:alpha"]); + + expect( + reconcileInteractiveMigrationSkillToggleValues( + [ + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + "skill:alpha", + "skill:beta", + MIGRATION_SKILL_SELECTION_SKIP, + ], + MIGRATION_SKILL_SELECTION_SKIP, + selectable, + ), + ).toEqual([MIGRATION_SKILL_SELECTION_SKIP]); + + expect( + reconcileInteractiveMigrationSkillToggleValues( + [MIGRATION_SKILL_SELECTION_SKIP, "skill:alpha"], + "skill:alpha", + selectable, + ), + ).toEqual(["skill:alpha"]); + + expect( + reconcileInteractiveMigrationShortcutValues( + ["skill:alpha", "skill:beta"], + [ + MIGRATION_SKILL_SELECTION_SKIP, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + "skill:alpha", + "skill:beta", + ], + selectable, + "a", + ), + ).toEqual([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]); + + expect( + reconcileInteractiveMigrationShortcutValues( + [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF], + [ + MIGRATION_SKILL_SELECTION_SKIP, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, + MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + ], + selectable, + "i", + ), + ).toEqual([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]); + + expect( + reconcileInteractiveMigrationShortcutValues( + [MIGRATION_SKILL_SELECTION_SKIP], + [MIGRATION_SKILL_SELECTION_SKIP, "skill:beta"], + selectable, + "i", + ), + ).toEqual(["skill:beta"]); + }); + it("rejects unknown explicit skill selectors with available choices", () => { expect(() => applyMigrationSkillSelection( diff --git a/src/commands/migrate/selection.ts b/src/commands/migrate/selection.ts index 103230c0cac..b8f293a18e3 100644 --- a/src/commands/migrate/selection.ts +++ b/src/commands/migrate/selection.ts @@ -3,6 +3,13 @@ import { markMigrationItemSkipped, summarizeMigrationItems } from "../../plugin- import type { MigrationItem, MigrationPlan } from "../../plugins/types.js"; export const MIGRATION_SKILL_NOT_SELECTED_REASON = "not selected for migration"; +export const MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON = "__openclaw_migrate_toggle_all_on__"; +export const MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF = "__openclaw_migrate_toggle_all_off__"; +export const MIGRATION_SKILL_SELECTION_SKIP = "__openclaw_migrate_skip_for_now__"; + +export type InteractiveMigrationSkillSelection = + | { action: "skip" } + | { action: "select"; selectedItemIds: Set }; function normalizeSelectionRef(value: string): string { return value.trim().toLowerCase(); @@ -112,6 +119,10 @@ export function getMigrationSkillSelectionValue(item: MigrationItem): string { return item.id; } +export function getDefaultMigrationSkillSelectionValues(items: readonly MigrationItem[]): string[] { + return items.filter((item) => item.status === "planned").map(getMigrationSkillSelectionValue); +} + export function formatMigrationSkillSelectionLabel(item: MigrationItem): string { return readMigrationSkillName(item) ?? item.id.replace(/^skill:/u, ""); } @@ -157,3 +168,87 @@ export function applyMigrationSkillSelection( const selectedIds = resolveSelectedSkillItemIds(selectable, selectedSkillRefs); return applyMigrationSelectedSkillItemIds(plan, selectedIds); } + +export function resolveInteractiveMigrationSkillSelection( + items: readonly MigrationItem[], + selectedValues: readonly string[], +): InteractiveMigrationSkillSelection { + const selectableIds = new Set(items.map(getMigrationSkillSelectionValue)); + 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_SKILL_SELECTION_SKIP)) { + return { action: "skip" }; + } + + if (selectedValueSet.has(MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF)) { + return { action: "select", selectedItemIds: new Set() }; + } + if (selectedValueSet.has(MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON)) { + return { action: "select", selectedItemIds: selectableIds }; + } + + return { + action: "select", + selectedItemIds, + }; +} + +export function reconcileInteractiveMigrationSkillToggleValues( + selectedValues: readonly string[], + activatedValue: string | undefined, + selectableValues: readonly string[], +): string[] { + if (activatedValue === MIGRATION_SKILL_SELECTION_SKIP) { + return selectedValues.includes(MIGRATION_SKILL_SELECTION_SKIP) + ? [MIGRATION_SKILL_SELECTION_SKIP] + : []; + } + if (activatedValue === MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON) { + return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, ...selectableValues]; + } + if (activatedValue === MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF) { + return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]; + } + if (activatedValue !== undefined && selectableValues.includes(activatedValue)) { + return selectedValues.filter( + (value) => + value !== MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON && + value !== MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF && + value !== MIGRATION_SKILL_SELECTION_SKIP, + ); + } + return selectedValues.filter( + (value) => + value !== MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON || + !selectedValues.includes(MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF), + ); +} + +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" && + !previousValues.includes(MIGRATION_SKILL_SELECTION_SKIP) && + previousSelectable.length === selectableValues.length + ) { + return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]; + } + + const selectedSelectable = selectedValues.filter((value) => selectableValues.includes(value)); + if (selectedSelectable.length === selectableValues.length) { + return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, ...selectableValues]; + } + if (selectedSelectable.length === 0) { + return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]; + } + return selectedSelectable; +} diff --git a/src/commands/migrate/skill-selection-prompt.ts b/src/commands/migrate/skill-selection-prompt.ts new file mode 100644 index 00000000000..ea83788ef1e --- /dev/null +++ b/src/commands/migrate/skill-selection-prompt.ts @@ -0,0 +1,203 @@ +import { styleText } from "node:util"; +import { MultiSelectPrompt, settings, wrapTextWithPrefix } from "@clack/core"; +import { + limitOptions, + S_BAR, + S_BAR_END, + S_CHECKBOX_ACTIVE, + S_CHECKBOX_INACTIVE, + S_CHECKBOX_SELECTED, + symbol, + symbolBar, +} from "@clack/prompts"; +import { + reconcileInteractiveMigrationShortcutValues, + reconcileInteractiveMigrationSkillToggleValues, +} from "./selection.js"; + +type MigrationSkillSelectionOption = { + value: string; + label?: string; + hint?: string; + disabled?: boolean; +}; + +export type MigrationSkillSelectionPromptOptions = { + message: string; + options: MigrationSkillSelectionOption[]; + initialValues?: string[]; + maxItems?: number; + required?: boolean; + cursorAt?: string; + input?: NodeJS.ReadStream; + output?: NodeJS.WriteStream; + signal?: AbortSignal; + withGuide?: boolean; + selectableValues: readonly string[]; +}; + +function formatOption( + option: MigrationSkillSelectionOption, + state: + | "active" + | "active-selected" + | "cancelled" + | "disabled" + | "inactive" + | "selected" + | "submitted", +): string { + const label = option.label ?? option.value; + const withHint = option.hint ? `${label} ${styleText("dim", `(${option.hint})`)}` : label; + switch (state) { + case "active": + return `${styleText("cyan", S_CHECKBOX_ACTIVE)} ${withHint}`; + case "active-selected": + return `${styleText("green", S_CHECKBOX_SELECTED)} ${withHint}`; + case "cancelled": + return styleText(["strikethrough", "dim"], label); + case "disabled": + return `${styleText("gray", S_CHECKBOX_INACTIVE)} ${styleText(["strikethrough", "gray"], label)}${ + option.hint ? ` ${styleText("dim", `(${option.hint})`)}` : "" + }`; + case "selected": + return `${styleText("green", S_CHECKBOX_SELECTED)} ${styleText("dim", withHint)}`; + case "submitted": + return styleText("dim", label); + case "inactive": + return `${styleText("dim", S_CHECKBOX_INACTIVE)} ${styleText("dim", withHint)}`; + } + return withHint; +} + +export function promptMigrationSkillSelectionValues( + opts: MigrationSkillSelectionPromptOptions, +): Promise { + const required = opts.required ?? true; + const prompt = new MultiSelectPrompt({ + options: opts.options, + signal: opts.signal, + input: opts.input, + output: opts.output, + initialValues: opts.initialValues, + required, + cursorAt: opts.cursorAt, + validate(value) { + if (required && (value === undefined || value.length === 0)) { + return "Please select at least one option."; + } + return undefined; + }, + render() { + const withGuide = opts.withGuide ?? settings.withGuide; + const message = wrapTextWithPrefix( + opts.output, + opts.message, + withGuide ? `${symbolBar(this.state)} ` : "", + `${symbol(this.state)} `, + ); + const header = `${withGuide ? `${styleText("gray", S_BAR)}\n` : ""}${message}\n`; + const value = this.value ?? []; + const optionState = (option: MigrationSkillSelectionOption, active: boolean) => { + if (option.disabled) { + return formatOption(option, "disabled"); + } + const selected = value.includes(option.value); + if (active && selected) { + return formatOption(option, "active-selected"); + } + if (selected) { + return formatOption(option, "selected"); + } + return formatOption(option, active ? "active" : "inactive"); + }; + + switch (this.state) { + case "submit": { + const selected = this.options + .filter((option) => value.includes(option.value)) + .map((option) => formatOption(option, "submitted")) + .join(styleText("dim", ", ")); + const label = selected || styleText("dim", "none"); + return `${header}${wrapTextWithPrefix(opts.output, label, withGuide ? `${styleText("gray", S_BAR)} ` : "")}`; + } + case "cancel": { + const selected = this.options + .filter((option) => value.includes(option.value)) + .map((option) => formatOption(option, "cancelled")) + .join(styleText("dim", ", ")); + if (selected.trim() === "") { + return `${header}${styleText("gray", S_BAR)}`; + } + return `${header}${wrapTextWithPrefix( + opts.output, + selected, + withGuide ? `${styleText("gray", S_BAR)} ` : "", + )}${withGuide ? `\n${styleText("gray", S_BAR)}` : ""}`; + } + case "error": { + const prefix = withGuide ? `${styleText("yellow", S_BAR)} ` : ""; + const body = limitOptions({ + output: opts.output, + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + columnPadding: prefix.length, + rowPadding: header.split("\n").length + this.error.split("\n").length + 1, + style: optionState, + }).join(`\n${prefix}`); + const error = this.error + .split("\n") + .map((line, index) => + index === 0 + ? `${withGuide ? `${styleText("yellow", S_BAR_END)} ` : ""}${styleText("yellow", line)}` + : ` ${line}`, + ) + .join("\n"); + return `${header}${prefix}${body}\n${error}\n`; + } + default: { + const prefix = withGuide ? `${styleText("cyan", S_BAR)} ` : ""; + return `${header}${prefix}${limitOptions({ + output: opts.output, + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + columnPadding: prefix.length, + rowPadding: header.split("\n").length + (withGuide ? 2 : 1), + style: optionState, + }).join(`\n${prefix}`)}\n${withGuide ? styleText("cyan", S_BAR_END) : ""}\n`; + } + } + }, + }); + let lastSelectedValues = [...(prompt.value ?? [])]; + + prompt.on("cursor", (key) => { + if (key !== "space") { + return; + } + const activatedValue = prompt.options[prompt.cursor]?.value; + prompt.value = reconcileInteractiveMigrationSkillToggleValues( + prompt.value ?? [], + activatedValue, + opts.selectableValues, + ); + lastSelectedValues = [...(prompt.value ?? [])]; + }); + + prompt.on("key", (key) => { + if (key !== "a" && key !== "i") { + return; + } + prompt.value = reconcileInteractiveMigrationShortcutValues( + lastSelectedValues, + prompt.value ?? [], + opts.selectableValues, + key, + ); + lastSelectedValues = [...(prompt.value ?? [])]; + }); + + return prompt.prompt(); +}