From 0be8e6e3e4d64848592e5bc9fc76c726b4fa0b4c Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Tue, 17 Feb 2026 08:40:31 +0100 Subject: [PATCH] CLI: ask required params first and optional via multiselect --- src/cli/program/command-questionnaire.test.ts | 27 ++- src/cli/program/command-questionnaire.ts | 208 ++++++++++++------ 2 files changed, 172 insertions(+), 63 deletions(-) diff --git a/src/cli/program/command-questionnaire.test.ts b/src/cli/program/command-questionnaire.test.ts index f0ad6a20ce3..f34f68c4d13 100644 --- a/src/cli/program/command-questionnaire.test.ts +++ b/src/cli/program/command-questionnaire.test.ts @@ -1,6 +1,8 @@ -import { Option } from "commander"; +import { Command, Option } from "commander"; import { describe, expect, it } from "vitest"; import { + buildOptionalParameterEntries, + isRequiredOption, preferredOptionFlag, shouldPromptForOption, splitMultiValueInput, @@ -34,4 +36,27 @@ describe("command-questionnaire", () => { it("prompts for regular options", () => { expect(shouldPromptForOption(new Option("--provider "))).toBe(true); }); + + it("detects required options", () => { + const required = new Option("--provider ").makeOptionMandatory(true); + const optional = new Option("--verbose"); + + expect(isRequiredOption(required)).toBe(true); + expect(isRequiredOption(optional)).toBe(false); + }); + + it("builds optional parameter entries from optional options and arguments", () => { + const command = new Command("demo") + .argument("") + .argument("[note]") + .addOption(new Option("--provider ").makeOptionMandatory(true)) + .option("--verbose", "Verbose output"); + + const entries = buildOptionalParameterEntries(command); + + expect(entries.map((entry) => entry.label)).toContain("--verbose"); + expect(entries.map((entry) => entry.label)).toContain("[note]"); + expect(entries.map((entry) => entry.label)).not.toContain("--provider"); + expect(entries.map((entry) => entry.label)).not.toContain(""); + }); }); diff --git a/src/cli/program/command-questionnaire.ts b/src/cli/program/command-questionnaire.ts index 2729ed4b04b..750a7587c90 100644 --- a/src/cli/program/command-questionnaire.ts +++ b/src/cli/program/command-questionnaire.ts @@ -1,7 +1,7 @@ import type { Argument, Command, Option } from "commander"; import { - confirm as clackConfirm, isCancel, + multiselect as clackMultiselect, select as clackSelect, text as clackText, } from "@clack/prompts"; @@ -12,6 +12,22 @@ const INTERNAL_OPTION_NAMES = new Set(["help", "version", "interactive"]); type PromptResult = string[] | null; +type OptionalParameterEntry = + | { + id: string; + label: string; + hint?: string; + kind: "option"; + option: Option; + } + | { + id: string; + label: string; + hint?: string; + kind: "argument"; + argument: Argument; + }; + export function splitMultiValueInput(raw: string): string[] { return raw .split(/[\s,]+/) @@ -30,6 +46,62 @@ export function shouldPromptForOption(option: Option): boolean { return !INTERNAL_OPTION_NAMES.has(option.name()); } +export function isRequiredOption(option: Option): boolean { + return shouldPromptForOption(option) && option.mandatory; +} + +function formatArgumentLabel(argument: Argument): string { + const wrapped = argument.required ? `<${argument.name()}>` : `[${argument.name()}]`; + return argument.variadic ? wrapped.replace(/([\]>])$/, "...$1") : wrapped; +} + +function buildArgumentHint(argument: Argument): string { + if (argument.description) { + return argument.description; + } + return argument.required ? "Required argument" : "Optional argument"; +} + +function buildOptionHint(option: Option): string { + const desc = option.description?.trim(); + if (desc) { + return desc; + } + return option.mandatory ? "Required option" : "Optional option"; +} + +export function buildOptionalParameterEntries(command: Command): OptionalParameterEntry[] { + const entries: OptionalParameterEntry[] = []; + + for (const option of command.options) { + if (!shouldPromptForOption(option) || option.mandatory) { + continue; + } + entries.push({ + id: `opt:${option.attributeName()}`, + label: preferredOptionFlag(option), + hint: buildOptionHint(option), + kind: "option", + option, + }); + } + + command.registeredArguments.forEach((argument, index) => { + if (argument.required) { + return; + } + entries.push({ + id: `arg:${index}:${argument.name()}`, + label: formatArgumentLabel(argument), + hint: buildArgumentHint(argument), + kind: "argument", + argument, + }); + }); + + return entries; +} + async function askValue(params: { message: string; placeholder?: string; @@ -72,25 +144,10 @@ async function askChoice(params: { return choice; } -async function promptArgumentValue(argument: Argument): Promise { +async function promptArgumentValue(argument: Argument, required: boolean): Promise { const label = argument.name(); const suffix = argument.description ? ` — ${argument.description}` : ""; - if (!argument.required) { - const include = await clackConfirm({ - message: - stylePromptMessage(`Provide optional argument <${label}>?${suffix}`) ?? - `Provide optional argument <${label}>?${suffix}`, - initialValue: false, - }); - if (isCancel(include)) { - return null; - } - if (!include) { - return []; - } - } - if (argument.argChoices && argument.argChoices.length > 0 && !argument.variadic) { const choice = await askChoice({ message: `Select value for <${label}>`, @@ -103,14 +160,14 @@ async function promptArgumentValue(argument: Argument): Promise { if (argument.variadic) { const raw = await askValue({ message: `Values for <${label}...> (space/comma-separated)${suffix}`, - placeholder: argument.required ? "value1 value2" : "optional", - required: argument.required, + placeholder: required ? "value1 value2" : "optional", + required, }); if (raw === null) { return null; } const values = splitMultiValueInput(raw); - if (argument.required && values.length === 0) { + if (required && values.length === 0) { return null; } return values; @@ -118,45 +175,25 @@ async function promptArgumentValue(argument: Argument): Promise { const value = await askValue({ message: `Value for <${label}>${suffix}`, - required: argument.required, + required, }); if (value === null) { return null; } - if (!value && !argument.required) { + if (!value && !required) { return []; } return [value]; } -async function promptOptionValue(option: Option): Promise { +async function promptOptionValue(option: Option, required: boolean): Promise { const flag = preferredOptionFlag(option); const description = option.description ? ` — ${option.description}` : ""; if (option.isBoolean()) { - const verb = option.negate ? "Disable" : "Enable"; - const enabled = await clackConfirm({ - message: - stylePromptMessage(`${verb} ${flag}?${description}`) ?? `${verb} ${flag}?${description}`, - initialValue: false, - }); - if (isCancel(enabled)) { - return null; - } - return enabled ? [flag] : []; - } - - const shouldAsk = - option.mandatory || - (await clackConfirm({ - message: stylePromptMessage(`Set ${flag}?${description}`) ?? `Set ${flag}?${description}`, - initialValue: false, - })); - if (isCancel(shouldAsk)) { - return null; - } - if (!shouldAsk) { - return []; + // Required booleans imply the flag must be set. + // Optional booleans are only prompted when selected in the optional multiselect. + return [flag]; } if (option.argChoices && option.argChoices.length > 0 && !option.variadic) { @@ -176,6 +213,9 @@ async function promptOptionValue(option: Option): Promise { if (raw === null) { return null; } + if (required && raw.length === 0) { + return [flag]; + } return raw.length > 0 ? [flag, raw] : [flag]; } @@ -183,14 +223,14 @@ async function promptOptionValue(option: Option): Promise { const raw = await askValue({ message: `Values for ${flag} (space/comma-separated)${description}`, placeholder: "value1 value2", - required: option.mandatory || option.required, + required, }); if (raw === null) { return null; } const values = splitMultiValueInput(raw); if (values.length === 0) { - return option.mandatory || option.required ? null : [flag]; + return required ? null : [flag]; } const tokens: string[] = []; for (const value of values) { @@ -201,12 +241,12 @@ async function promptOptionValue(option: Option): Promise { const value = await askValue({ message: `Value for ${flag}${description}`, - required: option.mandatory || option.required, + required, }); if (value === null) { return null; } - if (!value && !(option.mandatory || option.required)) { + if (!value && !required) { return []; } return [flag, value]; @@ -222,25 +262,69 @@ export async function runCommandQuestionnaire(params: { } const optionTokens: string[] = []; - for (const option of command.options) { - if (!shouldPromptForOption(option)) { + const argumentTokens: string[] = []; + + // 1) Ask only required parameters first. + for (const argument of command.registeredArguments) { + if (!argument.required) { continue; } - const tokens = await promptOptionValue(option); - if (tokens === null) { - return null; - } - optionTokens.push(...tokens); - } - - const argumentTokens: string[] = []; - for (const argument of command.registeredArguments) { - const tokens = await promptArgumentValue(argument); + const tokens = await promptArgumentValue(argument, true); if (tokens === null) { return null; } argumentTokens.push(...tokens); } + for (const option of command.options) { + if (!isRequiredOption(option)) { + continue; + } + const tokens = await promptOptionValue(option, true); + if (tokens === null) { + return null; + } + optionTokens.push(...tokens); + } + + // 2) Then let user pick optional parameters to activate. + const optionalEntries = buildOptionalParameterEntries(command); + if (optionalEntries.length > 0) { + const selected = await clackMultiselect({ + message: + stylePromptMessage("Select optional parameters to set") ?? + "Select optional parameters to set", + options: optionalEntries.map((entry) => ({ + value: entry.id, + label: entry.label, + hint: entry.hint ? stylePromptHint(entry.hint) : undefined, + })), + required: false, + }); + if (isCancel(selected)) { + return null; + } + + const selectedIds = new Set(Array.isArray(selected) ? selected : []); + for (const entry of optionalEntries) { + if (!selectedIds.has(entry.id)) { + continue; + } + if (entry.kind === "option") { + const tokens = await promptOptionValue(entry.option, false); + if (tokens === null) { + return null; + } + optionTokens.push(...tokens); + continue; + } + const tokens = await promptArgumentValue(entry.argument, true); + if (tokens === null) { + return null; + } + argumentTokens.push(...tokens); + } + } + return [...optionTokens, ...argumentTokens]; }