diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..dc0a3ba --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,6 @@ +{ + "enabledMcpjsonServers": [ + "claude-browse" + ], + "enableAllProjectMcpServers": true +} diff --git a/src/cli.ts b/src/cli.ts index 71dd51d..87b0705 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 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 @@ -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 { - 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 { 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(); } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..5cefe31 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,137 @@ +import chalk from 'chalk'; +import logSymbols from 'log-symbols'; + +// Icons for commands +export const icons: Record = { + goto: '→', + click: '◉', + type: '⌨', + query: '?', + screenshot: '📷', + url: '🔗', + html: '<>', + back: '←', + forward: '→', + reload: '↻', + wait: '⏳', + newpage: '+', + close: '✕', + eval: '⚡', +}; + +// Colors for command types +export const cmdColor: Record 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 | 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`)); diff --git a/src/mcp.ts b/src/mcp.ts index 190a62c..4174b54 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -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 { } } +type ToolResult = { content: [{ type: 'text'; text: string }] }; + +function textResult(text: string): ToolResult { + return { content: [{ type: 'text' as const, text }] }; +} + +function withLogging>( + cmd: string, + fn: (args: T) => Promise +): (args: T) => Promise { + 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 diff --git a/src/server.ts b/src/server.ts index 71ef583..0baf7b4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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> = { - 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 = { - 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 { 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;