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/src/commands/clipboard/clear.ts b/packages/cli/src/commands/clipboard/clear.ts new file mode 100644 index 000000000..956278506 --- /dev/null +++ b/packages/cli/src/commands/clipboard/clear.ts @@ -0,0 +1,24 @@ +import { BrowseCommand } from "../../base.js"; +import { + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardClear extends BrowseCommand { + static override description = + "Clear the browser clipboard for the active page."; + + 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..8f91ea338 --- /dev/null +++ b/packages/cli/src/commands/clipboard/copy.ts @@ -0,0 +1,24 @@ +import { BrowseCommand } from "../../base.js"; +import { + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardCopy extends BrowseCommand { + static override description = + "Copy the current selection to the browser clipboard on the active page."; + + 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..ef3ae2329 --- /dev/null +++ b/packages/cli/src/commands/clipboard/cut.ts @@ -0,0 +1,24 @@ +import { BrowseCommand } from "../../base.js"; +import { + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardCut extends BrowseCommand { + static override description = + "Cut the current selection to the browser clipboard on the active page."; + + 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..8c019e21c --- /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 { + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardPaste extends BrowseCommand { + static override description = + "Paste clipboard text into the focused field on the active page."; + + 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..04443010f --- /dev/null +++ b/packages/cli/src/commands/clipboard/read.ts @@ -0,0 +1,24 @@ +import { BrowseCommand } from "../../base.js"; +import { + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardRead extends BrowseCommand { + static override description = + "Read text from the browser clipboard for the active page."; + + 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..fe03801c7 --- /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 { + driverCommandFlags, + runDriverCommandFromFlags, +} from "../../lib/driver/command-cli.js"; + +export default class ClipboardWrite extends BrowseCommand { + static override description = + "Write text to the browser clipboard for the active page."; + + 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/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..b45226db0 --- /dev/null +++ b/packages/cli/tests/clipboard.test.ts @@ -0,0 +1,66 @@ +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(); + }); +}); 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", ]),