From eb87af9ea5f198610c208bd4aebea0fcefb37c07 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Mon, 16 Feb 2026 20:55:05 +0100 Subject: [PATCH] CLI: add explicit interactive command entrypoint --- src/cli/program/command-selector.ts | 3 + src/cli/program/help.ts | 3 +- src/cli/run-main.test.ts | 59 +++++++++++++-- src/cli/run-main.ts | 113 +++++++++++++++++++++++++--- 4 files changed, 159 insertions(+), 19 deletions(-) diff --git a/src/cli/program/command-selector.ts b/src/cli/program/command-selector.ts index 2754e547bf..297050333b 100644 --- a/src/cli/program/command-selector.ts +++ b/src/cli/program/command-selector.ts @@ -48,6 +48,9 @@ function collectCandidatesRecursive(params: { if (isHiddenCommand(child) || child.name() === "help") { continue; } + if (params.parentPath.length === 0 && child.name() === "interactive") { + continue; + } const path = [...params.parentPath, child.name()]; const label = path.join(" "); if (!params.seen.has(label)) { diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index 8769a08db9..81d103419c 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -53,7 +53,8 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) { .option( "--profile ", "Use a named profile (isolates OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH under ~/.openclaw-)", - ); + ) + .option("-i, --interactive", "Open interactive command selector"); program.option("--no-color", "Disable ANSI colors", false); program.helpOption("-h, --help", "Display help for command"); diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 82688cf5eb..f314fbea9f 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -5,6 +5,7 @@ import { shouldRegisterPrimarySubcommand, shouldSkipPluginCommandRegistration, shouldUseInteractiveCommandSelector, + stripInteractiveSelectorArgs, } from "./run-main.js"; describe("rewriteUpdateFlagArgv", () => { @@ -105,20 +106,40 @@ describe("shouldSkipPluginCommandRegistration", () => { }); describe("shouldUseInteractiveCommandSelector", () => { - it("enables selector for plain no-arg interactive invocations", () => { + it("enables selector for -i", () => { expect( shouldUseInteractiveCommandSelector({ - argv: ["node", "openclaw"], + argv: ["node", "openclaw", "-i"], stdinIsTTY: true, stdoutIsTTY: true, }), ).toBe(true); }); - it("disables selector when a command is already present", () => { + it("enables selector for interactive command", () => { expect( shouldUseInteractiveCommandSelector({ - argv: ["node", "openclaw", "status"], + argv: ["node", "openclaw", "interactive"], + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true); + }); + + 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, }), @@ -128,14 +149,14 @@ describe("shouldUseInteractiveCommandSelector", () => { it("disables selector for non-interactive terminals or CI", () => { expect( shouldUseInteractiveCommandSelector({ - argv: ["node", "openclaw"], + argv: ["node", "openclaw", "-i"], stdinIsTTY: false, stdoutIsTTY: true, }), ).toBe(false); expect( shouldUseInteractiveCommandSelector({ - argv: ["node", "openclaw"], + argv: ["node", "openclaw", "-i"], stdinIsTTY: true, stdoutIsTTY: true, ciEnv: "1", @@ -143,7 +164,7 @@ describe("shouldUseInteractiveCommandSelector", () => { ).toBe(false); expect( shouldUseInteractiveCommandSelector({ - argv: ["node", "openclaw"], + argv: ["node", "openclaw", "interactive"], stdinIsTTY: true, stdoutIsTTY: true, disableSelectorEnv: "1", @@ -154,7 +175,7 @@ describe("shouldUseInteractiveCommandSelector", () => { it("disables selector for help/version invocations", () => { expect( shouldUseInteractiveCommandSelector({ - argv: ["node", "openclaw", "--help"], + argv: ["node", "openclaw", "-i", "--help"], stdinIsTTY: true, stdoutIsTTY: true, }), @@ -162,6 +183,28 @@ describe("shouldUseInteractiveCommandSelector", () => { }); }); +describe("stripInteractiveSelectorArgs", () => { + it("removes -i from root invocations", () => { + expect(stripInteractiveSelectorArgs(["node", "openclaw", "-i"])).toEqual(["node", "openclaw"]); + }); + + it("removes interactive command from root invocations", () => { + expect(stripInteractiveSelectorArgs(["node", "openclaw", "interactive"])).toEqual([ + "node", + "openclaw", + ]); + }); + + 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); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 1c235d0907..65b0bc69db 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -61,6 +61,46 @@ export function shouldEnsureCliPath(argv: string[]): boolean { return true; } +const ROOT_OPTIONS_WITH_VALUE = new Set(["--profile"]); + +function parseRootInvocation(argv: string[]): { + primary: string | null; + hasInteractiveFlag: boolean; +} { + const args = argv.slice(2); + let primary: string | null = null; + let hasInteractiveFlag = false; + let expectOptionValue = false; + + for (const arg of args) { + if (expectOptionValue) { + expectOptionValue = false; + continue; + } + if (arg === "--") { + break; + } + if (arg === "-i" || arg === "--interactive") { + hasInteractiveFlag = true; + continue; + } + if (arg.startsWith("--profile=")) { + continue; + } + if (ROOT_OPTIONS_WITH_VALUE.has(arg)) { + expectOptionValue = true; + continue; + } + if (arg.startsWith("-")) { + continue; + } + primary = arg; + break; + } + + return { primary, hasInteractiveFlag }; +} + export function shouldUseInteractiveCommandSelector(params: { argv: string[]; stdinIsTTY: boolean; @@ -71,7 +111,14 @@ export function shouldUseInteractiveCommandSelector(params: { if (hasHelpOrVersion(params.argv)) { return false; } - if (getPrimaryCommand(params.argv)) { + const root = parseRootInvocation(params.argv); + const requestedViaCommand = root.primary === "interactive"; + if (!root.hasInteractiveFlag && !requestedViaCommand) { + 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") { return false; } if (!params.stdinIsTTY || !params.stdoutIsTTY) { @@ -83,6 +130,49 @@ export function shouldUseInteractiveCommandSelector(params: { return true; } +export function stripInteractiveSelectorArgs(argv: string[]): string[] { + const args = argv.slice(2); + const next: string[] = []; + let sawPrimary = false; + let expectOptionValue = 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") { + 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("-")) { + sawPrimary = true; + if (arg === "interactive") { + continue; + } + } + } + next.push(arg); + } + + return [...argv.slice(0, 2), ...next]; +} + export async function runCli(argv: string[] = process.argv) { const normalizedArgv = normalizeWindowsArgv(argv); loadDotEnv({ quiet: true }); @@ -114,6 +204,17 @@ export async function runCli(argv: string[] = process.argv) { }); 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); @@ -142,15 +243,7 @@ export async function runCli(argv: string[] = process.argv) { registerPluginCliCommands(program, loadConfig()); } - if ( - 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) { const { runInteractiveCommandSelector } = await import("./program/command-selector.js"); const selectedPath = await runInteractiveCommandSelector(program); if (selectedPath && selectedPath.length > 0) {