scenescout 1.1.0 → 1.3.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,8 +13,11 @@ 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 { diagnose, installSkill, launchCommand, manualRegisterCommand, registerMcp, resolveClaudeDir, spawnRunner } from "./installer.js";
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";
18
+ import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
17
19
  import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME } from "./engine/memory.js";
20
+ import { localClock, formatSessionLine, LIVE_TOKEN_FILE, watchTarget, wholeSessions } from "./engine/live.js";
18
21
  import { formatScan, scanProject } from "./scan.js";
19
22
  const here = path.dirname(fileURLToPath(import.meta.url));
20
23
  const packageRoot = path.resolve(here, "..");
@@ -25,57 +28,98 @@ Usage:
25
28
  scenescout scan <projectPath> Discover framework, routes, auth states
26
29
  scenescout serve Run the MCP server (stdio)
27
30
  scenescout install One-step setup: skill + Chromium + MCP registration
28
- (--skip-browser, --no-register to opt out of a step;
29
- --browser-only when the skill and server came from a plugin)
31
+ It also puts the \`scenescout\` command on your PATH.
32
+ (--skip-browser, --no-register, --no-command to opt out of a step;
33
+ --browser-only when the skill and server came from a plugin;
34
+ --browsers <list> to choose what to download: chromium (default),
35
+ chromium-headless-shell, firefox, webkit, all — comma-separated)
36
+ (--client <list> to set up another MCP client instead of, or as well as,
37
+ Claude Code: claude-code (default), cursor, vscode, codex, gemini,
38
+ copilot, windsurf — comma-separated)
30
39
  scenescout doctor Check the setup and print the fix for anything missing
31
40
  (--engine: only node, the build and the browser — for plugin
32
41
  installs and other MCP clients)
33
- scenescout status [projectPath] What is the engine doing right now? (live status + recent actions)
42
+ scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
43
+ scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
44
+ of its page, and a live stream you can switch on per session
45
+ (--no-open to print the address only)
34
46
  `);
35
47
  process.exit(exitCode);
36
48
  }
37
- /** Realtime observability: read the status file + recent action log the running engine maintains. */
38
- function status(projectPath) {
39
- // A project last touched before the rename (or one a pre-rename engine is
40
- // using right now) still keeps its status under the legacy directory.
41
- const dir = [MEMORY_DIRNAME, LEGACY_MEMORY_DIRNAME]
49
+ /**
50
+ * A project last touched before the rename (or one a pre-rename engine is using
51
+ * right now) still keeps its status under the legacy directory.
52
+ */
53
+ function statusDir(projectPath) {
54
+ return ([MEMORY_DIRNAME, LEGACY_MEMORY_DIRNAME]
42
55
  .map((name) => path.join(projectPath, name))
43
- .find((candidate) => fs.existsSync(path.join(candidate, "status.json"))) ?? path.join(projectPath, MEMORY_DIRNAME);
56
+ .find((candidate) => fs.existsSync(path.join(candidate, "status.json"))) ?? path.join(projectPath, MEMORY_DIRNAME));
57
+ }
58
+ /** null when there is no file; "unreadable" when there is one and it does not parse. */
59
+ function readStatusFile(dir) {
44
60
  const statusPath = path.join(dir, "status.json");
45
- if (!fs.existsSync(statusPath)) {
46
- console.log(`No status file at ${statusPath} — no SceneScout engine has attached to this project (or it predates v0.8).`);
47
- return;
48
- }
49
- let st;
61
+ if (!fs.existsSync(statusPath))
62
+ return null;
50
63
  try {
51
- st = JSON.parse(fs.readFileSync(statusPath, "utf8"));
64
+ return JSON.parse(fs.readFileSync(statusPath, "utf8"));
52
65
  }
53
66
  catch {
54
67
  // status.json is written fire-and-forget on every tool call, so a process
55
68
  // killed mid-write leaves a truncated file. That is a diagnosable state,
56
69
  // not a reason for the diagnostic tool itself to crash.
57
- console.log(`Status file at ${statusPath} is unreadable or truncated — the engine was probably killed mid-write. Re-attach to refresh it.`);
70
+ return "unreadable";
71
+ }
72
+ }
73
+ function pidAlive(pid) {
74
+ if (!pid)
75
+ return false;
76
+ try {
77
+ process.kill(pid, 0);
78
+ return true;
79
+ }
80
+ catch (err) {
81
+ // EPERM means the process EXISTS but belongs to another user — only
82
+ // ESRCH actually means "no such process". Treating both as dead reported
83
+ // a live engine as stale.
84
+ return err?.code === "EPERM";
85
+ }
86
+ }
87
+ /** Realtime observability: read the status file + recent action log the running engine maintains. */
88
+ function status(projectPath) {
89
+ const dir = statusDir(projectPath);
90
+ const statusPath = path.join(dir, "status.json");
91
+ const st = readStatusFile(dir);
92
+ if (st === null) {
93
+ console.log(`No status file at ${statusPath} — no SceneScout engine has attached to this project (or it predates v0.8).`);
58
94
  return;
59
95
  }
60
- let alive = false;
61
- if (st.pid) {
62
- try {
63
- process.kill(st.pid, 0);
64
- alive = true;
65
- }
66
- catch (err) {
67
- // EPERM means the process EXISTS but belongs to another user — only
68
- // ESRCH actually means "no such process". Treating both as dead reported
69
- // a live engine as stale.
70
- alive = err?.code === "EPERM";
71
- }
96
+ if (st === "unreadable") {
97
+ console.log(`Status file at ${statusPath} is unreadable or truncated — the engine was probably killed mid-write. Re-attach to refresh it.`);
98
+ return;
72
99
  }
100
+ const alive = pidAlive(st.pid);
73
101
  const age = st.at ? Math.round((Date.now() - new Date(st.at).getTime()) / 1000) : null;
74
102
  console.log(`Engine pid ${st.pid ?? "?"} — ${alive ? "ALIVE" : "not running (stale status)"}`);
75
103
  console.log(`${st.phase === "running" ? "⏳ running" : "· idle after"}: ${st.tool ?? "?"}${age !== null ? ` (as of ${age}s ago)` : ""}`);
76
- console.log(`Session: ${st.session ?? "?"} (${st.role ?? "?"})${st.sessions && st.sessions.length > 1 ? ` · all sessions: ${st.sessions.join(", ")}` : ""}`);
77
- if (st.url)
78
- console.log(`URL: ${st.url}`);
104
+ // The file is written by another process and can be caught mid-write, so
105
+ // only entries whole enough to describe are described.
106
+ const sessions = wholeSessions(st.detail);
107
+ if (sessions.length > 0) {
108
+ // One line per session. The single "Session:" line below it is all an
109
+ // engine from before the live view can offer.
110
+ console.log(`Sessions (${sessions.length}):`);
111
+ for (const entry of sessions)
112
+ console.log(` ${formatSessionLine(entry, Date.now())}`);
113
+ if (alive && st.live?.port)
114
+ console.log("Live view: scenescout watch");
115
+ if (alive && st.live?.error)
116
+ console.log(`Live view unavailable: ${st.live.error}`);
117
+ }
118
+ else {
119
+ console.log(`Session: ${st.session ?? "?"} (${st.role ?? "?"})${st.sessions && st.sessions.length > 1 ? ` · all sessions: ${st.sessions.join(", ")}` : ""}`);
120
+ if (st.url)
121
+ console.log(`URL: ${st.url}`);
122
+ }
79
123
  // Recent actions from the newest session log — the "what has it been doing" trail.
80
124
  const logs = fs.existsSync(dir)
81
125
  ? fs
@@ -101,7 +145,7 @@ function status(projectPath) {
101
145
  for (const line of lines) {
102
146
  try {
103
147
  const e = JSON.parse(line);
104
- console.log(` ${e.at.slice(11, 19)} ${e.action}${e.target ? ` ${e.target}` : ""} @ ${e.url}`);
148
+ console.log(` ${localClock(e.at)} ${e.action}${e.target ? ` ${e.target}` : ""} @ ${e.url}`);
105
149
  }
106
150
  catch {
107
151
  /* skip malformed line */
@@ -109,23 +153,120 @@ function status(projectPath) {
109
153
  }
110
154
  }
111
155
  }
112
- /** Where Playwright expects its Chromium build; null when playwright cannot say. */
113
- async function chromiumPath() {
156
+ /** Open the engine's live view. The engine serves it; this only finds the address and hands it to a browser. */
157
+ function watch(projectPath, open) {
158
+ const dir = statusDir(projectPath);
159
+ const st = readStatusFile(dir);
160
+ let token = null;
114
161
  try {
115
- const { chromium } = await import("playwright");
116
- return chromium.executablePath() || null;
162
+ token = fs.readFileSync(path.join(dir, LIVE_TOKEN_FILE), "utf8");
117
163
  }
118
164
  catch {
119
- return null;
165
+ // watchTarget explains a missing token in context.
166
+ }
167
+ const target = watchTarget({ status: st, alive: st !== null && st !== "unreadable" && pidAlive(st.pid), token });
168
+ if ("problem" in target) {
169
+ console.log(target.problem);
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ console.log(`Live view: ${target.url}`);
174
+ console.log("It is served on this machine only, and the address holds its access token: treat it like a password.");
175
+ if (!open)
176
+ return;
177
+ const { command, args } = browserOpener(target.url);
178
+ const result = spawnSync(command, args, { stdio: "ignore" });
179
+ if (result.error || result.status !== 0)
180
+ console.log("Could not open a browser from here. Open the address above yourself.");
181
+ }
182
+ /** The platform's own "open this URL" command. */
183
+ function browserOpener(url) {
184
+ switch (process.platform) {
185
+ case "darwin":
186
+ return { command: "open", args: [url] };
187
+ case "win32":
188
+ return { command: "cmd", args: ["/c", "start", "", url] };
189
+ default:
190
+ return { command: "xdg-open", args: [url] };
191
+ }
192
+ }
193
+ /** Which browser builds are on disk, going by the paths Playwright reports for the version we depend on. */
194
+ async function presentBrowsers() {
195
+ const executables = { chromium: null, firefox: null, webkit: null };
196
+ try {
197
+ const playwright = await import("playwright");
198
+ for (const name of BROWSER_ENGINES)
199
+ executables[name] = playwright[name].executablePath() || null;
120
200
  }
201
+ catch {
202
+ // Playwright cannot be loaded: every build reads as absent, which is what doctor should say.
203
+ }
204
+ return browserPresence(executables);
121
205
  }
122
- /** Download Chromium through the playwright CLI that ships with our own dependency. */
123
- function downloadChromium() {
206
+ /** Download browser builds through the playwright CLI that ships with our own dependency. */
207
+ function downloadBrowsers(targets) {
124
208
  const require = createRequire(import.meta.url);
125
209
  const cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
126
- const r = spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
210
+ const r = spawnSync(process.execPath, [cli, ...playwrightInstallArgs(targets)], { stdio: "inherit" });
127
211
  return r.status === 0;
128
212
  }
213
+ /**
214
+ * The value of a flag written as `--name x` or `--name=x`. Undefined when the
215
+ * flag is absent; empty when it was given no value, which includes being
216
+ * followed by another flag.
217
+ */
218
+ function flagValue(flags, name) {
219
+ const inline = flags.find((f) => f.startsWith(`${name}=`));
220
+ if (inline)
221
+ return inline.slice(name.length + 1);
222
+ const at = flags.indexOf(name);
223
+ if (at < 0)
224
+ return undefined;
225
+ const next = flags[at + 1];
226
+ return next === undefined || next.startsWith("--") ? "" : next;
227
+ }
228
+ function browsersFlag(flags) {
229
+ // `--browser-only` is a different flag; a bare `--browser` is a slip that would otherwise be ignored and download Chromium.
230
+ const slip = flags.find((f) => f === "--browser" || f.startsWith("--browser="));
231
+ if (slip)
232
+ throw new Error(`unknown flag ${slip.split("=")[0]} — did you mean --browsers?`);
233
+ return flagValue(flags, "--browsers");
234
+ }
235
+ /** The `code` command on PATH, with its real path: the real path is what tells VS Code from a fork. */
236
+ function codeOnPath() {
237
+ const names = process.platform === "win32" ? ["code.cmd", "code.exe"] : ["code"];
238
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
239
+ for (const name of names) {
240
+ const candidate = path.join(dir, name);
241
+ try {
242
+ if (fs.existsSync(candidate))
243
+ return { command: candidate, realPath: fs.realpathSync(candidate) };
244
+ }
245
+ catch {
246
+ // An unreadable PATH entry is not a VS Code install.
247
+ }
248
+ }
249
+ }
250
+ return null;
251
+ }
252
+ /** Every value given for a flag, across repeats and both spellings, joined the way one comma-separated value would be. */
253
+ function flagValues(flags, names) {
254
+ const values = [];
255
+ flags.forEach((f, i) => {
256
+ for (const name of names) {
257
+ if (f.startsWith(`${name}=`))
258
+ values.push(f.slice(name.length + 1));
259
+ else if (f === name) {
260
+ const next = flags[i + 1];
261
+ values.push(next === undefined || next.startsWith("--") ? "" : next);
262
+ }
263
+ }
264
+ });
265
+ if (values.length === 0)
266
+ return undefined;
267
+ // One occurrence without a value is a mistake even when another has one.
268
+ return values.some((v) => v.trim() === "") ? "" : values.join(",");
269
+ }
129
270
  async function install(flags) {
130
271
  const serverPath = path.join(packageRoot, "dist", "mcp-server.js");
131
272
  if (!fs.existsSync(serverPath)) {
@@ -137,7 +278,17 @@ async function install(flags) {
137
278
  // A plugin install already brings the skill and the server registration; the
138
279
  // only thing it cannot bring is the browser download.
139
280
  const browserOnly = flags.includes("--browser-only");
140
- if (!browserOnly) {
281
+ // Read the choice before doing anything, so a typo costs nothing.
282
+ const selection = parseBrowserSelection(browsersFlag(flags));
283
+ if ("error" in selection)
284
+ throw new Error(selection.error);
285
+ const chosen = parseClients(flagValues(flags, ["--client", "--clients"]));
286
+ if ("error" in chosen)
287
+ throw new Error(chosen.error);
288
+ const forClaude = chosen.clients.includes("claude-code");
289
+ const others = chosen.clients.filter((c) => c !== "claude-code");
290
+ // The skill is Claude Code's way of receiving the method; every other client gets it from the server.
291
+ if (!browserOnly && forClaude) {
141
292
  const skill = installSkill({ packageRoot, claudeDir: resolveClaudeDir(process.env, os.homedir()) });
142
293
  for (const note of skill.notes)
143
294
  console.log(`· ${note}`);
@@ -149,45 +300,116 @@ async function install(flags) {
149
300
  console.log("· Browser download skipped (--skip-browser).");
150
301
  }
151
302
  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.");
303
+ const present = await presentBrowsers();
304
+ const missing = selection.targets.filter((t) => !present[t].installed);
305
+ for (const t of selection.targets)
306
+ if (present[t].installed)
307
+ console.log(`✓ ${t} already present: ${present[t].path}`);
308
+ if (missing.length > 0) {
309
+ const size = missing.reduce((sum, t) => sum + APPROX_DISK_MB[t], 0);
310
+ console.log( Downloading ${missing.join(", ")} (one-time, about ${size} MB on disk)…`);
311
+ if (downloadBrowsers(missing))
312
+ console.log(`✓ Downloaded: ${missing.join(", ")}.`);
160
313
  else {
161
314
  failed = true;
162
- console.log("✗ Chromium download failed — run `npx playwright install chromium` and check your network/proxy.");
315
+ console.log(`✗ Browser download failed — run \`npx playwright install ${missing.join(" ")}\` and check your network/proxy.`);
163
316
  }
164
317
  }
165
318
  }
319
+ // Downloading a browser the server will not launch leaves the first attach failing with no hint why.
320
+ const engine = defaultEngine(process.env);
321
+ const note = defaultAttachNote({
322
+ selected: flags.includes("--skip-browser") ? [] : selection.targets,
323
+ defaultEngine: engine,
324
+ defaultInstalled: (await presentBrowsers())[launchTarget(engine, false)].installed,
325
+ });
326
+ if (note)
327
+ console.log(`· Note: ${note}`);
328
+ const launch = launchCommand({ packageRoot, nodePath: process.execPath, serverPath });
166
329
  if (browserOnly) {
167
330
  // nothing to register
168
331
  }
169
332
  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`);
333
+ console.log( MCP registration skipped (--no-register). To do it by hand:\n");
334
+ if (forClaude)
335
+ console.log(` ${manualRegisterCommand(launch)}`);
336
+ for (const client of others)
337
+ console.log(` ${CLIENT_LABELS[client]}: ${manualFor(client, launch, os.homedir())}`);
338
+ console.log("");
171
339
  }
172
340
  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}`);
341
+ if (forClaude) {
342
+ const reg = registerMcp({ launch, serverPath, run: spawnRunner });
343
+ if (reg.status === "registered") {
344
+ console.log(`✓ MCP server ${reg.replaced ? "re-registered (paths refreshed)" : "registered"} with Claude Code at user scope.`);
345
+ for (const name of reg.removedLegacy)
346
+ console.log(`· removed the pre-rename MCP registration "${name}" (it pointed at this same server).`);
347
+ for (const note of reg.notes)
348
+ console.log(`· ${note}`);
349
+ }
350
+ else {
351
+ failed = true;
352
+ console.log(reg.status === "claude-missing"
353
+ ? "· `claude` is not on this shell's PATH, so the MCP server was not registered."
354
+ : `✗ \`claude mcp add\` failed: ${reg.detail}`);
355
+ console.log(` Run this once from a terminal where \`claude\` works:\n\n ${reg.manual}\n`);
356
+ }
357
+ }
358
+ const vscode = others.includes("vscode")
359
+ ? vscodeBinary({ platform: process.platform, home: os.homedir(), exists: fs.existsSync, codeOnPath: codeOnPath() })
360
+ : null;
361
+ for (const client of others) {
362
+ const reg = registerWithClient(client, { launch, home: os.homedir(), run: spawnRunner, vscode });
363
+ const label = CLIENT_LABELS[client];
364
+ if (reg.status === "registered") {
365
+ console.log(`✓ MCP server ${reg.replaced ? "re-registered" : "registered"} with ${label} (${reg.where}).`);
366
+ for (const note of reg.notes)
367
+ console.log(`· ${note}`);
368
+ }
369
+ else {
370
+ failed = true;
371
+ // On Windows a client installed through npm is a .cmd shim, which node cannot start directly.
372
+ const windowsNote = process.platform === "win32" ? " (or it is installed as a .cmd shim, which cannot be started from here)" : "";
373
+ console.log(reg.status === "client-missing"
374
+ ? `· ${label} was not found on this machine${windowsNote}, so nothing was registered with it.`
375
+ : `✗ Registering with ${label} failed: ${reg.detail}`);
376
+ console.log(` To do it by hand, ${reg.manual}\n`);
377
+ }
378
+ }
379
+ }
380
+ // `scenescout status`, `watch` and `doctor` are typed by a person, and neither
381
+ // a checkout nor an npx run leaves the command on PATH. Not having it costs
382
+ // convenience, never a working setup, so this step reports and does not fail.
383
+ let cli = isEphemeralRoot(packageRoot) ? `npx -y ${CLI_NAME}` : `node ${path.join(packageRoot, "dist", "cli.js")}`;
384
+ if (browserOnly) {
385
+ // a plugin install has no package of its own to put on PATH
386
+ }
387
+ else if (flags.includes("--no-command")) {
388
+ console.log(`· Putting \`${CLI_NAME}\` on PATH skipped (--no-command). Until then the command is: ${cli}`);
389
+ }
390
+ else {
391
+ const onPath = () => findOnUserPath({ names: process.platform === "win32" ? [`${CLI_NAME}.cmd`] : [CLI_NAME], pathValue: process.env.PATH ?? "" });
392
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
393
+ const done = ensureCommand(planCommand({ packageRoot, nodePath: process.execPath, version: pkg.version, resolved: onPath(), platform: process.platform }), spawnRunner);
394
+ if (done.status === "present") {
395
+ cli = CLI_NAME;
396
+ console.log(`✓ \`${CLI_NAME}\` command already on PATH: ${done.at}`);
397
+ }
398
+ else if (done.status === "installed") {
399
+ const at = onPath();
400
+ if (at)
401
+ cli = CLI_NAME;
402
+ const what = done.how === "link" ? "linked to this checkout, so it runs whatever was last built" : "installed globally";
403
+ console.log(at
404
+ ? `✓ \`${CLI_NAME}\` command ${what}: ${at}${done.replaced ? ` (it replaces ${done.replaced})` : ""}`
405
+ : `· \`${CLI_NAME}\` was ${what}, but npm's global bin directory is not on this shell's PATH. Add it (\`npm prefix -g\` names it; the commands are in its bin folder), or use: ${cli}`);
180
406
  }
181
407
  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`);
408
+ console.log(`· \`${CLI_NAME}\` was not put on PATH (${done.detail}). To do it by hand: ${done.manual}\n Until then the command is: ${cli}`);
187
409
  }
188
410
  }
189
411
  if (failed) {
190
- console.log("\nSetup is incomplete — fix the lines marked ✗ or · above, then run: scenescout doctor");
412
+ console.log(`\nSetup is incomplete — fix the lines marked ✗ or · above, then run: ${cli} doctor${forClaude ? "" : " --engine"}`);
191
413
  process.exitCode = 1;
192
414
  return;
193
415
  }
@@ -195,8 +417,12 @@ async function install(flags) {
195
417
  console.log("\nThe browser is ready — attach again.");
196
418
  return;
197
419
  }
198
- console.log("\nStart a FRESH Claude Code session, then in any project run: /scenescout");
199
- console.log("Something off? Run: scenescout doctor");
420
+ if (forClaude)
421
+ console.log("\nStart a FRESH Claude Code session, then in any project run: /scenescout");
422
+ // Telling someone to restart a client nothing was registered with sends them looking for a server that is not there.
423
+ if (others.length > 0 && !flags.includes("--no-register"))
424
+ console.log(`\n${firstMessageHint(others)}`);
425
+ console.log(`Something off? Run: ${cli} doctor${forClaude ? "" : " --engine"}`);
200
426
  }
201
427
  async function doctor(flags) {
202
428
  const checks = diagnose({
@@ -204,7 +430,12 @@ async function doctor(flags) {
204
430
  packageRoot,
205
431
  claudeDir: resolveClaudeDir(process.env, os.homedir()),
206
432
  nodeVersion: process.version,
207
- chromiumPath: await chromiumPath(),
433
+ // What a default attach launches: the headless build of the default browser.
434
+ defaultBrowser: await (async () => {
435
+ const target = launchTarget(defaultEngine(process.env), false);
436
+ const found = (await presentBrowsers())[target];
437
+ return { target, path: found.installed ? found.path : null, expected: found.path };
438
+ })(),
208
439
  run: spawnRunner,
209
440
  });
210
441
  for (const c of checks) {
@@ -214,7 +445,9 @@ async function doctor(flags) {
214
445
  }
215
446
  if (checks.some((c) => !c.ok))
216
447
  process.exit(1);
217
- console.log("\nAll good. In any project, run: /scenescout");
448
+ console.log(flags.includes("--engine")
449
+ ? "\nAll good. Ask your agent: Use SceneScout to test http://localhost:3000"
450
+ : "\nAll good. In any project, run: /scenescout (or ask: Use SceneScout to test http://localhost:3000)");
218
451
  }
219
452
  const [, , command, ...args] = process.argv;
220
453
  // A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
@@ -259,6 +492,11 @@ try {
259
492
  status(path.resolve(args[0] ?? process.cwd()));
260
493
  break;
261
494
  }
495
+ case "watch": {
496
+ const positional = args.filter((a) => !a.startsWith("--"));
497
+ watch(path.resolve(positional[0] ?? process.cwd()), !args.includes("--no-open"));
498
+ break;
499
+ }
262
500
  default:
263
501
  usage();
264
502
  }