opera-devtools-mcp 0.7.0 → 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.
- package/README.md +1 -1
- package/build/src/ToolHandler.js +9 -2
- package/build/src/bin/chrome-devtools.js +30 -97
- package/build/src/bin/opera-browser-cli.js +102 -0
- package/build/src/bin/opera-devtools-mcp.js +20 -1
- package/build/src/browser.js +18 -9
- package/build/src/daemon/client.js +46 -40
- package/build/src/daemon/daemon.js +62 -39
- package/build/src/opera/branding.js +4 -2
- package/build/src/opera/browserActivity.js +62 -0
- package/build/src/opera/browserCleanup.js +123 -0
- package/build/src/opera/browserErrors.js +66 -0
- package/build/src/opera/browserFlags.js +184 -38
- package/build/src/opera/browserTarget.js +513 -0
- package/build/src/opera/cdpErrors.js +391 -0
- package/build/src/opera/cliCommands.js +378 -0
- package/build/src/opera/cliOutput.js +284 -0
- package/build/src/opera/compactSnapshot.js +525 -0
- package/build/src/opera/config.js +166 -0
- package/build/src/opera/daemonLifecycle.js +257 -0
- package/build/src/opera/daemonLog.js +103 -0
- package/build/src/opera/daemonPidFile.js +83 -0
- package/build/src/opera/daemonShutdown.js +66 -0
- package/build/src/opera/daemonSocket.js +87 -0
- package/build/src/opera/daemonStreaming.js +130 -0
- package/build/src/opera/daemonToolCall.js +26 -0
- package/build/src/opera/detect.js +114 -0
- package/build/src/opera/doctor.js +317 -0
- package/build/src/opera/envConfig.js +229 -0
- package/build/src/opera/launcherNotice.js +116 -0
- package/build/src/opera/legacyBridgeCleanup.js +297 -0
- package/build/src/opera/logs.js +133 -0
- package/build/src/opera/mcpServerSupervisor.js +128 -0
- package/build/src/opera/migrationShared.js +164 -0
- package/build/src/opera/operaPages.js +56 -0
- package/build/src/opera/pageIdRouting.js +35 -0
- package/build/src/opera/pageRecovery.js +53 -0
- package/build/src/opera/profile.js +270 -0
- package/build/src/opera/refArgs.js +36 -0
- package/build/src/opera/serviceWorkerRetry.js +46 -4
- package/build/src/opera/setup.js +290 -0
- package/build/src/opera/skills/SKILL.md +160 -0
- package/build/src/opera/streamingTools.js +73 -0
- package/build/src/opera/suggestions.js +67 -0
- package/build/src/opera/toolHandlerHooks.js +25 -1
- package/build/src/opera/tools/opera.js +107 -38
- package/build/src/opera/urlResolver.js +69 -0
- package/build/src/opera/webStorageWarning.js +92 -0
- package/build/src/third_party/devtools-formatter-worker.js +1 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
- package/build/src/third_party/index.js +2 -1
- package/build/src/utils/url.js +6 -0
- package/build/src/version.js +1 -1
- package/package.json +12 -10
- 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
|