mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
CLI: add interactive questionnaire-driven command execution
This commit is contained in:
37
src/cli/program/command-questionnaire.test.ts
Normal file
37
src/cli/program/command-questionnaire.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
246
src/cli/program/command-questionnaire.ts
Normal file
246
src/cli/program/command-questionnaire.ts
Normal 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];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user