💬 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
This commit is contained in:
Adam Ladachowski
2026-02-06 20:28:30 +01:00
commit eccc03ee7a
13 changed files with 1401 additions and 0 deletions
+208
View File
@@ -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<BrowserOptions>;
constructor(options: BrowserOptions = {}) {
this.options = {
headless: options.headless ?? true,
width: options.width ?? 1280,
height: options.height ?? 800,
};
}
async launch(): Promise<void> {
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<void> {
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<void> {
const page = this.ensurePage();
await page.fill(selector, text);
}
async query(selector: string): Promise<ElementInfo[]> {
const page = this.ensurePage();
return page.$$eval(selector, (nodes) =>
nodes.map((el) => {
const attrs: Record<string, string> = {};
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<string> {
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<void> {
const page = this.ensurePage();
await page.waitForTimeout(ms);
}
async newPage(): Promise<void> {
if (!this.context) {
throw new Error('Browser not launched. Call launch() first.');
}
this.page = await this.context.newPage();
}
async eval(script: string): Promise<unknown> {
const page = this.ensurePage();
return page.evaluate(script);
}
async executeCommand(cmd: BrowserCommand): Promise<CommandResponse> {
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 };
}
}
}
+199
View File
@@ -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] <url>
Options:
-o, --output <file> Output screenshot path (default: screenshot.png)
-w, --width <px> Viewport width (default: 1280)
-h, --height <px> Viewport height (default: 800)
-f, --fullpage Capture full page scroll
--wait <ms> Wait time after load (default: 2000)
--headed Show browser window
-i, --interactive Keep browser open for manual interaction
-q, --query <selector> Query elements by CSS selector and show attributes
-j, --json Output query results as JSON
-c, --click <selector> Click on element (can be repeated for multiple clicks)
-t, --type <sel>=<text> Type text into input (can be repeated)
-s, --serve <port> 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<void> {
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);
});
+25
View File
@@ -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';
+233
View File
@@ -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<void> {
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<void> {
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<BrowserServer> {
const server = new BrowserServer(options);
await server.start();
return server;
}
+113
View File
@@ -0,0 +1,113 @@
export interface BrowserOptions {
headless?: boolean;
width?: number;
height?: number;
}
export interface ElementInfo {
tag: string;
text: string;
attributes: Record<string, string>;
}
// 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;