fix(cli): preserve lazy command parent flags

This commit is contained in:
Peter Steinberger
2026-04-30 00:48:46 +01:00
parent 36bb723dfb
commit 01254500df
7 changed files with 186 additions and 29 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/browser: preserve parent flags while lazy-loading browser subcommands, so `openclaw browser --json open` and `openclaw browser --json tabs` keep machine-readable output after reparsing. Fixes #74574. Thanks @devintegeritsm.
- Plugins/runtime-deps: add `openclaw plugins deps` inspection and repair with script-free package-manager defaults shared across plugin installers, so operators can repair missing bundled runtime deps without corrupting JSON output or blocking unrelated conflict-free deps. Thanks @vincentkoc.
- Agents/output: strip internal `[tool calls omitted]` replay placeholders from user-facing replies while preserving visible reply whitespace. Fixes #74573. Thanks @blaspat.
- Agents/sessions: emit a terminal lifecycle backstop when embedded timeout/error turns return without `agent_end`, so Gateway sessions no longer stay stuck in `running` after failover surfaces a timeout. Fixes #74607. Thanks @millerc79.

View File

@@ -3,16 +3,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const manageMocks = vi.hoisted(() => {
const doctorAction = vi.fn();
const openAction = vi.fn();
const statusAction = vi.fn();
const tabsAction = vi.fn();
const registerBrowserManageCommands = vi.fn((browser: Command) => {
browser.command("status").description("Show browser status").action(statusAction);
browser.command("tabs").description("List tabs").action(tabsAction);
browser.command("open").description("Open URL").argument("<url>").action(openAction);
browser
.command("doctor")
.description("Check browser plugin readiness")
.option("--deep", "Run a live snapshot probe")
.action(doctorAction);
});
return { doctorAction, registerBrowserManageCommands, statusAction };
return { doctorAction, openAction, registerBrowserManageCommands, statusAction, tabsAction };
});
const inspectMocks = vi.hoisted(() => ({
registerBrowserInspectCommands: vi.fn(),
@@ -44,7 +48,9 @@ describe("registerBrowserCli lazy browser subcommands", () => {
vi.unstubAllEnvs();
manageMocks.registerBrowserManageCommands.mockClear();
manageMocks.doctorAction.mockClear();
manageMocks.openAction.mockClear();
manageMocks.statusAction.mockClear();
manageMocks.tabsAction.mockClear();
inspectMocks.registerBrowserInspectCommands.mockClear();
actionInputMocks.registerBrowserActionInputCommands.mockClear();
actionObserveMocks.registerBrowserActionObserveCommands.mockClear();
@@ -103,6 +109,29 @@ describe("registerBrowserCli lazy browser subcommands", () => {
expect(manageMocks.doctorAction.mock.calls[0]?.[0]).toMatchObject({ deep: true });
});
it("preserves parent --json while reparsing lazy manage commands", async () => {
const program = new Command();
program.name("openclaw");
registerBrowserCli(program, ["node", "openclaw", "browser", "--json", "open", "about:blank"]);
await program.parseAsync(["browser", "--json", "open", "about:blank"], { from: "user" });
expect(manageMocks.openAction).toHaveBeenCalledTimes(1);
const openCommand = manageMocks.openAction.mock.calls[0]?.at(-1) as Command | undefined;
expect(openCommand?.parent?.opts()).toMatchObject({ json: true });
const tabsProgram = new Command();
tabsProgram.name("openclaw");
registerBrowserCli(tabsProgram, ["node", "openclaw", "browser", "--json", "tabs"]);
await tabsProgram.parseAsync(["browser", "--json", "tabs"], { from: "user" });
expect(manageMocks.tabsAction).toHaveBeenCalledTimes(1);
const tabsCommand = manageMocks.tabsAction.mock.calls[0]?.at(-1) as Command | undefined;
expect(tabsCommand?.parent?.opts()).toMatchObject({ json: true });
});
it("can eagerly register all browser groups for compatibility", async () => {
vi.stubEnv("OPENCLAW_DISABLE_LAZY_SUBCOMMANDS", "1");
const program = new Command();

View File

@@ -4,6 +4,7 @@ import { reparseProgramFromActionArgs } from "./action-reparse.js";
const buildParseArgvMock = vi.hoisted(() => vi.fn());
const resolveActionArgsMock = vi.hoisted(() => vi.fn());
const resolveCommandOptionArgsMock = vi.hoisted(() => vi.fn());
vi.mock("../argv.js", () => ({
buildParseArgv: buildParseArgvMock,
@@ -11,6 +12,7 @@ vi.mock("../argv.js", () => ({
vi.mock("./helpers.js", () => ({
resolveActionArgs: resolveActionArgsMock,
resolveCommandOptionArgs: resolveCommandOptionArgsMock,
}));
describe("reparseProgramFromActionArgs", () => {
@@ -18,6 +20,7 @@ describe("reparseProgramFromActionArgs", () => {
vi.clearAllMocks();
buildParseArgvMock.mockReturnValue(["node", "openclaw", "status"]);
resolveActionArgsMock.mockReturnValue([]);
resolveCommandOptionArgsMock.mockReturnValue([]);
});
it("uses action command name + args as fallback argv", async () => {
@@ -60,6 +63,27 @@ describe("reparseProgramFromActionArgs", () => {
expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "status"]);
});
it("preserves explicit parent command options in fallback argv", async () => {
const program = new Command().name("browser");
const parseAsync = vi.spyOn(program, "parseAsync").mockResolvedValue(program);
const actionCommand = {
name: () => "open",
parent: program,
} as unknown as Command;
resolveActionArgsMock.mockReturnValue(["about:blank"]);
resolveCommandOptionArgsMock.mockReturnValue(["--json"]);
await reparseProgramFromActionArgs(program, [actionCommand]);
expect(resolveCommandOptionArgsMock).toHaveBeenCalledWith(program);
expect(buildParseArgvMock).toHaveBeenCalledWith({
programName: "browser",
rawArgs: [],
fallbackArgv: ["--json", "open", "about:blank"],
});
expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "status"]);
});
it("uses program root when action command is missing", async () => {
const program = new Command().name("openclaw");
const parseAsync = vi.spyOn(program, "parseAsync").mockResolvedValue(program);

View File

@@ -1,6 +1,15 @@
import type { Command } from "commander";
import { buildParseArgv } from "../argv.js";
import { resolveActionArgs } from "./helpers.js";
import { resolveActionArgs, resolveCommandOptionArgs } from "./helpers.js";
function buildFallbackArgv(program: Command, actionCommand: Command | undefined): string[] {
const actionArgsList = resolveActionArgs(actionCommand);
const parentOptionArgs =
actionCommand?.parent === program ? resolveCommandOptionArgs(program) : [];
return actionCommand?.name()
? [...parentOptionArgs, actionCommand.name(), ...actionArgsList]
: [...parentOptionArgs, ...actionArgsList];
}
export async function reparseProgramFromActionArgs(
program: Command,
@@ -9,10 +18,7 @@ export async function reparseProgramFromActionArgs(
const actionCommand = actionArgs.at(-1) as Command | undefined;
const root = actionCommand?.parent ?? program;
const rawArgs = (root as Command & { rawArgs?: string[] }).rawArgs;
const actionArgsList = resolveActionArgs(actionCommand);
const fallbackArgv = actionCommand?.name()
? [actionCommand.name(), ...actionArgsList]
: actionArgsList;
const fallbackArgv = buildFallbackArgv(program, actionCommand);
const parseArgv = buildParseArgv({
programName: program.name(),
rawArgs,

View File

@@ -1,6 +1,11 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { collectOption, parsePositiveIntOrUndefined, resolveActionArgs } from "./helpers.js";
import {
collectOption,
parsePositiveIntOrUndefined,
resolveActionArgs,
resolveCommandOptionArgs,
} from "./helpers.js";
describe("program helpers", () => {
it("collectOption appends values in order", () => {
@@ -38,4 +43,46 @@ describe("program helpers", () => {
expect(resolveActionArgs(command)).toEqual([]);
expect(resolveActionArgs(undefined)).toEqual([]);
});
it("resolveCommandOptionArgs serializes explicit options", () => {
const command = new Command()
.option("--json", "JSON output", false)
.option("--timeout <ms>", "Timeout", "30000")
.option("--tag <name>", "Tag", collectOption)
.option("--no-progress", "Disable progress");
command.parse([
"node",
"test",
"--json",
"--timeout",
"10",
"--tag",
"a",
"--tag",
"b",
"--no-progress",
]);
expect(resolveCommandOptionArgs(command)).toEqual([
"--json",
"--timeout",
"10",
"--tag",
"a",
"--tag",
"b",
"--no-progress",
]);
});
it("resolveCommandOptionArgs skips defaults", () => {
const command = new Command()
.option("--json", "JSON output", false)
.option("--timeout <ms>", "Timeout", "30000");
command.parse(["node", "test"]);
expect(resolveCommandOptionArgs(command)).toEqual([]);
});
});

View File

@@ -1,3 +1,5 @@
import type { Command } from "commander";
export function collectOption(value: string, previous: string[] = []): string[] {
return [...previous, value];
}
@@ -23,10 +25,76 @@ export function parsePositiveIntOrUndefined(value: unknown): number | undefined
return undefined;
}
export function resolveActionArgs(actionCommand?: import("commander").Command): string[] {
export function resolveActionArgs(actionCommand?: Command): string[] {
if (!actionCommand) {
return [];
}
const args = (actionCommand as import("commander").Command & { args?: string[] }).args;
const args = (actionCommand as Command & { args?: string[] }).args;
return Array.isArray(args) ? args : [];
}
function isDefaultOptionValue(command: Command, name: string): boolean {
if (typeof command.getOptionValueSource !== "function") {
return false;
}
return command.getOptionValueSource(name) === "default";
}
function appendOptionValue(out: string[], flag: string, value: unknown): void {
if (value === undefined) {
return;
}
if (value === false) {
if (flag.startsWith("--no-")) {
out.push(flag);
}
return;
}
if (value === true) {
out.push(flag);
return;
}
const arg = stringifyOptionValue(value);
if (arg !== undefined) {
out.push(flag, arg);
}
}
function stringifyOptionValue(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
if (typeof value === "bigint") {
return value.toString();
}
return undefined;
}
export function resolveCommandOptionArgs(command?: Command): string[] {
if (!command) {
return [];
}
const out: string[] = [];
for (const option of command.options) {
const name = option.attributeName();
if (isDefaultOptionValue(command, name)) {
continue;
}
const flag = option.long ?? option.short;
if (!flag) {
continue;
}
const value = command.getOptionValue(name);
if (Array.isArray(value)) {
for (const item of value) {
appendOptionValue(out, flag, item);
}
continue;
}
appendOptionValue(out, flag, value);
}
return out;
}

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { reparseProgramFromActionArgs } from "./action-reparse.js";
import { removeCommandByName } from "./command-tree.js";
import { resolveCommandOptionArgs } from "./helpers.js";
type RegisterLazyCommandParams = {
program: Command;
@@ -14,25 +15,6 @@ type RegisterLazyCommandParams = {
register: () => Promise<void> | void;
};
function resolvePlaceholderOptionArgs(command: Command): string[] {
const out: string[] = [];
for (const option of command.options) {
const value = command.getOptionValue(option.attributeName());
if (value === undefined || value === false) {
continue;
}
const flag = option.long ?? option.short;
if (!flag) {
continue;
}
out.push(flag);
if (value !== true) {
out.push(String(value));
}
}
return out;
}
export function registerLazyCommand({
program,
name,
@@ -51,7 +33,7 @@ export function registerLazyCommand({
const actionCommand = actionArgs.at(-1) as (Command & { args?: string[] }) | undefined;
if (actionCommand) {
actionCommand.args = [
...resolvePlaceholderOptionArgs(actionCommand),
...resolveCommandOptionArgs(actionCommand),
...(actionCommand.args ?? []),
];
}