commit eccc03ee7a809da4f8bd836af1db255e3de3d392 Author: Adam Ladachowski Date: Fri Feb 6 20:28:30 2026 +0100 💬 Commit message: Update 2026-02-06 20:28:30, 13 files, 1401 lines 📁 Files changed: 13 📝 Lines changed: 1401 • .gitignore • CLAUDE.md • PLAN.md • README.md • TODO.md • package-lock.json • package.json • browser.ts • cli.ts • index.ts • server.ts • types.ts • tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a090a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/node_modules/ +/public/ +/dist/ +/screenshots/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..46122c3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Development + +```bash +npm install # Install dependencies +npx playwright install webkit # Install WebKit browser +npm run build # Compile TypeScript to dist/ +npm run dev # Watch mode compilation +npm start # Run the CLI (after build) +``` + +## Architecture + +This is a TypeScript library providing headless browser automation via Playwright WebKit. It has three modes of operation: + +1. **CLI** (`src/cli.ts`) - Direct command-line usage for screenshots, clicking, typing, and querying +2. **Server** (`src/server.ts`) - HTTP server accepting JSON commands via POST requests +3. **Library** (`src/index.ts`) - Programmatic API via `ClaudeBrowser` class + +### Core Components + +- **ClaudeBrowser** (`src/browser.ts`) - Main class wrapping Playwright WebKit. Handles browser lifecycle, navigation, and all DOM interactions. Uses `executeCommand()` to process typed command objects. +- **BrowserServer** (`src/server.ts`) - HTTP server that wraps ClaudeBrowser. Accepts JSON POST requests and returns JSON responses. CORS-enabled. +- **Types** (`src/types.ts`) - Discriminated union of command types (`BrowserCommand`) and response types (`CommandResponse`). + +### Command Flow + +Commands are typed objects with a `cmd` discriminator: +```typescript +{ cmd: 'goto', url: string } +{ cmd: 'click', selector: string } +{ cmd: 'type', selector: string, text: string } +{ cmd: 'query', selector: string } +// etc. +``` + +All commands return `{ ok: true, ...data }` or `{ ok: false, error: string }`. + +## CLI Options + +Key flags: `-s ` (server mode), `-q ` (query), `-c ` (click), `-t =` (type), `-i` (interactive), `--headed` (visible browser). diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..22d7721 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,238 @@ +# Plan: Web Debugging Commands + +## Phase 1: Storage & Session Commands + +### Description +Add commands for inspecting and manipulating browser storage and cookies. These are essential for debugging authentication, session state, and client-side data persistence. + +### Steps + +#### Step 1.1: Add Cookies Command +- **Objective**: Implement get/set/clear cookies functionality +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `CookiesCommand` type with `action: 'get' | 'set' | 'clear'` + - Add optional `name`, `value`, `url` fields for set operation + - Implement `cookies()` method using `context.cookies()` and `context.addCookies()` + - Add logging with cookie icon + +#### Step 1.2: Add Storage Command +- **Objective**: Implement localStorage and sessionStorage read/write/clear +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: Step 1.1 +- **Implementation**: + - Add `StorageCommand` type with `storage: 'local' | 'session'` and `action: 'get' | 'set' | 'clear'` + - Implement via `page.evaluate()` accessing `window.localStorage` / `window.sessionStorage` + - Return all items on get, or specific key if provided + +## Phase 2: Console & Debugging Commands + +### Description +Add commands for capturing console output and debugging page state. Critical for understanding client-side errors and application behavior. + +### Steps + +#### Step 2.1: Add Console Command +- **Objective**: Capture and return console messages (log, error, warn, info) +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `ConsoleCommand` type with optional `clear: boolean` + - Store console messages in array via `page.on('console')` + - Initialize listener in `launch()` method + - Return accumulated messages with type, text, and timestamp + +#### Step 2.2: Add Metrics Command +- **Objective**: Return performance metrics and DOM statistics +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `MetricsCommand` type + - Use `page.evaluate()` to gather `performance.timing`, DOM node counts + - Return structured metrics object + +## Phase 3: Interaction Commands + +### Description +Add commands for advanced page interactions beyond click and type. Enables testing hover states, keyboard shortcuts, and form controls. + +### Steps + +#### Step 3.1: Add Scroll Command +- **Objective**: Scroll to position, element, or by delta +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `ScrollCommand` with `selector?: string`, `x?: number`, `y?: number`, `behavior?: 'smooth' | 'instant'` + - If selector provided, use `element.scrollIntoView()` + - Otherwise use `window.scrollTo()` + +#### Step 3.2: Add Hover Command +- **Objective**: Trigger hover state on element +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `HoverCommand` with `selector: string` + - Use `page.hover(selector)` + - Return success with element info + +#### Step 3.3: Add Select Command +- **Objective**: Select option(s) in dropdown/select elements +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `SelectCommand` with `selector: string`, `value: string | string[]` + - Use `page.selectOption(selector, value)` + - Return selected values + +#### Step 3.4: Add Keys Command +- **Objective**: Send keyboard shortcuts and special keys +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `KeysCommand` with `keys: string` (e.g., "Control+a", "Escape", "Enter") + - Use `page.keyboard.press()` for single keys + - Support key combinations + +## Phase 4: Network Commands + +### Description +Add commands for network inspection and manipulation. Enables debugging API calls, blocking resources, and simulating network conditions. + +### Steps + +#### Step 4.1: Add Network Logging +- **Objective**: Capture and return network requests/responses +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `NetworkCommand` with `action: 'log' | 'clear'`, optional `filter: string` + - Store requests via `page.on('request')` and `page.on('response')` + - Return array of {url, method, status, type, timing} + +#### Step 4.2: Add Block Command +- **Objective**: Block URLs or patterns (ads, analytics, etc.) +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: Step 4.1 +- **Implementation**: + - Add `BlockCommand` with `patterns: string[]` and `action: 'add' | 'remove' | 'clear'` + - Use `page.route()` to abort matching requests + - Maintain list of blocked patterns + +#### Step 4.3: Add Throttle Command +- **Objective**: Simulate slow network conditions +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `ThrottleCommand` with `preset: 'slow3g' | 'fast3g' | 'offline' | 'none'` + - Use CDP session to set network conditions via `Network.emulateNetworkConditions` + +## Phase 5: Output Commands + +### Description +Add commands for exporting page content in different formats. + +### Steps + +#### Step 5.1: Add PDF Command +- **Objective**: Save page as PDF +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `PdfCommand` with `path: string`, optional `format`, `landscape`, `margin` + - Use `page.pdf()` (WebKit supports this) + - Return path to saved file + +#### Step 5.2: Add Accessibility Command +- **Objective**: Dump accessibility tree snapshot +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `AccessibilityCommand` with optional `selector` for subtree + - Use `page.accessibility.snapshot()` + - Return tree structure + +## Phase 6: Advanced Commands + +### Description +Add commands for viewport manipulation, device emulation, and frame handling. + +### Steps + +#### Step 6.1: Add Viewport Command +- **Objective**: Resize viewport dynamically +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `ViewportCommand` with `width: number`, `height: number` + - Use `page.setViewportSize()` + - Return new dimensions + +#### Step 6.2: Add Emulate Command +- **Objective**: Emulate mobile devices +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: Step 6.1 +- **Implementation**: + - Add `EmulateCommand` with `device: string` (e.g., 'iPhone 12', 'Pixel 5') + - Use Playwright's device descriptors + - Apply viewport, userAgent, deviceScaleFactor + +#### Step 6.3: Add Frames Command +- **Objective**: List and switch to iframe contexts +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `FramesCommand` with `action: 'list' | 'switch'`, optional `selector` or `index` + - Use `page.frames()` to list, `frame.locator()` to switch context + - Track current frame for subsequent commands + +#### Step 6.4: Add Dialog Command +- **Objective**: Handle alert/confirm/prompt dialogs +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `DialogCommand` with `action: 'accept' | 'dismiss'`, optional `text` for prompt + - Set up `page.on('dialog')` handler + - Queue dialogs and respond based on command + +#### Step 6.5: Add Upload Command +- **Objective**: Fill file input elements +- **Files**: `src/types.ts`, `src/browser.ts`, `src/server.ts`, `src/index.ts` +- **Dependencies**: None +- **Implementation**: + - Add `UploadCommand` with `selector: string`, `files: string[]` + - Use `page.setInputFiles(selector, files)` + - Return success with file count + +## Phase 7: Documentation & Polish + +### Description +Update documentation and CLI help text with all new commands. + +### Steps + +#### Step 7.1: Update README +- **Objective**: Document all new commands with examples +- **Files**: `README.md` +- **Dependencies**: Phases 1-6 +- **Implementation**: + - Add command reference section + - Include curl examples for each command + - Document response formats + +#### Step 7.2: Update CLAUDE.md +- **Objective**: Update developer documentation +- **Files**: `CLAUDE.md` +- **Dependencies**: Step 7.1 +- **Implementation**: + - Update architecture section with new command flow + - Document internal logging format + +#### Step 7.3: Update CLI Help +- **Objective**: Update --help output with command list +- **Files**: `src/cli.ts` +- **Dependencies**: Phases 1-6 +- **Implementation**: + - Expand server mode help section + - Group commands by category diff --git a/README.md b/README.md new file mode 100644 index 0000000..7578181 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# @aladac/claude-browse + +Headless browser automation for Claude Code using Playwright WebKit. + +## Installation + +```bash +npm install @aladac/claude-browse +npx playwright install webkit +``` + +## CLI Usage + +```bash +# Take a screenshot +claude-browse https://example.com + +# Custom viewport and output +claude-browse -o page.png -w 1920 -h 1080 https://example.com + +# Query elements +claude-browse -q "a[href]" https://example.com +claude-browse -q "img" -j https://example.com # JSON output + +# Click and interact +claude-browse -c "button.submit" https://example.com +claude-browse -t "input[name=q]=hello" -c "button[type=submit]" https://google.com + +# Chain actions +claude-browse -c ".cookie-accept" -c "a.nav-link" -q "h1" https://example.com + +# Interactive mode (visible browser) +claude-browse -i --headed https://example.com +``` + +## Server Mode + +Start a persistent browser server that accepts commands via HTTP: + +```bash +claude-browse -s 3000 # headless +claude-browse -s 3000 --headed # visible browser +``` + +Send commands: + +```bash +# Navigate +curl -X POST localhost:3000 -d '{"cmd":"goto","url":"https://example.com"}' + +# Click +curl -X POST localhost:3000 -d '{"cmd":"click","selector":"button.submit"}' + +# Type +curl -X POST localhost:3000 -d '{"cmd":"type","selector":"#search","text":"hello"}' + +# Query elements +curl -X POST localhost:3000 -d '{"cmd":"query","selector":"a[href]"}' + +# Screenshot +curl -X POST localhost:3000 -d '{"cmd":"screenshot","path":"page.png"}' + +# Get current URL +curl -X POST localhost:3000 -d '{"cmd":"url"}' + +# Get page HTML +curl -X POST localhost:3000 -d '{"cmd":"html"}' + +# Navigation +curl -X POST localhost:3000 -d '{"cmd":"back"}' +curl -X POST localhost:3000 -d '{"cmd":"forward"}' +curl -X POST localhost:3000 -d '{"cmd":"reload"}' + +# Wait +curl -X POST localhost:3000 -d '{"cmd":"wait","ms":2000}' + +# Close server +curl -X POST localhost:3000 -d '{"cmd":"close"}' +``` + +## Programmatic Usage + +```typescript +import { ClaudeBrowser, startServer } from '@aladac/claude-browse'; + +// Direct browser control +const browser = new ClaudeBrowser({ + headless: true, + width: 1280, + height: 800, +}); + +await browser.launch(); +await browser.goto('https://example.com'); + +const elements = await browser.query('a[href]'); +console.log(elements); + +await browser.click('button.submit'); +await browser.type('#input', 'hello'); +await browser.screenshot('page.png'); + +await browser.close(); + +// Or start a server +const server = await startServer({ port: 3000, headless: false }); +// Server runs until closed via HTTP or SIGINT +``` + +## API + +### ClaudeBrowser + +- `launch()` - Launch the browser +- `close()` - Close the browser +- `goto(url)` - Navigate to URL +- `click(selector)` - Click element +- `type(selector, text)` - Type into input +- `query(selector)` - Query elements, returns attributes +- `screenshot(path?, fullPage?)` - Take screenshot +- `getUrl()` - Get current URL and title +- `getHtml(full?)` - Get page HTML +- `back()` / `forward()` / `reload()` - Navigation +- `wait(ms)` - Wait for timeout +- `newPage()` - Open new page +- `executeCommand(cmd)` - Execute a command object + +### BrowserServer + +- `start()` - Start HTTP server +- `stop()` - Stop server and close browser +- `getPort()` - Get server port + +## License + +MIT diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..1de5c0f --- /dev/null +++ b/TODO.md @@ -0,0 +1,36 @@ +# TODO: Web Debugging Commands + +## Phase 1: Storage & Session Commands +- [ ] Step 1.1: Add Cookies Command +- [ ] Step 1.2: Add Storage Command + +## Phase 2: Console & Debugging Commands +- [ ] Step 2.1: Add Console Command +- [ ] Step 2.2: Add Metrics Command + +## Phase 3: Interaction Commands +- [ ] Step 3.1: Add Scroll Command +- [ ] Step 3.2: Add Hover Command +- [ ] Step 3.3: Add Select Command +- [ ] Step 3.4: Add Keys Command + +## Phase 4: Network Commands +- [ ] Step 4.1: Add Network Logging +- [ ] Step 4.2: Add Block Command +- [ ] Step 4.3: Add Throttle Command + +## Phase 5: Output Commands +- [ ] Step 5.1: Add PDF Command +- [ ] Step 5.2: Add Accessibility Command + +## Phase 6: Advanced Commands +- [ ] Step 6.1: Add Viewport Command +- [ ] Step 6.2: Add Emulate Command +- [ ] Step 6.3: Add Frames Command +- [ ] Step 6.4: Add Dialog Command +- [ ] Step 6.5: Add Upload Command + +## Phase 7: Documentation & Polish +- [ ] Step 7.1: Update README +- [ ] Step 7.2: Update CLAUDE.md +- [ ] Step 7.3: Update CLI Help diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2610b55 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,101 @@ +{ + "name": "@aladac/claude-browse", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@aladac/claude-browse", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "playwright": "^1.41.0" + }, + "bin": { + "claude-browse": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz", + "integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2c68eb1 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "@aladac/claude-browse", + "version": "0.1.0", + "description": "Headless browser automation for Claude Code using Playwright WebKit", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "claude-browse": "dist/cli.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/cli.js", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "playwright", + "webkit", + "browser", + "automation", + "claude", + "headless" + ], + "author": "aladac", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/aladac/claude-browse" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "playwright": "^1.41.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.0" + }, + "files": [ + "dist", + "README.md" + ] +} diff --git a/src/browser.ts b/src/browser.ts new file mode 100644 index 0000000..10c892e --- /dev/null +++ b/src/browser.ts @@ -0,0 +1,208 @@ +import { webkit, Browser, BrowserContext, Page } from 'playwright'; +import { resolve } from 'node:path'; +import type { + BrowserOptions, + BrowserCommand, + CommandResponse, + ElementInfo, +} from './types.js'; + +export class ClaudeBrowser { + private browser: Browser | null = null; + private context: BrowserContext | null = null; + private page: Page | null = null; + private options: Required; + + constructor(options: BrowserOptions = {}) { + this.options = { + headless: options.headless ?? true, + width: options.width ?? 1280, + height: options.height ?? 800, + }; + } + + async launch(): Promise { + this.browser = await webkit.launch({ headless: this.options.headless }); + this.context = await this.browser.newContext({ + viewport: { + width: this.options.width, + height: this.options.height, + }, + }); + this.page = await this.context.newPage(); + } + + async close(): Promise { + if (this.browser) { + await this.browser.close(); + this.browser = null; + this.context = null; + this.page = null; + } + } + + private ensurePage(): Page { + if (!this.page) { + throw new Error('Browser not launched. Call launch() first.'); + } + return this.page; + } + + async goto(url: string): Promise<{ url: string; title: string }> { + const page = this.ensurePage(); + await page.goto(url, { waitUntil: 'networkidle' }); + return { url: page.url(), title: await page.title() }; + } + + async click(selector: string): Promise<{ url: string }> { + const page = this.ensurePage(); + await page.click(selector); + await page.waitForLoadState('networkidle').catch(() => {}); + return { url: page.url() }; + } + + async type(selector: string, text: string): Promise { + const page = this.ensurePage(); + await page.fill(selector, text); + } + + async query(selector: string): Promise { + const page = this.ensurePage(); + return page.$$eval(selector, (nodes) => + nodes.map((el) => { + const attrs: Record = {}; + for (const attr of el.attributes) { + attrs[attr.name] = attr.value; + } + return { + tag: el.tagName.toLowerCase(), + text: el.textContent?.trim().slice(0, 200) || '', + attributes: attrs, + }; + }) + ); + } + + async screenshot( + path?: string, + fullPage = false + ): Promise<{ path: string; buffer?: Buffer }> { + const page = this.ensurePage(); + const resolvedPath = resolve(path || 'screenshot.png'); + const buffer = await page.screenshot({ path: resolvedPath, fullPage }); + return { path: resolvedPath, buffer }; + } + + async getUrl(): Promise<{ url: string; title: string }> { + const page = this.ensurePage(); + return { url: page.url(), title: await page.title() }; + } + + async getHtml(full = false): Promise { + const page = this.ensurePage(); + const html = await page.content(); + return full ? html : html.slice(0, 10000); + } + + async back(): Promise<{ url: string }> { + const page = this.ensurePage(); + await page.goBack(); + return { url: page.url() }; + } + + async forward(): Promise<{ url: string }> { + const page = this.ensurePage(); + await page.goForward(); + return { url: page.url() }; + } + + async reload(): Promise<{ url: string }> { + const page = this.ensurePage(); + await page.reload(); + return { url: page.url() }; + } + + async wait(ms = 1000): Promise { + const page = this.ensurePage(); + await page.waitForTimeout(ms); + } + + async newPage(): Promise { + if (!this.context) { + throw new Error('Browser not launched. Call launch() first.'); + } + this.page = await this.context.newPage(); + } + + async eval(script: string): Promise { + const page = this.ensurePage(); + return page.evaluate(script); + } + + async executeCommand(cmd: BrowserCommand): Promise { + try { + switch (cmd.cmd) { + case 'goto': { + const result = await this.goto(cmd.url); + return { ok: true, ...result }; + } + case 'click': { + const result = await this.click(cmd.selector); + return { ok: true, ...result }; + } + case 'type': { + await this.type(cmd.selector, cmd.text); + return { ok: true }; + } + case 'query': { + const elements = await this.query(cmd.selector); + return { ok: true, count: elements.length, elements }; + } + case 'screenshot': { + const result = await this.screenshot(cmd.path, cmd.fullPage); + return { ok: true, path: result.path }; + } + case 'url': { + const result = await this.getUrl(); + return { ok: true, ...result }; + } + case 'html': { + const html = await this.getHtml(cmd.full); + return { ok: true, html }; + } + case 'back': { + const result = await this.back(); + return { ok: true, ...result }; + } + case 'forward': { + const result = await this.forward(); + return { ok: true, ...result }; + } + case 'reload': { + const result = await this.reload(); + return { ok: true, ...result }; + } + case 'wait': { + await this.wait(cmd.ms); + return { ok: true }; + } + case 'newpage': { + await this.newPage(); + return { ok: true }; + } + case 'close': { + await this.close(); + return { ok: true }; + } + case 'eval': { + const result = await this.eval(cmd.script); + return { ok: true, result }; + } + default: + return { ok: false, error: `Unknown command: ${(cmd as any).cmd}` }; + } + } catch (err) { + return { ok: false, error: (err as Error).message }; + } + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..91887bc --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,199 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; +import { resolve } from 'node:path'; +import { ClaudeBrowser } from './browser.js'; +import { startServer } from './server.js'; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + output: { type: 'string', short: 'o', default: 'screenshot.png' }, + width: { type: 'string', short: 'w', default: '1280' }, + height: { type: 'string', short: 'h', default: '800' }, + fullpage: { type: 'boolean', short: 'f', default: false }, + wait: { type: 'string', default: '2000' }, + headed: { type: 'boolean', default: false }, + interactive: { type: 'boolean', short: 'i', default: false }, + query: { type: 'string', short: 'q' }, + json: { type: 'boolean', short: 'j', default: false }, + click: { type: 'string', short: 'c', multiple: true }, + type: { type: 'string', short: 't', multiple: true }, + serve: { type: 'string', short: 's' }, + help: { type: 'boolean', default: false }, + version: { type: 'boolean', short: 'v', default: false }, + }, +}); + +const HELP = ` +Usage: claude-browse [options] + +Options: + -o, --output Output screenshot path (default: screenshot.png) + -w, --width Viewport width (default: 1280) + -h, --height Viewport height (default: 800) + -f, --fullpage Capture full page scroll + --wait Wait time after load (default: 2000) + --headed Show browser window + -i, --interactive Keep browser open for manual interaction + -q, --query Query elements by CSS selector and show attributes + -j, --json Output query results as JSON + -c, --click Click on element (can be repeated for multiple clicks) + -t, --type = Type text into input (can be repeated) + -s, --serve Start browser server on port (default: 3000) + -v, --version Show version + --help Show this help + +Examples: + claude-browse https://example.com + claude-browse -o page.png -w 1920 -h 1080 https://example.com + claude-browse -i --headed https://example.com + claude-browse -q "a[href]" https://example.com + claude-browse -q "img" -j https://example.com + claude-browse -c "button.submit" https://example.com + claude-browse -t "input[name=q]=hello" -c "button[type=submit]" https://google.com + claude-browse -c ".cookie-accept" -c "a.nav-link" -q "h1" https://example.com + +Server mode: + claude-browse -s 3000 # Start server on port 3000 + claude-browse -s 3000 --headed # Start with visible browser + + # Send commands via curl: + curl -X POST http://localhost:3000 -d '{"cmd":"goto","url":"https://example.com"}' + curl -X POST http://localhost:3000 -d '{"cmd":"click","selector":"button"}' + curl -X POST http://localhost:3000 -d '{"cmd":"type","selector":"input","text":"hello"}' + curl -X POST http://localhost:3000 -d '{"cmd":"query","selector":"a[href]"}' + curl -X POST http://localhost:3000 -d '{"cmd":"screenshot","path":"shot.png"}' + curl -X POST http://localhost:3000 -d '{"cmd":"url"}' + curl -X POST http://localhost:3000 -d '{"cmd":"html"}' + curl -X POST http://localhost:3000 -d '{"cmd":"close"}' +`; + +async function main(): Promise { + if (values.version) { + console.log('claude-browse 0.1.0'); + process.exit(0); + } + + // Server mode + if (values.serve) { + const port = parseInt(values.serve) || 3000; + const server = await startServer({ + port, + headless: !values.headed, + width: parseInt(values.width as string), + height: parseInt(values.height as string), + }); + + process.on('SIGINT', async () => { + console.log('\nShutting down...'); + await server.stop(); + process.exit(0); + }); + + // Keep alive + await new Promise(() => {}); + return; + } + + if (values.help || positionals.length === 0) { + console.log(HELP); + process.exit(0); + } + + const url = positionals[0]; + const outputPath = resolve(values.output as string); + + const browser = new ClaudeBrowser({ + headless: !values.headed, + width: parseInt(values.width as string), + height: parseInt(values.height as string), + }); + + await browser.launch(); + + console.log(`Navigating to: ${url}`); + await browser.goto(url); + await browser.wait(parseInt(values.wait as string)); + + // Process type actions (before clicks, typically for form filling) + const typeActions = values.type as string[] | undefined; + if (typeActions?.length) { + for (const typeAction of typeActions) { + const eqIndex = typeAction.indexOf('='); + if (eqIndex === -1) { + console.error( + `Invalid --type format: "${typeAction}" (expected selector=text)` + ); + continue; + } + const selector = typeAction.slice(0, eqIndex); + const text = typeAction.slice(eqIndex + 1); + console.log(`Typing "${text}" into: ${selector}`); + await browser.type(selector, text); + } + } + + // Process click actions + const clickActions = values.click as string[] | undefined; + if (clickActions?.length) { + for (const selector of clickActions) { + console.log(`Clicking: ${selector}`); + await browser.click(selector); + await browser.wait(500); + } + const { url: currentUrl } = await browser.getUrl(); + console.log(`Current URL: ${currentUrl}`); + } + + // Query elements if -q/--query is specified + if (values.query) { + const elements = await browser.query(values.query); + + if (values.json) { + console.log(JSON.stringify(elements, null, 2)); + } else { + console.log( + `Found ${elements.length} element(s) matching "${values.query}":\n` + ); + elements.forEach((el, i) => { + console.log(`[${i + 1}] <${el.tag}>`); + for (const [name, value] of Object.entries(el.attributes)) { + console.log(` ${name}="${value}"`); + } + if (el.text) { + console.log( + ` text: "${el.text.slice(0, 100)}${el.text.length > 100 ? '...' : ''}"` + ); + } + console.log(); + }); + } + + await browser.close(); + process.exit(0); + } + + if (!values.interactive) { + console.log(`Saving screenshot to: ${outputPath}`); + await browser.screenshot(outputPath, values.fullpage); + await browser.close(); + console.log('Done!'); + } else { + console.log('Interactive mode - browser will stay open.'); + console.log('Press Ctrl+C to exit.'); + + process.on('SIGINT', async () => { + console.log('\nClosing browser...'); + await browser.close(); + process.exit(0); + }); + + // Keep alive + await new Promise(() => {}); + } +} + +main().catch((err) => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6523312 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,25 @@ +export { ClaudeBrowser } from './browser.js'; +export { BrowserServer, startServer } from './server.js'; +export type { + BrowserOptions, + BrowserCommand, + CommandResponse, + ElementInfo, + SuccessResponse, + ErrorResponse, + GotoCommand, + ClickCommand, + TypeCommand, + QueryCommand, + ScreenshotCommand, + UrlCommand, + HtmlCommand, + BackCommand, + ForwardCommand, + ReloadCommand, + WaitCommand, + NewPageCommand, + CloseCommand, + EvalCommand, +} from './types.js'; +export type { ServerOptions } from './server.js'; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..3e56f03 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,233 @@ +import { createServer, Server, IncomingMessage, ServerResponse } from 'node:http'; +import { ClaudeBrowser } from './browser.js'; +import type { BrowserCommand, BrowserOptions, CommandResponse } from './types.js'; + +export interface ServerOptions extends BrowserOptions { + port?: number; +} + +// ANSI colors +const c = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + red: '\x1b[31m', + gray: '\x1b[90m', + white: '\x1b[37m', + bgBlue: '\x1b[44m', + bgGreen: '\x1b[42m', + bgYellow: '\x1b[43m', + bgMagenta: '\x1b[45m', +}; + +function timestamp(): string { + return c.gray + '[' + new Date().toISOString() + ']' + c.reset; +} + +function logCommand(cmd: BrowserCommand): void { + const ts = timestamp(); + switch (cmd.cmd) { + case 'goto': + console.log(`${ts} ${c.cyan}${c.bold}→${c.reset} ${c.cyan}GOTO${c.reset} ${c.white}${cmd.url}${c.reset}`); + break; + case 'click': + console.log(`${ts} ${c.yellow}${c.bold}◉${c.reset} ${c.yellow}CLICK${c.reset} ${c.white}${cmd.selector}${c.reset}`); + break; + case 'type': + console.log(`${ts} ${c.magenta}${c.bold}⌨${c.reset} ${c.magenta}TYPE${c.reset} ${c.white}${cmd.selector}${c.reset} ${c.dim}="${cmd.text}"${c.reset}`); + break; + case 'query': + console.log(`${ts} ${c.blue}${c.bold}?${c.reset} ${c.blue}QUERY${c.reset} ${c.white}${cmd.selector}${c.reset}`); + break; + case 'screenshot': + console.log(`${ts} ${c.green}${c.bold}📷${c.reset} ${c.green}SCREENSHOT${c.reset} ${c.dim}${cmd.path || 'screenshot.png'}${c.reset}`); + break; + case 'url': + console.log(`${ts} ${c.cyan}${c.bold}🔗${c.reset} ${c.cyan}URL${c.reset}`); + break; + case 'html': + console.log(`${ts} ${c.blue}${c.bold}<>${c.reset} ${c.blue}HTML${c.reset} ${cmd.full ? c.dim + '(full)' + c.reset : ''}`); + break; + case 'back': + console.log(`${ts} ${c.yellow}${c.bold}←${c.reset} ${c.yellow}BACK${c.reset}`); + break; + case 'forward': + console.log(`${ts} ${c.yellow}${c.bold}→${c.reset} ${c.yellow}FORWARD${c.reset}`); + break; + case 'reload': + console.log(`${ts} ${c.yellow}${c.bold}↻${c.reset} ${c.yellow}RELOAD${c.reset}`); + break; + case 'wait': + console.log(`${ts} ${c.gray}${c.bold}⏳${c.reset} ${c.gray}WAIT${c.reset} ${c.dim}${cmd.ms || 1000}ms${c.reset}`); + break; + case 'newpage': + console.log(`${ts} ${c.green}${c.bold}+${c.reset} ${c.green}NEW PAGE${c.reset}`); + break; + case 'close': + console.log(`${ts} ${c.red}${c.bold}✕${c.reset} ${c.red}CLOSE${c.reset}`); + break; + case 'eval': + const preview = cmd.script.length > 50 ? cmd.script.slice(0, 50) + '...' : cmd.script; + console.log(`${ts} ${c.magenta}${c.bold}⚡${c.reset} ${c.magenta}EVAL${c.reset} ${c.dim}${preview}${c.reset}`); + break; + } +} + +function logResult(cmd: BrowserCommand, result: CommandResponse): void { + const ts = timestamp(); + if (!result.ok) { + console.log(`${ts} ${c.red}✗ Error: ${result.error}${c.reset}`); + return; + } + + switch (cmd.cmd) { + case 'goto': + if ('title' in result && result.title) { + console.log(`${ts} ${c.green}✓${c.reset} ${c.dim}${result.title}${c.reset}`); + } + break; + case 'click': + if ('url' in result) { + console.log(`${ts} ${c.green}✓${c.reset} ${c.dim}→ ${result.url}${c.reset}`); + } + break; + case 'query': + if ('count' in result) { + console.log(`${ts} ${c.green}✓${c.reset} ${c.dim}Found ${result.count} element(s)${c.reset}`); + } + break; + case 'screenshot': + if ('path' in result) { + console.log(`${ts} ${c.green}✓${c.reset} ${c.dim}Saved to ${result.path}${c.reset}`); + } + break; + case 'url': + if ('url' in result) { + console.log(`${ts} ${c.green}✓${c.reset} ${c.dim}${result.url}${c.reset}`); + } + break; + case 'html': + if ('html' in result) { + console.log(`${ts} ${c.green}✓${c.reset} ${c.dim}${result.html?.length || 0} chars${c.reset}`); + } + break; + case 'eval': + if ('result' in result) { + const json = JSON.stringify(result.result); + const preview = json && json.length > 80 ? json.slice(0, 80) + '...' : json; + console.log(`${ts} ${c.green}✓${c.reset} ${c.dim}${preview}${c.reset}`); + } + break; + default: + console.log(`${ts} ${c.green}✓${c.reset}`); + } +} + +export class BrowserServer { + private browser: ClaudeBrowser; + private server: Server | null = null; + private port: number; + + constructor(options: ServerOptions = {}) { + this.browser = new ClaudeBrowser(options); + this.port = options.port ?? 3000; + } + + async start(): Promise { + await this.browser.launch(); + + this.server = createServer( + async (req: IncomingMessage, res: ServerResponse) => { + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'POST only' })); + return; + } + + let body = ''; + for await (const chunk of req) { + body += chunk; + } + + try { + const cmd: BrowserCommand = JSON.parse(body); + logCommand(cmd); + + // Handle close specially to shut down server + if (cmd.cmd === 'close') { + const result = { ok: true as const }; + logResult(cmd, result); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + await this.stop(); + process.exit(0); + } + + const result = await this.browser.executeCommand(cmd); + logResult(cmd, result); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (err) { + const errorResult = { ok: false as const, error: (err as Error).message }; + console.log(`${timestamp()} ${c.red}✗ ${errorResult.error}${c.reset}`); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(errorResult)); + } + } + ); + + return new Promise((resolve) => { + this.server!.listen(this.port, () => { + console.log(); + console.log(`${c.bold}${c.cyan} 🌐 Claude Browse Server${c.reset}`); + console.log(`${c.dim} ─────────────────────────${c.reset}`); + console.log(` ${c.green}▶${c.reset} Listening on ${c.bold}http://localhost:${this.port}${c.reset}`); + console.log(); + console.log(`${c.dim} Commands:${c.reset}`); + console.log(` ${c.cyan}goto${c.reset} ${c.yellow}click${c.reset} ${c.magenta}type${c.reset} ${c.blue}query${c.reset} ${c.green}screenshot${c.reset}`); + console.log(` ${c.cyan}url${c.reset} ${c.blue}html${c.reset} ${c.yellow}back${c.reset} ${c.yellow}forward${c.reset} ${c.yellow}reload${c.reset} ${c.gray}wait${c.reset} ${c.red}close${c.reset}`); + console.log(); + console.log(`${c.dim} Example:${c.reset}`); + console.log(` ${c.gray}curl -X POST localhost:${this.port} -d '{"cmd":"goto","url":"https://example.com"}'${c.reset}`); + console.log(); + resolve(); + }); + }); + } + + async stop(): Promise { + console.log(`\n${c.dim} Shutting down...${c.reset}`); + if (this.server) { + this.server.close(); + this.server = null; + } + await this.browser.close(); + console.log(` ${c.green}✓${c.reset} Browser closed\n`); + } + + getPort(): number { + return this.port; + } +} + +export async function startServer(options: ServerOptions = {}): Promise { + const server = new BrowserServer(options); + await server.start(); + return server; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..1ad2499 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,113 @@ +export interface BrowserOptions { + headless?: boolean; + width?: number; + height?: number; +} + +export interface ElementInfo { + tag: string; + text: string; + attributes: Record; +} + +// Command types +export interface GotoCommand { + cmd: 'goto'; + url: string; +} + +export interface ClickCommand { + cmd: 'click'; + selector: string; +} + +export interface TypeCommand { + cmd: 'type'; + selector: string; + text: string; +} + +export interface QueryCommand { + cmd: 'query'; + selector: string; +} + +export interface ScreenshotCommand { + cmd: 'screenshot'; + path?: string; + fullPage?: boolean; +} + +export interface UrlCommand { + cmd: 'url'; +} + +export interface HtmlCommand { + cmd: 'html'; + full?: boolean; +} + +export interface BackCommand { + cmd: 'back'; +} + +export interface ForwardCommand { + cmd: 'forward'; +} + +export interface ReloadCommand { + cmd: 'reload'; +} + +export interface WaitCommand { + cmd: 'wait'; + ms?: number; +} + +export interface NewPageCommand { + cmd: 'newpage'; +} + +export interface CloseCommand { + cmd: 'close'; +} + +export interface EvalCommand { + cmd: 'eval'; + script: string; +} + +export type BrowserCommand = + | GotoCommand + | ClickCommand + | TypeCommand + | QueryCommand + | ScreenshotCommand + | UrlCommand + | HtmlCommand + | BackCommand + | ForwardCommand + | ReloadCommand + | WaitCommand + | NewPageCommand + | CloseCommand + | EvalCommand; + +// Response types +export interface SuccessResponse { + ok: true; + url?: string; + title?: string; + path?: string; + html?: string; + count?: number; + elements?: ElementInfo[]; + result?: unknown; +} + +export interface ErrorResponse { + ok: false; + error: string; +} + +export type CommandResponse = SuccessResponse | ErrorResponse; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..012e5c8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}