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,378 @@
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
+ * The fork's half of the CLI's command surface.
9
+ *
10
+ * `src/bin/chrome-devtools.ts` is upstream-owned, and the four commands this
11
+ * fork adds to it — `setup`, `doctor`, `logs`, `url` — plus the wrapper that
12
+ * turns one generated tool definition into a runnable yargs command used to be
13
+ * written inline there. Registering them from here instead leaves upstream's
14
+ * file with one call per group, so upstream churn in that area conflicts on a
15
+ * line rather than on a hundred and fifty of them. See `docs/UPSTREAM.md`,
16
+ * design rule 2.
17
+ *
18
+ * Everything moved here is what the fork owns: the flags the extra commands
19
+ * take, the re-expansion of parsed argv into the ported `parse*Args` parsers,
20
+ * and the exit-code / streaming plumbing around a tool call — which is where
21
+ * that plumbing belongs, next to `cdpErrors.ts` and `cliOutput.ts`. `start` is
22
+ * not ours (it prepends `--viaCli` and prints the disclaimers), so it is
23
+ * injected rather than reimplemented.
24
+ */
25
+ import process from 'node:process';
26
+ import { mcpOptions } from '../config/mcp-options.js';
27
+ import { handleResponse, sendCommand, verifyDaemonVersion, } from '../daemon/client.js';
28
+ import { isDaemonRunning, serializeArgs } from '../daemon/utils.js';
29
+ import { VERSION } from '../version.js';
30
+ import { CLI_BIN_NAME, MCP_BIN_NAME } from './branding.js';
31
+ import { classifyBrowserFailure } from './browserErrors.js';
32
+ import { settleBrowserConflict } from './browserTarget.js';
33
+ import { CdpError, EXIT_CODES, describeToolFailure, wrapAiToolError, } from './cdpErrors.js';
34
+ import { formatError, formatToolResult, parseSnapshotFromResponse, renderError, } from './cliOutput.js';
35
+ import { handleDoctor } from './doctor.js';
36
+ import { handleLogs } from './logs.js';
37
+ import { withoutRoutingPageId } from './pageIdRouting.js';
38
+ import { normalizeRefArgs } from './refArgs.js';
39
+ import { handleSetup } from './setup.js';
40
+ import { isOperaAiTool, operaAiTimeoutMs } from './streamingTools.js';
41
+ import { handleUrl } from './urlResolver.js';
42
+ /**
43
+ * The failure text a daemon reply carries, or undefined when the call was fine.
44
+ *
45
+ * A tool error arrives as `isError` on a successful reply, and is read here the
46
+ * same way the renderer reads it: one text, checked for the one condition the
47
+ * CLI can do something about.
48
+ */
49
+ async function toolFailureText(response) {
50
+ if (!response.success) {
51
+ return String(response.error);
52
+ }
53
+ const result = JSON.parse(response.result);
54
+ if (result.isError !== true) {
55
+ return undefined;
56
+ }
57
+ return await handleResponse(result, 'md');
58
+ }
59
+ /**
60
+ * Settle the failure a browser reported: the profile is known to be in use (so
61
+ * the lock file is not asked) or the browser the daemon drives is gone, and
62
+ * `--takeover` — read here rather than passed down because the command parser
63
+ * never saw it (`bin/opera-browser-cli.ts` consumes it) — decides whether a
64
+ * browser may be restarted.
65
+ */
66
+ function settleKnownConflict(reason) {
67
+ return sessionId => settleBrowserConflict(sessionId, {
68
+ takeover: process.env.OPERA_CLI_TAKEOVER === '1',
69
+ reason,
70
+ });
71
+ }
72
+ /**
73
+ * Register the commands the fork adds over upstream's `start`/`status`/`stop`
74
+ * and the generated tool table.
75
+ *
76
+ * The flags are declared here so `--help` documents them and `strict` accepts
77
+ * them; the ported `parse*Args` functions then read the re-expanded argv, which
78
+ * keeps one parser per command instead of two.
79
+ */
80
+ export function registerOperaCommands(y, deps) {
81
+ y.command('setup', `Configure the browser ${CLI_BIN_NAME} drives (interactive unless a flag is passed)`, y => y
82
+ .option('non-interactive', {
83
+ type: 'boolean',
84
+ description: 'Configure from detection and flags, without prompting',
85
+ })
86
+ .option('yes', {
87
+ type: 'boolean',
88
+ alias: 'y',
89
+ description: 'Same as --non-interactive',
90
+ })
91
+ .option('executable', {
92
+ type: 'string',
93
+ description: 'Path to the Opera binary to drive',
94
+ })
95
+ .option('profile', {
96
+ type: 'string',
97
+ description: 'Persistent profile directory, or "skip" to use an isolated one',
98
+ })
99
+ .option('headed', { type: 'boolean', description: 'Run in headed mode' })
100
+ .option('headless', {
101
+ type: 'boolean',
102
+ description: 'Run in headless mode',
103
+ })
104
+ .strict(), async (argv) => {
105
+ console.log(await handleSetup(reExpandArgs(argv, {
106
+ 'non-interactive': 'boolean',
107
+ yes: 'boolean',
108
+ executable: 'string',
109
+ profile: 'string',
110
+ headed: 'boolean',
111
+ headless: 'boolean',
112
+ })));
113
+ });
114
+ y.command('doctor', 'Inspect the configuration, browser, daemon, and log', y => y
115
+ .option('fix', {
116
+ type: 'boolean',
117
+ description: 'Repair what can be repaired without asking',
118
+ })
119
+ .strict(), async (argv) => {
120
+ console.log(await handleDoctor(argv.fix ? ['--fix'] : [], argv.sessionId));
121
+ });
122
+ y.command('logs', `Show the ${MCP_BIN_NAME} daemon log`, y => y
123
+ .option('lines', {
124
+ type: 'number',
125
+ alias: 'n',
126
+ description: 'Number of lines to show (default 50)',
127
+ })
128
+ .option('follow', {
129
+ type: 'boolean',
130
+ alias: 'f',
131
+ description: 'Stream new output until interrupted',
132
+ })
133
+ .option('errors', {
134
+ type: 'boolean',
135
+ description: 'Show only lines that look like failures',
136
+ })
137
+ .strict(), async (argv) => {
138
+ const output = await handleLogs(reExpandArgs(argv, {
139
+ lines: 'number',
140
+ follow: 'boolean',
141
+ errors: 'boolean',
142
+ }), argv.sessionId);
143
+ // `--follow` prints as it goes and returns nothing.
144
+ if (output) {
145
+ console.log(output);
146
+ }
147
+ });
148
+ y.command('url <target>', 'Resolve a $uN URL token or an @ref element to its full URL', y => y.positional('target', {
149
+ type: 'string',
150
+ describe: 'A $uN token from the urls: trailer, or an @ref like @2.4',
151
+ }), async (argv) => {
152
+ const sessionId = argv.sessionId;
153
+ const { output, exitCode } = await handleUrl([argv.target], () => fetchSnapshotSection(sessionId, argv, deps), sessionId);
154
+ if (exitCode === 0) {
155
+ console.log(output);
156
+ }
157
+ else {
158
+ console.error(output);
159
+ process.exitCode = exitCode;
160
+ }
161
+ });
162
+ }
163
+ /**
164
+ * Register one generated tool as a yargs command.
165
+ *
166
+ * The argument surface comes from the generated `commands` table; the handler
167
+ * brings the daemon up if it is not running, sends the call, and maps the
168
+ * result (or the failure) onto the fork's exit-code contract.
169
+ */
170
+ export function registerToolCommand(y, commandName, commandDef, deps) {
171
+ // The CLI never routes by pageId: drop the routing positional that
172
+ // chrome-devtools-mcp injects onto page-scoped commands
173
+ // (src/opera/pageIdRouting.ts).
174
+ const args = withoutRoutingPageId(commandDef.args);
175
+ const requiredArgNames = Object.keys(args).filter(name => args[name].required);
176
+ const optionalArgNames = Object.keys(args).filter(name => !args[name].required);
177
+ let commandStr = commandName;
178
+ for (const arg of requiredArgNames) {
179
+ commandStr += ` <${arg}>`;
180
+ }
181
+ for (const arg of optionalArgNames) {
182
+ commandStr += ` [--${arg}]`;
183
+ }
184
+ y.command(commandStr, commandDef.description, y => {
185
+ y.option('output-format', {
186
+ choices: ['md', 'json', 'toon'],
187
+ default: 'md',
188
+ });
189
+ // The two snapshot flags opera-browser-cli documents on every
190
+ // snapshot-returning command. `--raw` is the whole escape hatch: it
191
+ // disables compaction and the URL lookup table together.
192
+ y.option('full', {
193
+ type: 'boolean',
194
+ description: 'Show the complete snapshot, without truncation',
195
+ default: false,
196
+ });
197
+ y.option('raw', {
198
+ type: 'boolean',
199
+ description: 'Show the unprocessed MCP output (disables compact format and URL lookup table)',
200
+ default: false,
201
+ });
202
+ for (const [argName, opt] of Object.entries(args)) {
203
+ const type = opt.type === 'integer' || opt.type === 'number'
204
+ ? 'number'
205
+ : opt.type === 'boolean'
206
+ ? 'boolean'
207
+ : opt.type === 'array'
208
+ ? 'array'
209
+ : 'string';
210
+ if (opt.required) {
211
+ const options = {
212
+ describe: opt.description,
213
+ type: type,
214
+ };
215
+ if (opt.default !== undefined) {
216
+ options.default = opt.default;
217
+ }
218
+ if (opt.enum) {
219
+ options.choices = opt.enum;
220
+ }
221
+ y.positional(argName, options);
222
+ }
223
+ else {
224
+ const options = {
225
+ describe: opt.description,
226
+ type: type,
227
+ };
228
+ if (opt.default !== undefined) {
229
+ options.default = opt.default;
230
+ }
231
+ if (opt.enum) {
232
+ options.choices = opt.enum;
233
+ }
234
+ y.option(argName, options);
235
+ }
236
+ }
237
+ }, async (argv) => {
238
+ const sessionId = argv.sessionId;
239
+ // Streaming is per-tool, not per-request: only the Opera AI tools produce
240
+ // partial output, and only they get the long timeout — the same timeout
241
+ // the daemon applies around the same call (`operaAiTimeoutMs`).
242
+ const streaming = isOperaAiTool(commandName);
243
+ try {
244
+ const versionWarningPromise = isDaemonRunning(sessionId)
245
+ ? verifyDaemonVersion(sessionId, VERSION)
246
+ : Promise.resolve(undefined);
247
+ if (!isDaemonRunning(sessionId)) {
248
+ await deps.start(serializeArgs(mcpOptions, argv), sessionId);
249
+ }
250
+ const rawArgs = {};
251
+ for (const argName of Object.keys(args)) {
252
+ if (argName in argv) {
253
+ rawArgs[argName] = argv[argName];
254
+ }
255
+ }
256
+ // The snapshot prints refs as `@4.11`; the MCP tools take `4_11`
257
+ // (src/opera/refArgs.ts).
258
+ const commandArgs = normalizeRefArgs(args, rawArgs);
259
+ const invoke = () => sendCommand({
260
+ method: 'invoke_tool',
261
+ tool: commandName,
262
+ args: commandArgs,
263
+ }, sessionId, operaAiTimeoutMs(commandName), streaming
264
+ ? (chunk) => process.stderr.write(chunk + '\n')
265
+ : undefined);
266
+ let response = await invoke();
267
+ let failureText = await toolFailureText(response);
268
+ // A browser failure the CLI can settle never reached the tool — the
269
+ // daemon could not get a browser to run it on — so the only thing to do
270
+ // is settle it and try once more. This is the case a preflight cannot
271
+ // see: a daemon pinned to a profile that a browser now holds, or to an
272
+ // attach URL whose browser is gone, and only the failure says so.
273
+ const browserFailure = failureText === undefined
274
+ ? undefined
275
+ : classifyBrowserFailure(failureText);
276
+ if (browserFailure !== undefined) {
277
+ await (deps.settleConflict ?? settleKnownConflict(browserFailure))(sessionId);
278
+ if (!isDaemonRunning(sessionId)) {
279
+ await deps.start(serializeArgs(mcpOptions, argv), sessionId);
280
+ }
281
+ response = await invoke();
282
+ failureText = await toolFailureText(response);
283
+ }
284
+ if (response.success) {
285
+ const result = JSON.parse(response.result);
286
+ const format = argv['output-format'];
287
+ if (result.isError === true && format !== 'json') {
288
+ // A tool error is a failure like any other: the same `error`/`code`
289
+ // document, the same suggestions, and the same stderr the daemon's
290
+ // own failures get — not the raw MCP text on stdout with an exit
291
+ // code and nothing structured to branch on. `json` stays the raw
292
+ // passthrough, because the MCP result *is* the machine-readable
293
+ // form of the failure.
294
+ const failure = describeToolFailure(commandName, failureText ?? (await handleResponse(result, 'md')));
295
+ console.error(await renderError(failure.message, failure.code, failure.suggestions));
296
+ process.exitCode = EXIT_CODES[failure.code];
297
+ }
298
+ else {
299
+ const output = await formatToolResult(result, format, {
300
+ command: commandName,
301
+ sessionId,
302
+ url: typeof commandArgs.url === 'string'
303
+ ? commandArgs.url
304
+ : undefined,
305
+ full: argv.full === true,
306
+ raw: argv.raw === true,
307
+ }, handleResponse);
308
+ console.log(output);
309
+ if (result.isError === true) {
310
+ process.exitCode =
311
+ EXIT_CODES[describeToolFailure(commandName, output).code];
312
+ }
313
+ }
314
+ }
315
+ else {
316
+ const failure = describeToolFailure(commandName, failureText ?? String(response.error));
317
+ console.error(await renderError(failure.message, failure.code, failure.suggestions));
318
+ process.exitCode = EXIT_CODES[failure.code];
319
+ }
320
+ const versionWarning = await versionWarningPromise;
321
+ if (versionWarning) {
322
+ console.warn(versionWarning);
323
+ }
324
+ }
325
+ catch (error) {
326
+ const { output, exitCode } = await formatError(wrapAiToolError(commandName, error));
327
+ console.error(output);
328
+ process.exitCode = exitCode;
329
+ }
330
+ });
331
+ }
332
+ /**
333
+ * The `--flag value` argv the ported command parsers read. yargs consumes
334
+ * flags, so a command whose parser is the source's own has to see them again.
335
+ */
336
+ function reExpandArgs(argv, spec, positionals = []) {
337
+ const args = [];
338
+ for (const name of positionals) {
339
+ const value = argv[name];
340
+ if (value !== undefined) {
341
+ args.push(String(value));
342
+ }
343
+ }
344
+ for (const [name, type] of Object.entries(spec)) {
345
+ const value = argv[name];
346
+ if (value === undefined || value === false) {
347
+ continue;
348
+ }
349
+ if (type === 'boolean') {
350
+ args.push(`--${name}`);
351
+ }
352
+ else {
353
+ args.push(`--${name}`, String(value));
354
+ }
355
+ }
356
+ return args;
357
+ }
358
+ /**
359
+ * A fresh `take_snapshot` section, for a command that needs the tree but was
360
+ * not itself a snapshot command. Injected into `handleUrl` so the resolver does
361
+ * not have to know how to bring a daemon up.
362
+ */
363
+ async function fetchSnapshotSection(sessionId, argv, deps) {
364
+ if (!isDaemonRunning(sessionId)) {
365
+ await deps.start(serializeArgs(mcpOptions, argv), sessionId);
366
+ }
367
+ const response = await sendCommand({ method: 'invoke_tool', tool: 'take_snapshot', args: {} }, sessionId);
368
+ if (!response.success) {
369
+ throw new CdpError(String(response.error), 'BROWSER_ERROR');
370
+ }
371
+ const result = JSON.parse(response.result);
372
+ const section = parseSnapshotFromResponse(await handleResponse(result, 'md'));
373
+ if (section === null) {
374
+ throw new CdpError('No page snapshot available — launch a page first', 'BROWSER_ERROR', [`Run \`${CLI_BIN_NAME} new_page https://example.com\` first`]);
375
+ }
376
+ return section;
377
+ }
378
+ //# sourceMappingURL=cliCommands.js.map
@@ -0,0 +1,284 @@
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
+ * The CLI's output path: compaction, the URL lookup table, suggestions, and the
9
+ * TOON-structured blocks they are delivered in.
10
+ *
11
+ * Ported from opera-browser-cli's `src/cli.ts` (`renderHelp`, `renderOutput`,
12
+ * `renderError`, `formatPageOutput`, `parseSnapshotFromResponse`,
13
+ * `stripSnapshotHeader`). The source compacted in the CLI, not in the bridge,
14
+ * and this keeps that split: the MCP server's response is unchanged and the
15
+ * trick (a third of the tokens, and refs an agent can copy straight into the
16
+ * next command) is applied on the way out.
17
+ *
18
+ * Everything here is Opera-owned, so the literal binary name is the branding
19
+ * constant rather than a copy of the source string.
20
+ */
21
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { getToonEncode } from '../third_party/index.js';
24
+ import { assertValidSessionId } from '../daemon/utils.js';
25
+ import { CLI_BIN_NAME } from './branding.js';
26
+ import { applyUrlLut, compactSnapshot, countRefs, extractPageOrigin, extractPageUrl, extractTitle, truncateSnapshot, } from './compactSnapshot.js';
27
+ import { CdpError, checkAiResultForCdpError, exitCodeFor } from './cdpErrors.js';
28
+ import { getStateDir } from './envConfig.js';
29
+ import { getSuggestions } from './suggestions.js';
30
+ import { isOperaAiTool } from './streamingTools.js';
31
+ // ---------------------------------------------------------------------------
32
+ // TOON
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * `@toon-format/toon` is an optional peer dependency, so it is loaded on first
36
+ * use rather than at module load: a user who never asks for structured output
37
+ * never needs it installed. Mirrors `McpResponse.ts`'s `getToonEncode` call.
38
+ */
39
+ export async function encode(value) {
40
+ try {
41
+ const toonEncode = await getToonEncode();
42
+ return toonEncode(value);
43
+ }
44
+ catch {
45
+ throw new CdpError('The `@toon-format/toon` package is required for TOON output. ' +
46
+ 'Install the peer dependency: npm install @toon-format/toon (add -g if installed globally).', 'BRIDGE_NOT_READY');
47
+ }
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Blocks
51
+ // ---------------------------------------------------------------------------
52
+ /** `help[N]:` followed by two-space-indented lines — the source's shape. */
53
+ export function renderHelp(lines) {
54
+ if (lines.length === 0) {
55
+ return '';
56
+ }
57
+ const indented = lines.map(line => ` ${line}`).join('\n');
58
+ return `help[${lines.length}]:\n${indented}`;
59
+ }
60
+ /** Joins non-empty blocks with a newline. */
61
+ export function renderOutput(blocks) {
62
+ return blocks.filter(Boolean).join('\n');
63
+ }
64
+ /** `encode({error, code})` plus a help block, so a failure is still structured. */
65
+ export async function renderError(message, code, suggestions = []) {
66
+ const blocks = [await encode({ error: message, code })];
67
+ if (suggestions.length > 0) {
68
+ blocks.push(renderHelp(suggestions));
69
+ }
70
+ return blocks.join('\n');
71
+ }
72
+ /**
73
+ * The output and exit code for a failure, so a caller can branch on *why*
74
+ * without parsing the message.
75
+ */
76
+ export async function formatError(error) {
77
+ const code = error instanceof CdpError ? error.code : 'UNKNOWN';
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ const suggestions = error instanceof CdpError ? error.suggestions : [];
80
+ return {
81
+ output: await renderError(message, code, suggestions),
82
+ exitCode: exitCodeFor(error),
83
+ };
84
+ }
85
+ /**
86
+ * Where `url $uN` finds the token assignments the agent actually saw.
87
+ *
88
+ * Written on every rendered snapshot: the map is derived from the *truncated*
89
+ * body, so re-deriving it from a fresh full snapshot would hand out different
90
+ * token IDs than the ones in the output the user is quoting.
91
+ *
92
+ * Scoped per session, like every other piece of daemon state (pid file, socket,
93
+ * log). Two invocations with different `--sessionId` values drive two browsers,
94
+ * and one shared file let `url $u3` in one shell answer with the URL the other
95
+ * shell's page had assigned to `$u3`. The default session (`''`) has exactly one
96
+ * daemon, so it keeps one file.
97
+ */
98
+ function getUrlMapFile(sessionId) {
99
+ assertValidSessionId(sessionId);
100
+ const suffix = sessionId ? `-${sessionId}` : '';
101
+ return join(getStateDir(), `last-url-map${suffix}.json`);
102
+ }
103
+ export function writeUrlMapSidecar(urlMap, sessionId, origin) {
104
+ try {
105
+ // The state dir is normally created by autoconfiguration, but a machine
106
+ // configured entirely through `OPERA_CLI_*` in the environment never runs
107
+ // it — and a missing dir here must not cost the user `url`.
108
+ mkdirSync(getStateDir(), { recursive: true, mode: 0o700 });
109
+ writeFileSync(getUrlMapFile(sessionId), JSON.stringify({ origin, tokens: Object.fromEntries(urlMap) }));
110
+ }
111
+ catch {
112
+ // Non-fatal: `url` falls back to re-deriving the map if the write fails.
113
+ }
114
+ }
115
+ export function loadUrlMapSidecar(sessionId) {
116
+ try {
117
+ // The wire shape, not `UrlMapSidecar`: `tokens` is a plain object on disk.
118
+ const stored = JSON.parse(readFileSync(getUrlMapFile(sessionId), 'utf-8'));
119
+ if (stored === null || typeof stored !== 'object') {
120
+ return null;
121
+ }
122
+ // A file written before the origin was recorded is a flat token map; read
123
+ // it as tokens-only so an upgrade does not break a token mid-session.
124
+ if (!('tokens' in stored)) {
125
+ return {
126
+ origin: null,
127
+ tokens: new Map(Object.entries(stored)),
128
+ };
129
+ }
130
+ return {
131
+ origin: typeof stored.origin === 'string' ? stored.origin : null,
132
+ tokens: new Map(Object.entries((stored.tokens ?? {}))),
133
+ };
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // Snapshot extraction
141
+ // ---------------------------------------------------------------------------
142
+ const SNAPSHOT_MARKER = '## Latest page snapshot';
143
+ /**
144
+ * Slice the `## Latest page snapshot` section out of an MCP response. Returns
145
+ * null when the response carries no snapshot — most tools do not.
146
+ */
147
+ export function parseSnapshotFromResponse(response) {
148
+ const idx = response.indexOf(SNAPSHOT_MARKER);
149
+ if (idx === -1) {
150
+ return null;
151
+ }
152
+ const after = response.slice(idx + SNAPSHOT_MARKER.length);
153
+ // The snapshot follows the header line, possibly after a blank line.
154
+ const trimmed = after.replace(/^\s*\n/, '');
155
+ // It ends at the next `## ` heading.
156
+ const nextHeading = trimmed.indexOf('\n## ');
157
+ return nextHeading === -1
158
+ ? trimmed.trimEnd()
159
+ : trimmed.slice(0, nextHeading).trimEnd();
160
+ }
161
+ /**
162
+ * Everything before the actual accessibility tree, stripped: the MCP preamble
163
+ * and headers a caller may have wrapped the tree in.
164
+ */
165
+ export function stripSnapshotHeader(text) {
166
+ const lines = text.split('\n');
167
+ const treeStart = lines.findIndex(line => /\bRootWebArea\b|\buid=/.test(line));
168
+ const result = treeStart > 0
169
+ ? lines.slice(treeStart).join('\n')
170
+ : text.replace(/^[\s\S]*?##\s+Latest page snapshot\s*\n/, '');
171
+ // Name the command users actually run, not the internal tool.
172
+ return result.replace(/Call list_pages\b/g, `Run \`${CLI_BIN_NAME} list_pages\``);
173
+ }
174
+ /**
175
+ * Whether a section is the accessibility-tree grammar `compactSnapshot` was
176
+ * written against. A `--experimentalDataFormat=toon` (or gcf) snapshot is a
177
+ * different language entirely, and compacting it would corrupt it, so it is
178
+ * passed through untouched.
179
+ */
180
+ function isAccessibilityTree(section) {
181
+ return /^\s*(?:uid=\S+|@\S+)\s+\S/m.test(section);
182
+ }
183
+ /**
184
+ * Compact, truncate, then apply the URL LUT — in that order, because the LUT
185
+ * trailer must only reference URLs still visible in the truncated body.
186
+ */
187
+ function buildPageOutput(snapshot, context) {
188
+ const { command, url, full = false, raw = false, sessionId } = context;
189
+ const tree = raw ? snapshot : compactSnapshot(snapshot);
190
+ const page = {};
191
+ const title = extractTitle(tree);
192
+ if (title) {
193
+ page.title = title;
194
+ }
195
+ // A command that navigated knows the URL it went to; every other command takes
196
+ // the page's own, read from the *raw* tree (compaction has already replaced
197
+ // the root node's url= with the origin-relative `/`).
198
+ const pageUrl = url ?? extractPageUrl(snapshot);
199
+ if (pageUrl) {
200
+ page.url = pageUrl;
201
+ }
202
+ page.refs = countRefs(tree);
203
+ const truncation = truncateSnapshot(tree, full, raw ? 16000 : 12000);
204
+ const lut = raw
205
+ ? { body: truncation.text, trailer: '', urlMap: new Map() }
206
+ : applyUrlLut(truncation.text);
207
+ // The origin comes from the *raw* tree: a later `compactSnapshot`-shaped tree
208
+ // (and so the body the map is derived from) has the root node's url= already
209
+ // shortened to `/`.
210
+ writeUrlMapSidecar(lut.urlMap, sessionId, extractPageOrigin(snapshot));
211
+ const suggestions = getSuggestions({ command, url, snapshot: tree });
212
+ if (truncation.truncated) {
213
+ suggestions.push(`Run \`${CLI_BIN_NAME} ${command}${url ? ' ' + url : ''} --full\` to see complete snapshot`);
214
+ }
215
+ return {
216
+ page,
217
+ snapshot: {
218
+ body: lut.body,
219
+ trailer: lut.trailer,
220
+ truncated: truncation.truncated,
221
+ totalLength: truncation.totalLength,
222
+ },
223
+ suggestions,
224
+ };
225
+ }
226
+ /** `page` metadata block, the `snapshot:` block, then the suggestions. */
227
+ async function renderPageOutput(out) {
228
+ let snapshotBlock = `snapshot:\n${out.snapshot.body.trimEnd()}`;
229
+ if (out.snapshot.trailer) {
230
+ snapshotBlock += `\n${out.snapshot.trailer}`;
231
+ }
232
+ if (out.snapshot.truncated) {
233
+ snapshotBlock += `\n ... (truncated, ${out.snapshot.totalLength} chars total)`;
234
+ }
235
+ return renderOutput([
236
+ await encode({ page: out.page }),
237
+ snapshotBlock,
238
+ renderHelp(out.suggestions),
239
+ ]);
240
+ }
241
+ /** The same content as one structured document, for `--output-format toon`. */
242
+ async function renderPageToon(out) {
243
+ return encode({
244
+ page: out.page,
245
+ snapshot: {
246
+ body: out.snapshot.body.trimEnd(),
247
+ ...(out.snapshot.trailer ? { urls: out.snapshot.trailer } : {}),
248
+ ...(out.snapshot.truncated
249
+ ? { truncated: true, totalLength: out.snapshot.totalLength }
250
+ : {}),
251
+ },
252
+ ...(out.suggestions.length > 0 ? { help: out.suggestions } : {}),
253
+ });
254
+ }
255
+ /**
256
+ * The text of a tool result, formatted for a terminal or for a machine.
257
+ *
258
+ * `json` is delegated to the daemon client's own `handleResponse` unchanged.
259
+ * For `md` and `toon` the response is flattened to text exactly once — so an
260
+ * image result is spilled to a file once, not twice — and only a response that
261
+ * carries an accessibility tree gets the compaction treatment.
262
+ *
263
+ * The flattening is also where Opera's "successful" error results are caught:
264
+ * the browser extension reports an unsigned user, or a browser with no Opera AI
265
+ * extension, as ordinary text on a successful call, and `checkAiResultForCdpError`
266
+ * is the only thing that can turn that into an exit code the caller can branch
267
+ * on. It throws, so the caller's error path handles it like any other failure.
268
+ */
269
+ export async function formatToolResult(result, format, context, handleResponse) {
270
+ if (format === 'json') {
271
+ return handleResponse(result, 'json');
272
+ }
273
+ const text = await handleResponse(result, 'md');
274
+ if (result.isError !== true && isOperaAiTool(context.command)) {
275
+ checkAiResultForCdpError(context.command, text);
276
+ }
277
+ const section = result.isError === true ? null : parseSnapshotFromResponse(text);
278
+ if (section === null || !isAccessibilityTree(section)) {
279
+ return format === 'toon' ? encode({ result: text }) : text;
280
+ }
281
+ const out = buildPageOutput(section, context);
282
+ return format === 'toon' ? renderPageToon(out) : renderPageOutput(out);
283
+ }
284
+ //# sourceMappingURL=cliOutput.js.map