From 34821f001e627797a2f0c5c6b86b4cf6bb7997f0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Jan 2026 02:28:51 -0600 Subject: [PATCH] bun --- package.json | 3 +- src/cli-light.ts | 322 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 src/cli-light.ts diff --git a/package.json b/package.json index f7c58ae..a456bff 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "agent-browser": "./bin/agent-browser" }, "scripts": { - "build": "tsc", + "build": "tsc && bun build ./src/cli-light.ts --compile --outfile ./bin/agent-browser", + "build:node": "tsc", "start": "node dist/index.js", "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", diff --git a/src/cli-light.ts b/src/cli-light.ts new file mode 100644 index 0000000..7e0067a --- /dev/null +++ b/src/cli-light.ts @@ -0,0 +1,322 @@ +#!/usr/bin/env bun +/** + * Lightweight CLI client for agent-browser + * + * This file contains ONLY the client logic (no Playwright imports). + * It can be compiled with Bun for fast startup times. + * + * The actual browser automation runs in a separate daemon process. + */ + +import * as net from 'net'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'child_process'; + +// ============================================================================ +// Configuration +// ============================================================================ + +const SESSION = process.env.AGENT_BROWSER_SESSION || 'default'; +const SOCKET_PATH = path.join(os.tmpdir(), `agent-browser-${SESSION}.sock`); +const PID_FILE = path.join(os.tmpdir(), `agent-browser-${SESSION}.pid`); + +// ============================================================================ +// Daemon Management +// ============================================================================ + +function isDaemonRunning(): boolean { + if (!fs.existsSync(PID_FILE)) return false; + try { + const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10); + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function ensureDaemon(): Promise { + if (isDaemonRunning() && fs.existsSync(SOCKET_PATH)) { + return; + } + + // Find the daemon script - look relative to this script + const scriptDir = path.dirname(process.argv[1]); + let daemonPath = path.join(scriptDir, 'daemon.js'); + + // Fallback paths + if (!fs.existsSync(daemonPath)) { + daemonPath = path.join(scriptDir, '../dist/daemon.js'); + } + if (!fs.existsSync(daemonPath)) { + daemonPath = path.join(process.cwd(), 'dist/daemon.js'); + } + + if (!fs.existsSync(daemonPath)) { + throw new Error(`Daemon not found. Looked in: ${daemonPath}`); + } + + const child = spawn('node', [daemonPath], { + detached: true, + stdio: 'ignore', + env: { ...process.env, AGENT_BROWSER_DAEMON: '1', AGENT_BROWSER_SESSION: SESSION }, + }); + child.unref(); + + // Wait for socket + for (let i = 0; i < 50; i++) { + if (fs.existsSync(SOCKET_PATH)) return; + await new Promise(r => setTimeout(r, 100)); + } + throw new Error('Failed to start daemon'); +} + +// ============================================================================ +// Command Execution +// ============================================================================ + +interface Response { + id: string; + success: boolean; + data?: unknown; + error?: string; +} + +async function sendCommand(cmd: Record): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + let resolved = false; + const socket = net.createConnection(SOCKET_PATH); + + const cleanup = () => { + socket.removeAllListeners(); + socket.destroy(); + }; + + socket.on('connect', () => { + socket.write(JSON.stringify(cmd) + '\n'); + }); + + socket.on('data', (data) => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx !== -1 && !resolved) { + resolved = true; + try { + const response = JSON.parse(buffer.substring(0, idx)) as Response; + cleanup(); + resolve(response); + } catch { + cleanup(); + reject(new Error('Invalid JSON response')); + } + } + }); + + socket.on('error', (err) => { + if (!resolved) { + resolved = true; + cleanup(); + reject(err); + } + }); + + socket.on('close', () => { + if (!resolved && buffer.trim()) { + resolved = true; + try { + resolve(JSON.parse(buffer.trim()) as Response); + } catch { + reject(new Error('Connection closed')); + } + } + }); + + setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error('Timeout')); + } + }, 15000); + }); +} + +// ============================================================================ +// CLI Parsing +// ============================================================================ + +function parseArgs(args: string[]): { cmd: Record | null; json: boolean } { + const json = args.includes('--json'); + const cleanArgs = args.filter(a => !a.startsWith('--')); + + if (cleanArgs.length === 0) return { cmd: null, json }; + + const command = cleanArgs[0]; + const rest = cleanArgs.slice(1); + const id = Math.random().toString(36).slice(2, 10); + + switch (command) { + case 'open': + case 'goto': + case 'navigate': + return { cmd: { id, action: 'navigate', url: rest[0]?.startsWith('http') ? rest[0] : `https://${rest[0]}` }, json }; + + case 'click': + return { cmd: { id, action: 'click', selector: rest[0] }, json }; + + case 'fill': + return { cmd: { id, action: 'fill', selector: rest[0], value: rest.slice(1).join(' ') }, json }; + + case 'type': + return { cmd: { id, action: 'type', selector: rest[0], text: rest.slice(1).join(' ') }, json }; + + case 'hover': + return { cmd: { id, action: 'hover', selector: rest[0] }, json }; + + case 'snapshot': + return { cmd: { id, action: 'snapshot' }, json }; + + case 'screenshot': + return { cmd: { id, action: 'screenshot', path: rest[0] }, json }; + + case 'close': + case 'quit': + return { cmd: { id, action: 'close' }, json }; + + case 'get': + if (rest[0] === 'text') return { cmd: { id, action: 'gettext', selector: rest[1] }, json }; + if (rest[0] === 'url') return { cmd: { id, action: 'url' }, json }; + if (rest[0] === 'title') return { cmd: { id, action: 'title' }, json }; + return { cmd: null, json }; + + case 'press': + return { cmd: { id, action: 'press', key: rest[0] }, json }; + + case 'wait': + if (/^\d+$/.test(rest[0])) { + return { cmd: { id, action: 'wait', timeout: parseInt(rest[0], 10) }, json }; + } + return { cmd: { id, action: 'wait', selector: rest[0] }, json }; + + case 'back': + return { cmd: { id, action: 'back' }, json }; + + case 'forward': + return { cmd: { id, action: 'forward' }, json }; + + case 'reload': + return { cmd: { id, action: 'reload' }, json }; + + case 'eval': + return { cmd: { id, action: 'evaluate', script: rest.join(' ') }, json }; + + default: + return { cmd: null, json }; + } +} + +// ============================================================================ +// Output Formatting +// ============================================================================ + +function printResponse(response: Response, json: boolean): void { + if (json) { + console.log(JSON.stringify(response)); + return; + } + + if (!response.success) { + console.error('\x1b[31m✗ Error:\x1b[0m', response.error); + process.exit(1); + } + + const data = response.data as Record; + + if (data?.url && data?.title) { + console.log('\x1b[32m✓\x1b[0m', '\x1b[1m' + data.title + '\x1b[0m'); + console.log('\x1b[2m ' + data.url + '\x1b[0m'); + } else if (data?.snapshot) { + console.log(data.snapshot); + } else if (data?.text !== undefined) { + console.log(data.text); + } else if (data?.url) { + console.log(data.url); + } else if (data?.title) { + console.log(data.title); + } else if (data?.result !== undefined) { + console.log(typeof data.result === 'object' ? JSON.stringify(data.result, null, 2) : data.result); + } else if (data?.closed) { + console.log('\x1b[32m✓\x1b[0m Browser closed'); + } else { + console.log('\x1b[32m✓\x1b[0m Done'); + } +} + +// ============================================================================ +// Main +// ============================================================================ + +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + console.log(` +agent-browser - fast browser automation CLI + +Usage: agent-browser [args] [--json] + +Commands: + open Navigate to URL + click Click element (use @ref from snapshot) + fill Fill input + type Type text + hover Hover element + snapshot Get accessibility tree with refs + screenshot [path] Take screenshot + get text Get text content + get url Get current URL + get title Get page title + press Press keyboard key + wait Wait for time or element + eval Evaluate JavaScript + close Close browser + +Options: + --json Output JSON (for AI agents) + +Examples: + agent-browser open example.com + agent-browser snapshot + agent-browser click @e2 + agent-browser fill @e3 "hello" +`); + process.exit(0); + } + + const { cmd, json } = parseArgs(args); + + if (!cmd) { + console.error('\x1b[31mUnknown command\x1b[0m'); + process.exit(1); + } + + try { + await ensureDaemon(); + const response = await sendCommand(cmd); + printResponse(response, json); + process.exit(0); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (json) { + console.log(JSON.stringify({ success: false, error: message })); + } else { + console.error('\x1b[31m✗ Error:\x1b[0m', message); + } + process.exit(1); + } +} + +main();