mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 11:51:22 +00:00
refactor: switch browser ownership to bundled plugin
This commit is contained in:
@@ -1 +1 @@
|
||||
export { registerBrowserActionInputCommands } from "./browser-cli-actions-input/register.js";
|
||||
export * from "../../extensions/browser/src/cli/browser-cli-actions-input.js";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } = {}) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user