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/README.md +98 -16
- package/SKILL.md +31 -2
- package/dist/src/bridge.d.ts +69 -1
- package/dist/src/bridge.js +261 -24
- package/dist/src/bridge.js.map +1 -1
- package/dist/src/browser-target.d.ts +84 -0
- package/dist/src/browser-target.js +161 -0
- package/dist/src/browser-target.js.map +1 -0
- package/dist/src/cli.d.ts +72 -2
- package/dist/src/cli.js +947 -213
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +75 -8
- package/dist/src/client.js +671 -119
- package/dist/src/client.js.map +1 -1
- package/dist/src/config.d.ts +58 -0
- package/dist/src/config.js +171 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/detect.d.ts +21 -0
- package/dist/src/detect.js +76 -0
- package/dist/src/detect.js.map +1 -0
- package/dist/src/identity.d.ts +61 -0
- package/dist/src/identity.js +87 -0
- package/dist/src/identity.js.map +1 -0
- package/dist/src/profile.d.ts +80 -0
- package/dist/src/profile.js +187 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/version.d.ts +10 -0
- package/dist/src/version.js +34 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +2 -2
- package/dist/bin/opera-cli-bridge.d.ts +0 -2
- package/dist/bin/opera-cli-bridge.js +0 -7
- package/dist/bin/opera-cli-bridge.js.map +0 -1
- package/dist/bin/opera-cli.d.ts +0 -2
- package/dist/bin/opera-cli.js +0 -4
- package/dist/bin/opera-cli.js.map +0 -1
- package/openclaw/.claude/settings.local.json +0 -8
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,
|
|
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 =
|
|
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[
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1147
|
-
|
|
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
|
-
|
|
1151
|
-
|
|
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
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
:
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
|
1655
|
+
return { interactive, executable, profile, headed };
|
|
1434
1656
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1469
|
-
const
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1942
|
+
else if (bridge.stalePidFile) {
|
|
1683
1943
|
checks.push({
|
|
1684
1944
|
name: "bridge",
|
|
1685
|
-
status: "
|
|
1686
|
-
detail: `pid ${bridge.pid}
|
|
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}
|
|
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
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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({
|
|
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
|
-
`
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1890
|
-
*
|
|
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
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
"
|
|
1903
|
-
|
|
1904
|
-
|
|
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: "
|
|
2484
|
+
code: "AUTH_REQUIRED",
|
|
1917
2485
|
suggestions: (cmd) => [
|
|
1918
|
-
`
|
|
1919
|
-
|
|
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: "
|
|
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: "
|
|
2502
|
+
code: "AUTH_REQUIRED",
|
|
1935
2503
|
suggestions: (cmd) => [
|
|
1936
|
-
"
|
|
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: "
|
|
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
|
-
|
|
2229
|
-
|
|
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
|
|
2234
|
-
if (
|
|
2235
|
-
process.stderr.write(
|
|
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
|
|
2242
|
-
|
|
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
|
-
|
|
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
|