CLI: return to interactive main menu after command runs

This commit is contained in:
Benjamin Jesuiter
2026-02-17 09:15:30 +01:00
parent 0be8e6e3e4
commit e78f25d05a
2 changed files with 48 additions and 13 deletions

View File

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

View File

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