From 7fa99357ae01604392cd0816c7dd591180ee8dee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 15:31:40 +0100 Subject: [PATCH] refactor(cli): use native Clack option types --- src/commands/migrate.test.ts | 2 - src/commands/migrate.ts | 2 - .../migrate/skill-selection-prompt.test.ts | 1 - .../migrate/skill-selection-prompt.ts | 35 ++--------- src/wizard/clack-navigation-prompts.ts | 60 ++++++------------- 5 files changed, 23 insertions(+), 77 deletions(-) diff --git a/src/commands/migrate.test.ts b/src/commands/migrate.test.ts index 11c3ac91b496..85460554e2a5 100644 --- a/src/commands/migrate.test.ts +++ b/src/commands/migrate.test.ts @@ -683,7 +683,6 @@ describe("migrateApplyCommand", () => { const selectionPrompt = multiselectPrompt(); expect(String(selectionPrompt.message)).toContain("Select Codex skills"); expect(selectionPrompt.initialValues).toStrictEqual(["skill:alpha", "skill:beta"]); - expect(selectionPrompt.required).toBe(false); expect(selectionPrompt.options?.map(({ label, value }) => ({ label, value }))).toStrictEqual([ { value: MIGRATION_SELECTION_ACCEPT, label: "Accept recommended" }, { value: "skill:alpha", label: "alpha" }, @@ -743,7 +742,6 @@ describe("migrateApplyCommand", () => { const pluginPrompt = multiselectPrompt(1); expect(String(pluginPrompt.message)).toContain("Select native Codex plugins"); expect(pluginPrompt.initialValues).toStrictEqual(["plugin:google-calendar", "plugin:gmail"]); - expect(pluginPrompt.required).toBe(false); expect(pluginPrompt.options?.map(({ label, value }) => ({ label, value }))).toStrictEqual([ { value: MIGRATION_SELECTION_ACCEPT, label: "Accept recommended" }, { value: "plugin:google-calendar", label: "google-calendar" }, diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 7a5acb26256c..30f4ff5512f6 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -203,7 +203,6 @@ async function promptCodexMigrationSkillSelection( }, ], initialValues: getDefaultMigrationSkillSelectionValues(skillItems), - required: false, selectableValues: skillItems.map(getMigrationSkillSelectionValue), cursorAt: MIGRATION_SELECTION_ACCEPT, }); @@ -264,7 +263,6 @@ async function promptCodexMigrationPluginSelection( }, ], initialValues: getDefaultMigrationPluginSelectionValues(pluginItems), - required: false, selectableValues: pluginItems.map(getMigrationPluginSelectionValue), cursorAt: MIGRATION_SELECTION_ACCEPT, }); diff --git a/src/commands/migrate/skill-selection-prompt.test.ts b/src/commands/migrate/skill-selection-prompt.test.ts index b6e746895d35..9958ac90ee4c 100644 --- a/src/commands/migrate/skill-selection-prompt.test.ts +++ b/src/commands/migrate/skill-selection-prompt.test.ts @@ -37,7 +37,6 @@ async function runPromptWithKeys(params: { { value: MIGRATION_SELECTION_TOGGLE_ALL_OFF, label: "Toggle all off" }, ], initialValues: params.initialValues, - required: false, cursorAt: params.cursorAt, selectableValues: ["skill:alpha", "skill:beta"], input, diff --git a/src/commands/migrate/skill-selection-prompt.ts b/src/commands/migrate/skill-selection-prompt.ts index 9602d177f01f..bbc3e0632e73 100644 --- a/src/commands/migrate/skill-selection-prompt.ts +++ b/src/commands/migrate/skill-selection-prompt.ts @@ -10,6 +10,8 @@ import { S_CHECKBOX_SELECTED, symbol, symbolBar, + type MultiSelectOptions, + type Option, } from "@clack/prompts"; import { MIGRATION_SELECTION_ACCEPT, @@ -18,30 +20,13 @@ import { reconcileInteractiveMigrationSkillToggleValues, } from "./selection.js"; -type MigrationSkillSelectionOption = { - value: string; - label?: string; - hint?: string; - disabled?: boolean; -}; - /** Options for the migration selection prompt, including testable IO streams. */ -type MigrationSkillSelectionPromptOptions = { - message: string; - options: MigrationSkillSelectionOption[]; - initialValues?: string[]; - maxItems?: number; - required?: boolean; - cursorAt?: string; - input?: NodeJS.ReadStream; - output?: NodeJS.WriteStream; - signal?: AbortSignal; - withGuide?: boolean; +type MigrationSkillSelectionPromptOptions = Omit, "required"> & { selectableValues: readonly string[]; }; function formatOption( - option: MigrationSkillSelectionOption, + option: Option, state: | "active" | "active-selected" @@ -78,21 +63,13 @@ function formatOption( export function promptMigrationSkillSelectionValues( opts: MigrationSkillSelectionPromptOptions, ): Promise { - const required = opts.required ?? true; - const prompt = new MultiSelectPrompt({ + 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( @@ -103,7 +80,7 @@ export function promptMigrationSkillSelectionValues( ); const header = `${withGuide ? `${styleText("gray", S_BAR)}\n` : ""}${message}\n`; const value = this.value ?? []; - const optionState = (option: MigrationSkillSelectionOption, active: boolean) => { + const optionState = (option: Option, active: boolean) => { if (option.disabled) { return formatOption(option, "disabled"); } diff --git a/src/wizard/clack-navigation-prompts.ts b/src/wizard/clack-navigation-prompts.ts index 42384e0b25fc..bcff169ef97a 100644 --- a/src/wizard/clack-navigation-prompts.ts +++ b/src/wizard/clack-navigation-prompts.ts @@ -1,5 +1,4 @@ // Clack prompt wrappers that add onboarding navigation footers. -import type { Writable } from "node:stream"; import { styleText } from "node:util"; import { AutocompletePrompt, @@ -27,6 +26,7 @@ import { type AutocompleteOptions, type ConfirmOptions, type MultiSelectOptions, + type Option, type PasswordOptions, type SelectOptions, type TextOptions, @@ -34,20 +34,11 @@ import { import { expectDefined } from "@openclaw/normalization-core"; import type { WizardPromptNavigation } from "./prompts.js"; -type PromptOption = { - value: Value; - label?: string; - hint?: string; - disabled?: boolean; -}; - type NavigationPromptOptions = { navigation?: WizardPromptNavigation; - withGuide?: boolean; - output?: Writable; }; -function getOptionLabel(option: PromptOption): string { +function getOptionLabel(option: Option): string { return option.label ?? String(option.value ?? ""); } @@ -61,7 +52,7 @@ function computeLabel(label: string, format: (text: string) => string): string { .join("\n"); } -function getFilteredOption(searchText: string, option: PromptOption): boolean { +function getFilteredOption(searchText: string, option: Option): boolean { if (!searchText) { return true; } @@ -73,19 +64,6 @@ function getFilteredOption(searchText: string, option: PromptOption( - values: Value[], - options: Array>, -): Array> { - return options.filter((option) => values.includes(option.value)); -} - -function adaptOptionFilter( - filter: AutocompleteOptions["filter"] | undefined, -): ((search: string, option: PromptOption) => boolean) | undefined { - return filter ? (search, option) => filter(search, option as never) : undefined; -} - function formatNavigationFooter(navigation: WizardPromptNavigation | undefined): string { if (!navigation || (!navigation.canGoBack && !navigation.canGoForward)) { return ""; @@ -117,7 +95,7 @@ function hasGuide(opts: { withGuide?: boolean }): boolean { return opts.withGuide ?? clackSettings.withGuide; } -function selectOptionRenderer(option: PromptOption, state: string): string { +function selectOptionRenderer(option: Option, state: string): string { const label = getOptionLabel(option); switch (state) { case "disabled": @@ -143,7 +121,7 @@ export function selectWithNavigationFooter( opts: SelectOptions & NavigationPromptOptions, ): Promise { return new SelectPrompt({ - options: opts.options as Array>, + options: opts.options as Array>, signal: opts.signal, input: opts.input, output: opts.output, @@ -217,12 +195,12 @@ export function selectWithNavigationFooter( export function autocompleteWithNavigationFooter( opts: AutocompleteOptions & NavigationPromptOptions, ): Promise { - const prompt = new AutocompletePrompt>({ - options: opts.options as Array>, + const prompt = new AutocompletePrompt>({ + options: opts.options as Array>, initialValue: opts.initialValue === undefined ? undefined : [opts.initialValue], initialUserInput: opts.initialUserInput, placeholder: opts.placeholder, - filter: adaptOptionFilter(opts.filter) ?? getFilteredOption, + filter: opts.filter ?? getFilteredOption, signal: opts.signal, input: opts.input, output: opts.output, @@ -235,10 +213,7 @@ export function autocompleteWithNavigationFooter( const userInput = this.userInput; const options = this.options; const showPlaceholder = userInput === "" && opts.placeholder !== undefined; - const opt = ( - option: PromptOption, - state: "inactive" | "active" | "disabled", - ): string => { + const opt = (option: Option, state: "inactive" | "active" | "disabled"): string => { const label = getOptionLabel(option); const hint = option.hint && option.value === this.focusedValue @@ -260,7 +235,7 @@ export function autocompleteWithNavigationFooter( switch (this.state) { case "submit": { - const selected = getSelectedOptions(this.selectedValues, options); + const selected = options.filter((option) => this.selectedValues.includes(option.value)); const label = selected.length > 0 ? ` ${styleText("dim", selected.map(getOptionLabel).join(", "))}` @@ -457,7 +432,7 @@ export function passwordWithNavigationFooter( } function multiselectOptionRenderer( - option: PromptOption, + option: Option, state: | "inactive" | "active" @@ -504,12 +479,11 @@ export function multiselectWithNavigationFooter( ): Promise { const required = opts.required ?? true; return new MultiSelectPrompt({ - options: opts.options as Array>, + options: opts.options as Array>, signal: opts.signal, input: opts.input, output: opts.output, initialValues: opts.initialValues, - required, cursorAt: opts.cursorAt, validate(selected: Value[] | undefined) { if (required && (selected === undefined || selected.length === 0)) { @@ -536,7 +510,7 @@ export function multiselectWithNavigationFooter( ); const title = `${showGuide ? `${styleText("gray", S_BAR)}\n` : ""}${wrappedMessage}\n`; const value = this.value ?? []; - const styleOption = (option: PromptOption, active: boolean) => { + const styleOption = (option: Option, active: boolean) => { if (option.disabled) { return multiselectOptionRenderer(option, "disabled"); } @@ -634,7 +608,7 @@ export function autocompleteMultiselectWithNavigationFooter( opts: AutocompleteMultiSelectOptions & NavigationPromptOptions, ): Promise { const formatOption = ( - option: PromptOption, + option: Option, active: boolean, selectedValues: Value[], focusedValue: Value | undefined, @@ -661,11 +635,11 @@ export function autocompleteMultiselectWithNavigationFooter( return `${checkbox} ${styleText("dim", label)}`; }; - const prompt = new AutocompletePrompt>({ - options: opts.options as Array>, + const prompt = new AutocompletePrompt>({ + options: opts.options as Array>, multiple: true, placeholder: opts.placeholder, - filter: adaptOptionFilter(opts.filter) ?? getFilteredOption, + filter: opts.filter ?? getFilteredOption, validate: () => { if (opts.required && prompt.selectedValues.length === 0) { return "Please select at least one item";