refactor(cli): use native Clack option types

This commit is contained in:
Peter Steinberger
2026-07-14 15:31:40 +01:00
parent 66def08d83
commit 7fa99357ae
5 changed files with 23 additions and 77 deletions

View File

@@ -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" },

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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<MultiSelectOptions<string>, "required"> & {
selectableValues: readonly string[];
};
function formatOption(
option: MigrationSkillSelectionOption,
option: Option<string>,
state:
| "active"
| "active-selected"
@@ -78,21 +63,13 @@ function formatOption(
export function promptMigrationSkillSelectionValues(
opts: MigrationSkillSelectionPromptOptions,
): Promise<string[] | symbol | undefined> {
const required = opts.required ?? true;
const prompt = new MultiSelectPrompt<MigrationSkillSelectionOption>({
const prompt = new MultiSelectPrompt<Option<string>>({
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<string>, active: boolean) => {
if (option.disabled) {
return formatOption(option, "disabled");
}

View File

@@ -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: Value;
label?: string;
hint?: string;
disabled?: boolean;
};
type NavigationPromptOptions = {
navigation?: WizardPromptNavigation;
withGuide?: boolean;
output?: Writable;
};
function getOptionLabel<Value>(option: PromptOption<Value>): string {
function getOptionLabel<Value>(option: Option<Value>): string {
return option.label ?? String(option.value ?? "");
}
@@ -61,7 +52,7 @@ function computeLabel(label: string, format: (text: string) => string): string {
.join("\n");
}
function getFilteredOption<Value>(searchText: string, option: PromptOption<Value>): boolean {
function getFilteredOption<Value>(searchText: string, option: Option<Value>): boolean {
if (!searchText) {
return true;
}
@@ -73,19 +64,6 @@ function getFilteredOption<Value>(searchText: string, option: PromptOption<Value
);
}
function getSelectedOptions<Value>(
values: Value[],
options: Array<PromptOption<Value>>,
): Array<PromptOption<Value>> {
return options.filter((option) => values.includes(option.value));
}
function adaptOptionFilter<Value>(
filter: AutocompleteOptions<Value>["filter"] | undefined,
): ((search: string, option: PromptOption<Value>) => 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<Value>(option: PromptOption<Value>, state: string): string {
function selectOptionRenderer<Value>(option: Option<Value>, state: string): string {
const label = getOptionLabel(option);
switch (state) {
case "disabled":
@@ -143,7 +121,7 @@ export function selectWithNavigationFooter<Value>(
opts: SelectOptions<Value> & NavigationPromptOptions,
): Promise<Value | symbol> {
return new SelectPrompt({
options: opts.options as Array<PromptOption<Value>>,
options: opts.options as Array<Option<Value>>,
signal: opts.signal,
input: opts.input,
output: opts.output,
@@ -217,12 +195,12 @@ export function selectWithNavigationFooter<Value>(
export function autocompleteWithNavigationFooter<Value>(
opts: AutocompleteOptions<Value> & NavigationPromptOptions,
): Promise<Value | symbol> {
const prompt = new AutocompletePrompt<PromptOption<Value>>({
options: opts.options as Array<PromptOption<Value>>,
const prompt = new AutocompletePrompt<Option<Value>>({
options: opts.options as Array<Option<Value>>,
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<Value>(
const userInput = this.userInput;
const options = this.options;
const showPlaceholder = userInput === "" && opts.placeholder !== undefined;
const opt = (
option: PromptOption<Value>,
state: "inactive" | "active" | "disabled",
): string => {
const opt = (option: Option<Value>, state: "inactive" | "active" | "disabled"): string => {
const label = getOptionLabel(option);
const hint =
option.hint && option.value === this.focusedValue
@@ -260,7 +235,7 @@ export function autocompleteWithNavigationFooter<Value>(
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<Value>(
option: PromptOption<Value>,
option: Option<Value>,
state:
| "inactive"
| "active"
@@ -504,12 +479,11 @@ export function multiselectWithNavigationFooter<Value>(
): Promise<Value[] | symbol> {
const required = opts.required ?? true;
return new MultiSelectPrompt({
options: opts.options as Array<PromptOption<Value>>,
options: opts.options as Array<Option<Value>>,
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<Value>(
);
const title = `${showGuide ? `${styleText("gray", S_BAR)}\n` : ""}${wrappedMessage}\n`;
const value = this.value ?? [];
const styleOption = (option: PromptOption<Value>, active: boolean) => {
const styleOption = (option: Option<Value>, active: boolean) => {
if (option.disabled) {
return multiselectOptionRenderer(option, "disabled");
}
@@ -634,7 +608,7 @@ export function autocompleteMultiselectWithNavigationFooter<Value>(
opts: AutocompleteMultiSelectOptions<Value> & NavigationPromptOptions,
): Promise<Value[] | symbol> {
const formatOption = (
option: PromptOption<Value>,
option: Option<Value>,
active: boolean,
selectedValues: Value[],
focusedValue: Value | undefined,
@@ -661,11 +635,11 @@ export function autocompleteMultiselectWithNavigationFooter<Value>(
return `${checkbox} ${styleText("dim", label)}`;
};
const prompt = new AutocompletePrompt<PromptOption<Value>>({
options: opts.options as Array<PromptOption<Value>>,
const prompt = new AutocompletePrompt<Option<Value>>({
options: opts.options as Array<Option<Value>>,
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";