diff --git a/.changeset/browse-clipboard-cli.md b/.changeset/browse-clipboard-cli.md new file mode 100644 index 000000000..63072c191 --- /dev/null +++ b/.changeset/browse-clipboard-cli.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse clipboard` commands (read, write, paste, copy, cut, clear) so the browse CLI exposes the SDK clipboard API for agent and terminal workflows. diff --git a/packages/cli/package.json b/packages/cli/package.json index b7fcc0cda..e4932b4d7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,6 +31,9 @@ }, "topicSeparator": " ", "topics": { + "clipboard": { + "description": "Read, write, and interact with the browser session clipboard." + }, "cloud": { "description": "Manage Browserbase cloud resources and APIs." }, diff --git a/packages/cli/src/commands/clipboard/clear.ts b/packages/cli/src/commands/clipboard/clear.ts new file mode 100644 index 000000000..d657eede0 --- /dev/null +++ b/packages/cli/src/commands/clipboard/clear.ts @@ -0,0 +1,24 @@ +import { BrowseCommand } from "../../base.js"; +import { + clipboardScopeNote, + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardClear extends BrowseCommand { + static override description = `Clear the clipboard for the active browser session.\n\n${clipboardScopeNote}`; + + static override examples = [ + "browse clipboard clear", + "browse clipboard clear --session research", + ]; + + static override flags = { + ...driverCommandFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(ClipboardClear); + await runDriverCommandFromFlags("clipboard.clear", {}, flags); + } +} diff --git a/packages/cli/src/commands/clipboard/copy.ts b/packages/cli/src/commands/clipboard/copy.ts new file mode 100644 index 000000000..732f80f9f --- /dev/null +++ b/packages/cli/src/commands/clipboard/copy.ts @@ -0,0 +1,24 @@ +import { BrowseCommand } from "../../base.js"; +import { + clipboardScopeNote, + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardCopy extends BrowseCommand { + static override description = `Copy the current page selection to the session clipboard.\n\n${clipboardScopeNote}`; + + static override examples = [ + "browse clipboard copy", + "browse clipboard copy --session research", + ]; + + static override flags = { + ...driverCommandFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(ClipboardCopy); + await runDriverCommandFromFlags("clipboard.copy", {}, flags); + } +} diff --git a/packages/cli/src/commands/clipboard/cut.ts b/packages/cli/src/commands/clipboard/cut.ts new file mode 100644 index 000000000..25bf39b5e --- /dev/null +++ b/packages/cli/src/commands/clipboard/cut.ts @@ -0,0 +1,24 @@ +import { BrowseCommand } from "../../base.js"; +import { + clipboardScopeNote, + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardCut extends BrowseCommand { + static override description = `Cut the current page selection to the session clipboard.\n\n${clipboardScopeNote}`; + + static override examples = [ + "browse clipboard cut", + "browse clipboard cut --session research", + ]; + + static override flags = { + ...driverCommandFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(ClipboardCut); + await runDriverCommandFromFlags("clipboard.cut", {}, flags); + } +} diff --git a/packages/cli/src/commands/clipboard/paste.ts b/packages/cli/src/commands/clipboard/paste.ts new file mode 100644 index 000000000..7e491ef46 --- /dev/null +++ b/packages/cli/src/commands/clipboard/paste.ts @@ -0,0 +1,35 @@ +import { Flags } from "@oclif/core"; + +import { BrowseCommand } from "../../base.js"; +import { + clipboardScopeNote, + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardPaste extends BrowseCommand { + static override description = `Paste session clipboard text into the focused field on the active page.\n\n${clipboardScopeNote}`; + + static override examples = [ + "browse clipboard paste", + "browse clipboard paste --shortcut Control+V", + ]; + + static override flags = { + ...driverCommandFlags, + shortcut: Flags.string({ + description: "Keyboard shortcut to trigger paste.", + helpValue: "", + options: ["ControlOrMeta+V", "Meta+V", "Control+V"], + }), + }; + + async run(): Promise { + const { flags } = await this.parse(ClipboardPaste); + await runDriverCommandFromFlags( + "clipboard.paste", + { shortcut: flags.shortcut }, + flags, + ); + } +} diff --git a/packages/cli/src/commands/clipboard/read.ts b/packages/cli/src/commands/clipboard/read.ts new file mode 100644 index 000000000..61b7ac03e --- /dev/null +++ b/packages/cli/src/commands/clipboard/read.ts @@ -0,0 +1,24 @@ +import { BrowseCommand } from "../../base.js"; +import { + clipboardScopeNote, + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardRead extends BrowseCommand { + static override description = `Read text from the session clipboard.\n\n${clipboardScopeNote}`; + + static override examples = [ + "browse clipboard read", + "browse clipboard read --session research", + ]; + + static override flags = { + ...driverCommandFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(ClipboardRead); + await runDriverCommandFromFlags("clipboard.read", {}, flags); + } +} diff --git a/packages/cli/src/commands/clipboard/write.ts b/packages/cli/src/commands/clipboard/write.ts new file mode 100644 index 000000000..c10bca11e --- /dev/null +++ b/packages/cli/src/commands/clipboard/write.ts @@ -0,0 +1,37 @@ +import { Args } from "@oclif/core"; + +import { BrowseCommand } from "../../base.js"; +import { + clipboardScopeNote, + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardWrite extends BrowseCommand { + static override description = `Write text to the session clipboard.\n\n${clipboardScopeNote}`; + + static override examples = [ + "browse clipboard write 'hello world'", + "browse clipboard write 'seed text' --session research", + ]; + + static override args = { + text: Args.string({ + description: "Text to write to the clipboard.", + required: true, + }), + }; + + static override flags = { + ...driverCommandFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(ClipboardWrite); + await runDriverCommandFromFlags( + "clipboard.write", + { text: args.text }, + flags, + ); + } +} diff --git a/packages/cli/src/lib/driver/command-cli.ts b/packages/cli/src/lib/driver/command-cli.ts index 18c442f55..008ff21c7 100644 --- a/packages/cli/src/lib/driver/command-cli.ts +++ b/packages/cli/src/lib/driver/command-cli.ts @@ -18,6 +18,9 @@ import type { ConnectionTarget } from "./types.js"; import { outputJson } from "../output.js"; import { runDriverCommandWithTarget } from "./runtime.js"; +export const clipboardScopeNote = + "Clipboard scope depends on the session target: Browserbase remote sessions and managed headless local sessions use an isolated per-browser clipboard, while headed local sessions and --cdp attachments share the machine's OS clipboard with every other app and session on that machine."; + export const driverCommandFlags = { "auto-connect": autoConnectFlag, cdp: cdpFlag, diff --git a/packages/cli/src/lib/driver/commands/clipboard.ts b/packages/cli/src/lib/driver/commands/clipboard.ts new file mode 100644 index 000000000..c25148490 --- /dev/null +++ b/packages/cli/src/lib/driver/commands/clipboard.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +import type { DriverCommandHandlers } from "./types.js"; + +const writeParamsSchema = z.object({ + text: z.string(), +}); + +const pasteParamsSchema = z.object({ + shortcut: z.enum(["ControlOrMeta+V", "Meta+V", "Control+V"]).optional(), +}); + +export const clipboardHandlers: DriverCommandHandlers = { + async "clipboard.read"(manager) { + const context = await manager.browserContext(); + const text = await context.clipboard.readText(); + return { text }; + }, + + async "clipboard.write"(manager, params) { + const { text } = writeParamsSchema.parse(params); + const context = await manager.browserContext(); + await context.clipboard.writeText(text); + return { ok: true }; + }, + + async "clipboard.clear"(manager) { + const context = await manager.browserContext(); + await context.clipboard.clear(); + return { ok: true }; + }, + + async "clipboard.paste"(manager, params) { + const { shortcut } = pasteParamsSchema.parse(params ?? {}); + const context = await manager.browserContext(); + await context.clipboard.paste(shortcut ? { shortcut } : undefined); + return { ok: true }; + }, + + async "clipboard.copy"(manager) { + const context = await manager.browserContext(); + await context.clipboard.copy(); + return { ok: true }; + }, + + async "clipboard.cut"(manager) { + const context = await manager.browserContext(); + await context.clipboard.cut(); + return { ok: true }; + }, +}; diff --git a/packages/cli/src/lib/driver/commands/registry.ts b/packages/cli/src/lib/driver/commands/registry.ts index c6baeab06..55d46afb2 100644 --- a/packages/cli/src/lib/driver/commands/registry.ts +++ b/packages/cli/src/lib/driver/commands/registry.ts @@ -1,4 +1,5 @@ import type { DriverSessionManager } from "../session-manager.js"; +import { clipboardHandlers } from "./clipboard.js"; import { elementsHandlers } from "./elements.js"; import { keyboardHandlers } from "./keyboard.js"; import { mouseHandlers } from "./mouse.js"; @@ -12,6 +13,7 @@ import type { DriverCommandHandlers, DriverCommandName } from "./types.js"; const handlers: DriverCommandHandlers = { ...navigationHandlers, + ...clipboardHandlers, ...elementsHandlers, ...keyboardHandlers, ...mouseHandlers, diff --git a/packages/cli/src/lib/driver/commands/types.ts b/packages/cli/src/lib/driver/commands/types.ts index b72480f2c..5f64df208 100644 --- a/packages/cli/src/lib/driver/commands/types.ts +++ b/packages/cli/src/lib/driver/commands/types.ts @@ -4,6 +4,12 @@ import { z } from "zod"; export const DRIVER_COMMAND_NAMES = [ "back", "click", + "clipboard.clear", + "clipboard.copy", + "clipboard.cut", + "clipboard.paste", + "clipboard.read", + "clipboard.write", "cursor", "eval", "fill", diff --git a/packages/cli/tests/clipboard.test.ts b/packages/cli/tests/clipboard.test.ts new file mode 100644 index 000000000..606f0b877 --- /dev/null +++ b/packages/cli/tests/clipboard.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +import { clipboardHandlers } from "../src/lib/driver/commands/clipboard.js"; +import type { DriverSessionManager } from "../src/lib/driver/session-manager.js"; + +function makeManager(clipboard: Record>) { + return { + browserContext: vi.fn().mockResolvedValue({ clipboard }), + } as unknown as DriverSessionManager; +} + +describe("clipboard driver handlers", () => { + it("reads clipboard text", async () => { + const readText = vi.fn().mockResolvedValue("copied value"); + const manager = makeManager({ readText }); + + await expect( + clipboardHandlers["clipboard.read"]!(manager, {}), + ).resolves.toEqual({ text: "copied value" }); + expect(readText).toHaveBeenCalledOnce(); + }); + + it("writes clipboard text", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const manager = makeManager({ writeText }); + + await expect( + clipboardHandlers["clipboard.write"]!(manager, { text: "hello" }), + ).resolves.toEqual({ ok: true }); + expect(writeText).toHaveBeenCalledWith("hello"); + }); + + it("pastes with an optional shortcut", async () => { + const paste = vi.fn().mockResolvedValue(undefined); + const manager = makeManager({ paste }); + + await expect( + clipboardHandlers["clipboard.paste"]!(manager, { + shortcut: "Control+V", + }), + ).resolves.toEqual({ ok: true }); + expect(paste).toHaveBeenCalledWith({ shortcut: "Control+V" }); + }); + + it("clears, copies, and cuts via clipboard helpers", async () => { + const clear = vi.fn().mockResolvedValue(undefined); + const copy = vi.fn().mockResolvedValue(undefined); + const cut = vi.fn().mockResolvedValue(undefined); + const manager = makeManager({ clear, copy, cut }); + + await expect( + clipboardHandlers["clipboard.clear"]!(manager, {}), + ).resolves.toEqual({ ok: true }); + await expect( + clipboardHandlers["clipboard.copy"]!(manager, {}), + ).resolves.toEqual({ ok: true }); + await expect( + clipboardHandlers["clipboard.cut"]!(manager, {}), + ).resolves.toEqual({ + ok: true, + }); + expect(clear).toHaveBeenCalledOnce(); + expect(copy).toHaveBeenCalledOnce(); + expect(cut).toHaveBeenCalledOnce(); + }); + + it("rejects write without text", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const manager = makeManager({ writeText }); + + await expect( + clipboardHandlers["clipboard.write"]!(manager, {}), + ).rejects.toThrow(); + expect(writeText).not.toHaveBeenCalled(); + }); + + it("rejects write with non-string text", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const manager = makeManager({ writeText }); + + await expect( + clipboardHandlers["clipboard.write"]!(manager, { text: 42 }), + ).rejects.toThrow(); + expect(writeText).not.toHaveBeenCalled(); + }); + + it("rejects paste with an unsupported shortcut", async () => { + const paste = vi.fn().mockResolvedValue(undefined); + const manager = makeManager({ paste }); + + await expect( + clipboardHandlers["clipboard.paste"]!(manager, { shortcut: "Alt+V" }), + ).rejects.toThrow(); + expect(paste).not.toHaveBeenCalled(); + }); + + it("pastes without options when no shortcut is given", async () => { + const paste = vi.fn().mockResolvedValue(undefined); + const manager = makeManager({ paste }); + + await expect( + clipboardHandlers["clipboard.paste"]!(manager, undefined), + ).resolves.toEqual({ ok: true }); + expect(paste).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index baecd003b..fdc7d9f3d 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -25,6 +25,8 @@ describe("driver commands", () => { "snapshot", "tab.switch", "network.on", + "clipboard.read", + "clipboard.write", "upload", "viewport", ]),