#!/usr/bin/env ts-node /** * Minimal Chrome DevTools helpers inspired by Mario Zechner's * "What if you don't need MCP?" article. * * Keeps everything in one TypeScript CLI so agents (or humans) can drive Chrome * directly via the DevTools protocol without pulling in a large MCP server. */import { Command } from 'commander';import { execFileSync, execSync, spawn } from 'node:child_process';import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';import { writeFile } from 'node:fs/promises';import http from 'node:http';import os from 'node:os';import path from 'node:path';import readline from 'node:readline/promises';import { stdin as input, stdout as output } from 'node:process';import { fileURLToPath } from 'node:url';import { inspect } from 'node:util';import puppeteer, { type HTTPRequest } from 'puppeteer-core'; /** Utility type so TypeScript knows the async function constructor */type AsyncFunctionCtor = new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>; const DEFAULT_PORT = 9222;const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.cache', 'scraping');const DEFAULT_CHROME_BIN = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; function browserURL(port: number): string { return `http://localhost:${port}`;} async function connectBrowser(port: number) { return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null });} function resolveComparablePath(inputPath: string): string { let existingPath = path.resolve(inputPath); const missingSegments: string[] = []; while (!existsSync(existingPath)) { const parent = path.dirname(existingPath); if (parent === existingPath) break; missingSegments.unshift(path.basename(existingPath)); existingPath = parent; } return path.join(realpathSync(existingPath), ...missingSegments);} function pathsOverlap(first: string, second: string): boolean { const relative = path.relative(first, second); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));} export function copyChromeProfile(sourceDir: string, profileDir: string): void { const source = realpathSync(sourceDir); if (!statSync(source).isDirectory()) { throw new Error('Chrome profile source must be a directory'); } let destinationLinkExists = false; try { destinationLinkExists = lstatSync(profileDir).isSymbolicLink(); } catch { // Missing destinations are created below. } if (destinationLinkExists && !existsSync(profileDir)) { throw new Error('Chrome profile destination symlink target does not exist'); } let destination = resolveComparablePath(profileDir); if (pathsOverlap(source, destination) || pathsOverlap(destination, source)) { throw new Error('Chrome profile source and destination must not overlap'); } if (existsSync(profileDir) && statSync(profileDir).isDirectory()) { for (const entry of readdirSync(destination)) { rmSync(path.join(destination, entry), { recursive: true, force: true }); } } else { rmSync(profileDir, { recursive: true, force: true }); mkdirSync(profileDir, { recursive: true }); destination = resolveComparablePath(profileDir); } cpSync(source, destination, { recursive: true, force: true, verbatimSymlinks: true, });} async function getActivePage(port: number) { const browser = await connectBrowser(port); const pages = await browser.pages(); const page = pages.at(-1); if (!page) { await browser.disconnect(); throw new Error('No active tab found'); } return { browser, page };} const program = new Command();program .name('browser-tools') .description('Lightweight Chrome DevTools helpers (no MCP required).') .configureHelp({ sortSubcommands: true }) .showSuggestionAfterError(); program .command('start') .description('Launch Chrome with remote debugging enabled.') .option('-p, --port <number>', 'Remote debugging port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .option('--profile', 'Copy your default Chrome profile before launch.', false) .option('--profile-dir <path>', 'Directory for the temporary Chrome profile.', DEFAULT_PROFILE_DIR) .option('--chrome-path <path>', 'Path to the Chrome binary.', DEFAULT_CHROME_BIN) .option('--kill-existing', 'Stop any running Google Chrome before launch (default: false).', false) .action(async (options) => { const { port, profile, profileDir, chromePath, killExisting } = options as { port: number; profile: boolean; profileDir: string; chromePath: string; killExisting: boolean; }; if (killExisting) { try { execFileSync('killall', ['Google Chrome'], { stdio: 'ignore' }); } catch { // ignore missing processes } await new Promise((resolve) => setTimeout(resolve, 1000)); } mkdirSync(profileDir, { recursive: true }); if (profile) { const source = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome'); copyChromeProfile(source, profileDir); } spawn(chromePath, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--disable-popup-blocking'], { detached: true, stdio: 'ignore', }).unref(); let connected = false; for (let attempt = 0; attempt < 30; attempt++) { try { const browser = await connectBrowser(port); await browser.disconnect(); connected = true; break; } catch { await new Promise((resolve) => setTimeout(resolve, 500)); } } if (!connected) { console.error(`✗ Failed to start Chrome on port ${port}`); process.exit(1); } console.log(`✓ Chrome listening on http://localhost:${port}${profile ? ' (profile copied)' : ''}`); }); program .command('nav <url>') .description('Navigate the current tab or open a new tab.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .option('--new', 'Open in a new tab.', false) .action(async (url: string, options) => { const port = options.port as number; const browser = await connectBrowser(port); try { if (options.new) { const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); console.log('✓ Opened in new tab:', url); } else { const pages = await browser.pages(); const page = pages.at(-1); if (!page) { throw new Error('No active tab found'); } await page.goto(url, { waitUntil: 'domcontentloaded' }); console.log('✓ Navigated current tab to:', url); } } finally { await browser.disconnect(); } }); program .command('eval <code...>') .description('Evaluate JavaScript in the active page context.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .option('--pretty-print', 'Format array/object results with indentation.', false) .action(async (code: string[], options) => { const snippet = code.join(' '); const port = options.port as number; const pretty = Boolean(options.prettyPrint); const useColor = process.stdout.isTTY; const printPretty = (value: unknown) => { console.log( inspect(value, { depth: 6, colors: useColor, maxArrayLength: 50, breakLength: 80, compact: false, }), ); }; const { browser, page } = await getActivePage(port); try { const result = await page.evaluate((body) => { const ASYNC_FN = Object.getPrototypeOf(async () => {}).constructor as AsyncFunctionCtor; return new ASYNC_FN(`return (${body})`)(); }, snippet); if (pretty) { printPretty(result); } else if (Array.isArray(result)) { result.forEach((entry, index) => { if (index > 0) { console.log(''); } Object.entries(entry).forEach(([key, value]) => { console.log(`${key}: ${value}`); }); }); } else if (typeof result === 'object' && result !== null) { Object.entries(result).forEach(([key, value]) => { console.log(`${key}: ${value}`); }); } else { console.log(result); } } finally { await browser.disconnect(); } }); program .command('screenshot') .description('Capture the current viewport and print the temp PNG path.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .action(async (options) => { const port = options.port as number; const { browser, page } = await getActivePage(port); const client = await page.target().createCDPSession(); try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filePath = path.join(os.tmpdir(), `screenshot-${timestamp}.png`); const layoutMetrics = await client.send('Page.getLayoutMetrics').catch(() => undefined); const layoutViewport = layoutMetrics?.layoutViewport as | { clientWidth: number; clientHeight: number; pageX?: number; pageY?: number } | undefined; let cssWidth = layoutViewport?.clientWidth; let cssHeight = layoutViewport?.clientHeight; const pageX = layoutViewport?.pageX ?? 0; const pageY = layoutViewport?.pageY ?? 0; if (!cssWidth || !cssHeight) { const viewport = page.viewport(); cssWidth = viewport?.width; cssHeight = viewport?.height; } if (!cssWidth || !cssHeight) { const fallback = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, })); cssWidth = fallback.width; cssHeight = fallback.height; } const maxDimension = 2000; const scale = cssWidth && cssHeight ? Math.max( 0.01, Math.min(1, maxDimension / Math.max(cssWidth, cssHeight)), ) : 1; if (!cssWidth || !cssHeight) { await page.screenshot({ path: filePath }); console.log(filePath); return; } const screenshot = await client.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false, clip: { x: pageX, y: pageY, width: cssWidth, height: cssHeight, scale, }, }); await writeFile(filePath, Buffer.from(screenshot.data, 'base64')); console.log(filePath); } finally { try { await client.detach(); } catch { // ignore } await browser.disconnect(); } }); program .command('pick <message...>') .description('Interactive DOM picker that prints metadata for clicked elements.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .action(async (messageParts: string[], options) => { const message = messageParts.join(' '); const port = options.port as number; const { browser, page } = await getActivePage(port); try { await page.evaluate(() => { const scope = globalThis as typeof globalThis & { pickOverlayInjected?: boolean; pick?: (prompt: string) => Promise<unknown>; }; if (scope.pickOverlayInjected) { return; } scope.pickOverlayInjected = true; scope.pick = async (prompt: string) => new Promise((resolve) => { const selections: unknown[] = []; const selectedElements = new Set<HTMLElement>(); const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none'; const highlight = document.createElement('div'); highlight.style.cssText = 'position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.05s ease'; overlay.appendChild(highlight); const banner = document.createElement('div'); banner.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:12px 24px;border-radius:8px;font:14px system-ui;box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;z-index:2147483647'; const updateBanner = () => { banner.textContent = `${prompt} (${selections.length} selected, Cmd/Ctrl+click to add, Enter to finish, ESC to cancel)`; }; const cleanup = () => { document.removeEventListener('mousemove', onMove, true); document.removeEventListener('click', onClick, true); document.removeEventListener('keydown', onKey, true); overlay.remove(); banner.remove(); selectedElements.forEach((el) => { el.style.outline = ''; }); }; const serialize = (el: HTMLElement) => { const parents: string[] = []; let current = el.parentElement; while (current && current !== document.body) { const id = current.id ? `#${current.id}` : ''; const cls = current.className ? `.${current.className.trim().split(/\s+/).join('.')}` : ''; parents.push(`${current.tagName.toLowerCase()}${id}${cls}`); current = current.parentElement; } return { tag: el.tagName.toLowerCase(), id: el.id || null, class: el.className || null, text: el.textContent?.trim()?.slice(0, 200) || null, html: el.outerHTML.slice(0, 500), parents: parents.join(' > '), }; }; const onMove = (event: MouseEvent) => { const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; if (!node || overlay.contains(node) || banner.contains(node)) return; const rect = node.getBoundingClientRect(); highlight.style.cssText = `position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);top:${rect.top}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px`; }; const onClick = (event: MouseEvent) => { if (banner.contains(event.target as Node)) return; event.preventDefault(); event.stopPropagation(); const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; if (!node || overlay.contains(node) || banner.contains(node)) return; if (event.metaKey || event.ctrlKey) { if (!selectedElements.has(node)) { selectedElements.add(node); node.style.outline = '3px solid #10b981'; selections.push(serialize(node)); updateBanner(); } } else { cleanup(); const info = serialize(node); resolve(selections.length > 0 ? selections : info); } }; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { cleanup(); resolve(null); } else if (event.key === 'Enter' && selections.length > 0) { cleanup(); resolve(selections); } }; document.addEventListener('mousemove', onMove, true); document.addEventListener('click', onClick, true); document.addEventListener('keydown', onKey, true); document.body.append(overlay, banner); updateBanner(); }); }); const result = await page.evaluate((msg) => { const pickFn = (window as Window & { pick?: (message: string) => Promise<unknown> }).pick; if (!pickFn) { return null; } return pickFn(msg); }, message); if (Array.isArray(result)) { result.forEach((entry, index) => { if (index > 0) { console.log(''); } Object.entries(entry).forEach(([key, value]) => { console.log(`${key}: ${value}`); }); }); } else if (result && typeof result === 'object') { Object.entries(result).forEach(([key, value]) => { console.log(`${key}: ${value}`); }); } else { console.log(result); } } finally { await browser.disconnect(); } }); program .command('console') .description('Capture and display console logs from the active tab.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .option('--types <list>', 'Comma-separated log types to show (e.g., log,error,warn). Default: all types') .option('--follow', 'Continuous monitoring mode (like tail -f)', false) .option('--timeout <seconds>', 'Capture duration in seconds (default: 5 for one-shot, infinite for --follow)', (value) => Number.parseInt(value, 10)) .option('--color', 'Force color output') .option('--no-color', 'Disable color output') .option('--no-serialize', 'Disable object serialization (show raw text only)') .action(async (options) => { const port = options.port as number; const follow = options.follow as boolean; const timeout = options.timeout as number | undefined; const typesFilter = options.types as string | undefined; const serialize = options.serialize !== false; // Track explicit color flags by looking at argv to avoid Commander defaults overriding TTY detection. const argv = process.argv.slice(2); const colorFlag = argv.includes('--color') ? true : argv.includes('--no-color') ? false : undefined; // Determine if we should use colors: explicit flag or TTY auto-detection const useColor = colorFlag ?? process.stdout.isTTY; // Parse types filter const normalizeType = (value: string) => { const lower = value.toLowerCase(); if (lower === 'warning') return 'warn'; return lower; }; const allowedTypes = typesFilter ? new Set(typesFilter.split(',').map((t) => normalizeType(t.trim()))) : null; // null means show all types Lines 1–500 of 1240. Load more
#!/usr/bin/env ts-node /** * Minimal Chrome DevTools helpers inspired by Mario Zechner's * "What if you don't need MCP?" article. * * Keeps everything in one TypeScript CLI so agents (or humans) can drive Chrome * directly via the DevTools protocol without pulling in a large MCP server. */ import { Command } from 'commander'; import { execFileSync, execSync, spawn } from 'node:child_process'; import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; import { fileURLToPath } from 'node:url'; import { inspect } from 'node:util'; import puppeteer, { type HTTPRequest } from 'puppeteer-core'; /** Utility type so TypeScript knows the async function constructor */ type AsyncFunctionCtor = new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>; const DEFAULT_PORT = 9222; const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.cache', 'scraping'); const DEFAULT_CHROME_BIN = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; function browserURL(port: number): string { return `http://localhost:${port}`; } async function connectBrowser(port: number) { return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null }); } function resolveComparablePath(inputPath: string): string { let existingPath = path.resolve(inputPath); const missingSegments: string[] = []; while (!existsSync(existingPath)) { const parent = path.dirname(existingPath); if (parent === existingPath) break; missingSegments.unshift(path.basename(existingPath)); existingPath = parent; } return path.join(realpathSync(existingPath), ...missingSegments); } function pathsOverlap(first: string, second: string): boolean { const relative = path.relative(first, second); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); } export function copyChromeProfile(sourceDir: string, profileDir: string): void { const source = realpathSync(sourceDir); if (!statSync(source).isDirectory()) { throw new Error('Chrome profile source must be a directory'); } let destinationLinkExists = false; try { destinationLinkExists = lstatSync(profileDir).isSymbolicLink(); } catch { // Missing destinations are created below. } if (destinationLinkExists && !existsSync(profileDir)) { throw new Error('Chrome profile destination symlink target does not exist'); } let destination = resolveComparablePath(profileDir); if (pathsOverlap(source, destination) || pathsOverlap(destination, source)) { throw new Error('Chrome profile source and destination must not overlap'); } if (existsSync(profileDir) && statSync(profileDir).isDirectory()) { for (const entry of readdirSync(destination)) { rmSync(path.join(destination, entry), { recursive: true, force: true }); } } else { rmSync(profileDir, { recursive: true, force: true }); mkdirSync(profileDir, { recursive: true }); destination = resolveComparablePath(profileDir); } cpSync(source, destination, { recursive: true, force: true, verbatimSymlinks: true, }); } async function getActivePage(port: number) { const browser = await connectBrowser(port); const pages = await browser.pages(); const page = pages.at(-1); if (!page) { await browser.disconnect(); throw new Error('No active tab found'); } return { browser, page }; } const program = new Command(); program .name('browser-tools') .description('Lightweight Chrome DevTools helpers (no MCP required).') .configureHelp({ sortSubcommands: true }) .showSuggestionAfterError(); program .command('start') .description('Launch Chrome with remote debugging enabled.') .option('-p, --port <number>', 'Remote debugging port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .option('--profile', 'Copy your default Chrome profile before launch.', false) .option('--profile-dir <path>', 'Directory for the temporary Chrome profile.', DEFAULT_PROFILE_DIR) .option('--chrome-path <path>', 'Path to the Chrome binary.', DEFAULT_CHROME_BIN) .option('--kill-existing', 'Stop any running Google Chrome before launch (default: false).', false) .action(async (options) => { const { port, profile, profileDir, chromePath, killExisting } = options as { port: number; profile: boolean; profileDir: string; chromePath: string; killExisting: boolean; }; if (killExisting) { try { execFileSync('killall', ['Google Chrome'], { stdio: 'ignore' }); } catch { // ignore missing processes } await new Promise((resolve) => setTimeout(resolve, 1000)); } mkdirSync(profileDir, { recursive: true }); if (profile) { const source = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome'); copyChromeProfile(source, profileDir); } spawn(chromePath, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--disable-popup-blocking'], { detached: true, stdio: 'ignore', }).unref(); let connected = false; for (let attempt = 0; attempt < 30; attempt++) { try { const browser = await connectBrowser(port); await browser.disconnect(); connected = true; break; } catch { await new Promise((resolve) => setTimeout(resolve, 500)); } } if (!connected) { console.error(`✗ Failed to start Chrome on port ${port}`); process.exit(1); } console.log(`✓ Chrome listening on http://localhost:${port}${profile ? ' (profile copied)' : ''}`); }); program .command('nav <url>') .description('Navigate the current tab or open a new tab.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .option('--new', 'Open in a new tab.', false) .action(async (url: string, options) => { const port = options.port as number; const browser = await connectBrowser(port); try { if (options.new) { const page = await browser.newPage(); await page.goto(url, { waitUntil: 'domcontentloaded' }); console.log('✓ Opened in new tab:', url); } else { const pages = await browser.pages(); const page = pages.at(-1); if (!page) { throw new Error('No active tab found'); } await page.goto(url, { waitUntil: 'domcontentloaded' }); console.log('✓ Navigated current tab to:', url); } } finally { await browser.disconnect(); } }); program .command('eval <code...>') .description('Evaluate JavaScript in the active page context.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .option('--pretty-print', 'Format array/object results with indentation.', false) .action(async (code: string[], options) => { const snippet = code.join(' '); const port = options.port as number; const pretty = Boolean(options.prettyPrint); const useColor = process.stdout.isTTY; const printPretty = (value: unknown) => { console.log( inspect(value, { depth: 6, colors: useColor, maxArrayLength: 50, breakLength: 80, compact: false, }), ); }; const { browser, page } = await getActivePage(port); try { const result = await page.evaluate((body) => { const ASYNC_FN = Object.getPrototypeOf(async () => {}).constructor as AsyncFunctionCtor; return new ASYNC_FN(`return (${body})`)(); }, snippet); if (pretty) { printPretty(result); } else if (Array.isArray(result)) { result.forEach((entry, index) => { if (index > 0) { console.log(''); } Object.entries(entry).forEach(([key, value]) => { console.log(`${key}: ${value}`); }); }); } else if (typeof result === 'object' && result !== null) { Object.entries(result).forEach(([key, value]) => { console.log(`${key}: ${value}`); }); } else { console.log(result); } } finally { await browser.disconnect(); } }); program .command('screenshot') .description('Capture the current viewport and print the temp PNG path.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .action(async (options) => { const port = options.port as number; const { browser, page } = await getActivePage(port); const client = await page.target().createCDPSession(); try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filePath = path.join(os.tmpdir(), `screenshot-${timestamp}.png`); const layoutMetrics = await client.send('Page.getLayoutMetrics').catch(() => undefined); const layoutViewport = layoutMetrics?.layoutViewport as | { clientWidth: number; clientHeight: number; pageX?: number; pageY?: number } | undefined; let cssWidth = layoutViewport?.clientWidth; let cssHeight = layoutViewport?.clientHeight; const pageX = layoutViewport?.pageX ?? 0; const pageY = layoutViewport?.pageY ?? 0; if (!cssWidth || !cssHeight) { const viewport = page.viewport(); cssWidth = viewport?.width; cssHeight = viewport?.height; } if (!cssWidth || !cssHeight) { const fallback = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, })); cssWidth = fallback.width; cssHeight = fallback.height; } const maxDimension = 2000; const scale = cssWidth && cssHeight ? Math.max( 0.01, Math.min(1, maxDimension / Math.max(cssWidth, cssHeight)), ) : 1; if (!cssWidth || !cssHeight) { await page.screenshot({ path: filePath }); console.log(filePath); return; } const screenshot = await client.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false, clip: { x: pageX, y: pageY, width: cssWidth, height: cssHeight, scale, }, }); await writeFile(filePath, Buffer.from(screenshot.data, 'base64')); console.log(filePath); } finally { try { await client.detach(); } catch { // ignore } await browser.disconnect(); } }); program .command('pick <message...>') .description('Interactive DOM picker that prints metadata for clicked elements.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .action(async (messageParts: string[], options) => { const message = messageParts.join(' '); const port = options.port as number; const { browser, page } = await getActivePage(port); try { await page.evaluate(() => { const scope = globalThis as typeof globalThis & { pickOverlayInjected?: boolean; pick?: (prompt: string) => Promise<unknown>; }; if (scope.pickOverlayInjected) { return; } scope.pickOverlayInjected = true; scope.pick = async (prompt: string) => new Promise((resolve) => { const selections: unknown[] = []; const selectedElements = new Set<HTMLElement>(); const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none'; const highlight = document.createElement('div'); highlight.style.cssText = 'position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.05s ease'; overlay.appendChild(highlight); const banner = document.createElement('div'); banner.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:12px 24px;border-radius:8px;font:14px system-ui;box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;z-index:2147483647'; const updateBanner = () => { banner.textContent = `${prompt} (${selections.length} selected, Cmd/Ctrl+click to add, Enter to finish, ESC to cancel)`; }; const cleanup = () => { document.removeEventListener('mousemove', onMove, true); document.removeEventListener('click', onClick, true); document.removeEventListener('keydown', onKey, true); overlay.remove(); banner.remove(); selectedElements.forEach((el) => { el.style.outline = ''; }); }; const serialize = (el: HTMLElement) => { const parents: string[] = []; let current = el.parentElement; while (current && current !== document.body) { const id = current.id ? `#${current.id}` : ''; const cls = current.className ? `.${current.className.trim().split(/\s+/).join('.')}` : ''; parents.push(`${current.tagName.toLowerCase()}${id}${cls}`); current = current.parentElement; } return { tag: el.tagName.toLowerCase(), id: el.id || null, class: el.className || null, text: el.textContent?.trim()?.slice(0, 200) || null, html: el.outerHTML.slice(0, 500), parents: parents.join(' > '), }; }; const onMove = (event: MouseEvent) => { const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; if (!node || overlay.contains(node) || banner.contains(node)) return; const rect = node.getBoundingClientRect(); highlight.style.cssText = `position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);top:${rect.top}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px`; }; const onClick = (event: MouseEvent) => { if (banner.contains(event.target as Node)) return; event.preventDefault(); event.stopPropagation(); const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; if (!node || overlay.contains(node) || banner.contains(node)) return; if (event.metaKey || event.ctrlKey) { if (!selectedElements.has(node)) { selectedElements.add(node); node.style.outline = '3px solid #10b981'; selections.push(serialize(node)); updateBanner(); } } else { cleanup(); const info = serialize(node); resolve(selections.length > 0 ? selections : info); } }; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { cleanup(); resolve(null); } else if (event.key === 'Enter' && selections.length > 0) { cleanup(); resolve(selections); } }; document.addEventListener('mousemove', onMove, true); document.addEventListener('click', onClick, true); document.addEventListener('keydown', onKey, true); document.body.append(overlay, banner); updateBanner(); }); }); const result = await page.evaluate((msg) => { const pickFn = (window as Window & { pick?: (message: string) => Promise<unknown> }).pick; if (!pickFn) { return null; } return pickFn(msg); }, message); if (Array.isArray(result)) { result.forEach((entry, index) => { if (index > 0) { console.log(''); } Object.entries(entry).forEach(([key, value]) => { console.log(`${key}: ${value}`); }); }); } else if (result && typeof result === 'object') { Object.entries(result).forEach(([key, value]) => { console.log(`${key}: ${value}`); }); } else { console.log(result); } } finally { await browser.disconnect(); } }); program .command('console') .description('Capture and display console logs from the active tab.') .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) .option('--types <list>', 'Comma-separated log types to show (e.g., log,error,warn). Default: all types') .option('--follow', 'Continuous monitoring mode (like tail -f)', false) .option('--timeout <seconds>', 'Capture duration in seconds (default: 5 for one-shot, infinite for --follow)', (value) => Number.parseInt(value, 10)) .option('--color', 'Force color output') .option('--no-color', 'Disable color output') .option('--no-serialize', 'Disable object serialization (show raw text only)') .action(async (options) => { const port = options.port as number; const follow = options.follow as boolean; const timeout = options.timeout as number | undefined; const typesFilter = options.types as string | undefined; const serialize = options.serialize !== false; // Track explicit color flags by looking at argv to avoid Commander defaults overriding TTY detection. const argv = process.argv.slice(2); const colorFlag = argv.includes('--color') ? true : argv.includes('--no-color') ? false : undefined; // Determine if we should use colors: explicit flag or TTY auto-detection const useColor = colorFlag ?? process.stdout.isTTY; // Parse types filter const normalizeType = (value: string) => { const lower = value.toLowerCase(); if (lower === 'warning') return 'warn'; return lower; }; const allowedTypes = typesFilter ? new Set(typesFilter.split(',').map((t) => normalizeType(t.trim()))) : null; // null means show all types