/ src / cli.ts
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  }