fix(cli): preserve lazy placeholder options

This commit is contained in:
Peter Steinberger
2026-04-26 04:37:36 +01:00
parent e74f2e1501
commit 0ca9c4dcb0
4 changed files with 75 additions and 4 deletions

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -6,21 +6,55 @@ type RegisterLazyCommandParams = {
program: Command;
name: string;
description: string;
options?: readonly {
flags: string;
description: string;
}[];
removeNames?: string[];
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,
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);
}