CLI: add interactive questionnaire-driven command execution

This commit is contained in:
Benjamin Jesuiter
2026-02-17 08:31:38 +01:00
parent 3b2e145587
commit f0ef3f4897
5 changed files with 315 additions and 20 deletions

View File

@@ -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 <name>");
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 <name>"))).toBe(true);
});
});

View File

@@ -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<string | null> {
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<string | null> {
const choice = await clackSelect<string>({
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<PromptResult> {
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<PromptResult> {
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<string[] | null> {
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];
}

View File

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

View File

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

View File

@@ -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];
}
}