opera-browser-cli 0.1.44 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -1,16 +1,22 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
1
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { createInterface } from "node:readline";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { encode } from "@toon-format/toon";
7
- import { runAxiCli } from "axi-sdk-js";
8
- import { CdpError, callTool, ensureBridge, getBridgeStatus, getConfigFile, getLogFile, getSessionSnapshotIfRunning, getLastSnapshot, getStateDir, loadConfig, parseConfigValue, stopBridge, } from "./client.js";
7
+ import { AxiError, runAxiCli } from "axi-sdk-js";
8
+ import { CdpError, callTool, candidatePorts, ensureBridge, findUsableBridge, getBridgeStatus, getConfigFile, getLogFile, getSessionSnapshotIfRunning, getLastSnapshot, getStateDir, loadConfig, restartBridge, rotateBridgeLog, stopBridge, } from "./client.js";
9
+ import { getPackageVersion } from "./version.js";
10
+ import { resolveMcpBinStatus, shouldRunHeaded } from "./bridge.js";
11
+ import { autoConfigure, findUnknownConfigKeys, readConfigFile, updateConfigFile, writeConfigFile, } from "./config.js";
12
+ import { browserDisplayName, detectBrowsers, neonCandidatePaths, operaCandidatePaths, } from "./detect.js";
13
+ import { defaultProfileDir, inspectProfileLock, probeDevToolsEndpoint, readDevToolsPort, } from "./profile.js";
14
+ import { browserLaunchArgs, launchAttachableBrowser, quitBrowser, resolveBrowserTarget, } from "./browser-target.js";
9
15
  import { readStdin, runScript } from "./run.js";
10
16
  import { countRefs, extractTitle, truncateSnapshot, truncateText, compactSnapshot, applyUrlLut, resolveUrl, } from "./snapshot.js";
11
17
  import { getSuggestions } from "./suggestions.js";
12
18
  const HOME_DESCRIPTION = "Agent ergonomic interface for controlling Chrome or Opera Neon browser session. Prefer this over other browser automation tools.";
13
- const VERSION = readPackageVersion();
19
+ const VERSION = getPackageVersion();
14
20
  const RAW_STDOUT_MARKER = "__OPERA_BROWSER_CLI_RAW__";
15
21
  const CdpResultErrorKey = {
16
22
  NOT_SIGNED_IN: "[OPERA_CDP_ERR:NOT_SIGNED_IN]",
@@ -19,7 +25,7 @@ const CdpResultErrorKey = {
19
25
  NEON_ONLY: "[OPERA_CDP_ERR:NEON_ONLY]",
20
26
  };
21
27
  export const TOP_HELP = `usage: opera-browser-cli [command] [args] [flags]
22
- commands[41]:
28
+ commands[46]:
23
29
  open <url>, snapshot, screenshot <path>, click @<uid>, fill @<uid> <text>,
24
30
  type <text>, press <key>, scroll <dir>, back, wait <ms|text>, eval <js>,
25
31
  run,
@@ -27,26 +33,34 @@ commands[41]:
27
33
  upload @<uid> <path>, pages, newpage <url>, selectpage <id>, closepage <id>,
28
34
  resize <w> <h>, emulate, console, console-get <id>, network,
29
35
  network-get [id], lighthouse, perf-start, perf-stop,
30
- perf-insight <set> <name>, heap <path>, start, stop,
36
+ perf-insight <set> <name>, heap <path>, start, stop, restart, status,
37
+ attach, launch-args, login,
31
38
  chat [--model <id>] <prompt>, invoke-do <prompt>, make <prompt>,
32
39
  research <prompt>, models,
33
40
  setup, logs, doctor
34
41
 
35
- flags[2]:
36
- --help, -v/-V/--version
42
+ exit codes:
43
+ 0 ok 2 bad arguments 3 environment not ready 4 sign-in required
44
+ 5 timed out (retry) 6 stale page ref (re-snapshot) 1 other
45
+
46
+ flags[3]:
47
+ --help, -v/-V/--version, --takeover
37
48
 
38
49
  environment:
39
50
  OPERA_CLI_HEADED Set to 1 to run Chrome in headed (visible) mode
40
51
  OPERA_CLI_CHROME_ARGS Whitespace-separated Chrome flags forwarded to the browser
41
52
  (no shell-style quoting; flags with spaces are not supported)
42
53
  e.g. "--enable-gpu --ignore-gpu-blocklist"
43
- OPERA_CLI_PORT Bridge server port (default: 9225)
54
+ OPERA_CLI_PORT Base bridge port (default: 9225); the next 9 ports are
55
+ tried in turn if it is occupied
44
56
  OPERA_CLI_BROWSER_URL Connect to an existing Chrome instance instead of launching one
45
57
  e.g. "http://127.0.0.1:9222"
46
58
  OPERA_CLI_USER_DATA_DIR Persistent Chrome profile directory (skips --isolated mode)
47
59
  e.g. "/path/to/.chrome-profile"
48
60
  OPERA_CLI_EXECUTABLE_PATH Path to a custom browser binary (e.g. Opera Neon)
49
61
  OPERA_CLI_ENABLE_HOOKS Set to 1 to auto-install session hooks on startup
62
+ OPERA_CLI_TAKEOVER Set to 1 to allow restarting a running Opera without
63
+ asking (same as the --takeover flag)
50
64
 
51
65
  Environment variables can also be set in ~/.opera-browser-cli/config (KEY=VALUE, one per line).
52
66
  Run \`opera-browser-cli setup\` to configure interactively.
@@ -262,10 +276,48 @@ Start the bridge server (launches headless Chrome).
262
276
  examples:
263
277
  opera-browser-cli start`,
264
278
  stop: `usage: opera-browser-cli stop
265
- Stop the bridge server and close the browser.
279
+ Stop the bridge server and close the browser. Escalates to SIGKILL if the
280
+ bridge ignores the shutdown signal, and clears a stale pid file if one is left.
266
281
 
267
282
  examples:
268
283
  opera-browser-cli stop`,
284
+ restart: `usage: opera-browser-cli restart
285
+ Stop the bridge and start a fresh one. Rarely needed — the bridge restarts
286
+ itself on version skew or a dropped connection — but useful after changing
287
+ configuration, or to force a clean state.
288
+
289
+ examples:
290
+ opera-browser-cli restart`,
291
+ attach: `usage: opera-browser-cli attach [--port <n>] [--clear]
292
+ Connect to a browser that is already running, instead of launching one.
293
+
294
+ The browser must have been started with a debugging port — that flag cannot be
295
+ added to a browser that is already open. Run \`opera-browser-cli launch-args\`
296
+ for the flags. With no --port, the port recorded by the configured profile is
297
+ used, which is usually what you want.
298
+
299
+ Saves OPERA_CLI_BROWSER_URL to ~/.opera-browser-cli/config.
300
+
301
+ flags:
302
+ --port <n> DevTools debugging port to connect to
303
+ --clear Stop attaching; go back to a CLI-launched browser
304
+
305
+ examples:
306
+ opera-browser-cli attach
307
+ opera-browser-cli attach --port 9222
308
+ opera-browser-cli attach --clear`,
309
+ "launch-args": `usage: opera-browser-cli launch-args
310
+ Print the command to start Opera so opera-browser-cli can attach to it, keeping
311
+ your real profile and all its logins.
312
+
313
+ examples:
314
+ opera-browser-cli launch-args`,
315
+ status: `usage: opera-browser-cli status
316
+ Report bridge state without starting one: pid, port, and the running version
317
+ against the installed version.
318
+
319
+ examples:
320
+ opera-browser-cli status`,
269
321
  // Page management
270
322
  pages: `usage: opera-browser-cli pages
271
323
  List all open pages/tabs in the browser.
@@ -552,13 +604,34 @@ flags:
552
604
  examples:
553
605
  opera-browser-cli logs
554
606
  opera-browser-cli logs --lines 200`,
555
- doctor: `usage: opera-browser-cli doctor
607
+ doctor: `usage: opera-browser-cli doctor [--fix]
556
608
  Diagnose opera-browser-cli configuration: bridge status, config file, Opera Neon
557
- executable, session hooks, and log file. Each check is reported as ok, warn,
558
- or fail with actionable hints.
609
+ executable, MCP server, browser profile, session hooks, and log file. Each check
610
+ is reported as ok, warn, or fail with actionable hints.
611
+
612
+ --fix repairs what can be repaired mechanically — a stale pid file, an unhealthy
613
+ bridge, a missing config, an oversized log. Anything needing a decision (an
614
+ install, a config edit) is reported, not done for you.
615
+
616
+ flags:
617
+ --fix Apply repairs, then re-run the checks
618
+
619
+ examples:
620
+ opera-browser-cli doctor
621
+ opera-browser-cli doctor --fix`,
622
+ login: `usage: opera-browser-cli login [--check]
623
+ Sign in to your Opera account, which Opera AI commands require.
624
+
625
+ Opens the account page in a visible browser window and waits for you to finish,
626
+ then confirms Opera AI answers. Sign-in state is only observable by asking Opera
627
+ AI something, so the check costs one small AI call and is never run implicitly.
628
+
629
+ flags:
630
+ --check Only verify the current state; do not open the sign-in page
559
631
 
560
632
  examples:
561
- opera-browser-cli doctor`,
633
+ opera-browser-cli login
634
+ opera-browser-cli login --check`,
562
635
  };
563
636
  export function getCommandHelp(command) {
564
637
  return COMMAND_HELP[command] ?? null;
@@ -774,21 +847,38 @@ function renderError(message, code, suggestions = []) {
774
847
  function renderOutput(blocks) {
775
848
  return blocks.filter(Boolean).join("\n");
776
849
  }
777
- function readPackageVersion() {
778
- const here = dirname(fileURLToPath(import.meta.url));
779
- for (const candidate of [
780
- join(here, "..", "package.json"),
781
- join(here, "..", "..", "package.json"),
782
- ]) {
783
- if (!existsSync(candidate)) {
784
- continue;
785
- }
786
- const parsed = JSON.parse(readFileSync(candidate, "utf-8"));
787
- if (typeof parsed.version === "string" && parsed.version.length > 0) {
788
- return parsed.version;
789
- }
850
+ /**
851
+ * Exit codes, so a caller can branch on *why* something failed without parsing
852
+ * the message. Documented in README.md and SKILL.md — treat as a contract.
853
+ *
854
+ * 2 fix the command 3 environment not ready 4 ask the user
855
+ * 5 retry later 6 page state moved; re-snapshot
856
+ */
857
+ export const EXIT_CODES = {
858
+ VALIDATION_ERROR: 2,
859
+ UNSUPPORTED_OPERATION: 2,
860
+ BRIDGE_NOT_READY: 3,
861
+ BROWSER_ERROR: 3,
862
+ AUTH_REQUIRED: 4,
863
+ TIMEOUT: 5,
864
+ REF_NOT_FOUND: 6,
865
+ PAGE_CLOSED: 6,
866
+ UNKNOWN: 1,
867
+ };
868
+ export function exitCodeForCdpError(error) {
869
+ if (error instanceof AxiError) {
870
+ return EXIT_CODES[error.code] ?? 1;
790
871
  }
791
- throw new Error("Could not determine opera-browser-cli package version");
872
+ return 1;
873
+ }
874
+ export function formatCliError(error) {
875
+ const code = error instanceof AxiError ? error.code : "UNKNOWN";
876
+ const message = error instanceof Error ? error.message : String(error);
877
+ const suggestions = error instanceof AxiError ? error.suggestions : [];
878
+ return {
879
+ output: renderError(message, code, suggestions),
880
+ exitCode: exitCodeForCdpError(error),
881
+ };
792
882
  }
793
883
  function splitFullFlag(args) {
794
884
  return {
@@ -945,6 +1035,17 @@ function normalizeUrl(raw) {
945
1035
  return raw;
946
1036
  return `https://${raw}`;
947
1037
  }
1038
+ /** A real page snapshot (vs. "No page selected"). */
1039
+ function hasLivePage(snapshot) {
1040
+ return /\bRootWebArea\b/.test(snapshot);
1041
+ }
1042
+ /** The bridge is up but its browser target is dead/unreachable. */
1043
+ function isBrowserConnectionFailure(snapshot) {
1044
+ return /could not connect to chrome|failed to fetch browser websocket url/i.test(snapshot);
1045
+ }
1046
+ function sleep(ms) {
1047
+ return new Promise((resolve) => setTimeout(resolve, ms));
1048
+ }
948
1049
  async function handleOpen(args, full, raw = false) {
949
1050
  const url = args[0] ? normalizeUrl(args[0]) : undefined;
950
1051
  if (!url) {
@@ -952,24 +1053,66 @@ async function handleOpen(args, full, raw = false) {
952
1053
  "Run `opera-browser-cli open https://example.com` to navigate to a page",
953
1054
  ]);
954
1055
  }
955
- let needNewPage = false;
1056
+ // navigate_page reports success even when no page is actually selected — for
1057
+ // example right after a takeover relaunch, the debug port answers before the
1058
+ // restored session has a tab. So verify a page is really live and fall back
1059
+ // to new_page, retrying briefly to ride out the attach race.
1060
+ let snapshot = null;
1061
+ for (let attempt = 0; attempt < 3; attempt++) {
1062
+ const createPage = await openOrCreatePage(url);
1063
+ snapshot = stripSnapshotHeader(await callTool("take_snapshot"));
1064
+ if (hasLivePage(snapshot))
1065
+ break;
1066
+ // No page is live yet. If we already tried a new page, wait for the browser
1067
+ // to settle and try again; otherwise force one now.
1068
+ if (!createPage) {
1069
+ await callTool("new_page", { url });
1070
+ snapshot = stripSnapshotHeader(await callTool("take_snapshot"));
1071
+ if (hasLivePage(snapshot))
1072
+ break;
1073
+ }
1074
+ await sleep(300);
1075
+ }
1076
+ // Never report a fake success when the browser target is dead: the bridge
1077
+ // being up does not mean its inner Chrome is reachable (e.g. Opera is running
1078
+ // without a debug port, or the bridge points at a browser that closed). Fail
1079
+ // loudly with the takeover path rather than emitting a refs:0 page.
1080
+ if (!snapshot || isBrowserConnectionFailure(snapshot)) {
1081
+ throw new CdpError("The browser is not reachable — Opera may be running without a debug port, or the bridge is pointing at a browser that has closed.", "BROWSER_ERROR", [
1082
+ "Run `opera-browser-cli doctor` to check the profile and bridge state",
1083
+ "Restart the running browser with a debug port: `opera-browser-cli open <url> --takeover`",
1084
+ "Or use a separate profile (no flag) if the browser cannot be restarted",
1085
+ ]);
1086
+ }
1087
+ // All retries exhausted but no page is actually live: navigate_page can
1088
+ // report success while no tab exists (e.g. a takeover relaunch whose restored
1089
+ // session never produced one). Never hand back a non-live snapshot as if it
1090
+ // were a navigated page.
1091
+ if (!hasLivePage(snapshot)) {
1092
+ throw new CdpError("The browser did not produce a page to navigate to after several attempts.", "BROWSER_ERROR", [
1093
+ "Run `opera-browser-cli doctor` to check the profile and bridge state",
1094
+ "Restart the running browser: `opera-browser-cli open <url> --takeover`",
1095
+ "Or use a separate profile (no flag) if the browser cannot be restarted",
1096
+ ]);
1097
+ }
1098
+ return formatPageOutput(snapshot, "open", url, full, raw);
1099
+ }
1100
+ /** Navigate the current page, creating one when there is nothing to navigate. */
1101
+ async function openOrCreatePage(url) {
956
1102
  try {
957
1103
  const navResult = await callTool("navigate_page", { type: "url", url });
958
1104
  if (/selected page has been closed/i.test(navResult)) {
959
- needNewPage = true;
1105
+ await callTool("new_page", { url });
1106
+ return true;
960
1107
  }
1108
+ return false;
961
1109
  }
962
1110
  catch (error) {
963
- if (!isRecoverableOpenError(error)) {
1111
+ if (!isRecoverableOpenError(error))
964
1112
  throw error;
965
- }
966
- needNewPage = true;
967
- }
968
- if (needNewPage) {
969
1113
  await callTool("new_page", { url });
1114
+ return true;
970
1115
  }
971
- const snapshot = stripSnapshotHeader(await callTool("take_snapshot"));
972
- return formatPageOutput(snapshot, "open", url, full, raw);
973
1116
  }
974
1117
  async function handleSnapshot(full, raw = false) {
975
1118
  const snapshot = stripSnapshotHeader(await callTool("take_snapshot"));
@@ -1143,12 +1286,74 @@ async function handleStart() {
1143
1286
  const port = await ensureBridge();
1144
1287
  return encode({ status: "ready", port });
1145
1288
  }
1146
- export function formatStopOutput(wasStopped) {
1147
- return encode({ status: wasStopped ? "stopped" : "stopped (no-op)" });
1289
+ export function formatStopOutput(result) {
1290
+ const status = result.stopped
1291
+ ? result.forced
1292
+ ? "stopped (forced)"
1293
+ : "stopped"
1294
+ : result.stale
1295
+ ? "stopped (stale pid file removed)"
1296
+ : "stopped (no-op)";
1297
+ const payload = { status };
1298
+ if (result.pid != null)
1299
+ payload.pid = result.pid;
1300
+ if (result.port != null)
1301
+ payload.port = result.port;
1302
+ return encode(payload);
1148
1303
  }
1149
1304
  async function handleStop() {
1150
- const wasStopped = await stopBridge();
1151
- return formatStopOutput(wasStopped);
1305
+ return formatStopOutput(await stopBridge());
1306
+ }
1307
+ async function handleRestart() {
1308
+ const port = await restartBridge();
1309
+ return encode({ status: "ready", port, version: VERSION });
1310
+ }
1311
+ export function formatStatusOutput(status) {
1312
+ if (!status.pidFileExists && !status.processAlive) {
1313
+ return renderOutput([
1314
+ encode({ bridge: "not running", version: status.expectedVersion }),
1315
+ renderHelp(["Run `opera-browser-cli open <url>` — the bridge starts automatically"]),
1316
+ ]);
1317
+ }
1318
+ if (status.versionSkew) {
1319
+ return renderOutput([
1320
+ encode({
1321
+ bridge: "running (stale version)",
1322
+ pid: status.pid,
1323
+ port: status.port,
1324
+ running: status.runningVersion,
1325
+ expected: status.expectedVersion,
1326
+ }),
1327
+ renderHelp([
1328
+ "The next command restarts it automatically",
1329
+ "Run `opera-browser-cli restart` to do it now",
1330
+ ]),
1331
+ ]);
1332
+ }
1333
+ if (status.stalePidFile) {
1334
+ return renderOutput([
1335
+ encode({ bridge: "not running", stale_pid: status.pid }),
1336
+ renderHelp(["Run `opera-browser-cli stop` to clean up the stale pid file"]),
1337
+ ]);
1338
+ }
1339
+ if (!status.healthy) {
1340
+ return renderOutput([
1341
+ encode({ bridge: "unhealthy", pid: status.pid, port: status.port }),
1342
+ renderHelp([
1343
+ "Run `opera-browser-cli restart` to bring it back",
1344
+ "Run `opera-browser-cli logs` to see why",
1345
+ ]),
1346
+ ]);
1347
+ }
1348
+ return encode({
1349
+ bridge: "ready",
1350
+ pid: status.pid,
1351
+ port: status.port,
1352
+ version: status.runningVersion,
1353
+ });
1354
+ }
1355
+ async function handleStatus() {
1356
+ return formatStatusOutput(await getBridgeStatus());
1152
1357
  }
1153
1358
  // --- Page management handlers ---
1154
1359
  async function handlePages() {
@@ -1411,107 +1616,120 @@ async function handleHeap(args) {
1411
1616
  * matching profile (Neon vs Neon Developer).
1412
1617
  */
1413
1618
  function defaultNeonProfileDir(neonPath) {
1414
- const home = homedir();
1415
- let candidate;
1416
- if (process.platform === "darwin") {
1417
- const isDeveloper = neonPath?.includes("Opera Neon Developer.app") ?? false;
1418
- const bundle = isDeveloper
1419
- ? "com.operasoftware.OperaNeonDeveloper"
1420
- : "com.operasoftware.OperaNeon";
1421
- candidate = `${home}/Library/Application Support/${bundle}`;
1422
- }
1423
- else if (process.platform === "win32") {
1424
- const appData = process.env.APPDATA ?? `${home}\\AppData\\Roaming`;
1425
- const isDeveloper = neonPath?.includes("Developer") ?? false;
1426
- candidate = isDeveloper
1427
- ? `${appData}\\Opera Software\\Opera Neon Developer`
1428
- : `${appData}\\Opera Software\\Opera Neon`;
1429
- }
1430
- else {
1431
- return null;
1619
+ return defaultProfileDir(neonPath, homedir());
1620
+ }
1621
+ export function parseSetupArgs(args) {
1622
+ let interactive = true;
1623
+ let executable;
1624
+ let profile;
1625
+ let headed;
1626
+ for (let i = 0; i < args.length; i++) {
1627
+ switch (args[i]) {
1628
+ case "--non-interactive":
1629
+ case "--yes":
1630
+ case "-y":
1631
+ interactive = false;
1632
+ break;
1633
+ case "--executable":
1634
+ if (i + 1 < args.length) {
1635
+ executable = args[++i];
1636
+ interactive = false;
1637
+ }
1638
+ break;
1639
+ case "--profile":
1640
+ if (i + 1 < args.length) {
1641
+ profile = args[++i];
1642
+ interactive = false;
1643
+ }
1644
+ break;
1645
+ case "--headed":
1646
+ headed = true;
1647
+ interactive = false;
1648
+ break;
1649
+ case "--headless":
1650
+ headed = false;
1651
+ interactive = false;
1652
+ break;
1653
+ }
1432
1654
  }
1433
- return existsSync(candidate) ? candidate : null;
1655
+ return { interactive, executable, profile, headed };
1434
1656
  }
1435
- function neonCandidatePaths() {
1436
- const home = homedir();
1437
- if (process.platform === "darwin") {
1438
- return [
1439
- "/Applications/Opera Neon.app/Contents/MacOS/Opera",
1440
- "/Applications/Opera Neon Developer.app/Contents/MacOS/Opera",
1441
- `${home}/Applications/Opera Neon.app/Contents/MacOS/Opera`,
1442
- `${home}/Applications/Opera Neon Developer.app/Contents/MacOS/Opera`,
1443
- ];
1657
+ /** Install SKILL.md for Claude Code and the generic cross-agent path. */
1658
+ function installSkillFiles(report) {
1659
+ const here = dirname(fileURLToPath(import.meta.url));
1660
+ const skillSrc = [join(here, "..", "SKILL.md"), join(here, "..", "..", "SKILL.md")].find((p) => existsSync(p));
1661
+ if (!skillSrc) {
1662
+ report("SKILL.md not found — skipping skill install");
1663
+ return;
1444
1664
  }
1445
- if (process.platform === "win32") {
1446
- const localAppData = process.env.LOCALAPPDATA ?? `${home}\\AppData\\Local`;
1447
- const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files";
1448
- return [
1449
- `${localAppData}\\Programs\\Opera Neon\\opera.exe`,
1450
- `${programFiles}\\Opera Neon\\opera.exe`,
1451
- `${localAppData}\\Programs\\Opera Neon Developer\\opera.exe`,
1452
- `${programFiles}\\Opera Neon Developer\\opera.exe`,
1453
- ];
1665
+ for (const { agent, dir } of [
1666
+ { agent: "Claude", dir: join(homedir(), ".claude", "skills") },
1667
+ { agent: "generic", dir: join(homedir(), ".agents", "skills") },
1668
+ ]) {
1669
+ const skillDst = join(dir, "opera-browser-cli", "SKILL.md");
1670
+ mkdirSync(dirname(skillDst), { recursive: true });
1671
+ copyFileSync(skillSrc, skillDst);
1672
+ report(`Installed ${agent} skill -> ${skillDst}`);
1454
1673
  }
1455
- // Opera Neon does not ship for Linux.
1456
- return [];
1457
1674
  }
1458
- function operaCandidatePaths() {
1459
- const home = homedir();
1460
- if (process.platform === "darwin") {
1461
- return [
1462
- "/Applications/Opera GX.app/Contents/MacOS/Opera",
1463
- "/Applications/Opera.app/Contents/MacOS/Opera",
1464
- `${home}/Applications/Opera GX.app/Contents/MacOS/Opera`,
1465
- `${home}/Applications/Opera.app/Contents/MacOS/Opera`,
1466
- ];
1675
+ /**
1676
+ * Configure without prompting: detection plus whatever the flags override.
1677
+ *
1678
+ * `setup` used to refuse outright without a TTY, which ruled out exactly the
1679
+ * callers that most need it — agents, provisioning scripts, containers.
1680
+ */
1681
+ function setupNonInteractive(parsed) {
1682
+ const config = readConfigFile();
1683
+ const executable = parsed.executable ??
1684
+ config.OPERA_CLI_EXECUTABLE_PATH ??
1685
+ detectBrowsers(process.platform, homedir())[0]?.path;
1686
+ if (executable)
1687
+ config.OPERA_CLI_EXECUTABLE_PATH = executable;
1688
+ const headed = parsed.headed ?? (config.OPERA_CLI_HEADED === "1" || Boolean(executable));
1689
+ if (headed)
1690
+ config.OPERA_CLI_HEADED = "1";
1691
+ else
1692
+ delete config.OPERA_CLI_HEADED;
1693
+ if (parsed.profile === "skip") {
1694
+ delete config.OPERA_CLI_USER_DATA_DIR;
1467
1695
  }
1468
- if (process.platform === "win32") {
1469
- const localAppData = process.env.LOCALAPPDATA ?? `${home}\\AppData\\Local`;
1470
- const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files";
1471
- return [
1472
- `${localAppData}\\Programs\\Opera GX\\opera.exe`,
1473
- `${localAppData}\\Programs\\Opera\\opera.exe`,
1474
- `${programFiles}\\Opera GX\\opera.exe`,
1475
- `${programFiles}\\Opera\\opera.exe`,
1476
- ];
1696
+ else {
1697
+ const profile = parsed.profile ??
1698
+ config.OPERA_CLI_USER_DATA_DIR ??
1699
+ defaultProfileDir(executable, homedir()) ??
1700
+ join(getStateDir(), "profile");
1701
+ config.OPERA_CLI_USER_DATA_DIR = profile;
1702
+ }
1703
+ writeConfigFile(config);
1704
+ const notes = [];
1705
+ installSkillFiles((line) => notes.push(line));
1706
+ const help = ["Run `opera-browser-cli open https://example.com` to start browsing"];
1707
+ if (!executable) {
1708
+ help.unshift("No Opera installation found — set OPERA_CLI_EXECUTABLE_PATH or pass --executable <path>");
1477
1709
  }
1478
- return [];
1479
- }
1480
- function browserDisplayName(binPath) {
1481
- if (binPath.includes("Neon Developer"))
1482
- return "Opera Neon Developer";
1483
- if (binPath.includes("Neon"))
1484
- return "Opera Neon";
1485
- if (binPath.includes("GX"))
1486
- return "Opera GX";
1487
- return "Opera";
1488
- }
1489
- async function handleSetup(_args) {
1490
- if (!process.stdin.isTTY) {
1491
- throw new CdpError("setup requires an interactive terminal", "VALIDATION_ERROR", ["Run `opera-browser-cli setup` directly in your shell, not through an agent"]);
1492
- }
1493
- const stateDir = join(homedir(), ".opera-browser-cli");
1494
- const configFile = join(stateDir, "config");
1495
- const existing = {};
1496
- if (existsSync(configFile)) {
1497
- for (const line of readFileSync(configFile, "utf-8").split("\n")) {
1498
- const t = line.trim();
1499
- if (!t || t.startsWith("#"))
1500
- continue;
1501
- const eq = t.indexOf("=");
1502
- if (eq === -1)
1503
- continue;
1504
- existing[t.slice(0, eq).trim()] = parseConfigValue(t.slice(eq + 1).trim());
1505
- }
1710
+ return renderOutput([
1711
+ encode({ config: getConfigFile(), settings: config }),
1712
+ notes.join("\n"),
1713
+ renderHelp(help),
1714
+ ]);
1715
+ }
1716
+ async function handleSetup(args) {
1717
+ const parsed = parseSetupArgs(args);
1718
+ // No terminal to prompt in is a reason to fall back, not to fail.
1719
+ if (!parsed.interactive || !process.stdin.isTTY) {
1720
+ return setupNonInteractive(parsed);
1506
1721
  }
1722
+ const stateDir = getStateDir();
1723
+ const configFile = getConfigFile();
1724
+ const existing = readConfigFile();
1507
1725
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1508
1726
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
1509
1727
  const config = { ...existing };
1510
1728
  try {
1511
1729
  process.stdout.write("opera-browser-cli setup\n\n");
1512
1730
  // 1. Browser executable path
1513
- const detectedNeons = neonCandidatePaths().filter((p) => existsSync(p));
1514
- const detectedOpera = operaCandidatePaths().find((p) => existsSync(p));
1731
+ const detectedNeons = neonCandidatePaths(process.platform, homedir()).filter((p) => existsSync(p));
1732
+ const detectedOpera = operaCandidatePaths(process.platform, homedir()).find((p) => existsSync(p));
1515
1733
  const currentExec = existing["OPERA_CLI_EXECUTABLE_PATH"];
1516
1734
  if (detectedNeons.length > 0) {
1517
1735
  // Always show the full list so the user can switch between versions.
@@ -1613,35 +1831,9 @@ async function handleSetup(_args) {
1613
1831
  finally {
1614
1832
  rl.close();
1615
1833
  }
1616
- // Write config
1617
- mkdirSync(stateDir, { recursive: true });
1618
- const lines = [
1619
- "# opera-browser-cli configuration — auto-loaded on every run",
1620
- "# Values here are used as defaults when the env var is not already set.",
1621
- "",
1622
- ...Object.entries(config).map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`),
1623
- ];
1624
- writeFileSync(configFile, lines.join("\n") + "\n");
1834
+ writeConfigFile(config);
1625
1835
  process.stdout.write(`\nSaved to ${configFile}\n`);
1626
- // Install SKILL.md as the Claude Code skill, plus the generic
1627
- // ~/.agents/skills path that cross-agent tools (Codex, etc.) scan.
1628
- const here = dirname(fileURLToPath(import.meta.url));
1629
- const skillSrc = [join(here, "..", "SKILL.md"), join(here, "..", "..", "SKILL.md")].find((p) => existsSync(p));
1630
- const skillRoots = [
1631
- { agent: "Claude", dir: join(homedir(), ".claude", "skills") },
1632
- { agent: "generic", dir: join(homedir(), ".agents", "skills") },
1633
- ];
1634
- if (skillSrc) {
1635
- for (const { agent, dir } of skillRoots) {
1636
- const skillDst = join(dir, "opera-browser-cli", "SKILL.md");
1637
- mkdirSync(dirname(skillDst), { recursive: true });
1638
- copyFileSync(skillSrc, skillDst);
1639
- process.stdout.write(`Installed ${agent} skill -> ${skillDst}\n`);
1640
- }
1641
- }
1642
- else {
1643
- process.stdout.write("SKILL.md not found — skipping skill install\n");
1644
- }
1836
+ installSkillFiles((line) => process.stdout.write(line + "\n"));
1645
1837
  return renderOutput([
1646
1838
  encode({ config: configFile, settings: config }),
1647
1839
  renderHelp([
@@ -1651,6 +1843,74 @@ async function handleSetup(_args) {
1651
1843
  ]),
1652
1844
  ]);
1653
1845
  }
1846
+ // --- Attach ---
1847
+ export function parseAttachArgs(args) {
1848
+ let port = null;
1849
+ let clear = false;
1850
+ for (let i = 0; i < args.length; i++) {
1851
+ if (args[i] === "--port" && i + 1 < args.length) {
1852
+ const parsed = Number.parseInt(args[++i] ?? "", 10);
1853
+ if (Number.isInteger(parsed) && parsed > 0)
1854
+ port = parsed;
1855
+ }
1856
+ else if (args[i] === "--clear") {
1857
+ clear = true;
1858
+ }
1859
+ }
1860
+ return { port, clear };
1861
+ }
1862
+ async function handleAttach(args) {
1863
+ const { port, clear } = parseAttachArgs(args);
1864
+ if (clear) {
1865
+ updateConfigFile({ OPERA_CLI_BROWSER_URL: null });
1866
+ return renderOutput([
1867
+ encode({ attach: "cleared" }),
1868
+ renderHelp(["opera-browser-cli will launch its own browser from now on"]),
1869
+ ]);
1870
+ }
1871
+ // With no explicit port, look for one the configured profile advertised.
1872
+ const userDataDir = process.env.OPERA_CLI_USER_DATA_DIR;
1873
+ const resolved = port ?? (userDataDir ? readDevToolsPort(userDataDir) : null);
1874
+ if (resolved === null) {
1875
+ throw new CdpError("No debugging port given, and none found for the configured profile", "VALIDATION_ERROR", [
1876
+ "Run `opera-browser-cli attach --port <n>` if you know the port",
1877
+ "Run `opera-browser-cli launch-args` to start Opera with a debugging port",
1878
+ ]);
1879
+ }
1880
+ const identity = await probeDevToolsEndpoint(resolved);
1881
+ if (identity === null) {
1882
+ throw new CdpError(`Nothing is answering DevTools on port ${resolved}`, "BROWSER_ERROR", [
1883
+ "Check the browser is running and was started with --remote-debugging-port",
1884
+ "Run `opera-browser-cli launch-args` for the exact flags",
1885
+ ]);
1886
+ }
1887
+ const url = `http://127.0.0.1:${resolved}`;
1888
+ updateConfigFile({ OPERA_CLI_BROWSER_URL: url });
1889
+ const help = ["Run `opera-browser-cli attach --clear` to go back to a CLI-launched browser"];
1890
+ if (!identity.isOpera) {
1891
+ help.unshift(`Note: ${identity.browser} is not an Opera browser — Opera AI commands will not work`);
1892
+ }
1893
+ return renderOutput([
1894
+ encode({ attach: url, browser: identity.browser }),
1895
+ renderHelp(help),
1896
+ ]);
1897
+ }
1898
+ function handleLaunchArgs() {
1899
+ const execPath = process.env.OPERA_CLI_EXECUTABLE_PATH;
1900
+ const userDataDir = process.env.OPERA_CLI_USER_DATA_DIR;
1901
+ const args = browserLaunchArgs(userDataDir);
1902
+ const binary = execPath ?? "/Applications/Opera Neon.app/Contents/MacOS/Opera";
1903
+ const command = [JSON.stringify(binary), ...args.map((a) => JSON.stringify(a))].join(" ");
1904
+ return renderOutput([
1905
+ encode({ launch: "start Opera with these flags, then run `opera-browser-cli attach`" }),
1906
+ `command:\n ${command}`,
1907
+ renderHelp([
1908
+ "The port is chosen by the browser and recorded in DevToolsActivePort",
1909
+ "opera-browser-cli finds it automatically — `attach` is only needed for a different profile",
1910
+ "A debugging port lets any local process drive this browser; close it when done",
1911
+ ]),
1912
+ ]);
1913
+ }
1654
1914
  function fileContainsMarker(path, marker) {
1655
1915
  if (!existsSync(path))
1656
1916
  return false;
@@ -1672,32 +1932,39 @@ async function runDoctorChecks() {
1672
1932
  const checks = [];
1673
1933
  // Bridge
1674
1934
  const bridge = await getBridgeStatus();
1675
- if (!bridge.pidFileExists) {
1935
+ if (!bridge.pidFileExists && !bridge.processAlive) {
1676
1936
  checks.push({
1677
1937
  name: "bridge",
1678
1938
  status: "warn",
1679
1939
  detail: "not running (will auto-start on first command)",
1680
1940
  });
1681
1941
  }
1682
- else if (!bridge.processAlive) {
1942
+ else if (bridge.stalePidFile) {
1683
1943
  checks.push({
1684
1944
  name: "bridge",
1685
- status: "fail",
1686
- detail: `pid ${bridge.pid} in pid file but process is dead`,
1945
+ status: "warn",
1946
+ detail: `stale pid file (pid ${bridge.pid} is not a running bridge) cleared on next start`,
1947
+ });
1948
+ }
1949
+ else if (bridge.versionSkew) {
1950
+ checks.push({
1951
+ name: "bridge",
1952
+ status: "warn",
1953
+ detail: `running ${bridge.runningVersion}, installed ${bridge.expectedVersion} — restarts automatically on next command`,
1687
1954
  });
1688
1955
  }
1689
1956
  else if (!bridge.healthy) {
1690
1957
  checks.push({
1691
1958
  name: "bridge",
1692
1959
  status: "fail",
1693
- detail: `pid ${bridge.pid} alive on port ${bridge.port} but /health did not respond`,
1960
+ detail: `pid ${bridge.pid} on port ${bridge.port} is not serving a healthy /health`,
1694
1961
  });
1695
1962
  }
1696
1963
  else {
1697
1964
  checks.push({
1698
1965
  name: "bridge",
1699
1966
  status: "ok",
1700
- detail: `running, pid ${bridge.pid}, port ${bridge.port}`,
1967
+ detail: `running ${bridge.runningVersion}, pid ${bridge.pid}, port ${bridge.port}`,
1701
1968
  });
1702
1969
  }
1703
1970
  // Config file
@@ -1710,14 +1977,28 @@ async function runDoctorChecks() {
1710
1977
  });
1711
1978
  }
1712
1979
  else {
1713
- const lines = readFileSync(configFile, "utf-8")
1714
- .split("\n")
1715
- .filter((l) => l.trim() && !l.trim().startsWith("#"));
1716
- checks.push({
1717
- name: "config",
1718
- status: "ok",
1719
- detail: `${configFile} (${lines.length} var${lines.length === 1 ? "" : "s"} set)`,
1720
- });
1980
+ const config = readConfigFile();
1981
+ const count = Object.keys(config).length;
1982
+ const unknown = findUnknownConfigKeys(config);
1983
+ if (unknown.length > 0) {
1984
+ // A typo'd key is silently ignored at load time and looks perfectly
1985
+ // correct in the file, so it has to be called out here or never.
1986
+ const described = unknown
1987
+ .map((u) => (u.suggestion ? `${u.key} (did you mean ${u.suggestion}?)` : u.key))
1988
+ .join(", ");
1989
+ checks.push({
1990
+ name: "config",
1991
+ status: "warn",
1992
+ detail: `${configFile} — unrecognised key${unknown.length === 1 ? "" : "s"}: ${described}`,
1993
+ });
1994
+ }
1995
+ else {
1996
+ checks.push({
1997
+ name: "config",
1998
+ status: "ok",
1999
+ detail: `${configFile} (${count} var${count === 1 ? "" : "s"} set)`,
2000
+ });
2001
+ }
1721
2002
  }
1722
2003
  // Opera Neon executable
1723
2004
  const execPath = process.env.OPERA_CLI_EXECUTABLE_PATH;
@@ -1750,6 +2031,67 @@ async function runDoctorChecks() {
1750
2031
  detail: execPath,
1751
2032
  });
1752
2033
  }
2034
+ // opera-devtools-mcp — the bridge cannot start without it
2035
+ const mcp = resolveMcpBinStatus();
2036
+ checks.push(mcp.found
2037
+ ? { name: "mcp", status: "ok", detail: `${mcp.bin} (${mcp.source})` }
2038
+ : {
2039
+ name: "mcp",
2040
+ status: "fail",
2041
+ detail: `opera-devtools-mcp not found at ${mcp.bin} (${mcp.source})`,
2042
+ });
2043
+ // Browser target — launch, or attach to something already running
2044
+ if (browserUrl) {
2045
+ const attachPort = Number.parseInt(new URL(browserUrl).port, 10);
2046
+ const identity = Number.isFinite(attachPort)
2047
+ ? await probeDevToolsEndpoint(attachPort)
2048
+ : null;
2049
+ checks.push(identity
2050
+ ? { name: "browser", status: "ok", detail: `attached to ${identity.browser}` }
2051
+ : {
2052
+ name: "browser",
2053
+ status: "fail",
2054
+ detail: `OPERA_CLI_BROWSER_URL=${browserUrl} is not answering`,
2055
+ });
2056
+ }
2057
+ // Profile lock — the usual reason a launch silently fails
2058
+ const profileDir = process.env.OPERA_CLI_USER_DATA_DIR;
2059
+ if (!profileDir) {
2060
+ checks.push({
2061
+ name: "profile",
2062
+ status: "ok",
2063
+ detail: "isolated (no persistent profile configured)",
2064
+ });
2065
+ }
2066
+ else if (!existsSync(profileDir)) {
2067
+ checks.push({
2068
+ name: "profile",
2069
+ status: "ok",
2070
+ detail: `${profileDir} (will be created on first launch)`,
2071
+ });
2072
+ }
2073
+ else {
2074
+ const lock = inspectProfileLock(profileDir);
2075
+ const attachable = readDevToolsPort(profileDir);
2076
+ const live = attachable !== null ? await probeDevToolsEndpoint(attachable) : null;
2077
+ if (lock.state === "free") {
2078
+ checks.push({ name: "profile", status: "ok", detail: `${profileDir} (free)` });
2079
+ }
2080
+ else if (live) {
2081
+ checks.push({
2082
+ name: "profile",
2083
+ status: "ok",
2084
+ detail: `in use by ${live.browser}, attachable on port ${attachable}`,
2085
+ });
2086
+ }
2087
+ else {
2088
+ checks.push({
2089
+ name: "profile",
2090
+ status: "warn",
2091
+ detail: `in use${lock.pid ? ` by pid ${lock.pid}` : ""} with no debugging port — a separate profile will be used`,
2092
+ });
2093
+ }
2094
+ }
1753
2095
  // Session hooks
1754
2096
  const home = homedir();
1755
2097
  const claudeSettings = join(home, ".claude", "settings.json");
@@ -1811,7 +2153,58 @@ async function runDoctorChecks() {
1811
2153
  }
1812
2154
  return checks;
1813
2155
  }
1814
- async function handleDoctor(_args) {
2156
+ /**
2157
+ * Repair what can be repaired mechanically. Anything needing a decision — an
2158
+ * install, a config edit — is reported, never done on the user's behalf.
2159
+ */
2160
+ async function runDoctorFixes(checks) {
2161
+ const done = [];
2162
+ const bridge = checks.find((c) => c.name === "bridge");
2163
+ if (bridge && bridge.status !== "ok") {
2164
+ if (bridge.detail.includes("stale pid file")) {
2165
+ await stopBridge();
2166
+ done.push("cleared the stale pid file");
2167
+ }
2168
+ else if (bridge.detail.includes("not running")) {
2169
+ // Nothing broken — it starts on demand.
2170
+ }
2171
+ else {
2172
+ await restartBridge();
2173
+ done.push("restarted the bridge");
2174
+ }
2175
+ }
2176
+ if (checks.some((c) => c.name === "config" && c.detail.includes("not found"))) {
2177
+ const result = autoConfigure();
2178
+ if (result.status === "configured") {
2179
+ done.push(`wrote a config for ${result.browser.name}`);
2180
+ }
2181
+ }
2182
+ const logs = checks.find((c) => c.name === "logs");
2183
+ if (logs && /\d+(\.\d+)? MB/.test(logs.detail)) {
2184
+ const size = Number.parseFloat(logs.detail.match(/([\d.]+) MB/)?.[1] ?? "0");
2185
+ if (size >= 5 && rotateBridgeLog())
2186
+ done.push("rotated the bridge log");
2187
+ }
2188
+ return done;
2189
+ }
2190
+ async function handleDoctor(args) {
2191
+ if (args.includes("--fix")) {
2192
+ const applied = await runDoctorFixes(await runDoctorChecks());
2193
+ const after = await runDoctorChecks();
2194
+ const summary = {
2195
+ fixed: applied.length,
2196
+ ok: after.filter((c) => c.status === "ok").length,
2197
+ warn: after.filter((c) => c.status === "warn").length,
2198
+ fail: after.filter((c) => c.status === "fail").length,
2199
+ };
2200
+ return renderOutput([
2201
+ encode({ doctor: summary }),
2202
+ applied.length > 0
2203
+ ? `fixed[${applied.length}]:\n${applied.map((f) => ` ${f}`).join("\n")}`
2204
+ : "fixed: nothing needed repairing",
2205
+ `checks[${after.length}]:\n${after.map((c) => ` ${c.name}: ${c.status} (${c.detail})`).join("\n")}`,
2206
+ ]);
2207
+ }
1815
2208
  const checks = await runDoctorChecks();
1816
2209
  const summary = {
1817
2210
  ok: checks.filter((c) => c.status === "ok").length,
@@ -1828,12 +2221,21 @@ async function handleDoctor(_args) {
1828
2221
  help.push("Run `opera-browser-cli setup` to detect Opera Neon, or set OPERA_CLI_EXECUTABLE_PATH");
1829
2222
  }
1830
2223
  if (checks.some((c) => c.name === "bridge" && c.status === "fail")) {
1831
- help.push("Run `opera-browser-cli stop` then any command to restart the bridge");
2224
+ help.push("Run `opera-browser-cli restart` to bring the bridge back");
1832
2225
  help.push("Run `opera-browser-cli logs` to see why the bridge is unhealthy");
1833
2226
  }
1834
2227
  if (checks.some((c) => c.name === "hooks" && c.status !== "ok")) {
1835
2228
  help.push("Run any command with OPERA_CLI_ENABLE_HOOKS=1 to install session hooks");
1836
2229
  }
2230
+ if (checks.some((c) => c.name === "mcp" && c.status !== "ok")) {
2231
+ help.push("Install the MCP server: `npm install -g opera-devtools-mcp`, or set OPERA_CLI_MCP_BIN");
2232
+ }
2233
+ if (checks.some((c) => c.name === "profile" && c.status === "warn")) {
2234
+ help.push("Run `opera-browser-cli launch-args` to restart Opera so the CLI can attach to your real profile");
2235
+ }
2236
+ if (checks.some((c) => c.name === "browser" && c.status === "fail")) {
2237
+ help.push("Run `opera-browser-cli attach --clear` to stop attaching to a dead endpoint");
2238
+ }
1837
2239
  return renderOutput([
1838
2240
  encode({ doctor: summary }),
1839
2241
  checksBlock,
@@ -1842,19 +2244,70 @@ async function handleDoctor(_args) {
1842
2244
  }
1843
2245
  // --- Logs ---
1844
2246
  const LOGS_DEFAULT_LINES = 50;
1845
- function parseLogsArgs(args) {
2247
+ export function parseLogsArgs(args) {
1846
2248
  let lines = LOGS_DEFAULT_LINES;
2249
+ let follow = false;
2250
+ let errorsOnly = false;
1847
2251
  for (let i = 0; i < args.length; i++) {
1848
2252
  if ((args[i] === "-n" || args[i] === "--lines") && i + 1 < args.length) {
1849
2253
  const parsed = parseInt(args[++i] ?? "", 10);
1850
2254
  if (Number.isFinite(parsed) && parsed > 0)
1851
2255
  lines = parsed;
1852
2256
  }
2257
+ else if (args[i] === "-f" || args[i] === "--follow") {
2258
+ follow = true;
2259
+ }
2260
+ else if (args[i] === "--errors") {
2261
+ errorsOnly = true;
2262
+ }
2263
+ }
2264
+ return { lines, follow, errorsOnly };
2265
+ }
2266
+ /** The lines worth looking at when something has gone wrong. */
2267
+ const LOG_ERROR_PATTERN = /error|failed|fatal|exception|refused|denied|timeout|timed out|in use|EADDRINUSE|ECONNREFUSED|EACCES|not found|unauthorized|cannot/i;
2268
+ export function filterLogLines(lines, errorsOnly) {
2269
+ return errorsOnly ? lines.filter((l) => LOG_ERROR_PATTERN.test(l)) : lines;
2270
+ }
2271
+ /** Stream appended log output until interrupted. */
2272
+ async function followLog(errorsOnly) {
2273
+ const logFile = getLogFile();
2274
+ let offset = existsSync(logFile) ? statSync(logFile).size : 0;
2275
+ let stop = false;
2276
+ const onSigint = () => {
2277
+ stop = true;
2278
+ };
2279
+ process.on("SIGINT", onSigint);
2280
+ try {
2281
+ while (!stop) {
2282
+ await new Promise((r) => setTimeout(r, 500));
2283
+ if (!existsSync(logFile))
2284
+ continue;
2285
+ const size = statSync(logFile).size;
2286
+ // A rotation shrinks the file; start over from the top of the new one.
2287
+ if (size < offset)
2288
+ offset = 0;
2289
+ if (size === offset)
2290
+ continue;
2291
+ const fd = openSync(logFile, "r");
2292
+ try {
2293
+ const buffer = Buffer.alloc(size - offset);
2294
+ readSync(fd, buffer, 0, buffer.length, offset);
2295
+ offset = size;
2296
+ const fresh = filterLogLines(buffer.toString("utf-8").split("\n").filter(Boolean), errorsOnly);
2297
+ if (fresh.length > 0)
2298
+ process.stdout.write(fresh.join("\n") + "\n");
2299
+ }
2300
+ finally {
2301
+ closeSync(fd);
2302
+ }
2303
+ }
2304
+ }
2305
+ finally {
2306
+ process.off("SIGINT", onSigint);
1853
2307
  }
1854
- return { lines };
1855
2308
  }
1856
2309
  async function handleLogs(args) {
1857
- const { lines } = parseLogsArgs(args);
2310
+ const { lines, follow, errorsOnly } = parseLogsArgs(args);
1858
2311
  const logFile = getLogFile();
1859
2312
  if (!existsSync(logFile)) {
1860
2313
  return renderOutput([
@@ -1870,38 +2323,153 @@ async function handleLogs(args) {
1870
2323
  if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
1871
2324
  allLines.pop();
1872
2325
  }
1873
- const tail = allLines.slice(-lines);
2326
+ const matched = filterLogLines(allLines, errorsOnly);
2327
+ const tail = matched.slice(-lines);
2328
+ if (follow) {
2329
+ process.stdout.write(renderOutput([
2330
+ encode({ path: logFile, following: true, errors_only: errorsOnly }),
2331
+ tail.join("\n"),
2332
+ ]) + "\n");
2333
+ await followLog(errorsOnly);
2334
+ return "";
2335
+ }
1874
2336
  return renderOutput([
1875
- encode({ path: logFile, lines: tail.length, total: allLines.length }),
2337
+ encode({
2338
+ path: logFile,
2339
+ lines: tail.length,
2340
+ total: allLines.length,
2341
+ ...(errorsOnly ? { matched: matched.length } : {}),
2342
+ }),
1876
2343
  tail.join("\n"),
1877
2344
  renderHelp([
1878
2345
  `Run \`opera-browser-cli logs --lines <N>\` to show more (default ${LOGS_DEFAULT_LINES})`,
1879
- `Tail live: \`tail -f ${logFile}\``,
2346
+ "Run `opera-browser-cli logs --errors` to show only failure lines",
2347
+ "Run `opera-browser-cli logs --follow` to stream new output",
1880
2348
  ]),
1881
2349
  ]);
1882
2350
  }
1883
- // --- Opera AI handlers ---
1884
2351
  /**
1885
- * Pre-flight check for AI commands. Fails fast if Opera Neon is clearly
1886
- * not configured, so we don't pay the 30s bridge-startup tax just to surface
1887
- * a confusing protocol error.
2352
+ * What kind of browser we are about to drive.
1888
2353
  *
1889
- * Skipped when OPERA_CLI_BROWSER_URL is set the user manages the browser
1890
- * themselves and presumably knows it's Opera Neon.
2354
+ * The old check only asked whether the configured path existed, which cannot
2355
+ * tell Neon from Opera from Chrome — so it passed in exactly the two cases that
2356
+ * fail: a plain Opera (no invoke-do/make/research) and a non-Opera browser
2357
+ * (no Opera AI at all). Attached browsers report their real identity; launched
2358
+ * ones are identified by their build, which is how Opera names its binaries.
2359
+ */
2360
+ export function classifyBrowser(executablePath, attachedBrowser) {
2361
+ if (attachedBrowser) {
2362
+ if (/neon/i.test(attachedBrowser))
2363
+ return "neon";
2364
+ if (/opera|opr\//i.test(attachedBrowser))
2365
+ return "opera";
2366
+ return "other";
2367
+ }
2368
+ if (!executablePath)
2369
+ return "unknown";
2370
+ if (/neon/i.test(executablePath))
2371
+ return "neon";
2372
+ if (/opera/i.test(executablePath))
2373
+ return "opera";
2374
+ return "other";
2375
+ }
2376
+ const NEON_ONLY_HELP = [
2377
+ "Install Opera Neon from https://www.operaneon.com",
2378
+ "Run `opera-browser-cli setup` to point at it",
2379
+ "Run `opera-browser-cli doctor` to inspect the current configuration",
2380
+ ];
2381
+ /**
2382
+ * Fail fast for commands that need Opera Neon, so we do not pay a browser
2383
+ * launch to surface a confusing protocol error.
1891
2384
  */
1892
2385
  function requireNeon(command) {
2386
+ // An explicitly attached browser is identified for real by `doctor`; here we
2387
+ // trust the user to know what they pointed us at.
1893
2388
  if (process.env.OPERA_CLI_BROWSER_URL)
1894
2389
  return;
1895
2390
  const execPath = process.env.OPERA_CLI_EXECUTABLE_PATH;
1896
- if (execPath && existsSync(execPath))
1897
- return;
1898
- const reason = execPath
1899
- ? `OPERA_CLI_EXECUTABLE_PATH points at "${execPath}" which does not exist`
1900
- : "OPERA_CLI_EXECUTABLE_PATH is not set — opera-browser-cli would launch vanilla Chrome, which has no Opera AI";
1901
- throw new CdpError(`${command} requires Opera Neon — ${reason}`, "VALIDATION_ERROR", [
1902
- "Run `opera-browser-cli setup` to detect and configure Opera Neon",
1903
- "Or set OPERA_CLI_EXECUTABLE_PATH to your Opera Neon binary",
1904
- "Run `opera-browser-cli doctor` to inspect the current configuration",
2391
+ if (execPath && !existsSync(execPath)) {
2392
+ throw new CdpError(`${command} requires Opera Neon, and OPERA_CLI_EXECUTABLE_PATH points at "${execPath}", which does not exist`, "VALIDATION_ERROR", NEON_ONLY_HELP);
2393
+ }
2394
+ switch (classifyBrowser(execPath)) {
2395
+ case "neon":
2396
+ return;
2397
+ case "opera":
2398
+ throw new CdpError(`${command} is only available on Opera Neon — the configured browser is a standard Opera build`, "UNSUPPORTED_OPERATION", ["`opera-browser-cli chat` works on this browser", ...NEON_ONLY_HELP]);
2399
+ case "other":
2400
+ throw new CdpError(`${command} requires Opera Neon — the configured browser is not an Opera build`, "VALIDATION_ERROR", NEON_ONLY_HELP);
2401
+ default:
2402
+ throw new CdpError(`${command} requires Opera Neon — no browser is configured, so a plain Chrome would be launched`, "VALIDATION_ERROR", NEON_ONLY_HELP);
2403
+ }
2404
+ }
2405
+ // --- Login ---
2406
+ const OPERA_ACCOUNT_URL = "https://auth.opera.com/account/";
2407
+ /**
2408
+ * Ask Opera AI something trivial purely to find out whether it will answer.
2409
+ *
2410
+ * There is no cheaper signal: sign-in, subscription, and consent state are only
2411
+ * observable through the reply to a real call. So this is never run implicitly
2412
+ * — only when the user asks to check.
2413
+ */
2414
+ async function probeOperaAuth() {
2415
+ try {
2416
+ const result = await callTool("opera_chat", { prompt: "ping" });
2417
+ for (const descriptor of CDP_RESULT_ERRORS) {
2418
+ if (descriptor.match(result)) {
2419
+ return {
2420
+ ok: false,
2421
+ detail: typeof descriptor.message === "function"
2422
+ ? descriptor.message("login")
2423
+ : descriptor.message,
2424
+ };
2425
+ }
2426
+ }
2427
+ return { ok: true, detail: "Opera AI responded" };
2428
+ }
2429
+ catch (error) {
2430
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
2431
+ }
2432
+ }
2433
+ async function handleLogin(args) {
2434
+ const checkOnly = args.includes("--check");
2435
+ if (!checkOnly) {
2436
+ if (!shouldRunHeaded()) {
2437
+ throw new CdpError("Signing in needs a visible browser window, and this session is headless", "VALIDATION_ERROR", [
2438
+ "Run `OPERA_CLI_HEADED=1 opera-browser-cli login`",
2439
+ "Or run `opera-browser-cli setup --headed` to make it the default",
2440
+ ]);
2441
+ }
2442
+ await callTool("new_page", { url: OPERA_ACCOUNT_URL });
2443
+ if (!process.stdin.isTTY) {
2444
+ // No way to wait for the user, and probing now would just report the
2445
+ // state they have not had a chance to change yet.
2446
+ return renderOutput([
2447
+ encode({ login: "sign-in page opened", url: OPERA_ACCOUNT_URL }),
2448
+ renderHelp([
2449
+ "Complete sign-in in the browser window",
2450
+ "Run `opera-browser-cli login --check` to confirm it worked",
2451
+ ]),
2452
+ ]);
2453
+ }
2454
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2455
+ try {
2456
+ await new Promise((resolve) => rl.question(`\nSign in at ${OPERA_ACCOUNT_URL} in the browser window, then press Enter: `, resolve));
2457
+ }
2458
+ finally {
2459
+ rl.close();
2460
+ }
2461
+ }
2462
+ const probe = await probeOperaAuth();
2463
+ if (!probe.ok) {
2464
+ throw new CdpError(`Opera AI is not available: ${probe.detail}`, "AUTH_REQUIRED", [
2465
+ "Run `opera-browser-cli login` to sign in",
2466
+ "Check your subscription at https://auth.opera.com/account/",
2467
+ "Run `opera-browser-cli doctor` to inspect the current configuration",
2468
+ ]);
2469
+ }
2470
+ return renderOutput([
2471
+ encode({ login: "signed in", detail: probe.detail }),
2472
+ renderHelp(['Run `opera-browser-cli chat "summarise this page"` to use Opera AI']),
1905
2473
  ]);
1906
2474
  }
1907
2475
  /**
@@ -1913,16 +2481,16 @@ const CDP_RESULT_ERRORS = [
1913
2481
  {
1914
2482
  match: (r) => r.includes(CdpResultErrorKey.NOT_SIGNED_IN),
1915
2483
  message: "Opera: user is not signed in",
1916
- code: "BROWSER_ERROR",
2484
+ code: "AUTH_REQUIRED",
1917
2485
  suggestions: (cmd) => [
1918
- `Re-run \`opera-browser-cli ${cmd}\` after signing in`,
1919
- "Run `opera-browser-cli doctor` to inspect the current configuration",
2486
+ "Run `opera-browser-cli login` to sign in to your Opera account",
2487
+ `Re-run \`opera-browser-cli ${cmd}\` afterwards`,
1920
2488
  ],
1921
2489
  },
1922
2490
  {
1923
2491
  match: (r) => r.includes(CdpResultErrorKey.SUBSCRIPTION_REQUIRED),
1924
2492
  message: "Opera: an active subscription is required",
1925
- code: "BROWSER_ERROR",
2493
+ code: "AUTH_REQUIRED",
1926
2494
  suggestions: (cmd) => [
1927
2495
  "Check your Opera subscription at https://auth.opera.com/account/",
1928
2496
  `Re-run \`opera-browser-cli ${cmd}\` after activating a subscription`,
@@ -1931,21 +2499,17 @@ const CDP_RESULT_ERRORS = [
1931
2499
  {
1932
2500
  match: (r) => r.includes(CdpResultErrorKey.CONSENT_REQUIRED),
1933
2501
  message: "Opera: user consent has not been accepted",
1934
- code: "BROWSER_ERROR",
2502
+ code: "AUTH_REQUIRED",
1935
2503
  suggestions: (cmd) => [
1936
- "Open Opera and accept the consent prompt before using AI features",
2504
+ "Run `opera-browser-cli login` the consent prompt appears on first use",
1937
2505
  `Re-run \`opera-browser-cli ${cmd}\` after accepting consent`,
1938
2506
  ],
1939
2507
  },
1940
2508
  {
1941
2509
  match: (r) => r.includes(CdpResultErrorKey.NEON_ONLY),
1942
2510
  message: (cmd) => `Opera: ${cmd} is only available on Opera Neon`,
1943
- code: "BROWSER_ERROR",
1944
- suggestions: () => [
1945
- "Install Opera Neon from https://www.operaneon.com",
1946
- "Run `opera-browser-cli setup` to configure the Opera Neon executable path",
1947
- "Run `opera-browser-cli doctor` to inspect the current configuration",
1948
- ],
2511
+ code: "UNSUPPORTED_OPERATION",
2512
+ suggestions: () => NEON_ONLY_HELP,
1949
2513
  },
1950
2514
  ];
1951
2515
  function checkAiResultForCdpError(command, result) {
@@ -2216,6 +2780,11 @@ const COMMANDS = {
2216
2780
  heap: withoutFullFlag(handleHeap),
2217
2781
  start: async () => handleStart(),
2218
2782
  stop: async () => handleStop(),
2783
+ restart: async () => handleRestart(),
2784
+ status: async () => handleStatus(),
2785
+ attach: withoutFullFlag(handleAttach),
2786
+ "launch-args": async () => handleLaunchArgs(),
2787
+ login: withoutFullFlag(handleLogin),
2219
2788
  chat: withoutFullFlag(handleChat),
2220
2789
  "invoke-do": withoutFullFlag(handleInvokeDo),
2221
2790
  make: withoutFullFlag(handleMake),
@@ -2225,23 +2794,187 @@ const COMMANDS = {
2225
2794
  logs: withoutFullFlag(handleLogs),
2226
2795
  doctor: withoutFullFlag(handleDoctor),
2227
2796
  };
2228
- const SETUP_SKIP_COMMANDS = new Set(["setup", "doctor", "logs", "--help", "-h", "--version", "-v", "-V"]);
2229
- function warnIfUnconfigured(argv) {
2797
+ // --- Browser conflict preflight ---
2798
+ /** Commands that never touch a browser, so never need a target resolved. */
2799
+ const BROWSER_SKIP_COMMANDS = new Set([
2800
+ "setup",
2801
+ "doctor",
2802
+ "logs",
2803
+ "status",
2804
+ "stop",
2805
+ "attach",
2806
+ "launch-args",
2807
+ "models",
2808
+ "--help",
2809
+ "-h",
2810
+ "--version",
2811
+ "-v",
2812
+ "-V",
2813
+ ]);
2814
+ function separateProfileDir() {
2815
+ return join(getStateDir(), "profile");
2816
+ }
2817
+ /**
2818
+ * Resolve a profile conflict: the user's browser is holding the profile and we
2819
+ * cannot reach it.
2820
+ *
2821
+ * Restarting somebody's browser is not a decision to make on their behalf, so
2822
+ * it happens only on an explicit yes — a TTY prompt, or `--takeover` for
2823
+ * scripted callers. Everything else falls back to a separate profile, which
2824
+ * always works and costs only a sign-in.
2825
+ */
2826
+ async function resolveBrowserConflict(target, takeover) {
2827
+ const canPrompt = Boolean(process.stdin.isTTY && process.stdout.isTTY);
2828
+ let choice = "separate";
2829
+ if (takeover) {
2830
+ choice = "takeover";
2831
+ }
2832
+ else if (canPrompt) {
2833
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2834
+ try {
2835
+ process.stdout.write(`\nOpera is already running on the profile opera-browser-cli is configured to use:\n ${target.userDataDir}\n\n` +
2836
+ "A browser can only be automated if it was started with a debugging port,\n" +
2837
+ "and that flag cannot be added to a browser that is already open.\n\n" +
2838
+ " [1] Restart Opera now so the CLI can drive it (tabs are restored)\n" +
2839
+ " [2] Use a separate profile instead (you will need to sign in there)\n\n" +
2840
+ "Restarting opens a local debugging port for as long as that browser runs.\n");
2841
+ const answer = (await new Promise((resolve) => rl.question("Select [1/2] (default 2): ", resolve)))
2842
+ .trim()
2843
+ .toLowerCase();
2844
+ if (answer === "1" || answer === "y")
2845
+ choice = "takeover";
2846
+ }
2847
+ finally {
2848
+ rl.close();
2849
+ }
2850
+ }
2851
+ if (choice === "separate") {
2852
+ const dir = separateProfileDir();
2853
+ process.env.OPERA_CLI_USER_DATA_DIR = dir;
2854
+ process.stderr.write(`note: Opera is running on the configured profile; using ${dir} for this run.\n` +
2855
+ " Run `opera-browser-cli launch-args` to start Opera so the CLI can attach to it.\n");
2856
+ return;
2857
+ }
2858
+ const quit = await quitBrowser(target.lock, target.userDataDir);
2859
+ if (!quit.ok) {
2860
+ throw new CdpError(quit.reason === "no-pid"
2861
+ ? "Could not identify the process holding the profile, so it was not signalled."
2862
+ : "Opera did not shut down within 20s.", "BROWSER_ERROR", [
2863
+ "Quit Opera yourself, then re-run the command",
2864
+ "Or run `opera-browser-cli launch-args` to restart it with a debugging port",
2865
+ ]);
2866
+ }
2867
+ const launched = await launchAttachableBrowser(process.env.OPERA_CLI_EXECUTABLE_PATH, target.userDataDir);
2868
+ if (!launched.ok || !launched.url) {
2869
+ throw new CdpError(`Opera was stopped but could not be restarted (${launched.reason ?? "unknown"}).`, "BROWSER_ERROR", [
2870
+ "Start Opera yourself, then re-run the command",
2871
+ "Run `opera-browser-cli launch-args` for the flags that let the CLI attach",
2872
+ "Run `opera-browser-cli doctor` to check the configured executable path",
2873
+ ]);
2874
+ }
2875
+ process.env.OPERA_CLI_BROWSER_URL = launched.url;
2876
+ process.stderr.write(`note: restarted Opera and attached at ${launched.url}\n`);
2877
+ }
2878
+ /**
2879
+ * Work out which browser this command should drive, before the bridge starts.
2880
+ *
2881
+ * Runs in the CLI rather than the bridge because resolving a conflict may need
2882
+ * to ask the user something, and the bridge is detached with no terminal.
2883
+ *
2884
+ * This runs even when a bridge is already alive. A bridge fixes its browser
2885
+ * (attach URL, profile, flags) at startup, so a healthy bridge is only "the
2886
+ * question is settled" while it is still driving the right browser. The case
2887
+ * that must never be silently skipped is a conflict: the user's own Opera is
2888
+ * running on the configured profile without a debug port. That used to be
2889
+ * bypassed whenever any bridge was running, so the restart prompt never fired
2890
+ * and the CLI kept driving a stale headless / separate-profile browser.
2891
+ */
2892
+ export async function preflightBrowser(argv, takeover) {
2893
+ const cmd = argv[0];
2894
+ if (cmd === undefined || BROWSER_SKIP_COMMANDS.has(cmd))
2895
+ return;
2896
+ // Explicitly pointed at a browser, or using an isolated profile that nothing
2897
+ // else can hold: either way there is no conflict possible.
2898
+ if (process.env.OPERA_CLI_BROWSER_URL)
2899
+ return;
2900
+ if (!process.env.OPERA_CLI_USER_DATA_DIR)
2901
+ return;
2902
+ const target = await resolveBrowserTarget({
2903
+ browserUrl: process.env.OPERA_CLI_BROWSER_URL,
2904
+ userDataDir: process.env.OPERA_CLI_USER_DATA_DIR,
2905
+ executablePath: process.env.OPERA_CLI_EXECUTABLE_PATH,
2906
+ });
2907
+ if (target.mode === "attach") {
2908
+ // A live debug port on the configured profile. Set the attach URL so any
2909
+ // freshly-started bridge (including a recovery rebuild) attaches to it.
2910
+ // This is inert when a healthy bridge is already driving this browser —
2911
+ // ensureBridge reuses it and the env is only read at bridge startup.
2912
+ process.env.OPERA_CLI_BROWSER_URL = target.url;
2913
+ return;
2914
+ }
2915
+ if (target.mode === "managed")
2916
+ return;
2917
+ // Conflict: a browser is holding the configured profile with no debug port.
2918
+ // Settle it even when a bridge is running — this is the case that used to be
2919
+ // silently skipped, leaving the user on a headless / separate-profile browser.
2920
+ await resolveBrowserConflict(target, takeover);
2921
+ // Takeover relaunched the user's browser with a debug port and set a fresh
2922
+ // BROWSER_URL, which an already-running bridge (it fixed its browser at
2923
+ // startup) would not reflect — so replace it. The separate-profile fallback
2924
+ // is different: a bridge that is already running was started on that separate
2925
+ // profile, so it should be reused, not reset (which would relaunch its
2926
+ // browser on every command). Only a takeover needs the bridge rebuilt.
2927
+ if (process.env.OPERA_CLI_BROWSER_URL) {
2928
+ if ((await findUsableBridge(candidatePorts())) !== null) {
2929
+ process.stderr.write("note: browser selection changed; resetting the running bridge.\n");
2930
+ await restartBridge();
2931
+ }
2932
+ }
2933
+ }
2934
+ const SETUP_SKIP_COMMANDS = new Set(["setup", "logs", "--help", "-h", "--version", "-v", "-V"]);
2935
+ /**
2936
+ * Configure a machine that has never been configured, in place, without asking.
2937
+ *
2938
+ * This replaces a stderr hint that told the user to go and run `setup` and then
2939
+ * carried on into a broken configuration anyway. Detection is unambiguous on
2940
+ * the platforms Opera ships for, so there is nothing to ask; and doing it here
2941
+ * rather than in `setup` means it works identically under an agent, which is
2942
+ * how most of these commands are actually run.
2943
+ */
2944
+ function ensureConfigured(argv) {
2230
2945
  const cmd = argv[0];
2231
2946
  if (cmd !== undefined && SETUP_SKIP_COMMANDS.has(cmd))
2232
2947
  return;
2233
- const configFile = join(homedir(), ".opera-browser-cli", "config");
2234
- if (!existsSync(configFile) && !process.env.OPERA_CLI_EXECUTABLE_PATH && !process.env.OPERA_CLI_BROWSER_URL) {
2235
- process.stderr.write("hint: run `opera-browser-cli setup` to configure (first-time setup)\n");
2948
+ const result = autoConfigure();
2949
+ if (result.status === "configured") {
2950
+ process.stderr.write(`configured: ${result.browser.name} (${result.browser.isNeon ? "Opera AI available" : "chat only — install Opera Neon for invoke-do/make/research"}) ` +
2951
+ "— run `opera-browser-cli setup` to change\n");
2952
+ return;
2236
2953
  }
2954
+ if (result.status === "no-browser" && cmd !== "doctor") {
2955
+ process.stderr.write("hint: no Opera installation found — run `opera-browser-cli setup`, or set OPERA_CLI_EXECUTABLE_PATH\n");
2956
+ }
2957
+ }
2958
+ export function extractTakeoverFlag(argv) {
2959
+ return {
2960
+ argv: argv.filter((arg) => arg !== "--takeover"),
2961
+ takeover: argv.includes("--takeover") || process.env.OPERA_CLI_TAKEOVER === "1",
2962
+ };
2237
2963
  }
2238
2964
  export async function main(options = {}) {
2239
2965
  loadConfig();
2240
2966
  const normalized = normalizeMainOptions(options);
2241
- const requestedArgv = resolveArgv(normalized.argv);
2242
- warnIfUnconfigured(requestedArgv);
2967
+ const rawArgv = resolveArgv(normalized.argv);
2968
+ const { argv: requestedArgv, takeover } = extractTakeoverFlag(rawArgv);
2969
+ ensureConfigured(requestedArgv);
2970
+ await preflightBrowser(requestedArgv, takeover);
2243
2971
  const homeFull = shouldRenderFullHome(requestedArgv);
2244
- const argv = homeFull ? [] : normalized.argv;
2972
+ // Only hand axi an explicit argv when we have one to give: either the caller
2973
+ // supplied it, or we stripped --takeover out of it. Otherwise let axi read
2974
+ // process.argv itself, which is the documented behaviour.
2975
+ const stripped = requestedArgv.length !== rawArgv.length;
2976
+ const passthroughArgv = normalized.argv !== undefined || stripped ? requestedArgv : undefined;
2977
+ const argv = homeFull ? [] : passthroughArgv;
2245
2978
  const stdout = wrapStdout(normalized.stdout, argv);
2246
2979
  await runAxiCli({
2247
2980
  ...(argv ? { argv } : {}),
@@ -2254,6 +2987,7 @@ export async function main(options = {}) {
2254
2987
  commands: COMMANDS,
2255
2988
  getCommandHelp,
2256
2989
  renderUnknownCommand,
2990
+ formatError: formatCliError,
2257
2991
  });
2258
2992
  }
2259
2993
  //# sourceMappingURL=cli.js.map