refactor: switch browser ownership to bundled plugin

This commit is contained in:
Peter Steinberger
2026-03-26 22:18:41 +00:00
parent 197510f693
commit 8eeb7f0829
255 changed files with 16981 additions and 21074 deletions

View File

@@ -1 +1 @@
export { registerBrowserActionInputCommands } from "./browser-cli-actions-input/register.js";
export * from "../../extensions/browser/src/cli/browser-cli-actions-input.js";

View File

@@ -1,195 +1 @@
import type { Command } from "commander";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import {
callBrowserAct,
logBrowserActionResult,
requireRef,
resolveBrowserActionContext,
} from "./shared.js";
export function registerBrowserElementCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
const runElementAction = async (params: {
cmd: Command;
body: Record<string, unknown>;
successMessage: string | ((result: unknown) => string);
timeoutMs?: number;
}): Promise<void> => {
const { parent, profile } = resolveBrowserActionContext(params.cmd, parentOpts);
try {
const result = await callBrowserAct({
parent,
profile,
body: params.body,
timeoutMs: params.timeoutMs,
});
const successMessage =
typeof params.successMessage === "function"
? params.successMessage(result)
: params.successMessage;
logBrowserActionResult(parent, result, successMessage);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
};
browser
.command("click")
.description("Click an element by ref from snapshot")
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--double", "Double click", false)
.option("--button <left|right|middle>", "Mouse button to use")
.option("--modifiers <list>", "Comma-separated modifiers (Shift,Alt,Meta)")
.action(async (ref: string | undefined, opts, cmd) => {
const refValue = requireRef(ref);
if (!refValue) {
return;
}
const modifiers = opts.modifiers
? String(opts.modifiers)
.split(",")
.map((v: string) => v.trim())
.filter(Boolean)
: undefined;
await runElementAction({
cmd,
body: {
kind: "click",
ref: refValue,
targetId: opts.targetId?.trim() || undefined,
doubleClick: Boolean(opts.double),
button: opts.button?.trim() || undefined,
modifiers,
},
successMessage: (result) => {
const url = (result as { url?: unknown }).url;
const suffix = typeof url === "string" && url ? ` on ${url}` : "";
return `clicked ref ${refValue}${suffix}`;
},
});
});
browser
.command("type")
.description("Type into an element by ref from snapshot")
.argument("<ref>", "Ref id from snapshot")
.argument("<text>", "Text to type")
.option("--submit", "Press Enter after typing", false)
.option("--slowly", "Type slowly (human-like)", false)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (ref: string | undefined, text: string, opts, cmd) => {
const refValue = requireRef(ref);
if (!refValue) {
return;
}
await runElementAction({
cmd,
body: {
kind: "type",
ref: refValue,
text,
submit: Boolean(opts.submit),
slowly: Boolean(opts.slowly),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `typed into ref ${refValue}`,
});
});
browser
.command("press")
.description("Press a key")
.argument("<key>", "Key to press (e.g. Enter)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (key: string, opts, cmd) => {
await runElementAction({
cmd,
body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
successMessage: `pressed ${key}`,
});
});
browser
.command("hover")
.description("Hover an element by ai ref")
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (ref: string, opts, cmd) => {
await runElementAction({
cmd,
body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
successMessage: `hovered ref ${ref}`,
});
});
browser
.command("scrollintoview")
.description("Scroll an element into view by ref from snapshot")
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--timeout-ms <ms>", "How long to wait for scroll (default: 20000)", (v: string) =>
Number(v),
)
.action(async (ref: string | undefined, opts, cmd) => {
const refValue = requireRef(ref);
if (!refValue) {
return;
}
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
await runElementAction({
cmd,
body: {
kind: "scrollIntoView",
ref: refValue,
targetId: opts.targetId?.trim() || undefined,
timeoutMs,
},
timeoutMs,
successMessage: `scrolled into view: ${refValue}`,
});
});
browser
.command("drag")
.description("Drag from one ref to another")
.argument("<startRef>", "Start ref id")
.argument("<endRef>", "End ref id")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (startRef: string, endRef: string, opts, cmd) => {
await runElementAction({
cmd,
body: {
kind: "drag",
startRef,
endRef,
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `dragged ${startRef}${endRef}`,
});
});
browser
.command("select")
.description("Select option(s) in a select element")
.argument("<ref>", "Ref id from snapshot")
.argument("<values...>", "Option values to select")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (ref: string, values: string[], opts, cmd) => {
await runElementAction({
cmd,
body: {
kind: "select",
ref,
values,
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `selected ${values.join(", ")}`,
});
});
}
export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.element.js";

View File

@@ -1,201 +1 @@
import type { Command } from "commander";
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "../../browser/paths.js";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { shortenHomePath } from "../../utils.js";
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import { resolveBrowserActionContext } from "./shared.js";
async function normalizeUploadPaths(paths: string[]): Promise<string[]> {
const result = await resolveExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!result.ok) {
throw new Error(result.error);
}
return result.paths;
}
async function runBrowserPostAction<T>(params: {
parent: BrowserParentOpts;
profile: string | undefined;
path: string;
body: Record<string, unknown>;
timeoutMs: number;
describeSuccess: (result: T) => string;
}): Promise<void> {
try {
const result = await callBrowserRequest<T>(
params.parent,
{
method: "POST",
path: params.path,
query: params.profile ? { profile: params.profile } : undefined,
body: params.body,
},
{ timeoutMs: params.timeoutMs },
);
if (params.parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(params.describeSuccess(result));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}
export function registerBrowserFilesAndDownloadsCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
const resolveTimeoutAndTarget = (opts: { timeoutMs?: unknown; targetId?: unknown }) => {
const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : undefined;
const targetId =
typeof opts.targetId === "string" ? opts.targetId.trim() || undefined : undefined;
return { timeoutMs, targetId };
};
const runDownloadCommand = async (
cmd: Command,
opts: { timeoutMs?: unknown; targetId?: unknown },
request: { path: string; body: Record<string, unknown> },
) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const { timeoutMs, targetId } = resolveTimeoutAndTarget(opts);
await runBrowserPostAction<{ download: { path: string } }>({
parent,
profile,
path: request.path,
body: {
...request.body,
targetId,
timeoutMs,
},
timeoutMs: timeoutMs ?? 20000,
describeSuccess: (result) => `downloaded: ${shortenHomePath(result.download.path)}`,
});
};
browser
.command("upload")
.description("Arm file upload for the next file chooser")
.argument(
"<paths...>",
"File paths to upload (must be within OpenClaw temp uploads dir, e.g. /tmp/openclaw/uploads/file.pdf)",
)
.option("--ref <ref>", "Ref id from snapshot to click after arming")
.option("--input-ref <ref>", "Ref id for <input type=file> to set directly")
.option("--element <selector>", "CSS selector for <input type=file>")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option(
"--timeout-ms <ms>",
"How long to wait for the next file chooser (default: 120000)",
(v: string) => Number(v),
)
.action(async (paths: string[], opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const normalizedPaths = await normalizeUploadPaths(paths);
const { timeoutMs, targetId } = resolveTimeoutAndTarget(opts);
await runBrowserPostAction({
parent,
profile,
path: "/hooks/file-chooser",
body: {
paths: normalizedPaths,
ref: opts.ref?.trim() || undefined,
inputRef: opts.inputRef?.trim() || undefined,
element: opts.element?.trim() || undefined,
targetId,
timeoutMs,
},
timeoutMs: timeoutMs ?? 20000,
describeSuccess: () => `upload armed for ${paths.length} file(s)`,
});
});
browser
.command("waitfordownload")
.description("Wait for the next download (and save it)")
.argument(
"[path]",
"Save path within openclaw temp downloads dir (default: /tmp/openclaw/downloads/...; fallback: os.tmpdir()/openclaw/downloads/...)",
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option(
"--timeout-ms <ms>",
"How long to wait for the next download (default: 120000)",
(v: string) => Number(v),
)
.action(async (outPath: string | undefined, opts, cmd) => {
await runDownloadCommand(cmd, opts, {
path: "/wait/download",
body: {
path: outPath?.trim() || undefined,
},
});
});
browser
.command("download")
.description("Click a ref and save the resulting download")
.argument("<ref>", "Ref id from snapshot to click")
.argument(
"<path>",
"Save path within openclaw temp downloads dir (e.g. report.pdf or /tmp/openclaw/downloads/report.pdf)",
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option(
"--timeout-ms <ms>",
"How long to wait for the download to start (default: 120000)",
(v: string) => Number(v),
)
.action(async (ref: string, outPath: string, opts, cmd) => {
await runDownloadCommand(cmd, opts, {
path: "/download",
body: {
ref,
path: outPath,
},
});
});
browser
.command("dialog")
.description("Arm the next modal dialog (alert/confirm/prompt)")
.option("--accept", "Accept the dialog", false)
.option("--dismiss", "Dismiss the dialog", false)
.option("--prompt <text>", "Prompt response text")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option(
"--timeout-ms <ms>",
"How long to wait for the next dialog (default: 120000)",
(v: string) => Number(v),
)
.action(async (opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
if (accept === undefined) {
defaultRuntime.error(danger("Specify --accept or --dismiss"));
defaultRuntime.exit(1);
return;
}
const { timeoutMs, targetId } = resolveTimeoutAndTarget(opts);
await runBrowserPostAction({
parent,
profile,
path: "/hooks/dialog",
body: {
accept,
promptText: opts.prompt?.trim() || undefined,
targetId,
timeoutMs,
},
timeoutMs: timeoutMs ?? 20000,
describeSuccess: () => "dialog armed",
});
});
}
export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.files-downloads.js";

View File

@@ -1,128 +1 @@
import type { Command } from "commander";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import {
callBrowserAct,
logBrowserActionResult,
readFields,
resolveBrowserActionContext,
} from "./shared.js";
export function registerBrowserFormWaitEvalCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
browser
.command("fill")
.description("Fill a form with JSON field descriptors")
.option("--fields <json>", "JSON array of field objects")
.option("--fields-file <path>", "Read JSON array from a file")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const fields = await readFields({
fields: opts.fields,
fieldsFile: opts.fieldsFile,
});
const result = await callBrowserAct<{ result?: unknown }>({
parent,
profile,
body: {
kind: "fill",
fields,
targetId: opts.targetId?.trim() || undefined,
},
});
logBrowserActionResult(parent, result, `filled ${fields.length} field(s)`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("wait")
.description("Wait for time, selector, URL, load state, or JS conditions")
.argument("[selector]", "CSS selector to wait for (visible)")
.option("--time <ms>", "Wait for N milliseconds", (v: string) => Number(v))
.option("--text <value>", "Wait for text to appear")
.option("--text-gone <value>", "Wait for text to disappear")
.option("--url <pattern>", "Wait for URL (supports globs like **/dash)")
.option("--load <load|domcontentloaded|networkidle>", "Wait for load state")
.option("--fn <js>", "Wait for JS condition (passed to waitForFunction)")
.option(
"--timeout-ms <ms>",
"How long to wait for each condition (default: 20000)",
(v: string) => Number(v),
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (selector: string | undefined, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const sel = selector?.trim() || undefined;
const load =
opts.load === "load" || opts.load === "domcontentloaded" || opts.load === "networkidle"
? (opts.load as "load" | "domcontentloaded" | "networkidle")
: undefined;
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
const result = await callBrowserAct<{ result?: unknown }>({
parent,
profile,
body: {
kind: "wait",
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
text: opts.text?.trim() || undefined,
textGone: opts.textGone?.trim() || undefined,
selector: sel,
url: opts.url?.trim() || undefined,
loadState: load,
fn: opts.fn?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
timeoutMs,
},
timeoutMs,
});
logBrowserActionResult(parent, result, "wait complete");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("evaluate")
.description("Evaluate a function against the page or a ref")
.option("--fn <code>", "Function source, e.g. (el) => el.textContent")
.option("--ref <id>", "Ref from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
if (!opts.fn) {
defaultRuntime.error(danger("Missing --fn"));
defaultRuntime.exit(1);
return;
}
try {
const result = await callBrowserAct<{ result?: unknown }>({
parent,
profile,
body: {
kind: "evaluate",
fn: opts.fn,
ref: opts.ref?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
},
});
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.writeJson(result.result ?? null);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}
export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.js";

View File

@@ -1,70 +1 @@
import type { Command } from "commander";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { runBrowserResizeWithOutput } from "../browser-cli-resize.js";
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import { requireRef, resolveBrowserActionContext } from "./shared.js";
export function registerBrowserNavigationCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
browser
.command("navigate")
.description("Navigate the current tab to a URL")
.argument("<url>", "URL to navigate to")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (url: string, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const result = await callBrowserRequest<{ url?: string }>(
parent,
{
method: "POST",
path: "/navigate",
query: profile ? { profile } : undefined,
body: {
url,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(`navigated to ${result.url ?? url}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("resize")
.description("Resize the viewport")
.argument("<width>", "Viewport width", (v: string) => Number(v))
.argument("<height>", "Viewport height", (v: string) => Number(v))
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (width: number, height: number, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
await runBrowserResizeWithOutput({
parent,
profile,
width,
height,
targetId: opts.targetId,
timeoutMs: 20000,
successMessage: `resized to ${width}x${height}`,
});
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
// Keep `requireRef` reachable; shared utilities are intended for other modules too.
void requireRef;
}
export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.navigation.js";

View File

@@ -1,16 +1 @@
import type { Command } from "commander";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { registerBrowserElementCommands } from "./register.element.js";
import { registerBrowserFilesAndDownloadsCommands } from "./register.files-downloads.js";
import { registerBrowserFormWaitEvalCommands } from "./register.form-wait-eval.js";
import { registerBrowserNavigationCommands } from "./register.navigation.js";
export function registerBrowserActionInputCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
registerBrowserNavigationCommands(browser, parentOpts);
registerBrowserElementCommands(browser, parentOpts);
registerBrowserFilesAndDownloadsCommands(browser, parentOpts);
registerBrowserFormWaitEvalCommands(browser, parentOpts);
}
export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/register.js";

View File

@@ -1,100 +1 @@
import type { Command } from "commander";
import type { BrowserFormField } from "../../browser/client-actions-core.js";
import {
normalizeBrowserFormField,
normalizeBrowserFormFieldValue,
} from "../../browser/form-fields.js";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
export type BrowserActionContext = {
parent: BrowserParentOpts;
profile: string | undefined;
};
export function resolveBrowserActionContext(
cmd: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
): BrowserActionContext {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
return { parent, profile };
}
export async function callBrowserAct<T = unknown>(params: {
parent: BrowserParentOpts;
profile?: string;
body: Record<string, unknown>;
timeoutMs?: number;
}): Promise<T> {
return await callBrowserRequest<T>(
params.parent,
{
method: "POST",
path: "/act",
query: params.profile ? { profile: params.profile } : undefined,
body: params.body,
},
{ timeoutMs: params.timeoutMs ?? 20000 },
);
}
export function logBrowserActionResult(
parent: BrowserParentOpts,
result: unknown,
successMessage: string,
) {
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(successMessage);
}
export function requireRef(ref: string | undefined) {
const refValue = typeof ref === "string" ? ref.trim() : "";
if (!refValue) {
defaultRuntime.error(danger("ref is required"));
defaultRuntime.exit(1);
return null;
}
return refValue;
}
async function readFile(path: string): Promise<string> {
const fs = await import("node:fs/promises");
return await fs.readFile(path, "utf8");
}
export async function readFields(opts: {
fields?: string;
fieldsFile?: string;
}): Promise<BrowserFormField[]> {
const payload = opts.fieldsFile ? await readFile(opts.fieldsFile) : (opts.fields ?? "");
if (!payload.trim()) {
throw new Error("fields are required");
}
const parsed = JSON.parse(payload) as unknown;
if (!Array.isArray(parsed)) {
throw new Error("fields must be an array");
}
return parsed.map((entry, index) => {
if (!entry || typeof entry !== "object") {
throw new Error(`fields[${index}] must be an object`);
}
const rec = entry as Record<string, unknown>;
const parsedField = normalizeBrowserFormField(rec);
if (!parsedField) {
throw new Error(`fields[${index}] must include ref`);
}
if (
rec.value === undefined ||
rec.value === null ||
normalizeBrowserFormFieldValue(rec.value) !== undefined
) {
return parsedField;
}
throw new Error(`fields[${index}].value must be string, number, boolean, or null`);
});
}
export * from "../../../extensions/browser/src/cli/browser-cli-actions-input/shared.js";

View File

@@ -1,116 +1 @@
import type { Command } from "commander";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
function runBrowserObserve(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
});
}
export function registerBrowserActionObserveCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
browser
.command("console")
.description("Get recent console messages")
.option("--level <level>", "Filter by level (error, warn, info)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserObserve(async () => {
const result = await callBrowserRequest<{ messages: unknown[] }>(
parent,
{
method: "GET",
path: "/console",
query: {
level: opts.level?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
profile,
},
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.writeJson(result.messages);
});
});
browser
.command("pdf")
.description("Save page as PDF")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserObserve(async () => {
const result = await callBrowserRequest<{ path: string }>(
parent,
{
method: "POST",
path: "/pdf",
query: profile ? { profile } : undefined,
body: { targetId: opts.targetId?.trim() || undefined },
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(`PDF: ${shortenHomePath(result.path)}`);
});
});
browser
.command("responsebody")
.description("Wait for a network response and return its body")
.argument("<url>", "URL (exact, substring, or glob like **/api)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option(
"--timeout-ms <ms>",
"How long to wait for the response (default: 20000)",
(v: string) => Number(v),
)
.option("--max-chars <n>", "Max body chars to return (default: 200000)", (v: string) =>
Number(v),
)
.action(async (url: string, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserObserve(async () => {
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
const maxChars = Number.isFinite(opts.maxChars) ? opts.maxChars : undefined;
const result = await callBrowserRequest<{ response: { body: string } }>(
parent,
{
method: "POST",
path: "/response/body",
query: profile ? { profile } : undefined,
body: {
url,
targetId: opts.targetId?.trim() || undefined,
timeoutMs,
maxChars,
},
},
{ timeoutMs: timeoutMs ?? 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(result.response.body);
});
});
}
export * from "../../extensions/browser/src/cli/browser-cli-actions-observe.js";

View File

@@ -1,232 +1 @@
import type { Command } from "commander";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
const BROWSER_DEBUG_TIMEOUT_MS = 20000;
type BrowserRequestParams = Parameters<typeof callBrowserRequest>[1];
type DebugContext = {
parent: BrowserParentOpts;
profile?: string;
};
function runBrowserDebug(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
});
}
async function withDebugContext(
cmd: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
action: (context: DebugContext) => Promise<void>,
) {
const parent = parentOpts(cmd);
await runBrowserDebug(() =>
action({
parent,
profile: parent.browserProfile,
}),
);
}
function printJsonResult(parent: BrowserParentOpts, result: unknown): boolean {
if (!parent.json) {
return false;
}
defaultRuntime.writeJson(result);
return true;
}
async function callDebugRequest<T>(
parent: BrowserParentOpts,
params: BrowserRequestParams,
): Promise<T> {
return callBrowserRequest<T>(parent, params, { timeoutMs: BROWSER_DEBUG_TIMEOUT_MS });
}
function resolveProfileQuery(profile?: string) {
return profile ? { profile } : undefined;
}
function resolveDebugQuery(params: {
targetId?: unknown;
clear?: unknown;
profile?: string;
filter?: unknown;
}) {
return {
targetId: typeof params.targetId === "string" ? params.targetId.trim() || undefined : undefined,
filter: typeof params.filter === "string" ? params.filter.trim() || undefined : undefined,
clear: Boolean(params.clear),
profile: params.profile,
};
}
export function registerBrowserDebugCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
browser
.command("highlight")
.description("Highlight an element by ref")
.argument("<ref>", "Ref id from snapshot")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (ref: string, opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest(parent, {
method: "POST",
path: "/highlight",
query: resolveProfileQuery(profile),
body: {
ref: ref.trim(),
targetId: opts.targetId?.trim() || undefined,
},
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`highlighted ${ref.trim()}`);
});
});
browser
.command("errors")
.description("Get recent page errors")
.option("--clear", "Clear stored errors after reading", false)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest<{
errors: Array<{ timestamp: string; name?: string; message: string }>;
}>(parent, {
method: "GET",
path: "/errors",
query: resolveDebugQuery({
targetId: opts.targetId,
clear: opts.clear,
profile,
}),
});
if (printJsonResult(parent, result)) {
return;
}
if (!result.errors.length) {
defaultRuntime.log("No page errors.");
return;
}
defaultRuntime.log(
result.errors
.map((e) => `${e.timestamp} ${e.name ? `${e.name}: ` : ""}${e.message}`)
.join("\n"),
);
});
});
browser
.command("requests")
.description("Get recent network requests (best-effort)")
.option("--filter <text>", "Only show URLs that contain this substring")
.option("--clear", "Clear stored requests after reading", false)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest<{
requests: Array<{
timestamp: string;
method: string;
status?: number;
ok?: boolean;
url: string;
failureText?: string;
}>;
}>(parent, {
method: "GET",
path: "/requests",
query: resolveDebugQuery({
targetId: opts.targetId,
filter: opts.filter,
clear: opts.clear,
profile,
}),
});
if (printJsonResult(parent, result)) {
return;
}
if (!result.requests.length) {
defaultRuntime.log("No requests recorded.");
return;
}
defaultRuntime.log(
result.requests
.map((r) => {
const status = typeof r.status === "number" ? ` ${r.status}` : "";
const ok = r.ok === true ? " ok" : r.ok === false ? " fail" : "";
const fail = r.failureText ? ` (${r.failureText})` : "";
return `${r.timestamp} ${r.method}${status}${ok} ${r.url}${fail}`;
})
.join("\n"),
);
});
});
const trace = browser.command("trace").description("Record a Playwright trace");
trace
.command("start")
.description("Start trace recording")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--no-screenshots", "Disable screenshots")
.option("--no-snapshots", "Disable snapshots")
.option("--sources", "Include sources (bigger traces)", false)
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest(parent, {
method: "POST",
path: "/trace/start",
query: resolveProfileQuery(profile),
body: {
targetId: opts.targetId?.trim() || undefined,
screenshots: Boolean(opts.screenshots),
snapshots: Boolean(opts.snapshots),
sources: Boolean(opts.sources),
},
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log("trace started");
});
});
trace
.command("stop")
.description("Stop trace recording and write a .zip")
.option(
"--out <path>",
"Output path within openclaw temp dir (e.g. trace.zip or /tmp/openclaw/trace.zip)",
)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
await withDebugContext(cmd, parentOpts, async ({ parent, profile }) => {
const result = await callDebugRequest<{ path: string }>(parent, {
method: "POST",
path: "/trace/stop",
query: resolveProfileQuery(profile),
body: {
targetId: opts.targetId?.trim() || undefined,
path: opts.out?.trim() || undefined,
},
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`TRACE:${shortenHomePath(result.path)}`);
});
});
}
export * from "../../extensions/browser/src/cli/browser-cli-debug.js";

View File

@@ -1,34 +1 @@
export const browserCoreExamples = [
"openclaw browser status",
"openclaw browser start",
"openclaw browser stop",
"openclaw browser tabs",
"openclaw browser open https://example.com",
"openclaw browser focus abcd1234",
"openclaw browser close abcd1234",
"openclaw browser screenshot",
"openclaw browser screenshot --full-page",
"openclaw browser screenshot --ref 12",
"openclaw browser snapshot",
"openclaw browser snapshot --format aria --limit 200",
"openclaw browser snapshot --efficient",
"openclaw browser snapshot --labels",
];
export const browserActionExamples = [
"openclaw browser navigate https://example.com",
"openclaw browser resize 1280 720",
"openclaw browser click 12 --double",
'openclaw browser type 23 "hello" --submit',
"openclaw browser press Enter",
"openclaw browser hover 44",
"openclaw browser drag 10 11",
"openclaw browser select 9 OptionA OptionB",
"openclaw browser upload /tmp/openclaw/uploads/file.pdf",
'openclaw browser fill --fields \'[{"ref":"1","value":"Ada"}]\'',
"openclaw browser dialog --accept",
'openclaw browser wait --text "Done"',
"openclaw browser evaluate --fn '(el) => el.textContent' --ref 7",
"openclaw browser console --level error",
"openclaw browser pdf",
];
export * from "../../extensions/browser/src/cli/browser-cli-examples.js";

View File

@@ -46,15 +46,17 @@ const sharedMocks = vi.hoisted(() => ({
},
),
}));
vi.mock("./browser-cli-shared.js", () => ({
vi.mock("../../extensions/browser/src/cli/browser-cli-shared.js", () => ({
callBrowserRequest: sharedMocks.callBrowserRequest,
}));
vi.mock("../runtime.js", () => ({
vi.mock("../../extensions/browser/src/core-api.js", async () => ({
...(await vi.importActual<object>("../../extensions/browser/src/core-api.js")),
defaultRuntime: runtime,
loadConfig: configMocks.loadConfig,
}));
let registerBrowserInspectCommands: typeof import("./browser-cli-inspect.js").registerBrowserInspectCommands;
let registerBrowserInspectCommands: typeof import("../../extensions/browser/src/cli/browser-cli-inspect.js").registerBrowserInspectCommands;
type SnapshotDefaultsCase = {
label: string;
@@ -78,7 +80,8 @@ describe("browser cli snapshot defaults", () => {
const runSnapshot = async (args: string[]) => await runBrowserInspect(["snapshot", ...args]);
beforeAll(async () => {
({ registerBrowserInspectCommands } = await import("./browser-cli-inspect.js"));
({ registerBrowserInspectCommands } =
await import("../../extensions/browser/src/cli/browser-cli-inspect.js"));
});
afterEach(() => {

View File

@@ -1,154 +1 @@
import type { Command } from "commander";
import type { SnapshotResult } from "../browser/client.js";
import { loadConfig } from "../config/config.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
export function registerBrowserInspectCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
browser
.command("screenshot")
.description("Capture a screenshot (MEDIA:<path>)")
.argument("[targetId]", "CDP target id (or unique prefix)")
.option("--full-page", "Capture full scrollable page", false)
.option("--ref <ref>", "ARIA ref from ai snapshot")
.option("--element <selector>", "CSS selector for element screenshot")
.option("--type <png|jpeg>", "Output type (default: png)", "png")
.action(async (targetId: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
try {
const result = await callBrowserRequest<{ path: string }>(
parent,
{
method: "POST",
path: "/screenshot",
query: profile ? { profile } : undefined,
body: {
targetId: targetId?.trim() || undefined,
fullPage: Boolean(opts.fullPage),
ref: opts.ref?.trim() || undefined,
element: opts.element?.trim() || undefined,
type: opts.type === "jpeg" ? "jpeg" : "png",
},
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(`MEDIA:${shortenHomePath(result.path)}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("snapshot")
.description("Capture a snapshot (default: ai; aria is the accessibility tree)")
.option("--format <aria|ai>", "Snapshot format (default: ai)", "ai")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option("--limit <n>", "Max nodes (default: 500/800)", (v: string) => Number(v))
.option("--mode <efficient>", "Snapshot preset (efficient)")
.option("--efficient", "Use the efficient snapshot preset", false)
.option("--interactive", "Role snapshot: interactive elements only", false)
.option("--compact", "Role snapshot: compact output", false)
.option("--depth <n>", "Role snapshot: max depth", (v: string) => Number(v))
.option("--selector <sel>", "Role snapshot: scope to CSS selector")
.option("--frame <sel>", "Role snapshot: scope to an iframe selector")
.option("--labels", "Include viewport label overlay screenshot", false)
.option("--out <path>", "Write snapshot to a file")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const format = opts.format === "aria" ? "aria" : "ai";
const configMode =
format === "ai" && loadConfig().browser?.snapshotDefaults?.mode === "efficient"
? "efficient"
: undefined;
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
try {
const query: Record<string, string | number | boolean | undefined> = {
format,
targetId: opts.targetId?.trim() || undefined,
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
interactive: opts.interactive ? true : undefined,
compact: opts.compact ? true : undefined,
depth: Number.isFinite(opts.depth) ? opts.depth : undefined,
selector: opts.selector?.trim() || undefined,
frame: opts.frame?.trim() || undefined,
labels: opts.labels ? true : undefined,
mode,
profile,
};
const result = await callBrowserRequest<SnapshotResult>(
parent,
{
method: "GET",
path: "/snapshot",
query,
},
{ timeoutMs: 20000 },
);
if (opts.out) {
const fs = await import("node:fs/promises");
if (result.format === "ai") {
await fs.writeFile(opts.out, result.snapshot, "utf8");
} else {
const payload = JSON.stringify(result, null, 2);
await fs.writeFile(opts.out, payload, "utf8");
}
if (parent?.json) {
defaultRuntime.writeJson({
ok: true,
out: opts.out,
...(result.format === "ai" && result.imagePath
? { imagePath: result.imagePath }
: {}),
});
} else {
defaultRuntime.log(shortenHomePath(opts.out));
if (result.format === "ai" && result.imagePath) {
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
}
}
return;
}
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
if (result.format === "ai") {
defaultRuntime.log(result.snapshot);
if (result.imagePath) {
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
}
return;
}
const nodes = "nodes" in result ? result.nodes : [];
defaultRuntime.log(
nodes
.map((n) => {
const indent = " ".repeat(Math.min(20, n.depth));
const name = n.name ? ` "${n.name}"` : "";
const value = n.value ? ` = "${n.value}"` : "";
return `${indent}- ${n.role}${name}${value}`;
})
.join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}
export * from "../../extensions/browser/src/cli/browser-cli-inspect.js";

View File

@@ -1,5 +1,5 @@
import { vi } from "vitest";
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
import { registerBrowserManageCommands } from "../../extensions/browser/src/cli/browser-cli-manage.js";
import { createBrowserProgram } from "./browser-cli-test-helpers.js";
type BrowserRequest = { path?: string };
@@ -31,20 +31,16 @@ const browserManageMocks = vi.hoisted(() => ({
),
}));
vi.mock("./browser-cli-shared.js", () => ({
vi.mock("../../extensions/browser/src/cli/browser-cli-shared.js", () => ({
callBrowserRequest: browserManageMocks.callBrowserRequest,
}));
vi.mock("./cli-utils.js", async () => ({
vi.mock("../../extensions/browser/src/core-api.js", async () => ({
...(await vi.importActual<object>("../../extensions/browser/src/core-api.js")),
...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule()),
...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()),
}));
vi.mock(
"../runtime.js",
async () =>
await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(),
);
export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }) {
const { program, browser, parentOpts } = createBrowserProgram();
if (params?.withParentTimeout) {

View File

@@ -1,533 +1 @@
import type { Command } from "commander";
import { redactCdpUrl } from "../browser/cdp.helpers.js";
import type {
BrowserTransport,
BrowserCreateProfileResult,
BrowserDeleteProfileResult,
BrowserResetProfileResult,
BrowserStatus,
BrowserTab,
ProfileStatus,
} from "../browser/client.js";
import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
const BROWSER_MANAGE_REQUEST_TIMEOUT_MS = 45_000;
function resolveProfileQuery(profile?: string) {
return profile ? { profile } : undefined;
}
function printJsonResult(parent: BrowserParentOpts, payload: unknown): boolean {
if (!parent?.json) {
return false;
}
defaultRuntime.writeJson(payload);
return true;
}
async function callTabAction(
parent: BrowserParentOpts,
profile: string | undefined,
body: { action: "new" | "select" | "close"; index?: number },
) {
return callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/action",
query: resolveProfileQuery(profile),
body,
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
}
async function fetchBrowserStatus(
parent: BrowserParentOpts,
profile?: string,
): Promise<BrowserStatus> {
return await callBrowserRequest<BrowserStatus>(
parent,
{
method: "GET",
path: "/",
query: resolveProfileQuery(profile),
},
{
timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS,
},
);
}
async function runBrowserToggle(
parent: BrowserParentOpts,
params: { profile?: string; path: string },
) {
await callBrowserRequest(parent, {
method: "POST",
path: params.path,
query: resolveProfileQuery(params.profile),
});
const status = await fetchBrowserStatus(parent, params.profile);
if (printJsonResult(parent, status)) {
return;
}
const name = status.profile ?? "openclaw";
defaultRuntime.log(info(`🦞 browser [${name}] running: ${status.running}`));
}
function runBrowserCommand(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
});
}
function logBrowserTabs(tabs: BrowserTab[], json?: boolean) {
if (json) {
defaultRuntime.writeJson({ tabs });
return;
}
if (tabs.length === 0) {
defaultRuntime.log("No tabs (browser closed or no targets).");
return;
}
defaultRuntime.log(
tabs
.map((t, i) => `${i + 1}. ${t.title || "(untitled)"}\n ${t.url}\n id: ${t.targetId}`)
.join("\n"),
);
}
function usesChromeMcpTransport(params: {
transport?: BrowserTransport;
driver?: "openclaw" | "existing-session";
}): boolean {
return params.transport === "chrome-mcp" || params.driver === "existing-session";
}
function formatBrowserConnectionSummary(params: {
transport?: BrowserTransport;
driver?: "openclaw" | "existing-session";
isRemote?: boolean;
cdpPort?: number | null;
cdpUrl?: string | null;
userDataDir?: string | null;
}): string {
if (usesChromeMcpTransport(params)) {
const userDataDir = params.userDataDir ? shortenHomePath(params.userDataDir) : null;
return userDataDir
? `transport: chrome-mcp, userDataDir: ${userDataDir}`
: "transport: chrome-mcp";
}
if (params.isRemote) {
return `cdpUrl: ${params.cdpUrl ?? "(unset)"}`;
}
return `port: ${params.cdpPort ?? "(unset)"}`;
}
export function registerBrowserManageCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
browser
.command("status")
.description("Show browser status")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const status = await fetchBrowserStatus(parent, parent?.browserProfile);
if (printJsonResult(parent, status)) {
return;
}
const detectedPath = status.detectedExecutablePath ?? status.executablePath;
const detectedDisplay = detectedPath ? shortenHomePath(detectedPath) : "auto";
defaultRuntime.log(
[
`profile: ${status.profile ?? "openclaw"}`,
`enabled: ${status.enabled}`,
`running: ${status.running}`,
`transport: ${
usesChromeMcpTransport(status) ? "chrome-mcp" : (status.transport ?? "cdp")
}`,
...(!usesChromeMcpTransport(status)
? [
`cdpPort: ${status.cdpPort ?? "(unset)"}`,
`cdpUrl: ${redactCdpUrl(status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`)}`,
]
: status.userDataDir
? [`userDataDir: ${shortenHomePath(status.userDataDir)}`]
: []),
`browser: ${status.chosenBrowser ?? "unknown"}`,
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
`detectedPath: ${detectedDisplay}`,
`profileColor: ${status.color}`,
...(status.detectError ? [`detectError: ${status.detectError}`] : []),
].join("\n"),
);
});
});
browser
.command("start")
.description("Start the browser (no-op if already running)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
await runBrowserToggle(parent, { profile, path: "/start" });
});
});
browser
.command("stop")
.description("Stop the browser (best-effort)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
await runBrowserToggle(parent, { profile, path: "/stop" });
});
});
browser
.command("reset-profile")
.description("Reset browser profile (moves it to Trash)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callBrowserRequest<BrowserResetProfileResult>(
parent,
{
method: "POST",
path: "/reset-profile",
query: resolveProfileQuery(profile),
},
{ timeoutMs: 20000 },
);
if (printJsonResult(parent, result)) {
return;
}
if (!result.moved) {
defaultRuntime.log(info(`🦞 browser profile already missing.`));
return;
}
const dest = result.to ?? result.from;
defaultRuntime.log(info(`🦞 browser profile moved to Trash (${dest})`));
});
});
browser
.command("tabs")
.description("List open tabs")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callBrowserRequest<{ running: boolean; tabs: BrowserTab[] }>(
parent,
{
method: "GET",
path: "/tabs",
query: resolveProfileQuery(profile),
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
const tabs = result.tabs ?? [];
logBrowserTabs(tabs, parent?.json);
});
});
const tab = browser
.command("tab")
.description("Tab shortcuts (index-based)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callBrowserRequest<{ ok: true; tabs: BrowserTab[] }>(
parent,
{
method: "POST",
path: "/tabs/action",
query: resolveProfileQuery(profile),
body: {
action: "list",
},
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
const tabs = result.tabs ?? [];
logBrowserTabs(tabs, parent?.json);
});
});
tab
.command("new")
.description("Open a new tab (about:blank)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callTabAction(parent, profile, { action: "new" });
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log("opened new tab");
});
});
tab
.command("select")
.description("Focus tab by index (1-based)")
.argument("<index>", "Tab index (1-based)", (v: string) => Number(v))
.action(async (index: number, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
if (!Number.isFinite(index) || index < 1) {
defaultRuntime.error(danger("index must be a positive number"));
defaultRuntime.exit(1);
return;
}
await runBrowserCommand(async () => {
const result = await callTabAction(parent, profile, {
action: "select",
index: Math.floor(index) - 1,
});
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log(`selected tab ${Math.floor(index)}`);
});
});
tab
.command("close")
.description("Close tab by index (1-based); default: first tab")
.argument("[index]", "Tab index (1-based)", (v: string) => Number(v))
.action(async (index: number | undefined, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const idx =
typeof index === "number" && Number.isFinite(index) ? Math.floor(index) - 1 : undefined;
if (typeof idx === "number" && idx < 0) {
defaultRuntime.error(danger("index must be >= 1"));
defaultRuntime.exit(1);
return;
}
await runBrowserCommand(async () => {
const result = await callTabAction(parent, profile, { action: "close", index: idx });
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log("closed tab");
});
});
browser
.command("open")
.description("Open a URL in a new tab")
.argument("<url>", "URL to open")
.action(async (url: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const tab = await callBrowserRequest<BrowserTab>(
parent,
{
method: "POST",
path: "/tabs/open",
query: resolveProfileQuery(profile),
body: { url },
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
if (printJsonResult(parent, tab)) {
return;
}
defaultRuntime.log(`opened: ${tab.url}\nid: ${tab.targetId}`);
});
});
browser
.command("focus")
.description("Focus a tab by target id (or unique prefix)")
.argument("<targetId>", "Target id or unique prefix")
.action(async (targetId: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
await callBrowserRequest(
parent,
{
method: "POST",
path: "/tabs/focus",
query: resolveProfileQuery(profile),
body: { targetId },
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
if (printJsonResult(parent, { ok: true })) {
return;
}
defaultRuntime.log(`focused tab ${targetId}`);
});
});
browser
.command("close")
.description("Close a tab (target id optional)")
.argument("[targetId]", "Target id or unique prefix (optional)")
.action(async (targetId: string | undefined, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
if (targetId?.trim()) {
await callBrowserRequest(
parent,
{
method: "DELETE",
path: `/tabs/${encodeURIComponent(targetId.trim())}`,
query: resolveProfileQuery(profile),
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
} else {
await callBrowserRequest(
parent,
{
method: "POST",
path: "/act",
query: resolveProfileQuery(profile),
body: { kind: "close" },
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
}
if (printJsonResult(parent, { ok: true })) {
return;
}
defaultRuntime.log("closed tab");
});
});
// Profile management commands
browser
.command("profiles")
.description("List all browser profiles")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const result = await callBrowserRequest<{ profiles: ProfileStatus[] }>(
parent,
{
method: "GET",
path: "/profiles",
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
const profiles = result.profiles ?? [];
if (printJsonResult(parent, { profiles })) {
return;
}
if (profiles.length === 0) {
defaultRuntime.log("No profiles configured.");
return;
}
defaultRuntime.log(
profiles
.map((p) => {
const status = p.running ? "running" : "stopped";
const tabs = p.running ? ` (${p.tabCount} tabs)` : "";
const def = p.isDefault ? " [default]" : "";
const loc = formatBrowserConnectionSummary(p);
const remote = p.isRemote ? " [remote]" : "";
const driver = p.driver !== "openclaw" ? ` [${p.driver}]` : "";
return `${p.name}: ${status}${tabs}${def}${remote}${driver}\n ${loc}, color: ${p.color}`;
})
.join("\n"),
);
});
});
browser
.command("create-profile")
.description("Create a new browser profile")
.requiredOption("--name <name>", "Profile name (lowercase, numbers, hyphens)")
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
.option("--user-data-dir <path>", "User data dir for existing-session Chromium attach")
.option("--driver <driver>", "Profile driver (openclaw|existing-session). Default: openclaw")
.action(
async (
opts: {
name: string;
color?: string;
cdpUrl?: string;
userDataDir?: string;
driver?: string;
},
cmd,
) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const result = await callBrowserRequest<BrowserCreateProfileResult>(
parent,
{
method: "POST",
path: "/profiles/create",
body: {
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
userDataDir: opts.userDataDir,
driver: opts.driver === "existing-session" ? "existing-session" : undefined,
},
},
{ timeoutMs: 10_000 },
);
if (printJsonResult(parent, result)) {
return;
}
const loc = ` ${formatBrowserConnectionSummary(result)}`;
defaultRuntime.log(
info(
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${
result.userDataDir ? `\n userDataDir: ${shortenHomePath(result.userDataDir)}` : ""
}${opts.driver === "existing-session" ? "\n driver: existing-session" : ""}`,
),
);
});
},
);
browser
.command("delete-profile")
.description("Delete a browser profile")
.requiredOption("--name <name>", "Profile name to delete")
.action(async (opts: { name: string }, cmd) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const result = await callBrowserRequest<BrowserDeleteProfileResult>(
parent,
{
method: "DELETE",
path: `/profiles/${encodeURIComponent(opts.name)}`,
},
{ timeoutMs: 20_000 },
);
if (printJsonResult(parent, result)) {
return;
}
const msg = result.deleted
? `🦞 Deleted profile "${result.profile}" (user data removed)`
: `🦞 Deleted profile "${result.profile}" (no user data found)`;
defaultRuntime.log(info(msg));
});
});
}
export * from "../../extensions/browser/src/cli/browser-cli-manage.js";

View File

@@ -1,37 +1 @@
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { callBrowserResize, type BrowserParentOpts } from "./browser-cli-shared.js";
export async function runBrowserResizeWithOutput(params: {
parent: BrowserParentOpts;
profile?: string;
width: number;
height: number;
targetId?: string;
timeoutMs?: number;
successMessage: string;
}): Promise<void> {
const { width, height } = params;
if (!Number.isFinite(width) || !Number.isFinite(height)) {
defaultRuntime.error(danger("width and height must be numbers"));
defaultRuntime.exit(1);
return;
}
const result = await callBrowserResize(
params.parent,
{
profile: params.profile,
width,
height,
targetId: params.targetId,
},
{ timeoutMs: params.timeoutMs ?? 20000 },
);
if (params.parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(params.successMessage);
}
export * from "../../extensions/browser/src/cli/browser-cli-resize.js";

View File

@@ -1,84 +1 @@
import type { GatewayRpcOpts } from "./gateway-rpc.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
export type BrowserParentOpts = GatewayRpcOpts & {
json?: boolean;
browserProfile?: string;
};
type BrowserRequestParams = {
method: "GET" | "POST" | "DELETE";
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
};
function normalizeQuery(query: BrowserRequestParams["query"]): Record<string, string> | undefined {
if (!query) {
return undefined;
}
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(query)) {
if (value === undefined) {
continue;
}
out[key] = String(value);
}
return Object.keys(out).length ? out : undefined;
}
export async function callBrowserRequest<T>(
opts: BrowserParentOpts,
params: BrowserRequestParams,
extra?: { timeoutMs?: number; progress?: boolean },
): Promise<T> {
const resolvedTimeoutMs =
typeof extra?.timeoutMs === "number" && Number.isFinite(extra.timeoutMs)
? Math.max(1, Math.floor(extra.timeoutMs))
: typeof opts.timeout === "string"
? Number.parseInt(opts.timeout, 10)
: undefined;
const resolvedTimeout =
typeof resolvedTimeoutMs === "number" && Number.isFinite(resolvedTimeoutMs)
? resolvedTimeoutMs
: undefined;
const timeout = typeof resolvedTimeout === "number" ? String(resolvedTimeout) : opts.timeout;
const payload = await callGatewayFromCli(
"browser.request",
{ ...opts, timeout },
{
method: params.method,
path: params.path,
query: normalizeQuery(params.query),
body: params.body,
timeoutMs: resolvedTimeout,
},
{ progress: extra?.progress },
);
if (payload === undefined) {
throw new Error("Unexpected browser.request response");
}
return payload as T;
}
export async function callBrowserResize(
opts: BrowserParentOpts,
params: { profile?: string; width: number; height: number; targetId?: string },
extra?: { timeoutMs?: number },
): Promise<unknown> {
return callBrowserRequest(
opts,
{
method: "POST",
path: "/act",
query: params.profile ? { profile: params.profile } : undefined,
body: {
kind: "resize",
width: params.width,
height: params.height,
targetId: params.targetId?.trim() || undefined,
},
},
extra,
);
}
export * from "../../extensions/browser/src/cli/browser-cli-shared.js";

View File

@@ -1,229 +1 @@
import type { Command } from "commander";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { inheritOptionFromParent } from "./command-options.js";
function resolveUrl(opts: { url?: string }, command: Command): string | undefined {
if (typeof opts.url === "string" && opts.url.trim()) {
return opts.url.trim();
}
const inherited = inheritOptionFromParent<string>(command, "url");
if (typeof inherited === "string" && inherited.trim()) {
return inherited.trim();
}
return undefined;
}
function resolveTargetId(rawTargetId: unknown, command: Command): string | undefined {
const local = typeof rawTargetId === "string" ? rawTargetId.trim() : "";
if (local) {
return local;
}
const inherited = inheritOptionFromParent<string>(command, "targetId");
if (typeof inherited !== "string") {
return undefined;
}
const trimmed = inherited.trim();
return trimmed ? trimmed : undefined;
}
async function runMutationRequest(params: {
parent: BrowserParentOpts;
request: Parameters<typeof callBrowserRequest>[1];
successMessage: string;
}) {
try {
const result = await callBrowserRequest(params.parent, params.request, { timeoutMs: 20000 });
if (params.parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(params.successMessage);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}
export function registerBrowserCookiesAndStorageCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
const cookies = browser.command("cookies").description("Read/write cookies");
cookies
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
try {
const result = await callBrowserRequest<{ cookies?: unknown[] }>(
parent,
{
method: "GET",
path: "/cookies",
query: {
targetId,
profile,
},
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.writeJson(result.cookies ?? []);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
cookies
.command("set")
.description("Set a cookie (requires --url or domain+path)")
.argument("<name>", "Cookie name")
.argument("<value>", "Cookie value")
.option("--url <url>", "Cookie URL scope (recommended)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (name: string, value: string, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
const url = resolveUrl(opts, cmd);
if (!url) {
defaultRuntime.error(danger("Missing required --url option for cookies set"));
defaultRuntime.exit(1);
return;
}
await runMutationRequest({
parent,
request: {
method: "POST",
path: "/cookies/set",
query: profile ? { profile } : undefined,
body: {
targetId,
cookie: { name, value, url },
},
},
successMessage: `cookie set: ${name}`,
});
});
cookies
.command("clear")
.description("Clear all cookies")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
await runMutationRequest({
parent,
request: {
method: "POST",
path: "/cookies/clear",
query: profile ? { profile } : undefined,
body: {
targetId,
},
},
successMessage: "cookies cleared",
});
});
const storage = browser.command("storage").description("Read/write localStorage/sessionStorage");
function registerStorageKind(kind: "local" | "session") {
const cmd = storage.command(kind).description(`${kind}Storage commands`);
cmd
.command("get")
.description(`Get ${kind}Storage (all keys or one key)`)
.argument("[key]", "Key (optional)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (key: string | undefined, opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd2);
try {
const result = await callBrowserRequest<{ values?: Record<string, string> }>(
parent,
{
method: "GET",
path: `/storage/${kind}`,
query: {
key: key?.trim() || undefined,
targetId,
profile,
},
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.writeJson(result.values ?? {});
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
cmd
.command("set")
.description(`Set a ${kind}Storage key`)
.argument("<key>", "Key")
.argument("<value>", "Value")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (key: string, value: string, opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd2);
await runMutationRequest({
parent,
request: {
method: "POST",
path: `/storage/${kind}/set`,
query: profile ? { profile } : undefined,
body: {
key,
value,
targetId,
},
},
successMessage: `${kind}Storage set: ${key}`,
});
});
cmd
.command("clear")
.description(`Clear all ${kind}Storage keys`)
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd2);
await runMutationRequest({
parent,
request: {
method: "POST",
path: `/storage/${kind}/clear`,
query: profile ? { profile } : undefined,
body: {
targetId,
},
},
successMessage: `${kind}Storage cleared`,
});
});
}
registerStorageKind("local");
registerStorageKind("session");
}
export * from "../../extensions/browser/src/cli/browser-cli-state.cookies-storage.js";

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerBrowserStateCommands } from "./browser-cli-state.js";
import { registerBrowserStateCommands } from "../../extensions/browser/src/cli/browser-cli-state.js";
import {
createBrowserProgram as createBrowserProgramShared,
getBrowserCliRuntime,
@@ -11,19 +11,19 @@ const mocks = vi.hoisted(() => ({
runBrowserResizeWithOutput: vi.fn(async (_params: unknown) => {}),
}));
vi.mock("./browser-cli-shared.js", () => ({
vi.mock("../../extensions/browser/src/cli/browser-cli-shared.js", () => ({
callBrowserRequest: mocks.callBrowserRequest,
}));
vi.mock("./browser-cli-resize.js", () => ({
vi.mock("../../extensions/browser/src/cli/browser-cli-resize.js", () => ({
runBrowserResizeWithOutput: mocks.runBrowserResizeWithOutput,
}));
vi.mock(
"../runtime.js",
async () =>
await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(),
);
vi.mock("../../extensions/browser/src/core-api.js", async () => ({
...(await vi.importActual<object>("../../extensions/browser/src/core-api.js")),
...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule()),
...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()),
}));
describe("browser state option collisions", () => {
const createStateProgram = ({ withGatewayUrl = false } = {}) => {

View File

@@ -1,276 +1 @@
import type { Command } from "commander";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { runBrowserResizeWithOutput } from "./browser-cli-resize.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
import { runCommandWithRuntime } from "./cli-utils.js";
function parseOnOff(raw: string): boolean | null {
const parsed = parseBooleanValue(raw);
return parsed === undefined ? null : parsed;
}
function runBrowserCommand(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
});
}
async function runBrowserSetRequest(params: {
parent: BrowserParentOpts;
path: string;
body: Record<string, unknown>;
successMessage: string;
}) {
await runBrowserCommand(async () => {
const profile = params.parent?.browserProfile;
const result = await callBrowserRequest(
params.parent,
{
method: "POST",
path: params.path,
query: profile ? { profile } : undefined,
body: params.body,
},
{ timeoutMs: 20000 },
);
if (params.parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log(params.successMessage);
});
}
export function registerBrowserStateCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
) {
registerBrowserCookiesAndStorageCommands(browser, parentOpts);
const set = browser.command("set").description("Browser environment settings");
set
.command("viewport")
.description("Set viewport size (alias for resize)")
.argument("<width>", "Viewport width", (v: string) => Number(v))
.argument("<height>", "Viewport height", (v: string) => Number(v))
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (width: number, height: number, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
await runBrowserResizeWithOutput({
parent,
profile,
width,
height,
targetId: opts.targetId,
timeoutMs: 20000,
successMessage: `viewport set: ${width}x${height}`,
});
});
});
set
.command("offline")
.description("Toggle offline mode")
.argument("<on|off>", "on/off")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (value: string, opts, cmd) => {
const parent = parentOpts(cmd);
const offline = parseOnOff(value);
if (offline === null) {
defaultRuntime.error(danger("Expected on|off"));
defaultRuntime.exit(1);
return;
}
await runBrowserSetRequest({
parent,
path: "/set/offline",
body: {
offline,
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `offline: ${offline}`,
});
});
set
.command("headers")
.description("Set extra HTTP headers (JSON object)")
.argument("[headersJson]", "JSON object of headers (alternative to --headers-json)")
.option("--headers-json <json>", "JSON object of headers")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (headersJson: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const headersJsonValue =
(typeof opts.headersJson === "string" && opts.headersJson.trim()) ||
(headersJson?.trim() ? headersJson.trim() : undefined);
if (!headersJsonValue) {
throw new Error("Missing headers JSON (pass --headers-json or positional JSON argument)");
}
const parsed = JSON.parse(String(headersJsonValue)) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Headers JSON must be a JSON object");
}
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === "string") {
headers[k] = v;
}
}
const profile = parent?.browserProfile;
const result = await callBrowserRequest(
parent,
{
method: "POST",
path: "/set/headers",
query: profile ? { profile } : undefined,
body: {
headers,
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
);
if (parent?.json) {
defaultRuntime.writeJson(result);
return;
}
defaultRuntime.log("headers set");
});
});
set
.command("credentials")
.description("Set HTTP basic auth credentials")
.option("--clear", "Clear credentials", false)
.argument("[username]", "Username")
.argument("[password]", "Password")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (username: string | undefined, password: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
parent,
path: "/set/credentials",
body: {
username: username?.trim() || undefined,
password,
clear: Boolean(opts.clear),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: opts.clear ? "credentials cleared" : "credentials set",
});
});
set
.command("geo")
.description("Set geolocation (and grant permission)")
.option("--clear", "Clear geolocation + permissions", false)
.argument("[latitude]", "Latitude", (v: string) => Number(v))
.argument("[longitude]", "Longitude", (v: string) => Number(v))
.option("--accuracy <m>", "Accuracy in meters", (v: string) => Number(v))
.option("--origin <origin>", "Origin to grant permissions for")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (latitude: number | undefined, longitude: number | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
parent,
path: "/set/geolocation",
body: {
latitude: Number.isFinite(latitude) ? latitude : undefined,
longitude: Number.isFinite(longitude) ? longitude : undefined,
accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined,
origin: opts.origin?.trim() || undefined,
clear: Boolean(opts.clear),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: opts.clear ? "geolocation cleared" : "geolocation set",
});
});
set
.command("media")
.description("Emulate prefers-color-scheme")
.argument("<dark|light|none>", "dark/light/none")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (value: string, opts, cmd) => {
const parent = parentOpts(cmd);
const v = value.trim().toLowerCase();
const colorScheme =
v === "dark" ? "dark" : v === "light" ? "light" : v === "none" ? "none" : null;
if (!colorScheme) {
defaultRuntime.error(danger("Expected dark|light|none"));
defaultRuntime.exit(1);
return;
}
await runBrowserSetRequest({
parent,
path: "/set/media",
body: {
colorScheme,
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `media colorScheme: ${colorScheme}`,
});
});
set
.command("timezone")
.description("Override timezone (CDP)")
.argument("<timezoneId>", "Timezone ID (e.g. America/New_York)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (timezoneId: string, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
parent,
path: "/set/timezone",
body: {
timezoneId,
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `timezone: ${timezoneId}`,
});
});
set
.command("locale")
.description("Override locale (CDP)")
.argument("<locale>", "Locale (e.g. en-US)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (locale: string, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
parent,
path: "/set/locale",
body: {
locale,
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `locale: ${locale}`,
});
});
set
.command("device")
.description('Apply a Playwright device descriptor (e.g. "iPhone 14")')
.argument("<name>", "Device name (Playwright devices)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (name: string, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserSetRequest({
parent,
path: "/set/device",
body: {
name,
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `device: ${name}`,
});
});
}
export * from "../../extensions/browser/src/cli/browser-cli-state.js";

View File

@@ -1,53 +1 @@
import type { Command } from "commander";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { registerBrowserActionInputCommands } from "./browser-cli-actions-input.js";
import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js";
import { registerBrowserDebugCommands } from "./browser-cli-debug.js";
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 { formatCliCommand } from "./command-format.js";
import { addGatewayClientOptions } from "./gateway-rpc.js";
import { formatHelpExamples } from "./help-format.js";
export function registerBrowserCli(program: Command) {
const browser = program
.command("browser")
.description("Manage OpenClaw's dedicated browser (Chrome/Chromium)")
.option("--browser-profile <name>", "Browser profile name (default from config)")
.option("--json", "Output machine-readable JSON", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples(
[...browserCoreExamples, ...browserActionExamples].map((cmd) => [cmd, ""]),
true,
)}\n\n${theme.muted("Docs:")} ${formatDocsLink(
"/cli/browser",
"docs.openclaw.ai/cli/browser",
)}\n`,
)
.action(() => {
browser.outputHelp();
defaultRuntime.error(
danger(`Missing subcommand. Try: "${formatCliCommand("openclaw browser status")}"`),
);
defaultRuntime.exit(1);
});
addGatewayClientOptions(browser);
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);
}
export * from "../../extensions/browser/src/cli/browser-cli.js";

View File

@@ -13,7 +13,11 @@ import {
} from "./completion-fish.js";
import { getCoreCliCommandNames, registerCoreCliByName } from "./program/command-registry.js";
import { getProgramContext } from "./program/program-context.js";
import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis.js";
import {
getSubCliEntries,
loadValidatedConfigForPluginRegistration,
registerSubCliByName,
} from "./program/register.subclis.js";
const COMPLETION_SHELLS = ["zsh", "bash", "powershell", "fish"] as const;
type CompletionShell = (typeof COMPLETION_SHELLS)[number];
@@ -273,6 +277,12 @@ export function registerCompletionCli(program: Command) {
await registerSubCliByName(program, entry.name);
}
const config = await loadValidatedConfigForPluginRegistration();
if (config) {
const { registerPluginCliCommands } = await import("../plugins/cli.js");
registerPluginCliCommands(program, config);
}
if (options.writeState) {
const writeShells = options.shell ? [shell] : [...COMPLETION_SHELLS];
await writeCompletionCache({

View File

@@ -74,7 +74,6 @@ describe("command-registry", () => {
expect(names).toContain("config");
expect(names).toContain("agents");
expect(names).toContain("backup");
expect(names).toContain("browser");
expect(names).toContain("sessions");
expect(names).not.toContain("agent");
expect(names).not.toContain("status");

View File

@@ -203,19 +203,6 @@ const coreEntries: CoreCliEntry[] = [
mod.registerStatusHealthSessionsCommands(program);
},
},
{
commands: [
{
name: "browser",
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
hasSubcommands: true,
},
],
register: async ({ program }) => {
const mod = await import("../browser-cli.js");
mod.registerBrowserCli(program);
},
},
];
export function getCoreCliCommandNames(): string[] {

View File

@@ -81,11 +81,6 @@ export const CORE_CLI_COMMAND_DESCRIPTORS = [
description: "List stored conversation sessions",
hasSubcommands: true,
},
{
name: "browser",
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
hasSubcommands: true,
},
] as const satisfies ReadonlyArray<CoreCliCommandDescriptor>;
export function getCoreCliCommandDescriptors(): ReadonlyArray<CoreCliCommandDescriptor> {