opera-devtools-mcp 0.6.1 → 0.8.0

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.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/build/src/McpContext.js +78 -41
  3. package/build/src/McpPage.js +5 -1
  4. package/build/src/ToolHandler.js +54 -76
  5. package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
  6. package/build/src/bin/chrome-devtools.js +56 -124
  7. package/build/src/bin/opera-browser-cli.js +102 -0
  8. package/build/src/bin/opera-devtools-cli-options.js +1 -1
  9. package/build/src/bin/opera-devtools-mcp-cli-options.js +1 -1
  10. package/build/src/bin/opera-devtools-mcp.js +20 -1
  11. package/build/src/browser.js +23 -25
  12. package/build/src/config/browser-options.js +126 -0
  13. package/build/src/config/category-options.js +81 -0
  14. package/build/src/{bin/chrome-devtools-cli-options.js → config/cli-options.js} +368 -26
  15. package/build/src/{bin/chrome-devtools-mcp-cli-options.js → config/mcp-options.js} +143 -164
  16. package/build/src/daemon/client.js +55 -40
  17. package/build/src/daemon/daemon.js +62 -39
  18. package/build/src/daemon/utils.js +6 -0
  19. package/build/src/devtools/DevtoolsUtils.js +27 -21
  20. package/build/src/formatters/NetworkFormatter.js +5 -2
  21. package/build/src/index.js +166 -100
  22. package/build/src/opera/branding.js +4 -2
  23. package/build/src/opera/browserActivity.js +62 -0
  24. package/build/src/opera/browserCleanup.js +123 -0
  25. package/build/src/opera/browserErrors.js +66 -0
  26. package/build/src/opera/browserFlags.js +184 -38
  27. package/build/src/opera/browserTarget.js +513 -0
  28. package/build/src/opera/cdpErrors.js +391 -0
  29. package/build/src/opera/cliCommands.js +378 -0
  30. package/build/src/opera/cliOutput.js +284 -0
  31. package/build/src/opera/compactSnapshot.js +525 -0
  32. package/build/src/opera/config.js +166 -0
  33. package/build/src/opera/daemonLifecycle.js +257 -0
  34. package/build/src/opera/daemonLog.js +103 -0
  35. package/build/src/opera/daemonPidFile.js +83 -0
  36. package/build/src/opera/daemonShutdown.js +66 -0
  37. package/build/src/opera/daemonSocket.js +87 -0
  38. package/build/src/opera/daemonStreaming.js +130 -0
  39. package/build/src/opera/daemonToolCall.js +26 -0
  40. package/build/src/opera/detect.js +114 -0
  41. package/build/src/opera/doctor.js +317 -0
  42. package/build/src/opera/envConfig.js +229 -0
  43. package/build/src/opera/launcherNotice.js +116 -0
  44. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  45. package/build/src/opera/logs.js +133 -0
  46. package/build/src/opera/mcpServerSupervisor.js +128 -0
  47. package/build/src/opera/migrationShared.js +164 -0
  48. package/build/src/opera/operaPages.js +56 -0
  49. package/build/src/opera/pageIdRouting.js +35 -0
  50. package/build/src/opera/pageRecovery.js +53 -0
  51. package/build/src/opera/profile.js +270 -0
  52. package/build/src/opera/refArgs.js +36 -0
  53. package/build/src/opera/serviceWorkerRetry.js +46 -4
  54. package/build/src/opera/setup.js +290 -0
  55. package/build/src/opera/skills/SKILL.md +160 -0
  56. package/build/src/opera/streamingTools.js +73 -0
  57. package/build/src/opera/suggestions.js +67 -0
  58. package/build/src/opera/toolHandlerHooks.js +25 -1
  59. package/build/src/opera/tools/opera.js +107 -38
  60. package/build/src/opera/urlResolver.js +69 -0
  61. package/build/src/opera/webStorageWarning.js +92 -0
  62. package/build/src/processors/HeapSnapshotManager.js +12 -0
  63. package/build/src/telemetry/ClearcutLogger.js +19 -6
  64. package/build/src/telemetry/transformation.js +4 -0
  65. package/build/src/telemetry/types.js +4 -0
  66. package/build/src/third_party/THIRD_PARTY_NOTICES +5 -5
  67. package/build/src/third_party/bundled-packages.json +3 -3
  68. package/build/src/third_party/devtools-formatter-worker.js +23 -0
  69. package/build/src/third_party/devtools-heap-snapshot-worker.js +101 -20
  70. package/build/src/third_party/index.js +15460 -14256
  71. package/build/src/third_party/issue-descriptions/federatedAuthRequestAccountsBlockedByConnectionAllowlist.md +1 -0
  72. package/build/src/third_party/issue-descriptions/federatedAuthRequestConfigBlockedByConnectionAllowlist.md +1 -0
  73. package/build/src/third_party/issue-descriptions/federatedAuthRequestIdTokenBlockedByConnectionAllowlist.md +1 -0
  74. package/build/src/third_party/issue-descriptions/federatedAuthRequestWellKnownBlockedByConnectionAllowlist.md +1 -0
  75. package/build/src/tools/ToolDefinition.js +15 -0
  76. package/build/src/tools/categories.js +0 -6
  77. package/build/src/tools/comments.js +90 -0
  78. package/build/src/tools/console.js +1 -1
  79. package/build/src/tools/emulation.js +1 -1
  80. package/build/src/tools/extensions.js +1 -1
  81. package/build/src/tools/memory.js +60 -6
  82. package/build/src/tools/network.js +2 -2
  83. package/build/src/tools/pages.js +26 -15
  84. package/build/src/tools/performance.js +4 -3
  85. package/build/src/tools/screencast.js +3 -2
  86. package/build/src/tools/screenshot.js +39 -8
  87. package/build/src/tools/script.js +17 -4
  88. package/build/src/tools/slim/tools.js +41 -33
  89. package/build/src/tools/snapshot.js +1 -1
  90. package/build/src/tools/tools.js +2 -0
  91. package/build/src/utils/WaitForHelper.js +12 -1
  92. package/build/src/utils/bytes.js +105 -0
  93. package/build/src/utils/url.js +79 -0
  94. package/build/src/version.js +1 -1
  95. package/package.json +29 -8
  96. package/build/src/bin/opera-devtools.js +0 -10
@@ -0,0 +1,270 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * Browser profile inspection — where a build keeps its profile, whether that
9
+ * profile is in use, and whether the browser holding it can be talked to.
10
+ *
11
+ * Ported from opera-browser-cli's `src/profile.ts` (Phase 1b brought only
12
+ * `defaultProfileDir`; Phase 2 adds the inspection half, which `doctor`'s
13
+ * profile check needs).
14
+ *
15
+ * Chromium refuses to start a second instance on a user-data-dir that is
16
+ * already open: it hands its command line to the running instance through a
17
+ * singleton socket and exits. Launching into a live profile therefore does not
18
+ * fail loudly, it fails as "the browser we asked for never appeared" — which is
19
+ * why this has to be detected before launch rather than diagnosed after it.
20
+ *
21
+ * Two files in the user-data-dir root tell us what we need:
22
+ *
23
+ * SingletonLock a symlink whose target is "<hostname>-<pid>" (POSIX),
24
+ * or "Mac-<pid>" (Chromium's macOS singleton variant).
25
+ * Present and live => the profile is in use.
26
+ * DevToolsActivePort written whenever the browser was started with
27
+ * --remote-debugging-port. Line 1 is the port.
28
+ *
29
+ * Neither file is authoritative on its own: SingletonLock outlives a crash, and
30
+ * DevToolsActivePort outlives a clean exit. Both are confirmed against the live
31
+ * system before being acted on.
32
+ */
33
+ import childProcess from 'node:child_process';
34
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs';
35
+ import { request } from 'node:http';
36
+ import { hostname } from 'node:os';
37
+ import { join } from 'node:path';
38
+ import { browserDisplayName } from './detect.js';
39
+ /** macOS bundle id that owns the profile of each build. */
40
+ const MAC_BUNDLE_ID = {
41
+ 'Opera Neon Developer': 'com.operasoftware.OperaNeonDeveloper',
42
+ 'Opera Neon': 'com.operasoftware.OperaNeon',
43
+ 'Opera GX': 'com.operasoftware.OperaGX',
44
+ Opera: 'com.operasoftware.Opera',
45
+ };
46
+ /**
47
+ * Where the given Opera build keeps its real profile, if we can find it.
48
+ * `platform` and `env` are test seams; production callers use the defaults.
49
+ */
50
+ export function defaultProfileDir(browserPath, home, platform = process.platform, env = process.env) {
51
+ // The build name decides both paths: it is the Windows profile folder
52
+ // verbatim, and the key of the macOS bundle id.
53
+ const build = browserDisplayName(browserPath ?? '');
54
+ let candidate;
55
+ if (platform === 'darwin') {
56
+ // Joined, not interpolated: the returned path is compared and printed, and
57
+ // a caller on the same machine builds the same location with `path.join`.
58
+ candidate = join(home, 'Library', 'Application Support', MAC_BUNDLE_ID[build]);
59
+ }
60
+ else if (platform === 'win32') {
61
+ const appData = env.APPDATA ?? `${home}\\AppData\\Roaming`;
62
+ candidate = `${appData}\\Opera Software\\${build}`;
63
+ }
64
+ else {
65
+ return null;
66
+ }
67
+ return existsSync(candidate) ? candidate : null;
68
+ }
69
+ /**
70
+ * Split a SingletonLock target into hostname and pid.
71
+ *
72
+ * The hostname routinely contains dashes ("Someones-MacBook-Pro-24601"), so the
73
+ * split has to come from the right.
74
+ */
75
+ export function parseSingletonTarget(target) {
76
+ const split = target.lastIndexOf('-');
77
+ if (split <= 0) {
78
+ return null;
79
+ }
80
+ const pid = Number.parseInt(target.slice(split + 1), 10);
81
+ if (!Number.isInteger(pid) || pid <= 0) {
82
+ return null;
83
+ }
84
+ return { hostname: target.slice(0, split), pid };
85
+ }
86
+ /**
87
+ * The names this machine's Chromium may have written into a `SingletonLock`.
88
+ *
89
+ * `os.hostname()` is `gethostname()`, and on macOS that is the short,
90
+ * ComputerName-derived name — "Mac", on a laptop that has been renamed — while
91
+ * Chromium's POSIX singleton asks `[[NSHost currentHost] name]`, the Bonjour
92
+ * name built from the LocalHostName ("opera-users-MacBook-Pro-2.local"). The
93
+ * two disagree on any renamed Mac, and a lock that names the other spelling is
94
+ * still this machine's. Treating it as foreign discards the pid, and the pid is
95
+ * the only thing that can restart the browser holding the profile: the user is
96
+ * told to quit Opera themselves for a browser we could have identified.
97
+ *
98
+ * `scutil` is asked per call rather than cached, so a test can stub the process
99
+ * seam and see its own answer; it costs a few milliseconds on a path that runs
100
+ * once per command, and a failure (no `scutil`, or a sandbox that blocks it)
101
+ * simply leaves the name out — the old behaviour.
102
+ */
103
+ export function localHostNames(platform = process.platform) {
104
+ const names = [hostname()];
105
+ if (platform === 'darwin') {
106
+ const local = readMacLocalHostName();
107
+ if (local !== null) {
108
+ names.push(local, `${local}.local`);
109
+ }
110
+ // Chromium's macOS singleton has also been seen writing this marker in
111
+ // place of a hostname; it means this machine either way.
112
+ names.push('Mac');
113
+ }
114
+ return [...new Set(names)];
115
+ }
116
+ function readMacLocalHostName() {
117
+ try {
118
+ const name = childProcess
119
+ .execFileSync('scutil', ['--get', 'LocalHostName'], {
120
+ encoding: 'utf8',
121
+ stdio: ['ignore', 'pipe', 'ignore'],
122
+ })
123
+ .trim();
124
+ return name && name !== 'not set' ? name : null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /**
131
+ * Whether a pid belongs to a running process on this machine.
132
+ *
133
+ * `EPERM` means it exists but belongs to another user, which still counts as
134
+ * alive; anything else means it is gone. Exported because the pid is polled
135
+ * while waiting for a signalled browser to exit (`browserTarget.ts`), and that
136
+ * poll has to agree with this one about the EPERM case.
137
+ */
138
+ export function isProcessAlive(pid) {
139
+ try {
140
+ process.kill(pid, 0);
141
+ return true;
142
+ }
143
+ catch (error) {
144
+ return error.code === 'EPERM';
145
+ }
146
+ }
147
+ /**
148
+ * Determine whether a user-data-dir is currently held by a running browser.
149
+ *
150
+ * A dangling lock reads as "free": Chromium cleans those up itself on the next
151
+ * launch, so treating one as a conflict would block a launch that would in fact
152
+ * succeed.
153
+ */
154
+ export function inspectProfileLock(userDataDir, aliveCheck = isProcessAlive, platform = process.platform, localNames = localHostNames(platform)) {
155
+ const lockPath = join(userDataDir, 'SingletonLock');
156
+ let target;
157
+ try {
158
+ // lstat, not stat: the link is expected to dangle after a crash, and a
159
+ // dangling symlink is exactly the case we want to report as free.
160
+ if (!lstatSync(lockPath).isSymbolicLink()) {
161
+ // Windows writes a regular file instead of a symlink. We can see that the
162
+ // profile is claimed but not by whom.
163
+ return { state: 'unknown', pid: null, hostname: null };
164
+ }
165
+ target = readlinkSync(lockPath);
166
+ }
167
+ catch {
168
+ return { state: 'free', pid: null, hostname: null };
169
+ }
170
+ const parsed = parseSingletonTarget(target);
171
+ if (parsed === null) {
172
+ return { state: 'unknown', pid: null, hostname: null };
173
+ }
174
+ // Another machine (a synced profile) or a local name we do not know: never
175
+ // signal its pid.
176
+ if (!localNames.includes(parsed.hostname)) {
177
+ return { state: 'unknown', pid: null, hostname: parsed.hostname };
178
+ }
179
+ if (!aliveCheck(parsed.pid)) {
180
+ return { state: 'free', pid: null, hostname: parsed.hostname };
181
+ }
182
+ return { state: 'locked', pid: parsed.pid, hostname: parsed.hostname };
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // DevTools endpoint
186
+ // ---------------------------------------------------------------------------
187
+ /** First line of DevToolsActivePort is the port; the second is a ws path. */
188
+ export function parseDevToolsActivePort(contents) {
189
+ const first = contents.split('\n')[0]?.trim() ?? '';
190
+ const port = Number.parseInt(first, 10);
191
+ if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
192
+ return null;
193
+ }
194
+ return port;
195
+ }
196
+ /** The debug port a running browser advertised, if it was given one. */
197
+ export function readDevToolsPort(userDataDir) {
198
+ const portFile = join(userDataDir, 'DevToolsActivePort');
199
+ try {
200
+ if (!existsSync(portFile)) {
201
+ return null;
202
+ }
203
+ return parseDevToolsActivePort(readFileSync(portFile, 'utf-8'));
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ /**
210
+ * Confirm a debug port is live and find out what is on the other end.
211
+ *
212
+ * DevToolsActivePort survives a clean exit, so a recorded port proves nothing
213
+ * until something answers on it.
214
+ */
215
+ export function probeDevToolsEndpoint(port, timeoutMs = 1500) {
216
+ const { promise, resolve } = Promise.withResolvers();
217
+ const req = request({
218
+ hostname: '127.0.0.1',
219
+ port,
220
+ path: '/json/version',
221
+ method: 'GET',
222
+ timeout: timeoutMs,
223
+ }, res => {
224
+ let body = '';
225
+ res.on('data', chunk => (body += chunk));
226
+ res.on('end', () => {
227
+ try {
228
+ const parsed = JSON.parse(body);
229
+ if (typeof parsed.Browser !== 'string') {
230
+ resolve(null);
231
+ return;
232
+ }
233
+ resolve({
234
+ browser: parsed.Browser,
235
+ isOpera: /opera|opr\//i.test(parsed.Browser),
236
+ });
237
+ }
238
+ catch {
239
+ resolve(null);
240
+ }
241
+ });
242
+ });
243
+ req.on('error', () => resolve(null));
244
+ req.on('timeout', () => {
245
+ req.destroy();
246
+ resolve(null);
247
+ });
248
+ req.end();
249
+ return promise;
250
+ }
251
+ /**
252
+ * The browser URL to attach to for this profile, or null when there is nothing
253
+ * live to attach to.
254
+ *
255
+ * This is the one signal that makes driving the user's own browser automatic:
256
+ * a browser started with `--remote-debugging-port` records its port in the
257
+ * profile, so every later command finds it without any configuration.
258
+ */
259
+ export async function findAttachableEndpoint(userDataDir) {
260
+ const port = readDevToolsPort(userDataDir);
261
+ if (port === null) {
262
+ return null;
263
+ }
264
+ const identity = await probeDevToolsEndpoint(port);
265
+ if (identity === null) {
266
+ return null;
267
+ }
268
+ return { url: `http://127.0.0.1:${port}`, identity };
269
+ }
270
+ //# sourceMappingURL=profile.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ import { refToMcp } from './compactSnapshot.js';
8
+ /**
9
+ * One command's ref-valued argument names, in table order: `uid` (click, fill,
10
+ * hover, take_screenshot, upload_file) plus `from_uid` and `to_uid` (drag).
11
+ *
12
+ * The naming rule is the whole contract — the descriptions upstream writes for
13
+ * these arguments all open with `The uid of `, and
14
+ * `tests/opera/refArgs.test.ts` pins the two derivations against each other, so
15
+ * an intake merge that renames or adds one fails there instead of quietly
16
+ * shipping a ref the CLI cannot translate.
17
+ */
18
+ export function refArgNames(args) {
19
+ return Object.keys(args).filter(name => name === 'uid' || name.endsWith('_uid'));
20
+ }
21
+ /**
22
+ * The command's arguments with every ref value in MCP wire form, so `@4.11` —
23
+ * the form the snapshot printed — reaches the daemon as `4_11`. A value that is
24
+ * not a string, or an argument that is not a ref, is passed through untouched.
25
+ */
26
+ export function normalizeRefArgs(args, values) {
27
+ const normalized = { ...values };
28
+ for (const name of refArgNames(args)) {
29
+ const value = normalized[name];
30
+ if (typeof value === 'string') {
31
+ normalized[name] = refToMcp(value);
32
+ }
33
+ }
34
+ return normalized;
35
+ }
36
+ //# sourceMappingURL=refArgs.js.map
@@ -4,9 +4,12 @@
4
4
  *
5
5
  * This file is an original work developed by Opera.
6
6
  */
7
+ import { ConnectionClosedError, TargetCloseError } from '../third_party/index.js';
8
+ import { logger } from '../utils/logger.js';
7
9
  /**
8
10
  * Opera's AI service worker may not be running yet when the first CDP command
9
- * arrives, so dispatches are retried with a fixed backoff.
11
+ * arrives, so a dispatch that failed *before reaching it* is retried with a
12
+ * fixed backoff.
10
13
  *
11
14
  * Exposed as a mutable object so tests can drive the retry loop without waiting
12
15
  * on real time. Faking timers is not a workable alternative here: sinon's fake
@@ -17,7 +20,42 @@ export const serviceWorkerRetryPolicy = {
17
20
  maxAttempts: 5,
18
21
  delayMs: 2500,
19
22
  };
20
- const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
23
+ function sleep(ms) {
24
+ const { promise, resolve } = Promise.withResolvers();
25
+ setTimeout(resolve, ms);
26
+ return promise;
27
+ }
28
+ /**
29
+ * The one failure a replay is allowed after: Opera's dispatcher had nothing to
30
+ * dispatch to, which is what a service worker that is still coming up reports.
31
+ * `opera/cdpErrors.ts` recognises the same wording to tell the user a browser
32
+ * without Opera AI apart from one that is merely not ready yet.
33
+ */
34
+ const NOT_DISPATCHED = /dispatcher was not able to dispatch|no target/i;
35
+ /**
36
+ * Whether a replay of this failure is safe.
37
+ *
38
+ * A dispatch is not idempotent — `chat`, `do`, `make` and `research` each open
39
+ * the tab they run in, and the AI run behind them may already be under way — so
40
+ * a retry is only defensible when the error proves the action never reached
41
+ * Opera's AI at all. Retrying every other failure multiplied real side effects:
42
+ * a chat that kept failing on a browser-side storage error was sent five times
43
+ * and left five tabs, and the last four failures were logged only as "attempt
44
+ * N/5", with the actual error buried.
45
+ *
46
+ * Connection-closed errors are permanent for a different reason (the CDP
47
+ * session is gone for good) and are matched by class rather than by `error.name`
48
+ * — the names are Puppeteer's to change, and a rename would silently turn every
49
+ * permanent failure back into five retries. The classes come through
50
+ * `third_party/index.ts`.
51
+ */
52
+ function isRetryableError(error) {
53
+ if (error instanceof TargetCloseError ||
54
+ error instanceof ConnectionClosedError) {
55
+ return false;
56
+ }
57
+ return NOT_DISPATCHED.test(error.message);
58
+ }
21
59
  export async function withServiceWorkerRetry(fn) {
22
60
  const { maxAttempts, delayMs } = serviceWorkerRetryPolicy;
23
61
  let lastError;
@@ -27,9 +65,13 @@ export async function withServiceWorkerRetry(fn) {
27
65
  }
28
66
  catch (e) {
29
67
  lastError = e;
30
- if (attempt < maxAttempts - 1) {
31
- await sleep(delayMs);
68
+ if (attempt >= maxAttempts - 1 || !isRetryableError(lastError)) {
69
+ break;
32
70
  }
71
+ // The line is what makes a replay visible in `opera-browser-cli logs`:
72
+ // the extra tab it may leave is the only other trace of it.
73
+ logger?.(`Opera dispatch attempt ${attempt + 1}/${maxAttempts} failed, retrying in ${delayMs}ms: ${lastError.message}`);
74
+ await sleep(delayMs);
33
75
  }
34
76
  }
35
77
  throw lastError;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * `setup` — interactive and non-interactive configuration.
9
+ *
10
+ * Ported from opera-browser-cli's `src/cli.ts`. The three settings it writes
11
+ * are the three the fork reads (`OPERA_CLI_EXECUTABLE_PATH`, `OPERA_CLI_HEADED`,
12
+ * `OPERA_CLI_USER_DATA_DIR`); hooks are not ported, so nothing here installs
13
+ * them.
14
+ *
15
+ * Two shape changes from the source:
16
+ *
17
+ * - The skill files are installed to `~/.claude/skills/opera-browser-cli/` and
18
+ * `~/.agents/skills/opera-browser-cli/`, from the `SKILL.md` kept at
19
+ * `src/opera/skills/` (copied to `build/src/opera/skills/` by
20
+ * `scripts/post-build.ts`, so it publishes with the rest of `build/src`).
21
+ * - A non-TTY falls back to the non-interactive path rather than refusing: the
22
+ * callers that most need `setup` are agents, provisioning scripts and
23
+ * containers, and none of them have a terminal.
24
+ */
25
+ import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
26
+ import { homedir } from 'node:os';
27
+ import { dirname, join } from 'node:path';
28
+ import { createInterface } from 'node:readline';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { CLI_BIN_NAME } from './branding.js';
31
+ import { encode, renderHelp, renderOutput } from './cliOutput.js';
32
+ import { writeConfigFile } from './config.js';
33
+ import { browserDisplayName, detectBrowsers, neonCandidatePaths, operaCandidatePaths, } from './detect.js';
34
+ import { getConfigFile, getStateDir, readConfigFile } from './envConfig.js';
35
+ import { defaultProfileDir } from './profile.js';
36
+ /** `--executable`, `--profile`, `--headed`/`--headless` each imply non-interactive. */
37
+ export function parseSetupArgs(args) {
38
+ let interactive = true;
39
+ let executable;
40
+ let profile;
41
+ let headed;
42
+ for (let i = 0; i < args.length; i++) {
43
+ switch (args[i]) {
44
+ case '--non-interactive':
45
+ case '--yes':
46
+ case '-y':
47
+ interactive = false;
48
+ break;
49
+ case '--executable':
50
+ if (i + 1 < args.length) {
51
+ executable = args[++i];
52
+ interactive = false;
53
+ }
54
+ break;
55
+ case '--profile':
56
+ if (i + 1 < args.length) {
57
+ profile = args[++i];
58
+ interactive = false;
59
+ }
60
+ break;
61
+ case '--headed':
62
+ headed = true;
63
+ interactive = false;
64
+ break;
65
+ case '--headless':
66
+ headed = false;
67
+ interactive = false;
68
+ break;
69
+ }
70
+ }
71
+ return { interactive, executable, profile, headed };
72
+ }
73
+ /**
74
+ * Where `SKILL.md` lives: the `skills/` directory beside this module.
75
+ *
76
+ * The layout is the same in the source tree (`src/opera/skills/`) and in the
77
+ * build output (`build/src/opera/skills/`, written by `scripts/post-build.ts`),
78
+ * so one candidate covers both.
79
+ */
80
+ function findSkillSource() {
81
+ const here = dirname(fileURLToPath(import.meta.url));
82
+ const candidate = join(here, 'skills', 'SKILL.md');
83
+ return existsSync(candidate) ? candidate : null;
84
+ }
85
+ /** Install SKILL.md for Claude Code and the generic cross-agent path. */
86
+ export function installSkillFiles(report, home = homedir()) {
87
+ const skillSrc = findSkillSource();
88
+ if (skillSrc === null) {
89
+ report('SKILL.md not found in src/opera/skills — skipping skill install');
90
+ return;
91
+ }
92
+ for (const { agent, dir } of [
93
+ { agent: 'Claude', dir: join(home, '.claude', 'skills') },
94
+ { agent: 'generic', dir: join(home, '.agents', 'skills') },
95
+ ]) {
96
+ // The skill directory name is the skill's own identity (see the
97
+ // `name:` frontmatter in SKILL.md), not the binary name.
98
+ const skillDst = join(dir, 'opera-browser-cli', 'SKILL.md');
99
+ mkdirSync(dirname(skillDst), { recursive: true });
100
+ copyFileSync(skillSrc, skillDst);
101
+ report(`Installed ${agent} skill -> ${skillDst}`);
102
+ }
103
+ }
104
+ /**
105
+ * Configure without prompting: detection plus whatever the flags override.
106
+ *
107
+ * `home` and `platform` are test seams; production callers use the defaults.
108
+ * Detection, the executable it finds and the profile that follows from it must
109
+ * all agree on one platform, so the seam is threaded rather than read again.
110
+ */
111
+ export async function setupNonInteractive(parsed, home = homedir(), platform = process.platform) {
112
+ const config = readConfigFile(getConfigFile(home));
113
+ const executable = parsed.executable ??
114
+ config.OPERA_CLI_EXECUTABLE_PATH ??
115
+ detectBrowsers(platform, home)[0]?.path;
116
+ if (executable) {
117
+ config.OPERA_CLI_EXECUTABLE_PATH = executable;
118
+ }
119
+ const headed = parsed.headed ?? (config.OPERA_CLI_HEADED === '1' || Boolean(executable));
120
+ if (headed) {
121
+ config.OPERA_CLI_HEADED = '1';
122
+ }
123
+ else {
124
+ delete config.OPERA_CLI_HEADED;
125
+ }
126
+ if (parsed.profile === 'skip') {
127
+ delete config.OPERA_CLI_USER_DATA_DIR;
128
+ }
129
+ else {
130
+ const profile = parsed.profile ??
131
+ config.OPERA_CLI_USER_DATA_DIR ??
132
+ defaultProfileDir(executable, home, platform) ??
133
+ join(getStateDir(home), 'profile');
134
+ config.OPERA_CLI_USER_DATA_DIR = profile;
135
+ }
136
+ writeConfigFile(config, home);
137
+ const notes = [];
138
+ installSkillFiles(line => notes.push(line), home);
139
+ const help = [
140
+ `Run \`${CLI_BIN_NAME} new_page https://example.com\` to start browsing`,
141
+ ];
142
+ if (!executable) {
143
+ help.unshift('No Opera installation found — set OPERA_CLI_EXECUTABLE_PATH or pass --executable <path>');
144
+ }
145
+ return renderOutput([
146
+ await encode({ config: getConfigFile(home), settings: config }),
147
+ notes.join('\n'),
148
+ renderHelp(help),
149
+ ]);
150
+ }
151
+ /**
152
+ * The interactive wizard. `home` and `platform` are test seams: the prompts are
153
+ * per-platform (which installs exist, and where each build keeps its profile),
154
+ * so callers that simulate a machine pin both rather than reading the host.
155
+ */
156
+ export async function handleSetup(args, home = homedir(), platform = process.platform) {
157
+ const parsed = parseSetupArgs(args);
158
+ // No terminal to prompt in is a reason to fall back, not to fail.
159
+ if (!parsed.interactive || !process.stdin.isTTY) {
160
+ return setupNonInteractive(parsed, home, platform);
161
+ }
162
+ const stateDir = getStateDir(home);
163
+ const configFile = getConfigFile(home);
164
+ const existing = readConfigFile(configFile);
165
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
166
+ const ask = (question) => new Promise(resolve => rl.question(question, resolve));
167
+ const config = { ...existing };
168
+ try {
169
+ process.stdout.write(`${CLI_BIN_NAME} setup\n\n`);
170
+ // 1. Browser executable path
171
+ const detectedNeons = neonCandidatePaths(platform, home).filter(path => existsSync(path));
172
+ const detectedOpera = operaCandidatePaths(platform, home).find(path => existsSync(path));
173
+ const currentExec = existing['OPERA_CLI_EXECUTABLE_PATH'];
174
+ if (detectedNeons.length > 0) {
175
+ // Always show the full list so the user can switch between versions.
176
+ // Mark whichever entry matches the current config (if any).
177
+ const currentIdx = detectedNeons.indexOf(currentExec ?? '');
178
+ process.stdout.write('Opera Neon installations found:\n');
179
+ detectedNeons.forEach((path, i) => {
180
+ const marker = i === currentIdx ? ' (current)' : '';
181
+ process.stdout.write(` [${i + 1}] ${browserDisplayName(path)}${marker}\n ${path}\n`);
182
+ });
183
+ const defaultIdx = currentIdx >= 0 ? currentIdx + 1 : 1;
184
+ const ans = (await ask(`Select [1-${detectedNeons.length}], enter a custom path, or "clear" to unset [${defaultIdx}]: `)).trim();
185
+ if (ans.toLowerCase() === 'clear') {
186
+ delete config['OPERA_CLI_EXECUTABLE_PATH'];
187
+ }
188
+ else if (!ans) {
189
+ config['OPERA_CLI_EXECUTABLE_PATH'] = detectedNeons[defaultIdx - 1];
190
+ }
191
+ else {
192
+ const idx = Number.parseInt(ans, 10);
193
+ if (Number.isFinite(idx) && idx >= 1 && idx <= detectedNeons.length) {
194
+ config['OPERA_CLI_EXECUTABLE_PATH'] = detectedNeons[idx - 1];
195
+ }
196
+ else {
197
+ config['OPERA_CLI_EXECUTABLE_PATH'] = ans; // custom path
198
+ }
199
+ }
200
+ }
201
+ else if (currentExec) {
202
+ // No auto-detected Neons but something is already configured.
203
+ process.stdout.write(`Browser binary: ${currentExec}\n`);
204
+ const ans = (await ask('Enter a new path, "clear" to remove, or press Enter to keep: ')).trim();
205
+ if (ans.toLowerCase() === 'clear') {
206
+ delete config['OPERA_CLI_EXECUTABLE_PATH'];
207
+ }
208
+ else if (ans) {
209
+ config['OPERA_CLI_EXECUTABLE_PATH'] = ans;
210
+ }
211
+ }
212
+ else {
213
+ // Nothing detected or configured.
214
+ process.stdout.write('Opera Neon not found. Install it from https://www.operaneon.com to enable the full Opera AI tool set.\n');
215
+ if (detectedOpera) {
216
+ const operaName = browserDisplayName(detectedOpera);
217
+ process.stdout.write(`\nFound ${operaName} at:\n ${detectedOpera}\n`);
218
+ const ans = (await ask(`Use ${operaName} as the browser? (invoke-do/make/research require Opera Neon) [Y/n]: `))
219
+ .trim()
220
+ .toLowerCase();
221
+ if (ans === '' || ans === 'y') {
222
+ config['OPERA_CLI_EXECUTABLE_PATH'] = detectedOpera;
223
+ }
224
+ }
225
+ }
226
+ // 2. Headed mode (defaults to Y so users see the browser they're driving)
227
+ const headedAns = (await ask('Run in headed (visible) mode? [Y/n]: '))
228
+ .trim()
229
+ .toLowerCase();
230
+ if (headedAns === 'n') {
231
+ delete config['OPERA_CLI_HEADED'];
232
+ }
233
+ else {
234
+ config['OPERA_CLI_HEADED'] = '1';
235
+ }
236
+ // 3. Persistent profile directory
237
+ const currentProfile = existing['OPERA_CLI_USER_DATA_DIR'] ?? '';
238
+ const detectedProfile = defaultProfileDir(config['OPERA_CLI_EXECUTABLE_PATH'], home, platform);
239
+ let profilePrompt;
240
+ let profileDefault;
241
+ let profileListShown = false;
242
+ if (currentProfile &&
243
+ detectedProfile &&
244
+ currentProfile !== detectedProfile) {
245
+ profileListShown = true;
246
+ process.stdout.write('Persistent profile directory:\n');
247
+ process.stdout.write(` [1] ${currentProfile} (current)\n`);
248
+ process.stdout.write(` [2] ${detectedProfile} (detected)\n`);
249
+ profilePrompt =
250
+ 'Select [1/2], enter a custom path, or "skip" to omit [1]: ';
251
+ profileDefault = currentProfile;
252
+ }
253
+ else {
254
+ profileDefault =
255
+ currentProfile || detectedProfile || join(stateDir, 'profile');
256
+ profilePrompt = `Persistent profile directory (blank to use default, "skip" to omit):\n [${profileDefault}]: `;
257
+ }
258
+ const profileAns = (await ask(profilePrompt)).trim();
259
+ if (profileAns.toLowerCase() === 'skip') {
260
+ delete config['OPERA_CLI_USER_DATA_DIR'];
261
+ }
262
+ else if (profileListShown && profileAns === '2' && detectedProfile) {
263
+ config['OPERA_CLI_USER_DATA_DIR'] = detectedProfile;
264
+ }
265
+ else if (profileListShown && (profileAns === '1' || !profileAns)) {
266
+ config['OPERA_CLI_USER_DATA_DIR'] = currentProfile;
267
+ }
268
+ else if (profileAns) {
269
+ config['OPERA_CLI_USER_DATA_DIR'] = profileAns;
270
+ }
271
+ else {
272
+ config['OPERA_CLI_USER_DATA_DIR'] = profileDefault;
273
+ }
274
+ }
275
+ finally {
276
+ rl.close();
277
+ }
278
+ writeConfigFile(config, home);
279
+ process.stdout.write(`\nSaved to ${configFile}\n`);
280
+ installSkillFiles(line => process.stdout.write(line + '\n'), home);
281
+ return renderOutput([
282
+ await encode({ config: configFile, settings: config }),
283
+ renderHelp([
284
+ `Run \`${CLI_BIN_NAME} --help\` to see all commands`,
285
+ `Run \`${CLI_BIN_NAME} setup\` again to reconfigure`,
286
+ `Run \`${CLI_BIN_NAME} new_page https://example.com\` to start browsing`,
287
+ ]),
288
+ ]);
289
+ }
290
+ //# sourceMappingURL=setup.js.map