diff --git a/src/cli/program/command-questionnaire.test.ts b/src/cli/program/command-questionnaire.test.ts new file mode 100644 index 0000000000..f0ad6a20ce --- /dev/null +++ b/src/cli/program/command-questionnaire.test.ts @@ -0,0 +1,37 @@ +import { Option } from "commander"; +import { describe, expect, it } from "vitest"; +import { + preferredOptionFlag, + shouldPromptForOption, + splitMultiValueInput, +} from "./command-questionnaire.js"; + +describe("command-questionnaire", () => { + it("splits multi-value input by spaces and commas", () => { + expect(splitMultiValueInput("a b, c,,d")).toEqual(["a", "b", "c", "d"]); + }); + + it("prefers long option flags", () => { + const option = new Option("-p, --provider "); + expect(preferredOptionFlag(option)).toBe("--provider"); + }); + + it("falls back to short flag when long is absent", () => { + const option = new Option("-f"); + expect(preferredOptionFlag(option)).toBe("-f"); + }); + + it("skips internal and hidden options", () => { + expect(shouldPromptForOption(new Option("-h, --help"))).toBe(false); + expect(shouldPromptForOption(new Option("-V, --version"))).toBe(false); + expect(shouldPromptForOption(new Option("-i, --interactive"))).toBe(false); + + const hidden = new Option("--secret"); + hidden.hideHelp(true); + expect(shouldPromptForOption(hidden)).toBe(false); + }); + + it("prompts for regular options", () => { + expect(shouldPromptForOption(new Option("--provider "))).toBe(true); + }); +}); diff --git a/src/cli/program/command-questionnaire.ts b/src/cli/program/command-questionnaire.ts new file mode 100644 index 0000000000..2729ed4b04 --- /dev/null +++ b/src/cli/program/command-questionnaire.ts @@ -0,0 +1,246 @@ +import type { Argument, Command, Option } from "commander"; +import { + confirm as clackConfirm, + isCancel, + select as clackSelect, + text as clackText, +} from "@clack/prompts"; +import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js"; +import { resolveCommandByPath } from "./command-selector.js"; + +const INTERNAL_OPTION_NAMES = new Set(["help", "version", "interactive"]); + +type PromptResult = string[] | null; + +export function splitMultiValueInput(raw: string): string[] { + return raw + .split(/[\s,]+/) + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +export function preferredOptionFlag(option: Option): string { + return option.long ?? option.short ?? option.flags.split(/[ ,|]+/)[0] ?? option.flags; +} + +export function shouldPromptForOption(option: Option): boolean { + if (option.hidden) { + return false; + } + return !INTERNAL_OPTION_NAMES.has(option.name()); +} + +async function askValue(params: { + message: string; + placeholder?: string; + required?: boolean; +}): Promise { + const value = await clackText({ + message: stylePromptMessage(params.message) ?? params.message, + placeholder: params.placeholder, + validate: params.required + ? (input) => { + if (!input || input.trim().length === 0) { + return "Value required"; + } + return undefined; + } + : undefined, + }); + if (isCancel(value)) { + return null; + } + return String(value ?? "").trim(); +} + +async function askChoice(params: { + message: string; + choices: readonly string[]; + hint?: string; +}): Promise { + const choice = await clackSelect({ + message: stylePromptMessage(params.message) ?? params.message, + options: params.choices.map((value) => ({ + value, + label: value, + hint: params.hint ? stylePromptHint(params.hint) : undefined, + })), + }); + if (isCancel(choice)) { + return null; + } + return choice; +} + +async function promptArgumentValue(argument: Argument): 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}>`, + choices: argument.argChoices, + hint: argument.description, + }); + return choice === null ? null : [choice]; + } + + if (argument.variadic) { + const raw = await askValue({ + message: `Values for <${label}...> (space/comma-separated)${suffix}`, + placeholder: argument.required ? "value1 value2" : "optional", + required: argument.required, + }); + if (raw === null) { + return null; + } + const values = splitMultiValueInput(raw); + if (argument.required && values.length === 0) { + return null; + } + return values; + } + + const value = await askValue({ + message: `Value for <${label}>${suffix}`, + required: argument.required, + }); + if (value === null) { + return null; + } + if (!value && !argument.required) { + return []; + } + return [value]; +} + +async function promptOptionValue(option: Option): 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 []; + } + + if (option.argChoices && option.argChoices.length > 0 && !option.variadic) { + const choice = await askChoice({ + message: `Select value for ${flag}`, + choices: option.argChoices, + hint: option.description, + }); + return choice === null ? null : [flag, choice]; + } + + if (option.optional) { + const raw = await askValue({ + message: `Optional value for ${flag} (leave empty for flag only)${description}`, + required: false, + }); + if (raw === null) { + return null; + } + return raw.length > 0 ? [flag, raw] : [flag]; + } + + if (option.variadic) { + const raw = await askValue({ + message: `Values for ${flag} (space/comma-separated)${description}`, + placeholder: "value1 value2", + required: option.mandatory || option.required, + }); + if (raw === null) { + return null; + } + const values = splitMultiValueInput(raw); + if (values.length === 0) { + return option.mandatory || option.required ? null : [flag]; + } + const tokens: string[] = []; + for (const value of values) { + tokens.push(flag, value); + } + return tokens; + } + + const value = await askValue({ + message: `Value for ${flag}${description}`, + required: option.mandatory || option.required, + }); + if (value === null) { + return null; + } + if (!value && !(option.mandatory || option.required)) { + return []; + } + return [flag, value]; +} + +export async function runCommandQuestionnaire(params: { + program: Command; + commandPath: string[]; +}): Promise { + const command = resolveCommandByPath(params.program, params.commandPath); + if (!command) { + return []; + } + + const optionTokens: string[] = []; + for (const option of command.options) { + if (!shouldPromptForOption(option)) { + 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); + if (tokens === null) { + return null; + } + argumentTokens.push(...tokens); + } + + return [...optionTokens, ...argumentTokens]; +} diff --git a/src/cli/program/command-selector.ts b/src/cli/program/command-selector.ts index 5e3a7abc42..0c2fbadfc1 100644 --- a/src/cli/program/command-selector.ts +++ b/src/cli/program/command-selector.ts @@ -31,14 +31,8 @@ function isHiddenCommand(command: Command): boolean { return Boolean((command as Command & { _hidden?: boolean })._hidden); } -function shouldSkipCommand(command: Command, parentDepth: number): boolean { - if (isHiddenCommand(command) || command.name() === "help") { - return true; - } - if (parentDepth === 0 && command.name() === "interactive") { - return true; - } - return false; +function shouldSkipCommand(command: Command, _parentDepth: number): boolean { + return isHiddenCommand(command) || command.name() === "help"; } function resolveCommandDescription(command: Command): string { diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index f314fbea9f..beff226cc4 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -116,14 +116,24 @@ describe("shouldUseInteractiveCommandSelector", () => { ).toBe(true); }); - it("enables selector for interactive command", () => { + it("enables selector for --interactive", () => { + expect( + shouldUseInteractiveCommandSelector({ + argv: ["node", "openclaw", "--interactive"], + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true); + }); + + it("does not enable selector for interactive command name", () => { expect( shouldUseInteractiveCommandSelector({ argv: ["node", "openclaw", "interactive"], stdinIsTTY: true, stdoutIsTTY: true, }), - ).toBe(true); + ).toBe(false); }); it("keeps default no-arg invocation on fast help path", () => { @@ -164,7 +174,7 @@ describe("shouldUseInteractiveCommandSelector", () => { ).toBe(false); expect( shouldUseInteractiveCommandSelector({ - argv: ["node", "openclaw", "interactive"], + argv: ["node", "openclaw", "--interactive"], stdinIsTTY: true, stdoutIsTTY: true, disableSelectorEnv: "1", @@ -188,10 +198,11 @@ describe("stripInteractiveSelectorArgs", () => { expect(stripInteractiveSelectorArgs(["node", "openclaw", "-i"])).toEqual(["node", "openclaw"]); }); - it("removes interactive command from root invocations", () => { + it("keeps non-flag command arguments unchanged", () => { expect(stripInteractiveSelectorArgs(["node", "openclaw", "interactive"])).toEqual([ "node", "openclaw", + "interactive", ]); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 65b0bc69db..751afbea03 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -112,13 +112,12 @@ export function shouldUseInteractiveCommandSelector(params: { return false; } const root = parseRootInvocation(params.argv); - const requestedViaCommand = root.primary === "interactive"; - if (!root.hasInteractiveFlag && !requestedViaCommand) { + if (!root.hasInteractiveFlag) { return false; } // Keep -i as an explicit interactive entrypoint only for root invocations. // If a real command is already present, run it normally and ignore -i. - if (root.primary && root.primary !== "interactive") { + if (root.primary) { return false; } if (!params.stdinIsTTY || !params.stdoutIsTTY) { @@ -162,9 +161,6 @@ export function stripInteractiveSelectorArgs(argv: string[]): string[] { } if (!arg.startsWith("-")) { sawPrimary = true; - if (arg === "interactive") { - continue; - } } } next.push(arg); @@ -246,8 +242,19 @@ export async function runCli(argv: string[] = process.argv) { if (useInteractiveSelector) { const { runInteractiveCommandSelector } = await import("./program/command-selector.js"); const selectedPath = await runInteractiveCommandSelector(program); - if (selectedPath && selectedPath.length > 0) { - parseArgv = [...parseArgv, ...selectedPath]; + if (!selectedPath || selectedPath.length === 0) { + // Exit silently when leaving interactive mode. + return; + } + + parseArgv = [...parseArgv, ...selectedPath]; + const { runCommandQuestionnaire } = await import("./program/command-questionnaire.js"); + const promptArgs = await runCommandQuestionnaire({ program, commandPath: selectedPath }); + if (promptArgs === null) { + return; + } + if (promptArgs.length > 0) { + parseArgv = [...parseArgv, ...promptArgs]; } }