💬 Commit message: Update 2026-02-06 20:32:18, 7 files, 781 lines

📁 Files changed: 7
📝 Lines changed: 781

  • CLAUDE.md
  • biome.json
  • package-lock.json
  • package.json
  • browser.ts
  • cli.ts
  • server.ts
This commit is contained in:
Adam Ladachowski
2026-02-06 20:32:19 +01:00
parent eccc03ee7a
commit 3ece3bd3b8
7 changed files with 546 additions and 251 deletions
+7 -13
View File
@@ -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 };
+126 -109
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<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(() => {});
await runServerMode();
return;
}
@@ -100,97 +207,7 @@ async function main(): Promise<void> {
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) => {
+176 -125
View File
@@ -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<string, (ts: string, result: CommandResponse) => 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<void> {
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<void> {
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<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));
}
}
);
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();
});
});