From 115c97fcbb04d388c4130a1374ee3134862ef15f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 21 Mar 2026 10:23:35 -0700 Subject: [PATCH] feat: browse connect/disconnect/focus CLI commands - connect: pre-server command that discovers browser, starts server in CDP mode - disconnect: drops CDP connection, restarts in headless mode - focus: brings browser window to foreground via osascript (macOS) - status: now shows Mode: cdp | launched | headed - startServer() accepts extra env vars for CDP URL/port passthrough Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/cli.ts | 70 +++++++++++++++++++++++++++++++++++-- browse/src/commands.ts | 5 +++ browse/src/meta-commands.ts | 65 ++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 830b2e7c..b0c7cc16 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -83,6 +83,8 @@ interface ServerState { startedAt: string; serverPath: string; binaryVersion?: string; + mode?: 'launched' | 'cdp'; + cdpPort?: number; } // ─── State File ──────────────────────────────────────────────── @@ -161,7 +163,7 @@ function cleanupLegacyState(): void { } // ─── Server Lifecycle ────────────────────────────────────────── -async function startServer(): Promise { +async function startServer(extraEnv?: Record): Promise { ensureStateDir(config); // Clean up stale state file @@ -176,7 +178,7 @@ async function startServer(): Promise { : ['bun', 'run', SERVER_SCRIPT]; const proc = Bun.spawn(serverCmd, { stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, BROWSE_STATE_FILE: config.stateFile }, + env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv }, }); // Don't hold the CLI open @@ -342,6 +344,70 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: const command = args[0]; const commandArgs = args.slice(1); + // ─── CDP Connect (pre-server command) ─────────────────────── + // connect must be handled BEFORE ensureServer() because it needs + // to restart the server with CDP env vars. + if (command === 'connect') { + const { discoverAndConnect } = await import('./chrome-launcher'); + + // Parse args: connect [browser] [--port N] + let preferredBrowser: string | undefined; + let port = 9222; + for (let i = 0; i < commandArgs.length; i++) { + if (commandArgs[i] === '--port' && commandArgs[i + 1]) { + port = parseInt(commandArgs[i + 1], 10); + i++; + } else if (!commandArgs[i].startsWith('-')) { + preferredBrowser = commandArgs[i]; + } + } + + // Check if already in CDP mode + const existingState = readState(); + if (existingState && existingState.mode === 'cdp') { + console.log('Already connected to real browser via CDP.'); + process.exit(0); + } + + // Kill existing server if running + if (existingState) { + try { process.kill(existingState.pid, 'SIGTERM'); } catch {} + try { fs.unlinkSync(config.stateFile); } catch {} + // Wait for clean shutdown + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + // Discover and connect to browser + console.log(`Discovering browser${preferredBrowser ? ` (${preferredBrowser})` : ''}...`); + try { + const result = await discoverAndConnect(preferredBrowser, port); + console.log(`Found ${result.browser} CDP at port ${result.port}`); + + // Start server with CDP env vars + const newState = await startServer({ + BROWSE_CDP_URL: result.wsUrl, + BROWSE_CDP_PORT: String(result.port), + }); + + // Print connected status + const resp = await fetch(`http://127.0.0.1:${newState.port}/command`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${newState.token}`, + }, + body: JSON.stringify({ command: 'tabs', args: [] }), + signal: AbortSignal.timeout(5000), + }); + const tabList = await resp.text(); + console.log(`Connected to ${result.browser} via CDP\n${tabList}`); + } catch (err: any) { + console.error(`[browse] Connect failed: ${err.message}`); + process.exit(1); + } + process.exit(0); + } + // Special case: chain reads from stdin if (command === 'chain' && commandArgs.length === 0) { const stdin = await Bun.stdin.text(); diff --git a/browse/src/commands.ts b/browse/src/commands.ts index c3509af1..72aa33d5 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -31,6 +31,7 @@ export const META_COMMANDS = new Set([ 'chain', 'diff', 'url', 'snapshot', 'handoff', 'resume', + 'connect', 'disconnect', 'focus', ]); export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]); @@ -98,6 +99,10 @@ export const COMMAND_DESCRIPTIONS: Record 0 && args[0].startsWith('@')) { + try { + const resolved = await bm.resolveRef(args[0]); + if ('locator' in resolved) { + await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 }); + return `Browser activated. Scrolled ${args[0]} into view.`; + } + } catch { + // Ref not found — still activated the browser + } + } + + return 'Browser window activated.'; + } catch (err: any) { + return `focus failed: ${err.message}. macOS only.`; + } + } + default: throw new Error(`Unknown meta command: ${command}`); }