From e78f25d05a90ab3c49b3989c6bdea029e5f9a1b7 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Tue, 17 Feb 2026 09:15:30 +0100 Subject: [PATCH] CLI: return to interactive main menu after command runs --- src/cli/run-main.test.ts | 14 ++++++++++++ src/cli/run-main.ts | 47 +++++++++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index beff226cc4f..a91e3d9fd0f 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isCommanderExitError, rewriteUpdateFlagArgv, shouldEnsureCliPath, shouldRegisterPrimarySubcommand, @@ -8,6 +9,19 @@ import { 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"]; diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 751afbea032..f3662e63c72 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -169,6 +169,14 @@ export function stripInteractiveSelectorArgs(argv: string[]): string[] { return [...argv.slice(0, 2), ...next]; } +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 }); @@ -240,21 +248,34 @@ export async function runCli(argv: string[] = process.argv) { } if (useInteractiveSelector) { + const interactiveBaseArgv = parseArgv; const { runInteractiveCommandSelector } = await import("./program/command-selector.js"); - const selectedPath = await runInteractiveCommandSelector(program); - 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]; + + // 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)); + } + } } }