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/CHANGELOG.md +29 -0
- package/README.md +126 -27
- package/dist/browsers.js +196 -0
- package/dist/cli.js +308 -70
- package/dist/clients.js +213 -0
- package/dist/engine/browser.js +148 -20
- package/dist/engine/hover.js +42 -0
- package/dist/engine/launch.js +12 -5
- package/dist/engine/live-page.js +644 -0
- package/dist/engine/live.js +549 -0
- package/dist/engine/memory.js +3 -0
- package/dist/engine/probes.js +4 -1
- package/dist/engine/report.js +8 -2
- package/dist/installer.js +106 -12
- package/dist/mcp-server.js +268 -28
- package/dist/playbook.js +83 -0
- package/package.json +17 -5
- package/skills/scenescout/SKILL.md +5 -4
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,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 {
|
|
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
|
-
|
|
29
|
-
|
|
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? (
|
|
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
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
let st;
|
|
61
|
+
if (!fs.existsSync(statusPath))
|
|
62
|
+
return null;
|
|
50
63
|
try {
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
/**
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
return chromium.executablePath() || null;
|
|
162
|
+
token = fs.readFileSync(path.join(dir, LIVE_TOKEN_FILE), "utf8");
|
|
117
163
|
}
|
|
118
164
|
catch {
|
|
119
|
-
|
|
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
|
|
123
|
-
function
|
|
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,
|
|
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
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|