diff --git a/CHANGELOG.md b/CHANGELOG.md index afa31c3abfc..43118dee25d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/browser/src/cli/browser-cli.lazy.test.ts b/extensions/browser/src/cli/browser-cli.lazy.test.ts index 9ae8d101ebf..7e3384931f3 100644 --- a/extensions/browser/src/cli/browser-cli.lazy.test.ts +++ b/extensions/browser/src/cli/browser-cli.lazy.test.ts @@ -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("").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(); diff --git a/src/cli/program/action-reparse.test.ts b/src/cli/program/action-reparse.test.ts index 24360a2e0d6..9fcfe0f5ac5 100644 --- a/src/cli/program/action-reparse.test.ts +++ b/src/cli/program/action-reparse.test.ts @@ -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); diff --git a/src/cli/program/action-reparse.ts b/src/cli/program/action-reparse.ts index fcb46c5de73..74251d5fe95 100644 --- a/src/cli/program/action-reparse.ts +++ b/src/cli/program/action-reparse.ts @@ -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, diff --git a/src/cli/program/helpers.test.ts b/src/cli/program/helpers.test.ts index d9c3295695a..a4440197ef3 100644 --- a/src/cli/program/helpers.test.ts +++ b/src/cli/program/helpers.test.ts @@ -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 ", "Timeout", "30000") + .option("--tag ", "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 ", "Timeout", "30000"); + + command.parse(["node", "test"]); + + expect(resolveCommandOptionArgs(command)).toEqual([]); + }); }); diff --git a/src/cli/program/helpers.ts b/src/cli/program/helpers.ts index 60ed2cbb3d7..77c768c34dc 100644 --- a/src/cli/program/helpers.ts +++ b/src/cli/program/helpers.ts @@ -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; +} diff --git a/src/cli/program/register-lazy-command.ts b/src/cli/program/register-lazy-command.ts index c33f4ec05f3..a5fd1200996 100644 --- a/src/cli/program/register-lazy-command.ts +++ b/src/cli/program/register-lazy-command.ts @@ -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; }; -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 ?? []), ]; }