From 776d2ab65d51225f04ea3078bc302e5aa5eb53ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 11:39:32 +0100 Subject: [PATCH] fix(browser): lazy-load browser CLI runtime Co-authored-by: pandego <7780875+pandego@users.noreply.github.com> Co-authored-by: Tianworld <3580442280@qq.com> --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/plugins/sdk-subpaths.md | 2 +- extensions/browser/cli-metadata.ts | 2 +- extensions/browser/index.test.ts | 83 ++++++- extensions/browser/plugin-registration.ts | 112 +++++++-- extensions/browser/register.runtime.ts | 1 - .../browser/src/cli/browser-cli.lazy.test.ts | 99 ++++++++ extensions/browser/src/cli/browser-cli.ts | 228 +++++++++++++++++- package.json | 8 +- src/plugin-sdk/cli-runtime.ts | 24 ++ 11 files changed, 514 insertions(+), 50 deletions(-) create mode 100644 extensions/browser/src/cli/browser-cli.lazy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e879a80b554..d09bad834d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Telegram: remove the startup persisted-offset `getUpdates` preflight so polling restarts do not self-conflict before the runner starts. Fixes #69304. (#69779) Thanks @chinar-amrutkar. - Telegram: keep the polling stall watchdog active even when grammY reports the runner as not running while its task is still pending, so a rebuilt transport cannot leave `getUpdates` silent until a manual gateway restart. Fixes #69064. Thanks @LDLoeb. - Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai. +- Browser/CLI: lazy-load browser command groups and plugin runtime services so `openclaw browser --help` can render without loading the full browser automation stack. Fixes #65400. (#65460, #66640) Thanks @pandego and @Tianworld. - Browser/downloads: seed managed Chrome profiles with OpenClaw download prefs and capture unmanaged click-triggered downloads under the guarded downloads directory, while explicit download waiters still own their target file. (#64558) Thanks @Pearcekieser. - Browser/Chrome: stop passing redundant `--disable-setuid-sandbox` when `browser.noSandbox` is enabled; `--no-sandbox` remains the effective sandbox opt-out. (#67939) Thanks @sebykrueger. - Browser/client: stop telling agents to permanently avoid the browser after transient timeout or cancellation failures; keep the no-retry hint for persistent unavailable/rate-limit cases. (#46505) Thanks @jriff. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 99d4b0480a5..cc44fbd766f 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -3a217ac0157fb46f42d455f1509b70a6d4ca3c41d6da00ac412b642875e4c9ef plugin-sdk-api-baseline.json -d962b39c50017ef8e8545d9a3902a4f37f19d338256d8c96c9f860c7a120b687 plugin-sdk-api-baseline.jsonl +d5bad55d588ecafab1298a2a79578ce13becced8bc33d2b8543161ab528feca4 plugin-sdk-api-baseline.json +373ded33d5ecc61229de5179827182f0c6f805a804e1f0666cf2da68301153be plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 172c6af777f..8b770a6d1f8 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -152,7 +152,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/hook-runtime` | Shared webhook/internal hook pipeline helpers | | `plugin-sdk/lazy-runtime` | Lazy runtime import/binding helpers such as `createLazyRuntimeModule`, `createLazyRuntimeMethod`, and `createLazyRuntimeSurface` | | `plugin-sdk/process-runtime` | Process exec helpers | - | `plugin-sdk/cli-runtime` | CLI formatting, wait, and version helpers | + | `plugin-sdk/cli-runtime` | CLI formatting, wait, version, argument-invocation, and lazy command-group helpers | | `plugin-sdk/gateway-runtime` | Gateway client and channel-status patch helpers | | `plugin-sdk/config-runtime` | Config load/write helpers and plugin-config lookup helpers | | `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable | diff --git a/extensions/browser/cli-metadata.ts b/extensions/browser/cli-metadata.ts index b4dc7a665e6..ae27cf639d2 100644 --- a/extensions/browser/cli-metadata.ts +++ b/extensions/browser/cli-metadata.ts @@ -7,7 +7,7 @@ export default definePluginEntry({ register(api) { api.registerCli( async ({ program }) => { - const { registerBrowserCli } = await import("./runtime-api.js"); + const { registerBrowserCli } = await import("./src/cli/browser-cli.js"); registerBrowserCli(program); }, { commands: ["browser"] }, diff --git a/extensions/browser/index.test.ts b/extensions/browser/index.test.ts index a69ed193164..6f85c19e9f0 100644 --- a/extensions/browser/index.test.ts +++ b/extensions/browser/index.test.ts @@ -19,10 +19,12 @@ const runtimeApiMocks = vi.hoisted(() => ({ name: "browser", description: "browser", parameters: { type: "object", properties: {} }, - execute: vi.fn(), + execute: vi.fn(async () => ({ type: "json", value: { ok: true } })), })), + collectBrowserSecurityAuditFindings: vi.fn(() => []), handleBrowserGatewayRequest: vi.fn(), registerBrowserCli: vi.fn(), + runBrowserProxyCommand: vi.fn(async () => "ok"), })); vi.mock("./register.runtime.js", async () => { @@ -30,13 +32,18 @@ vi.mock("./register.runtime.js", async () => { await vi.importActual("./register.runtime.js"); return { ...actual, + collectBrowserSecurityAuditFindings: runtimeApiMocks.collectBrowserSecurityAuditFindings, createBrowserPluginService: runtimeApiMocks.createBrowserPluginService, createBrowserTool: runtimeApiMocks.createBrowserTool, handleBrowserGatewayRequest: runtimeApiMocks.handleBrowserGatewayRequest, - registerBrowserCli: runtimeApiMocks.registerBrowserCli, + runBrowserProxyCommand: runtimeApiMocks.runBrowserProxyCommand, }; }); +vi.mock("./src/cli/browser-cli.js", () => ({ + registerBrowserCli: runtimeApiMocks.registerBrowserCli, +})); + function createApi() { const registerCli = vi.fn(); const registerGatewayMethod = vi.fn(); @@ -94,23 +101,29 @@ describe("browser plugin", () => { expect(fs.readFileSync(skillPath, "utf8")).toContain("name: browser-automation"); }); - it("forwards per-session browser options into the tool factory", async () => { + it("keeps browser tool registration synchronous while loading runtime on execute", async () => { const { api, registerTool } = createApi(); registerBrowserPlugin(api); - const tool = registerTool.mock.calls[0]?.[0]; - if (typeof tool !== "function") { + const factory = registerTool.mock.calls[0]?.[0]; + if (typeof factory !== "function") { throw new Error("expected browser plugin to register a tool factory"); } - tool({ + const tool = factory({ sessionKey: "agent:main:webchat:direct:123", browser: { sandboxBridgeUrl: "http://127.0.0.1:9999", allowHostControl: true, }, }); + if (!tool || Array.isArray(tool)) { + throw new Error("expected browser plugin to return a single tool"); + } + expect(tool.name).toBe("browser"); + expect(runtimeApiMocks.createBrowserTool).not.toHaveBeenCalled(); + await tool.execute("call-1", { action: "status" }); expect(runtimeApiMocks.createBrowserTool).toHaveBeenCalledWith({ sandboxBridgeUrl: "http://127.0.0.1:9999", allowHostControl: true, @@ -118,15 +131,61 @@ describe("browser plugin", () => { }); }); - it("registers browser.request as an admin gateway method", () => { + it("registers CLI descriptors and lazy-loads the lightweight browser CLI", async () => { + const { api, registerCli } = createApi(); + registerBrowserPlugin(api); + + expect(registerCli).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + commands: ["browser"], + descriptors: [ + { + name: "browser", + description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)", + hasSubcommands: true, + }, + ], + }), + ); + + const registrar = registerCli.mock.calls[0]?.[0]; + await registrar({ program: {} as never }); + expect(runtimeApiMocks.registerBrowserCli).toHaveBeenCalledWith({}); + }); + + it("registers browser.request as an admin gateway method and lazy-loads handler", async () => { const { api, registerGatewayMethod } = createApi(); registerBrowserPlugin(api); - expect(registerGatewayMethod).toHaveBeenCalledWith( - "browser.request", - runtimeApiMocks.handleBrowserGatewayRequest, - { scope: "operator.admin" }, - ); + expect(registerGatewayMethod).toHaveBeenCalledWith("browser.request", expect.any(Function), { + scope: "operator.admin", + }); + const handler = registerGatewayMethod.mock.calls[0]?.[1]; + await handler({ method: "browser.request" }); + expect(runtimeApiMocks.handleBrowserGatewayRequest).toHaveBeenCalledWith({ + method: "browser.request", + }); + }); + + it("lazy-loads node host and audit runtime handlers", async () => { + await expect(browserPluginNodeHostCommands[0]?.handle("{}")).resolves.toBe("ok"); + expect(runtimeApiMocks.runBrowserProxyCommand).toHaveBeenCalledWith("{}"); + + await expect(browserSecurityAuditCollectors[0]?.({} as never)).resolves.toEqual([]); + expect(runtimeApiMocks.collectBrowserSecurityAuditFindings).toHaveBeenCalled(); + }); + + it("lazy-loads the browser service on start", async () => { + const { api, registerService } = createApi(); + registerBrowserPlugin(api); + + const service = registerService.mock.calls[0]?.[0]; + expect(service).toMatchObject({ id: "browser-control" }); + expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled(); + + await service.start({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } }); + expect(runtimeApiMocks.createBrowserPluginService).toHaveBeenCalledOnce(); }); it("declares setup auto-enable reasons for browser config surfaces", () => { diff --git a/extensions/browser/plugin-registration.ts b/extensions/browser/plugin-registration.ts index f441978bf83..9bc013eea52 100644 --- a/extensions/browser/plugin-registration.ts +++ b/extensions/browser/plugin-registration.ts @@ -1,17 +1,52 @@ import type { + AnyAgentTool, OpenClawPluginApi, OpenClawPluginNodeHostCommand, + OpenClawPluginSecurityAuditCollector, + OpenClawPluginService, OpenClawPluginToolContext, OpenClawPluginToolFactory, } from "openclaw/plugin-sdk/plugin-entry"; -import { - collectBrowserSecurityAuditFindings, - createBrowserPluginService, - createBrowserTool, - handleBrowserGatewayRequest, - registerBrowserCli, - runBrowserProxyCommand, -} from "./register.runtime.js"; +import { BrowserToolSchema } from "./src/browser-tool.schema.js"; + +const BROWSER_CLI_DESCRIPTOR = { + name: "browser", + description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)", + hasSubcommands: true, +}; + +function createLazyBrowserTool(opts?: { + sandboxBridgeUrl?: string; + allowHostControl?: boolean; + agentSessionKey?: string; +}): AnyAgentTool { + const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host"; + const hostHint = + opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed."; + return { + label: "Browser", + name: "browser", + description: [ + "Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).", + "Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).", + 'For the logged-in user browser, use profile="user". A supported Chromium-based browser (v144+) must be running on the selected host or browser node. Use only when existing logins/cookies matter and the user is present.', + 'For profile="user" or other existing-session profiles, omit timeoutMs on act:type, evaluate, hover, scrollIntoView, drag, select, and fill; that driver rejects per-call timeout overrides for those actions.', + 'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node= or target="node".', + "When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc). For tab operations, targetId also accepts tabId handles (t1) and labels from action=tabs.", + "For multi-step browser work, login checks, stale refs, duplicate tabs, or Google Meet flows, use the bundled browser-automation skill when it is available.", + 'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.', + "Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.", + `target selects browser location (sandbox|host|node). Default: ${targetDefault}.`, + hostHint, + ].join(" "), + parameters: BrowserToolSchema, + execute: async (toolCallId, args, signal, onUpdate) => { + const { createBrowserTool } = await import("./register.runtime.js"); + const tool = createBrowserTool(opts); + return await tool.execute(toolCallId, args, signal, onUpdate); + }, + }; +} export const browserPluginReload = { restartPrefixes: ["browser"] }; @@ -19,22 +54,67 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [ { command: "browser.proxy", cap: "browser", - handle: runBrowserProxyCommand, + handle: async (paramsJSON) => { + const { runBrowserProxyCommand } = await import("./register.runtime.js"); + return await runBrowserProxyCommand(paramsJSON); + }, }, ]; -export const browserSecurityAuditCollectors = [collectBrowserSecurityAuditFindings]; +export const browserSecurityAuditCollectors: OpenClawPluginSecurityAuditCollector[] = [ + async (ctx) => { + const { collectBrowserSecurityAuditFindings } = await import("./register.runtime.js"); + return collectBrowserSecurityAuditFindings(ctx); + }, +]; + +function createLazyBrowserPluginService(): OpenClawPluginService { + let service: OpenClawPluginService | null = null; + const loadService = async () => { + if (!service) { + const { createBrowserPluginService } = await import("./register.runtime.js"); + service = createBrowserPluginService(); + } + return service; + }; + return { + id: "browser-control", + start: async (ctx) => { + const loaded = await loadService(); + await loaded.start(ctx); + }, + stop: async (ctx) => { + if (!service?.stop) { + return; + } + await service.stop(ctx); + }, + }; +} export function registerBrowserPlugin(api: OpenClawPluginApi) { api.registerTool(((ctx: OpenClawPluginToolContext) => - createBrowserTool({ + createLazyBrowserTool({ sandboxBridgeUrl: ctx.browser?.sandboxBridgeUrl, allowHostControl: ctx.browser?.allowHostControl, agentSessionKey: ctx.sessionKey, })) as OpenClawPluginToolFactory); - api.registerCli(({ program }) => registerBrowserCli(program), { commands: ["browser"] }); - api.registerGatewayMethod("browser.request", handleBrowserGatewayRequest, { - scope: "operator.admin", - }); - api.registerService(createBrowserPluginService()); + api.registerCli( + async ({ program }) => { + const { registerBrowserCli } = await import("./src/cli/browser-cli.js"); + registerBrowserCli(program); + }, + { commands: ["browser"], descriptors: [BROWSER_CLI_DESCRIPTOR] }, + ); + api.registerGatewayMethod( + "browser.request", + async (opts) => { + const { handleBrowserGatewayRequest } = await import("./register.runtime.js"); + return await handleBrowserGatewayRequest(opts); + }, + { + scope: "operator.admin", + }, + ); + api.registerService(createLazyBrowserPluginService()); } diff --git a/extensions/browser/register.runtime.ts b/extensions/browser/register.runtime.ts index 348c301299e..52060f79080 100644 --- a/extensions/browser/register.runtime.ts +++ b/extensions/browser/register.runtime.ts @@ -1,5 +1,4 @@ export { createBrowserTool } from "./src/browser-tool.js"; -export { registerBrowserCli } from "./src/cli/browser-cli.js"; export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js"; export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js"; export { createBrowserPluginService } from "./src/plugin-service.js"; diff --git a/extensions/browser/src/cli/browser-cli.lazy.test.ts b/extensions/browser/src/cli/browser-cli.lazy.test.ts new file mode 100644 index 00000000000..9ad2a5b4474 --- /dev/null +++ b/extensions/browser/src/cli/browser-cli.lazy.test.ts @@ -0,0 +1,99 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const manageMocks = vi.hoisted(() => { + const statusAction = vi.fn(); + const registerBrowserManageCommands = vi.fn((browser: Command) => { + browser.command("status").description("Show browser status").action(statusAction); + }); + return { registerBrowserManageCommands, statusAction }; +}); +const inspectMocks = vi.hoisted(() => ({ + registerBrowserInspectCommands: vi.fn(), +})); +const actionInputMocks = vi.hoisted(() => ({ + registerBrowserActionInputCommands: vi.fn(), +})); +const actionObserveMocks = vi.hoisted(() => ({ + registerBrowserActionObserveCommands: vi.fn(), +})); +const debugMocks = vi.hoisted(() => ({ + registerBrowserDebugCommands: vi.fn(), +})); +const stateMocks = vi.hoisted(() => ({ + registerBrowserStateCommands: vi.fn(), +})); + +vi.mock("./browser-cli-manage.js", () => manageMocks); +vi.mock("./browser-cli-inspect.js", () => inspectMocks); +vi.mock("./browser-cli-actions-input.js", () => actionInputMocks); +vi.mock("./browser-cli-actions-observe.js", () => actionObserveMocks); +vi.mock("./browser-cli-debug.js", () => debugMocks); +vi.mock("./browser-cli-state.js", () => stateMocks); + +const { registerBrowserCli } = await import("./browser-cli.js"); + +describe("registerBrowserCli lazy browser subcommands", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + manageMocks.registerBrowserManageCommands.mockClear(); + manageMocks.statusAction.mockClear(); + inspectMocks.registerBrowserInspectCommands.mockClear(); + actionInputMocks.registerBrowserActionInputCommands.mockClear(); + actionObserveMocks.registerBrowserActionObserveCommands.mockClear(); + debugMocks.registerBrowserDebugCommands.mockClear(); + stateMocks.registerBrowserStateCommands.mockClear(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("registers browser placeholders without loading handlers for help", () => { + const program = new Command(); + program.name("openclaw"); + + registerBrowserCli(program, ["node", "openclaw", "browser", "--help"]); + + 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"); + expect(manageMocks.registerBrowserManageCommands).not.toHaveBeenCalled(); + expect(inspectMocks.registerBrowserInspectCommands).not.toHaveBeenCalled(); + expect(actionInputMocks.registerBrowserActionInputCommands).not.toHaveBeenCalled(); + }); + + it("registers only the requested browser group before dispatch", async () => { + const program = new Command(); + program.name("openclaw"); + + registerBrowserCli(program, ["node", "openclaw", "browser", "status"]); + + const browser = program.commands.find((command) => command.name() === "browser"); + expect(browser?.commands.map((command) => command.name())).toEqual(["status"]); + + await program.parseAsync(["browser", "status"], { from: "user" }); + + expect(manageMocks.registerBrowserManageCommands).toHaveBeenCalledTimes(1); + expect(inspectMocks.registerBrowserInspectCommands).not.toHaveBeenCalled(); + expect(manageMocks.statusAction).toHaveBeenCalledTimes(1); + }); + + it("can eagerly register all browser groups for compatibility", async () => { + vi.stubEnv("OPENCLAW_DISABLE_LAZY_SUBCOMMANDS", "1"); + const program = new Command(); + program.name("openclaw"); + + registerBrowserCli(program, ["node", "openclaw", "browser", "--help"]); + + await vi.waitFor(() => + expect(manageMocks.registerBrowserManageCommands).toHaveBeenCalledTimes(1), + ); + expect(inspectMocks.registerBrowserInspectCommands).toHaveBeenCalledTimes(1); + expect(actionInputMocks.registerBrowserActionInputCommands).toHaveBeenCalledTimes(1); + expect(actionObserveMocks.registerBrowserActionObserveCommands).toHaveBeenCalledTimes(1); + expect(debugMocks.registerBrowserDebugCommands).toHaveBeenCalledTimes(1); + expect(stateMocks.registerBrowserStateCommands).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/browser/src/cli/browser-cli.ts b/extensions/browser/src/cli/browser-cli.ts index 25e989fb6ff..bf23e6663ce 100644 --- a/extensions/browser/src/cli/browser-cli.ts +++ b/extensions/browser/src/cli/browser-cli.ts @@ -1,12 +1,15 @@ import type { Command } from "commander"; -import { registerBrowserActionInputCommands } from "./browser-cli-actions-input.js"; -import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js"; -import { registerBrowserDebugCommands } from "./browser-cli-debug.js"; +import { + buildCommandGroupEntries, + registerCommandGroups, + resolveCliArgvInvocation, + shouldEagerRegisterSubcommands, + type CommandGroupEntry, + type CommandGroupDescriptorSpec, + type NamedCommandDescriptor, +} from "openclaw/plugin-sdk/cli-runtime"; import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js"; -import { registerBrowserInspectCommands } from "./browser-cli-inspect.js"; -import { registerBrowserManageCommands } from "./browser-cli-manage.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js"; -import { registerBrowserStateCommands } from "./browser-cli-state.js"; import { addGatewayClientOptions, danger, @@ -17,7 +20,211 @@ import { theme, } from "./core-api.js"; -export function registerBrowserCli(program: Command) { +type BrowserCommandDescriptor = NamedCommandDescriptor; +type BrowserCommandRegistrar = (args: { + browser: Command; + parentOpts: (cmd: Command) => BrowserParentOpts; +}) => Promise | void; + +const browserCommandDescriptors: readonly BrowserCommandDescriptor[] = [ + { name: "status", description: "Show browser status", hasSubcommands: false }, + { + name: "start", + description: "Start the browser (no-op if already running)", + hasSubcommands: false, + }, + { name: "stop", description: "Stop the browser (best-effort)", hasSubcommands: false }, + { + name: "reset-profile", + description: "Reset browser profile (moves it to Trash)", + hasSubcommands: false, + }, + { name: "tabs", description: "List open tabs", hasSubcommands: false }, + { name: "tab", description: "Tab shortcuts (index-based)", hasSubcommands: true }, + { name: "open", description: "Open a URL in a new tab", hasSubcommands: false }, + { + name: "focus", + description: "Focus a tab by target id (or unique prefix)", + hasSubcommands: false, + }, + { name: "close", description: "Close a tab (target id optional)", hasSubcommands: false }, + { name: "profiles", description: "List all browser profiles", hasSubcommands: false }, + { name: "create-profile", description: "Create a new browser profile", hasSubcommands: false }, + { name: "delete-profile", description: "Delete a browser profile", hasSubcommands: false }, + { name: "screenshot", description: "Capture a screenshot (MEDIA:)", hasSubcommands: false }, + { + name: "snapshot", + description: "Capture a snapshot (default: ai; aria is the accessibility tree)", + hasSubcommands: false, + }, + { name: "navigate", description: "Navigate the current tab to a URL", hasSubcommands: false }, + { name: "resize", description: "Resize the viewport", hasSubcommands: false }, + { name: "click", description: "Click an element by ref from snapshot", hasSubcommands: false }, + { name: "click-coords", description: "Click viewport coordinates", hasSubcommands: false }, + { name: "type", description: "Type into an element by ref from snapshot", hasSubcommands: false }, + { name: "press", description: "Press a key", hasSubcommands: false }, + { name: "hover", description: "Hover an element by ai ref", hasSubcommands: false }, + { + name: "scrollintoview", + description: "Scroll an element into view by ref from snapshot", + hasSubcommands: false, + }, + { name: "drag", description: "Drag from one ref to another", hasSubcommands: false }, + { name: "select", description: "Select option(s) in a select element", hasSubcommands: false }, + { + name: "upload", + description: "Arm file upload for the next file chooser", + hasSubcommands: false, + }, + { + name: "waitfordownload", + description: "Wait for the next download (and save it)", + hasSubcommands: false, + }, + { + name: "download", + description: "Click a ref and save the resulting download", + hasSubcommands: false, + }, + { + name: "dialog", + description: "Arm the next modal dialog (alert/confirm/prompt)", + hasSubcommands: false, + }, + { name: "fill", description: "Fill a form with JSON field descriptors", hasSubcommands: false }, + { + name: "wait", + description: "Wait for time, selector, URL, load state, or JS conditions", + hasSubcommands: false, + }, + { + name: "evaluate", + description: "Evaluate a function against the page or a ref", + hasSubcommands: false, + }, + { name: "console", description: "Get recent console messages", hasSubcommands: false }, + { name: "pdf", description: "Save page as PDF", hasSubcommands: false }, + { + name: "responsebody", + description: "Wait for a network response and return its body", + hasSubcommands: false, + }, + { name: "highlight", description: "Highlight an element by ref", hasSubcommands: false }, + { name: "errors", description: "Get recent page errors", hasSubcommands: false }, + { + name: "requests", + description: "Get recent network requests (best-effort)", + hasSubcommands: false, + }, + { name: "doctor", description: "Diagnose browser readiness", hasSubcommands: false }, + { name: "trace", description: "Record a Playwright trace", hasSubcommands: true }, + { name: "cookies", description: "Read/write cookies", hasSubcommands: true }, + { name: "storage", description: "Read/write localStorage/sessionStorage", hasSubcommands: true }, + { name: "set", description: "Browser environment settings", hasSubcommands: true }, +]; + +const browserCommandGroupSpecs: readonly CommandGroupDescriptorSpec[] = [ + { + commandNames: [ + "status", + "start", + "stop", + "reset-profile", + "tabs", + "tab", + "open", + "focus", + "close", + "profiles", + "create-profile", + "delete-profile", + ], + register: async (args) => { + const module = await import("./browser-cli-manage.js"); + module.registerBrowserManageCommands(args.browser, args.parentOpts); + }, + }, + { + commandNames: ["screenshot", "snapshot"], + register: async (args) => { + const module = await import("./browser-cli-inspect.js"); + module.registerBrowserInspectCommands(args.browser, args.parentOpts); + }, + }, + { + commandNames: [ + "navigate", + "resize", + "click", + "click-coords", + "type", + "press", + "hover", + "scrollintoview", + "drag", + "select", + "upload", + "waitfordownload", + "download", + "dialog", + "fill", + "wait", + "evaluate", + ], + register: async (args) => { + const module = await import("./browser-cli-actions-input.js"); + module.registerBrowserActionInputCommands(args.browser, args.parentOpts); + }, + }, + { + commandNames: ["console", "pdf", "responsebody"], + register: async (args) => { + const module = await import("./browser-cli-actions-observe.js"); + module.registerBrowserActionObserveCommands(args.browser, args.parentOpts); + }, + }, + { + commandNames: ["highlight", "errors", "requests", "doctor", "trace"], + register: async (args) => { + const module = await import("./browser-cli-debug.js"); + module.registerBrowserDebugCommands(args.browser, args.parentOpts); + }, + }, + { + commandNames: ["cookies", "storage", "set"], + register: async (args) => { + const module = await import("./browser-cli-state.js"); + module.registerBrowserStateCommands(args.browser, args.parentOpts); + }, + }, +]; + +function buildBrowserCommandGroups(params: { + browser: Command; + parentOpts: (cmd: Command) => BrowserParentOpts; +}): CommandGroupEntry[] { + return buildCommandGroupEntries( + browserCommandDescriptors, + browserCommandGroupSpecs, + (register) => async () => await register(params), + ); +} + +function registerLazyBrowserCommands( + browser: Command, + parentOpts: (cmd: Command) => BrowserParentOpts, + argv: string[], +) { + const { primary, commandPath } = resolveCliArgvInvocation(argv); + const subcommand = primary === "browser" ? (commandPath[1] ?? null) : null; + registerCommandGroups(browser, buildBrowserCommandGroups({ browser, parentOpts }), { + eager: shouldEagerRegisterSubcommands(), + primary: subcommand, + registerPrimaryOnly: subcommand !== null, + }); +} + +export function registerBrowserCli(program: Command, argv: string[] = process.argv) { const browser = program .command("browser") .description("Manage OpenClaw's dedicated browser (Chrome/Chromium)") @@ -46,10 +253,5 @@ export function registerBrowserCli(program: Command) { const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; - registerBrowserManageCommands(browser, parentOpts); - registerBrowserInspectCommands(browser, parentOpts); - registerBrowserActionInputCommands(browser, parentOpts); - registerBrowserActionObserveCommands(browser, parentOpts); - registerBrowserDebugCommands(browser, parentOpts); - registerBrowserStateCommands(browser, parentOpts); + registerLazyBrowserCommands(browser, parentOpts, argv); } diff --git a/package.json b/package.json index ea2b866912b..19115b08d3f 100644 --- a/package.json +++ b/package.json @@ -1130,14 +1130,14 @@ "types": "./dist/plugin-sdk/provider-usage.d.ts", "default": "./dist/plugin-sdk/provider-usage.js" }, - "./plugin-sdk/web-content-extractor": { - "types": "./dist/plugin-sdk/web-content-extractor.d.ts", - "default": "./dist/plugin-sdk/web-content-extractor.js" - }, "./plugin-sdk/document-extractor": { "types": "./dist/plugin-sdk/document-extractor.d.ts", "default": "./dist/plugin-sdk/document-extractor.js" }, + "./plugin-sdk/web-content-extractor": { + "types": "./dist/plugin-sdk/web-content-extractor.d.ts", + "default": "./dist/plugin-sdk/web-content-extractor.js" + }, "./plugin-sdk/provider-web-fetch-contract": { "types": "./dist/plugin-sdk/provider-web-fetch-contract.d.ts", "default": "./dist/plugin-sdk/provider-web-fetch-contract.js" diff --git a/src/plugin-sdk/cli-runtime.ts b/src/plugin-sdk/cli-runtime.ts index 7c01f9bf614..a47ea39507f 100644 --- a/src/plugin-sdk/cli-runtime.ts +++ b/src/plugin-sdk/cli-runtime.ts @@ -1,7 +1,31 @@ // Public CLI/output helpers for plugins that share terminal-facing command behavior. export * from "../cli/command-format.js"; +export { + buildCommandGroupEntries, + defineImportedCommandGroupSpec, + defineImportedCommandGroupSpecs, + defineImportedProgramCommandGroupSpec, + defineImportedProgramCommandGroupSpecs, + resolveCommandGroupEntries, + type CommandGroupDescriptorSpec, + type ImportedCommandGroupDefinition, + type ImportedProgramCommandGroupDefinition, + type NamedCommandDescriptor, +} from "../cli/program/command-group-descriptors.js"; +export { + findCommandGroupEntry, + getCommandGroupNames, + registerCommandGroupByName, + registerCommandGroups, + registerLazyCommandGroup, + removeCommandGroupNames, + type CommandGroupEntry, + type CommandGroupPlaceholder, +} from "../cli/program/register-command-groups.js"; export * from "../cli/parse-duration.js"; +export { resolveCliArgvInvocation, type CliArgvInvocation } from "../cli/argv-invocation.js"; +export { shouldEagerRegisterSubcommands } from "../cli/command-registration-policy.js"; export * from "../cli/wait.js"; export { stylePromptTitle } from "../terminal/prompt-style.js"; export * from "../version.js";