From 3ece3bd3b8e5e64b547f1a0e738fec23ec7faec4 Mon Sep 17 00:00:00 2001 From: Adam Ladachowski Date: Fri, 6 Feb 2026 20:32:19 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=AC=20Commit=20message:=20Update=20202?= =?UTF-8?q?6-02-06=2020:32:18,=207=20files,=20781=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 📁 Files changed: 7 📝 Lines changed: 781 • CLAUDE.md • biome.json • package-lock.json • package.json • browser.ts • cli.ts • server.ts --- CLAUDE.md | 31 ++++- biome.json | 37 ++++++ package-lock.json | 165 +++++++++++++++++++++++++ package.json | 8 ++ src/browser.ts | 20 ++- src/cli.ts | 235 +++++++++++++++++++----------------- src/server.ts | 301 +++++++++++++++++++++++++++------------------- 7 files changed, 546 insertions(+), 251 deletions(-) create mode 100644 biome.json diff --git a/CLAUDE.md b/CLAUDE.md index 46122c3..e6c9015 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,13 +5,36 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build & Development ```bash -npm install # Install dependencies +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) +npm run build # Compile TypeScript to dist/ +npm run dev # Watch mode compilation +npm start # Run the CLI (after build) ``` +## Lint, Format & Typecheck + +```bash +npm run check # Run all checks (lint + format) +npm run fix # Fix all auto-fixable issues +npm run lint # Lint only +npm run lint:fix # Lint with autofix +npm run format # Check formatting +npm run format:fix # Fix formatting +npm run typecheck # TypeScript type checking +``` + +## Code Style + +- **Short methods**: Keep methods under 20 lines. Extract logic into focused helper methods. +- **Single responsibility**: Each class handles one concern. Split large classes by domain. +- **Class structure**: + - `ClaudeBrowser` - browser lifecycle and page interactions + - `BrowserServer` - HTTP server and request handling + - `Logger` (planned) - colored console output +- **Naming**: Commands use `verbNoun` pattern (e.g., `getCookies`, `setStorage`). +- **Types**: All commands in discriminated union. Add new commands to `BrowserCommand` type. + ## Architecture This is a TypeScript library providing headless browser automation via Playwright WebKit. It has three modes of operation: diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..16b6fdb --- /dev/null +++ b/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "warn", + "options": { "maxAllowedComplexity": 10 } + } + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "es5" + } + }, + "files": { + "include": ["src/**/*.ts"], + "ignore": ["dist", "node_modules"] + } +} diff --git a/package-lock.json b/package-lock.json index 2610b55..02ea7f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "claude-browse": "dist/cli.js" }, "devDependencies": { + "@biomejs/biome": "^1.9.0", "@types/node": "^20.11.0", "typescript": "^5.3.0" }, @@ -22,6 +23,170 @@ "node": ">=18" } }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@types/node": { "version": "20.19.32", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz", diff --git a/package.json b/package.json index 2c68eb1..98455b0 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,13 @@ "build": "tsc", "dev": "tsc --watch", "start": "node dist/cli.js", + "lint": "biome lint src/", + "lint:fix": "biome lint --write src/", + "format": "biome format src/", + "format:fix": "biome format --write src/", + "check": "biome check src/", + "fix": "biome check --write src/", + "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build" }, "keywords": [ @@ -35,6 +42,7 @@ "playwright": "^1.41.0" }, "devDependencies": { + "@biomejs/biome": "^1.9.0", "@types/node": "^20.11.0", "typescript": "^5.3.0" }, diff --git a/src/browser.ts b/src/browser.ts index 10c892e..66cd2f8 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1,11 +1,6 @@ -import { webkit, Browser, BrowserContext, Page } from 'playwright'; import { resolve } from 'node:path'; -import type { - BrowserOptions, - BrowserCommand, - CommandResponse, - ElementInfo, -} from './types.js'; +import { type Browser, type BrowserContext, type Page, webkit } from 'playwright'; +import type { BrowserCommand, BrowserOptions, CommandResponse, ElementInfo } from './types.js'; export class ClaudeBrowser { private browser: Browser | null = null; @@ -83,10 +78,7 @@ export class ClaudeBrowser { ); } - async screenshot( - path?: string, - fullPage = false - ): Promise<{ path: string; buffer?: Buffer }> { + 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 }); @@ -198,8 +190,10 @@ export class ClaudeBrowser { const result = await this.eval(cmd.script); return { ok: true, result }; } - default: - return { ok: false, error: `Unknown command: ${(cmd as any).cmd}` }; + default: { + const _exhaustive: never = cmd; + return { ok: false, error: `Unknown command: ${(_exhaustive as { cmd: string }).cmd}` }; + } } } catch (err) { return { ok: false, error: (err as Error).message }; diff --git a/src/cli.ts b/src/cli.ts index 91887bc..71dd51d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node -import { parseArgs } from 'node:util'; import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; import { ClaudeBrowser } from './browser.js'; import { startServer } from './server.js'; +import type { ElementInfo } from './types.js'; const { values, positionals } = parseArgs({ allowPositionals: true, @@ -68,30 +69,136 @@ Server mode: curl -X POST http://localhost:3000 -d '{"cmd":"close"}' `; +function getViewportConfig() { + return { + headless: !values.headed, + width: Number.parseInt(values.width as string), + height: Number.parseInt(values.height as string), + }; +} + +async function runServerMode(): Promise { + const port = Number.parseInt(values.serve as string) || 3000; + const server = await startServer({ port, ...getViewportConfig() }); + + process.on('SIGINT', async () => { + console.log('\nShutting down...'); + await server.stop(); + process.exit(0); + }); + + await new Promise(() => {}); +} + +async function processTypeActions(browser: ClaudeBrowser): Promise { + const typeActions = values.type as string[] | undefined; + if (!typeActions?.length) return; + + 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); + } +} + +async function processClickActions(browser: ClaudeBrowser): Promise { + const clickActions = values.click as string[] | undefined; + if (!clickActions?.length) return; + + 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}`); +} + +function printElement(el: ElementInfo, index: number): void { + console.log(`[${index + 1}] <${el.tag}>`); + for (const [name, value] of Object.entries(el.attributes)) { + console.log(` ${name}="${value}"`); + } + if (el.text) { + const truncated = el.text.length > 100 ? `${el.text.slice(0, 100)}...` : el.text; + console.log(` text: "${truncated}"`); + } + console.log(); +} + +async function runQueryMode(browser: ClaudeBrowser): Promise { + const elements = await browser.query(values.query as string); + + if (values.json) { + console.log(JSON.stringify(elements, null, 2)); + } else { + console.log(`Found ${elements.length} element(s) matching "${values.query}":\n`); + elements.forEach(printElement); + } + + await browser.close(); + process.exit(0); +} + +async function runInteractiveMode(browser: ClaudeBrowser): Promise { + 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); + }); + + await new Promise(() => {}); +} + +async function runScreenshotMode(browser: ClaudeBrowser): Promise { + const outputPath = resolve(values.output as string); + console.log(`Saving screenshot to: ${outputPath}`); + await browser.screenshot(outputPath, values.fullpage); + await browser.close(); + console.log('Done!'); +} + +async function runBrowserMode(): Promise { + const url = positionals[0]; + const browser = new ClaudeBrowser(getViewportConfig()); + + await browser.launch(); + console.log(`Navigating to: ${url}`); + await browser.goto(url); + await browser.wait(Number.parseInt(values.wait as string)); + + await processTypeActions(browser); + await processClickActions(browser); + + if (values.query) { + await runQueryMode(browser); + return; + } + + if (values.interactive) { + await runInteractiveMode(browser); + } else { + await runScreenshotMode(browser); + } +} + 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(() => {}); + await runServerMode(); return; } @@ -100,97 +207,7 @@ async function main(): Promise { 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(() => {}); - } + await runBrowserMode(); } main().catch((err) => { diff --git a/src/server.ts b/src/server.ts index 3e56f03..f593d0b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import { createServer, Server, IncomingMessage, ServerResponse } from 'node:http'; +import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http'; import { ClaudeBrowser } from './browser.js'; import type { BrowserCommand, BrowserOptions, CommandResponse } from './types.js'; @@ -19,115 +19,175 @@ const c = { 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; + return `${c.gray}[${new Date().toISOString()}]${c.reset}`; +} + +function logOk(ts: string, msg?: string): void { + const suffix = msg ? ` ${c.dim}${msg}${c.reset}` : ''; + console.log(`${ts} ${c.green}✓${c.reset}${suffix}`); +} + +function logError(ts: string, error: string): void { + console.log(`${ts} ${c.red}✗ Error: ${error}${c.reset}`); +} + +function logCmd(ts: string, icon: string, color: string, name: string, detail?: string): void { + const suffix = detail ? ` ${detail}` : ''; + console.log(`${ts} ${color}${c.bold}${icon}${c.reset} ${color}${name}${c.reset}${suffix}`); } 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}`); + logCmd(ts, '→', c.cyan, 'GOTO', `${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}`); + logCmd(ts, '◉', c.yellow, 'CLICK', `${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}`); + logCmd( + ts, + '⌨', + c.magenta, + 'TYPE', + `${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}`); + logCmd(ts, '?', c.blue, 'QUERY', `${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}`); + logCmd(ts, '📷', c.green, 'SCREENSHOT', `${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}`); + logCmd(ts, '🔗', c.cyan, 'URL'); break; case 'html': - console.log(`${ts} ${c.blue}${c.bold}<>${c.reset} ${c.blue}HTML${c.reset} ${cmd.full ? c.dim + '(full)' + c.reset : ''}`); + logCmd(ts, '<>', c.blue, 'HTML', cmd.full ? `${c.dim}(full)${c.reset}` : undefined); break; case 'back': - console.log(`${ts} ${c.yellow}${c.bold}←${c.reset} ${c.yellow}BACK${c.reset}`); + logCmd(ts, '←', c.yellow, 'BACK'); break; case 'forward': - console.log(`${ts} ${c.yellow}${c.bold}→${c.reset} ${c.yellow}FORWARD${c.reset}`); + logCmd(ts, '→', c.yellow, 'FORWARD'); break; case 'reload': - console.log(`${ts} ${c.yellow}${c.bold}↻${c.reset} ${c.yellow}RELOAD${c.reset}`); + logCmd(ts, '↻', c.yellow, 'RELOAD'); 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}`); + logCmd(ts, '⏳', c.gray, 'WAIT', `${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}`); + logCmd(ts, '+', c.green, 'NEW PAGE'); break; case 'close': - console.log(`${ts} ${c.red}${c.bold}✕${c.reset} ${c.red}CLOSE${c.reset}`); + logCmd(ts, '✕', c.red, 'CLOSE'); 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}`); + case 'eval': { + const preview = cmd.script.length > 50 ? `${cmd.script.slice(0, 50)}...` : cmd.script; + logCmd(ts, '⚡', c.magenta, 'EVAL', `${c.dim}${preview}${c.reset}`); break; + } } } +function truncate(str: string, max: number): string { + return str.length > max ? `${str.slice(0, max)}...` : str; +} + +function logResultGoto(ts: string, result: CommandResponse): void { + if (result.ok && 'title' in result && result.title) { + logOk(ts, result.title); + } +} + +function logResultClick(ts: string, result: CommandResponse): void { + if (result.ok && 'url' in result) { + logOk(ts, `→ ${result.url}`); + } +} + +function logResultQuery(ts: string, result: CommandResponse): void { + if (result.ok && 'count' in result) { + logOk(ts, `Found ${result.count} element(s)`); + } +} + +function logResultScreenshot(ts: string, result: CommandResponse): void { + if (result.ok && 'path' in result) { + logOk(ts, `Saved to ${result.path}`); + } +} + +function logResultUrl(ts: string, result: CommandResponse): void { + if (result.ok && 'url' in result) { + logOk(ts, result.url); + } +} + +function logResultHtml(ts: string, result: CommandResponse): void { + if (result.ok && 'html' in result) { + logOk(ts, `${result.html?.length || 0} chars`); + } +} + +function logResultEval(ts: string, result: CommandResponse): void { + if (result.ok && 'result' in result) { + const json = JSON.stringify(result.result); + logOk(ts, truncate(json, 80)); + } +} + +const resultLoggers: Record void> = { + goto: logResultGoto, + click: logResultClick, + query: logResultQuery, + screenshot: logResultScreenshot, + url: logResultUrl, + html: logResultHtml, + eval: logResultEval, +}; + function logResult(cmd: BrowserCommand, result: CommandResponse): void { const ts = timestamp(); if (!result.ok) { - console.log(`${ts} ${c.red}✗ Error: ${result.error}${c.reset}`); + logError(ts, result.error); 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}`); + const logger = resultLoggers[cmd.cmd]; + if (logger) { + logger(ts, result); + } else { + logOk(ts); } } +function printBanner(port: number): void { + 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:${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:${port} -d '{"cmd":"goto","url":"https://example.com"}'${c.reset}` + ); + console.log(); +} + export class BrowserServer { private browser: ClaudeBrowser; private server: Server | null = null; @@ -138,74 +198,65 @@ export class BrowserServer { this.port = options.port ?? 3000; } + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + 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; + } + + await this.executeRequest(body, res); + } + + private async executeRequest(body: string, res: ServerResponse): Promise { + try { + const cmd: BrowserCommand = JSON.parse(body); + logCommand(cmd); + + 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 }; + logError(timestamp(), errorResult.error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(errorResult)); + } + } + 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)); - } - } - ); + this.server = createServer((req, res) => this.handleRequest(req, res)); 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(); + printBanner(this.port); resolve(); }); });