scenescout 1.0.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 +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup logic behind `scenescout install` and `scenescout doctor`.
|
|
3
|
+
*
|
|
4
|
+
* It lives outside cli.ts for the same reason engine rules live outside
|
|
5
|
+
* browser.ts: every function here takes its environment (home directory,
|
|
6
|
+
* command runner) as an argument, so a test can point it at a temp dir and a
|
|
7
|
+
* fake `claude` binary instead of mutating the developer's real ~/.claude.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
export const SKILL_NAME = "scenescout";
|
|
13
|
+
export const MCP_NAME = "scenescout";
|
|
14
|
+
/** Names this tool's skill and MCP server had before renames; cleaned up on install so the old slash command and a duplicate tool set do not linger. */
|
|
15
|
+
const LEGACY_SKILL_NAMES = ["frontend-tester", "scenecraft"];
|
|
16
|
+
const LEGACY_MCP_NAMES = ["scenecraft"];
|
|
17
|
+
/** Real runner. `missing` separates "the binary is not installed" from "it ran and failed". */
|
|
18
|
+
export const spawnRunner = (command, args) => {
|
|
19
|
+
const r = spawnSync(command, args, { encoding: "utf8", timeout: 60_000 });
|
|
20
|
+
const missing = r.error?.code === "ENOENT";
|
|
21
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? (r.error ? String(r.error.message) : ""), missing };
|
|
22
|
+
};
|
|
23
|
+
/** Written into a copy-mode install so a later install can tell its own copy from a user's directory. */
|
|
24
|
+
const OWNERSHIP_MARKER = ".installed-by-scenescout";
|
|
25
|
+
/** Every marker any version has written; a copy made under an earlier name is still ours to replace. */
|
|
26
|
+
const OWNERSHIP_MARKERS = [OWNERSHIP_MARKER, ".installed-by-scenecraft"];
|
|
27
|
+
/**
|
|
28
|
+
* An npx run executes from a cache directory that npm may delete at any time;
|
|
29
|
+
* a symlink into it would dangle. Everything else (a clone, a global install)
|
|
30
|
+
* is stable, and a symlink there keeps skill edits live.
|
|
31
|
+
*/
|
|
32
|
+
export function isEphemeralRoot(packageRoot) {
|
|
33
|
+
return packageRoot.split(path.sep).includes("_npx");
|
|
34
|
+
}
|
|
35
|
+
/** Where Claude Code keeps user-level config; it honours CLAUDE_CONFIG_DIR, so install must too. */
|
|
36
|
+
export function resolveClaudeDir(env, homeDir) {
|
|
37
|
+
return env.CLAUDE_CONFIG_DIR?.trim() || path.join(homeDir, ".claude");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Did a SceneScout install put this entry here? A symlink is only ever ours
|
|
41
|
+
* (it is a pointer, deleting it loses nothing); a real directory is ours only
|
|
42
|
+
* if it carries the marker a copy-mode install writes. Anything else may be
|
|
43
|
+
* the user's own work, and an installer has no business deleting that.
|
|
44
|
+
*/
|
|
45
|
+
function isOurs(entry) {
|
|
46
|
+
const stat = fs.lstatSync(entry, { throwIfNoEntry: false });
|
|
47
|
+
if (!stat)
|
|
48
|
+
return false;
|
|
49
|
+
if (stat.isSymbolicLink())
|
|
50
|
+
return true;
|
|
51
|
+
return stat.isDirectory() && OWNERSHIP_MARKERS.some((marker) => fs.existsSync(path.join(entry, marker)));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Remove an entry isOurs() vouched for. A link is unlinked, never rm'd: with
|
|
55
|
+
* `force`, a recursive rm of a DANGLING link stats the missing target, swallows
|
|
56
|
+
* the ENOENT, and leaves the link in place — and a dangling link is exactly
|
|
57
|
+
* what a moved or deleted checkout leaves behind.
|
|
58
|
+
*/
|
|
59
|
+
function removeOwned(entry) {
|
|
60
|
+
if (fs.lstatSync(entry).isSymbolicLink())
|
|
61
|
+
fs.unlinkSync(entry);
|
|
62
|
+
else
|
|
63
|
+
fs.rmSync(entry, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
export function installSkill(opts) {
|
|
66
|
+
const src = path.join(opts.packageRoot, "skills", SKILL_NAME);
|
|
67
|
+
if (!fs.existsSync(path.join(src, "SKILL.md"))) {
|
|
68
|
+
throw new Error(`skill source not found at ${src} — is this a complete SceneScout checkout?`);
|
|
69
|
+
}
|
|
70
|
+
const skillsDir = path.join(opts.claudeDir, "skills");
|
|
71
|
+
const dest = path.join(skillsDir, SKILL_NAME);
|
|
72
|
+
const notes = [];
|
|
73
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
74
|
+
for (const name of LEGACY_SKILL_NAMES) {
|
|
75
|
+
const legacy = path.join(skillsDir, name);
|
|
76
|
+
if (isOurs(legacy)) {
|
|
77
|
+
removeOwned(legacy);
|
|
78
|
+
notes.push(`removed the pre-rename skill at ${legacy}`);
|
|
79
|
+
}
|
|
80
|
+
else if (fs.lstatSync(legacy, { throwIfNoEntry: false })) {
|
|
81
|
+
notes.push(`left ${legacy} alone — it was not installed by SceneScout; delete it yourself if it is the old skill`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (isOurs(dest)) {
|
|
85
|
+
removeOwned(dest);
|
|
86
|
+
}
|
|
87
|
+
else if (fs.lstatSync(dest, { throwIfNoEntry: false })) {
|
|
88
|
+
// Not provably ours: move it aside instead of deleting someone's edits.
|
|
89
|
+
const backup = `${dest}.backup-${(opts.now ?? Date.now)()}`;
|
|
90
|
+
fs.renameSync(dest, backup);
|
|
91
|
+
notes.push(`an existing ${dest} was not installed by SceneScout — moved it to ${backup}`);
|
|
92
|
+
}
|
|
93
|
+
if (!isEphemeralRoot(opts.packageRoot)) {
|
|
94
|
+
try {
|
|
95
|
+
fs.symlinkSync(src, dest, "dir");
|
|
96
|
+
return { mode: "symlink", dest, src, notes };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Symlinks need elevated rights on stock Windows — fall through to a copy.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
103
|
+
fs.writeFileSync(path.join(dest, OWNERSHIP_MARKER), "Managed by `scenescout install`; re-running it replaces this directory.\n");
|
|
104
|
+
return { mode: "copy", dest, src, notes };
|
|
105
|
+
}
|
|
106
|
+
/** POSIX-shell quoting for the command we print; never used to execute anything. */
|
|
107
|
+
function quote(arg) {
|
|
108
|
+
return /^[A-Za-z0-9_\/.:=@-]+$/.test(arg) ? arg : `"${arg.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
109
|
+
}
|
|
110
|
+
/** The arguments after `--` that start the published package through npx. */
|
|
111
|
+
export const NPX_SERVE_ARGS = ["-y", "scenescout", "serve"];
|
|
112
|
+
/**
|
|
113
|
+
* The command Claude Code should run to start the server.
|
|
114
|
+
*
|
|
115
|
+
* From a stable location (a clone, a global install) that is this node binary
|
|
116
|
+
* plus the server script. From an npx run it must NOT be: the script then lives
|
|
117
|
+
* in npm's cache, which npm may clear at any time, leaving a registration that
|
|
118
|
+
* silently stops working. There the launcher is `npx -y scenescout serve`,
|
|
119
|
+
* which fetches the package again if it has to.
|
|
120
|
+
*
|
|
121
|
+
* Both use an ABSOLUTE binary path: Claude Code's launch environment often
|
|
122
|
+
* lacks the shell PATH (nvm/fnm), and a bare "node" or "npx" fails to start.
|
|
123
|
+
*/
|
|
124
|
+
export function launchCommand(opts) {
|
|
125
|
+
if (!isEphemeralRoot(opts.packageRoot))
|
|
126
|
+
return [opts.nodePath, opts.serverPath];
|
|
127
|
+
const npx = path.join(path.dirname(opts.nodePath), process.platform === "win32" ? "npx.cmd" : "npx");
|
|
128
|
+
// Some Node installs ship without npm beside the binary; a bare "npx" that
|
|
129
|
+
// resolves on PATH is better than an absolute path to nothing.
|
|
130
|
+
return [fs.existsSync(npx) ? npx : "npx", ...NPX_SERVE_ARGS];
|
|
131
|
+
}
|
|
132
|
+
export function mcpAddArgs(launch) {
|
|
133
|
+
return ["mcp", "add", "--scope", "user", MCP_NAME, "--", ...launch];
|
|
134
|
+
}
|
|
135
|
+
export function manualRegisterCommand(launch) {
|
|
136
|
+
return ["claude", ...mcpAddArgs(launch)].map(quote).join(" ");
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Register the MCP server with Claude Code. Idempotent: an existing
|
|
140
|
+
* registration under our name is replaced, so re-running install after moving
|
|
141
|
+
* the checkout or switching node versions repairs the stored paths.
|
|
142
|
+
*/
|
|
143
|
+
export function registerMcp(opts) {
|
|
144
|
+
const manual = manualRegisterCommand(opts.launch);
|
|
145
|
+
const add = () => opts.run("claude", mcpAddArgs(opts.launch));
|
|
146
|
+
let result = add();
|
|
147
|
+
if (result.missing)
|
|
148
|
+
return { status: "claude-missing", manual };
|
|
149
|
+
let replaced = false;
|
|
150
|
+
const notes = [];
|
|
151
|
+
if (result.status !== 0 && /already exists/i.test(result.stderr + result.stdout)) {
|
|
152
|
+
// Look before replacing. The usual case is our own earlier registration
|
|
153
|
+
// with a stale path, which is replaced quietly. A registration that runs
|
|
154
|
+
// something else — a second checkout, a fork — is still replaced, because
|
|
155
|
+
// one name can hold one server, but the user is told what it ran so they
|
|
156
|
+
// can put it back.
|
|
157
|
+
const previous = describeOtherRegistration(opts);
|
|
158
|
+
if (previous)
|
|
159
|
+
notes.push(previous);
|
|
160
|
+
const removed = opts.run("claude", ["mcp", "remove", "--scope", "user", MCP_NAME]);
|
|
161
|
+
if (removed.status !== 0) {
|
|
162
|
+
return { status: "failed", manual, detail: (removed.stderr || removed.stdout).trim() };
|
|
163
|
+
}
|
|
164
|
+
replaced = true;
|
|
165
|
+
result = add();
|
|
166
|
+
}
|
|
167
|
+
if (result.status !== 0) {
|
|
168
|
+
const reason = (result.stderr || result.stdout).trim();
|
|
169
|
+
// Say so when the failure left the user worse off than before we started.
|
|
170
|
+
const detail = replaced ? `the previous registration was removed, and re-adding it failed: ${reason}` : reason;
|
|
171
|
+
return { status: "failed", manual, detail };
|
|
172
|
+
}
|
|
173
|
+
const legacy = removeLegacyRegistrations(opts);
|
|
174
|
+
return { status: "registered", replaced, removedLegacy: legacy.removedLegacy, notes: [...notes, ...legacy.notes] };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A note describing the registration about to be replaced, when it starts
|
|
178
|
+
* something other than this install. Null when it is ours (same server script,
|
|
179
|
+
* or the same npx launcher), or when it cannot be read — an unreadable listing
|
|
180
|
+
* is not evidence of somebody else's server.
|
|
181
|
+
*/
|
|
182
|
+
function describeOtherRegistration(opts) {
|
|
183
|
+
const got = opts.run("claude", ["mcp", "get", MCP_NAME]);
|
|
184
|
+
if (got.missing || got.status !== 0)
|
|
185
|
+
return null;
|
|
186
|
+
const { command, serverPath: args } = parseRegistration(got.stdout + got.stderr);
|
|
187
|
+
if (args === null)
|
|
188
|
+
return null;
|
|
189
|
+
const sameScript = samePath(args, opts.serverPath);
|
|
190
|
+
const sameNpx = args === NPX_SERVE_ARGS.join(" ");
|
|
191
|
+
if (sameScript || sameNpx)
|
|
192
|
+
return null;
|
|
193
|
+
return `replaced an existing "${MCP_NAME}" registration that ran something else: ${[command, args].filter(Boolean).join(" ")} — re-register that one under a different name if you still need it`;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Drop a registration left under a pre-rename name — but only one that points
|
|
197
|
+
* at THIS server. Left in place it loads the same engine twice, and the agent
|
|
198
|
+
* sees every tool in duplicate. A same-named server pointing anywhere else is
|
|
199
|
+
* somebody's own and is not touched. Best-effort: a failure here leaves a
|
|
200
|
+
* working (if duplicated) setup, so it never fails the install.
|
|
201
|
+
*/
|
|
202
|
+
function removeLegacyRegistrations(opts) {
|
|
203
|
+
const removedLegacy = [];
|
|
204
|
+
const notes = [];
|
|
205
|
+
for (const name of LEGACY_MCP_NAMES) {
|
|
206
|
+
const got = opts.run("claude", ["mcp", "get", name]);
|
|
207
|
+
if (got.missing || got.status !== 0)
|
|
208
|
+
continue;
|
|
209
|
+
const listing = got.stdout + got.stderr;
|
|
210
|
+
const { serverPath } = parseRegistration(listing);
|
|
211
|
+
if (serverPath === null || !samePath(serverPath, opts.serverPath))
|
|
212
|
+
continue;
|
|
213
|
+
// Name the scope `get` reported: an unscoped remove is refused when the
|
|
214
|
+
// same name exists in more than one.
|
|
215
|
+
const scope = /^[ \t]*Scope:[ \t]*(User|Local|Project)\b/im.exec(listing)?.[1].toLowerCase();
|
|
216
|
+
const removed = opts.run("claude", ["mcp", "remove", ...(scope ? ["--scope", scope] : []), name]);
|
|
217
|
+
if (removed.status === 0)
|
|
218
|
+
removedLegacy.push(name);
|
|
219
|
+
else
|
|
220
|
+
notes.push(`the pre-rename MCP registration "${name}" points at this same server and could not be removed — every tool will appear twice until you run: claude mcp remove ${name}`);
|
|
221
|
+
}
|
|
222
|
+
return { removedLegacy, notes };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Do two paths name the same file? A stored registration is compared by real
|
|
226
|
+
* path, not by spelling: the same install is routinely reachable through a
|
|
227
|
+
* symlink, and on a case-insensitive filesystem through a differently-cased
|
|
228
|
+
* path. Comparing strings reported both as "pointing elsewhere".
|
|
229
|
+
*/
|
|
230
|
+
export function samePath(a, b) {
|
|
231
|
+
const real = (p) => {
|
|
232
|
+
try {
|
|
233
|
+
return fs.realpathSync.native(p);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return path.resolve(p);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
return real(a) === real(b);
|
|
240
|
+
}
|
|
241
|
+
/** The launch command and server script a `claude mcp get` listing names; null for whatever it does not show. */
|
|
242
|
+
export function parseRegistration(listing) {
|
|
243
|
+
const field = (name) => new RegExp(`^[ \\t]*${name}:[ \\t]*(\\S.*?)[ \\t]*$`, "m").exec(listing)?.[1] ?? null;
|
|
244
|
+
return { command: field("Command"), serverPath: field("Args") };
|
|
245
|
+
}
|
|
246
|
+
/** Everything a working setup needs, each with the command that repairs it. */
|
|
247
|
+
export function diagnose(opts) {
|
|
248
|
+
const checks = [];
|
|
249
|
+
const major = Number(opts.nodeVersion.replace(/^v/, "").split(".")[0]);
|
|
250
|
+
checks.push({ name: "node >= 20", ok: major >= 20, detail: opts.nodeVersion, fix: "install Node 20 or newer" });
|
|
251
|
+
const server = path.join(opts.packageRoot, "dist", "mcp-server.js");
|
|
252
|
+
checks.push({ name: "engine built", ok: fs.existsSync(server), detail: server, fix: "npm run build" });
|
|
253
|
+
const chromiumOk = !!opts.chromiumPath && fs.existsSync(opts.chromiumPath);
|
|
254
|
+
checks.push({
|
|
255
|
+
name: "chromium downloaded",
|
|
256
|
+
ok: chromiumOk,
|
|
257
|
+
detail: opts.chromiumPath ?? "playwright could not name a browser path",
|
|
258
|
+
fix: "npm run setup (or: npx playwright install chromium)",
|
|
259
|
+
});
|
|
260
|
+
if (opts.scope === "engine")
|
|
261
|
+
return checks;
|
|
262
|
+
const skill = path.join(opts.claudeDir, "skills", SKILL_NAME, "SKILL.md");
|
|
263
|
+
checks.push({ name: "skill installed", ok: fs.existsSync(skill), detail: skill, fix: "npm run setup" });
|
|
264
|
+
const got = opts.run("claude", ["mcp", "get", MCP_NAME]);
|
|
265
|
+
if (got.missing) {
|
|
266
|
+
checks.push({
|
|
267
|
+
name: "claude CLI on PATH",
|
|
268
|
+
ok: false,
|
|
269
|
+
detail: "`claude` not found",
|
|
270
|
+
fix: "install Claude Code, or register the server by hand (see README)",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const listing = got.stdout + got.stderr;
|
|
275
|
+
if (got.status !== 0) {
|
|
276
|
+
checks.push({ name: "MCP server registered", ok: false, detail: "no server named scenescout", fix: "npm run setup" });
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
const { command, serverPath } = parseRegistration(listing);
|
|
280
|
+
if (serverPath === null) {
|
|
281
|
+
// An unreadable listing is not evidence of a wrong install; claiming so
|
|
282
|
+
// would produce a failure that re-running install can never clear.
|
|
283
|
+
checks.push({ name: "MCP server registered", ok: true, detail: "registered (could not read its path from `claude mcp get` to verify it)" });
|
|
284
|
+
}
|
|
285
|
+
else if (serverPath === NPX_SERVE_ARGS.join(" ")) {
|
|
286
|
+
// Registered through npx: there is no script path to compare, only the launcher to vet.
|
|
287
|
+
const absolute = command !== null && path.isAbsolute(command);
|
|
288
|
+
checks.push({
|
|
289
|
+
name: "MCP server registered",
|
|
290
|
+
ok: absolute,
|
|
291
|
+
detail: absolute ? `via ${command} ${serverPath}` : `registered with a bare \`${command}\` command, which Claude Code may not find on its PATH`,
|
|
292
|
+
fix: "scenescout install",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
else if (!samePath(serverPath, server)) {
|
|
296
|
+
// The usual aftermath of moving or deleting a checkout.
|
|
297
|
+
checks.push({ name: "MCP server registered", ok: false, detail: `registered, but pointing at ${serverPath} — not this install`, fix: "npm run setup" });
|
|
298
|
+
}
|
|
299
|
+
else if (command !== null && !path.isAbsolute(command)) {
|
|
300
|
+
// A bare "node" resolves in your shell and then fails inside Claude
|
|
301
|
+
// Code, whose launch environment often lacks the nvm/fnm PATH.
|
|
302
|
+
checks.push({
|
|
303
|
+
name: "MCP server registered",
|
|
304
|
+
ok: false,
|
|
305
|
+
detail: `registered with a bare \`${command}\` command, which Claude Code may not find on its PATH`,
|
|
306
|
+
fix: "npm run setup",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
checks.push({ name: "MCP server registered", ok: true, detail: server });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return checks;
|
|
315
|
+
}
|