Compare commits

...

11 Commits

Author SHA1 Message Date
Benjamin Jesuiter
47dbf9beda CLI: unify interactive root argv scanning 2026-02-17 10:42:06 +01:00
Benjamin Jesuiter
d8812d5590 CLI: simplify selector skip checks 2026-02-17 10:32:17 +01:00
Benjamin Jesuiter
e78f25d05a CLI: return to interactive main menu after command runs 2026-02-17 10:15:20 +01:00
Benjamin Jesuiter
0be8e6e3e4 CLI: ask required params first and optional via multiselect 2026-02-17 10:15:20 +01:00
Benjamin Jesuiter
f0ef3f4897 CLI: add interactive questionnaire-driven command execution 2026-02-17 10:15:20 +01:00
Benjamin Jesuiter
3b2e145587 CLI: stabilize interactive selector ordering per query 2026-02-17 10:15:20 +01:00
Benjamin Jesuiter
92e1e87034 CLI: add run-current and nested picker for mixed commands 2026-02-17 10:15:20 +01:00
Benjamin Jesuiter
bde982ae7c CLI: add nested subcommand fuzzy selection 2026-02-17 10:15:20 +01:00
Benjamin Jesuiter
eb87af9ea5 CLI: add explicit interactive command entrypoint 2026-02-17 10:15:20 +01:00
Benjamin Jesuiter
a25156769d CLI: switch command selector to interactive autocomplete 2026-02-17 10:15:20 +01:00
Benjamin Jesuiter
3e9e9258a4 CLI: add fuzzy selector when no command is given 2026-02-17 10:15:20 +01:00
7 changed files with 1147 additions and 3 deletions

View File

@@ -0,0 +1,62 @@
import { Command, Option } from "commander";
import { describe, expect, it } from "vitest";
import {
buildOptionalParameterEntries,
isRequiredOption,
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);
});
it("detects required options", () => {
const required = new Option("--provider <name>").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("<target>")
.argument("[note]")
.addOption(new Option("--provider <name>").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("<target>");
});
});

View File

@@ -0,0 +1,330 @@
import type { Argument, Command, Option } from "commander";
import {
isCancel,
multiselect as clackMultiselect,
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;
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,]+/)
.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());
}
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;
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, required: boolean): Promise<PromptResult> {
const label = argument.name();
const suffix = argument.description ? ` — ${argument.description}` : "";
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: required ? "value1 value2" : "optional",
required,
});
if (raw === null) {
return null;
}
const values = splitMultiValueInput(raw);
if (required && values.length === 0) {
return null;
}
return values;
}
const value = await askValue({
message: `Value for <${label}>${suffix}`,
required,
});
if (value === null) {
return null;
}
if (!value && !required) {
return [];
}
return [value];
}
async function promptOptionValue(option: Option, required: boolean): Promise<PromptResult> {
const flag = preferredOptionFlag(option);
const description = option.description ? ` — ${option.description}` : "";
if (option.isBoolean()) {
// 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) {
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;
}
if (required && raw.length === 0) {
return [flag];
}
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,
});
if (raw === null) {
return null;
}
const values = splitMultiValueInput(raw);
if (values.length === 0) {
return 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,
});
if (value === null) {
return null;
}
if (!value && !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[] = [];
const argumentTokens: string[] = [];
// 1) Ask only required parameters first.
for (const argument of command.registeredArguments) {
if (!argument.required) {
continue;
}
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<string>({
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];
}

View File

@@ -0,0 +1,120 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import {
collectCommandSelectorCandidates,
collectDirectSubcommandSelectorCandidates,
commandRequiresSubcommand,
rankCommandSelectorCandidates,
resolveCommandByPath,
} from "./command-selector.js";
describe("command-selector", () => {
it("collects nested command paths", () => {
const program = new Command();
const message = program.command("message").description("Manage messages");
message.command("send").description("Send a message");
message.command("read").description("Read messages");
program.command("status").description("Show status");
const candidates = collectCommandSelectorCandidates(program);
const labels = candidates.map((candidate) => candidate.label);
expect(labels).toContain("message");
expect(labels).toContain("message send");
expect(labels).toContain("message read");
expect(labels).toContain("status");
});
it("skips hidden commands", () => {
const program = new Command();
program.command("visible").description("Visible command");
const secret = program.command("secret").description("Secret command");
(secret as Command & { _hidden?: boolean })._hidden = true;
const candidates = collectCommandSelectorCandidates(program);
const labels = candidates.map((candidate) => candidate.label);
expect(labels).toContain("visible");
expect(labels).not.toContain("secret");
});
it("supports fuzzy ranking", () => {
const program = new Command();
const message = program.command("message").description("Manage messages");
message.command("send").description("Send a message");
message.command("search").description("Search messages");
program.command("status").description("Show status");
const candidates = collectCommandSelectorCandidates(program);
const ranked = rankCommandSelectorCandidates(candidates, "msg snd");
expect(ranked[0]?.label).toBe("message send");
expect(ranked.some((candidate) => candidate.label === "status")).toBe(false);
});
it("prioritizes deep commands when querying a shared subcommand name", () => {
const program = new Command();
const models = program.command("models").description("Model commands");
const aliases = models.command("aliases").description("Alias commands");
aliases.command("add").description("Add alias");
const fallbacks = models.command("fallbacks").description("Fallback commands");
fallbacks.command("add").description("Add fallback");
const candidates = collectCommandSelectorCandidates(program);
const ranked = rankCommandSelectorCandidates(candidates, "add");
const topLabels = ranked.slice(0, 2).map((candidate) => candidate.label);
expect(topLabels).toEqual(["models aliases add", "models fallbacks add"]);
const aliasesParentIndex = ranked.findIndex(
(candidate) => candidate.label === "models aliases",
);
const aliasesAddIndex = ranked.findIndex(
(candidate) => candidate.label === "models aliases add",
);
expect(aliasesParentIndex).toBeGreaterThan(aliasesAddIndex);
});
it("resolves commands by path", () => {
const program = new Command();
const models = program.command("models");
const auth = models.command("auth").description("Auth");
expect(resolveCommandByPath(program, ["models"]))?.toBe(models);
expect(resolveCommandByPath(program, ["models", "auth"]))?.toBe(auth);
expect(resolveCommandByPath(program, ["models", "missing"])).toBeNull();
});
it("detects commands that require subcommands", () => {
const program = new Command();
const models = program.command("models").description("Model commands");
const auth = models
.command("auth")
.description("Auth command")
.action(() => undefined);
auth.command("login").description("Login auth profile");
const status = program
.command("status")
.description("Status")
.action(() => undefined);
expect(commandRequiresSubcommand(models)).toBe(true);
expect(commandRequiresSubcommand(auth)).toBe(true);
expect(commandRequiresSubcommand(status)).toBe(false);
});
it("collects direct subcommand candidates", () => {
const program = new Command();
const models = program.command("models").description("Model commands");
models.command("auth").description("Authenticate");
models.command("scan").description("Scan models");
const candidates = collectDirectSubcommandSelectorCandidates(program, ["models"]);
expect(candidates.map((candidate) => candidate.label)).toEqual(["auth", "scan"]);
expect(candidates.map((candidate) => candidate.path.join(" "))).toEqual([
"models auth",
"models scan",
]);
});
});

View File

@@ -0,0 +1,345 @@
import { autocomplete as clackAutocomplete, isCancel } from "@clack/prompts";
import type { Command } from "commander";
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
import { fuzzyFilterLower, prepareSearchItems } from "../../tui/components/fuzzy-filter.js";
import { getCoreCliCommandNames, registerCoreCliByName } from "./command-registry.js";
import { getProgramContext } from "./program-context.js";
import { getSubCliEntries, registerSubCliByName } from "./register.subclis.js";
const SHOW_HELP_VALUE = "__show_help__";
const BACK_TO_MAIN_VALUE = "__back_to_main__";
const RUN_CURRENT_VALUE = "__run_current__";
const PATH_SEPARATOR = "\u0000";
const SELECTION_VALUE_SEPARATOR = "\u0001";
const MAX_RESULTS = 200;
type CommandSelectorCandidate = {
path: string[];
label: string;
description: string;
searchText: string;
};
type PreparedCommandSelectorCandidate = CommandSelectorCandidate & {
searchTextLower: string;
};
type SelectorPromptResult = string[] | "back_to_main" | "run_current" | null;
function isHiddenCommand(command: Command): boolean {
// Commander stores hidden state on a private field.
return Boolean((command as Command & { _hidden?: boolean })._hidden);
}
function shouldSkipCommand(command: Command): boolean {
return isHiddenCommand(command) || command.name() === "help";
}
function resolveCommandDescription(command: Command): string {
const summary = typeof command.summary === "function" ? command.summary().trim() : "";
if (summary) {
return summary;
}
const description = command.description().trim();
if (description) {
return description;
}
return "Run this command";
}
function prepareSortedCandidates(
raw: CommandSelectorCandidate[],
): PreparedCommandSelectorCandidate[] {
const prepared = prepareSearchItems(raw);
prepared.sort((a, b) => a.label.localeCompare(b.label));
return prepared;
}
function collectCandidatesRecursive(params: {
command: Command;
parentPath: string[];
seen: Set<string>;
out: CommandSelectorCandidate[];
}): void {
for (const child of params.command.commands) {
if (shouldSkipCommand(child)) {
continue;
}
const path = [...params.parentPath, child.name()];
const label = path.join(" ");
if (!params.seen.has(label)) {
params.seen.add(label);
params.out.push({
path,
label,
description: resolveCommandDescription(child),
searchText: path.join(" "),
});
}
collectCandidatesRecursive({
command: child,
parentPath: path,
seen: params.seen,
out: params.out,
});
}
}
export function collectCommandSelectorCandidates(
program: Command,
): PreparedCommandSelectorCandidate[] {
const seen = new Set<string>();
const raw: CommandSelectorCandidate[] = [];
collectCandidatesRecursive({ command: program, parentPath: [], seen, out: raw });
return prepareSortedCandidates(raw);
}
export function resolveCommandByPath(program: Command, path: string[]): Command | null {
let current: Command = program;
for (const segment of path) {
const next = current.commands.find((child) => child.name() === segment);
if (!next) {
return null;
}
current = next;
}
return current;
}
export function commandRequiresSubcommand(command: Command): boolean {
return command.commands.some((child) => !shouldSkipCommand(child));
}
export function collectDirectSubcommandSelectorCandidates(
program: Command,
basePath: string[],
): PreparedCommandSelectorCandidate[] {
const parent = resolveCommandByPath(program, basePath);
if (!parent) {
return [];
}
const raw: CommandSelectorCandidate[] = [];
for (const child of parent.commands) {
if (shouldSkipCommand(child)) {
continue;
}
const path = [...basePath, child.name()];
raw.push({
path,
label: child.name(),
description: resolveCommandDescription(child),
searchText: `${child.name()} ${path.join(" ")}`,
});
}
return prepareSortedCandidates(raw);
}
function prioritizeDeepCommandsForSubcommandQuery(params: {
ranked: PreparedCommandSelectorCandidate[];
queryLower: string;
}): PreparedCommandSelectorCandidate[] {
const tokens = params.queryLower.split(/\s+/).filter((token) => token.length > 0);
if (tokens.length !== 1) {
return params.ranked;
}
const [token] = tokens;
if (!token) {
return params.ranked;
}
const deepExact: PreparedCommandSelectorCandidate[] = [];
const remaining: PreparedCommandSelectorCandidate[] = [];
for (const candidate of params.ranked) {
const last = candidate.path[candidate.path.length - 1]?.toLowerCase();
if (candidate.path.length >= 2 && last === token) {
deepExact.push(candidate);
continue;
}
remaining.push(candidate);
}
if (deepExact.length === 0) {
return params.ranked;
}
return [...deepExact, ...remaining];
}
export function rankCommandSelectorCandidates(
candidates: PreparedCommandSelectorCandidate[],
query: string,
): PreparedCommandSelectorCandidate[] {
const queryLower = query.trim().toLowerCase();
if (!queryLower) {
return candidates;
}
const ranked = fuzzyFilterLower(candidates, queryLower);
return prioritizeDeepCommandsForSubcommandQuery({ ranked, queryLower });
}
async function hydrateProgramCommandsForSelector(program: Command): Promise<void> {
const ctx = getProgramContext(program);
if (ctx) {
for (const name of getCoreCliCommandNames()) {
try {
await registerCoreCliByName(program, ctx, name);
} catch {
// Keep selector usable even if one registrar fails in this environment.
}
}
}
for (const entry of getSubCliEntries()) {
try {
await registerSubCliByName(program, entry.name);
} catch {
// Keep selector usable even if one registrar fails in this environment.
}
}
}
function serializePath(path: string[]): string {
return path.join(PATH_SEPARATOR);
}
function deserializePath(value: string): string[] {
return value
.split(PATH_SEPARATOR)
.map((segment) => segment.trim())
.filter(Boolean);
}
function serializeSelectionValue(params: { path: string[]; query: string }): string {
return `${params.query}${SELECTION_VALUE_SEPARATOR}${serializePath(params.path)}`;
}
function deserializeSelectionPath(value: string): string[] {
const separatorIndex = value.indexOf(SELECTION_VALUE_SEPARATOR);
const pathValue = separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value;
return deserializePath(pathValue);
}
async function promptForCommandSelection(params: {
message: string;
placeholder: string;
candidates: PreparedCommandSelectorCandidate[];
includeBackToMain?: boolean;
includeRunCurrent?: boolean;
currentPath?: string[];
}): Promise<SelectorPromptResult> {
const selection = await clackAutocomplete<string>({
message: params.message,
placeholder: params.placeholder,
maxItems: 10,
// We pre-rank the list with our fuzzy scorer, then opt out of clack's own
// filter so item order stays stable and score-based.
filter: () => true,
options() {
const query = this.userInput.trim();
const ranked = rankCommandSelectorCandidates(params.candidates, query).slice(0, MAX_RESULTS);
return [
...ranked.map((candidate) => ({
value: serializeSelectionValue({ path: candidate.path, query }),
label: candidate.label,
hint: stylePromptHint(candidate.description),
})),
...(params.includeRunCurrent
? [
{
value: RUN_CURRENT_VALUE,
label: "./",
hint: stylePromptHint(
`Run ${params.currentPath?.join(" ") ?? "selected command"} directly`,
),
},
]
: []),
...(params.includeBackToMain
? [
{
value: BACK_TO_MAIN_VALUE,
label: "../",
hint: stylePromptHint("Back to main command selector"),
},
]
: []),
{
value: SHOW_HELP_VALUE,
label: "Show help",
hint: stylePromptHint("Skip selector and print CLI help"),
},
];
},
});
if (isCancel(selection) || selection === SHOW_HELP_VALUE) {
return null;
}
if (selection === BACK_TO_MAIN_VALUE) {
return "back_to_main";
}
if (selection === RUN_CURRENT_VALUE) {
return "run_current";
}
return deserializeSelectionPath(selection);
}
export async function runInteractiveCommandSelector(program: Command): Promise<string[] | null> {
await hydrateProgramCommandsForSelector(program);
const mainCandidates = collectCommandSelectorCandidates(program);
if (mainCandidates.length === 0) {
return null;
}
while (true) {
const mainSelection = await promptForCommandSelection({
message: stylePromptMessage("Find and run a command") ?? "Find and run a command",
placeholder: "Type to fuzzy-search (e.g. msg snd)",
candidates: mainCandidates,
});
if (!mainSelection || mainSelection === "back_to_main" || mainSelection === "run_current") {
return null;
}
let selectedPath = mainSelection;
let selectedCommand = resolveCommandByPath(program, selectedPath);
if (!selectedCommand || !commandRequiresSubcommand(selectedCommand)) {
return selectedPath;
}
while (true) {
const subcommandCandidates = collectDirectSubcommandSelectorCandidates(program, selectedPath);
if (subcommandCandidates.length === 0) {
return selectedPath;
}
const subSelection = await promptForCommandSelection({
message:
stylePromptMessage(`Select subcommand for ${selectedPath.join(" ")}`) ??
`Select subcommand for ${selectedPath.join(" ")}`,
placeholder: "Type to fuzzy-search subcommands",
candidates: subcommandCandidates,
includeRunCurrent: true,
currentPath: selectedPath,
includeBackToMain: true,
});
if (!subSelection) {
return null;
}
if (subSelection === "back_to_main") {
break;
}
if (subSelection === "run_current") {
return selectedPath;
}
selectedPath = subSelection;
selectedCommand = resolveCommandByPath(program, selectedPath);
if (!selectedCommand || !commandRequiresSubcommand(selectedCommand)) {
return selectedPath;
}
}
}
}

View File

@@ -53,7 +53,8 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
.option(
"--profile <name>",
"Use a named profile (isolates OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH under ~/.openclaw-<name>)",
);
)
.option("-i, --interactive", "Open interactive command selector");
program.option("--no-color", "Disable ANSI colors", false);
program.helpOption("-h, --help", "Display help for command");

View File

@@ -1,11 +1,28 @@
import { describe, expect, it } from "vitest";
import {
isCommanderExitError,
rewriteUpdateFlagArgv,
scanInteractiveRootArgv,
shouldEnsureCliPath,
shouldRegisterPrimarySubcommand,
shouldSkipPluginCommandRegistration,
shouldUseInteractiveCommandSelector,
stripInteractiveSelectorArgs,
} from "./run-main.js";
describe("isCommanderExitError", () => {
it("detects commander exit errors", () => {
expect(isCommanderExitError({ code: "commander.helpDisplayed" })).toBe(true);
expect(isCommanderExitError({ code: "commander.unknownOption" })).toBe(true);
});
it("ignores non-commander errors", () => {
expect(isCommanderExitError(new Error("boom"))).toBe(false);
expect(isCommanderExitError({ code: "custom.error" })).toBe(false);
expect(isCommanderExitError(null)).toBe(false);
});
});
describe("rewriteUpdateFlagArgv", () => {
it("leaves argv unchanged when --update is absent", () => {
const argv = ["node", "entry.js", "status"];
@@ -103,6 +120,138 @@ describe("shouldSkipPluginCommandRegistration", () => {
});
});
describe("scanInteractiveRootArgv", () => {
it("extracts interactive flag, primary, and stripped argv in one pass", () => {
expect(
scanInteractiveRootArgv(["node", "openclaw", "-i", "--profile", "dev"]).hasInteractiveFlag,
).toBe(true);
expect(
scanInteractiveRootArgv(["node", "openclaw", "-i", "--profile", "dev"]).primary,
).toBeNull();
expect(
scanInteractiveRootArgv(["node", "openclaw", "-i", "--profile", "dev"]).strippedArgv,
).toEqual(["node", "openclaw", "--profile", "dev"]);
});
it("detects primary commands while stripping interactive flags", () => {
const scanned = scanInteractiveRootArgv(["node", "openclaw", "-i", "status", "--json"]);
expect(scanned.hasInteractiveFlag).toBe(true);
expect(scanned.primary).toBe("status");
expect(scanned.strippedArgv).toEqual(["node", "openclaw", "status", "--json"]);
});
});
describe("shouldUseInteractiveCommandSelector", () => {
it("enables selector for -i", () => {
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw", "-i"],
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(true);
});
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(false);
});
it("keeps default no-arg invocation on fast help path", () => {
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw"],
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(false);
});
it("ignores -i when a real command is already present", () => {
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw", "-i", "status"],
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(false);
});
it("disables selector for non-interactive terminals or CI", () => {
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw", "-i"],
stdinIsTTY: false,
stdoutIsTTY: true,
}),
).toBe(false);
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw", "-i"],
stdinIsTTY: true,
stdoutIsTTY: true,
ciEnv: "1",
}),
).toBe(false);
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw", "--interactive"],
stdinIsTTY: true,
stdoutIsTTY: true,
disableSelectorEnv: "1",
}),
).toBe(false);
});
it("disables selector for help/version invocations", () => {
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw", "-i", "--help"],
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(false);
});
});
describe("stripInteractiveSelectorArgs", () => {
it("removes -i from root invocations", () => {
expect(stripInteractiveSelectorArgs(["node", "openclaw", "-i"])).toEqual(["node", "openclaw"]);
});
it("keeps non-flag command arguments unchanged", () => {
expect(stripInteractiveSelectorArgs(["node", "openclaw", "interactive"])).toEqual([
"node",
"openclaw",
"interactive",
]);
});
it("keeps unrelated arguments", () => {
expect(stripInteractiveSelectorArgs(["node", "openclaw", "--profile", "dev", "-i"])).toEqual([
"node",
"openclaw",
"--profile",
"dev",
]);
});
});
describe("shouldEnsureCliPath", () => {
it("skips path bootstrap for help/version invocations", () => {
expect(shouldEnsureCliPath(["node", "openclaw", "--help"])).toBe(false);

View File

@@ -1,7 +1,7 @@
import process from "node:process";
import { fileURLToPath } from "node:url";
import { loadDotEnv } from "../infra/dotenv.js";
import { normalizeEnv } from "../infra/env.js";
import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js";
import { formatUncaughtError } from "../infra/errors.js";
import { isMainModule } from "../infra/is-main.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
@@ -61,6 +61,100 @@ export function shouldEnsureCliPath(argv: string[]): boolean {
return true;
}
const ROOT_OPTIONS_WITH_VALUE = new Set(["--profile"]);
export function scanInteractiveRootArgv(argv: string[]): {
primary: string | null;
hasInteractiveFlag: boolean;
strippedArgv: string[];
} {
const args = argv.slice(2);
const next: string[] = [];
let primary: string | null = null;
let hasInteractiveFlag = false;
let expectOptionValue = false;
let sawPrimary = false;
for (const arg of args) {
if (!sawPrimary) {
if (expectOptionValue) {
expectOptionValue = false;
next.push(arg);
continue;
}
if (arg === "--") {
sawPrimary = true;
next.push(arg);
continue;
}
if (arg === "-i" || arg === "--interactive") {
hasInteractiveFlag = true;
continue;
}
if (arg.startsWith("--profile=")) {
next.push(arg);
continue;
}
if (ROOT_OPTIONS_WITH_VALUE.has(arg)) {
expectOptionValue = true;
next.push(arg);
continue;
}
if (!arg.startsWith("-")) {
primary = arg;
sawPrimary = true;
}
}
next.push(arg);
}
return {
primary,
hasInteractiveFlag,
strippedArgv: [...argv.slice(0, 2), ...next],
};
}
export function shouldUseInteractiveCommandSelector(params: {
argv: string[];
stdinIsTTY: boolean;
stdoutIsTTY: boolean;
ciEnv?: string;
disableSelectorEnv?: string;
}): boolean {
if (hasHelpOrVersion(params.argv)) {
return false;
}
const root = scanInteractiveRootArgv(params.argv);
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) {
return false;
}
if (!params.stdinIsTTY || !params.stdoutIsTTY) {
return false;
}
if (isTruthyEnvValue(params.ciEnv) || isTruthyEnvValue(params.disableSelectorEnv)) {
return false;
}
return true;
}
export function stripInteractiveSelectorArgs(argv: string[]): string[] {
return scanInteractiveRootArgv(argv).strippedArgv;
}
export function isCommanderExitError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const code = (error as { code?: unknown }).code;
return typeof code === "string" && code.startsWith("commander.");
}
export async function runCli(argv: string[] = process.argv) {
const normalizedArgv = normalizeWindowsArgv(argv);
loadDotEnv({ quiet: true });
@@ -91,7 +185,18 @@ export async function runCli(argv: string[] = process.argv) {
process.exit(1);
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
let parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
const useInteractiveSelector = shouldUseInteractiveCommandSelector({
argv: parseArgv,
stdinIsTTY: Boolean(process.stdin.isTTY),
stdoutIsTTY: Boolean(process.stdout.isTTY),
ciEnv: process.env.CI,
disableSelectorEnv: process.env.OPENCLAW_DISABLE_COMMAND_SELECTOR,
});
if (useInteractiveSelector) {
parseArgv = stripInteractiveSelectorArgs(parseArgv);
}
// Register the primary command (builtin or subcli) so help and command parsing
// are correct even with lazy command registration.
const primary = getPrimaryCommand(parseArgv);
@@ -120,6 +225,38 @@ export async function runCli(argv: string[] = process.argv) {
registerPluginCliCommands(program, loadConfig());
}
if (useInteractiveSelector) {
const interactiveBaseArgv = parseArgv;
const { runInteractiveCommandSelector } = await import("./program/command-selector.js");
const { runCommandQuestionnaire } = await import("./program/command-questionnaire.js");
// In interactive mode we keep the process alive and return to the main menu
// after each command run (or handled command-parse/help exit).
program.exitOverride();
while (true) {
const selectedPath = await runInteractiveCommandSelector(program);
if (!selectedPath || selectedPath.length === 0) {
// Exit silently when leaving interactive mode.
return;
}
const promptArgs = await runCommandQuestionnaire({ program, commandPath: selectedPath });
if (promptArgs === null) {
// User cancelled parameter entry: return to the main picker.
continue;
}
const commandArgv = [...interactiveBaseArgv, ...selectedPath, ...promptArgs];
try {
await program.parseAsync(commandArgv);
} catch (error) {
if (!isCommanderExitError(error)) {
console.error("[openclaw] Command failed:", formatUncaughtError(error));
}
}
}
}
await program.parseAsync(parseArgv);
}