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/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 Chromium, register the MCP server
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
- /** Where Playwright expects its Chromium build; null when playwright cannot say. */
113
- async function chromiumPath() {
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 { chromium } = await import("playwright");
116
- return chromium.executablePath() || null;
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
- return null;
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 Chromium through the playwright CLI that ships with our own dependency. */
123
- function downloadChromium() {
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, "install", "chromium"], { stdio: "inherit" });
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
- if (!browserOnly) {
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 existing = await chromiumPath();
153
- if (existing && fs.existsSync(existing)) {
154
- console.log(`✓ Chromium already present: ${existing}`);
155
- }
156
- else {
157
- console.log("· Downloading Chromium (one-time, ~150 MB)…");
158
- if (downloadChromium())
159
- console.log("✓ Chromium downloaded.");
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("✗ Chromium download failed — run `npx playwright install chromium` and check your network/proxy.");
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( MCP registration skipped (--no-register). To do it by hand:\n\n ${manualRegisterCommand(launchCommand({ packageRoot, nodePath: process.execPath, serverPath }))}\n`);
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
- const reg = registerMcp({ launch: launchCommand({ packageRoot, nodePath: process.execPath, serverPath }), serverPath, run: spawnRunner });
174
- if (reg.status === "registered") {
175
- console.log(`✓ MCP server ${reg.replaced ? "re-registered (paths refreshed)" : "registered"} with Claude Code at user scope.`);
176
- for (const name of reg.removedLegacy)
177
- console.log( removed the pre-rename MCP registration "${name}" (it pointed at this same server).`);
178
- for (const note of reg.notes)
179
- console.log( ${note}`);
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
- else {
182
- failed = true;
183
- console.log(reg.status === "claude-missing"
184
- ? `claude` is not on this shell's PATH, so the MCP server was not registered."
185
- : `✗ \`claude mcp add\` failed: ${reg.detail}`);
186
- console.log(` Run this once from a terminal where \`claude\` works:\n\n ${reg.manual}\n`);
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("\nSetup is incomplete — fix the lines marked ✗ or · above, then run: scenescout doctor");
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
- console.log("\nStart a FRESH Claude Code session, then in any project run: /scenescout");
199
- console.log("Something off? Run: scenescout doctor");
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
- chromiumPath: await chromiumPath(),
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("\nAll good. In any project, run: /scenescout");
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
@@ -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
+ }
@@ -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.browser = await this.launchWithRecovery(opts.headed ?? false);
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
- const beforeLines = new Set(bodyBefore
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
- const launch = chromium.launch({
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
+ }