From 0ca9c4dcb0f2d195fa55c7b95ef7c8787b15bd1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 04:37:36 +0100 Subject: [PATCH] fix(cli): preserve lazy placeholder options --- .../browser/src/cli/browser-cli.lazy.test.ts | 27 +++++++++++++-- extensions/browser/src/cli/browser-cli.ts | 11 ++++-- src/cli/program/register-command-groups.ts | 7 ++++ src/cli/program/register-lazy-command.ts | 34 +++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/extensions/browser/src/cli/browser-cli.lazy.test.ts b/extensions/browser/src/cli/browser-cli.lazy.test.ts index 9ad2a5b4474..9ae8d101ebf 100644 --- a/extensions/browser/src/cli/browser-cli.lazy.test.ts +++ b/extensions/browser/src/cli/browser-cli.lazy.test.ts @@ -2,11 +2,17 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const manageMocks = vi.hoisted(() => { + const doctorAction = vi.fn(); const statusAction = vi.fn(); const registerBrowserManageCommands = vi.fn((browser: Command) => { browser.command("status").description("Show browser status").action(statusAction); + browser + .command("doctor") + .description("Check browser plugin readiness") + .option("--deep", "Run a live snapshot probe") + .action(doctorAction); }); - return { registerBrowserManageCommands, statusAction }; + return { doctorAction, registerBrowserManageCommands, statusAction }; }); const inspectMocks = vi.hoisted(() => ({ registerBrowserInspectCommands: vi.fn(), @@ -37,6 +43,7 @@ describe("registerBrowserCli lazy browser subcommands", () => { beforeEach(() => { vi.unstubAllEnvs(); manageMocks.registerBrowserManageCommands.mockClear(); + manageMocks.doctorAction.mockClear(); manageMocks.statusAction.mockClear(); inspectMocks.registerBrowserInspectCommands.mockClear(); actionInputMocks.registerBrowserActionInputCommands.mockClear(); @@ -58,7 +65,9 @@ describe("registerBrowserCli lazy browser subcommands", () => { const browser = program.commands.find((command) => command.name() === "browser"); expect(browser?.commands.map((command) => command.name())).toContain("status"); expect(browser?.commands.map((command) => command.name())).toContain("snapshot"); - expect(browser?.commands.map((command) => command.name())).toContain("doctor"); + const doctor = browser?.commands.find((command) => command.name() === "doctor"); + expect(doctor).toBeDefined(); + expect(doctor?.options.map((option) => option.long)).toContain("--deep"); expect(manageMocks.registerBrowserManageCommands).not.toHaveBeenCalled(); expect(inspectMocks.registerBrowserInspectCommands).not.toHaveBeenCalled(); expect(actionInputMocks.registerBrowserActionInputCommands).not.toHaveBeenCalled(); @@ -80,6 +89,20 @@ describe("registerBrowserCli lazy browser subcommands", () => { expect(manageMocks.statusAction).toHaveBeenCalledTimes(1); }); + it("loads browser doctor from the manage group so --deep is available", async () => { + const program = new Command(); + program.name("openclaw"); + + registerBrowserCli(program, ["node", "openclaw", "browser", "doctor", "--deep"]); + + await program.parseAsync(["browser", "doctor", "--deep"], { from: "user" }); + + expect(manageMocks.registerBrowserManageCommands).toHaveBeenCalledTimes(1); + expect(debugMocks.registerBrowserDebugCommands).not.toHaveBeenCalled(); + expect(manageMocks.doctorAction).toHaveBeenCalledTimes(1); + expect(manageMocks.doctorAction.mock.calls[0]?.[0]).toMatchObject({ deep: 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/extensions/browser/src/cli/browser-cli.ts b/extensions/browser/src/cli/browser-cli.ts index 7c36d3c75ef..5c576bf5cfe 100644 --- a/extensions/browser/src/cli/browser-cli.ts +++ b/extensions/browser/src/cli/browser-cli.ts @@ -28,9 +28,14 @@ type BrowserCommandGroupDefinition = { register: BrowserCommandRegistrar; }; -const command = (name: string, description: string): CommandGroupPlaceholder => ({ +const command = ( + name: string, + description: string, + options?: CommandGroupPlaceholder["options"], +): CommandGroupPlaceholder => ({ name, description, + ...(options ? { options } : {}), }); const browserCommandGroupDefinitions: readonly BrowserCommandGroupDefinition[] = [ @@ -48,6 +53,9 @@ const browserCommandGroupDefinitions: readonly BrowserCommandGroupDefinition[] = command("profiles", "List all browser profiles"), command("create-profile", "Create a new browser profile"), command("delete-profile", "Delete a browser profile"), + command("doctor", "Check browser plugin readiness", [ + { flags: "--deep", description: "Run a live snapshot probe" }, + ]), ], register: async (args) => { const module = await import("./browser-cli-manage.js"); @@ -105,7 +113,6 @@ const browserCommandGroupDefinitions: readonly BrowserCommandGroupDefinition[] = command("highlight", "Highlight an element by ref"), command("errors", "Get recent page errors"), command("requests", "Get recent network requests (best-effort)"), - command("doctor", "Check browser plugin readiness"), command("trace", "Record a Playwright trace"), ], register: async (args) => { diff --git a/src/cli/program/register-command-groups.ts b/src/cli/program/register-command-groups.ts index 0a4e7503b85..f8dcb89a0d0 100644 --- a/src/cli/program/register-command-groups.ts +++ b/src/cli/program/register-command-groups.ts @@ -5,6 +5,12 @@ import { registerLazyCommand } from "./register-lazy-command.js"; export type CommandGroupPlaceholder = { name: string; description: string; + options?: readonly CommandGroupPlaceholderOption[]; +}; + +export type CommandGroupPlaceholderOption = { + flags: string; + description: string; }; export type CommandGroupEntry = { @@ -53,6 +59,7 @@ export function registerLazyCommandGroup( program, name: placeholder.name, description: placeholder.description, + options: placeholder.options, removeNames: [...new Set(getCommandGroupNames(entry))], register: async () => { await entry.register(program); diff --git a/src/cli/program/register-lazy-command.ts b/src/cli/program/register-lazy-command.ts index acf895049d9..c33f4ec05f3 100644 --- a/src/cli/program/register-lazy-command.ts +++ b/src/cli/program/register-lazy-command.ts @@ -6,21 +6,55 @@ type RegisterLazyCommandParams = { program: Command; name: string; description: string; + options?: readonly { + flags: string; + description: string; + }[]; removeNames?: string[]; 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, description, + options, removeNames, register, }: RegisterLazyCommandParams): void { const placeholder = program.command(name).description(description); + for (const option of options ?? []) { + placeholder.option(option.flags, option.description); + } placeholder.allowUnknownOption(true); placeholder.allowExcessArguments(true); placeholder.action(async (...actionArgs) => { + const actionCommand = actionArgs.at(-1) as (Command & { args?: string[] }) | undefined; + if (actionCommand) { + actionCommand.args = [ + ...resolvePlaceholderOptionArgs(actionCommand), + ...(actionCommand.args ?? []), + ]; + } for (const commandName of new Set(removeNames ?? [name])) { removeCommandByName(program, commandName); }