cli.ts
1 /** 2 * CLI entry point: registers built-in commands and wires up Commander. 3 * 4 * Built-in commands are registered inline here (list, validate, explore, etc.). 5 * Dynamic adapter commands are registered via commanderAdapter.ts. 6 */ 7 8 import * as fs from 'node:fs'; 9 import * as os from 'node:os'; 10 import * as path from 'node:path'; 11 import { fileURLToPath } from 'node:url'; 12 import { Command } from 'commander'; 13 import { styleText } from 'node:util'; 14 import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js'; 15 import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; 16 import { serializeCommand, formatArgSummary } from './serialization.js'; 17 import { render as renderOutput } from './output.js'; 18 import { PKG_VERSION } from './version.js'; 19 import { printCompletionScript } from './completion.js'; 20 import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js'; 21 import { registerAllCommands } from './commanderAdapter.js'; 22 import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js'; 23 import { TargetError, type TargetErrorCode } from './browser/target-errors.js'; 24 import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs, clickResolvedJs, type ResolveOptions, type TargetMatchLevel } from './browser/target-resolver.js'; 25 import { buildFindJs, isFindError, type FindResult, type FindError } from './browser/find.js'; 26 import { inferShape } from './browser/shape.js'; 27 import { assignKeys } from './browser/network-key.js'; 28 import { DEFAULT_TTL_MS, findEntry, loadNetworkCache, saveNetworkCache, type CachedNetworkEntry } from './browser/network-cache.js'; 29 import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js'; 30 import { buildHtmlTreeJs, type HtmlTreeResult } from './browser/html-tree.js'; 31 import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js'; 32 import { analyzeSite, type PageSignals } from './browser/analyze.js'; 33 import { daemonStatus, daemonStop } from './commands/daemon.js'; 34 import { log } from './logger.js'; 35 36 const CLI_FILE = fileURLToPath(import.meta.url); 37 const DEFAULT_BROWSER_WORKSPACE = 'browser:default'; 38 const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"'; 39 40 type BrowserNetworkItem = { 41 url: string; 42 method: string; 43 status: number; 44 size: number; 45 ct: string; 46 body: unknown; 47 /** Full body size in chars before any capture-layer truncation. */ 48 bodyFullSize?: number; 49 /** True when the capture layer had to cap the stored body to protect memory. */ 50 bodyTruncated?: boolean; 51 }; 52 53 /** 54 * Normalize raw capture entries (from daemon/CDP `readNetworkCapture` or 55 * the JS interceptor's `window.__opencli_net`) into a consistent shape. 56 * Response preview is parsed as JSON when possible, otherwise kept as string. 57 * `bodyFullSize` / `bodyTruncated` surface capture-layer truncation so the 58 * agent-facing envelope can warn when the body isn't whole. 59 */ 60 async function captureNetworkItems(page: import('./types.js').IPage): Promise<BrowserNetworkItem[]> { 61 if (page.readNetworkCapture) { 62 const raw = await page.readNetworkCapture(); 63 if (Array.isArray(raw) && raw.length > 0) { 64 return (raw as Array<Record<string, unknown>>).map((e) => { 65 const preview = (e.responsePreview as string) ?? null; 66 let body: unknown = null; 67 if (preview) { 68 try { body = JSON.parse(preview); } catch { body = preview; } 69 } 70 const fullSize = typeof e.responseBodyFullSize === 'number' 71 ? (e.responseBodyFullSize as number) 72 : (preview ? preview.length : 0); 73 const truncated = e.responseBodyTruncated === true; 74 return { 75 url: (e.url as string) || '', 76 method: (e.method as string) || 'GET', 77 status: (e.responseStatus as number) || 0, 78 size: fullSize, 79 ct: (e.responseContentType as string) || '', 80 body, 81 bodyFullSize: fullSize, 82 bodyTruncated: truncated, 83 }; 84 }); 85 } 86 } 87 const raw = await page.evaluate(`(function(){ var out = window.__opencli_net || []; window.__opencli_net = []; return JSON.stringify(out); })()`) as string; 88 try { 89 return JSON.parse(raw) as BrowserNetworkItem[]; 90 } catch { 91 if (process.env.OPENCLI_VERBOSE) log.warn(`[network] Failed to parse interceptor buffer: ${typeof raw === 'string' ? raw.slice(0, 200) : String(raw)}`); 92 return []; 93 } 94 } 95 96 /** Drop static-resource / telemetry noise so agents see only API-shaped traffic. */ 97 function filterNetworkItems(items: BrowserNetworkItem[]): BrowserNetworkItem[] { 98 return items.filter((r) => 99 (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) && 100 !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) && 101 !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url), 102 ); 103 } 104 105 /** Exit codes by network error code — usage errors vs runtime failures. */ 106 const NETWORK_ERROR_EXIT: Record<string, number> = { 107 invalid_args: EXIT_CODES.USAGE_ERROR, 108 invalid_filter: EXIT_CODES.USAGE_ERROR, 109 invalid_max_body: EXIT_CODES.USAGE_ERROR, 110 }; 111 112 /** Emit a structured error JSON so agents can branch on `error.code` without regex. */ 113 function emitNetworkError(code: string, message: string, extra: Record<string, unknown> = {}): void { 114 console.log(JSON.stringify({ error: { code, message, ...extra } }, null, 2)); 115 process.exitCode = NETWORK_ERROR_EXIT[code] ?? EXIT_CODES.GENERIC_ERROR; 116 } 117 118 /** 119 * Check whether the site-memory scaffolding exists under 120 * ~/.opencli/sites/<site>/. Agents have a strong tendency to forget to write 121 * endpoints.json / notes.md after a successful verify, which dooms the next 122 * agent to redo recon from scratch. Surfacing the current state as part of 123 * verify's final report converts that "silent skip" into a visible nudge; 124 * `--strict-memory` escalates it to a failure so agents driving a hardened 125 * workflow can't forget. 126 */ 127 export type SiteMemoryReport = { 128 ok: boolean; 129 siteDir: string; 130 endpoints: { present: boolean; count: number; path: string }; 131 notes: { present: boolean; path: string }; 132 }; 133 134 export function checkSiteMemory(site: string): SiteMemoryReport { 135 const siteDir = path.join(os.homedir(), '.opencli', 'sites', site); 136 const endpointsPath = path.join(siteDir, 'endpoints.json'); 137 const notesPath = path.join(siteDir, 'notes.md'); 138 let endpointsCount = 0; 139 let endpointsPresent = fs.existsSync(endpointsPath); 140 if (endpointsPresent) { 141 try { 142 const parsed = JSON.parse(fs.readFileSync(endpointsPath, 'utf-8')); 143 if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { 144 endpointsCount = Object.keys(parsed).length; 145 } else if (Array.isArray(parsed)) { 146 endpointsCount = parsed.length; 147 } 148 } catch { 149 endpointsPresent = false; 150 } 151 } 152 const notesPresent = fs.existsSync(notesPath); 153 return { 154 ok: endpointsPresent && endpointsCount > 0 && notesPresent, 155 siteDir, 156 endpoints: { present: endpointsPresent, count: endpointsCount, path: endpointsPath }, 157 notes: { present: notesPresent, path: notesPath }, 158 }; 159 } 160 161 export function printSiteMemoryReport(report: SiteMemoryReport, strict: boolean | undefined): void { 162 if (report.ok) { 163 console.log(` ✓ Memory: endpoints.json (${report.endpoints.count}), notes.md present at ${report.siteDir}`); 164 return; 165 } 166 const marker = strict ? '✗' : '⚠'; 167 const missing: string[] = []; 168 if (!report.endpoints.present) missing.push('endpoints.json'); 169 else if (report.endpoints.count === 0) missing.push('endpoints.json (empty)'); 170 if (!report.notes.present) missing.push('notes.md'); 171 console.log(` ${marker} Memory: missing ${missing.join(', ')} under ${report.siteDir}`); 172 console.log(` Write the endpoint you just verified + a 1-line session note so the next agent starts from minute 0, not minute 95.`); 173 if (!strict) { 174 console.log(` (Re-run with --strict-memory to fail instead of warn.)`); 175 } 176 } 177 178 /** Coerce adapter JSON output into a row array. Accepts `[{...}]`, single `{}`, or `{items:[...]}`-style envelopes. */ 179 export function normalizeVerifyRows(data: unknown): Record<string, unknown>[] { 180 if (Array.isArray(data)) { 181 return data.map((r) => (r && typeof r === 'object' ? r as Record<string, unknown> : { value: r })); 182 } 183 if (data && typeof data === 'object') { 184 const obj = data as Record<string, unknown>; 185 for (const k of ['rows', 'items', 'data', 'results']) { 186 if (Array.isArray(obj[k])) { 187 return (obj[k] as unknown[]).map((r) => (r && typeof r === 'object' ? r as Record<string, unknown> : { value: r })); 188 } 189 } 190 return [obj]; 191 } 192 return []; 193 } 194 195 /** Render up to 10 rows as a compact padded table for eyeball inspection during verify. */ 196 export function renderVerifyPreview( 197 rows: Record<string, unknown>[], 198 opts: { maxRows?: number; maxCols?: number; cellMax?: number } = {}, 199 ): string { 200 const maxRows = opts.maxRows ?? 10; 201 const maxCols = opts.maxCols ?? 6; 202 const cellMax = opts.cellMax ?? 40; 203 if (rows.length === 0) return ' (no rows)'; 204 205 const allCols = Array.from(new Set(rows.flatMap((r) => Object.keys(r)))); 206 const cols = allCols.slice(0, maxCols); 207 const shown = rows.slice(0, maxRows); 208 const cellOf = (v: unknown): string => { 209 if (v === null || v === undefined) return ''; 210 const s = typeof v === 'object' ? JSON.stringify(v) : String(v); 211 return s.replace(/\s+/g, ' ').slice(0, cellMax); 212 }; 213 const widths = cols.map((c) => Math.max(c.length, ...shown.map((r) => cellOf(r[c]).length))); 214 const fmtRow = (vals: string[]): string => vals.map((v, i) => v.padEnd(widths[i])).join(' '); 215 216 const out: string[] = []; 217 out.push(` ${fmtRow(cols)}`); 218 out.push(` ${widths.map((w) => '-'.repeat(w)).join(' ')}`); 219 for (const r of shown) out.push(` ${fmtRow(cols.map((c) => cellOf(r[c])))}`); 220 if (rows.length > maxRows) out.push(` ... and ${rows.length - maxRows} more row(s)`); 221 if (allCols.length > maxCols) out.push(` (${allCols.length - maxCols} more column(s) hidden)`); 222 return out.join('\n'); 223 } 224 225 type BrowserTargetState = { 226 defaultPage?: string; 227 updatedAt: string; 228 }; 229 230 type BrowserTabSummary = { 231 page?: string; 232 }; 233 234 function getBrowserCacheDir(): string { 235 return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache'); 236 } 237 238 function getBrowserTargetStatePath(scope: string = DEFAULT_BROWSER_WORKSPACE): string { 239 const safeWorkspace = scope.replace(/[^a-zA-Z0-9_-]+/g, '_'); 240 return path.join(getBrowserCacheDir(), 'browser-state', `${safeWorkspace}.json`); 241 } 242 243 function loadBrowserTargetState(scope: string = DEFAULT_BROWSER_WORKSPACE): BrowserTargetState | null { 244 try { 245 const raw = fs.readFileSync(getBrowserTargetStatePath(scope), 'utf-8'); 246 const parsed = JSON.parse(raw) as BrowserTargetState | null; 247 return parsed && typeof parsed === 'object' ? parsed : null; 248 } catch { 249 return null; 250 } 251 } 252 253 function saveBrowserTargetState(defaultPage?: string, scope: string = DEFAULT_BROWSER_WORKSPACE): void { 254 const target = getBrowserTargetStatePath(scope); 255 if (!defaultPage) { 256 fs.rmSync(target, { force: true }); 257 return; 258 } 259 fs.mkdirSync(path.dirname(target), { recursive: true }); 260 fs.writeFileSync(target, JSON.stringify({ defaultPage, updatedAt: new Date().toISOString() }), 'utf-8'); 261 } 262 263 function hasBrowserTabTarget(tabs: unknown[], targetPage: string): boolean { 264 return tabs.some((tab) => { 265 return typeof tab === 'object' 266 && tab !== null 267 && 'page' in tab 268 && typeof (tab as BrowserTabSummary).page === 'string' 269 && (tab as BrowserTabSummary).page === targetPage; 270 }); 271 } 272 273 async function resolveBrowserTargetInSession( 274 page: import('./types.js').IPage, 275 targetPage: string, 276 opts: { scope?: string; source: 'explicit' | 'saved' }, 277 ): Promise<string | undefined> { 278 const candidate = targetPage.trim(); 279 if (!candidate) return undefined; 280 281 let tabs: unknown[]; 282 try { 283 tabs = await page.tabs(); 284 } catch (err) { 285 if (opts.source === 'saved') { 286 saveBrowserTargetState(undefined, opts.scope); 287 return undefined; 288 } 289 throw new Error( 290 `Target tab ${candidate} could not be validated in the current browser session. ` + 291 'The Browser Bridge workspace may have restarted; re-run "opencli browser tab list" and choose a current target.', 292 { cause: err }, 293 ); 294 } 295 296 if (Array.isArray(tabs) && hasBrowserTabTarget(tabs, candidate)) { 297 return candidate; 298 } 299 300 if (opts.source === 'saved') { 301 saveBrowserTargetState(undefined, opts.scope); 302 return undefined; 303 } 304 305 throw new Error( 306 `Target tab ${candidate} is not part of the current browser session. ` + 307 'The Browser Bridge workspace may have restarted; re-run "opencli browser tab list" and choose a current target.', 308 ); 309 } 310 311 async function resolveStoredBrowserTarget(page: import('./types.js').IPage, scope: string = DEFAULT_BROWSER_WORKSPACE): Promise<string | undefined> { 312 const defaultPage = loadBrowserTargetState(scope)?.defaultPage?.trim(); 313 if (!defaultPage) return undefined; 314 return resolveBrowserTargetInSession(page, defaultPage, { scope, source: 'saved' }); 315 } 316 317 /** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */ 318 async function getBrowserPage(targetPage?: string): Promise<import('./types.js').IPage> { 319 const { BrowserBridge } = await import('./browser/index.js'); 320 const bridge = new BrowserBridge(); 321 const envTimeout = process.env.OPENCLI_BROWSER_TIMEOUT; 322 const idleTimeout = envTimeout ? parseInt(envTimeout, 10) : undefined; 323 const page = await bridge.connect({ 324 timeout: 30, 325 workspace: DEFAULT_BROWSER_WORKSPACE, 326 ...(idleTimeout && idleTimeout > 0 && { idleTimeout }), 327 }); 328 const resolvedTargetPage = targetPage 329 ? await resolveBrowserTargetInSession(page, targetPage, { scope: DEFAULT_BROWSER_WORKSPACE, source: 'explicit' }) 330 : await resolveStoredBrowserTarget(page, DEFAULT_BROWSER_WORKSPACE); 331 if (resolvedTargetPage) { 332 if (!page.setActivePage) { 333 throw new Error('This browser session does not support explicit tab targeting'); 334 } 335 page.setActivePage(resolvedTargetPage); 336 } 337 return page; 338 } 339 340 function addBrowserTabOption(command: Command): Command { 341 return command.option('--tab <targetId>', BROWSER_TAB_OPTION_DESCRIPTION); 342 } 343 344 function getBrowserTargetId(command?: Command): string | undefined { 345 if (!command) return undefined; 346 const opts = command.optsWithGlobals ? command.optsWithGlobals() : command.opts(); 347 return typeof opts.tab === 'string' && opts.tab.trim() ? opts.tab.trim() : undefined; 348 } 349 350 function resolveBrowserTabTarget(targetId?: string, opts?: { tab?: string }): string | undefined { 351 if (typeof targetId === 'string' && targetId.trim()) return targetId.trim(); 352 if (typeof opts?.tab === 'string' && opts.tab.trim()) return opts.tab.trim(); 353 return undefined; 354 } 355 356 function parsePositiveIntOption(val: string | undefined, label: string, fallback: number): number { 357 if (val === undefined) return fallback; 358 const parsed = parseInt(val, 10); 359 if (Number.isNaN(parsed) || parsed <= 0) { 360 console.error(`[cli] Invalid ${label}="${val}", using default ${fallback}`); 361 return fallback; 362 } 363 return parsed; 364 } 365 366 function applyVerbose(opts: { verbose?: boolean }): void { 367 if (opts.verbose) process.env.OPENCLI_VERBOSE = '1'; 368 } 369 370 export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command { 371 const program = new Command(); 372 // enablePositionalOptions: prevents parent from consuming flags meant for subcommands; 373 // prerequisite for passThroughOptions to forward --help/--version to external binaries 374 program 375 .name('opencli') 376 .description('Make any website your CLI. Zero setup. AI-powered.') 377 .version(PKG_VERSION) 378 .enablePositionalOptions(); 379 380 // ── Built-in: list ──────────────────────────────────────────────────────── 381 382 program 383 .command('list') 384 .description('List all available CLI commands') 385 .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table') 386 .option('--json', 'JSON output (deprecated)') 387 .action((opts) => { 388 const registry = getRegistry(); 389 const commands = [...new Set(registry.values())].sort((a, b) => fullName(a).localeCompare(fullName(b))); 390 const fmt = opts.json && opts.format === 'table' ? 'json' : opts.format; 391 const isStructured = fmt === 'json' || fmt === 'yaml'; 392 393 if (fmt !== 'table') { 394 const rows = isStructured 395 ? commands.map(serializeCommand) 396 : commands.map(c => ({ 397 command: fullName(c), 398 site: c.site, 399 name: c.name, 400 aliases: c.aliases?.join(', ') ?? '', 401 description: c.description, 402 strategy: strategyLabel(c), 403 browser: !!c.browser, 404 args: formatArgSummary(c.args), 405 })); 406 renderOutput(rows, { 407 fmt, 408 columns: ['command', 'site', 'name', 'aliases', 'description', 'strategy', 'browser', 'args', 409 ...(isStructured ? ['columns', 'domain'] : [])], 410 title: 'opencli/list', 411 source: 'opencli list', 412 }); 413 return; 414 } 415 416 // Table (default) — grouped by site 417 const sites = new Map<string, CliCommand[]>(); 418 for (const cmd of commands) { 419 const g = sites.get(cmd.site) ?? []; 420 g.push(cmd); 421 sites.set(cmd.site, g); 422 } 423 424 console.log(); 425 console.log(styleText('bold', ' opencli') + styleText('dim', ' — available commands')); 426 console.log(); 427 for (const [site, cmds] of sites) { 428 console.log(styleText(['bold', 'cyan'], ` ${site}`)); 429 for (const cmd of cmds) { 430 const label = strategyLabel(cmd); 431 const tag = label === 'public' 432 ? styleText('green', '[public]') 433 : styleText('yellow', `[${label}]`); 434 const aliases = cmd.aliases?.length ? styleText('dim', ` (aliases: ${cmd.aliases.join(', ')})`) : ''; 435 console.log(` ${cmd.name} ${tag}${aliases}${cmd.description ? styleText('dim', ` — ${cmd.description}`) : ''}`); 436 } 437 console.log(); 438 } 439 440 const externalClis = loadExternalClis(); 441 if (externalClis.length > 0) { 442 console.log(styleText(['bold', 'cyan'], ' external CLIs')); 443 for (const ext of externalClis) { 444 const isInstalled = isBinaryInstalled(ext.binary); 445 const tag = isInstalled ? styleText('green', '[installed]') : styleText('yellow', '[auto-install]'); 446 console.log(` ${ext.name} ${tag}${ext.description ? styleText('dim', ` — ${ext.description}`) : ''}`); 447 } 448 console.log(); 449 } 450 451 console.log(styleText('dim', ` ${commands.length} built-in commands across ${sites.size} sites, ${externalClis.length} external CLIs`)); 452 console.log(); 453 }); 454 455 // ── Built-in: validate / verify ─────────────────────────────────────────── 456 457 program 458 .command('validate') 459 .description('Validate CLI definitions') 460 .argument('[target]', 'site or site/name') 461 .action(async (target) => { 462 const { validateClisWithTarget, renderValidationReport } = await import('./validate.js'); 463 console.log(renderValidationReport(validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target))); 464 }); 465 466 program 467 .command('verify') 468 .description('Validate + smoke test') 469 .argument('[target]') 470 .option('--smoke', 'Run smoke tests', false) 471 .action(async (target, opts) => { 472 const { verifyClis, renderVerifyReport } = await import('./verify.js'); 473 const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke }); 474 console.log(renderVerifyReport(r)); 475 process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; 476 }); 477 478 // ── Built-in: browser (browser control for Claude Code skill) ─────────────── 479 // 480 // Make websites accessible for AI agents. 481 // All commands wrapped in browserAction() for consistent error handling. 482 483 const browser = program 484 .command('browser') 485 .description('Browser control — navigate, click, type, extract, wait (no LLM needed)'); 486 487 /** 488 * Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver. 489 * Returns the CSS match count so callers can propagate `matches_n` into the 490 * JSON envelope printed back to the agent. 491 */ 492 async function resolveRef( 493 page: Awaited<ReturnType<typeof getBrowserPage>>, 494 ref: string, 495 opts: ResolveOptions = {}, 496 ): Promise<{ matches_n: number; match_level: TargetMatchLevel }> { 497 const resolution = await page.evaluate(resolveTargetJs(ref, opts)) as 498 | { ok: true; matches_n: number; match_level: TargetMatchLevel } 499 | { ok: false; code: TargetErrorCode; message: string; hint: string; candidates?: string[]; matches_n?: number }; 500 if (!resolution.ok) { 501 throw new TargetError({ 502 code: resolution.code, 503 message: resolution.message, 504 hint: resolution.hint, 505 candidates: resolution.candidates, 506 matches_n: resolution.matches_n, 507 }); 508 } 509 return { matches_n: resolution.matches_n, match_level: resolution.match_level }; 510 } 511 512 /** 513 * Parse `--nth <n>` flag, returning the parsed 0-based index or a usage error. 514 * The surface mirrors `--depth` etc. in `browser get html --as json`: the flag 515 * is optional, must be a non-negative integer when present, and on failure we 516 * emit the structured error envelope rather than throwing past the command. 517 */ 518 function parseNthFlag(raw: unknown): number | null | { error: string } { 519 if (raw === undefined || raw === null || raw === '') return null; 520 const str = String(raw); 521 if (!/^\d+$/.test(str)) { 522 return { error: `--nth must be a non-negative integer, got "${str}"` }; 523 } 524 return Number.parseInt(str, 10); 525 } 526 527 /** Emit the `{ error: { code, message, hint?, candidates?, matches_n? } }` envelope used by the selector-first commands. */ 528 function emitTargetError(err: TargetError): void { 529 console.log(JSON.stringify({ 530 error: { 531 code: err.code, 532 message: err.message, 533 hint: err.hint, 534 ...(err.candidates && { candidates: err.candidates }), 535 ...(err.matches_n !== undefined && { matches_n: err.matches_n }), 536 }, 537 }, null, 2)); 538 } 539 540 /** Wrap browser actions with error handling and optional --json output */ 541 function browserAction(fn: (page: Awaited<ReturnType<typeof getBrowserPage>>, ...args: any[]) => Promise<unknown>) { 542 return async (...args: any[]) => { 543 try { 544 const command = args.at(-1) instanceof Command ? args.at(-1) as Command : undefined; 545 const targetPage = getBrowserTargetId(command); 546 const page = await getBrowserPage(targetPage); 547 await fn(page, ...args); 548 } catch (err) { 549 if (err instanceof BrowserConnectError) { 550 log.error(err.message); 551 if (err.hint) log.error(`Hint: ${err.hint}`); 552 } else if (err instanceof TargetError) { 553 // Agent-facing structured envelope on stdout + short human line on stderr. 554 emitTargetError(err); 555 log.error(`[${err.code}] ${err.message}`); 556 if (err.hint) log.error(`Hint: ${err.hint}`); 557 } else { 558 const msg = getErrorMessage(err); 559 if (msg.includes('attach failed') || msg.includes('chrome-extension://')) { 560 log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`); 561 } else { 562 log.error(msg); 563 } 564 } 565 process.exitCode = EXIT_CODES.GENERIC_ERROR; 566 } 567 }; 568 } 569 570 const browserTab = browser 571 .command('tab') 572 .description('Tab management — list, create, and close tabs in the automation window'); 573 574 browserTab.command('list') 575 .description('List tabs in the automation window with target IDs') 576 .action(browserAction(async (page) => { 577 const tabs = await page.tabs(); 578 console.log(JSON.stringify(tabs, null, 2)); 579 })); 580 581 browserTab.command('new') 582 .argument('[url]', 'Optional URL to open in the new tab') 583 .description('Create a new tab and print its target ID') 584 .action(browserAction(async (page, url?: string) => { 585 if (!page.newTab) { 586 throw new Error('This browser session does not support creating tabs'); 587 } 588 const createdPage = await page.newTab(url); 589 console.log(JSON.stringify({ 590 page: createdPage, 591 url: url ?? null, 592 }, null, 2)); 593 })); 594 595 addBrowserTabOption(browserTab.command('select') 596 .argument('[targetId]', 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"') 597 .description('Select a tab by target ID and make it the default browser tab')) 598 .action(browserAction(async (page, targetId?: string, opts?: { tab?: string }) => { 599 const resolvedTarget = resolveBrowserTabTarget(targetId, opts); 600 if (!resolvedTarget) { 601 throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.'); 602 } 603 await page.selectTab(resolvedTarget); 604 saveBrowserTargetState(resolvedTarget, DEFAULT_BROWSER_WORKSPACE); 605 console.log(JSON.stringify({ selected: resolvedTarget }, null, 2)); 606 })); 607 608 addBrowserTabOption(browserTab.command('close') 609 .argument('[targetId]', 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"') 610 .description('Close a tab by target ID')) 611 .action(browserAction(async (page, targetId?: string, opts?: { tab?: string }) => { 612 const resolvedTarget = resolveBrowserTabTarget(targetId, opts); 613 if (!page.closeTab) { 614 throw new Error('This browser session does not support closing tabs'); 615 } 616 if (!resolvedTarget) { 617 throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.'); 618 } 619 const validatedTarget = await resolveBrowserTargetInSession(page, resolvedTarget, { 620 scope: DEFAULT_BROWSER_WORKSPACE, 621 source: 'explicit', 622 }); 623 if (!validatedTarget) { 624 throw new Error(`Target tab ${resolvedTarget} is not part of the current browser session.`); 625 } 626 await page.closeTab(validatedTarget); 627 if (loadBrowserTargetState(DEFAULT_BROWSER_WORKSPACE)?.defaultPage === validatedTarget) { 628 saveBrowserTargetState(undefined, DEFAULT_BROWSER_WORKSPACE); 629 } 630 console.log(JSON.stringify({ closed: validatedTarget }, null, 2)); 631 })); 632 633 // ── Navigation ── 634 635 /** 636 * Network interceptor JS — injected on every open/navigate to capture 637 * fetch/XHR bodies when the session-level capture channel (CDP/extension) 638 * isn't available. Keeps parity with the CDP path's truncation contract: 639 * when a body exceeds the per-entry cap, we keep a string prefix and set 640 * `bodyTruncated: true` + `bodyFullSize: <original length>` so `browser 641 * network` can propagate a visible signal to the agent instead of 642 * silently dropping the body. Per-entry cap is 1 MiB and the ring is 643 * capped at 200 entries, bounding worst-case in-page memory. 644 */ 645 const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`; 646 647 addBrowserTabOption(browser.command('open').argument('<url>').description('Open URL in automation window')) 648 .action(browserAction(async (page, url) => { 649 // Start session-level capture before navigation (catches initial requests) 650 const hasSessionCapture = await page.startNetworkCapture?.() ?? false; 651 await page.goto(url); 652 await page.wait(2); 653 // Fallback: inject JS interceptor when session capture is unavailable 654 if (!hasSessionCapture) { 655 try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ } 656 } 657 console.log(JSON.stringify({ 658 url: await page.getCurrentUrl?.() ?? url, 659 ...(page.getActivePage?.() ? { page: page.getActivePage?.() } : {}), 660 }, null, 2)); 661 })); 662 663 addBrowserTabOption(browser.command('back').description('Go back in browser history')) 664 .action(browserAction(async (page) => { 665 await page.evaluate('history.back()'); 666 await page.wait(2); 667 console.log('Navigated back'); 668 })); 669 670 addBrowserTabOption(browser.command('scroll').argument('<direction>', 'up or down').option('--amount <pixels>', 'Pixels to scroll', '500')) 671 .description('Scroll page') 672 .action(browserAction(async (page, direction, opts) => { 673 if (direction !== 'up' && direction !== 'down') { 674 console.error(`Invalid direction "${direction}". Use "up" or "down".`); 675 process.exitCode = EXIT_CODES.USAGE_ERROR; 676 return; 677 } 678 await page.scroll(direction, parseInt(opts.amount, 10)); 679 console.log(`Scrolled ${direction}`); 680 })); 681 682 // ── Inspect ── 683 684 addBrowserTabOption(browser.command('state').description('Page state: URL, title, interactive elements with [N] indices')) 685 .action(browserAction(async (page) => { 686 const snapshot = await page.snapshot({ viewportExpand: 2000 }); 687 const url = await page.getCurrentUrl?.() ?? ''; 688 console.log(`URL: ${url}\n`); 689 console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2)); 690 })); 691 692 addBrowserTabOption(browser.command('frames').description('List cross-origin iframe targets in snapshot order')) 693 .action(browserAction(async (page) => { 694 const frames = await page.frames?.() ?? []; 695 console.log(JSON.stringify(frames, null, 2)); 696 })); 697 698 addBrowserTabOption(browser.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)')) 699 .description('Take screenshot') 700 .action(browserAction(async (page, path) => { 701 if (path) { 702 await page.screenshot({ path }); 703 console.log(`Screenshot saved to: ${path}`); 704 } else { 705 console.log(await page.screenshot({ format: 'png' })); 706 } 707 })); 708 709 // ── Analyze (site recon, agent-native) ── 710 // 711 // Mechanizes the `site-recon.md` decision tree into one CLI call. The agent 712 // calls `browser analyze <url>` and gets back: 713 // 714 // - pattern: A/B/C/D (mapped from network + SSR-globals signals) 715 // - anti_bot: vendor + evidence + the one-liner for "what to do next" 716 // - initial_state: which window globals are populated 717 // - nearest_adapter: existing commands for the same site, if any 718 // - recommended_next_step: a single imperative sentence 719 // 720 // Intent: replace the "open → eyeball network → curl → WAF → try again" 721 // feedback loop with a single deterministic verdict. Without this, agents 722 // burn ~20min per WAF-protected site re-discovering anti-bot posture. 723 addBrowserTabOption(browser.command('analyze').argument('<url>')) 724 .description('Classify site: anti-bot vendor, pattern (A/B/C/D), nearest adapter, recommended next step') 725 .action(browserAction(async (page, url) => { 726 const hasSessionCapture = await page.startNetworkCapture?.() ?? false; 727 await page.goto(url); 728 await page.wait(2); 729 if (!hasSessionCapture) { 730 try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ } 731 } 732 await captureNetworkItems(page); 733 // Best-effort: give the page another beat so XHR after DOMContentLoaded lands. 734 await page.wait(1); 735 736 const rawItems = await captureNetworkItems(page); 737 const networkEntries = rawItems.map((e) => ({ 738 url: e.url, 739 status: e.status, 740 contentType: e.ct, 741 bodyPreview: typeof e.body === 'string' 742 ? e.body.slice(0, 2000) 743 : (e.body ? JSON.stringify(e.body).slice(0, 2000) : null), 744 })); 745 746 const probeJs = `(function(){ 747 return { 748 cookieNames: (document.cookie || '').split(';').map(function(c){ return c.trim().split('=')[0]; }).filter(Boolean), 749 initialState: { 750 __INITIAL_STATE__: typeof window.__INITIAL_STATE__ !== 'undefined', 751 __NUXT__: typeof window.__NUXT__ !== 'undefined', 752 __NEXT_DATA__: typeof window.__NEXT_DATA__ !== 'undefined', 753 __APOLLO_STATE__: typeof window.__APOLLO_STATE__ !== 'undefined', 754 }, 755 title: document.title || '', 756 finalUrl: location.href, 757 }; 758 })()`; 759 const probe = await page.evaluate(probeJs) as { 760 cookieNames: string[]; 761 initialState: PageSignals['initialState']; 762 title: string; 763 finalUrl: string; 764 }; 765 const browserCookieNames = (await page.getCookies({ url: probe.finalUrl || url }).catch(() => [])) 766 .map((c) => c.name) 767 .filter(Boolean); 768 const cookieNames = [...new Set([...probe.cookieNames, ...browserCookieNames])]; 769 770 const signals: PageSignals = { 771 requestedUrl: url, 772 finalUrl: probe.finalUrl, 773 cookieNames, 774 networkEntries, 775 initialState: probe.initialState, 776 title: probe.title, 777 }; 778 const report = analyzeSite(signals, getRegistry()); 779 console.log(JSON.stringify(report, null, 2)); 780 })); 781 782 // ── Find (structured CSS query, agent-native) ── 783 // 784 // `browser find --css <sel>` lets agents jump straight from a semantic 785 // selector to a JSON list of matching elements, without having to parse 786 // the free-text state snapshot to recover indices. 787 addBrowserTabOption( 788 browser.command('find') 789 .option('--css <selector>', 'CSS selector (required)') 790 .option('--limit <n>', 'Max entries returned', '50') 791 .option('--text-max <n>', 'Max chars of trimmed text per entry', '120') 792 .description('Find DOM elements by CSS selector — returns JSON {matches_n, entries[]}'), 793 ) 794 .action(browserAction(async (page, opts) => { 795 if (!opts.css || typeof opts.css !== 'string') { 796 console.log(JSON.stringify({ 797 error: { 798 code: 'usage_error', 799 message: '--css <selector> is required', 800 hint: 'Example: opencli browser find --css ".btn.primary"', 801 }, 802 }, null, 2)); 803 process.exitCode = EXIT_CODES.USAGE_ERROR; 804 return; 805 } 806 const limit = parseNthFlag(opts.limit); 807 if (limit && typeof limit === 'object' && 'error' in limit) { 808 console.log(JSON.stringify({ error: { code: 'usage_error', message: limit.error.replace('--nth', '--limit') } }, null, 2)); 809 process.exitCode = EXIT_CODES.USAGE_ERROR; 810 return; 811 } 812 const textMax = parseNthFlag(opts.textMax); 813 if (textMax && typeof textMax === 'object' && 'error' in textMax) { 814 console.log(JSON.stringify({ error: { code: 'usage_error', message: textMax.error.replace('--nth', '--text-max') } }, null, 2)); 815 process.exitCode = EXIT_CODES.USAGE_ERROR; 816 return; 817 } 818 const result = await page.evaluate(buildFindJs(opts.css, { 819 limit: limit as number | null ?? undefined, 820 textMax: textMax as number | null ?? undefined, 821 })) as FindResult | FindError; 822 if (isFindError(result)) { 823 console.log(JSON.stringify(result, null, 2)); 824 process.exitCode = EXIT_CODES.GENERIC_ERROR; 825 return; 826 } 827 console.log(JSON.stringify(result, null, 2)); 828 })); 829 830 // ── Get commands (structured data extraction) ── 831 832 const get = browser.command('get').description('Get page properties'); 833 834 addBrowserTabOption(get.command('title').description('Page title')) 835 .action(browserAction(async (page) => { 836 console.log(await page.evaluate('document.title')); 837 })); 838 839 addBrowserTabOption(get.command('url').description('Current page URL')) 840 .action(browserAction(async (page) => { 841 console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href')); 842 })); 843 844 // Read commands (`get text/value/attributes`) always emit a JSON envelope: 845 // 846 // { value, matches_n } — success 847 // { error: { code, message, hint, matches_n? } } — structured failure 848 // 849 // `<target>` accepts either a numeric ref (from `browser state`/`browser find`) 850 // or a CSS selector. On multi-match CSS, the first element wins and the real 851 // match count is exposed via `matches_n`; `--nth <n>` picks a specific one. 852 const runGetCommand = async ( 853 page: Awaited<ReturnType<typeof getBrowserPage>>, 854 target: string, 855 opts: { nth?: string }, 856 evalJs: string, 857 field: 'text' | 'value' | 'attributes', 858 ): Promise<void> => { 859 const nth = parseNthFlag(opts.nth); 860 if (nth && typeof nth === 'object' && 'error' in nth) { 861 console.log(JSON.stringify({ error: { code: 'usage_error', message: nth.error } }, null, 2)); 862 process.exitCode = EXIT_CODES.USAGE_ERROR; 863 return; 864 } 865 const { matches_n, match_level } = await resolveRef(page, String(target), { 866 firstOnMulti: nth === null, 867 ...(typeof nth === 'number' ? { nth } : {}), 868 }); 869 const raw = await page.evaluate(evalJs); 870 let value: unknown; 871 if (field === 'attributes') { 872 // getAttributesResolvedJs stringifies the attribute record — parse it back so 873 // the JSON envelope contains a real object rather than a nested JSON string. 874 try { value = raw == null ? {} : JSON.parse(String(raw)); } 875 catch { value = raw; } 876 } else { 877 value = raw ?? null; 878 } 879 console.log(JSON.stringify({ value, matches_n, match_level }, null, 2)); 880 }; 881 882 addBrowserTabOption( 883 get.command('text') 884 .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector') 885 .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector') 886 .description('Element text content — JSON envelope {value, matches_n}'), 887 ) 888 .action(browserAction(async (page, target, opts) => 889 runGetCommand(page, String(target), opts ?? {}, getTextResolvedJs(), 'text'))); 890 891 addBrowserTabOption( 892 get.command('value') 893 .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector') 894 .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector') 895 .description('Input/textarea value — JSON envelope {value, matches_n}'), 896 ) 897 .action(browserAction(async (page, target, opts) => 898 runGetCommand(page, String(target), opts ?? {}, getValueResolvedJs(), 'value'))); 899 900 addBrowserTabOption( 901 get.command('html') 902 .option('--selector <css>', 'CSS selector scope (first match)') 903 .option('--as <format>', 'Output format: "html" (default) or "json" for structured tree', 'html') 904 .option('--max <n>', 'Max characters of raw HTML to return (0 = unlimited)', '0') 905 .option('--depth <n>', '(--as json) Max tree depth below root (0 = root only, 0 disables = unlimited via empty)', '') 906 .option('--children-max <n>', '(--as json) Max element children kept per node (empty = unlimited)', '') 907 .option('--text-max <n>', '(--as json) Max chars of direct text kept per node (empty = unlimited)', '') 908 .description('Page HTML (or scoped); use --as json for a {tag, attrs, text, children} tree'), 909 ) 910 .action(browserAction(async (page, opts) => { 911 const format = String(opts.as || 'html').toLowerCase(); 912 if (format !== 'html' && format !== 'json') { 913 console.log(JSON.stringify({ error: { code: 'invalid_format', message: `--as must be "html" or "json", got "${opts.as}"` } }, null, 2)); 914 process.exitCode = EXIT_CODES.USAGE_ERROR; 915 return; 916 } 917 918 // `--max` is validated up-front (before touching the page) so a bad value 919 // gets the same structured error regardless of selector/format path. 920 const rawMax = String(opts.max ?? '0'); 921 if (!/^\d+$/.test(rawMax)) { 922 console.log(JSON.stringify({ error: { code: 'invalid_max', message: `--max must be a non-negative integer, got "${opts.max}"` } }, null, 2)); 923 process.exitCode = EXIT_CODES.USAGE_ERROR; 924 return; 925 } 926 const max = Number.parseInt(rawMax, 10); 927 928 if (format === 'json') { 929 const parseBudget = (flag: string, value: unknown): number | null | { error: string } => { 930 const raw = value === undefined || value === null ? '' : String(value); 931 if (raw === '') return null; 932 if (!/^\d+$/.test(raw)) return { error: `${flag} must be a non-negative integer, got "${raw}"` }; 933 return Number.parseInt(raw, 10); 934 }; 935 const depth = parseBudget('--depth', opts.depth); 936 const childrenMax = parseBudget('--children-max', opts.childrenMax); 937 const textMax = parseBudget('--text-max', opts.textMax); 938 for (const budget of [depth, childrenMax, textMax]) { 939 if (budget && typeof budget === 'object' && 'error' in budget) { 940 console.log(JSON.stringify({ error: { code: 'invalid_budget', message: budget.error } }, null, 2)); 941 process.exitCode = EXIT_CODES.USAGE_ERROR; 942 return; 943 } 944 } 945 const js = buildHtmlTreeJs({ 946 selector: opts.selector ?? null, 947 depth: depth as number | null, 948 childrenMax: childrenMax as number | null, 949 textMax: textMax as number | null, 950 }); 951 const result = await page.evaluate(js) as HtmlTreeResult | { selector: string; invalidSelector: true; reason: string } | null; 952 if (result && typeof result === 'object' && 'invalidSelector' in result && result.invalidSelector) { 953 console.log(JSON.stringify({ 954 error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${result.reason}` }, 955 }, null, 2)); 956 process.exitCode = EXIT_CODES.USAGE_ERROR; 957 return; 958 } 959 const ok = result as HtmlTreeResult | null; 960 if (!ok || ok.matched === 0) { 961 console.log(JSON.stringify({ 962 error: { 963 code: 'selector_not_found', 964 message: opts.selector 965 ? `Selector "${opts.selector}" matched 0 elements.` 966 : 'Page has no documentElement.', 967 }, 968 }, null, 2)); 969 process.exitCode = EXIT_CODES.USAGE_ERROR; 970 return; 971 } 972 console.log(JSON.stringify(ok, null, 2)); 973 return; 974 } 975 976 // Raw HTML path — unbounded by default; --max optionally caps with a visible marker. 977 // Selector lookup is wrapped in try/catch inside page context so an invalid 978 // selector returns a structured signal instead of throwing through page.evaluate. 979 const sel = opts.selector ? JSON.stringify(opts.selector) : 'null'; 980 const rawResult = await page.evaluate( 981 `(() => { 982 const s = ${sel}; 983 if (s) { 984 try { 985 const el = document.querySelector(s); 986 return { kind: 'ok', html: el ? el.outerHTML : null }; 987 } catch (e) { 988 return { kind: 'invalid_selector', reason: (e && e.message) || String(e) }; 989 } 990 } 991 return { kind: 'ok', html: document.documentElement ? document.documentElement.outerHTML : null }; 992 })()`, 993 ) as { kind: 'ok'; html: string | null } | { kind: 'invalid_selector'; reason: string }; 994 995 if (rawResult.kind === 'invalid_selector') { 996 console.log(JSON.stringify({ 997 error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${rawResult.reason}` }, 998 }, null, 2)); 999 process.exitCode = EXIT_CODES.USAGE_ERROR; 1000 return; 1001 } 1002 const html = rawResult.html; 1003 1004 if (html === null) { 1005 if (opts.selector) { 1006 console.log(JSON.stringify({ 1007 error: { code: 'selector_not_found', message: `Selector "${opts.selector}" matched 0 elements.` }, 1008 }, null, 2)); 1009 process.exitCode = EXIT_CODES.USAGE_ERROR; 1010 return; 1011 } 1012 console.log('(empty)'); 1013 return; 1014 } 1015 if (max > 0 && html.length > max) { 1016 console.log(`<!-- opencli: truncated ${max} of ${html.length} chars; re-run without --max (or --max 0) for full -->\n${html.slice(0, max)}`); 1017 return; 1018 } 1019 console.log(html); 1020 })); 1021 1022 addBrowserTabOption( 1023 get.command('attributes') 1024 .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector') 1025 .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector') 1026 .description('Element attributes — JSON envelope {value, matches_n}'), 1027 ) 1028 .action(browserAction(async (page, target, opts) => 1029 runGetCommand(page, String(target), opts ?? {}, getAttributesResolvedJs(), 'attributes'))); 1030 1031 // ── Interact ── 1032 // 1033 // Write commands (`click/type/select`) share the same `<target>` contract 1034 // as the read commands but *reject* multi-match CSS as `selector_ambiguous` 1035 // unless the caller passes `--nth <n>`. That asymmetry is intentional: 1036 // clicking "one of three buttons" at random is almost never what the agent 1037 // meant. Every branch emits a JSON envelope on stdout; error envelopes go 1038 // through the unified TargetError handler in browserAction. 1039 1040 /** 1041 * Parse the `--nth` flag and convert it to `ResolveOptions`. 1042 * Returns `{ error }` when the flag was malformed (so the command can 1043 * print the structured usage error and exit) or `{ opts }` to feed 1044 * into resolveRef / page.click / page.typeText. 1045 */ 1046 function nthToResolveOpts(raw: unknown): { error: string } | { opts: ResolveOptions } { 1047 const parsed = parseNthFlag(raw); 1048 if (parsed && typeof parsed === 'object' && 'error' in parsed) return parsed; 1049 if (typeof parsed === 'number') return { opts: { nth: parsed } }; 1050 return { opts: {} }; 1051 } 1052 1053 addBrowserTabOption( 1054 browser.command('click') 1055 .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector') 1056 .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)') 1057 .description('Click element — JSON envelope {clicked, target, matches_n}'), 1058 ) 1059 .action(browserAction(async (page, target, opts) => { 1060 const parsed = nthToResolveOpts(opts?.nth); 1061 if ('error' in parsed) { 1062 console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2)); 1063 process.exitCode = EXIT_CODES.USAGE_ERROR; 1064 return; 1065 } 1066 const { matches_n, match_level } = await page.click(String(target), parsed.opts); 1067 console.log(JSON.stringify({ clicked: true, target: String(target), matches_n, match_level }, null, 2)); 1068 })); 1069 1070 addBrowserTabOption( 1071 browser.command('type') 1072 .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector') 1073 .argument('<text>', 'Text to type') 1074 .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)') 1075 .description('Click element, then type text — JSON envelope {typed, text, target, matches_n, autocomplete}'), 1076 ) 1077 .action(browserAction(async (page, target, text, opts) => { 1078 const parsed = nthToResolveOpts(opts?.nth); 1079 if ('error' in parsed) { 1080 console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2)); 1081 process.exitCode = EXIT_CODES.USAGE_ERROR; 1082 return; 1083 } 1084 // Click first (focuses the field), wait briefly, then type. 1085 await page.click(String(target), parsed.opts); 1086 await page.wait(0.3); 1087 const { matches_n, match_level } = await page.typeText(String(target), String(text), parsed.opts); 1088 // __resolved is already set by the resolver call inside page.typeText 1089 const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs()) as boolean; 1090 if (isAutocomplete) await page.wait(0.4); 1091 console.log(JSON.stringify({ 1092 typed: true, 1093 text: String(text), 1094 target: String(target), 1095 matches_n, 1096 match_level, 1097 autocomplete: !!isAutocomplete, 1098 }, null, 2)); 1099 })); 1100 1101 addBrowserTabOption( 1102 browser.command('select') 1103 .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector of a <select> element') 1104 .argument('<option>', 'Option text (or value) to select') 1105 .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)') 1106 .description('Select dropdown option — JSON envelope {selected, target, matches_n}'), 1107 ) 1108 .action(browserAction(async (page, target, option, opts) => { 1109 const parsed = nthToResolveOpts(opts?.nth); 1110 if ('error' in parsed) { 1111 console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2)); 1112 process.exitCode = EXIT_CODES.USAGE_ERROR; 1113 return; 1114 } 1115 const { matches_n, match_level } = await resolveRef(page, String(target), parsed.opts); 1116 const result = await page.evaluate(selectResolvedJs(String(option))) as 1117 | { error?: string; selected?: string; available?: string[] } 1118 | null; 1119 if (result?.error) { 1120 // The select-specific "Not a <select>" / "Option not found" errors 1121 // are domain-level failures — emit a structured envelope so agents 1122 // can branch on code rather than scrape a log line. 1123 console.log(JSON.stringify({ 1124 error: { 1125 code: result.error === 'Not a <select>' ? 'not_a_select' : 'option_not_found', 1126 message: result.error, 1127 ...(result.available && { available: result.available }), 1128 matches_n, 1129 }, 1130 }, null, 2)); 1131 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1132 return; 1133 } 1134 console.log(JSON.stringify({ 1135 selected: result?.selected ?? String(option), 1136 target: String(target), 1137 matches_n, 1138 match_level, 1139 }, null, 2)); 1140 })); 1141 1142 addBrowserTabOption(browser.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)')) 1143 .description('Press keyboard key') 1144 .action(browserAction(async (page, key) => { 1145 await page.pressKey(key); 1146 console.log(`Pressed: ${key}`); 1147 })); 1148 1149 // ── Wait commands ── 1150 1151 addBrowserTabOption(browser.command('wait')) 1152 .argument('<type>', 'selector, text, time, or xhr') 1153 .argument('[value]', 'CSS selector, text string, seconds, or XHR URL regex') 1154 .option('--timeout <ms>', 'Timeout in milliseconds', '10000') 1155 .description('Wait for selector, text, time, or matching XHR (e.g. wait selector ".loaded", wait text "Success", wait time 3, wait xhr "/api/search")') 1156 .action(browserAction(async (page, type, value, opts) => { 1157 const timeout = parseInt(opts.timeout, 10); 1158 if (type === 'time') { 1159 const seconds = parseFloat(value ?? '2'); 1160 await page.wait(seconds); 1161 console.log(`Waited ${seconds}s`); 1162 } else if (type === 'selector') { 1163 if (!value) { console.error('Missing CSS selector'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } 1164 await page.wait({ selector: value, timeout: timeout / 1000 }); 1165 console.log(`Element "${value}" appeared`); 1166 } else if (type === 'text') { 1167 if (!value) { console.error('Missing text'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } 1168 await page.wait({ text: value, timeout: timeout / 1000 }); 1169 console.log(`Text "${value}" appeared`); 1170 } else if (type === 'xhr') { 1171 // Poll the capture ring until an entry matches the URL regex — turns 1172 // the common "open page, wait N seconds, hope the data landed" idiom 1173 // into a deterministic barrier keyed on the API the agent actually 1174 // cares about. Prevents silent "empty DOM" failures on slow SPAs. 1175 if (!value) { console.error('Missing XHR URL regex'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } 1176 let re: RegExp; 1177 try { re = new RegExp(value); } catch (err) { 1178 console.error(`Invalid regex "${value}": ${err instanceof Error ? err.message : String(err)}`); 1179 process.exitCode = EXIT_CODES.USAGE_ERROR; 1180 return; 1181 } 1182 const hasSessionCapture = await page.startNetworkCapture?.() ?? false; 1183 if (!hasSessionCapture) { 1184 try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ } 1185 } 1186 await captureNetworkItems(page); 1187 const deadline = Date.now() + timeout; 1188 const pollMs = 400; 1189 let matched: BrowserNetworkItem | null = null; 1190 while (Date.now() < deadline && !matched) { 1191 const items = await captureNetworkItems(page); 1192 matched = items.find((e) => re.test(e.url)) ?? null; 1193 if (!matched) await new Promise((r) => setTimeout(r, pollMs)); 1194 } 1195 if (!matched) { 1196 console.log(JSON.stringify({ 1197 error: { 1198 code: 'xhr_not_seen', 1199 message: `No captured XHR matched /${value}/ within ${timeout}ms`, 1200 hint: 'Check the pattern against `browser network` output; the endpoint may not have fired yet, or capture is disabled.', 1201 }, 1202 }, null, 2)); 1203 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1204 return; 1205 } 1206 console.log(JSON.stringify({ 1207 matched: { url: matched.url, status: matched.status, contentType: matched.ct }, 1208 }, null, 2)); 1209 } else { 1210 console.error(`Unknown wait type "${type}". Use: selector, text, time, or xhr`); 1211 process.exitCode = EXIT_CODES.USAGE_ERROR; 1212 } 1213 })); 1214 1215 // ── Extract ── 1216 1217 addBrowserTabOption( 1218 browser.command('eval') 1219 .argument('<js>', 'JavaScript code') 1220 .option('--frame <index>', 'Cross-origin iframe index from "browser frames"') 1221 .description('Execute JS in page context, return result'), 1222 ) 1223 .action(browserAction(async (page, js, opts) => { 1224 let result: unknown; 1225 if (opts.frame !== undefined) { 1226 const frameIndex = Number.parseInt(opts.frame, 10); 1227 if (!Number.isInteger(frameIndex) || frameIndex < 0) { 1228 console.error(`Invalid frame index "${opts.frame}". Use a 0-based index from "browser frames".`); 1229 process.exitCode = EXIT_CODES.USAGE_ERROR; 1230 return; 1231 } 1232 if (!page.evaluateInFrame) { 1233 throw new Error('This browser session does not support frame-targeted evaluation'); 1234 } 1235 result = await page.evaluateInFrame(js, frameIndex); 1236 } else { 1237 result = await page.evaluate(js); 1238 } 1239 if (typeof result === 'string') console.log(result); 1240 else console.log(JSON.stringify(result, null, 2)); 1241 })); 1242 1243 // ── Extract (content reading) ── 1244 // 1245 // `extract` answers the "read this page" question that `get html` / `get text` 1246 // can't: denoise → markdown → paragraph-aware chunking. Agents walk long pages 1247 // by passing back the `next_start_char` cursor instead of juggling selectors. 1248 1249 addBrowserTabOption( 1250 browser.command('extract') 1251 .option('--selector <css>', 'CSS selector scope; defaults to <main>/<article>/<body>') 1252 .option('--chunk-size <chars>', 'Target chunk size in chars', '20000') 1253 .option('--start <char>', 'Start offset (use next_start_char from a previous extract)', '0') 1254 .description('Extract page content as markdown, paragraph-aware chunks for long pages'), 1255 ) 1256 .action(browserAction(async (page, opts) => { 1257 const rawChunk = String(opts.chunkSize ?? '20000'); 1258 if (!/^\d+$/.test(rawChunk) || Number.parseInt(rawChunk, 10) <= 0) { 1259 console.log(JSON.stringify({ error: { code: 'invalid_chunk_size', message: `--chunk-size must be a positive integer, got "${opts.chunkSize}"` } }, null, 2)); 1260 process.exitCode = EXIT_CODES.USAGE_ERROR; 1261 return; 1262 } 1263 const rawStart = String(opts.start ?? '0'); 1264 if (!/^\d+$/.test(rawStart)) { 1265 console.log(JSON.stringify({ error: { code: 'invalid_start', message: `--start must be a non-negative integer, got "${opts.start}"` } }, null, 2)); 1266 process.exitCode = EXIT_CODES.USAGE_ERROR; 1267 return; 1268 } 1269 const chunkSize = Number.parseInt(rawChunk, 10); 1270 const start = Number.parseInt(rawStart, 10); 1271 const selector = typeof opts.selector === 'string' && opts.selector.length > 0 ? opts.selector : null; 1272 1273 const js = buildExtractHtmlJs(selector); 1274 const res = await page.evaluate(js) as 1275 | { ok: true; url: string; title: string; html: string } 1276 | { invalidSelector: true; reason: string } 1277 | { notFound: true } 1278 | null; 1279 1280 if (!res) { 1281 console.log(JSON.stringify({ error: { code: 'extract_failed', message: 'Page returned no root element.' } }, null, 2)); 1282 process.exitCode = EXIT_CODES.USAGE_ERROR; 1283 return; 1284 } 1285 if ('invalidSelector' in res) { 1286 console.log(JSON.stringify({ error: { code: 'invalid_selector', message: `Selector "${selector}" is not a valid CSS selector: ${res.reason}` } }, null, 2)); 1287 process.exitCode = EXIT_CODES.USAGE_ERROR; 1288 return; 1289 } 1290 if ('notFound' in res) { 1291 console.log(JSON.stringify({ error: { code: 'selector_not_found', message: selector ? `Selector "${selector}" matched 0 elements.` : 'Page has no body/main/article element.' } }, null, 2)); 1292 process.exitCode = EXIT_CODES.USAGE_ERROR; 1293 return; 1294 } 1295 1296 const envelope = runExtractFromHtml({ 1297 html: res.html, 1298 url: res.url, 1299 title: res.title, 1300 selector, 1301 start, 1302 chunkSize, 1303 }); 1304 console.log(JSON.stringify(envelope, null, 2)); 1305 })); 1306 1307 // ── Network (API discovery) ── 1308 // 1309 // Default output is JSON (agent-native). Each entry carries a stable `key` 1310 // (GraphQL operationName or `METHOD host+pathname`) so agents can fetch 1311 // full bodies with `--detail <key>` even after subsequent commands. 1312 // Captures are persisted per workspace under ~/.opencli/cache/browser-network/. 1313 1314 addBrowserTabOption(browser.command('network')) 1315 .option('--detail <key>', 'Emit full body for the entry with this key') 1316 .option('--all', 'Include static resources (js/css/images/telemetry)') 1317 .option('--raw', 'Emit full bodies for every entry (skip shape preview)') 1318 .option('--filter <fields>', 'Comma-separated field names; keep only entries whose body shape has ALL names as path segments') 1319 .option('--max-body <chars>', 'With --detail: cap the emitted body at N chars (0 = unlimited, default)', '0') 1320 .option('--ttl <ms>', 'Cache TTL in ms for --detail lookups', String(DEFAULT_TTL_MS)) 1321 .description('Capture network requests as shape previews; retrieve full bodies by key') 1322 .action(browserAction(async (page, opts) => { 1323 const ttlMs = parsePositiveIntOption(opts.ttl, 'ttl', DEFAULT_TTL_MS); 1324 const workspace = DEFAULT_BROWSER_WORKSPACE; 1325 const hasDetail = typeof opts.detail === 'string' && opts.detail.length > 0; 1326 const hasFilter = typeof opts.filter === 'string'; 1327 1328 // --detail and --filter do different things (one request by key vs. narrow 1329 // the list by shape), don't compose, and combining them has no sensible 1330 // semantic. Reject up front with a structured error instead of silently 1331 // dropping one. 1332 if (hasDetail && hasFilter) { 1333 emitNetworkError('invalid_args', '--filter and --detail cannot be used together (one narrows a list, the other fetches a specific entry).'); 1334 return; 1335 } 1336 1337 let filterFields: string[] | null = null; 1338 if (hasFilter) { 1339 const parsed = parseFilter(opts.filter as string); 1340 if ('reason' in parsed) { 1341 emitNetworkError('invalid_filter', parsed.reason); 1342 return; 1343 } 1344 filterFields = parsed.fields; 1345 } 1346 1347 // --detail short-circuits: read from cache only, no live capture needed. 1348 if (hasDetail) { 1349 const res = loadNetworkCache(workspace, { ttlMs }); 1350 if (res.status === 'missing') { 1351 emitNetworkError('cache_missing', `No cached capture. Run "browser network" first (in workspace "${workspace}").`); 1352 return; 1353 } 1354 if (res.status === 'expired') { 1355 emitNetworkError('cache_expired', `Cache is stale (age ${res.ageMs}ms > ttl ${ttlMs}ms). Re-run "browser network" to refresh.`); 1356 return; 1357 } 1358 if (res.status === 'corrupt' || !res.file) { 1359 emitNetworkError('cache_corrupt', 'Cache file is malformed; re-run "browser network" to regenerate.'); 1360 return; 1361 } 1362 const entry = findEntry(res.file, opts.detail); 1363 if (!entry) { 1364 emitNetworkError('key_not_found', `Key "${opts.detail}" not in cache.`, { 1365 available_keys: res.file.entries.map((e) => e.key), 1366 }); 1367 return; 1368 } 1369 const rawMaxBody = String(opts.maxBody ?? '0'); 1370 if (!/^\d+$/.test(rawMaxBody)) { 1371 emitNetworkError('invalid_max_body', `--max-body must be a non-negative integer, got "${opts.maxBody}"`); 1372 return; 1373 } 1374 const maxBody = Number.parseInt(rawMaxBody, 10); 1375 1376 // Body shape/source: 1377 // - If capture already truncated it (entry.body_truncated), the body is a string. 1378 // - If the adapter stored a JSON value, it parsed cleanly at capture time; leave it. 1379 // - --max-body applies a transport-level cap when the caller wants to keep output small. 1380 let outputBody: unknown = entry.body; 1381 let transportTruncated = false; 1382 if (maxBody > 0 && typeof entry.body === 'string' && entry.body.length > maxBody) { 1383 outputBody = entry.body.slice(0, maxBody); 1384 transportTruncated = true; 1385 } 1386 const captureTruncated = entry.body_truncated === true; 1387 1388 const detailEnvelope: Record<string, unknown> = { 1389 key: entry.key, 1390 url: entry.url, 1391 method: entry.method, 1392 status: entry.status, 1393 ct: entry.ct, 1394 size: entry.size, 1395 shape: inferShape(entry.body), 1396 body: outputBody, 1397 }; 1398 if (captureTruncated || transportTruncated) { 1399 detailEnvelope.body_truncated = true; 1400 detailEnvelope.body_full_size = entry.body_full_size ?? entry.size; 1401 detailEnvelope.body_truncation_reason = captureTruncated 1402 ? 'capture-limit' 1403 : 'max-body'; 1404 } 1405 console.log(JSON.stringify(detailEnvelope, null, 2)); 1406 return; 1407 } 1408 1409 // Fresh capture path. 1410 let rawItems: BrowserNetworkItem[]; 1411 try { 1412 rawItems = await captureNetworkItems(page); 1413 } catch (err) { 1414 emitNetworkError('capture_failed', `Could not read network capture: ${(err as Error).message}`); 1415 return; 1416 } 1417 1418 const items = opts.all ? rawItems : filterNetworkItems(rawItems); 1419 const filteredOut = rawItems.length - items.length; 1420 1421 const keyed = assignKeys(items); 1422 const cacheEntries: CachedNetworkEntry[] = keyed.map((it) => ({ 1423 key: it.key, 1424 url: it.url, 1425 method: it.method, 1426 status: it.status, 1427 size: it.size, 1428 ct: it.ct, 1429 body: it.body, 1430 ...(it.bodyTruncated ? { body_truncated: true } : {}), 1431 ...(it.bodyTruncated && typeof it.bodyFullSize === 'number' 1432 ? { body_full_size: it.bodyFullSize } 1433 : {}), 1434 })); 1435 // Soft failure: the caller already has the data, so surface a warning 1436 // via the output envelope rather than erroring out the whole command. 1437 let cacheWarning: string | null = null; 1438 try { 1439 saveNetworkCache(workspace, cacheEntries); 1440 } catch (err) { 1441 cacheWarning = `Could not persist capture cache: ${(err as Error).message}. --detail lookups may miss this capture.`; 1442 } 1443 1444 // Pair each cache entry with its shape up front so --filter can read 1445 // segments without recomputing, and the --raw view can keep the full 1446 // body. Cache persistence above stored the unfiltered set on purpose: 1447 // later `--detail <key>` lookups must still see requests that the 1448 // current --filter narrowed out. 1449 const shaped = cacheEntries.map((e) => ({ entry: e, shape: inferShape(e.body) })); 1450 const visible = filterFields 1451 ? shaped.filter((s) => shapeMatchesFilter(s.shape, filterFields)) 1452 : shaped; 1453 const filterDropped = filterFields ? shaped.length - visible.length : 0; 1454 1455 const envelope: Record<string, unknown> = { 1456 workspace, 1457 captured_at: new Date().toISOString(), 1458 count: visible.length, 1459 filtered_out: filteredOut, 1460 }; 1461 if (filterFields) { 1462 envelope.filter = filterFields; 1463 envelope.filter_dropped = filterDropped; 1464 } 1465 if (cacheWarning) envelope.cache_warning = cacheWarning; 1466 1467 const truncatedCount = visible.filter((s) => s.entry.body_truncated).length; 1468 if (truncatedCount > 0) { 1469 envelope.body_truncated_count = truncatedCount; 1470 envelope.body_truncated_hint = 'Some bodies exceeded the capture limit; their `shape` reflects only the captured prefix.'; 1471 } 1472 1473 if (opts.raw) { 1474 envelope.entries = visible.map((s) => s.entry); 1475 } else { 1476 envelope.entries = visible.map((s) => ({ 1477 key: s.entry.key, 1478 method: s.entry.method, 1479 status: s.entry.status, 1480 url: s.entry.url, 1481 ct: s.entry.ct, 1482 size: s.entry.size, 1483 shape: s.shape, 1484 ...(s.entry.body_truncated ? { body_truncated: true } : {}), 1485 })); 1486 envelope.detail_hint = 'Run "browser network --detail <key>" for full body.'; 1487 } 1488 console.log(JSON.stringify(envelope, null, 2)); 1489 })); 1490 1491 // ── Init (adapter scaffolding) ── 1492 1493 browser.command('init') 1494 .argument('<name>', 'Adapter name in site/command format (e.g. hn/top)') 1495 .description('Generate adapter scaffold in ~/.opencli/clis/') 1496 .action(async (name: string) => { 1497 try { 1498 const parts = name.split('/'); 1499 if (parts.length !== 2 || !parts[0] || !parts[1]) { 1500 console.error('Name must be site/command format (e.g. hn/top)'); 1501 process.exitCode = EXIT_CODES.USAGE_ERROR; 1502 return; 1503 } 1504 const [site, command] = parts; 1505 if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) { 1506 console.error('Name parts must be alphanumeric/dash/underscore only'); 1507 process.exitCode = EXIT_CODES.USAGE_ERROR; 1508 return; 1509 } 1510 1511 const os = await import('node:os'); 1512 const fs = await import('node:fs'); 1513 const path = await import('node:path'); 1514 const dir = path.join(os.homedir(), '.opencli', 'clis', site); 1515 const filePath = path.join(dir, `${command}.js`); 1516 1517 if (fs.existsSync(filePath)) { 1518 console.log(`Adapter already exists: ${filePath}`); 1519 return; 1520 } 1521 1522 // Try to detect domain from the last browser session 1523 let domain = site; 1524 try { 1525 const page = await getBrowserPage(); 1526 const url = await page.getCurrentUrl?.(); 1527 if (url) { try { domain = new URL(url).hostname; } catch {} } 1528 } catch { /* no active session */ } 1529 1530 const template = `import { cli, Strategy } from '@jackwener/opencli/registry'; 1531 1532 cli({ 1533 site: '${site}', 1534 name: '${command}', 1535 description: '', // TODO: describe what this command does 1536 domain: '${domain}', 1537 strategy: Strategy.PUBLIC, // TODO: PUBLIC (no auth), COOKIE (needs login), UI (DOM interaction) 1538 browser: false, // TODO: set true if needs browser 1539 args: [ 1540 { name: 'limit', type: 'int', default: 10, help: 'Number of items' }, 1541 ], 1542 columns: [], // TODO: field names for table output (e.g. ['title', 'score', 'url']) 1543 func: async (page, kwargs) => { 1544 // TODO: implement data fetching 1545 // Prefer API calls (fetch) over browser automation 1546 // page is available if browser: true 1547 return []; 1548 }, 1549 }); 1550 `; 1551 fs.mkdirSync(dir, { recursive: true }); 1552 fs.writeFileSync(filePath, template, 'utf-8'); 1553 console.log(`Created: ${filePath}`); 1554 console.log(`Edit the file to implement your adapter, then run: opencli browser verify ${name}`); 1555 } catch (err) { 1556 console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); 1557 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1558 } 1559 }); 1560 1561 // ── Verify (test adapter) ── 1562 1563 browser.command('verify') 1564 .argument('<name>', 'Adapter name in site/command format (e.g. hn/top)') 1565 .option('--write-fixture', 'Write a starter fixture to ~/.opencli/sites/<site>/verify/<command>.json if none exists') 1566 .option('--update-fixture', 'Overwrite an existing fixture with one derived from current output') 1567 .option('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)') 1568 .option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing') 1569 .description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.json when present') 1570 .action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean } = {}) => { 1571 try { 1572 const parts = name.split('/'); 1573 if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } 1574 const [site, command] = parts; 1575 if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) { 1576 console.error('Name parts must be alphanumeric/dash/underscore only'); 1577 process.exitCode = EXIT_CODES.USAGE_ERROR; 1578 return; 1579 } 1580 1581 const { execFileSync } = await import('node:child_process'); 1582 const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs } = await import('./browser/verify-fixture.js'); 1583 const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.js`); 1584 if (!fs.existsSync(filePath)) { 1585 console.error(`Adapter not found: ${filePath}`); 1586 console.error(`Run "opencli browser init ${name}" to create it.`); 1587 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1588 return; 1589 } 1590 1591 console.log(`🔍 Verifying ${name}...\n`); 1592 console.log(` Loading: ${filePath}`); 1593 1594 const useFixture = opts.fixture !== false; 1595 let fixture = useFixture ? loadFixture(site, command) : null; 1596 1597 // Build adapter args: fixture.args override the legacy --limit 3 heuristic. 1598 // - object form { "limit": 3 } → `--limit 3` 1599 // - array form ["123", "--limit", "3"] → verbatim (for positional subjects) 1600 const adapterSrc = fs.readFileSync(filePath, 'utf-8'); 1601 const hasLimitArg = /['"]limit['"]/.test(adapterSrc); 1602 const fixtureArgs = fixture?.args; 1603 const cliArgs: string[] = expandFixtureArgs(fixtureArgs); 1604 if (cliArgs.length === 0 && hasLimitArg) cliArgs.push('--limit', '3'); 1605 1606 const argDisplay = cliArgs.join(' '); 1607 const invocation = resolveBrowserVerifyInvocation(); 1608 1609 // Always request JSON so we can validate structurally. 1610 const execArgs = [...invocation.args, site, command, ...cliArgs, '--format', 'json']; 1611 1612 let rawJson: string; 1613 try { 1614 rawJson = execFileSync(invocation.binary, execArgs, { 1615 cwd: invocation.cwd, 1616 timeout: 30000, 1617 encoding: 'utf-8', 1618 env: process.env, 1619 stdio: ['pipe', 'pipe', 'pipe'], 1620 ...(invocation.shell ? { shell: true } : {}), 1621 }); 1622 } catch (err) { 1623 console.log(` Executing: opencli ${site} ${command} ${argDisplay}\n`); 1624 const execErr = err as { stdout?: string | Buffer; stderr?: string | Buffer }; 1625 if (execErr.stdout) console.log(String(execErr.stdout)); 1626 if (execErr.stderr) console.error(String(execErr.stderr).slice(0, 500)); 1627 console.log(`\n ✗ Adapter failed. Fix the code and try again.`); 1628 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1629 return; 1630 } 1631 1632 console.log(` Executing: opencli ${site} ${command} ${argDisplay}\n`); 1633 1634 let rows: Record<string, unknown>[]; 1635 try { 1636 rows = normalizeVerifyRows(JSON.parse(rawJson)); 1637 } catch { 1638 console.log(rawJson); 1639 console.log('\n ✗ Could not parse adapter output as JSON. Is `--format json` broken?'); 1640 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1641 return; 1642 } 1643 1644 console.log(renderVerifyPreview(rows)); 1645 console.log(`\n → ${rows.length} row${rows.length === 1 ? '' : 's'}`); 1646 1647 // ── Fixture handling ─────────────────────────────────────────── 1648 if (opts.writeFixture || opts.updateFixture) { 1649 if (fixture && !opts.updateFixture) { 1650 console.log(`\n Fixture already exists at ${fixturePath(site, command)}.`); 1651 console.log(` Use --update-fixture to overwrite.`); 1652 } else { 1653 const seedArgs = fixtureArgs !== undefined 1654 ? fixtureArgs 1655 : (hasLimitArg ? { limit: 3 } : undefined); 1656 const derived = deriveFixture(rows, seedArgs); 1657 const p = writeFixture(site, command, derived); 1658 console.log(`\n ${fixture ? '↻ Updated' : '✎ Wrote'} fixture: ${p}`); 1659 console.log(` Review and hand-tune the derived expectations (add patterns / notEmpty, tighten rowCount).`); 1660 fixture = derived; 1661 } 1662 } 1663 1664 if (!fixture) { 1665 console.log(`\n ✓ Adapter runs. (No fixture at ${fixturePath(site, command)} — consider --write-fixture to seed one.)`); 1666 const memoryReport = checkSiteMemory(site); 1667 printSiteMemoryReport(memoryReport, opts.strictMemory); 1668 if (!memoryReport.ok && opts.strictMemory) { 1669 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1670 } 1671 return; 1672 } 1673 1674 const failures = validateRows(rows, fixture); 1675 if (failures.length === 0) { 1676 console.log(`\n ✓ Adapter matches fixture (${fixturePath(site, command)}).`); 1677 const memoryReport = checkSiteMemory(site); 1678 printSiteMemoryReport(memoryReport, opts.strictMemory); 1679 if (!memoryReport.ok && opts.strictMemory) { 1680 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1681 } 1682 return; 1683 } 1684 1685 console.log(`\n ✗ Adapter output does not match fixture:`); 1686 for (const f of failures.slice(0, 20)) { 1687 const where = f.rowIndex !== undefined ? `row[${f.rowIndex}] ` : ''; 1688 console.log(` - [${f.rule}] ${where}${f.detail}`); 1689 } 1690 if (failures.length > 20) { 1691 console.log(` ... and ${failures.length - 20} more failure(s)`); 1692 } 1693 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1694 } catch (err) { 1695 console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); 1696 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1697 } 1698 }); 1699 1700 // ── Session ── 1701 1702 browser.command('close').description('Close the automation window') 1703 .action(browserAction(async (page) => { 1704 await page.closeWindow?.(); 1705 console.log('Automation window closed'); 1706 })); 1707 1708 // ── Built-in: doctor / completion ────────────────────────────────────────── 1709 1710 program 1711 .command('doctor') 1712 .description('Diagnose opencli browser bridge connectivity') 1713 .option('--no-live', 'Skip live browser connectivity test') 1714 .option('--sessions', 'Show active automation sessions', false) 1715 .option('-v, --verbose', 'Debug output') 1716 .action(async (opts) => { 1717 applyVerbose(opts); 1718 const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js'); 1719 const report = await runBrowserDoctor({ live: opts.live, sessions: opts.sessions, cliVersion: PKG_VERSION }); 1720 console.log(renderBrowserDoctorReport(report)); 1721 }); 1722 1723 program 1724 .command('completion') 1725 .description('Output shell completion script') 1726 .argument('<shell>', 'Shell type: bash, zsh, or fish') 1727 .action((shell) => { 1728 printCompletionScript(shell); 1729 }); 1730 1731 // ── Plugin management ────────────────────────────────────────────────────── 1732 1733 const pluginCmd = program.command('plugin').description('Manage opencli plugins'); 1734 1735 pluginCmd 1736 .command('install') 1737 .description('Install a plugin from a git repository') 1738 .argument('<source>', 'Plugin source (e.g. github:user/repo)') 1739 .action(async (source: string) => { 1740 const { installPlugin } = await import('./plugin.js'); 1741 const { discoverPlugins } = await import('./discovery.js'); 1742 try { 1743 const result = installPlugin(source); 1744 await discoverPlugins(); 1745 if (Array.isArray(result)) { 1746 if (result.length === 0) { 1747 console.log(styleText('yellow', 'No plugins were installed (all skipped or incompatible).')); 1748 } else { 1749 console.log(styleText('green', `\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`)); 1750 } 1751 } else { 1752 console.log(styleText('green', `\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`)); 1753 } 1754 } catch (err) { 1755 console.error(styleText('red', `Error: ${getErrorMessage(err)}`)); 1756 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1757 } 1758 }); 1759 1760 pluginCmd 1761 .command('uninstall') 1762 .description('Uninstall a plugin') 1763 .argument('<name>', 'Plugin name') 1764 .action(async (name: string) => { 1765 const { uninstallPlugin } = await import('./plugin.js'); 1766 try { 1767 uninstallPlugin(name); 1768 console.log(styleText('green', `✅ Plugin "${name}" uninstalled.`)); 1769 } catch (err) { 1770 console.error(styleText('red', `Error: ${getErrorMessage(err)}`)); 1771 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1772 } 1773 }); 1774 1775 pluginCmd 1776 .command('update') 1777 .description('Update a plugin (or all plugins) to the latest version') 1778 .argument('[name]', 'Plugin name (required unless --all is passed)') 1779 .option('--all', 'Update all installed plugins') 1780 .action(async (name: string | undefined, opts: { all?: boolean }) => { 1781 if (!name && !opts.all) { 1782 console.error(styleText('red', 'Error: Please specify a plugin name or use the --all flag.')); 1783 process.exitCode = EXIT_CODES.USAGE_ERROR; 1784 return; 1785 } 1786 if (name && opts.all) { 1787 console.error(styleText('red', 'Error: Cannot specify both a plugin name and --all.')); 1788 process.exitCode = EXIT_CODES.USAGE_ERROR; 1789 return; 1790 } 1791 1792 const { updatePlugin, updateAllPlugins } = await import('./plugin.js'); 1793 const { discoverPlugins } = await import('./discovery.js'); 1794 if (opts.all) { 1795 const results = updateAllPlugins(); 1796 if (results.length > 0) { 1797 await discoverPlugins(); 1798 } 1799 1800 let hasErrors = false; 1801 console.log(styleText('bold', ' Update Results:')); 1802 for (const result of results) { 1803 if (result.success) { 1804 console.log(` ${styleText('green', '✓')} ${result.name}`); 1805 continue; 1806 } 1807 hasErrors = true; 1808 console.log(` ${styleText('red', '✗')} ${result.name} — ${styleText('dim', String(result.error))}`); 1809 } 1810 1811 if (results.length === 0) { 1812 console.log(styleText('dim', ' No plugins installed.')); 1813 return; 1814 } 1815 1816 console.log(); 1817 if (hasErrors) { 1818 console.error(styleText('red', 'Completed with some errors.')); 1819 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1820 } else { 1821 console.log(styleText('green', '✅ All plugins updated successfully.')); 1822 } 1823 return; 1824 } 1825 1826 try { 1827 updatePlugin(name!); 1828 await discoverPlugins(); 1829 console.log(styleText('green', `✅ Plugin "${name}" updated successfully.`)); 1830 } catch (err) { 1831 console.error(styleText('red', `Error: ${getErrorMessage(err)}`)); 1832 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1833 } 1834 }); 1835 1836 1837 pluginCmd 1838 .command('list') 1839 .description('List installed plugins') 1840 .option('-f, --format <fmt>', 'Output format: table, json', 'table') 1841 .action(async (opts) => { 1842 const { listPlugins } = await import('./plugin.js'); 1843 const plugins = listPlugins(); 1844 if (plugins.length === 0) { 1845 console.log(styleText('dim', ' No plugins installed.')); 1846 console.log(styleText('dim', ' Install one with: opencli plugin install github:user/repo')); 1847 return; 1848 } 1849 if (opts.format === 'json') { 1850 renderOutput(plugins, { 1851 fmt: 'json', 1852 columns: ['name', 'commands', 'source'], 1853 title: 'opencli/plugins', 1854 source: 'opencli plugin list', 1855 }); 1856 return; 1857 } 1858 console.log(); 1859 console.log(styleText('bold', ' Installed plugins')); 1860 console.log(); 1861 1862 // Group by monorepo 1863 const standalone = plugins.filter((p) => !p.monorepoName); 1864 const monoGroups = new Map<string, typeof plugins>(); 1865 for (const p of plugins) { 1866 if (!p.monorepoName) continue; 1867 const g = monoGroups.get(p.monorepoName) ?? []; 1868 g.push(p); 1869 monoGroups.set(p.monorepoName, g); 1870 } 1871 1872 for (const p of standalone) { 1873 const version = p.version ? styleText('green', ` @${p.version}`) : ''; 1874 const desc = p.description ? styleText('dim', ` — ${p.description}`) : ''; 1875 const cmds = p.commands.length > 0 ? styleText('dim', ` (${p.commands.join(', ')})`) : ''; 1876 const src = p.source ? styleText('dim', ` ← ${p.source}`) : ''; 1877 console.log(` ${styleText('cyan', p.name)}${version}${desc}${cmds}${src}`); 1878 } 1879 1880 for (const [mono, group] of monoGroups) { 1881 console.log(); 1882 console.log(styleText(['bold', 'magenta'], ` 📦 ${mono}`) + styleText('dim', ' (monorepo)')); 1883 for (const p of group) { 1884 const version = p.version ? styleText('green', ` @${p.version}`) : ''; 1885 const desc = p.description ? styleText('dim', ` — ${p.description}`) : ''; 1886 const cmds = p.commands.length > 0 ? styleText('dim', ` (${p.commands.join(', ')})`) : ''; 1887 console.log(` ${styleText('cyan', p.name)}${version}${desc}${cmds}`); 1888 } 1889 } 1890 1891 console.log(); 1892 console.log(styleText('dim', ` ${plugins.length} plugin(s) installed`)); 1893 console.log(); 1894 }); 1895 1896 pluginCmd 1897 .command('create') 1898 .description('Create a new plugin scaffold') 1899 .argument('<name>', 'Plugin name (lowercase, hyphens allowed)') 1900 .option('-d, --dir <path>', 'Output directory (default: ./<name>)') 1901 .option('--description <text>', 'Plugin description') 1902 .action(async (name: string, opts: { dir?: string; description?: string }) => { 1903 const { createPluginScaffold } = await import('./plugin-scaffold.js'); 1904 try { 1905 const result = createPluginScaffold(name, { 1906 dir: opts.dir, 1907 description: opts.description, 1908 }); 1909 console.log(styleText('green', `✅ Plugin scaffold created at ${result.dir}`)); 1910 console.log(); 1911 console.log(styleText('bold', ' Files created:')); 1912 for (const f of result.files) { 1913 console.log(` ${styleText('cyan', f)}`); 1914 } 1915 console.log(); 1916 console.log(styleText('dim', ' Next steps:')); 1917 console.log(styleText('dim', ` cd ${result.dir}`)); 1918 console.log(styleText('dim', ` opencli plugin install file://${result.dir}`)); 1919 console.log(styleText('dim', ` opencli ${name} hello`)); 1920 } catch (err) { 1921 console.error(styleText('red', `Error: ${getErrorMessage(err)}`)); 1922 process.exitCode = EXIT_CODES.GENERIC_ERROR; 1923 } 1924 }); 1925 1926 // ── Built-in: adapter management ───────────────────────────────────────── 1927 const adapterCmd = program.command('adapter').description('Manage CLI adapters'); 1928 1929 adapterCmd 1930 .command('status') 1931 .description('Show which sites have local overrides vs using official baseline') 1932 .action(async () => { 1933 const os = await import('node:os'); 1934 const userClisDir = path.join(os.homedir(), '.opencli', 'clis'); 1935 const builtinClisDir = BUILTIN_CLIS; 1936 try { 1937 const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true }); 1938 const userSites = userEntries.filter(e => e.isDirectory()).map(e => e.name).sort(); 1939 let builtinSites: string[] = []; 1940 try { 1941 const builtinEntries = await fs.promises.readdir(builtinClisDir, { withFileTypes: true }); 1942 builtinSites = builtinEntries.filter(e => e.isDirectory()).map(e => e.name).sort(); 1943 } catch { /* no builtin dir */ } 1944 1945 if (userSites.length === 0) { 1946 console.log('No local adapter overrides. All sites use the official baseline.'); 1947 return; 1948 } 1949 1950 console.log(`Local overrides in ~/.opencli/clis/ (${userSites.length} sites):\n`); 1951 for (const site of userSites) { 1952 const isOfficial = builtinSites.includes(site); 1953 const label = isOfficial ? 'override' : 'custom'; 1954 console.log(` ${site} [${label}]`); 1955 } 1956 console.log(`\nOfficial baseline: ${builtinSites.length} sites in package`); 1957 } catch { 1958 console.log('No local adapter overrides. All sites use the official baseline.'); 1959 } 1960 }); 1961 1962 adapterCmd 1963 .command('eject') 1964 .description('Copy an official adapter to ~/.opencli/clis/ for local editing') 1965 .argument('<site>', 'Site name (e.g. twitter, bilibili)') 1966 .action(async (site: string) => { 1967 const os = await import('node:os'); 1968 const userClisDir = path.join(os.homedir(), '.opencli', 'clis'); 1969 const builtinSiteDir = path.join(BUILTIN_CLIS, site); 1970 const userSiteDir = path.join(userClisDir, site); 1971 1972 try { 1973 await fs.promises.access(builtinSiteDir); 1974 } catch { 1975 console.error(styleText('red', `Error: Site "${site}" not found in official adapters.`)); 1976 process.exitCode = EXIT_CODES.USAGE_ERROR; 1977 return; 1978 } 1979 1980 try { 1981 await fs.promises.access(userSiteDir); 1982 console.error(styleText('yellow', `Site "${site}" already exists in ~/.opencli/clis/. Use "opencli adapter reset ${site}" first to restore official version.`)); 1983 process.exitCode = EXIT_CODES.USAGE_ERROR; 1984 return; 1985 } catch { /* good, doesn't exist yet */ } 1986 1987 fs.cpSync(builtinSiteDir, userSiteDir, { recursive: true }); 1988 console.log(styleText('green', `✅ Ejected "${site}" to ~/.opencli/clis/${site}/`)); 1989 console.log('You can now edit the adapter files. Changes take effect immediately.'); 1990 console.log(styleText('yellow', 'Note: Official updates to this adapter will overwrite your changes.')); 1991 }); 1992 1993 adapterCmd 1994 .command('reset') 1995 .description('Remove local override and restore official adapter version') 1996 .argument('[site]', 'Site name (e.g. twitter, bilibili)') 1997 .option('--all', 'Reset all local overrides') 1998 .action(async (site: string | undefined, opts: { all?: boolean }) => { 1999 const os = await import('node:os'); 2000 const userClisDir = path.join(os.homedir(), '.opencli', 'clis'); 2001 2002 if (opts.all) { 2003 try { 2004 const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true }); 2005 const dirs = userEntries.filter(e => e.isDirectory()); 2006 if (dirs.length === 0) { 2007 console.log('No local sites to reset.'); 2008 return; 2009 } 2010 for (const dir of dirs) { 2011 fs.rmSync(path.join(userClisDir, dir.name), { recursive: true, force: true }); 2012 } 2013 console.log(styleText('green', `✅ Reset ${dirs.length} site(s). All adapters now use official baseline.`)); 2014 } catch { 2015 console.log('No local sites to reset.'); 2016 } 2017 return; 2018 } 2019 2020 if (!site) { 2021 console.error(styleText('red', 'Error: Please specify a site name or use --all.')); 2022 process.exitCode = EXIT_CODES.USAGE_ERROR; 2023 return; 2024 } 2025 2026 const userSiteDir = path.join(userClisDir, site); 2027 try { 2028 await fs.promises.access(userSiteDir); 2029 } catch { 2030 console.error(styleText('yellow', `Site "${site}" has no local override.`)); 2031 return; 2032 } 2033 2034 const isOfficial = fs.existsSync(path.join(BUILTIN_CLIS, site)); 2035 fs.rmSync(userSiteDir, { recursive: true, force: true }); 2036 console.log(styleText('green', isOfficial 2037 ? `✅ Reset "${site}". Now using official baseline.` 2038 : `✅ Removed custom site "${site}".`)); 2039 }); 2040 2041 // ── Built-in: daemon ────────────────────────────────────────────────────── 2042 const daemonCmd = program.command('daemon').description('Manage the opencli daemon'); 2043 daemonCmd 2044 .command('status') 2045 .description('Show daemon status') 2046 .action(async () => { await daemonStatus(); }); 2047 daemonCmd 2048 .command('stop') 2049 .description('Stop the daemon') 2050 .action(async () => { await daemonStop(); }); 2051 2052 // ── External CLIs ───────────────────────────────────────────────────────── 2053 2054 const externalClis = loadExternalClis(); 2055 2056 program 2057 .command('install') 2058 .description('Install an external CLI') 2059 .argument('<name>', 'Name of the external CLI') 2060 .action((name: string) => { 2061 const ext = externalClis.find(e => e.name === name); 2062 if (!ext) { 2063 console.error(styleText('red', `External CLI '${name}' not found in registry.`)); 2064 process.exitCode = EXIT_CODES.USAGE_ERROR; 2065 return; 2066 } 2067 installExternalCli(ext); 2068 }); 2069 2070 program 2071 .command('register') 2072 .description('Register an external CLI') 2073 .argument('<name>', 'Name of the CLI') 2074 .option('--binary <bin>', 'Binary name if different from name') 2075 .option('--install <cmd>', 'Auto-install command') 2076 .option('--desc <text>', 'Description') 2077 .action((name, opts) => { 2078 registerExternalCli(name, { binary: opts.binary, install: opts.install, description: opts.desc }); 2079 }); 2080 2081 function passthroughExternal(name: string, parsedArgs?: string[]) { 2082 const args = parsedArgs ?? (() => { 2083 const idx = process.argv.indexOf(name); 2084 return process.argv.slice(idx + 1); 2085 })(); 2086 try { 2087 executeExternalCli(name, args, externalClis); 2088 } catch (err) { 2089 console.error(styleText('red', `Error: ${getErrorMessage(err)}`)); 2090 process.exitCode = EXIT_CODES.GENERIC_ERROR; 2091 } 2092 } 2093 2094 for (const ext of externalClis) { 2095 if (program.commands.some(c => c.name() === ext.name)) continue; 2096 program 2097 .command(ext.name) 2098 .description(`(External) ${ext.description || ext.name}`) 2099 .argument('[args...]') 2100 .allowUnknownOption() 2101 .passThroughOptions() 2102 .helpOption(false) 2103 .action((args: string[]) => passthroughExternal(ext.name, args)); 2104 } 2105 2106 // ── Antigravity serve (long-running, special case) ──────────────────────── 2107 2108 const antigravityCmd = program.command('antigravity').description('antigravity commands'); 2109 antigravityCmd 2110 .command('serve') 2111 .description('Start Anthropic-compatible API proxy for Antigravity') 2112 .option('--port <port>', 'Server port (default: 8082)', '8082') 2113 .option('--timeout <seconds>', 'Maximum time to wait for a reply (default: 120s)') 2114 .action(async (opts) => { 2115 // @ts-expect-error JS adapter — no type declarations 2116 const { startServe } = await import('../clis/antigravity/serve.js'); 2117 await startServe({ 2118 port: parseInt(opts.port, 10), 2119 timeout: opts.timeout ? parsePositiveIntOption(opts.timeout, '--timeout', 120) : undefined, 2120 }); 2121 }); 2122 2123 // ── Dynamic adapter commands ────────────────────────────────────────────── 2124 2125 const siteGroups = new Map<string, Command>(); 2126 siteGroups.set('antigravity', antigravityCmd); 2127 registerAllCommands(program, siteGroups); 2128 2129 // ── Unknown command fallback ────────────────────────────────────────────── 2130 // Security: do NOT auto-discover and register arbitrary system binaries. 2131 // Only explicitly registered external CLIs (via `opencli register`) are allowed. 2132 2133 program.on('command:*', (operands: string[]) => { 2134 const binary = operands[0]; 2135 console.error(styleText('red', `error: unknown command '${binary}'`)); 2136 if (isBinaryInstalled(binary)) { 2137 console.error(styleText('dim', ` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`)); 2138 } 2139 program.outputHelp(); 2140 process.exitCode = EXIT_CODES.USAGE_ERROR; 2141 }); 2142 2143 return program; 2144 } 2145 2146 export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { 2147 createProgram(BUILTIN_CLIS, USER_CLIS).parse(); 2148 } 2149 2150 // ── Helpers ───────────────────────────────────────────────────────────────── 2151 2152 export interface BrowserVerifyInvocation { 2153 binary: string; 2154 args: string[]; 2155 cwd: string; 2156 shell?: boolean; 2157 } 2158 2159 export { findPackageRoot }; 2160 2161 export function resolveBrowserVerifyInvocation(opts: { 2162 projectRoot?: string; 2163 platform?: NodeJS.Platform; 2164 fileExists?: (path: string) => boolean; 2165 readFile?: (path: string) => string; 2166 } = {}): BrowserVerifyInvocation { 2167 const platform = opts.platform ?? process.platform; 2168 const fileExists = opts.fileExists ?? fs.existsSync; 2169 const readFile = opts.readFile ?? ((filePath: string) => fs.readFileSync(filePath, 'utf-8')); 2170 const projectRoot = opts.projectRoot ?? findPackageRoot(CLI_FILE, fileExists); 2171 2172 for (const builtEntry of getBuiltEntryCandidates(projectRoot, readFile)) { 2173 if (fileExists(builtEntry)) { 2174 return { 2175 binary: process.execPath, 2176 args: [builtEntry], 2177 cwd: projectRoot, 2178 }; 2179 } 2180 } 2181 2182 const sourceEntry = path.join(projectRoot, 'src', 'main.ts'); 2183 if (!fileExists(sourceEntry)) { 2184 throw new Error(`Could not find opencli entrypoint under ${projectRoot}. Expected built entry from package.json or src/main.ts.`); 2185 } 2186 2187 const localTsxBin = path.join(projectRoot, 'node_modules', '.bin', platform === 'win32' ? 'tsx.cmd' : 'tsx'); 2188 if (fileExists(localTsxBin)) { 2189 return { 2190 binary: localTsxBin, 2191 args: [sourceEntry], 2192 cwd: projectRoot, 2193 ...(platform === 'win32' ? { shell: true } : {}), 2194 }; 2195 } 2196 2197 return { 2198 binary: platform === 'win32' ? 'npx.cmd' : 'npx', 2199 args: ['tsx', sourceEntry], 2200 cwd: projectRoot, 2201 ...(platform === 'win32' ? { shell: true } : {}), 2202 }; 2203 }