💬 Commit message: Update 2026-02-06 21:32:05, 5 files, 447 lines

📁 Files changed: 5
📝 Lines changed: 447

  • settings.local.json
  • cli.ts
  • logger.ts
  • mcp.ts
  • server.ts
This commit is contained in:
Adam Ladachowski
2026-02-06 21:32:05 +01:00
parent 603e8ca39a
commit 2736a4b467
5 changed files with 272 additions and 175 deletions
+18 -20
View File
@@ -19,7 +19,6 @@ const { values, positionals } = parseArgs({
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 },
},
@@ -40,7 +39,6 @@ Options:
-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
@@ -54,19 +52,19 @@ Examples:
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
Server mode (default):
claude-browse # Start server on port 13373
claude-browse --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"}'
curl -X POST http://localhost:13373 -d '{"cmd":"goto","url":"https://example.com"}'
curl -X POST http://localhost:13373 -d '{"cmd":"click","selector":"button"}'
curl -X POST http://localhost:13373 -d '{"cmd":"type","selector":"input","text":"hello"}'
curl -X POST http://localhost:13373 -d '{"cmd":"query","selector":"a[href]"}'
curl -X POST http://localhost:13373 -d '{"cmd":"screenshot","path":"shot.png"}'
curl -X POST http://localhost:13373 -d '{"cmd":"url"}'
curl -X POST http://localhost:13373 -d '{"cmd":"html"}'
curl -X POST http://localhost:13373 -d '{"cmd":"close"}'
`;
function getViewportConfig() {
@@ -78,7 +76,7 @@ function getViewportConfig() {
}
async function runServerMode(): Promise<void> {
const port = Number.parseInt(values.serve as string) || 3000;
const port = 13373;
const server = await startServer({ port, ...getViewportConfig() });
process.on('SIGINT', async () => {
@@ -197,16 +195,16 @@ async function main(): Promise<void> {
process.exit(0);
}
if (values.serve) {
await runServerMode();
return;
}
if (values.help || positionals.length === 0) {
if (values.help) {
console.log(HELP);
process.exit(0);
}
if (positionals.length === 0) {
await runServerMode();
return;
}
await runBrowserMode();
}
+137
View File
@@ -0,0 +1,137 @@
import chalk from 'chalk';
import logSymbols from 'log-symbols';
// Icons for commands
export const icons: Record<string, string> = {
goto: '→',
click: '◉',
type: '⌨',
query: '?',
screenshot: '📷',
url: '🔗',
html: '<>',
back: '←',
forward: '→',
reload: '↻',
wait: '⏳',
newpage: '+',
close: '✕',
eval: '⚡',
};
// Colors for command types
export const cmdColor: Record<string, (s: string) => string> = {
goto: chalk.cyan,
click: chalk.yellow,
type: chalk.magenta,
query: chalk.blue,
screenshot: chalk.green,
url: chalk.cyan,
html: chalk.blue,
back: chalk.yellow,
forward: chalk.yellow,
reload: chalk.yellow,
wait: chalk.gray,
newpage: chalk.green,
close: chalk.red,
eval: chalk.magenta,
};
export function ts(): string {
return chalk.gray(`[${new Date().toISOString()}]`);
}
export function truncate(str: string, max: number): string {
return str.length > max ? `${str.slice(0, max)}...` : str;
}
export interface CommandLike {
cmd: string;
url?: string;
selector?: string;
text?: string;
path?: string;
full?: boolean;
ms?: number;
script?: string;
}
export function getCommandDetail(cmd: CommandLike): string | undefined {
switch (cmd.cmd) {
case 'goto':
return chalk.white(cmd.url);
case 'click':
case 'query':
return chalk.white(cmd.selector);
case 'type':
return `${chalk.white(cmd.selector)} ${chalk.dim(`="${cmd.text}"`)}`;
case 'screenshot':
return chalk.dim(cmd.path || 'screenshot.png');
case 'html':
return cmd.full ? chalk.dim('(full)') : undefined;
case 'wait':
return chalk.dim(`${cmd.ms || 1000}ms`);
case 'eval':
return chalk.dim(truncate(cmd.script || '', 50));
default:
return undefined;
}
}
export function formatCommand(cmd: CommandLike): string {
const color = cmdColor[cmd.cmd] || chalk.white;
const icon = icons[cmd.cmd] || '•';
const detail = getCommandDetail(cmd);
const suffix = detail ? ` ${detail}` : '';
return `${ts()} ${chalk.bold(color(icon))} ${color(cmd.cmd.toUpperCase())}${suffix}`;
}
export interface ResultLike {
ok: boolean;
error?: string;
title?: string;
url?: string;
count?: number;
path?: string;
html?: string;
result?: unknown;
}
const resultFormatters: Record<string, (r: ResultLike) => string | undefined> = {
goto: (r) => r.title,
click: (r) => (r.url ? `${r.url}` : undefined),
query: (r) => (r.count !== undefined ? `Found ${r.count} element(s)` : undefined),
screenshot: (r) => (r.path ? `Saved to ${r.path}` : undefined),
url: (r) => r.url,
html: (r) => (r.html !== undefined ? `${r.html.length} chars` : undefined),
eval: (r) => (r.result !== undefined ? truncate(JSON.stringify(r.result), 80) : undefined),
};
export function formatResult(cmd: CommandLike, result: ResultLike): string {
if (!result.ok) {
return `${ts()} ${logSymbols.error} ${chalk.red(result.error)}`;
}
const formatter = resultFormatters[cmd.cmd];
const msg = formatter ? formatter(result) : undefined;
const suffix = msg ? ` ${chalk.dim(msg)}` : '';
return `${ts()} ${logSymbols.success}${suffix}`;
}
export type LogFn = (msg: string) => void;
export function createLogger(logFn: LogFn = console.log) {
return {
command(cmd: CommandLike): void {
logFn(formatCommand(cmd));
},
result(cmd: CommandLike, result: ResultLike): void {
logFn(formatResult(cmd, result));
},
};
}
// Default logger to stdout
export const logger = createLogger();
// Logger to stderr (for MCP)
export const stderrLogger = createLogger((msg) => process.stderr.write(`${msg}\n`));
+105 -48
View File
@@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { ClaudeBrowser } from './browser.js';
import { type CommandLike, type ResultLike, stderrLogger as log } from './logger.js';
const browser = new ClaudeBrowser({ headless: true, width: 1280, height: 800 });
let launched = false;
@@ -14,52 +15,103 @@ async function ensureLaunched(): Promise<void> {
}
}
type ToolResult = { content: [{ type: 'text'; text: string }] };
function textResult(text: string): ToolResult {
return { content: [{ type: 'text' as const, text }] };
}
function withLogging<T extends Record<string, unknown>>(
cmd: string,
fn: (args: T) => Promise<ToolResult>
): (args: T) => Promise<ToolResult> {
return async (args: T) => {
const cmdLike: CommandLike = { cmd, ...args };
log.command(cmdLike);
try {
const result = await fn(args);
const parsed = JSON.parse(result.content[0]?.text || '{}');
const resultLike: ResultLike = { ok: true, ...parsed };
log.result(cmdLike, resultLike);
return result;
} catch (err) {
log.result(cmdLike, { ok: false, error: (err as Error).message });
throw err;
}
};
}
const server = new McpServer({
name: 'claude-browse',
version: '0.1.0',
});
// Navigation
server.tool('goto', 'Navigate to a URL', { url: z.string().url() }, async ({ url }) => {
await ensureLaunched();
const result = await browser.goto(url);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});
server.tool(
'goto',
'Navigate to a URL',
{ url: z.string().url() },
withLogging('goto', async ({ url }) => {
await ensureLaunched();
const result = await browser.goto(url);
return textResult(JSON.stringify(result));
})
);
server.tool('back', 'Go back in browser history', {}, async () => {
await ensureLaunched();
const result = await browser.back();
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});
server.tool(
'back',
'Go back in browser history',
{},
withLogging('back', async () => {
await ensureLaunched();
const result = await browser.back();
return textResult(JSON.stringify(result));
})
);
server.tool('forward', 'Go forward in browser history', {}, async () => {
await ensureLaunched();
const result = await browser.forward();
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});
server.tool(
'forward',
'Go forward in browser history',
{},
withLogging('forward', async () => {
await ensureLaunched();
const result = await browser.forward();
return textResult(JSON.stringify(result));
})
);
server.tool('reload', 'Reload the current page', {}, async () => {
await ensureLaunched();
const result = await browser.reload();
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});
server.tool(
'reload',
'Reload the current page',
{},
withLogging('reload', async () => {
await ensureLaunched();
const result = await browser.reload();
return textResult(JSON.stringify(result));
})
);
// Interaction
server.tool('click', 'Click on an element', { selector: z.string() }, async ({ selector }) => {
await ensureLaunched();
const result = await browser.click(selector);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});
server.tool(
'click',
'Click on an element',
{ selector: z.string() },
withLogging('click', async ({ selector }) => {
await ensureLaunched();
const result = await browser.click(selector);
return textResult(JSON.stringify(result));
})
);
server.tool(
'type',
'Type text into an input field',
{ selector: z.string(), text: z.string() },
async ({ selector, text }) => {
withLogging('type', async ({ selector, text }) => {
await ensureLaunched();
await browser.type(selector, text);
return { content: [{ type: 'text', text: 'ok' }] };
}
return textResult(JSON.stringify({ ok: true }));
})
);
// Query
@@ -67,28 +119,33 @@ server.tool(
'query',
'Query elements by CSS selector, returns tag, text, and attributes',
{ selector: z.string() },
async ({ selector }) => {
withLogging('query', async ({ selector }) => {
await ensureLaunched();
const elements = await browser.query(selector);
return { content: [{ type: 'text', text: JSON.stringify(elements, null, 2) }] };
}
return textResult(JSON.stringify({ ok: true, count: elements.length, elements }));
})
);
server.tool('url', 'Get current URL and page title', {}, async () => {
await ensureLaunched();
const result = await browser.getUrl();
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});
server.tool(
'url',
'Get current URL and page title',
{},
withLogging('url', async () => {
await ensureLaunched();
const result = await browser.getUrl();
return textResult(JSON.stringify(result));
})
);
server.tool(
'html',
'Get page HTML content',
{ full: z.boolean().optional().default(false) },
async ({ full }) => {
withLogging('html', async ({ full }) => {
await ensureLaunched();
const html = await browser.getHtml(full);
return { content: [{ type: 'text', text: html }] };
}
return textResult(JSON.stringify({ ok: true, html }));
})
);
// Screenshot
@@ -99,11 +156,11 @@ server.tool(
path: z.string().optional().default('screenshots/screenshot.png'),
fullPage: z.boolean().optional().default(false),
},
async ({ path, fullPage }) => {
withLogging('screenshot', async ({ path, fullPage }) => {
await ensureLaunched();
const result = await browser.screenshot(path, fullPage);
return { content: [{ type: 'text', text: `Screenshot saved to ${result.path}` }] };
}
return textResult(JSON.stringify({ ok: true, path: result.path }));
})
);
// Eval
@@ -111,11 +168,11 @@ server.tool(
'eval',
'Execute JavaScript in the browser context',
{ script: z.string() },
async ({ script }) => {
withLogging('eval', async ({ script }) => {
await ensureLaunched();
const result = await browser.eval(script);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
}
return textResult(JSON.stringify({ ok: true, result }));
})
);
// Utility
@@ -123,11 +180,11 @@ server.tool(
'wait',
'Wait for a specified time in milliseconds',
{ ms: z.number().optional().default(1000) },
async ({ ms }) => {
withLogging('wait', async ({ ms }) => {
await ensureLaunched();
await browser.wait(ms);
return { content: [{ type: 'text', text: 'ok' }] };
}
return textResult(JSON.stringify({ ok: true }));
})
);
// Start server
+6 -107
View File
@@ -2,114 +2,13 @@ import chalk from 'chalk';
import express, { type Request, type Response } from 'express';
import logSymbols from 'log-symbols';
import { ClaudeBrowser } from './browser.js';
import type { BrowserCommand, BrowserOptions, CommandResponse } from './types.js';
import { logger, ts } from './logger.js';
import type { BrowserCommand, BrowserOptions } from './types.js';
export interface ServerOptions extends BrowserOptions {
port?: number;
}
// Icons for commands
const icons = {
goto: '→',
click: '◉',
type: '⌨',
query: '?',
screenshot: '📷',
url: '🔗',
html: '<>',
back: '←',
forward: '→',
reload: '↻',
wait: '⏳',
newpage: '+',
close: '✕',
eval: '⚡',
};
// Colors for command types
const cmdColor: Record<string, (s: string) => string> = {
goto: chalk.cyan,
click: chalk.yellow,
type: chalk.magenta,
query: chalk.blue,
screenshot: chalk.green,
url: chalk.cyan,
html: chalk.blue,
back: chalk.yellow,
forward: chalk.yellow,
reload: chalk.yellow,
wait: chalk.gray,
newpage: chalk.green,
close: chalk.red,
eval: chalk.magenta,
};
function ts(): string {
return chalk.gray(`[${new Date().toISOString()}]`);
}
function truncate(str: string, max: number): string {
return str.length > max ? `${str.slice(0, max)}...` : str;
}
function getCommandDetail(cmd: BrowserCommand): string | undefined {
switch (cmd.cmd) {
case 'goto':
return chalk.white(cmd.url);
case 'click':
case 'query':
return chalk.white(cmd.selector);
case 'type':
return `${chalk.white(cmd.selector)} ${chalk.dim(`="${cmd.text}"`)}`;
case 'screenshot':
return chalk.dim(cmd.path || 'screenshot.png');
case 'html':
return cmd.full ? chalk.dim('(full)') : undefined;
case 'wait':
return chalk.dim(`${cmd.ms || 1000}ms`);
case 'eval':
return chalk.dim(truncate(cmd.script, 50));
default:
return undefined;
}
}
function logCommand(cmd: BrowserCommand): void {
const color = cmdColor[cmd.cmd] || chalk.white;
const icon = icons[cmd.cmd as keyof typeof icons] || '•';
const detail = getCommandDetail(cmd);
const suffix = detail ? ` ${detail}` : '';
console.log(`${ts()} ${chalk.bold(color(icon))} ${color(cmd.cmd.toUpperCase())}${suffix}`);
}
type ResultFormatter = (result: CommandResponse) => string | undefined;
const resultFormatters: Record<string, ResultFormatter> = {
goto: (r) => ('title' in r ? r.title : undefined),
click: (r) => ('url' in r ? `${r.url}` : undefined),
query: (r) => ('count' in r ? `Found ${r.count} element(s)` : undefined),
screenshot: (r) => ('path' in r ? `Saved to ${r.path}` : undefined),
url: (r) => ('url' in r ? r.url : undefined),
html: (r) => ('html' in r ? `${r.html?.length || 0} chars` : undefined),
eval: (r) => ('result' in r ? truncate(JSON.stringify(r.result), 80) : undefined),
};
function getResultMessage(cmd: BrowserCommand, result: CommandResponse): string | undefined {
if (!result.ok) return undefined;
const formatter = resultFormatters[cmd.cmd];
return formatter ? formatter(result) : undefined;
}
function logResult(cmd: BrowserCommand, result: CommandResponse): void {
if (!result.ok) {
console.log(`${ts()} ${logSymbols.error} ${chalk.red(result.error)}`);
return;
}
const msg = getResultMessage(cmd, result);
const suffix = msg ? ` ${chalk.dim(msg)}` : '';
console.log(`${ts()} ${logSymbols.success}${suffix}`);
}
function printBanner(port: number): void {
console.log();
console.log(chalk.cyan.bold(' 🌐 Claude Browse Server'));
@@ -139,7 +38,7 @@ export class BrowserServer {
constructor(options: ServerOptions = {}) {
this.browser = new ClaudeBrowser(options);
this.port = options.port ?? 3000;
this.port = options.port ?? 13373;
this.setupMiddleware();
this.setupRoutes();
}
@@ -156,17 +55,17 @@ export class BrowserServer {
private async handleCommand(req: Request, res: Response): Promise<void> {
try {
const cmd: BrowserCommand = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
logCommand(cmd);
logger.command(cmd);
if (cmd.cmd === 'close') {
logResult(cmd, { ok: true });
logger.result(cmd, { ok: true });
res.json({ ok: true });
await this.stop();
process.exit(0);
}
const result = await this.browser.executeCommand(cmd);
logResult(cmd, result);
logger.result(cmd, result);
res.json(result);
} catch (err) {
const error = (err as Error).message;