/**
 * CLI entry point: registers built-in commands and wires up Commander.
 *
 * Built-in commands are registered inline here (list, validate, explore, etc.).
 * Dynamic adapter commands are registered via commanderAdapter.ts.
 */

import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command, InvalidArgumentError, Option } from 'commander';
import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js';
import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
import { serializeCommand, formatArgSummary } from './serialization.js';
import { render as renderOutput } from './output.js';
import { PKG_VERSION } from './version.js';
import { printCompletionScript } from './completion.js';
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
import { listOpenCliSkills, readOpenCliSkill } from './skills.js';
import { registerAllCommands } from './commanderAdapter.js';
import { classifyAdapter, formatRootAdapterHelpText, installCommanderNamespaceStructuredHelp, installStructuredHelp, leadingPositionalFromUsage, rootHelpData, type RootAdapterGroups } from './help.js';
import { EXIT_CODES, getErrorMessage, BrowserConnectError, CliError } from './errors.js';
import { TargetError, type TargetErrorCode } from './browser/target-errors.js';
import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs, type ResolveOptions, type TargetMatchLevel } from './browser/target-resolver.js';
import { buildFindJs, buildSemanticFindJs, isFindError, type FindResult, type FindError, type SemanticFindOptions } from './browser/find.js';
import { inferShape } from './browser/shape.js';
import { assignKeys } from './browser/network-key.js';
import { DEFAULT_TTL_MS, findEntry, loadNetworkCache, saveNetworkCache, type CachedNetworkEntry } from './browser/network-cache.js';
import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js';
import { buildHtmlTreeJs, type HtmlTreeResult } from './browser/html-tree.js';
import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
import { analyzeSite, type PageSignals } from './browser/analyze.js';
import { registerAuthCommands } from './commands/auth.js';
import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js';
import { log } from './logger.js';
import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js';
import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js';
import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js';
import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './browser/config.js';
import type { BrowserDownloadWaitResult, IPage, ScreenshotOptions } from './types.js';
import type { BrowserWindowMode } from './runtime.js';

const CLI_FILE = fileURLToPath(import.meta.url);
const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"';
const FOLLOW_POLL_MS = 1_000;

type BrowserNetworkItem = {
  url: string;
  method: string;
  status: number;
  size: number;
  ct: string;
  body: unknown;
  /** Full body size in chars before any capture-layer truncation. */
  bodyFullSize?: number;
  /** True when the capture layer had to cap the stored body to protect memory. */
  bodyTruncated?: boolean;
  /** Epoch milliseconds when the request was observed. */
  timestamp?: number;
};

function parseDurationMs(raw: unknown, flagName: string): number | null | { error: string } {
  if (raw === undefined || raw === null || raw === '') return null;
  const str = String(raw).trim();
  const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(str);
  if (!match) return { error: `--${flagName} must be a duration like 500ms, 30s, 2m, got "${str}"` };
  const value = Number.parseFloat(match[1]);
  const unit = match[2] ?? 'ms';
  const multiplier = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1_000 : 1;
  return Math.round(value * multiplier);
}

function timestampFromRaw(value: unknown): number {
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : Date.now();
}

function toIsoTimestamp(timestamp: unknown): string | undefined {
  if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) return undefined;
  return new Date(timestamp).toISOString();
}

function filterByTimeWindow<T extends { timestamp?: number }>(items: T[], opts: { sinceMs?: number | null; untilMs?: number | null }, now: number = Date.now()): T[] {
  const sinceTs = opts.sinceMs != null ? now - opts.sinceMs : undefined;
  const untilTs = opts.untilMs != null ? now - opts.untilMs : undefined;
  return items.filter((item) => {
    const ts = item.timestamp ?? now;
    if (sinceTs !== undefined && ts < sinceTs) return false;
    if (untilTs !== undefined && ts > untilTs) return false;
    return true;
  });
}

export function selectFreshByTimestamp<T extends { timestamp?: unknown }>(
  items: T[],
  lastSeenTs: number,
): { fresh: T[]; lastSeenTs: number } {
  const fresh = items.filter((item) => Number(item.timestamp ?? 0) > lastSeenTs);
  const nextSeenTs = fresh.length > 0
    ? Math.max(lastSeenTs, ...fresh.map((item) => Number(item.timestamp ?? 0)).filter(Number.isFinite))
    : lastSeenTs;
  return { fresh, lastSeenTs: nextSeenTs };
}

/**
 * Normalize raw capture entries (from daemon/CDP `readNetworkCapture` or
 * the JS interceptor's `window.__opencli_net`) into a consistent shape.
 * Response preview is parsed as JSON when possible, otherwise kept as string.
 * `bodyFullSize` / `bodyTruncated` surface capture-layer truncation so the
 * agent-facing envelope can warn when the body isn't whole.
 */
async function captureNetworkItems(page: import('./types.js').IPage): Promise<BrowserNetworkItem[]> {
  if (page.readNetworkCapture) {
    const raw = await page.readNetworkCapture();
    if (Array.isArray(raw) && raw.length > 0) {
      return (raw as Array<Record<string, unknown>>).map((e) => {
        const preview = (e.responsePreview as string) ?? null;
        let body: unknown = null;
        if (preview) {
          try { body = JSON.parse(preview); } catch { body = preview; }
        }
        const fullSize = typeof e.responseBodyFullSize === 'number'
          ? (e.responseBodyFullSize as number)
          : (preview ? preview.length : 0);
        const truncated = e.responseBodyTruncated === true;
        return {
          url: (e.url as string) || '',
          method: (e.method as string) || 'GET',
          status: (e.responseStatus as number) || 0,
          size: fullSize,
          ct: (e.responseContentType as string) || '',
          body,
          bodyFullSize: fullSize,
          bodyTruncated: truncated,
          timestamp: timestampFromRaw(e.timestamp),
        };
      });
    }
  }
  const raw = await page.evaluate(`(function(){ var out = window.__opencli_net || []; window.__opencli_net = []; return JSON.stringify(out); })()`) as string;
  try {
    const parsed = JSON.parse(raw) as BrowserNetworkItem[];
    return parsed.map((item) => ({ ...item, timestamp: timestampFromRaw(item.timestamp) }));
  } catch {
    if (process.env.OPENCLI_VERBOSE) log.warn(`[network] Failed to parse interceptor buffer: ${typeof raw === 'string' ? raw.slice(0, 200) : String(raw)}`);
    return [];
  }
}

/** Drop static-resource / telemetry noise so agents see only API-shaped traffic. */
function filterNetworkItems(items: BrowserNetworkItem[]): BrowserNetworkItem[] {
  return items.filter((r) => {
    const ct = r.ct?.toLowerCase() ?? '';
    return (
      (ct.includes('json') || ct.includes('xml') || ct.includes('text/plain') || ct.includes('javascript')) &&
      !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
      !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url)
    );
  });
}

/** Exit codes by network error code — usage errors vs runtime failures. */
const NETWORK_ERROR_EXIT: Record<string, number> = {
  invalid_args: EXIT_CODES.USAGE_ERROR,
  invalid_filter: EXIT_CODES.USAGE_ERROR,
  invalid_max_body: EXIT_CODES.USAGE_ERROR,
};

/** Emit a structured error JSON so agents can branch on `error.code` without regex. */
function emitNetworkError(code: string, message: string, extra: Record<string, unknown> = {}): void {
  console.log(JSON.stringify({ error: { code, message, ...extra } }, null, 2));
  process.exitCode = NETWORK_ERROR_EXIT[code] ?? EXIT_CODES.GENERIC_ERROR;
}

/**
 * Check whether the site-memory scaffolding exists under
 * ~/.opencli/sites/<site>/. Agents have a strong tendency to forget to write
 * endpoints.json / notes.md after a successful verify, which dooms the next
 * agent to redo recon from scratch. Surfacing the current state as part of
 * verify's final report converts that "silent skip" into a visible nudge;
 * `--strict-memory` escalates it to a failure so agents driving a hardened
 * workflow can't forget.
 */
export type SiteMemoryReport = {
  ok: boolean;
  siteDir: string;
  endpoints: { present: boolean; count: number; path: string };
  notes: { present: boolean; path: string };
};

export type SitemapAvailability = {
  site: string;
  available: true;
  source: 'local' | 'global' | 'local+global';
  hint: string;
  paths: {
    local?: string;
    global?: string;
  };
};

type SitemapHintState = {
  seenSites: string[];
  updatedAt: string;
};

type SitemapAvailabilityOptions = {
  homeDir?: string;
  packageRoot?: string;
  registry?: Map<string, CliCommand>;
  fileExists?: (candidate: string) => boolean;
};

const SITEMAP_HINT =
  'Site sitemap available. For navigation context, use the opencli-browser-sitemap skill; treat browser state as truth if it disagrees.';

function siteNameCandidatesFromUrl(url: string, registry: Map<string, CliCommand> = getRegistry()): string[] {
  let host: string;
  try {
    host = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
  } catch {
    return [];
  }

  const scored = new Map<string, number>();
  for (const command of registry.values()) {
    if (!command.domain) continue;
    let domainHost = command.domain.toLowerCase().trim();
    try {
      domainHost = new URL(domainHost.includes('://') ? domainHost : `https://${domainHost}`).hostname.toLowerCase();
    } catch {
      domainHost = domainHost.split('/')[0] ?? domainHost;
    }
    domainHost = domainHost.replace(/^www\./, '');
    if (!domainHost) continue;
    if (host === domainHost || host.endsWith(`.${domainHost}`)) {
      scored.set(command.site, Math.max(scored.get(command.site) ?? 0, domainHost.length));
    }
  }

  const registrySites = [...scored.entries()]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .map(([site]) => site);

  const hostParts = host.split('.').filter(Boolean);
  const fallback = hostParts.length >= 2 ? hostParts[hostParts.length - 2] : hostParts[0];
  return [...new Set([...registrySites, ...(fallback ? [fallback] : [])])];
}

function firstExistingSitemapPath(paths: string[], fileExists: (candidate: string) => boolean): string | undefined {
  return paths.find((candidate) => fileExists(candidate));
}

function sitemapPathsForSite(site: string, opts: Required<Pick<SitemapAvailabilityOptions, 'homeDir' | 'packageRoot' | 'fileExists'>>): { local?: string; global?: string } {
  const safeSite = site.replace(/[^a-zA-Z0-9_-]+/g, '-');
  if (!safeSite) return {};
  const localBase = path.join(opts.homeDir, '.opencli', 'sites', safeSite);
  return {
    local: firstExistingSitemapPath([
      path.join(localBase, 'sitemap'),
      path.join(localBase, 'sitemap.md'),
    ], opts.fileExists),
    global: firstExistingSitemapPath([
      path.join(opts.packageRoot, 'sitemaps', safeSite),
      path.join(opts.packageRoot, 'sitemaps', `${safeSite}.md`),
    ], opts.fileExists),
  };
}

export function resolveSitemapAvailabilityForUrl(url: string, options: SitemapAvailabilityOptions = {}): SitemapAvailability | null {
  const homeDir = options.homeDir ?? os.homedir();
  const packageRoot = options.packageRoot ?? findPackageRoot(CLI_FILE);
  const registry = options.registry ?? getRegistry();
  const fileExists = options.fileExists ?? fs.existsSync;

  for (const site of siteNameCandidatesFromUrl(url, registry)) {
    const paths = sitemapPathsForSite(site, { homeDir, packageRoot, fileExists });
    if (!paths.local && !paths.global) continue;
    const source = paths.local && paths.global ? 'local+global' : paths.local ? 'local' : 'global';
    return {
      site,
      available: true,
      source,
      hint: SITEMAP_HINT,
      paths,
    };
  }
  return null;
}

function getBrowserSitemapHintStatePath(scope: string): string {
  const safeScope = scope.replace(/[^a-zA-Z0-9_-]+/g, '_');
  return path.join(getBrowserCacheDir(), 'browser-sitemap-hints', `${safeScope}.json`);
}

function loadBrowserSitemapHintState(scope: string): SitemapHintState {
  try {
    const parsed = JSON.parse(fs.readFileSync(getBrowserSitemapHintStatePath(scope), 'utf-8')) as SitemapHintState;
    if (parsed && typeof parsed === 'object' && Array.isArray(parsed.seenSites)) {
      return {
        seenSites: parsed.seenSites.filter((site) => typeof site === 'string'),
        updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date(0).toISOString(),
      };
    }
  } catch {
    // First command in this browser session has no hint cache yet.
  }
  return { seenSites: [], updatedAt: new Date(0).toISOString() };
}

function markBrowserSitemapHintSeen(scope: string, site: string): void {
  const state = loadBrowserSitemapHintState(scope);
  if (!state.seenSites.includes(site)) state.seenSites.push(site);
  const target = getBrowserSitemapHintStatePath(scope);
  fs.mkdirSync(path.dirname(target), { recursive: true });
  fs.writeFileSync(target, JSON.stringify({ seenSites: state.seenSites, updatedAt: new Date().toISOString() }), 'utf-8');
}

function sitemapHintForBrowserUrl(url: string, scope: string, opts: { oncePerSession: boolean }): SitemapAvailability | null {
  const sitemap = resolveSitemapAvailabilityForUrl(url);
  if (!sitemap) return null;
  if (!opts.oncePerSession) return sitemap;
  const state = loadBrowserSitemapHintState(scope);
  if (state.seenSites.includes(sitemap.site)) return null;
  markBrowserSitemapHintSeen(scope, sitemap.site);
  return sitemap;
}

export function checkSiteMemory(site: string): SiteMemoryReport {
  const siteDir = path.join(os.homedir(), '.opencli', 'sites', site);
  const endpointsPath = path.join(siteDir, 'endpoints.json');
  const notesPath = path.join(siteDir, 'notes.md');
  let endpointsCount = 0;
  let endpointsPresent = fs.existsSync(endpointsPath);
  if (endpointsPresent) {
    try {
      const parsed = JSON.parse(fs.readFileSync(endpointsPath, 'utf-8'));
      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
        endpointsCount = Object.keys(parsed).length;
      } else if (Array.isArray(parsed)) {
        endpointsCount = parsed.length;
      }
    } catch {
      endpointsPresent = false;
    }
  }
  const notesPresent = fs.existsSync(notesPath);
  return {
    ok: endpointsPresent && endpointsCount > 0 && notesPresent,
    siteDir,
    endpoints: { present: endpointsPresent, count: endpointsCount, path: endpointsPath },
    notes: { present: notesPresent, path: notesPath },
  };
}

export function printSiteMemoryReport(report: SiteMemoryReport, strict: boolean | undefined): void {
  if (report.ok) {
    console.log(`  ✓ Memory: endpoints.json (${report.endpoints.count}), notes.md present at ${report.siteDir}`);
    return;
  }
  const marker = strict ? '✗' : '⚠';
  const missing: string[] = [];
  if (!report.endpoints.present) missing.push('endpoints.json');
  else if (report.endpoints.count === 0) missing.push('endpoints.json (empty)');
  if (!report.notes.present) missing.push('notes.md');
  console.log(`  ${marker} Memory: missing ${missing.join(', ')} under ${report.siteDir}`);
  console.log(`    Write the endpoint you just verified + a 1-line session note so the next agent starts from minute 0, not minute 95.`);
  if (!strict) {
    console.log(`    (Re-run with --strict-memory to fail instead of warn.)`);
  }
}

/** Coerce adapter JSON output into a row array. Accepts `[{...}]`, single `{}`, or `{items:[...]}`-style envelopes. */
export function normalizeVerifyRows(data: unknown): Record<string, unknown>[] {
  if (Array.isArray(data)) {
    return data.map((r) => (r && typeof r === 'object' ? r as Record<string, unknown> : { value: r }));
  }
  if (data && typeof data === 'object') {
    const obj = data as Record<string, unknown>;
    for (const k of ['rows', 'items', 'data', 'results']) {
      if (Array.isArray(obj[k])) {
        return (obj[k] as unknown[]).map((r) => (r && typeof r === 'object' ? r as Record<string, unknown> : { value: r }));
      }
    }
    return [obj];
  }
  return [];
}

/** Render up to 10 rows as a compact padded table for eyeball inspection during verify. */
export function renderVerifyPreview(
  rows: Record<string, unknown>[],
  opts: { maxRows?: number; maxCols?: number; cellMax?: number } = {},
): string {
  const maxRows = opts.maxRows ?? 10;
  const maxCols = opts.maxCols ?? 6;
  const cellMax = opts.cellMax ?? 40;
  if (rows.length === 0) return '  (no rows)';

  const allCols = Array.from(new Set(rows.flatMap((r) => Object.keys(r))));
  const cols = allCols.slice(0, maxCols);
  const shown = rows.slice(0, maxRows);
  const cellOf = (v: unknown): string => {
    if (v === null || v === undefined) return '';
    const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
    return s.replace(/\s+/g, ' ').slice(0, cellMax);
  };
  const widths = cols.map((c) => Math.max(c.length, ...shown.map((r) => cellOf(r[c]).length)));
  const fmtRow = (vals: string[]): string => vals.map((v, i) => v.padEnd(widths[i])).join('  ');

  const out: string[] = [];
  out.push(`  ${fmtRow(cols)}`);
  out.push(`  ${widths.map((w) => '-'.repeat(w)).join('  ')}`);
  for (const r of shown) out.push(`  ${fmtRow(cols.map((c) => cellOf(r[c])))}`);
  if (rows.length > maxRows) out.push(`  ... and ${rows.length - maxRows} more row(s)`);
  if (allCols.length > maxCols) out.push(`  (${allCols.length - maxCols} more column(s) hidden)`);
  return out.join('\n');
}

type BrowserTargetState = {
  defaultPage?: string;
  updatedAt: string;
};

type BrowserTabSummary = {
  page?: string;
};

function getBrowserCacheDir(): string {
  return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache');
}

function getBrowserTargetStatePath(scope: string): string {
  const safeSession = scope.replace(/[^a-zA-Z0-9_-]+/g, '_');
  return path.join(getBrowserCacheDir(), 'browser-state', `${safeSession}.json`);
}

function loadBrowserTargetState(scope: string): BrowserTargetState | null {
  try {
    const raw = fs.readFileSync(getBrowserTargetStatePath(scope), 'utf-8');
    const parsed = JSON.parse(raw) as BrowserTargetState | null;
    return parsed && typeof parsed === 'object' ? parsed : null;
  } catch {
    return null;
  }
}

function saveBrowserTargetState(defaultPage: string | undefined, scope: string): void {
  const target = getBrowserTargetStatePath(scope);
  if (!defaultPage) {
    fs.rmSync(target, { force: true });
    return;
  }
  fs.mkdirSync(path.dirname(target), { recursive: true });
  fs.writeFileSync(target, JSON.stringify({ defaultPage, updatedAt: new Date().toISOString() }), 'utf-8');
}

function hasBrowserTabTarget(tabs: unknown[], targetPage: string): boolean {
  return tabs.some((tab) => {
    return typeof tab === 'object'
      && tab !== null
      && 'page' in tab
      && typeof (tab as BrowserTabSummary).page === 'string'
      && (tab as BrowserTabSummary).page === targetPage;
  });
}

async function resolveBrowserTargetInSession(
  page: import('./types.js').IPage,
  targetPage: string,
  opts: { scope: string; source: 'explicit' | 'saved' },
): Promise<string | undefined> {
  const candidate = targetPage.trim();
  if (!candidate) return undefined;

  let tabs: unknown[];
  try {
    tabs = await page.tabs();
  } catch (err) {
    if (opts.source === 'saved') {
      saveBrowserTargetState(undefined, opts.scope);
      return undefined;
    }
    throw new Error(
      `Target tab ${candidate} could not be validated in the current browser session. ` +
      'The Browser Bridge session may have restarted; re-run "opencli browser tab list" and choose a current target.',
      { cause: err },
    );
  }

  if (Array.isArray(tabs) && hasBrowserTabTarget(tabs, candidate)) {
    return candidate;
  }

  if (opts.source === 'saved') {
    saveBrowserTargetState(undefined, opts.scope);
    return undefined;
  }

  throw new Error(
    `Target tab ${candidate} is not part of the current browser session. ` +
    'The Browser Bridge session may have restarted; re-run "opencli browser tab list" and choose a current target.',
  );
}

function getBrowserScope(session: string, contextId?: string): string {
  return contextId ? `${contextId}:${session}` : session;
}

async function resolveStoredBrowserTarget(page: import('./types.js').IPage, scope: string): Promise<string | undefined> {
  const defaultPage = loadBrowserTargetState(scope)?.defaultPage?.trim();
  if (!defaultPage) return undefined;
  return resolveBrowserTargetInSession(page, defaultPage, { scope, source: 'saved' });
}

/** Create a browser page for browser commands. Uses a named browser session for continuity. */
async function getBrowserPage(
  session: string,
  targetPage?: string,
  contextId?: string,
  opts: { windowMode?: BrowserWindowMode } = {},
): Promise<import('./types.js').IPage> {
  const { BrowserBridge } = await import('./browser/index.js');
  const bridge = new BrowserBridge();
  // Internal GC timeout for browser sessions. Not the per-command runtime timeout.
  const envTimeout = process.env.OPENCLI_BROWSER_IDLE_TIMEOUT;
  const idleTimeout = envTimeout ? parseInt(envTimeout, 10) : undefined;
  const page = await bridge.connect({
    timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT,
    session,
    surface: 'browser',
    ...(contextId && { contextId }),
    ...(idleTimeout && idleTimeout > 0 && { idleTimeout }),
    windowMode: opts.windowMode ?? getBrowserWindowMode(undefined, 'foreground'),
  });
  const targetScope = getBrowserScope(session, contextId);
  const resolvedTargetPage = targetPage
    ? await resolveBrowserTargetInSession(page, targetPage, { scope: targetScope, source: 'explicit' })
    : await resolveStoredBrowserTarget(page, targetScope);
  if (resolvedTargetPage) {
    if (!page.setActivePage) {
      throw new Error('This browser session does not support explicit tab targeting');
    }
    page.setActivePage(resolvedTargetPage);
  }
  return page;
}

function getBrowserWindowMode(command: Command | undefined, defaultMode: BrowserWindowMode): BrowserWindowMode {
  const optionRaw = getCommandOption(command, 'window');
  if (optionRaw !== undefined && optionRaw !== '') {
    if (optionRaw === 'foreground' || optionRaw === 'background') return optionRaw;
    throw new Error(`--window must be one of: foreground, background. Received: "${String(optionRaw)}"`);
  }
  const envRaw = process.env.OPENCLI_WINDOW;
  if (envRaw !== undefined && envRaw !== '') {
    if (envRaw === 'foreground' || envRaw === 'background') return envRaw;
    throw new Error(`OPENCLI_WINDOW must be one of: foreground, background. Received: "${envRaw}"`);
  }
  return defaultMode;
}

function addBrowserTabOption(command: Command): Command {
  return command.option('--tab <targetId>', BROWSER_TAB_OPTION_DESCRIPTION);
}

function getBrowserTargetId(command?: Command): string | undefined {
  if (!command) return undefined;
  const opts = command.optsWithGlobals ? command.optsWithGlobals() : command.opts();
  return typeof opts.tab === 'string' && opts.tab.trim() ? opts.tab.trim() : undefined;
}

function getCommandOption(command: Command | undefined, option: string): unknown {
  let current: Command | undefined = command;
  while (current) {
    const opts = current.opts();
    if (Object.prototype.hasOwnProperty.call(opts, option) && opts[option] !== undefined) return opts[option];
    current = current.parent as Command | undefined;
  }
  return undefined;
}

function getBrowserSession(command?: Command): string {
  // The CLI surface is `opencli browser <session> <subcommand>`. main.ts rewrites
  // argv to insert `--session <name>` before commander parses it; this helper
  // reads back the rewritten flag.
  const raw = getCommandOption(command, 'session');
  if (typeof raw === 'string' && raw.trim()) return raw.trim();
  throw new Error('<session> is a required positional argument: opencli browser <session> <command>');
}

function getBrowserContextId(command?: Command): string | undefined {
  const raw = getCommandOption(command, 'profile');
  return resolveProfileContextId(typeof raw === 'string' && raw.trim() ? raw.trim() : undefined);
}

function getPageSession(page: import('./types.js').IPage): string {
  const session = (page as unknown as { session?: unknown }).session;
  if (typeof session === 'string' && session.trim()) return session.trim();
  throw new Error('Browser page is missing a session');
}

function getPageScope(page: import('./types.js').IPage): string {
  const contextId = (page as unknown as { contextId?: unknown }).contextId;
  return getBrowserScope(getPageSession(page), typeof contextId === 'string' && contextId.trim() ? contextId.trim() : undefined);
}

type SnapshotSource = 'dom' | 'ax';

function snapshotMetricText(snapshot: unknown): string {
  return typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2);
}

function snapshotMetrics(snapshot: unknown, elapsedMs: number): Record<string, unknown> {
  const text = snapshotMetricText(snapshot);
  const interactiveMatch = text.match(/^interactive:\s*(\d+)\s*$/m);
  return {
    ok: true,
    chars: text.length,
    bytes: Buffer.byteLength(text, 'utf8'),
    lines: text ? text.split(/\r?\n/).length : 0,
    approx_tokens: Math.ceil(text.length / 4),
    refs: (text.match(/(^|\n)\s*\[\d+\]/g) ?? []).length,
    frame_sections: (text.match(/(^|\n)frame /g) ?? []).length,
    ...(interactiveMatch ? { interactive: Number(interactiveMatch[1]) } : {}),
    elapsed_ms: elapsedMs,
  };
}

async function snapshotSourceMetrics(page: IPage, source: SnapshotSource): Promise<Record<string, unknown>> {
  const started = Date.now();
  try {
    const snapshot = await page.snapshot({ viewportExpand: 2000, source });
    return snapshotMetrics(snapshot, Date.now() - started);
  } catch (err) {
    return {
      ok: false,
      elapsed_ms: Date.now() - started,
      error: {
        ...(err instanceof Error && 'code' in err ? { code: String((err as { code?: unknown }).code) } : {}),
        message: err instanceof Error ? err.message : String(err),
      },
    };
  }
}

function resolveBrowserTabTarget(targetId?: string, opts?: { tab?: string } | Command): string | undefined {
  if (typeof targetId === 'string' && targetId.trim()) return targetId.trim();
  const tab = opts instanceof Command ? opts.opts().tab : opts?.tab;
  if (typeof tab === 'string' && tab.trim()) return tab.trim();
  return undefined;
}

function parsePositiveIntOption(val: string | undefined, label: string, fallback: number): number {
  if (val === undefined) return fallback;
  const parsed = parseInt(val, 10);
  if (Number.isNaN(parsed) || parsed <= 0) {
    console.error(`[cli] Invalid ${label}="${val}", using default ${fallback}`);
    return fallback;
  }
  return parsed;
}

function parseScreenshotDim(val: string, label: string): number {
  if (!/^\d+$/.test(val)) {
    throw new InvalidArgumentError(`--${label} must be a positive integer (got "${val}")`);
  }
  const parsed = parseInt(val, 10);
  if (parsed <= 0) {
    throw new InvalidArgumentError(`--${label} must be a positive integer (got "${val}")`);
  }
  return parsed;
}

function applyVerbose(opts: { verbose?: boolean }): void {
  if (opts.verbose) process.env.OPENCLI_VERBOSE = '1';
}

function formatChildCommandSummary(command: Command): string {
  return [...new Set(command.commands.map(child => child.name()))]
    .sort((a, b) => a.localeCompare(b))
    .join(', ');
}

function applyRootSubcommandSummaries(program: Command): void {
  for (const command of program.commands) {
    if (command.commands.length === 0) continue;
    const summary = formatChildCommandSummary(command);
    if (summary) command.description(summary);
  }
}

export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command {
  const program = new Command();
  // enablePositionalOptions: prevents parent from consuming flags meant for subcommands;
  // prerequisite for passThroughOptions to forward --help/--version to external binaries
  program
    .name('opencli')
    .description('Make any website your CLI. Zero setup. AI-powered.')
    .version(PKG_VERSION)
    .option('--profile <name>', 'Chrome profile/context alias for Browser Bridge commands')
    .enablePositionalOptions();

  // ── Built-in: list ────────────────────────────────────────────────────────

  program
    .command('list')
    .description('List all available CLI commands')
    .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
    .action((opts) => {
      const registry = getRegistry();
      const commands = [...new Set(registry.values())].sort((a, b) => fullName(a).localeCompare(fullName(b)));
      const fmt = opts.format;
      const isStructured = fmt === 'json' || fmt === 'yaml';

      if (fmt !== 'table') {
        const rows = isStructured
          ? commands.map(serializeCommand)
          : commands.map(c => ({
              command: fullName(c),
              site: c.site,
              name: c.name,
              aliases: c.aliases?.join(', ') ?? '',
              description: c.description,
              access: c.access,
              strategy: strategyLabel(c),
              browser: !!c.browser,
              args: formatArgSummary(c.args),
            }));
        renderOutput(rows, {
          fmt,
          columns: ['command', 'site', 'name', 'aliases', 'description', 'access', 'strategy', 'browser', 'args',
                     ...(isStructured ? ['columns', 'domain'] : [])],
          title: 'opencli/list',
          source: 'opencli list',
        });
        return;
      }

      // Table (default) — grouped by adapter kind (app vs site), then by site name.
      // classifyAdapter() reads the `domain` field: DNS-style domains are sites;
      // localhost/loopback endpoints and bare app names are apps.
      const appsBySite = new Map<string, CliCommand[]>();
      const sitesBySite = new Map<string, CliCommand[]>();
      for (const cmd of commands) {
        const target = classifyAdapter(cmd.domain) === 'app' ? appsBySite : sitesBySite;
        const g = target.get(cmd.site) ?? [];
        g.push(cmd);
        target.set(cmd.site, g);
      }

      const renderSiteGroup = (site: string, cmds: CliCommand[]): void => {
        console.log(`  ${site}`);
        for (const cmd of cmds) {
          const label = strategyLabel(cmd);
          const tag = label === 'public'
            ? '[public]'
            : `[${label}]`;
          const aliases = cmd.aliases?.length ? ` (aliases: ${cmd.aliases.join(', ')})` : '';
          console.log(`    ${cmd.name} ${tag}${aliases}${cmd.description ? ` — ${cmd.description}` : ''}`);
        }
        console.log();
      };

      console.log();
      console.log('  opencli' + ' — available commands');
      console.log();

      if (appsBySite.size > 0) {
        console.log('  App adapters');
        console.log();
        for (const [site, cmds] of appsBySite) renderSiteGroup(site, cmds);
      }

      if (sitesBySite.size > 0) {
        console.log('  Site adapters');
        console.log();
        for (const [site, cmds] of sitesBySite) renderSiteGroup(site, cmds);
      }

      const externalClis = loadExternalClis();
      if (externalClis.length > 0) {
        console.log('  external CLIs');
        for (const ext of externalClis) {
          const isInstalled = isBinaryInstalled(ext.binary);
          const tag = isInstalled ? '[installed]' : '[auto-install]';
          console.log(`    ${formatExternalCliLabel(ext)} ${tag}${ext.description ? ` — ${ext.description}` : ''}`);
        }
        console.log();
      }

      console.log(`  ${commands.length} built-in commands across ${appsBySite.size} apps + ${sitesBySite.size} sites, ${externalClis.length} external CLIs`);
      console.log();
    });

  // ── Built-in: validate / verify ───────────────────────────────────────────

  program
    .command('validate')
    .description('Validate CLI definitions')
    .argument('[target]', 'site or site/name')
    .action(async (target) => {
      const { validateClisWithTarget, renderValidationReport } = await import('./validate.js');
      console.log(renderValidationReport(validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target)));
    });

  program
    .command('verify')
    .description('Validate + smoke test')
    .argument('[target]')
    .option('--smoke', 'Run smoke tests', false)
    .action(async (target, opts) => {
      const { verifyClis, renderVerifyReport } = await import('./verify.js');
      const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
      console.log(renderVerifyReport(r));
      process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
    });

  const skillsCmd = program
    .command('skills')
    .description('Read bundled OpenCLI skills');

  skillsCmd
    .command('list')
    .description('List bundled opencli-* skills')
    .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
    .action((opts) => {
      const rows = listOpenCliSkills();
      renderOutput(rows, {
        fmt: opts.format,
        fmtExplicit: !!opts.format,
        columns: ['name', 'description', 'version', 'path'],
        title: 'opencli/skills/list',
        source: 'opencli skills list',
      });
    });

  skillsCmd
    .command('read')
    .description("Print an opencli-* skill's SKILL.md or reference file")
    .argument('<skill>', 'Skill name, or skill/path like opencli-browser/references/foo.md')
    .argument('[path]', 'Path under the skill directory')
    .option('--json', 'Output a JSON envelope instead of raw markdown', false)
    .action((skill: string, skillPath: string | undefined, opts) => {
      let result: ReturnType<typeof readOpenCliSkill>;
      try {
        result = readOpenCliSkill(skill, skillPath ?? '');
      } catch (err) {
        console.error(`Error: ${getErrorMessage(err)}`);
        if (err instanceof CliError && err.hint) console.error(`Hint: ${err.hint}`);
        process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR;
        return;
      }
      if (opts.json) {
        console.log(JSON.stringify(result, null, 2));
        return;
      }
      process.stdout.write(result.content);
      if (!result.content.endsWith('\n')) process.stdout.write('\n');
    });

  const authCmd = registerAuthCommands(program);

  program
    .command('convention-audit')
    .description('Scan adapters for agent-native convention violations')
    .argument('[target]', 'site or site/name')
    .option('--site <site>', 'Limit audit to one site')
    .option('-f, --format <fmt>', 'Output format: table, json, yaml', 'table')
    .option('--strict', 'Exit non-zero when violations are found', false)
    .action(async (target, opts) => {
      const { runConventionAudit, renderConventionAuditText } = await import('./convention-audit.js');
      const report = runConventionAudit({
        projectRoot: findPackageRoot(CLI_FILE),
        target,
        site: opts.site,
      });
      const fmt = String(opts.format ?? 'table').toLowerCase();
      if (fmt === 'json' || fmt === 'yaml' || fmt === 'yml') {
        renderOutput(report, { fmt });
      } else {
        console.log(renderConventionAuditText(report));
      }
      if (opts.strict && !report.ok) process.exitCode = EXIT_CODES.GENERIC_ERROR;
    });

  // ── Built-in: browser (browser control for Claude Code skill) ───────────────
  //
  // Make websites accessible for AI agents.
  // All commands wrapped in browserAction() for consistent error handling.

  const browser = program
    .command('browser')
    // --session is an internal hidden option used by the daemon protocol and direct
    // program.parseAsync callers (tests). User-facing surface is the <session>
    // positional; main.ts argv preprocessor rewrites positional -> --session.
    .addOption(new Option('--session <name>', 'Internal — set automatically from the <session> positional').hideHelp())
    .option('--window <mode>', 'Browser window mode: foreground or background')
    .description('Browser control — navigate, click, type, extract, wait (no LLM needed)')
    .usage('<session> <command> [options]')
    .addHelpText('after', `
<session> is a required positional: pass the name of the browser session every subcommand should operate on. Reuse the same name across calls to keep the tab/state alive; pick a different name to isolate parallel browser work.

Examples:
  $ opencli browser work open https://x.com
  $ opencli browser work open https://x.com --window background
  $ opencli browser work click 12
  $ opencli browser work state
  $ opencli browser work bind
  $ opencli browser work unbind
`);
  const originalBrowserDescription = browser.description();

  /**
   * Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver.
   * Returns the CSS match count so callers can propagate `matches_n` into the
   * JSON envelope printed back to the agent.
   */
  async function resolveRef(
    page: Awaited<ReturnType<typeof getBrowserPage>>,
    ref: string,
    opts: ResolveOptions = {},
  ): Promise<{ matches_n: number; match_level: TargetMatchLevel }> {
    const resolution = await page.evaluate(resolveTargetJs(ref, opts)) as
      | { ok: true; matches_n: number; match_level: TargetMatchLevel }
      | { ok: false; code: TargetErrorCode; message: string; hint: string; candidates?: string[]; matches_n?: number };
    if (!resolution.ok) {
      throw new TargetError({
        code: resolution.code,
        message: resolution.message,
        hint: resolution.hint,
        candidates: resolution.candidates,
        matches_n: resolution.matches_n,
      });
    }
    return { matches_n: resolution.matches_n, match_level: resolution.match_level };
  }

  /**
   * Parse `--nth <n>` flag, returning the parsed 0-based index or a usage error.
   * The surface mirrors `--depth` etc. in `browser get html --as json`: the flag
   * is optional, must be a non-negative integer when present, and on failure we
   * emit the structured error envelope rather than throwing past the command.
   */
  function parseNthFlag(raw: unknown): number | null | { error: string } {
    if (raw === undefined || raw === null || raw === '') return null;
    const str = String(raw);
    if (!/^\d+$/.test(str)) {
      return { error: `--nth must be a non-negative integer, got "${str}"` };
    }
    return Number.parseInt(str, 10);
  }

  /** Emit the `{ error: { code, message, hint?, candidates?, matches_n? } }` envelope used by the selector-first commands. */
  function emitTargetError(err: TargetError): void {
    console.log(JSON.stringify({
      error: {
        code: err.code,
        message: err.message,
        hint: err.hint,
        ...(err.candidates && { candidates: err.candidates }),
        ...(err.matches_n !== undefined && { matches_n: err.matches_n }),
      },
    }, null, 2));
  }

  function isJavaScriptDialogMessage(message: string): boolean {
    const normalized = message.toLowerCase();
    return normalized.includes('javascript dialog');
  }

  function emitJavaScriptDialogError(message: string): void {
    console.log(JSON.stringify({
      error: {
        code: 'javascript_dialog_open',
        message,
        hint: 'Handle the modal first: opencli browser dialog accept (or dismiss). Use --text for prompt dialogs.',
      },
    }, null, 2));
  }

  /** Wrap browser actions with error handling and optional --json output */
  function browserAction<Args extends unknown[]>(fn: (page: Awaited<ReturnType<typeof getBrowserPage>>, ...args: Args) => Promise<unknown>) {
    return async (...args: Args) => {
      let page: Awaited<ReturnType<typeof getBrowserPage>> | null = null;
      try {
        const command = args.at(-1) instanceof Command ? args.at(-1) as Command : undefined;
        const targetPage = getBrowserTargetId(command);
        const session = getBrowserSession(command);
        const contextId = getBrowserContextId(command);
        const windowMode = getBrowserWindowMode(command, 'foreground');
        page = await getBrowserPage(session, targetPage, contextId, { windowMode });
        await fn(page, ...args);
      } catch (err) {
        if (err instanceof BrowserConnectError) {
          log.error(err.message);
          if (err.hint) log.error(`Hint: ${err.hint}`);
        } else if (err instanceof BrowserCommandError) {
          if (isJavaScriptDialogMessage(err.message)) {
            emitJavaScriptDialogError(err.message);
          } else if (err.code) {
            console.log(JSON.stringify({
              error: {
                code: err.code,
                message: err.message,
                ...(err.hint ? { hint: err.hint } : {}),
              },
            }, null, 2));
          }
          log.error(err.message);
          if (err.hint) log.error(`Hint: ${err.hint}`);
        } else if (err instanceof TargetError) {
          // Agent-facing structured envelope on stdout + short human line on stderr.
          emitTargetError(err);
          log.error(`[${err.code}] ${err.message}`);
          if (err.hint) log.error(`Hint: ${err.hint}`);
        } else {
          const msg = getErrorMessage(err);
          if (isJavaScriptDialogMessage(msg)) {
            emitJavaScriptDialogError(msg);
            log.error(msg);
          } else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
            log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
          } else {
            log.error(msg);
          }
        }
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      }
    };
  }

  browser.command('bind')
    .description('Bind the current Chrome tab/window to the browser session named by <session>')
    .action(async (optsOrCommand, maybeCommand?: Command) => {
      const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
      const session = getBrowserSession(command);
      try {
        const { BrowserBridge } = await import('./browser/index.js');
        const bridge = new BrowserBridge();
        const contextId = getBrowserContextId(command);
        await bridge.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, session, surface: 'browser', ...(contextId && { contextId }) });
        const data = await bindTab(session, { ...(contextId && { contextId }) });
        saveBrowserTargetState(undefined, getBrowserScope(session, contextId));
        console.log(JSON.stringify({ session, ...((data && typeof data === 'object') ? data as Record<string, unknown> : { data }) }, null, 2));
      } catch (err) {
        if (err instanceof BrowserCommandError && err.code) {
          console.log(JSON.stringify({
            error: {
              code: err.code,
              message: err.message,
              ...(err.hint ? { hint: err.hint } : {}),
            },
          }, null, 2));
        }
        log.error(err instanceof Error ? err.message : String(err));
        if (err instanceof BrowserCommandError && err.hint) log.error(`Hint: ${err.hint}`);
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      }
    });

  browser.command('unbind')
    .description('Detach the bound browser session named by <session> without closing the user tab/window')
    .action(async (optsOrCommand, maybeCommand?: Command) => {
      const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
      const session = getBrowserSession(command);
      try {
        const { BrowserBridge } = await import('./browser/index.js');
        const bridge = new BrowserBridge();
        const contextId = getBrowserContextId(command);
        await bridge.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, session, surface: 'browser', ...(contextId && { contextId }) });
        await sendCommand('close-window', { session, surface: 'browser', ...(contextId && { contextId }) });
        saveBrowserTargetState(undefined, getBrowserScope(session, contextId));
        console.log(JSON.stringify({ unbound: true, session }, null, 2));
      } catch (err) {
        if (err instanceof BrowserCommandError && err.code) {
          console.log(JSON.stringify({
            error: {
              code: err.code,
              message: err.message,
              ...(err.hint ? { hint: err.hint } : {}),
            },
          }, null, 2));
        }
        log.error(err instanceof Error ? err.message : String(err));
        if (err instanceof BrowserCommandError && err.hint) log.error(`Hint: ${err.hint}`);
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      }
    });

  const browserTab = browser
    .command('tab')
    .description('Tab management — list, create, and close tabs in the browser session');

  browserTab.command('list')
    .description('List tabs in the browser session with target IDs')
    .action(browserAction(async (page) => {
      const tabs = await page.tabs();
      console.log(JSON.stringify(tabs, null, 2));
    }));

  browserTab.command('new')
    .argument('[url]', 'Optional URL to open in the new tab')
    .description('Create a new tab and print its target ID')
    .action(browserAction(async (page, url?: string) => {
      if (!page.newTab) {
        throw new Error('This browser session does not support creating tabs');
      }
      const createdPage = await page.newTab(url);
      console.log(JSON.stringify({
        page: createdPage,
        url: url ?? null,
      }, null, 2));
    }));

  addBrowserTabOption(browserTab.command('select')
    .argument('[targetId]', 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"')
    .description('Select a tab by target ID and make it the default browser tab'))
    .action(browserAction(async (page, targetId?: string, opts?: { tab?: string } | Command) => {
      const resolvedTarget = resolveBrowserTabTarget(targetId, opts);
      if (!resolvedTarget) {
        throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.');
      }
      await page.selectTab(resolvedTarget);
      saveBrowserTargetState(resolvedTarget, getPageScope(page));
      console.log(JSON.stringify({ selected: resolvedTarget }, null, 2));
    }));

  addBrowserTabOption(browserTab.command('close')
    .argument('[targetId]', 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"')
    .description('Close a tab by target ID'))
    .action(browserAction(async (page, targetId?: string, opts?: { tab?: string } | Command) => {
      const resolvedTarget = resolveBrowserTabTarget(targetId, opts);
      if (!page.closeTab) {
        throw new Error('This browser session does not support closing tabs');
      }
      if (!resolvedTarget) {
        throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.');
      }
      const validatedTarget = await resolveBrowserTargetInSession(page, resolvedTarget, {
        scope: getPageScope(page),
        source: 'explicit',
      });
      if (!validatedTarget) {
        throw new Error(`Target tab ${resolvedTarget} is not part of the current browser session.`);
      }
      await page.closeTab(validatedTarget);
      const scope = getPageScope(page);
      if (loadBrowserTargetState(scope)?.defaultPage === validatedTarget) {
        saveBrowserTargetState(undefined, scope);
      }
      console.log(JSON.stringify({ closed: validatedTarget }, null, 2));
    }));

  // ── Navigation ──

  /**
   * Network interceptor JS — injected on every open/navigate to capture
   * fetch/XHR bodies when the session-level capture channel (CDP/extension)
   * isn't available. Keeps parity with the CDP path's truncation contract:
   * when a body exceeds the per-entry cap, we keep a string prefix and set
   * `bodyTruncated: true` + `bodyFullSize: <original length>` so `browser
   * network` can propagate a visible signal to the agent instead of
   * silently dropping the body. Per-entry cap is 1 MiB and the ring is
   * capped at 200 entries, bounding worst-case in-page memory.
   */
  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,timestamp:Date.now()};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)}})()`;

  addBrowserTabOption(browser.command('open').argument('<url>').description('Open URL in the browser session'))
    .action(browserAction(async (page, url, opts) => {
      // Start session-level capture before navigation (catches initial requests)
      const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
      await page.goto(url);
      await page.wait(2);
      // Fallback: inject JS interceptor when session capture is unavailable
      if (!hasSessionCapture) {
        try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ }
      }
      const currentUrl = await page.getCurrentUrl?.() ?? url;
      const sitemap = sitemapHintForBrowserUrl(currentUrl, getPageScope(page), { oncePerSession: true });
      console.log(JSON.stringify({
        url: currentUrl,
        ...(page.getActivePage?.() ? { page: page.getActivePage?.() } : {}),
        ...(sitemap ? { sitemap } : {}),
      }, null, 2));
    }));

  addBrowserTabOption(browser.command('back').description('Go back in browser history'))
    .action(browserAction(async (page, opts) => {
      await page.evaluate('history.back()');
      await page.wait(2);
      console.log('Navigated back');
    }));

  addBrowserTabOption(browser.command('scroll').argument('<direction>', 'up or down').option('--amount <pixels>', 'Pixels to scroll', '500'))
    .description('Scroll page')
    .action(browserAction(async (page, direction, opts) => {
      if (direction !== 'up' && direction !== 'down') {
        console.error(`Invalid direction "${direction}". Use "up" or "down".`);
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      await page.scroll(direction, parseInt(opts.amount, 10));
      console.log(`Scrolled ${direction}`);
    }));

  // ── Inspect ──

  addBrowserTabOption(browser.command('state').description('Page state: URL, title, interactive elements with [N] indices')
    .option('--source <source>', 'Snapshot backend: dom (default) or ax prototype', 'dom')
    .option('--compare-sources', 'Print DOM vs AX snapshot metrics for observation promotion decisions', false))
    .action(browserAction(async (page, opts) => {
      if (opts.compareSources === true) {
        const [dom, ax] = await Promise.all([
          snapshotSourceMetrics(page, 'dom'),
          snapshotSourceMetrics(page, 'ax'),
        ]);
        console.log(JSON.stringify({
          url: await page.getCurrentUrl?.() ?? '',
          sources: { dom, ax },
        }, null, 2));
        return;
      }
      const source = String(opts.source ?? 'dom').toLowerCase();
      if (source !== 'dom' && source !== 'ax') {
        console.log(JSON.stringify({
          error: {
            code: 'invalid_source',
            message: `--source must be "dom" or "ax", got "${opts.source}"`,
          },
        }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const snapshot = await page.snapshot({ viewportExpand: 2000, source: source as 'dom' | 'ax' });
      const url = await page.getCurrentUrl?.() ?? '';
      console.log(`URL: ${url}\n`);
      console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
    }));

  addBrowserTabOption(browser.command('frames').description('List cross-origin iframe targets in snapshot order'))
    .action(browserAction(async (page) => {
      const frames = await page.frames?.() ?? [];
      console.log(JSON.stringify(frames, null, 2));
    }));

  addBrowserTabOption(browser.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)'))
    .option('--full-page', 'Capture the full scrollable page, not just the viewport', false)
    .option('--annotate', 'Overlay visible browser state ref labels on the screenshot', false)
    .option('--width <n>', 'Override viewport width in CSS pixels for this screenshot only', (v: string) => parseScreenshotDim(v, 'width'))
    .option('--height <n>', 'Override viewport height in CSS pixels for this screenshot only (ignored with --full-page)', (v: string) => parseScreenshotDim(v, 'height'))
    .description('Take screenshot')
    .action(browserAction(async (page, path, opts) => {
      const shotOpts: ScreenshotOptions = {
        fullPage: opts.fullPage === true,
        annotate: opts.annotate === true,
        width: opts.width,
        height: opts.height,
      };
      const capture = opts.annotate === true
        ? (page.annotatedScreenshot ?? page.screenshot).bind(page)
        : page.screenshot.bind(page);
      if (path) {
        await capture({ ...shotOpts, path });
        console.log(`Screenshot saved to: ${path}`);
      } else {
        console.log(await capture({ ...shotOpts, format: 'png' }));
      }
    }));

  addBrowserTabOption(browser.command('console'))
    .option('--level <level>', 'Console level: all, error, warning, log, info, debug', 'all')
    .option('--since <duration>', 'Only include messages from the last duration (for example: 30s, 2m)')
    .option('--until <duration>', 'Only include messages older than the duration from now')
    .option('--follow', 'Continuously print new console messages as JSON lines', false)
    .description('Read recent browser console messages')
    .action(browserAction(async (page, opts) => {
      const sinceMs = parseDurationMs(opts.since, 'since');
      const untilMs = parseDurationMs(opts.until, 'until');
      if (sinceMs && typeof sinceMs === 'object') {
        console.log(JSON.stringify({ error: { code: 'invalid_since', message: sinceMs.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      if (untilMs && typeof untilMs === 'object') {
        console.log(JSON.stringify({ error: { code: 'invalid_until', message: untilMs.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const normalize = (messages: unknown[]): Array<Record<string, unknown>> => messages.map((message) => {
        if (message && typeof message === 'object') {
          const record = message as Record<string, unknown>;
          return {
            ...record,
            timestamp: timestampFromRaw(record.timestamp),
          };
        }
        return { type: 'log', text: String(message), timestamp: Date.now() };
      });
      const filter = (messages: Array<Record<string, unknown>>) =>
        filterByTimeWindow(messages, { sinceMs, untilMs }).filter((message) => {
          if (opts.level === 'all') return true;
          const type = String(message.type ?? message.level ?? '').toLowerCase();
          return opts.level === 'error'
            ? type === 'error' || type === 'warning'
            : type === String(opts.level).toLowerCase();
        });

      if (opts.follow) {
        let lastSeenTs = 0;
        while (true) {
          const messages = filter(normalize(await page.consoleMessages('all')));
          const next = selectFreshByTimestamp(messages, lastSeenTs);
          for (const message of next.fresh) {
            console.log(JSON.stringify({
              ...message,
              timestamp: toIsoTimestamp(message.timestamp),
            }));
          }
          lastSeenTs = next.lastSeenTs;
          await new Promise((resolve) => setTimeout(resolve, FOLLOW_POLL_MS));
        }
      }

      const messages = filter(normalize(await page.consoleMessages(opts.level)));
      console.log(JSON.stringify({
        session: getPageSession(page),
        captured_at: new Date().toISOString(),
        count: messages.length,
        messages: messages.map((message) => ({
          ...message,
          timestamp: toIsoTimestamp(message.timestamp),
        })),
      }, null, 2));
    }));

  // ── Analyze (site recon, agent-native) ──
  //
  // Mechanizes the `site-recon.md` decision tree into one CLI call. The agent
  // calls `browser analyze <url>` and gets back:
  //
  //   - pattern: A/B/C/D (mapped from network + SSR-globals signals)
  //   - anti_bot: vendor + evidence + the one-liner for "what to do next"
  //   - api_candidates: captured endpoints scored as real data vs telemetry
  //   - initial_state: which window globals are populated
  //   - nearest_adapter: existing commands for the same site, if any
  //   - recommended_next_step: a single imperative sentence
  //
  // Intent: replace the "open → eyeball network → curl → WAF → try again"
  // feedback loop with a single deterministic verdict. Without this, agents
  // burn ~20min per WAF-protected site re-discovering anti-bot posture.
  addBrowserTabOption(browser.command('analyze').argument('<url>'))
    .description('Classify site: anti-bot vendor, real-data API candidates, pattern (A/B/C/D), nearest adapter, next step')
    .action(browserAction(async (page, url) => {
      const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
      await page.goto(url);
      await page.wait(2);
      if (!hasSessionCapture) {
        try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ }
      }
      await captureNetworkItems(page);
      // Best-effort: give the page another beat so XHR after DOMContentLoaded lands.
      await page.wait(1);

      const rawItems = await captureNetworkItems(page);
      const networkEntries = rawItems.map((e) => ({
        url: e.url,
        status: e.status,
        contentType: e.ct,
        bodyPreview: typeof e.body === 'string'
          ? e.body.slice(0, 2000)
          : (e.body ? JSON.stringify(e.body).slice(0, 2000) : null),
      }));

      const probeJs = `(function(){
        return {
          cookieNames: (document.cookie || '').split(';').map(function(c){ return c.trim().split('=')[0]; }).filter(Boolean),
          initialState: {
            __INITIAL_STATE__: typeof window.__INITIAL_STATE__ !== 'undefined',
            __NUXT__: typeof window.__NUXT__ !== 'undefined',
            __NEXT_DATA__: typeof window.__NEXT_DATA__ !== 'undefined',
            __APOLLO_STATE__: typeof window.__APOLLO_STATE__ !== 'undefined',
          },
          title: document.title || '',
          finalUrl: location.href,
        };
      })()`;
      const probe = await page.evaluate(probeJs) as {
        cookieNames: string[];
        initialState: PageSignals['initialState'];
        title: string;
        finalUrl: string;
      };
      const browserCookieNames = (await page.getCookies({ url: probe.finalUrl || url }).catch(() => []))
        .map((c) => c.name)
        .filter(Boolean);
      const cookieNames = [...new Set([...probe.cookieNames, ...browserCookieNames])];

      const signals: PageSignals = {
        requestedUrl: url,
        finalUrl: probe.finalUrl,
        cookieNames,
        networkEntries,
        initialState: probe.initialState,
        title: probe.title,
      };
      const report = analyzeSite(signals, getRegistry());
      const sitemap = resolveSitemapAvailabilityForUrl(probe.finalUrl || url);
      console.log(JSON.stringify({
        ...report,
        ...(sitemap ? { sitemap } : {}),
      }, null, 2));
    }));

  // ── Find (structured CSS query, agent-native) ──
  //
  // `browser find --css <sel>` lets agents jump straight from a semantic
  // selector to a JSON list of matching elements, without having to parse
  // the free-text state snapshot to recover indices.
  const addSemanticLocatorOptions = (cmd: Command): Command => cmd
    .option('--role <role>', 'Semantic role (button, link, textbox, option, etc.)')
    .option('--name <text>', 'Accessible name contains text (aria-label, label, title, placeholder, or visible text)')
    .option('--label <text>', 'Associated label contains text')
    .option('--text <text>', 'Visible text contains text')
    .option('--testid <id>', 'data-testid / data-test / test-id contains id');

  const addPrefixedSemanticLocatorOptions = (cmd: Command, prefix: string): Command => cmd
    .option(`--${prefix}-role <role>`, `${prefix} semantic role`)
    .option(`--${prefix}-name <text>`, `${prefix} accessible name contains text`)
    .option(`--${prefix}-label <text>`, `${prefix} associated label contains text`)
    .option(`--${prefix}-text <text>`, `${prefix} visible text contains text`)
    .option(`--${prefix}-testid <id>`, `${prefix} data-testid / data-test / test-id contains id`);

  const semanticLocatorFromOptions = (opts: Record<string, unknown>): SemanticFindOptions | null => {
    const locator: SemanticFindOptions = {};
    for (const key of ['role', 'name', 'label', 'text', 'testid'] as const) {
      const value = opts[key];
      if (typeof value === 'string' && value.trim()) locator[key] = value.trim();
    }
    return Object.keys(locator).length > 0 ? locator : null;
  };

  const prefixedSemanticLocatorFromOptions = (opts: Record<string, unknown>, prefix: string): SemanticFindOptions | null => {
    const locator: SemanticFindOptions = {};
    const map = {
      role: `${prefix}Role`,
      name: `${prefix}Name`,
      label: `${prefix}Label`,
      text: `${prefix}Text`,
      testid: `${prefix}Testid`,
    } as const;
    for (const key of ['role', 'name', 'label', 'text', 'testid'] as const) {
      const value = opts[map[key]];
      if (typeof value === 'string' && value.trim()) locator[key] = value.trim();
    }
    return Object.keys(locator).length > 0 ? locator : null;
  };

  const semanticTargetFromLocator = async (
    page: Awaited<ReturnType<typeof getBrowserPage>>,
    locator: SemanticFindOptions,
    mode: 'read' | 'write',
  ): Promise<string | { target: string; total_matches?: number } | { error: { code: string; message: string; hint?: string; matches_n?: number; entries?: FindResult['entries'] } }> => {
    const result = await page.evaluate(buildSemanticFindJs({ ...locator, limit: 6 })) as FindResult | FindError;
    if (isFindError(result)) return result;
    if (mode === 'write' && result.matches_n !== 1) {
      return {
        error: {
          code: 'semantic_ambiguous',
          message: `Semantic locator matched ${result.matches_n} elements; write actions require a unique target.`,
          hint: 'Add --name/--label/--text/--testid or use browser find with a narrower locator.',
          matches_n: result.matches_n,
          entries: result.entries,
        },
      };
    }
    const first = result.entries[0];
    if (!first) {
      return {
        error: {
          code: 'semantic_not_found',
          message: 'Semantic locator matched 0 elements',
          hint: 'Try browser state, --source ax, or relax the semantic locator.',
        },
      };
    }
    const target = String(first.ref);
    if (mode === 'read') {
      return {
        target,
        ...(result.matches_n > 1 ? { total_matches: result.matches_n } : {}),
      };
    }
    return target;
  };

  const semanticTargetFromOptions = async (
    page: Awaited<ReturnType<typeof getBrowserPage>>,
    opts: Record<string, unknown>,
    mode: 'read' | 'write',
  ): Promise<string | { target: string; total_matches?: number } | { error: { code: string; message: string; hint?: string; matches_n?: number; entries?: FindResult['entries'] } } | null> => {
    const locator = semanticLocatorFromOptions(opts);
    if (!locator) return null;
    return semanticTargetFromLocator(page, locator, mode);
  };

  const resolveExplicitOrSemanticTarget = async (
    page: Awaited<ReturnType<typeof getBrowserPage>>,
    target: unknown,
    opts: Record<string, unknown>,
    mode: 'read' | 'write',
  ): Promise<string | { target: string; total_matches?: number } | { error: { code: string; message: string; hint?: string; matches_n?: number; entries?: FindResult['entries'] } }> => {
    const explicit = typeof target === 'string' && target.trim() ? target.trim() : '';
    const hasSemantic = !!semanticLocatorFromOptions(opts);
    if (explicit && hasSemantic) {
      return {
        error: {
          code: 'usage_error',
          message: 'Pass either <target> or semantic locator flags, not both.',
        },
      };
    }
    if (explicit) return explicit;
    const semantic = await semanticTargetFromOptions(page, opts, mode);
    if (semantic) return semantic;
    return {
      error: {
        code: 'usage_error',
        message: 'Missing target. Pass a numeric ref/CSS selector, or semantic flags like --role button --name Submit.',
      },
    };
  };

  const printTargetResolutionError = (
    resolved: { error: { code: string; message: string; hint?: string; matches_n?: number; entries?: FindResult['entries'] } },
  ): void => {
    console.log(JSON.stringify(resolved, null, 2));
    process.exitCode = EXIT_CODES.USAGE_ERROR;
  };

  const resolveWriteTargetOrPrint = async (
    page: Awaited<ReturnType<typeof getBrowserPage>>,
    target: unknown,
    opts: Record<string, unknown>,
  ): Promise<string | null> => {
    const resolvedTarget = await resolveExplicitOrSemanticTarget(page, target, opts, 'write');
    if (typeof resolvedTarget === 'string') return resolvedTarget;
    if ('error' in resolvedTarget) printTargetResolutionError(resolvedTarget);
    return null;
  };

  const resolveWriteTargetAndValueOrPrint = async (
    page: Awaited<ReturnType<typeof getBrowserPage>>,
    targetOrValue: unknown,
    value: unknown,
    opts: Record<string, unknown>,
    valueLabel: string,
  ): Promise<{ target: string; value: string } | null> => {
    const hasSemantic = !!semanticLocatorFromOptions(opts);
    if (hasSemantic && value !== undefined) {
      printTargetResolutionError({
        error: {
          code: 'usage_error',
          message: `When using semantic locator flags, pass only <${valueLabel}> as the positional argument.`,
        },
      });
      return null;
    }
    const resolvedValue = hasSemantic ? targetOrValue : value;
    if (resolvedValue === undefined) {
      printTargetResolutionError({
        error: {
          code: 'usage_error',
          message: `Missing ${valueLabel}.`,
          hint: hasSemantic
            ? `With semantic locator flags, pass the ${valueLabel} as the only positional argument.`
            : `Pass both a target and ${valueLabel}.`,
        },
      });
      return null;
    }
    const resolvedTarget = await resolveWriteTargetOrPrint(page, hasSemantic ? undefined : targetOrValue, opts);
    if (!resolvedTarget) return null;
    return { target: resolvedTarget, value: String(resolvedValue) };
  };

  const resolvePrefixedWriteTargetOrPrint = async (
    page: Awaited<ReturnType<typeof getBrowserPage>>,
    target: unknown,
    opts: Record<string, unknown>,
    prefix: string,
    label: string,
  ): Promise<string | null> => {
    const explicit = typeof target === 'string' && target.trim() ? target.trim() : '';
    const locator = prefixedSemanticLocatorFromOptions(opts, prefix);
    if (explicit && locator) {
      printTargetResolutionError({
        error: {
          code: 'usage_error',
          message: `Pass either <${label}> or --${prefix}-* semantic locator flags, not both.`,
        },
      });
      return null;
    }
    if (explicit) return explicit;
    if (locator) {
      const resolved = await semanticTargetFromLocator(page, locator, 'write');
      if (typeof resolved === 'string') return resolved;
      if ('error' in resolved) printTargetResolutionError(resolved);
      return null;
    }
    printTargetResolutionError({
      error: {
        code: 'usage_error',
        message: `Missing ${label}. Pass a numeric ref/CSS selector, or --${prefix}-role/--${prefix}-name semantic flags.`,
      },
    });
    return null;
  };

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('find'))
      .option('--css <selector>', 'CSS selector (required)')
      .option('--limit <n>', 'Max entries returned', '50')
      .option('--text-max <n>', 'Max chars of trimmed text per entry', '120')
      .description('Find DOM elements by CSS or semantic locator — returns JSON {matches_n, entries[]}'),
  )
    .action(browserAction(async (page, opts) => {
      const locator = semanticLocatorFromOptions(opts);
      if ((!opts.css || typeof opts.css !== 'string') && !locator) {
        console.log(JSON.stringify({
          error: {
            code: 'usage_error',
            message: '--css <selector> or a semantic locator flag is required',
            hint: 'Examples: opencli browser find --css ".btn.primary"; opencli browser find --role button --name Save',
          },
        }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      if (opts.css && locator) {
        console.log(JSON.stringify({
          error: {
            code: 'usage_error',
            message: 'Pass either --css or semantic locator flags, not both.',
          },
        }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const limit = parseNthFlag(opts.limit);
      if (limit && typeof limit === 'object' && 'error' in limit) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: limit.error.replace('--nth', '--limit') } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const textMax = parseNthFlag(opts.textMax);
      if (textMax && typeof textMax === 'object' && 'error' in textMax) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: textMax.error.replace('--nth', '--text-max') } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const result = await page.evaluate(
        locator
          ? buildSemanticFindJs({
              ...locator,
              limit: limit as number | null ?? undefined,
              textMax: textMax as number | null ?? undefined,
            })
          : buildFindJs(opts.css, {
              limit: limit as number | null ?? undefined,
              textMax: textMax as number | null ?? undefined,
            }),
      ) as FindResult | FindError;
      if (isFindError(result)) {
        console.log(JSON.stringify(result, null, 2));
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
        return;
      }
      console.log(JSON.stringify(result, null, 2));
    }));

  // ── Get commands (structured data extraction) ──

  const get = browser.command('get').description('Get page properties');

  addBrowserTabOption(get.command('title').description('Page title'))
    .action(browserAction(async (page) => {
      console.log(await page.evaluate('document.title'));
    }));

  addBrowserTabOption(get.command('url').description('Current page URL'))
    .action(browserAction(async (page) => {
      console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href'));
    }));

  // Read commands (`get text/value/attributes`) always emit a JSON envelope:
  //
  //   { value, matches_n }                           — success
  //   { error: { code, message, hint, matches_n? } } — structured failure
  //
  // `<target>` accepts either a numeric ref (from `browser state`/`browser find`)
  // or a CSS selector. On multi-match CSS, the first element wins and the real
  // match count is exposed via `matches_n`; `--nth <n>` picks a specific one.
  const runGetCommand = async (
    page: Awaited<ReturnType<typeof getBrowserPage>>,
    target: string | undefined,
    opts: Record<string, unknown> & { nth?: string },
    evalJs: string,
    field: 'text' | 'value' | 'attributes',
  ): Promise<void> => {
    const resolvedTarget = await resolveExplicitOrSemanticTarget(page, target, opts, 'read');
    if (typeof resolvedTarget !== 'string' && 'error' in resolvedTarget) {
      console.log(JSON.stringify(resolvedTarget, null, 2));
      process.exitCode = EXIT_CODES.USAGE_ERROR;
      return;
    }
    const targetRef = typeof resolvedTarget === 'string' ? resolvedTarget : resolvedTarget.target;
    const totalMatches = typeof resolvedTarget === 'string' ? undefined : resolvedTarget.total_matches;
    const nth = parseNthFlag(opts.nth);
    if (nth && typeof nth === 'object' && 'error' in nth) {
      console.log(JSON.stringify({ error: { code: 'usage_error', message: nth.error } }, null, 2));
      process.exitCode = EXIT_CODES.USAGE_ERROR;
      return;
    }
    const { matches_n, match_level } = await resolveRef(page, targetRef, {
      firstOnMulti: nth === null,
      ...(typeof nth === 'number' ? { nth } : {}),
    });
    const raw = await page.evaluate(evalJs);
    let value: unknown;
    if (field === 'attributes') {
      // getAttributesResolvedJs stringifies the attribute record — parse it back so
      // the JSON envelope contains a real object rather than a nested JSON string.
      try { value = raw == null ? {} : JSON.parse(String(raw)); }
      catch { value = raw; }
    } else {
      value = raw ?? null;
    }
    console.log(JSON.stringify({
      value,
      matches_n,
      match_level,
      ...(totalMatches && totalMatches > 1 ? { total_matches: totalMatches } : {}),
    }, null, 2));
  };

  addBrowserTabOption(
    addSemanticLocatorOptions(get.command('text'))
      .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
      .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
      .description('Element text content — JSON envelope {value, matches_n}'),
  )
    .action(browserAction(async (page, target, opts) =>
      runGetCommand(page, target, opts ?? {}, getTextResolvedJs(), 'text')));

  addBrowserTabOption(
    addSemanticLocatorOptions(get.command('value'))
      .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
      .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
      .description('Input/textarea value — JSON envelope {value, matches_n}'),
  )
    .action(browserAction(async (page, target, opts) =>
      runGetCommand(page, target, opts ?? {}, getValueResolvedJs(), 'value')));

  addBrowserTabOption(
    get.command('html')
      .option('--selector <css>', 'CSS selector scope (first match)')
      .option('--as <format>', 'Output format: "html" (default) or "json" for structured tree', 'html')
      .option('--max <n>', 'Max characters of raw HTML to return (0 = unlimited)', '0')
      .option('--depth <n>', '(--as json) Max tree depth below root (0 = root only, 0 disables = unlimited via empty)', '')
      .option('--children-max <n>', '(--as json) Max element children kept per node (empty = unlimited)', '')
      .option('--text-max <n>', '(--as json) Max chars of direct text kept per node (empty = unlimited)', '')
      .description('Page HTML (or scoped); use --as json for a {tag, attrs, text, children} tree'),
  )
    .action(browserAction(async (page, opts) => {
      const format = String(opts.as || 'html').toLowerCase();
      if (format !== 'html' && format !== 'json') {
        console.log(JSON.stringify({ error: { code: 'invalid_format', message: `--as must be "html" or "json", got "${opts.as}"` } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }

      // `--max` is validated up-front (before touching the page) so a bad value
      // gets the same structured error regardless of selector/format path.
      const rawMax = String(opts.max ?? '0');
      if (!/^\d+$/.test(rawMax)) {
        console.log(JSON.stringify({ error: { code: 'invalid_max', message: `--max must be a non-negative integer, got "${opts.max}"` } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const max = Number.parseInt(rawMax, 10);

      if (format === 'json') {
        const parseBudget = (flag: string, value: unknown): number | null | { error: string } => {
          const raw = value === undefined || value === null ? '' : String(value);
          if (raw === '') return null;
          if (!/^\d+$/.test(raw)) return { error: `${flag} must be a non-negative integer, got "${raw}"` };
          return Number.parseInt(raw, 10);
        };
        const depth = parseBudget('--depth', opts.depth);
        const childrenMax = parseBudget('--children-max', opts.childrenMax);
        const textMax = parseBudget('--text-max', opts.textMax);
        for (const budget of [depth, childrenMax, textMax]) {
          if (budget && typeof budget === 'object' && 'error' in budget) {
            console.log(JSON.stringify({ error: { code: 'invalid_budget', message: budget.error } }, null, 2));
            process.exitCode = EXIT_CODES.USAGE_ERROR;
            return;
          }
        }
        const js = buildHtmlTreeJs({
          selector: opts.selector ?? null,
          depth: depth as number | null,
          childrenMax: childrenMax as number | null,
          textMax: textMax as number | null,
        });
        const result = await page.evaluate(js) as HtmlTreeResult | { selector: string; invalidSelector: true; reason: string } | null;
        if (result && typeof result === 'object' && 'invalidSelector' in result && result.invalidSelector) {
          console.log(JSON.stringify({
            error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${result.reason}` },
          }, null, 2));
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }
        const ok = result as HtmlTreeResult | null;
        if (!ok || ok.matched === 0) {
          console.log(JSON.stringify({
            error: {
              code: 'selector_not_found',
              message: opts.selector
                ? `Selector "${opts.selector}" matched 0 elements.`
                : 'Page has no documentElement.',
            },
          }, null, 2));
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }
        console.log(JSON.stringify(ok, null, 2));
        return;
      }

      // Raw HTML path — unbounded by default; --max optionally caps with a visible marker.
      // Selector lookup is wrapped in try/catch inside page context so an invalid
      // selector returns a structured signal instead of throwing through page.evaluate.
      const sel = opts.selector ? JSON.stringify(opts.selector) : 'null';
      const rawResult = await page.evaluate(
        `(() => {
          const s = ${sel};
          if (s) {
            try {
              const el = document.querySelector(s);
              return { kind: 'ok', html: el ? el.outerHTML : null };
            } catch (e) {
              return { kind: 'invalid_selector', reason: (e && e.message) || String(e) };
            }
          }
          return { kind: 'ok', html: document.documentElement ? document.documentElement.outerHTML : null };
        })()`,
      ) as { kind: 'ok'; html: string | null } | { kind: 'invalid_selector'; reason: string };

      if (rawResult.kind === 'invalid_selector') {
        console.log(JSON.stringify({
          error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${rawResult.reason}` },
        }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const html = rawResult.html;

      if (html === null) {
        if (opts.selector) {
          console.log(JSON.stringify({
            error: { code: 'selector_not_found', message: `Selector "${opts.selector}" matched 0 elements.` },
          }, null, 2));
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }
        console.log('(empty)');
        return;
      }
      if (max > 0 && html.length > max) {
        console.log(`<!-- opencli: truncated ${max} of ${html.length} chars; re-run without --max (or --max 0) for full -->\n${html.slice(0, max)}`);
        return;
      }
      console.log(html);
    }));

  addBrowserTabOption(
    addSemanticLocatorOptions(get.command('attributes'))
      .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
      .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
      .description('Element attributes — JSON envelope {value, matches_n}'),
  )
    .action(browserAction(async (page, target, opts) =>
      runGetCommand(page, target, opts ?? {}, getAttributesResolvedJs(), 'attributes')));

  // ── Interact ──
  //
  // Write commands (`click/type/select`) share the same `<target>` contract
  // as the read commands but *reject* multi-match CSS as `selector_ambiguous`
  // unless the caller passes `--nth <n>`. That asymmetry is intentional:
  // clicking "one of three buttons" at random is almost never what the agent
  // meant. Every branch emits a JSON envelope on stdout; error envelopes go
  // through the unified TargetError handler in browserAction.

  /**
   * Parse the `--nth` flag and convert it to `ResolveOptions`.
   * Returns `{ error }` when the flag was malformed (so the command can
   * print the structured usage error and exit) or `{ opts }` to feed
   * into resolveRef / page.click / page.typeText.
   */
  function nthToResolveOpts(raw: unknown): { error: string } | { opts: ResolveOptions } {
    const parsed = parseNthFlag(raw);
    if (parsed && typeof parsed === 'object' && 'error' in parsed) return parsed;
    if (typeof parsed === 'number') return { opts: { nth: parsed } };
    return { opts: {} };
  }

  function resolveUploadFilePaths(rawFiles: unknown): { files: string[] } | { error: { code: string; message: string; hint?: string } } {
    const inputs = Array.isArray(rawFiles) ? rawFiles : [];
    if (inputs.length === 0) {
      return {
        error: {
          code: 'usage_error',
          message: 'At least one file path is required.',
          hint: 'Example: opencli browser upload "input[type=file]" ./receipt.pdf',
        },
      };
    }
    const files: string[] = [];
    for (const input of inputs) {
      const raw = String(input);
      const expanded = raw === '~' || raw.startsWith(`~${path.sep}`)
        ? path.join(os.homedir(), raw.slice(2))
        : raw;
      const resolved = path.resolve(expanded);
      if (!fs.existsSync(resolved)) {
        return { error: { code: 'file_not_found', message: `File not found: ${resolved}` } };
      }
      const stat = fs.statSync(resolved);
      if (!stat.isFile()) {
        return { error: { code: 'not_a_file', message: `Not a regular file: ${resolved}` } };
      }
      files.push(resolved);
    }
    return { files };
  }

  function parseResolveFlag(raw: unknown, flag: string): { error: string } | { opts: ResolveOptions } {
    const parsed = parseNthFlag(raw);
    if (parsed && typeof parsed === 'object' && 'error' in parsed) {
      return { error: parsed.error.replace('--nth', flag) };
    }
    if (typeof parsed === 'number') return { opts: { nth: parsed } };
    return { opts: {} };
  }

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('click'))
      .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Click element — JSON envelope {clicked, target, matches_n}'),
  )
    .action(browserAction(async (page, target, opts) => {
      const resolvedTarget = await resolveExplicitOrSemanticTarget(page, target, opts ?? {}, 'write');
      if (typeof resolvedTarget !== 'string') {
        console.log(JSON.stringify(resolvedTarget, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const parsed = nthToResolveOpts(opts?.nth);
      if ('error' in parsed) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const { matches_n, match_level } = await page.click(resolvedTarget, parsed.opts);
      console.log(JSON.stringify({ clicked: true, target: resolvedTarget, matches_n, match_level }, null, 2));
    }));

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('type'))
      .argument('[targetOrText]', 'Numeric ref/CSS target, or text when using --role/--name/etc.')
      .argument('[text]', 'Text to type')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Click element, then type text — JSON envelope {typed, text, target, matches_n, autocomplete}'),
  )
    .action(browserAction(async (page, targetOrText, text, opts) => {
      const resolved = await resolveWriteTargetAndValueOrPrint(page, targetOrText, text, opts ?? {}, 'text');
      if (!resolved) return;
      const parsed = nthToResolveOpts(opts?.nth);
      if ('error' in parsed) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      // Click first (focuses the field), wait briefly, then type.
      await page.click(resolved.target, parsed.opts);
      await page.wait(0.3);
      const { matches_n, match_level } = await page.typeText(resolved.target, resolved.value, parsed.opts);
      // __resolved is already set by the resolver call inside page.typeText
      const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs()) as boolean;
      if (isAutocomplete) await page.wait(0.4);
      console.log(JSON.stringify({
        typed: true,
        text: resolved.value,
        target: resolved.target,
        matches_n,
        match_level,
        autocomplete: !!isAutocomplete,
      }, null, 2));
    }));

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('hover'))
      .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Move the mouse over an element — JSON envelope {hovered, target, matches_n}'),
  )
    .action(browserAction(async (page, target, opts) => {
      if (typeof page.hover !== 'function') throw new Error('browser hover is not supported by this browser backend');
      const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
      if (!resolvedTarget) return;
      const parsed = nthToResolveOpts(opts?.nth);
      if ('error' in parsed) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const { matches_n, match_level } = await page.hover(resolvedTarget, parsed.opts);
      console.log(JSON.stringify({ hovered: true, target: resolvedTarget, matches_n, match_level }, null, 2));
    }));

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('focus'))
      .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Focus an element — JSON envelope {focused, target, matches_n}'),
  )
    .action(browserAction(async (page, target, opts) => {
      if (typeof page.focus !== 'function') throw new Error('browser focus is not supported by this browser backend');
      const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
      if (!resolvedTarget) return;
      const parsed = nthToResolveOpts(opts?.nth);
      if ('error' in parsed) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const { focused, matches_n, match_level } = await page.focus(resolvedTarget, parsed.opts);
      console.log(JSON.stringify({ focused, target: resolvedTarget, matches_n, match_level }, null, 2));
    }));

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('dblclick'))
      .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Double-click element — JSON envelope {dblclicked, target, matches_n}'),
  )
    .action(browserAction(async (page, target, opts) => {
      if (typeof page.dblClick !== 'function') throw new Error('browser dblclick is not supported by this browser backend');
      const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
      if (!resolvedTarget) return;
      const parsed = nthToResolveOpts(opts?.nth);
      if ('error' in parsed) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const { matches_n, match_level } = await page.dblClick(resolvedTarget, parsed.opts);
      console.log(JSON.stringify({ dblclicked: true, target: resolvedTarget, matches_n, match_level }, null, 2));
    }));

  const runCheckCommand = async (
    page: Awaited<ReturnType<typeof getBrowserPage>>,
    target: unknown,
    opts: Record<string, unknown>,
    checked: boolean,
  ): Promise<void> => {
    if (typeof page.setChecked !== 'function') throw new Error(`browser ${checked ? 'check' : 'uncheck'} is not supported by this browser backend`);
    const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts);
    if (!resolvedTarget) return;
    const parsed = nthToResolveOpts(opts?.nth);
    if ('error' in parsed) {
      console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
      process.exitCode = EXIT_CODES.USAGE_ERROR;
      return;
    }
    const result = await page.setChecked(resolvedTarget, checked, parsed.opts);
    console.log(JSON.stringify({
      checked: result.checked,
      changed: result.changed,
      target: resolvedTarget,
      matches_n: result.matches_n,
      match_level: result.match_level,
      ...(result.kind ? { kind: result.kind } : {}),
    }, null, 2));
  };

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('check'))
      .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Ensure a checkbox/radio/aria-checked control is checked — JSON envelope {checked, changed, target, matches_n}'),
  )
    .action(browserAction(async (page, target, opts) => {
      await runCheckCommand(page, target, opts ?? {}, true);
    }));

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('uncheck'))
      .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Ensure a checkbox/aria-checked control is unchecked — JSON envelope {checked, changed, target, matches_n}'),
  )
    .action(browserAction(async (page, target, opts) => {
      await runCheckCommand(page, target, opts ?? {}, false);
    }));

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('upload'))
      .argument('[targetOrFile]', 'Numeric ref/CSS target, or first file when using --role/--name/etc.')
      .argument('[files...]', 'Local file path(s) to attach')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Attach local files to a file input — JSON envelope {uploaded, files, file_names, target, matches_n}'),
  )
    .action(browserAction(async (page, targetOrFile, files, opts) => {
      if (typeof page.uploadFiles !== 'function') throw new Error('browser upload is not supported by this browser backend');
      const hasSemantic = !!semanticLocatorFromOptions(opts ?? {});
      const target = hasSemantic ? undefined : targetOrFile;
      const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
      if (!resolvedTarget) return;
      const parsed = nthToResolveOpts(opts?.nth);
      if ('error' in parsed) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const rawFiles = hasSemantic
        ? [targetOrFile, ...(Array.isArray(files) ? files : [])].filter((value) => value !== undefined)
        : files;
      const resolvedFiles = resolveUploadFilePaths(rawFiles);
      if ('error' in resolvedFiles) {
        console.log(JSON.stringify({ error: resolvedFiles.error }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const result = await page.uploadFiles(resolvedTarget, resolvedFiles.files, parsed.opts);
      console.log(JSON.stringify(result, null, 2));
    }));

  addBrowserTabOption(
    addPrefixedSemanticLocatorOptions(
      addPrefixedSemanticLocatorOptions(browser.command('drag'), 'from'),
      'to',
    )
      .argument('[source]', 'Numeric ref/CSS selector to drag from, or omit with --from-role/--from-name/etc.')
      .argument('[target]', 'Numeric ref/CSS selector to drop onto, or omit with --to-role/--to-name/etc.')
      .option('--from-nth <n>', 'When <source> is a multi-match CSS selector, pick the nth match (0-based)')
      .option('--to-nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Drag one element to another — JSON envelope {dragged, source, target, source_matches_n, target_matches_n}'),
  )
    .action(browserAction(async (page, source, target, opts) => {
      if (typeof page.drag !== 'function') throw new Error('browser drag is not supported by this browser backend');
      const resolvedSource = await resolvePrefixedWriteTargetOrPrint(page, source, opts ?? {}, 'from', 'source');
      if (!resolvedSource) return;
      const resolvedTarget = await resolvePrefixedWriteTargetOrPrint(page, target, opts ?? {}, 'to', 'target');
      if (!resolvedTarget) return;
      const from = parseResolveFlag(opts?.fromNth, '--from-nth');
      if ('error' in from) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: from.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const to = parseResolveFlag(opts?.toNth, '--to-nth');
      if ('error' in to) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: to.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const result = await page.drag(resolvedSource, resolvedTarget, { from: from.opts, to: to.opts });
      console.log(JSON.stringify(result, null, 2));
    }));

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('fill'))
      .argument('[targetOrText]', 'Numeric ref/CSS target, or text when using --role/--name/etc.')
      .argument('[text]', 'Text to set exactly')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Set input/textarea/contenteditable text exactly and verify the value — JSON envelope {filled, verified, text, actual}'),
  )
    .action(browserAction(async (page, targetOrText, text, opts) => {
      const resolved = await resolveWriteTargetAndValueOrPrint(page, targetOrText, text, opts ?? {}, 'text');
      if (!resolved) return;
      const parsed = nthToResolveOpts(opts?.nth);
      if ('error' in parsed) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const result = await page.fillText(resolved.target, resolved.value, parsed.opts);
      if (!result.verified) process.exitCode = EXIT_CODES.GENERIC_ERROR;
      console.log(JSON.stringify({
        filled: result.filled,
        verified: result.verified,
        target: resolved.target,
        text: resolved.value,
        actual: result.actual,
        length: result.length,
        matches_n: result.matches_n,
        match_level: result.match_level,
        ...(result.mode ? { mode: result.mode } : {}),
      }, null, 2));
    }));

  addBrowserTabOption(
    addSemanticLocatorOptions(browser.command('select'))
      .argument('[targetOrOption]', 'Numeric ref/CSS target, or option text when using --role/--name/etc.')
      .argument('[option]', 'Option text (or value) to select')
      .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
      .description('Select dropdown option — JSON envelope {selected, target, matches_n}'),
  )
    .action(browserAction(async (page, targetOrOption, option, opts) => {
      const resolved = await resolveWriteTargetAndValueOrPrint(page, targetOrOption, option, opts ?? {}, 'option');
      if (!resolved) return;
      const parsed = nthToResolveOpts(opts?.nth);
      if ('error' in parsed) {
        console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const { matches_n, match_level } = await resolveRef(page, resolved.target, parsed.opts);
      const result = await page.evaluate(selectResolvedJs(resolved.value)) as
        | { error?: string; selected?: string; available?: string[] }
        | null;
      if (result?.error) {
        // The select-specific "Not a <select>" / "Option not found" errors
        // are domain-level failures — emit a structured envelope so agents
        // can branch on code rather than scrape a log line.
        console.log(JSON.stringify({
          error: {
            code: result.error === 'Not a <select>' ? 'not_a_select' : 'option_not_found',
            message: result.error,
            ...(result.available && { available: result.available }),
            matches_n,
          },
        }, null, 2));
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
        return;
      }
      console.log(JSON.stringify({
        selected: result?.selected ?? resolved.value,
        target: resolved.target,
        matches_n,
        match_level,
      }, null, 2));
    }));

  addBrowserTabOption(browser.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)'))
    .description('Press keyboard key')
    .action(browserAction(async (page, key) => {
      await page.pressKey(key);
      console.log(`Pressed: ${key}`);
    }));

  const browserDialog = browser
    .command('dialog')
    .description('Handle a blocking JavaScript alert/confirm/prompt dialog');

  addBrowserTabOption(browserDialog.command('accept')
    .option('--text <text>', 'Prompt text to submit for prompt() dialogs')
    .description('Accept the currently open JavaScript dialog'))
    .action(browserAction(async (page, opts?: { text?: string }) => {
      if (!page.handleJavaScriptDialog) {
        throw new Error('This browser session does not support JavaScript dialog handling');
      }
      try {
        await page.handleJavaScriptDialog(true, opts?.text);
      } catch (err) {
        const message = getErrorMessage(err);
        if (message.toLowerCase().includes('no dialog')) {
          console.log(JSON.stringify({
            error: {
              code: 'no_javascript_dialog',
              message: 'No JavaScript dialog is currently open.',
            },
          }, null, 2));
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }
        throw err;
      }
      console.log(JSON.stringify({ handled: true, action: 'accept', ...(opts?.text !== undefined && { text: opts.text }) }, null, 2));
    }));

  addBrowserTabOption(browserDialog.command('dismiss')
    .description('Dismiss the currently open JavaScript dialog'))
    .action(browserAction(async (page) => {
      if (!page.handleJavaScriptDialog) {
        throw new Error('This browser session does not support JavaScript dialog handling');
      }
      try {
        await page.handleJavaScriptDialog(false);
      } catch (err) {
        const message = getErrorMessage(err);
        if (message.toLowerCase().includes('no dialog')) {
          console.log(JSON.stringify({
            error: {
              code: 'no_javascript_dialog',
              message: 'No JavaScript dialog is currently open.',
            },
          }, null, 2));
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }
        throw err;
      }
      console.log(JSON.stringify({ handled: true, action: 'dismiss' }, null, 2));
    }));

  // ── Wait commands ──

  addBrowserTabOption(browser.command('wait'))
    .argument('<type>', 'selector, text, time, xhr, or download')
    .argument('[value]', 'CSS selector, text string, seconds, XHR URL regex, or download filename/URL pattern')
    .option('--timeout <ms>', 'Timeout in milliseconds', '10000')
    .description('Wait for selector, text, time, matching XHR, or browser download (e.g. wait selector ".loaded", wait text "Success", wait time 3, wait xhr "/api/search", wait download receipt.pdf)')
    .action(browserAction(async (page, type, value, opts) => {
      const timeout = parseInt(opts.timeout, 10);
      if (type === 'time') {
        const seconds = parseFloat(value ?? '2');
        await page.wait(seconds);
        console.log(`Waited ${seconds}s`);
      } else if (type === 'selector') {
        if (!value) { console.error('Missing CSS selector'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
        await page.wait({ selector: value, timeout: timeout / 1000 });
        console.log(`Element "${value}" appeared`);
      } else if (type === 'text') {
        if (!value) { console.error('Missing text'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
        await page.wait({ text: value, timeout: timeout / 1000 });
        console.log(`Text "${value}" appeared`);
      } else if (type === 'xhr') {
        // Poll the capture ring until an entry matches the URL regex — turns
        // the common "open page, wait N seconds, hope the data landed" idiom
        // into a deterministic barrier keyed on the API the agent actually
        // cares about. Prevents silent "empty DOM" failures on slow SPAs.
        if (!value) { console.error('Missing XHR URL regex'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
        let re: RegExp;
        try { re = new RegExp(value); } catch (err) {
          console.error(`Invalid regex "${value}": ${err instanceof Error ? err.message : String(err)}`);
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }
        const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
        if (!hasSessionCapture) {
          try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ }
        }
        await captureNetworkItems(page);
        const deadline = Date.now() + timeout;
        const pollMs = 400;
        let matched: BrowserNetworkItem | null = null;
        while (Date.now() < deadline && !matched) {
          const items = await captureNetworkItems(page);
          matched = items.find((e) => re.test(e.url)) ?? null;
          if (!matched) await new Promise((r) => setTimeout(r, pollMs));
        }
        if (!matched) {
          console.log(JSON.stringify({
            error: {
              code: 'xhr_not_seen',
              message: `No captured XHR matched /${value}/ within ${timeout}ms`,
              hint: 'Check the pattern against `browser network` output; the endpoint may not have fired yet, or capture is disabled.',
            },
          }, null, 2));
          process.exitCode = EXIT_CODES.GENERIC_ERROR;
          return;
        }
        console.log(JSON.stringify({
          matched: { url: matched.url, status: matched.status, contentType: matched.ct },
        }, null, 2));
      } else if (type === 'download') {
        if (typeof page.waitForDownload !== 'function') {
          console.log(JSON.stringify({
            error: {
              code: 'download_wait_unavailable',
              message: 'The active browser backend does not support download lifecycle waits.',
              hint: 'Use the Browser Bridge extension version 1.0.8 or newer, then retry the command.',
            },
          }, null, 2));
          process.exitCode = EXIT_CODES.GENERIC_ERROR;
          return;
        }
        const result = await page.waitForDownload(String(value ?? ''), timeout) as BrowserDownloadWaitResult;
        if (!result.downloaded) {
          const code = result.state === 'interrupted' && result.id !== undefined ? 'download_failed' : 'download_not_seen';
          console.log(JSON.stringify({
            error: {
              code,
              message: result.error ?? `No download matched "${value ?? '*'}" within ${timeout}ms`,
              hint: 'Check the pattern against the expected filename or URL; use a longer --timeout if the download starts slowly.',
            },
            download: result,
          }, null, 2));
          process.exitCode = EXIT_CODES.GENERIC_ERROR;
          return;
        }
        console.log(JSON.stringify(result, null, 2));
      } else {
        console.error(`Unknown wait type "${type}". Use: selector, text, time, xhr, or download`);
        process.exitCode = EXIT_CODES.USAGE_ERROR;
      }
    }));

  // ── Extract ──

  addBrowserTabOption(
    browser.command('eval')
      .argument('<js>', 'JavaScript code')
      .option('--frame <index>', 'Cross-origin iframe index from "browser frames"')
      .description('Execute JS in page context, return result'),
  )
    .action(browserAction(async (page, js, opts) => {
      let result: unknown;
      if (opts.frame !== undefined) {
        const frameIndex = Number.parseInt(opts.frame, 10);
        if (!Number.isInteger(frameIndex) || frameIndex < 0) {
          console.error(`Invalid frame index "${opts.frame}". Use a 0-based index from "browser frames".`);
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }
        if (!page.evaluateInFrame) {
          throw new Error('This browser session does not support frame-targeted evaluation');
        }
        result = await page.evaluateInFrame(js, frameIndex);
      } else {
        result = await page.evaluate(js);
      }
      if (typeof result === 'string') console.log(result);
      else console.log(JSON.stringify(result, null, 2));
    }));

  // ── Extract (content reading) ──
  //
  // `extract` answers the "read this page" question that `get html` / `get text`
  // can't: denoise → markdown → paragraph-aware chunking. Agents walk long pages
  // by passing back the `next_start_char` cursor instead of juggling selectors.

  addBrowserTabOption(
    browser.command('extract')
      .option('--selector <css>', 'CSS selector scope; defaults to <main>/<article>/<body>')
      .option('--chunk-size <chars>', 'Target chunk size in chars', '20000')
      .option('--start <char>', 'Start offset (use next_start_char from a previous extract)', '0')
      .description('Extract page content as markdown, paragraph-aware chunks for long pages'),
  )
    .action(browserAction(async (page, opts) => {
      const rawChunk = String(opts.chunkSize ?? '20000');
      if (!/^\d+$/.test(rawChunk) || Number.parseInt(rawChunk, 10) <= 0) {
        console.log(JSON.stringify({ error: { code: 'invalid_chunk_size', message: `--chunk-size must be a positive integer, got "${opts.chunkSize}"` } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const rawStart = String(opts.start ?? '0');
      if (!/^\d+$/.test(rawStart)) {
        console.log(JSON.stringify({ error: { code: 'invalid_start', message: `--start must be a non-negative integer, got "${opts.start}"` } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      const chunkSize = Number.parseInt(rawChunk, 10);
      const start = Number.parseInt(rawStart, 10);
      const selector = typeof opts.selector === 'string' && opts.selector.length > 0 ? opts.selector : null;

      const js = buildExtractHtmlJs(selector);
      const res = await page.evaluate(js) as
        | { ok: true; url: string; title: string; html: string }
        | { invalidSelector: true; reason: string }
        | { notFound: true }
        | null;

      if (!res) {
        console.log(JSON.stringify({ error: { code: 'extract_failed', message: 'Page returned no root element.' } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      if ('invalidSelector' in res) {
        console.log(JSON.stringify({ error: { code: 'invalid_selector', message: `Selector "${selector}" is not a valid CSS selector: ${res.reason}` } }, null, 2));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      if ('notFound' in res) {
        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));
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }

      const envelope = runExtractFromHtml({
        html: res.html,
        url: res.url,
        title: res.title,
        selector,
        start,
        chunkSize,
      });
      console.log(JSON.stringify(envelope, null, 2));
    }));

  // ── Network (API discovery) ──
  //
  // Default output is JSON (agent-native). Each entry carries a stable `key`
  // (GraphQL operationName or `METHOD host+pathname`) so agents can fetch
  // full bodies with `--detail <key>` even after subsequent commands.
  // Captures are persisted per browser session under ~/.opencli/cache/browser-network/.

  addBrowserTabOption(browser.command('network'))
    .option('--detail <key>', 'Emit full body for the entry with this key')
    .option('--all', 'Include static resources (js/css/images/telemetry)')
    .option('--raw', 'Emit full bodies for every entry (skip shape preview)')
    .option('--filter <fields>', 'Comma-separated field names; keep only entries whose body shape has ALL names as path segments')
    .option('--since <duration>', 'Only include entries from the last duration (for example: 30s, 2m)')
    .option('--until <duration>', 'Only include entries older than the duration from now')
    .option('--follow', 'Continuously print new matching entries as JSON lines', false)
    .option('--failed', 'Only include failed HTTP requests (status 0 or >= 400)', false)
    .option('--max-body <chars>', 'With --detail: cap the emitted body at N chars (0 = unlimited, default)', '0')
    .option('--ttl <ms>', 'Cache TTL in ms for --detail lookups', String(DEFAULT_TTL_MS))
    .description('Capture network requests as shape previews; retrieve full bodies by key')
    .action(browserAction(async (page, opts) => {
      const ttlMs = parsePositiveIntOption(opts.ttl, 'ttl', DEFAULT_TTL_MS);
      const session = getPageSession(page);
      const hasDetail = typeof opts.detail === 'string' && opts.detail.length > 0;
      const hasFilter = typeof opts.filter === 'string';
      const sinceMs = parseDurationMs(opts.since, 'since');
      const untilMs = parseDurationMs(opts.until, 'until');
      if (sinceMs && typeof sinceMs === 'object') {
        emitNetworkError('invalid_since', sinceMs.error);
        return;
      }
      if (untilMs && typeof untilMs === 'object') {
        emitNetworkError('invalid_until', untilMs.error);
        return;
      }

      // --detail and --filter do different things (one request by key vs. narrow
      // the list by shape), don't compose, and combining them has no sensible
      // semantic. Reject up front with a structured error instead of silently
      // dropping one.
      if (hasDetail && hasFilter) {
        emitNetworkError('invalid_args', '--filter and --detail cannot be used together (one narrows a list, the other fetches a specific entry).');
        return;
      }

      let filterFields: string[] | null = null;
      if (hasFilter) {
        const parsed = parseFilter(opts.filter as string);
        if ('reason' in parsed) {
          emitNetworkError('invalid_filter', parsed.reason);
          return;
        }
        filterFields = parsed.fields;
      }

      if (hasDetail && opts.follow) {
        emitNetworkError('invalid_args', '--follow cannot be used with --detail.');
        return;
      }

      // --detail short-circuits: read from cache only, no live capture needed.
      if (hasDetail) {
        const res = loadNetworkCache(session, { ttlMs });
        if (res.status === 'missing') {
          emitNetworkError('cache_missing', `No cached capture. Run "browser network" first (in session "${session}").`);
          return;
        }
        if (res.status === 'expired') {
          emitNetworkError('cache_expired', `Cache is stale (age ${res.ageMs}ms > ttl ${ttlMs}ms). Re-run "browser network" to refresh.`);
          return;
        }
        if (res.status === 'corrupt' || !res.file) {
          emitNetworkError('cache_corrupt', 'Cache file is malformed; re-run "browser network" to regenerate.');
          return;
        }
        const entry = findEntry(res.file, opts.detail);
        if (!entry) {
          emitNetworkError('key_not_found', `Key "${opts.detail}" not in cache.`, {
            available_keys: res.file.entries.map((e) => e.key),
          });
          return;
        }
        const rawMaxBody = String(opts.maxBody ?? '0');
        if (!/^\d+$/.test(rawMaxBody)) {
          emitNetworkError('invalid_max_body', `--max-body must be a non-negative integer, got "${opts.maxBody}"`);
          return;
        }
        const maxBody = Number.parseInt(rawMaxBody, 10);

        // Body shape/source:
        // - If capture already truncated it (entry.body_truncated), the body is a string.
        // - If the adapter stored a JSON value, it parsed cleanly at capture time; leave it.
        // - --max-body applies a transport-level cap when the caller wants to keep output small.
        let outputBody: unknown = entry.body;
        let transportTruncated = false;
        if (maxBody > 0 && typeof entry.body === 'string' && entry.body.length > maxBody) {
          outputBody = entry.body.slice(0, maxBody);
          transportTruncated = true;
        }
        const captureTruncated = entry.body_truncated === true;

        const detailEnvelope: Record<string, unknown> = {
          key: entry.key,
          url: entry.url,
          method: entry.method,
          status: entry.status,
          ct: entry.ct,
          size: entry.size,
          ...(typeof entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(entry.timestamp) } : {}),
          shape: inferShape(entry.body),
          body: outputBody,
        };
        if (captureTruncated || transportTruncated) {
          detailEnvelope.body_truncated = true;
          detailEnvelope.body_full_size = entry.body_full_size ?? entry.size;
          detailEnvelope.body_truncation_reason = captureTruncated
            ? 'capture-limit'
            : 'max-body';
        }
        console.log(JSON.stringify(detailEnvelope, null, 2));
        return;
      }

      if (opts.follow) {
        if (!await page.startNetworkCapture?.()) {
          try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ }
        }
        while (true) {
          const rawItems = await captureNetworkItems(page).catch((err) => {
            emitNetworkError('capture_failed', `Could not read network capture: ${(err as Error).message}`);
            return [];
          });
          let items = opts.all ? rawItems : filterNetworkItems(rawItems);
          items = filterByTimeWindow(items, { sinceMs, untilMs });
          if (opts.failed) items = items.filter((item) => item.status === 0 || item.status >= 400);
          const keyed = assignKeys(items);
          for (const item of keyed) {
            console.log(JSON.stringify({
              key: item.key,
              timestamp: toIsoTimestamp(item.timestamp),
              method: item.method,
              status: item.status,
              url: item.url,
              ct: item.ct,
              size: item.size,
              ...(item.bodyTruncated ? { body_truncated: true } : {}),
            }));
          }
          await new Promise((resolve) => setTimeout(resolve, FOLLOW_POLL_MS));
        }
      }

      // Fresh capture path.
      let rawItems: BrowserNetworkItem[];
      try {
        rawItems = await captureNetworkItems(page);
      } catch (err) {
        emitNetworkError('capture_failed', `Could not read network capture: ${(err as Error).message}`);
        return;
      }

      let items = opts.all ? rawItems : filterNetworkItems(rawItems);
      items = filterByTimeWindow(items, { sinceMs, untilMs });
      if (opts.failed) items = items.filter((item) => item.status === 0 || item.status >= 400);
      const filteredOut = rawItems.length - items.length;

      const keyed = assignKeys(items);
      const cacheEntries: CachedNetworkEntry[] = keyed.map((it) => ({
        key: it.key,
        url: it.url,
        method: it.method,
        status: it.status,
        size: it.size,
        ct: it.ct,
        body: it.body,
        ...(typeof it.timestamp === 'number' ? { timestamp: it.timestamp } : {}),
        ...(it.bodyTruncated ? { body_truncated: true } : {}),
        ...(it.bodyTruncated && typeof it.bodyFullSize === 'number'
          ? { body_full_size: it.bodyFullSize }
          : {}),
      }));
      // Soft failure: the caller already has the data, so surface a warning
      // via the output envelope rather than erroring out the whole command.
      let cacheWarning: string | null = null;
      try {
        saveNetworkCache(session, cacheEntries);
      } catch (err) {
        cacheWarning = `Could not persist capture cache: ${(err as Error).message}. --detail lookups may miss this capture.`;
      }

      // Pair each cache entry with its shape up front so --filter can read
      // segments without recomputing, and the --raw view can keep the full
      // body. Cache persistence above stored the unfiltered set on purpose:
      // later `--detail <key>` lookups must still see requests that the
      // current --filter narrowed out.
      const shaped = cacheEntries.map((e) => ({ entry: e, shape: inferShape(e.body) }));
      const visible = filterFields
        ? shaped.filter((s) => shapeMatchesFilter(s.shape, filterFields))
        : shaped;
      const filterDropped = filterFields ? shaped.length - visible.length : 0;

      const envelope: Record<string, unknown> = {
        session,
        captured_at: new Date().toISOString(),
        count: visible.length,
        filtered_out: filteredOut,
      };
      if (filterFields) {
        envelope.filter = filterFields;
        envelope.filter_dropped = filterDropped;
      }
      if (cacheWarning) envelope.cache_warning = cacheWarning;

      const truncatedCount = visible.filter((s) => s.entry.body_truncated).length;
      if (truncatedCount > 0) {
        envelope.body_truncated_count = truncatedCount;
        envelope.body_truncated_hint = 'Some bodies exceeded the capture limit; their `shape` reflects only the captured prefix.';
      }

      if (opts.raw) {
        envelope.entries = visible.map((s) => ({
          ...s.entry,
          ...(typeof s.entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(s.entry.timestamp) } : {}),
        }));
      } else {
        envelope.entries = visible.map((s) => ({
          key: s.entry.key,
          method: s.entry.method,
          ...(typeof s.entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(s.entry.timestamp) } : {}),
          status: s.entry.status,
          url: s.entry.url,
          ct: s.entry.ct,
          size: s.entry.size,
          shape: s.shape,
          ...(s.entry.body_truncated ? { body_truncated: true } : {}),
        }));
        envelope.detail_hint = 'Run "browser network --detail <key>" for full body.';
      }
      console.log(JSON.stringify(envelope, null, 2));
    }));

  // ── Init (adapter scaffolding) ──

  browser.command('init')
    .argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
    .description('Generate adapter scaffold in ~/.opencli/clis/')
    .action(async (name: string) => {
      try {
        const parts = name.split('/');
        if (parts.length !== 2 || !parts[0] || !parts[1]) {
          console.error('Name must be site/command format (e.g. hn/top)');
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }
        const [site, command] = parts;
        if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
          console.error('Name parts must be alphanumeric/dash/underscore only');
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }

        const os = await import('node:os');
        const fs = await import('node:fs');
        const path = await import('node:path');
        const dir = path.join(os.homedir(), '.opencli', 'clis', site);
        const filePath = path.join(dir, `${command}.js`);

        if (fs.existsSync(filePath)) {
          console.log(`Adapter already exists: ${filePath}`);
          return;
        }

        let domain = site;

        const template = `import { cli, Strategy } from '@jackwener/opencli/registry';

cli({
  site: '${site}',
  name: '${command}',
  description: '', // TODO: describe what this command does
  access: 'read',  // TODO: 'read' for queries, 'write' for remote/account state changes
  example: 'opencli ${site} ${command} -f yaml',
  domain: '${domain}',
  strategy: Strategy.PUBLIC, // TODO: PUBLIC (no auth), COOKIE (needs login), UI (DOM interaction)
  browser: false,            // TODO: set true if needs browser
  args: [
    { name: 'limit', type: 'int', default: 10, help: 'Number of items' },
  ],
  columns: [], // TODO: field names for table output (e.g. ['title', 'score', 'url'])
  func: async (kwargs) => {
    // TODO: implement data fetching
    // Prefer API calls (fetch) over browser automation
    // If you set browser: true, change this to: async (page, kwargs) => { ... }
    return [];
  },
});
`;
        fs.mkdirSync(dir, { recursive: true });
        fs.writeFileSync(filePath, template, 'utf-8');
        console.log(`Created: ${filePath}`);
        console.log('First time on this site? Run: opencli browser analyze <url>');
        console.log(`Edit the file to implement your adapter, then run: opencli browser verify ${name}`);
      } catch (err) {
        console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      }
    });

  // ── Verify (test adapter) ──

  browser.command('verify')
    .argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
    .option('--write-fixture', 'Write a starter fixture to ~/.opencli/sites/<site>/verify/<command>.json if none exists')
    .option('--update-fixture', 'Overwrite an existing fixture with one derived from current output')
    .option('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)')
    .option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing')
    .option('--seed-args <value>', 'Seed args when no fixture exists; use JSON array/object for multiple args or flags')
    .option('--trace <mode>', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off')
    .description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.json when present')
    .action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; seedArgs?: string; trace?: string } = {}) => {
      try {
        const parts = name.split('/');
        if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
        const [site, command] = parts;
        if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
          console.error('Name parts must be alphanumeric/dash/underscore only');
          process.exitCode = EXIT_CODES.USAGE_ERROR;
          return;
        }

        const { execFileSync } = await import('node:child_process');
        const { loadFixture, writeFixture, deriveFixture, validateRows, validateRowShape, fixturePath, expandFixtureArgs, parseSeedArgs } = await import('./browser/verify-fixture.js');
        const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.js`);
        if (!fs.existsSync(filePath)) {
          console.error(`Adapter not found: ${filePath}`);
          console.error(`Run "opencli browser init ${name}" to create it.`);
          process.exitCode = EXIT_CODES.GENERIC_ERROR;
          return;
        }

        console.log(`🔍 Verifying ${name}...\n`);
        console.log(`  Loading: ${filePath}`);

        const useFixture = opts.fixture !== false;
        let fixture = useFixture ? loadFixture(site, command) : null;

        // Build adapter args: fixture.args override the legacy --limit 3 heuristic.
        //   - object form   { "limit": 3 }            → `--limit 3`
        //   - array form    ["123", "--limit", "3"]   → verbatim (for positional subjects)
        const adapterSrc = fs.readFileSync(filePath, 'utf-8');
        const hasLimitArg = /['"]limit['"]/.test(adapterSrc);
        const seedArgs = parseSeedArgs(opts.seedArgs);
        const explicitArgs = fixture?.args ?? seedArgs;
        const cliArgs: string[] = expandFixtureArgs(explicitArgs);
        if (explicitArgs === undefined && cliArgs.length === 0 && hasLimitArg) cliArgs.push('--limit', '3');

        const traceArgs = opts.trace && opts.trace !== 'off' ? ['--trace', opts.trace] : [];
        const argDisplay = [...cliArgs, ...traceArgs].join(' ');
        const invocation = resolveBrowserVerifyInvocation();

        // Always request JSON so we can validate structurally.
        const execArgs = [...invocation.args, site, command, ...cliArgs, ...traceArgs, '--format', 'json'];

        let rawJson: string;
        try {
          rawJson = execFileSync(invocation.binary, execArgs, {
            cwd: invocation.cwd,
            timeout: 30000,
            encoding: 'utf-8',
            env: process.env,
            stdio: ['pipe', 'pipe', 'pipe'],
            ...(invocation.shell ? { shell: true } : {}),
          });
        } catch (err) {
          console.log(`  Executing: opencli ${site} ${command} ${argDisplay}\n`);
          const execErr = err as { stdout?: string | Buffer; stderr?: string | Buffer };
          if (execErr.stdout) console.log(String(execErr.stdout));
          if (execErr.stderr) console.error(String(execErr.stderr).slice(0, 500));
          console.log(`\n  ✗ Adapter failed. Fix the code and try again.`);
          process.exitCode = EXIT_CODES.GENERIC_ERROR;
          return;
        }

        console.log(`  Executing: opencli ${site} ${command} ${argDisplay}\n`);

        let rows: Record<string, unknown>[];
        try {
          rows = normalizeVerifyRows(JSON.parse(rawJson));
        } catch {
          console.log(rawJson);
          console.log('\n  ✗ Could not parse adapter output as JSON. Is `--format json` broken?');
          process.exitCode = EXIT_CODES.GENERIC_ERROR;
          return;
        }

        console.log(renderVerifyPreview(rows));
        console.log(`\n  → ${rows.length} row${rows.length === 1 ? '' : 's'}`);

        const shapeFailures = validateRowShape(rows);
        if (shapeFailures.length > 0) {
          console.log(`\n  ✗ Adapter output violates row shape conventions:`);
          for (const f of shapeFailures.slice(0, 20)) {
            const where = f.rowIndex !== undefined ? `row[${f.rowIndex}] ` : '';
            console.log(`    - [${f.rule}] ${where}${f.detail}`);
          }
          if (shapeFailures.length > 20) {
            console.log(`    ... and ${shapeFailures.length - 20} more failure(s)`);
          }
          console.log(`\n  Keep rows agent-native: <=12 top-level keys, nesting depth <=1, and id-shaped fields at top level.`);
          process.exitCode = EXIT_CODES.GENERIC_ERROR;
          return;
        }

        // ── Fixture handling ───────────────────────────────────────────
        if (opts.writeFixture || opts.updateFixture) {
          if (fixture && !opts.updateFixture) {
            console.log(`\n  Fixture already exists at ${fixturePath(site, command)}.`);
            console.log(`  Use --update-fixture to overwrite.`);
          } else {
            const fixtureArgs = explicitArgs !== undefined
              ? explicitArgs
              : (hasLimitArg ? { limit: 3 } : undefined);
            const derived = deriveFixture(rows, fixtureArgs);
            const p = writeFixture(site, command, derived);
            console.log(`\n  ${fixture ? '↻ Updated' : '✎ Wrote'} fixture: ${p}`);
            console.log(`  Review and hand-tune the derived expectations (add patterns / notEmpty, tighten rowCount).`);
            fixture = derived;
          }
        }

        if (!fixture) {
          console.log(`\n  ✓ Adapter runs. (No fixture at ${fixturePath(site, command)} — consider --write-fixture to seed one.)`);
          const memoryReport = checkSiteMemory(site);
          printSiteMemoryReport(memoryReport, opts.strictMemory);
          if (!memoryReport.ok && opts.strictMemory) {
            process.exitCode = EXIT_CODES.GENERIC_ERROR;
          }
          return;
        }

        const failures = validateRows(rows, fixture);
        if (failures.length === 0) {
          console.log(`\n  ✓ Adapter matches fixture (${fixturePath(site, command)}).`);
          const memoryReport = checkSiteMemory(site);
          printSiteMemoryReport(memoryReport, opts.strictMemory);
          if (!memoryReport.ok && opts.strictMemory) {
            process.exitCode = EXIT_CODES.GENERIC_ERROR;
          }
          return;
        }

        console.log(`\n  ✗ Adapter output does not match fixture:`);
        for (const f of failures.slice(0, 20)) {
          const where = f.rowIndex !== undefined ? `row[${f.rowIndex}] ` : '';
          console.log(`    - [${f.rule}] ${where}${f.detail}`);
        }
        if (failures.length > 20) {
          console.log(`    ... and ${failures.length - 20} more failure(s)`);
        }
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      } catch (err) {
        console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      }
    });

  // ── Session ──

  browser.command('close').description('Release the current browser session tab lease')
    .action(browserAction(async (page) => {
      await page.closeWindow?.();
      console.log('Browser session tab lease released');
    }));

  // ── Built-in: doctor / completion ──────────────────────────────────────────

  program
    .command('doctor')
    .description('Diagnose opencli browser bridge connectivity')
    .option('-v, --verbose', 'Debug output')
    .action(async (opts) => {
      applyVerbose(opts);
      const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
      const report = await runBrowserDoctor({ cliVersion: PKG_VERSION });
      console.log(renderBrowserDoctorReport(report));
    });

  program
    .command('completion')
    .description('Output shell completion script')
    .argument('<shell>', 'Shell type: bash, zsh, or fish')
    .action((shell) => {
      printCompletionScript(shell);
    });

  // ── Plugin management ──────────────────────────────────────────────────────

  const pluginCmd = program.command('plugin').description('Manage opencli plugins');
  // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
  const originalPluginDescription = pluginCmd.description();

  pluginCmd
    .command('install')
    .description('Install a plugin from a git repository')
    .argument('<source>', 'Plugin source (e.g. github:user/repo)')
    .action(async (source: string) => {
      const { installPlugin } = await import('./plugin.js');
      const { discoverPlugins } = await import('./discovery.js');
      try {
        const result = installPlugin(source);
        await discoverPlugins();
        if (Array.isArray(result)) {
          if (result.length === 0) {
            console.log('No plugins were installed (all skipped or incompatible).');
          } else {
            console.log(`\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`);
          }
        } else {
          console.log(`\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`);
        }
      } catch (err) {
        console.error(`Error: ${getErrorMessage(err)}`);
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      }
    });

  pluginCmd
    .command('uninstall')
    .description('Uninstall a plugin')
    .argument('<name>', 'Plugin name')
    .action(async (name: string) => {
      const { uninstallPlugin } = await import('./plugin.js');
      try {
        uninstallPlugin(name);
        console.log(`✅ Plugin "${name}" uninstalled.`);
      } catch (err) {
        console.error(`Error: ${getErrorMessage(err)}`);
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      }
    });

  pluginCmd
    .command('update')
    .description('Update a plugin (or all plugins) to the latest version')
    .argument('[name]', 'Plugin name (required unless --all is passed)')
    .option('--all', 'Update all installed plugins')
    .action(async (name: string | undefined, opts: { all?: boolean }) => {
      if (!name && !opts.all) {
        console.error('Error: Please specify a plugin name or use the --all flag.');
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      if (name && opts.all) {
        console.error('Error: Cannot specify both a plugin name and --all.');
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }

      const { updatePlugin, updateAllPlugins } = await import('./plugin.js');
      const { discoverPlugins } = await import('./discovery.js');
      if (opts.all) {
        const results = updateAllPlugins();
        if (results.length > 0) {
          await discoverPlugins();
        }

        let hasErrors = false;
        console.log('  Update Results:');
        for (const result of results) {
          if (result.success) {
            console.log(`  ✓ ${result.name}`);
            continue;
          }
          hasErrors = true;
          console.log(`  ✗ ${result.name} — ${String(result.error)}`);
        }

        if (results.length === 0) {
          console.log('  No plugins installed.');
          return;
        }

        console.log();
        if (hasErrors) {
          console.error('Completed with some errors.');
          process.exitCode = EXIT_CODES.GENERIC_ERROR;
        } else {
          console.log('✅ All plugins updated successfully.');
        }
        return;
      }

      try {
        updatePlugin(name!);
        await discoverPlugins();
        console.log(`✅ Plugin "${name}" updated successfully.`);
      } catch (err) {
        console.error(`Error: ${getErrorMessage(err)}`);
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      }
    });


  pluginCmd
    .command('list')
    .description('List installed plugins')
    .option('-f, --format <fmt>', 'Output format: table, json', 'table')
    .action(async (opts) => {
      const { listPlugins } = await import('./plugin.js');
      const plugins = listPlugins();
      if (plugins.length === 0) {
        console.log('  No plugins installed.');
        console.log('  Install one with: opencli plugin install github:user/repo');
        return;
      }
      if (opts.format === 'json') {
        renderOutput(plugins, {
          fmt: 'json',
          columns: ['name', 'commands', 'source'],
          title: 'opencli/plugins',
          source: 'opencli plugin list',
        });
        return;
      }
      console.log();
      console.log('  Installed plugins');
      console.log();

      // Group by monorepo
      const standalone = plugins.filter((p) => !p.monorepoName);
      const monoGroups = new Map<string, typeof plugins>();
      for (const p of plugins) {
        if (!p.monorepoName) continue;
        const g = monoGroups.get(p.monorepoName) ?? [];
        g.push(p);
        monoGroups.set(p.monorepoName, g);
      }

      for (const p of standalone) {
        const version = p.version ? ` @${p.version}` : '';
        const desc = p.description ? ` — ${p.description}` : '';
        const cmds = p.commands.length > 0 ? ` (${p.commands.join(', ')})` : '';
        const src = p.source ? ` ← ${p.source}` : '';
        console.log(`  ${p.name}${version}${desc}${cmds}${src}`);
      }

      for (const [mono, group] of monoGroups) {
        console.log();
        console.log(`  📦 ${mono}` + ' (monorepo)');
        for (const p of group) {
          const version = p.version ? ` @${p.version}` : '';
          const desc = p.description ? ` — ${p.description}` : '';
          const cmds = p.commands.length > 0 ? ` (${p.commands.join(', ')})` : '';
          console.log(`    ${p.name}${version}${desc}${cmds}`);
        }
      }

      console.log();
      console.log(`  ${plugins.length} plugin(s) installed`);
      console.log();
    });

  pluginCmd
    .command('create')
    .description('Create a new plugin scaffold')
    .argument('<name>', 'Plugin name (lowercase, hyphens allowed)')
    .option('-d, --dir <path>', 'Output directory (default: ./<name>)')
    .option('--description <text>', 'Plugin description')
    .action(async (name: string, opts: { dir?: string; description?: string }) => {
      const { createPluginScaffold } = await import('./plugin-scaffold.js');
      try {
        const result = createPluginScaffold(name, {
          dir: opts.dir,
          description: opts.description,
        });
        console.log(`✅ Plugin scaffold created at ${result.dir}`);
        console.log();
        console.log('  Files created:');
        for (const f of result.files) {
          console.log(`    ${f}`);
        }
        console.log();
        console.log('  Next steps:');
        console.log(`    cd ${result.dir}`);
        console.log(`    opencli plugin install file://${result.dir}`);
        console.log(`    opencli ${name} hello`);
      } catch (err) {
        console.error(`Error: ${getErrorMessage(err)}`);
        process.exitCode = EXIT_CODES.GENERIC_ERROR;
      }
    });

  // ── Built-in: adapter management ─────────────────────────────────────────
  const adapterCmd = program.command('adapter').description('Manage CLI adapters');
  // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
  const originalAdapterDescription = adapterCmd.description();

  adapterCmd
    .command('status')
    .description('Show which sites have local overrides vs using official baseline')
    .action(async () => {
      const os = await import('node:os');
      const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
      const builtinClisDir = BUILTIN_CLIS;
      try {
        const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
        const userSites = userEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
        let builtinSites: string[] = [];
        try {
          const builtinEntries = await fs.promises.readdir(builtinClisDir, { withFileTypes: true });
          builtinSites = builtinEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
        } catch { /* no builtin dir */ }

        if (userSites.length === 0) {
          console.log('No local adapter overrides. All sites use the official baseline.');
          return;
        }

        console.log(`Local overrides in ~/.opencli/clis/ (${userSites.length} sites):\n`);
        for (const site of userSites) {
          const isOfficial = builtinSites.includes(site);
          const label = isOfficial ? 'override' : 'custom';
          console.log(`  ${site} [${label}]`);
        }
        console.log(`\nOfficial baseline: ${builtinSites.length} sites in package`);
      } catch {
        console.log('No local adapter overrides. All sites use the official baseline.');
      }
    });

  adapterCmd
    .command('eject')
    .description('Copy an official adapter to ~/.opencli/clis/ for local editing')
    .argument('<site>', 'Site name (e.g. twitter, bilibili)')
    .action(async (site: string) => {
      const os = await import('node:os');
      const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
      const builtinSiteDir = path.join(BUILTIN_CLIS, site);
      const userSiteDir = path.join(userClisDir, site);

      try {
        await fs.promises.access(builtinSiteDir);
      } catch {
        console.error(`Error: Site "${site}" not found in official adapters.`);
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }

      try {
        await fs.promises.access(userSiteDir);
        console.error(`Site "${site}" already exists in ~/.opencli/clis/. Use "opencli adapter reset ${site}" first to restore official version.`);
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      } catch { /* good, doesn't exist yet */ }

      fs.cpSync(builtinSiteDir, userSiteDir, { recursive: true });
      console.log(`✅ Ejected "${site}" to ~/.opencli/clis/${site}/`);
      console.log('You can now edit the adapter files. Changes take effect immediately.');
      console.log('Note: Official updates to this adapter will overwrite your changes.');
    });

  adapterCmd
    .command('reset')
    .description('Remove local override and restore official adapter version')
    .argument('[site]', 'Site name (e.g. twitter, bilibili)')
    .option('--all', 'Reset all local overrides')
    .action(async (site: string | undefined, opts: { all?: boolean }) => {
      const os = await import('node:os');
      const userClisDir = path.join(os.homedir(), '.opencli', 'clis');

      if (opts.all) {
        try {
          const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
          const dirs = userEntries.filter(e => e.isDirectory());
          if (dirs.length === 0) {
            console.log('No local sites to reset.');
            return;
          }
          for (const dir of dirs) {
            fs.rmSync(path.join(userClisDir, dir.name), { recursive: true, force: true });
          }
          console.log(`✅ Reset ${dirs.length} site(s). All adapters now use official baseline.`);
        } catch {
          console.log('No local sites to reset.');
        }
        return;
      }

      if (!site) {
        console.error('Error: Please specify a site name or use --all.');
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }

      const userSiteDir = path.join(userClisDir, site);
      try {
        await fs.promises.access(userSiteDir);
      } catch {
        console.error(`Site "${site}" has no local override.`);
        return;
      }

      const isOfficial = fs.existsSync(path.join(BUILTIN_CLIS, site));
      fs.rmSync(userSiteDir, { recursive: true, force: true });
      console.log(isOfficial
        ? `✅ Reset "${site}". Now using official baseline.`
        : `✅ Removed custom site "${site}".`);
    });

  // ── Built-in: browser profile selection ──────────────────────────────────
  const profileCmd = program.command('profile').description('Manage Browser Bridge Chrome profiles');
  // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
  const originalProfileDescription = profileCmd.description();

  profileCmd
    .command('list')
    .description('List Chrome profiles connected through the Browser Bridge extension')
    .action(async () => {
      const status = await fetchDaemonStatus();
      const config = loadProfileConfig();
      const profiles = status?.profiles ?? [];
      if (!status) {
        console.log('Daemon is not running. Run opencli doctor after opening Chrome.');
        return;
      }
      if (isDaemonStale(status, PKG_VERSION) || !Array.isArray(status.profiles)) {
        console.log(`Daemon ${formatDaemonVersion(status)} is stale for CLI v${PKG_VERSION}.`);
        console.log('Run: opencli daemon restart');
        return;
      }
      if (profiles.length === 0) {
        console.log('No Browser Bridge profiles connected.');
        console.log('Open a Chrome profile with the OpenCLI extension installed, then run opencli profile list again.');
        return;
      }

      const knownContextIds = new Set(profiles.map((profile) => profile.contextId));
      console.log('Connected Browser Bridge profiles');
      console.log();
      for (const profile of profiles) {
        const alias = aliasForContextId(config, profile.contextId);
        const defaultMark = config.defaultContextId === profile.contextId ? ' default' : '';
        const aliasText = alias ? ` ${alias}` : '';
        const version = profile.extensionVersion ? ` v${profile.extensionVersion}` : ' version unknown';
        console.log(`  ${profile.contextId}${aliasText}${defaultMark} — connected${version}`);
      }

      const disconnectedAliases = Object.entries(config.aliases)
        .filter(([, contextId]) => !knownContextIds.has(contextId));
      if (disconnectedAliases.length > 0 || (config.defaultContextId && !knownContextIds.has(config.defaultContextId))) {
        console.log();
        console.log('Disconnected saved profiles:');
        const shown = new Set<string>();
        for (const [alias, contextId] of disconnectedAliases) {
          shown.add(contextId);
          console.log(`  ${contextId} ${alias} — not connected`);
        }
        if (config.defaultContextId && !shown.has(config.defaultContextId) && !knownContextIds.has(config.defaultContextId)) {
          console.log(`  ${config.defaultContextId} — default, not connected`);
        }
      }
    });

  profileCmd
    .command('rename')
    .description('Assign a local alias to a connected Browser Bridge profile')
    .argument('<contextId>', 'Profile contextId from opencli profile list')
    .argument('<alias>', 'Local alias, e.g. work or personal')
    .action((contextId: string, alias: string) => {
      try {
        renameProfile(contextId, alias);
        console.log(`Profile ${contextId} is now aliased as ${alias}.`);
      } catch (err) {
        console.error(`Error: ${getErrorMessage(err)}`);
        process.exitCode = EXIT_CODES.USAGE_ERROR;
      }
    });

  profileCmd
    .command('use')
    .description('Set the default Browser Bridge profile for future commands')
    .argument('<profile>', 'Profile alias or contextId')
    .action((profile: string) => {
      try {
        const config = setDefaultProfile(profile);
        console.log(`Default Browser Bridge profile: ${config.defaultContextId ?? profile}`);
      } catch (err) {
        console.error(`Error: ${getErrorMessage(err)}`);
        process.exitCode = EXIT_CODES.USAGE_ERROR;
      }
    });

  // ── Built-in: daemon ──────────────────────────────────────────────────────
  const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
  // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
  const originalDaemonDescription = daemonCmd.description();
  daemonCmd
    .command('status')
    .description('Show daemon status')
    .action(async () => { await daemonStatus(); });
  daemonCmd
    .command('stop')
    .description('Stop the daemon')
    .action(async () => { await daemonStop(); });
  daemonCmd
    .command('restart')
    .description('Restart the daemon')
    .action(async () => { await daemonRestart(); });

  // ── External CLIs ─────────────────────────────────────────────────────────

  const externalClis = loadExternalClis();

  const externalCmd = program
    .command('external')
    .description('Manage external CLI passthrough commands');

  externalCmd
    .command('install')
    .description('Install an external CLI')
    .argument('<name>', 'Name of the external CLI')
    .action((name: string) => {
      const ext = externalClis.find(e => e.name === name);
      if (!ext) {
        console.error(`External CLI '${name}' not found in registry.`);
        process.exitCode = EXIT_CODES.USAGE_ERROR;
        return;
      }
      installExternalCli(ext);
    });

  externalCmd
    .command('register')
    .description('Register an external CLI')
    .argument('<name>', 'Name of the CLI')
    .option('--binary <bin>', 'Binary name if different from name')
    .option('--install <cmd>', 'Auto-install command')
    .option('--desc <text>', 'Description')
    .action((name, opts) => {
      registerExternalCli(name, { binary: opts.binary, install: opts.install, description: opts.desc });
    });

  externalCmd
    .command('list')
    .description('List registered external CLIs')
    .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
    .action((opts) => {
      const rows = loadExternalClis().map((ext) => ({
        name: ext.name,
        package: ext.package ?? '',
        binary: ext.binary,
        installed: isBinaryInstalled(ext.binary),
        description: ext.description ?? '',
        homepage: ext.homepage ?? '',
        tags: ext.tags?.join(', ') ?? '',
      }));
      renderOutput(rows, {
        fmt: opts.format,
        columns: ['name', 'package', 'binary', 'installed', 'description', 'homepage', 'tags'],
        title: 'opencli/external/list',
        source: 'opencli external list',
      });
    });

  function passthroughExternal(name: string, parsedArgs?: string[]) {
    const args = parsedArgs ?? (() => {
      const idx = process.argv.indexOf(name);
      return process.argv.slice(idx + 1);
    })();
    try {
      executeExternalCli(name, args, externalClis);
    } catch (err) {
      console.error(`Error: ${getErrorMessage(err)}`);
      process.exitCode = EXIT_CODES.GENERIC_ERROR;
    }
  }

  for (const ext of externalClis) {
    if (program.commands.some(c => c.name() === ext.name)) continue;
    program
      .command(ext.name)
      .description(`(External) ${ext.description || ext.name}`)
      .argument('[args...]')
      .allowUnknownOption()
      .passThroughOptions()
      .helpOption(false)
      .action((args: string[]) => passthroughExternal(ext.name, args));
  }

  // ── Antigravity serve (long-running, special case) ────────────────────────

  const antigravityCmd = program.command('antigravity').description('antigravity commands');
  antigravityCmd
    .command('serve')
    .description('Start Anthropic-compatible API proxy for Antigravity')
    .option('--port <port>', 'Server port (default: 8082)', '8082')
    .option('--timeout <seconds>', 'Maximum time to wait for a reply (default: 120s)')
    .action(async (opts) => {
      // @ts-expect-error JS adapter — no type declarations
      const { startServe } = await import('../../clis/antigravity/serve.js');
      await startServe({
        port: parseInt(opts.port, 10),
        timeout: opts.timeout ? parsePositiveIntOption(opts.timeout, '--timeout', 120) : undefined,
      });
    });

  // ── Dynamic adapter commands ──────────────────────────────────────────────

  const siteGroups = new Map<string, Command>();
  siteGroups.set('antigravity', antigravityCmd);
  const siteNames = registerAllCommands(program, siteGroups);
  applyRootSubcommandSummaries(program);

  // ── Help-text grouping: External CLIs / App adapters / Site adapters ──
  // Classification derives from each adapter's `domain` field — see classifyAdapter.
  // External CLIs are taken from the externalClis registry (passthrough binaries).
  const externalNames = externalClis.map(ext => ext.name);
  const externalHelpEntries = externalClis.map(ext => ({
    name: ext.name,
    label: formatExternalCliLabel(ext),
  }));
  const siteDomains = new Map<string, string | undefined>();
  for (const [, cmd] of getRegistry()) {
    if (!siteDomains.has(cmd.site)) siteDomains.set(cmd.site, cmd.domain);
  }
  const apps: string[] = [];
  const sites: string[] = [];
  for (const site of siteNames) {
    if (classifyAdapter(siteDomains.get(site)) === 'app') apps.push(site);
    else sites.push(site);
  }
  const adapterGroups: RootAdapterGroups = { external: externalHelpEntries, apps, sites };
  const adapterNameSet = new Set<string>([...externalNames, ...siteNames]);
  installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: originalBrowserDescription });
  installCommanderNamespaceStructuredHelp(authCmd, { globalCommand: program, description: 'Inspect website login status' });
  installCommanderNamespaceStructuredHelp(daemonCmd, { globalCommand: program, description: originalDaemonDescription });
  installCommanderNamespaceStructuredHelp(pluginCmd, { globalCommand: program, description: originalPluginDescription });
  installCommanderNamespaceStructuredHelp(adapterCmd, { globalCommand: program, description: originalAdapterDescription });
  installCommanderNamespaceStructuredHelp(profileCmd, { globalCommand: program, description: originalProfileDescription });
  program.configureHelp({
    visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())),
  });
  // When an ancestor command declares a leading positional via `.usage(...)`
  // (e.g. `browser` -> `<session> <command> [options]`), inject the positional
  // between that ancestor's name and the next path segment so the help Usage
  // line is accurate: `Usage: opencli browser <session> click [target] [options]`
  // instead of `opencli browser click [target] [options]`. Commander does NOT
  // inherit configureHelp into subcommands, so we walk the descendant tree and
  // apply the override on each.
  const ancestorAwareCommandUsage = (cmd: Command): string => {
    const ancestors: string[] = [];
    let ancestor: Command | null = cmd.parent;
    while (ancestor) {
      const positional = leadingPositionalFromUsage(ancestor);
      ancestors.unshift(positional ? `${ancestor.name()} ${positional}` : ancestor.name());
      ancestor = ancestor.parent;
    }
    return [...ancestors, cmd.name(), cmd.usage()].filter(Boolean).join(' ').trim();
  };
  function applyAncestorAwareUsage(cmd: Command): void {
    cmd.configureHelp({ commandUsage: ancestorAwareCommandUsage });
    for (const sub of cmd.commands) applyAncestorAwareUsage(sub);
  }
  applyAncestorAwareUsage(browser);
  installStructuredHelp(program, () => rootHelpData(program, adapterGroups), () => formatRootAdapterHelpText(adapterGroups));

  // ── Unknown command fallback ──────────────────────────────────────────────
  // Security: do NOT auto-discover and register arbitrary system binaries.
  // Only explicitly registered external CLIs are allowed.

  program.on('command:*', (operands: string[]) => {
    const binary = operands[0];
    console.error(`error: unknown command '${binary}'`);
    if (isBinaryInstalled(binary)) {
      console.error(`  Tip: '${binary}' exists on your PATH. Use 'opencli external register ${binary}' to add it as an external CLI.`);
    }
    program.outputHelp();
    process.exitCode = EXIT_CODES.USAGE_ERROR;
  });

  return program;
}

export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
  createProgram(BUILTIN_CLIS, USER_CLIS).parse();
}

// ── Helpers ─────────────────────────────────────────────────────────────────

export interface BrowserVerifyInvocation {
  binary: string;
  args: string[];
  cwd: string;
  shell?: boolean;
}

export { findPackageRoot };

export function resolveBrowserVerifyInvocation(opts: {
  projectRoot?: string;
  platform?: NodeJS.Platform;
  fileExists?: (path: string) => boolean;
  readFile?: (path: string) => string;
} = {}): BrowserVerifyInvocation {
  const platform = opts.platform ?? process.platform;
  const fileExists = opts.fileExists ?? fs.existsSync;
  const readFile = opts.readFile ?? ((filePath: string) => fs.readFileSync(filePath, 'utf-8'));
  const projectRoot = opts.projectRoot ?? findPackageRoot(CLI_FILE, fileExists);

  for (const builtEntry of getBuiltEntryCandidates(projectRoot, readFile)) {
    if (fileExists(builtEntry)) {
      return {
        binary: process.execPath,
        args: [builtEntry],
        cwd: projectRoot,
      };
    }
  }

  const sourceEntry = path.join(projectRoot, 'src', 'main.ts');
  if (!fileExists(sourceEntry)) {
    throw new Error(`Could not find opencli entrypoint under ${projectRoot}. Expected built entry from package.json or src/main.ts.`);
  }

  const localTsxBin = path.join(projectRoot, 'node_modules', '.bin', platform === 'win32' ? 'tsx.cmd' : 'tsx');
  if (fileExists(localTsxBin)) {
    return {
      binary: localTsxBin,
      args: [sourceEntry],
      cwd: projectRoot,
      ...(platform === 'win32' ? { shell: true } : {}),
    };
  }

  return {
    binary: platform === 'win32' ? 'npx.cmd' : 'npx',
    args: ['tsx', sourceEntry],
    cwd: projectRoot,
    ...(platform === 'win32' ? { shell: true } : {}),
  };
}
