scenescout 1.1.0 → 1.2.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/CHANGELOG.md +16 -0
- package/README.md +88 -20
- package/dist/browsers.js +185 -0
- package/dist/cli.js +167 -39
- package/dist/clients.js +213 -0
- package/dist/engine/browser.js +21 -17
- package/dist/engine/hover.js +42 -0
- package/dist/engine/launch.js +12 -5
- package/dist/engine/probes.js +4 -1
- package/dist/installer.js +24 -8
- package/dist/mcp-server.js +54 -4
- package/dist/playbook.js +83 -0
- package/package.json +15 -4
- package/skills/scenescout/SKILL.md +3 -3
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* scenescout scan <projectPath> Print project discovery results
|
|
6
6
|
* scenescout serve Run the MCP server on stdio
|
|
7
|
-
* scenescout install Install the skill, download
|
|
7
|
+
* scenescout install Install the skill, download the browser, register the MCP server
|
|
8
8
|
* scenescout doctor Check every piece of the setup and say how to fix what is missing
|
|
9
9
|
*/
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
@@ -13,6 +13,8 @@ import { createRequire } from "node:module";
|
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, defaultEngine, launchTarget, parseBrowserSelection, playwrightInstallArgs, } from "./browsers.js";
|
|
17
|
+
import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
|
|
16
18
|
import { diagnose, installSkill, launchCommand, manualRegisterCommand, registerMcp, resolveClaudeDir, spawnRunner } from "./installer.js";
|
|
17
19
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME } from "./engine/memory.js";
|
|
18
20
|
import { formatScan, scanProject } from "./scan.js";
|
|
@@ -26,7 +28,12 @@ Usage:
|
|
|
26
28
|
scenescout serve Run the MCP server (stdio)
|
|
27
29
|
scenescout install One-step setup: skill + Chromium + MCP registration
|
|
28
30
|
(--skip-browser, --no-register to opt out of a step;
|
|
29
|
-
--browser-only when the skill and server came from a plugin
|
|
31
|
+
--browser-only when the skill and server came from a plugin;
|
|
32
|
+
--browsers <list> to choose what to download: chromium (default),
|
|
33
|
+
chromium-headless-shell, firefox, webkit, all — comma-separated)
|
|
34
|
+
(--client <list> to set up another MCP client instead of, or as well as,
|
|
35
|
+
Claude Code: claude-code (default), cursor, vscode, codex, gemini,
|
|
36
|
+
copilot, windsurf — comma-separated)
|
|
30
37
|
scenescout doctor Check the setup and print the fix for anything missing
|
|
31
38
|
(--engine: only node, the build and the browser — for plugin
|
|
32
39
|
installs and other MCP clients)
|
|
@@ -109,23 +116,83 @@ function status(projectPath) {
|
|
|
109
116
|
}
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
|
-
/**
|
|
113
|
-
async function
|
|
119
|
+
/** Which browser builds are on disk, going by the paths Playwright reports for the version we depend on. */
|
|
120
|
+
async function presentBrowsers() {
|
|
121
|
+
const executables = { chromium: null, firefox: null, webkit: null };
|
|
114
122
|
try {
|
|
115
|
-
const
|
|
116
|
-
|
|
123
|
+
const playwright = await import("playwright");
|
|
124
|
+
for (const name of BROWSER_ENGINES)
|
|
125
|
+
executables[name] = playwright[name].executablePath() || null;
|
|
117
126
|
}
|
|
118
127
|
catch {
|
|
119
|
-
|
|
128
|
+
// Playwright cannot be loaded: every build reads as absent, which is what doctor should say.
|
|
120
129
|
}
|
|
130
|
+
return browserPresence(executables);
|
|
121
131
|
}
|
|
122
|
-
/** Download
|
|
123
|
-
function
|
|
132
|
+
/** Download browser builds through the playwright CLI that ships with our own dependency. */
|
|
133
|
+
function downloadBrowsers(targets) {
|
|
124
134
|
const require = createRequire(import.meta.url);
|
|
125
135
|
const cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
|
|
126
|
-
const r = spawnSync(process.execPath, [cli,
|
|
136
|
+
const r = spawnSync(process.execPath, [cli, ...playwrightInstallArgs(targets)], { stdio: "inherit" });
|
|
127
137
|
return r.status === 0;
|
|
128
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* The value of a flag written as `--name x` or `--name=x`. Undefined when the
|
|
141
|
+
* flag is absent; empty when it was given no value, which includes being
|
|
142
|
+
* followed by another flag.
|
|
143
|
+
*/
|
|
144
|
+
function flagValue(flags, name) {
|
|
145
|
+
const inline = flags.find((f) => f.startsWith(`${name}=`));
|
|
146
|
+
if (inline)
|
|
147
|
+
return inline.slice(name.length + 1);
|
|
148
|
+
const at = flags.indexOf(name);
|
|
149
|
+
if (at < 0)
|
|
150
|
+
return undefined;
|
|
151
|
+
const next = flags[at + 1];
|
|
152
|
+
return next === undefined || next.startsWith("--") ? "" : next;
|
|
153
|
+
}
|
|
154
|
+
function browsersFlag(flags) {
|
|
155
|
+
// `--browser-only` is a different flag; a bare `--browser` is a slip that would otherwise be ignored and download Chromium.
|
|
156
|
+
const slip = flags.find((f) => f === "--browser" || f.startsWith("--browser="));
|
|
157
|
+
if (slip)
|
|
158
|
+
throw new Error(`unknown flag ${slip.split("=")[0]} — did you mean --browsers?`);
|
|
159
|
+
return flagValue(flags, "--browsers");
|
|
160
|
+
}
|
|
161
|
+
/** The `code` command on PATH, with its real path: the real path is what tells VS Code from a fork. */
|
|
162
|
+
function codeOnPath() {
|
|
163
|
+
const names = process.platform === "win32" ? ["code.cmd", "code.exe"] : ["code"];
|
|
164
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
165
|
+
for (const name of names) {
|
|
166
|
+
const candidate = path.join(dir, name);
|
|
167
|
+
try {
|
|
168
|
+
if (fs.existsSync(candidate))
|
|
169
|
+
return { command: candidate, realPath: fs.realpathSync(candidate) };
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// An unreadable PATH entry is not a VS Code install.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
/** Every value given for a flag, across repeats and both spellings, joined the way one comma-separated value would be. */
|
|
179
|
+
function flagValues(flags, names) {
|
|
180
|
+
const values = [];
|
|
181
|
+
flags.forEach((f, i) => {
|
|
182
|
+
for (const name of names) {
|
|
183
|
+
if (f.startsWith(`${name}=`))
|
|
184
|
+
values.push(f.slice(name.length + 1));
|
|
185
|
+
else if (f === name) {
|
|
186
|
+
const next = flags[i + 1];
|
|
187
|
+
values.push(next === undefined || next.startsWith("--") ? "" : next);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
if (values.length === 0)
|
|
192
|
+
return undefined;
|
|
193
|
+
// One occurrence without a value is a mistake even when another has one.
|
|
194
|
+
return values.some((v) => v.trim() === "") ? "" : values.join(",");
|
|
195
|
+
}
|
|
129
196
|
async function install(flags) {
|
|
130
197
|
const serverPath = path.join(packageRoot, "dist", "mcp-server.js");
|
|
131
198
|
if (!fs.existsSync(serverPath)) {
|
|
@@ -137,7 +204,17 @@ async function install(flags) {
|
|
|
137
204
|
// A plugin install already brings the skill and the server registration; the
|
|
138
205
|
// only thing it cannot bring is the browser download.
|
|
139
206
|
const browserOnly = flags.includes("--browser-only");
|
|
140
|
-
|
|
207
|
+
// Read the choice before doing anything, so a typo costs nothing.
|
|
208
|
+
const selection = parseBrowserSelection(browsersFlag(flags));
|
|
209
|
+
if ("error" in selection)
|
|
210
|
+
throw new Error(selection.error);
|
|
211
|
+
const chosen = parseClients(flagValues(flags, ["--client", "--clients"]));
|
|
212
|
+
if ("error" in chosen)
|
|
213
|
+
throw new Error(chosen.error);
|
|
214
|
+
const forClaude = chosen.clients.includes("claude-code");
|
|
215
|
+
const others = chosen.clients.filter((c) => c !== "claude-code");
|
|
216
|
+
// The skill is Claude Code's way of receiving the method; every other client gets it from the server.
|
|
217
|
+
if (!browserOnly && forClaude) {
|
|
141
218
|
const skill = installSkill({ packageRoot, claudeDir: resolveClaudeDir(process.env, os.homedir()) });
|
|
142
219
|
for (const note of skill.notes)
|
|
143
220
|
console.log(`· ${note}`);
|
|
@@ -149,45 +226,85 @@ async function install(flags) {
|
|
|
149
226
|
console.log("· Browser download skipped (--skip-browser).");
|
|
150
227
|
}
|
|
151
228
|
else {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
229
|
+
const present = await presentBrowsers();
|
|
230
|
+
const missing = selection.targets.filter((t) => !present[t].installed);
|
|
231
|
+
for (const t of selection.targets)
|
|
232
|
+
if (present[t].installed)
|
|
233
|
+
console.log(`✓ ${t} already present: ${present[t].path}`);
|
|
234
|
+
if (missing.length > 0) {
|
|
235
|
+
const size = missing.reduce((sum, t) => sum + APPROX_DISK_MB[t], 0);
|
|
236
|
+
console.log(`· Downloading ${missing.join(", ")} (one-time, about ${size} MB on disk)…`);
|
|
237
|
+
if (downloadBrowsers(missing))
|
|
238
|
+
console.log(`✓ Downloaded: ${missing.join(", ")}.`);
|
|
160
239
|
else {
|
|
161
240
|
failed = true;
|
|
162
|
-
console.log(
|
|
241
|
+
console.log(`✗ Browser download failed — run \`npx playwright install ${missing.join(" ")}\` and check your network/proxy.`);
|
|
163
242
|
}
|
|
164
243
|
}
|
|
165
244
|
}
|
|
245
|
+
// Downloading a browser the server will not launch leaves the first attach failing with no hint why.
|
|
246
|
+
const engine = defaultEngine(process.env);
|
|
247
|
+
const note = defaultAttachNote({
|
|
248
|
+
selected: flags.includes("--skip-browser") ? [] : selection.targets,
|
|
249
|
+
defaultEngine: engine,
|
|
250
|
+
defaultInstalled: (await presentBrowsers())[launchTarget(engine, false)].installed,
|
|
251
|
+
});
|
|
252
|
+
if (note)
|
|
253
|
+
console.log(`· Note: ${note}`);
|
|
254
|
+
const launch = launchCommand({ packageRoot, nodePath: process.execPath, serverPath });
|
|
166
255
|
if (browserOnly) {
|
|
167
256
|
// nothing to register
|
|
168
257
|
}
|
|
169
258
|
else if (flags.includes("--no-register")) {
|
|
170
|
-
console.log(
|
|
259
|
+
console.log("· MCP registration skipped (--no-register). To do it by hand:\n");
|
|
260
|
+
if (forClaude)
|
|
261
|
+
console.log(` ${manualRegisterCommand(launch)}`);
|
|
262
|
+
for (const client of others)
|
|
263
|
+
console.log(` ${CLIENT_LABELS[client]}: ${manualFor(client, launch, os.homedir())}`);
|
|
264
|
+
console.log("");
|
|
171
265
|
}
|
|
172
266
|
else {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
267
|
+
if (forClaude) {
|
|
268
|
+
const reg = registerMcp({ launch, serverPath, run: spawnRunner });
|
|
269
|
+
if (reg.status === "registered") {
|
|
270
|
+
console.log(`✓ MCP server ${reg.replaced ? "re-registered (paths refreshed)" : "registered"} with Claude Code at user scope.`);
|
|
271
|
+
for (const name of reg.removedLegacy)
|
|
272
|
+
console.log(`· removed the pre-rename MCP registration "${name}" (it pointed at this same server).`);
|
|
273
|
+
for (const note of reg.notes)
|
|
274
|
+
console.log(`· ${note}`);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
failed = true;
|
|
278
|
+
console.log(reg.status === "claude-missing"
|
|
279
|
+
? "· `claude` is not on this shell's PATH, so the MCP server was not registered."
|
|
280
|
+
: `✗ \`claude mcp add\` failed: ${reg.detail}`);
|
|
281
|
+
console.log(` Run this once from a terminal where \`claude\` works:\n\n ${reg.manual}\n`);
|
|
282
|
+
}
|
|
180
283
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
284
|
+
const vscode = others.includes("vscode")
|
|
285
|
+
? vscodeBinary({ platform: process.platform, home: os.homedir(), exists: fs.existsSync, codeOnPath: codeOnPath() })
|
|
286
|
+
: null;
|
|
287
|
+
for (const client of others) {
|
|
288
|
+
const reg = registerWithClient(client, { launch, home: os.homedir(), run: spawnRunner, vscode });
|
|
289
|
+
const label = CLIENT_LABELS[client];
|
|
290
|
+
if (reg.status === "registered") {
|
|
291
|
+
console.log(`✓ MCP server ${reg.replaced ? "re-registered" : "registered"} with ${label} (${reg.where}).`);
|
|
292
|
+
for (const note of reg.notes)
|
|
293
|
+
console.log(`· ${note}`);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
failed = true;
|
|
297
|
+
// On Windows a client installed through npm is a .cmd shim, which node cannot start directly.
|
|
298
|
+
const windowsNote = process.platform === "win32" ? " (or it is installed as a .cmd shim, which cannot be started from here)" : "";
|
|
299
|
+
console.log(reg.status === "client-missing"
|
|
300
|
+
? `· ${label} was not found on this machine${windowsNote}, so nothing was registered with it.`
|
|
301
|
+
: `✗ Registering with ${label} failed: ${reg.detail}`);
|
|
302
|
+
console.log(` To do it by hand, ${reg.manual}\n`);
|
|
303
|
+
}
|
|
187
304
|
}
|
|
188
305
|
}
|
|
189
306
|
if (failed) {
|
|
190
|
-
console.log(
|
|
307
|
+
console.log(`\nSetup is incomplete — fix the lines marked ✗ or · above, then run: scenescout doctor${forClaude ? "" : " --engine"}`);
|
|
191
308
|
process.exitCode = 1;
|
|
192
309
|
return;
|
|
193
310
|
}
|
|
@@ -195,8 +312,12 @@ async function install(flags) {
|
|
|
195
312
|
console.log("\nThe browser is ready — attach again.");
|
|
196
313
|
return;
|
|
197
314
|
}
|
|
198
|
-
|
|
199
|
-
|
|
315
|
+
if (forClaude)
|
|
316
|
+
console.log("\nStart a FRESH Claude Code session, then in any project run: /scenescout");
|
|
317
|
+
// Telling someone to restart a client nothing was registered with sends them looking for a server that is not there.
|
|
318
|
+
if (others.length > 0 && !flags.includes("--no-register"))
|
|
319
|
+
console.log(`\n${firstMessageHint(others)}`);
|
|
320
|
+
console.log(`Something off? Run: scenescout doctor${forClaude ? "" : " --engine"}`);
|
|
200
321
|
}
|
|
201
322
|
async function doctor(flags) {
|
|
202
323
|
const checks = diagnose({
|
|
@@ -204,7 +325,12 @@ async function doctor(flags) {
|
|
|
204
325
|
packageRoot,
|
|
205
326
|
claudeDir: resolveClaudeDir(process.env, os.homedir()),
|
|
206
327
|
nodeVersion: process.version,
|
|
207
|
-
|
|
328
|
+
// What a default attach launches: the headless build of the default browser.
|
|
329
|
+
defaultBrowser: await (async () => {
|
|
330
|
+
const target = launchTarget(defaultEngine(process.env), false);
|
|
331
|
+
const found = (await presentBrowsers())[target];
|
|
332
|
+
return { target, path: found.installed ? found.path : null, expected: found.path };
|
|
333
|
+
})(),
|
|
208
334
|
run: spawnRunner,
|
|
209
335
|
});
|
|
210
336
|
for (const c of checks) {
|
|
@@ -214,7 +340,9 @@ async function doctor(flags) {
|
|
|
214
340
|
}
|
|
215
341
|
if (checks.some((c) => !c.ok))
|
|
216
342
|
process.exit(1);
|
|
217
|
-
console.log(
|
|
343
|
+
console.log(flags.includes("--engine")
|
|
344
|
+
? "\nAll good. Ask your agent: Use SceneScout to test http://localhost:3000"
|
|
345
|
+
: "\nAll good. In any project, run: /scenescout (or ask: Use SceneScout to test http://localhost:3000)");
|
|
218
346
|
}
|
|
219
347
|
const [, , command, ...args] = process.argv;
|
|
220
348
|
// A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
|
package/dist/clients.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registering the MCP server with clients other than Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Each client keeps its server list somewhere different. Where a client has a
|
|
5
|
+
* command for adding a server, that command is used: it knows its own config
|
|
6
|
+
* format and location. Where it has none, the JSON file it reads is edited in
|
|
7
|
+
* place, keeping every other entry.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here launches a process or touches the home directory on its own:
|
|
10
|
+
* the runner, the home directory and the platform are passed in, so every rule
|
|
11
|
+
* can be table-tested.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { MCP_NAME } from "./installer.js";
|
|
16
|
+
export const OTHER_CLIENTS = ["cursor", "vscode", "codex", "gemini", "copilot", "windsurf"];
|
|
17
|
+
export const CLIENTS = ["claude-code", ...OTHER_CLIENTS];
|
|
18
|
+
export const CLIENT_LABELS = {
|
|
19
|
+
"claude-code": "Claude Code",
|
|
20
|
+
cursor: "Cursor",
|
|
21
|
+
vscode: "VS Code (GitHub Copilot agent mode)",
|
|
22
|
+
codex: "Codex CLI",
|
|
23
|
+
gemini: "Gemini CLI",
|
|
24
|
+
copilot: "GitHub Copilot CLI",
|
|
25
|
+
windsurf: "Windsurf",
|
|
26
|
+
};
|
|
27
|
+
/** Read the value of `--client`. Absent means Claude Code, which is what install has always set up. */
|
|
28
|
+
export function parseClients(value) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return { clients: ["claude-code"] };
|
|
31
|
+
const names = value
|
|
32
|
+
.split(",")
|
|
33
|
+
.map((s) => s.trim().toLowerCase())
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
const choices = CLIENTS.join(", ");
|
|
36
|
+
if (names.length === 0)
|
|
37
|
+
return { error: `--client needs a value. Choose from: ${choices}.` };
|
|
38
|
+
const picked = new Set();
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
if (!CLIENTS.includes(name))
|
|
41
|
+
return { error: `"${name}" is not a client install knows how to set up. Choose from: ${choices}.` };
|
|
42
|
+
picked.add(name);
|
|
43
|
+
}
|
|
44
|
+
return { clients: CLIENTS.filter((c) => picked.has(c)) };
|
|
45
|
+
}
|
|
46
|
+
const STRATEGIES = {
|
|
47
|
+
cursor: { kind: "file", file: (home) => path.join(home, ".cursor", "mcp.json"), key: "mcpServers" },
|
|
48
|
+
windsurf: { kind: "file", file: (home) => path.join(home, ".codeium", "windsurf", "mcp_config.json"), key: "mcpServers" },
|
|
49
|
+
// `add` replaces an entry of the same name.
|
|
50
|
+
codex: { kind: "command", binary: "codex", add: (launch) => ["mcp", "add", MCP_NAME, "--", ...launch] },
|
|
51
|
+
// `add` updates an entry of the same name. The default scope is the project; a tool like this belongs to the user.
|
|
52
|
+
gemini: { kind: "command", binary: "gemini", add: (launch) => ["mcp", "add", "--scope", "user", MCP_NAME, ...launch] },
|
|
53
|
+
// `add` refuses a name that already exists, so the old entry is removed first.
|
|
54
|
+
copilot: { kind: "command", binary: "copilot", add: (launch) => ["mcp", "add", MCP_NAME, "--", ...launch], removeFirst: ["mcp", "remove", MCP_NAME] },
|
|
55
|
+
};
|
|
56
|
+
const quote = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
57
|
+
/** The entry every `mcpServers`-style file takes. */
|
|
58
|
+
export function serverEntry(launch) {
|
|
59
|
+
return { command: launch[0], args: launch.slice(1) };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Put the server into a client's JSON server list, keeping everything else in
|
|
63
|
+
* the file. A file that is not valid JSON is left exactly as it is: rewriting
|
|
64
|
+
* it would discard whatever the person had in it.
|
|
65
|
+
*/
|
|
66
|
+
export function registerInFile(file, key, launch) {
|
|
67
|
+
const entry = serverEntry(launch);
|
|
68
|
+
const manual = `add this under "${key}" in ${file}:\n ${JSON.stringify({ [MCP_NAME]: entry })}`;
|
|
69
|
+
let config = {};
|
|
70
|
+
// A config kept in a dotfiles repository is a link. Renaming over the link
|
|
71
|
+
// would replace it with a plain file and leave the real one unchanged, so
|
|
72
|
+
// the write goes to whatever the link points at.
|
|
73
|
+
let target = file;
|
|
74
|
+
let mode = 0o600;
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(file)) {
|
|
77
|
+
target = fs.realpathSync(file);
|
|
78
|
+
mode = fs.statSync(target).mode & 0o777;
|
|
79
|
+
// Some editors save with a byte-order mark, which JSON.parse rejects.
|
|
80
|
+
const raw = fs.readFileSync(target, "utf8").replace(/^\uFEFF/, "");
|
|
81
|
+
if (raw.trim().length > 0) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
status: "failed",
|
|
89
|
+
detail: `${file} is not valid JSON (${err instanceof Error ? err.message : String(err)}), so it was left untouched`,
|
|
90
|
+
manual,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
94
|
+
return { status: "failed", detail: `${file} does not hold a JSON object, so it was left untouched`, manual };
|
|
95
|
+
}
|
|
96
|
+
config = parsed;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return { status: "failed", detail: `${file} could not be read (${err instanceof Error ? err.message : String(err)})`, manual };
|
|
102
|
+
}
|
|
103
|
+
const existing = config[key];
|
|
104
|
+
if (existing !== undefined && (existing === null || typeof existing !== "object" || Array.isArray(existing))) {
|
|
105
|
+
return { status: "failed", detail: `"${key}" in ${file} is not an object, so the file was left untouched`, manual };
|
|
106
|
+
}
|
|
107
|
+
const servers = (existing ?? {});
|
|
108
|
+
const before = servers[MCP_NAME];
|
|
109
|
+
const notes = [];
|
|
110
|
+
if (before !== undefined && JSON.stringify(before) !== JSON.stringify(entry)) {
|
|
111
|
+
notes.push(`the previous "${MCP_NAME}" entry was replaced; it ran: ${JSON.stringify(before)}`);
|
|
112
|
+
}
|
|
113
|
+
config[key] = { ...servers, [MCP_NAME]: entry };
|
|
114
|
+
// Written beside the target and renamed over it, so a crash cannot leave half a file.
|
|
115
|
+
const tmp = `${target}.scenescout-${process.pid}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
118
|
+
fs.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode });
|
|
119
|
+
fs.renameSync(tmp, target);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
// The temporary file is a full copy of the config, which can hold secrets in `env`; it must not stay behind.
|
|
123
|
+
fs.rmSync(tmp, { force: true });
|
|
124
|
+
return { status: "failed", detail: `${file} could not be written (${err instanceof Error ? err.message : String(err)}), so it was left untouched`, manual };
|
|
125
|
+
}
|
|
126
|
+
return { status: "registered", where: file, replaced: before !== undefined, notes };
|
|
127
|
+
}
|
|
128
|
+
function registerWithCommand(client, launch, run) {
|
|
129
|
+
const manual = [client.binary, ...client.add(launch)].map(quote).join(" ");
|
|
130
|
+
let removed = false;
|
|
131
|
+
if (client.removeFirst) {
|
|
132
|
+
const removal = run(client.binary, client.removeFirst);
|
|
133
|
+
if (removal.missing)
|
|
134
|
+
return { status: "client-missing", manual };
|
|
135
|
+
// A non-zero exit here only means there was nothing of that name to remove.
|
|
136
|
+
removed = removal.status === 0;
|
|
137
|
+
}
|
|
138
|
+
const added = run(client.binary, client.add(launch));
|
|
139
|
+
if (added.missing)
|
|
140
|
+
return { status: "client-missing", manual };
|
|
141
|
+
if (added.status !== 0) {
|
|
142
|
+
const reason = (added.stdout + added.stderr).trim().split("\n")[0] || `exit code ${added.status}`;
|
|
143
|
+
// Having removed the old entry to make room, say so: the person now has no registration at all.
|
|
144
|
+
const lost = removed ? ` The previous "${MCP_NAME}" entry had already been removed to make room, so ${client.binary} now has none.` : "";
|
|
145
|
+
return { status: "failed", detail: reason + lost, manual };
|
|
146
|
+
}
|
|
147
|
+
return { status: "registered", where: `${client.binary} mcp`, replaced: removed, notes: [] };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The VS Code command line, or null when only a fork is installed. Cursor and
|
|
151
|
+
* Windsurf both install a `code` command of their own, and running that one
|
|
152
|
+
* registers the server in the wrong editor: it reports success and VS Code
|
|
153
|
+
* never sees the entry. A `code` that resolves into another editor's files is
|
|
154
|
+
* therefore not VS Code.
|
|
155
|
+
*
|
|
156
|
+
* The real path is only inspected. What gets run is the command as found on
|
|
157
|
+
* PATH: some installs (snap) link `code` to a launcher that decides what to
|
|
158
|
+
* start from the name it was called by, and running the link's target directly
|
|
159
|
+
* starts the wrong thing.
|
|
160
|
+
*/
|
|
161
|
+
export function vscodeBinary(opts) {
|
|
162
|
+
if (opts.platform === "darwin") {
|
|
163
|
+
for (const root of ["/Applications", path.posix.join(opts.home, "Applications")]) {
|
|
164
|
+
const bundled = path.posix.join(root, "Visual Studio Code.app", "Contents", "Resources", "app", "bin", "code");
|
|
165
|
+
if (opts.exists(bundled))
|
|
166
|
+
return bundled;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!opts.codeOnPath)
|
|
170
|
+
return null;
|
|
171
|
+
// Looked for below the home directory's own name, so an account called "cursor" does not disqualify every install under it.
|
|
172
|
+
const real = opts.codeOnPath.realPath;
|
|
173
|
+
const belowHome = real.toLowerCase().startsWith(opts.home.toLowerCase()) ? real.slice(opts.home.length) : real;
|
|
174
|
+
return /cursor|windsurf|codeium|vscodium/i.test(belowHome) ? null : opts.codeOnPath.command;
|
|
175
|
+
}
|
|
176
|
+
/** What `code --add-mcp` takes: the entry plus its name. */
|
|
177
|
+
export function vscodeAddArgs(launch) {
|
|
178
|
+
return ["--add-mcp", JSON.stringify({ name: MCP_NAME, ...serverEntry(launch) })];
|
|
179
|
+
}
|
|
180
|
+
export function registerWithClient(client, opts) {
|
|
181
|
+
if (client === "vscode") {
|
|
182
|
+
const manual = `in VS Code run "MCP: Add Server…" and choose a command (stdio) server, or run:\n code ${vscodeAddArgs(opts.launch).map(quote).join(" ")}`;
|
|
183
|
+
if (!opts.vscode)
|
|
184
|
+
return { status: "client-missing", manual };
|
|
185
|
+
const added = opts.run(opts.vscode, vscodeAddArgs(opts.launch));
|
|
186
|
+
if (added.missing)
|
|
187
|
+
return { status: "client-missing", manual };
|
|
188
|
+
const output = (added.stdout + added.stderr).trim();
|
|
189
|
+
if (added.status !== 0)
|
|
190
|
+
return { status: "failed", detail: output.split("\n")[0] || `exit code ${added.status}`, manual };
|
|
191
|
+
// This command replaces an entry of the same name and does not say whether there was one.
|
|
192
|
+
return { status: "registered", where: "VS Code's user profile", replaced: false, notes: [] };
|
|
193
|
+
}
|
|
194
|
+
const strategy = STRATEGIES[client];
|
|
195
|
+
return strategy.kind === "file" ? registerInFile(strategy.file(opts.home), strategy.key, opts.launch) : registerWithCommand(strategy, opts.launch, opts.run);
|
|
196
|
+
}
|
|
197
|
+
/** How to register with a client by hand, without running anything. */
|
|
198
|
+
export function manualFor(client, launch, home) {
|
|
199
|
+
if (client === "vscode")
|
|
200
|
+
return `code ${vscodeAddArgs(launch).map(quote).join(" ")}`;
|
|
201
|
+
const strategy = STRATEGIES[client];
|
|
202
|
+
if (strategy.kind === "command")
|
|
203
|
+
return [strategy.binary, ...strategy.add(launch)].map(quote).join(" ");
|
|
204
|
+
return `add under "${strategy.key}" in ${strategy.file(home)}: ${JSON.stringify({ [MCP_NAME]: serverEntry(launch) })}`;
|
|
205
|
+
}
|
|
206
|
+
/** What to tell the person once their clients are set up: how the method reaches an agent that has no skill. */
|
|
207
|
+
export function firstMessageHint(clients) {
|
|
208
|
+
const names = clients.map((c) => CLIENT_LABELS[c]);
|
|
209
|
+
const list = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names[0];
|
|
210
|
+
return (`Restart ${list} (or reload the MCP servers there), then ask the agent:\n` +
|
|
211
|
+
` Use SceneScout to test http://localhost:3000\n` +
|
|
212
|
+
`The server hands the agent the testing method through its scout_playbook tool.`);
|
|
213
|
+
}
|
package/dist/engine/browser.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chromium } from "playwright";
|
|
1
|
+
import { chromium, firefox, webkit } from "playwright";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
|
|
@@ -8,6 +8,8 @@ import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGE
|
|
|
8
8
|
import { OracleMonitor, formatViolations } from "./oracles.js";
|
|
9
9
|
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
10
10
|
import { formatJourney, measureJourney } from "./journey.js";
|
|
11
|
+
import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, serviceWorkerPolicy, sharedWorkersAllowed } from "../browsers.js";
|
|
12
|
+
import { revealedLines } from "./hover.js";
|
|
11
13
|
import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
12
14
|
import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
|
|
13
15
|
import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
|
|
@@ -228,6 +230,8 @@ export class BrowserEngine {
|
|
|
228
230
|
}
|
|
229
231
|
/** Whether the browser window is visible — headed hover results carry a physical-cursor caveat. */
|
|
230
232
|
headed = false;
|
|
233
|
+
/** The browser this engine launched; reported by attach so a finding can say where it was seen. */
|
|
234
|
+
engineName = "chromium";
|
|
231
235
|
/** Non-GET requests fired since the last action — surfaces silent state mutation in read-only runs (timestamped for attribution). */
|
|
232
236
|
mutationRequests = [];
|
|
233
237
|
/**
|
|
@@ -293,11 +297,15 @@ export class BrowserEngine {
|
|
|
293
297
|
this.knownRoutes = [];
|
|
294
298
|
}
|
|
295
299
|
try {
|
|
296
|
-
this.
|
|
300
|
+
this.engineName = opts.browser ?? defaultEngine(process.env);
|
|
301
|
+
this.browser = await this.launchWithRecovery(this.engineName, opts.headed ?? false);
|
|
297
302
|
this.context = await this.browser.newContext({
|
|
298
303
|
storageState: opts.storageStatePath,
|
|
299
304
|
viewport: opts.viewport ?? { width: 1280, height: 900 },
|
|
305
|
+
serviceWorkers: serviceWorkerPolicy(this.engineName),
|
|
300
306
|
});
|
|
307
|
+
if (!sharedWorkersAllowed(this.mode))
|
|
308
|
+
await this.context.addInitScript(REMOVE_SHARED_WORKER_SCRIPT);
|
|
301
309
|
this.page = await this.context.newPage();
|
|
302
310
|
}
|
|
303
311
|
catch (err) {
|
|
@@ -461,6 +469,8 @@ export class BrowserEngine {
|
|
|
461
469
|
`Continuing now tests a logged-out app.`
|
|
462
470
|
: "";
|
|
463
471
|
return (`Attached to ${this.page.url()} (mode=${this.mode}` +
|
|
472
|
+
`${this.engineName === "chromium" ? "" : `, browser=${this.engineName}, service workers blocked because their requests cannot be intercepted here`}` +
|
|
473
|
+
`${focusAdvanceKey(this.engineName, process.platform) === "Tab" ? "" : `, keyboard: Tab stops only at text fields in this browser — press Alt+Tab to reach buttons and links`}` +
|
|
464
474
|
`${opts.storageStatePath ? `, auth=${opts.storageStatePath}` : ""}). ` +
|
|
465
475
|
`Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
|
|
466
476
|
`${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
|
|
@@ -1278,16 +1288,7 @@ export class BrowserEngine {
|
|
|
1278
1288
|
const bodyAfter = (await page.evaluate(`document.body ? document.body.innerText : ""`).catch(() => null));
|
|
1279
1289
|
if (bodyAfter === null)
|
|
1280
1290
|
return { revealed: [], fallbackUsed: false };
|
|
1281
|
-
|
|
1282
|
-
.split("\n")
|
|
1283
|
-
.map((l) => l.trim())
|
|
1284
|
-
.filter(Boolean));
|
|
1285
|
-
revealed = bodyAfter
|
|
1286
|
-
.split("\n")
|
|
1287
|
-
.map((l) => l.trim())
|
|
1288
|
-
.filter((l) => l && !beforeLines.has(l))
|
|
1289
|
-
.slice(0, 5)
|
|
1290
|
-
.map((l) => l.slice(0, 300));
|
|
1291
|
+
revealed = revealedLines(bodyBefore, bodyAfter);
|
|
1291
1292
|
return { revealed, fallbackUsed: revealed.length > 0 };
|
|
1292
1293
|
}
|
|
1293
1294
|
/** Pre-hover baselines: overlay texts, body text, and whether the page is already churning on its own. */
|
|
@@ -1924,15 +1925,18 @@ export class BrowserEngine {
|
|
|
1924
1925
|
* On the first failure or timeout, reap orphaned Playwright processes and
|
|
1925
1926
|
* try once more before giving up with a diagnosable error.
|
|
1926
1927
|
*/
|
|
1927
|
-
async launchWithRecovery(headed) {
|
|
1928
|
+
async launchWithRecovery(engine, headed) {
|
|
1929
|
+
const types = { chromium, firefox, webkit };
|
|
1928
1930
|
const attempt = async () => {
|
|
1929
1931
|
// The marker is what makes reapOrphanBrowsers safe to run at startup:
|
|
1930
1932
|
// it appears in the child's command line, so the sweep can tell a browser
|
|
1931
1933
|
// WE leaked from one belonging to somebody else's Playwright run.
|
|
1932
1934
|
// `--enable-features` takes arbitrary names and ignores unknown ones.
|
|
1933
|
-
|
|
1935
|
+
// It is a Chromium switch: Firefox and WebKit are launched without it,
|
|
1936
|
+
// so a leaked one of those is not reaped and has to be closed by hand.
|
|
1937
|
+
const launch = types[engine].launch({
|
|
1934
1938
|
headless: !headed,
|
|
1935
|
-
args: [`--enable-features=${BROWSER_MARKER}`],
|
|
1939
|
+
args: engine === "chromium" ? [`--enable-features=${BROWSER_MARKER}`] : [],
|
|
1936
1940
|
});
|
|
1937
1941
|
let timer;
|
|
1938
1942
|
try {
|
|
@@ -1959,13 +1963,13 @@ export class BrowserEngine {
|
|
|
1959
1963
|
const firstMessage = firstErr instanceof Error ? firstErr.message : String(firstErr);
|
|
1960
1964
|
// A browser that was never downloaded will not appear on a second try.
|
|
1961
1965
|
if (isMissingBrowser(firstMessage))
|
|
1962
|
-
throw new Error(explainLaunchFailure(firstMessage, 0));
|
|
1966
|
+
throw new Error(explainLaunchFailure(firstMessage, 0, { engine, headed }));
|
|
1963
1967
|
const reaped = reapOrphanBrowsers();
|
|
1964
1968
|
try {
|
|
1965
1969
|
return await attempt();
|
|
1966
1970
|
}
|
|
1967
1971
|
catch {
|
|
1968
|
-
throw new Error(explainLaunchFailure(firstMessage, reaped));
|
|
1972
|
+
throw new Error(explainLaunchFailure(firstMessage, reaped, { engine, headed }));
|
|
1969
1973
|
}
|
|
1970
1974
|
}
|
|
1971
1975
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page-text fallback for hover: which text is new after the pointer moved.
|
|
3
|
+
*
|
|
4
|
+
* Pure, so the rule can be table-tested. The browser side only supplies the
|
|
5
|
+
* two `innerText` readings.
|
|
6
|
+
*/
|
|
7
|
+
const squash = (s) => s.replace(/\s+/g, " ").trim();
|
|
8
|
+
/** How many times `needle` occurs in `haystack`, overlaps not counted. */
|
|
9
|
+
function occurrences(haystack, needle) {
|
|
10
|
+
let n = 0;
|
|
11
|
+
for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length))
|
|
12
|
+
n++;
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Lines present after the hover whose text was not on the page before it.
|
|
17
|
+
*
|
|
18
|
+
* Comparing line by line is not enough. When something that was showing goes
|
|
19
|
+
* away — the previous hover's tooltip closing as the pointer leaves it — the
|
|
20
|
+
* text around it can re-flow onto a line of its own, and that line is "new"
|
|
21
|
+
* only as a line: every word of it was already visible. How text re-flows
|
|
22
|
+
* differs between browsers. So a line counts as revealed when its text occurs
|
|
23
|
+
* MORE often after the hover than before: text that only moved occurs as often
|
|
24
|
+
* as it did, while a tooltip reading "Delete" on a page that already says
|
|
25
|
+
* "Delete account" occurs once more and is still reported.
|
|
26
|
+
*/
|
|
27
|
+
export function revealedLines(bodyBefore, bodyAfter, limit = 5) {
|
|
28
|
+
const beforeText = squash(bodyBefore);
|
|
29
|
+
const afterText = squash(bodyAfter);
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
return bodyAfter
|
|
32
|
+
.split("\n")
|
|
33
|
+
.map(squash)
|
|
34
|
+
.filter((l) => {
|
|
35
|
+
if (!l || seen.has(l))
|
|
36
|
+
return false;
|
|
37
|
+
seen.add(l);
|
|
38
|
+
return occurrences(afterText, l) > occurrences(beforeText, l);
|
|
39
|
+
})
|
|
40
|
+
.slice(0, limit)
|
|
41
|
+
.map((l) => l.slice(0, 300));
|
|
42
|
+
}
|