scenescout 3.15.0 → 3.16.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +64 -15
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +190 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +104 -43
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +182 -39
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/tickets.js +671 -0
  47. package/dist/engine/unload.js +3 -2
  48. package/dist/export-run.js +633 -0
  49. package/dist/first-run.js +4 -0
  50. package/dist/installer.js +267 -3
  51. package/dist/intake.js +104 -0
  52. package/dist/login-run.js +250 -36
  53. package/dist/mcp-server.js +567 -57
  54. package/dist/playbook.js +5 -0
  55. package/package.json +6 -3
  56. package/skills/scenescout/SKILL.md +49 -16
package/dist/first-run.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * and scripts/smoke/first-run.ts runs the built CLI against the demo app with
20
20
  * a throwaway home directory.
21
21
  */
22
+ import { DEFAULT_BASELINE_THRESHOLD } from "./engine/baseline.js";
22
23
  import fs from "node:fs";
23
24
  import path from "node:path";
24
25
  import { APPROX_DISK_MB, launchTarget } from "./browsers.js";
@@ -151,6 +152,9 @@ export function firstRunCheckOptions(o, projectDir) {
151
152
  flowWrites: "never",
152
153
  onRefusedStep: "report",
153
154
  gateRetests: "never",
155
+ // A first look pictures nothing: baselines are a project's own list.
156
+ baseline: "off",
157
+ baselineThreshold: DEFAULT_BASELINE_THRESHOLD,
154
158
  };
155
159
  }
156
160
  /** What a first look downloads: the one build a headless Chromium launch needs, when it is not on disk, and nothing else. */
package/dist/installer.js CHANGED
@@ -6,10 +6,11 @@
6
6
  * command runner) as an argument, so a test can point it at a temp dir and a
7
7
  * fake `claude` binary instead of mutating the developer's real ~/.claude.
8
8
  */
9
- import { spawnSync } from "node:child_process";
9
+ import { spawn, spawnSync } from "node:child_process";
10
10
  import fs from "node:fs";
11
+ import { createRequire } from "node:module";
11
12
  import path from "node:path";
12
- import { engineOf } from "./browsers.js";
13
+ import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, engineOf, playwrightInstallArgs, } from "./browsers.js";
13
14
  export const SKILL_NAME = "scenescout";
14
15
  export const MCP_NAME = "scenescout";
15
16
  /** 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. */
@@ -363,11 +364,24 @@ export function diagnose(opts) {
363
364
  detail: browser.path ?? (browser.expected ? `not found at ${browser.expected}` : "playwright could not name a browser path"),
364
365
  fix: `${repair.browser(browser.target)} (or: npx playwright install ${browser.target})`,
365
366
  });
367
+ const extension = opts.desktopExtension ?? null;
368
+ if (extension)
369
+ checks.push(...desktopExtensionChecks(extension, opts.headlessShellDir ?? null, opts.version));
366
370
  if (opts.scope === "engine")
367
371
  return checks;
368
372
  const skill = path.join(opts.claudeDir, "skills", SKILL_NAME, "SKILL.md");
369
- checks.push({ name: "skill installed", ok: fs.existsSync(skill), detail: skill, fix: repair.setup });
370
373
  const got = opts.run("claude", ["mcp", "get", MCP_NAME]);
374
+ if (extension && !fs.existsSync(skill) && (got.missing || got.status !== 0)) {
375
+ // The extension brings its own server, and Claude Desktop has no skill
376
+ // folder: someone who only uses Claude Desktop has nothing to fix here.
377
+ checks.push({
378
+ name: CLAUDE_CODE_NOT_NEEDED,
379
+ ok: true,
380
+ detail: `not set up, and the desktop extension does not need it (to use SceneScout in Claude Code as well: ${repair.setup})`,
381
+ });
382
+ return checks;
383
+ }
384
+ checks.push({ name: "skill installed", ok: fs.existsSync(skill), detail: skill, fix: repair.setup });
371
385
  if (got.missing) {
372
386
  checks.push({
373
387
  name: "claude CLI on PATH",
@@ -419,3 +433,253 @@ export function diagnose(opts) {
419
433
  }
420
434
  return checks;
421
435
  }
436
+ /** Which browser builds are on disk, going by the paths Playwright reports for the version we depend on. */
437
+ export async function presentBrowsers() {
438
+ const executables = { chromium: null, firefox: null, webkit: null };
439
+ try {
440
+ const playwright = await import("playwright");
441
+ for (const name of BROWSER_ENGINES)
442
+ executables[name] = playwright[name].executablePath() || null;
443
+ }
444
+ catch {
445
+ // Playwright cannot be loaded: every build reads as absent, which is what doctor should say.
446
+ }
447
+ return browserPresence(executables);
448
+ }
449
+ /**
450
+ * The reason Playwright's installer gives for a failed download: its first
451
+ * `Error:` line, which names the cause (a refused connection, a proxy, a full
452
+ * disk) where its last lines are a stack trace. Null when it gave none.
453
+ */
454
+ export function installerFailure(stderr) {
455
+ const lines = stderr.split(/\r?\n/).map((l) => l.trim());
456
+ const first = lines.find((l) => /^Error: \S/.test(l)) ?? lines.find((l) => l !== "" && !l.startsWith("at ") && /fail/i.test(l));
457
+ return first
458
+ ? first
459
+ .replace(/^Error: /, "")
460
+ .replace(/, caused by$/, "")
461
+ .slice(0, 300)
462
+ : null;
463
+ }
464
+ /** How long a browser download may run before it is stopped and reported as failed. */
465
+ export const BROWSER_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
466
+ /**
467
+ * The environment Playwright's installer runs in. A host that runs this server
468
+ * inside its own Electron binary (a desktop app's bundled runtime) makes
469
+ * process.execPath that binary, which starts the app instead of a script
470
+ * unless told to behave as Node.
471
+ */
472
+ export function downloadEnv(env, electron) {
473
+ return electron ? { ...env, ELECTRON_RUN_AS_NODE: "1" } : env;
474
+ }
475
+ /**
476
+ * Download browser builds through the playwright CLI that ships with our own
477
+ * dependency. `inherit` shows its progress on this terminal; `stderr` keeps
478
+ * stdout clean for a server whose stdout is its protocol channel.
479
+ */
480
+ export function downloadBrowsers(targets, output) {
481
+ return new Promise((resolve) => {
482
+ let cli;
483
+ try {
484
+ cli = path.join(path.dirname(createRequire(import.meta.url).resolve("playwright/package.json")), "cli.js");
485
+ }
486
+ catch (err) {
487
+ resolve({ ok: false, detail: `Playwright's installer could not be found: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` });
488
+ return;
489
+ }
490
+ let seen = "";
491
+ let timedOut = false;
492
+ const child = spawn(process.execPath, [cli, ...playwrightInstallArgs(targets)], {
493
+ stdio: output === "inherit" ? "inherit" : ["ignore", process.stderr, "pipe"],
494
+ env: downloadEnv(process.env, process.versions.electron),
495
+ });
496
+ child.stderr?.on("data", (chunk) => {
497
+ process.stderr.write(chunk);
498
+ if (seen.length < 20_000)
499
+ seen += chunk.toString();
500
+ });
501
+ const timer = setTimeout(() => {
502
+ timedOut = true;
503
+ child.kill();
504
+ }, BROWSER_DOWNLOAD_TIMEOUT_MS);
505
+ child.on("error", (err) => {
506
+ clearTimeout(timer);
507
+ resolve({ ok: false, detail: err.message });
508
+ });
509
+ // A stopped download may leave a child of the installer holding the pipe, so `close` may never come: answer on `exit`.
510
+ child.on("exit", () => {
511
+ if (timedOut)
512
+ resolve({ ok: false, detail: `stopped after ${BROWSER_DOWNLOAD_TIMEOUT_MS / 60_000} minutes` });
513
+ });
514
+ child.on("close", (code, signal) => {
515
+ clearTimeout(timer);
516
+ if (code === 0)
517
+ resolve({ ok: true });
518
+ else if (timedOut)
519
+ resolve({ ok: false, detail: `stopped after ${BROWSER_DOWNLOAD_TIMEOUT_MS / 60_000} minutes` });
520
+ else
521
+ resolve({
522
+ ok: false,
523
+ detail: installerFailure(seen) ?? (signal ? `the installer was stopped by ${signal}` : `the installer exited with code ${code}`),
524
+ });
525
+ });
526
+ });
527
+ }
528
+ /** The `name` in the desktop extension's manifest.json, which is how its install is told apart from other extensions. */
529
+ export const DESKTOP_EXTENSION_NAME = "scenescout";
530
+ /** Where Claude Desktop keeps the folder it unpacks each installed extension into. */
531
+ export function desktopExtensionRoots(opts) {
532
+ const folder = "Claude Extensions";
533
+ if (opts.platform === "darwin")
534
+ return [path.join(opts.home, "Library", "Application Support", "Claude", folder)];
535
+ if (opts.platform === "win32") {
536
+ const roots = [];
537
+ const appData = opts.env.APPDATA?.trim() || path.join(opts.home, "AppData", "Roaming");
538
+ roots.push(path.join(appData, "Claude", folder));
539
+ // The Microsoft Store build keeps its data in its package's own folder,
540
+ // named after the package with a publisher suffix.
541
+ const packages = path.join(opts.env.LOCALAPPDATA?.trim() || path.join(opts.home, "AppData", "Local"), "Packages");
542
+ for (const entry of readDirNames(packages)) {
543
+ if (/^Claude_/i.test(entry))
544
+ roots.push(path.join(packages, entry, "LocalCache", "Roaming", "Claude", folder));
545
+ }
546
+ return roots;
547
+ }
548
+ return [path.join(opts.env.XDG_CONFIG_HOME?.trim() || path.join(opts.home, ".config"), "Claude", folder)];
549
+ }
550
+ /** The names in a folder, or none when the folder does not exist or cannot be listed. */
551
+ function readDirNames(dir) {
552
+ try {
553
+ return fs.readdirSync(dir);
554
+ }
555
+ catch (err) {
556
+ const code = err.code;
557
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "EPERM")
558
+ return [];
559
+ throw err;
560
+ }
561
+ }
562
+ function readJson(file) {
563
+ let text;
564
+ try {
565
+ text = fs.readFileSync(file, "utf8");
566
+ }
567
+ catch (err) {
568
+ // No such file, or a plain file where a folder was expected (a .DS_Store among the extension folders).
569
+ const code = err.code;
570
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "EPERM")
571
+ return null;
572
+ throw err;
573
+ }
574
+ try {
575
+ const value = JSON.parse(text);
576
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
577
+ }
578
+ catch {
579
+ // A manifest that is not JSON is not one this function can vouch for; another extension's broken file is not ours to report.
580
+ return null;
581
+ }
582
+ }
583
+ /**
584
+ * The SceneScout desktop extension under any of `roots`, or null.
585
+ *
586
+ * Extensions are matched by the `name` in their manifest.json, not by folder
587
+ * name: Claude Desktop names the folder after an id it derives itself.
588
+ */
589
+ export function findDesktopExtension(roots) {
590
+ for (const root of roots) {
591
+ for (const name of readDirNames(root).sort()) {
592
+ const dir = path.join(root, name);
593
+ const manifest = readJson(path.join(dir, "manifest.json"));
594
+ if (manifest?.name !== DESKTOP_EXTENSION_NAME)
595
+ continue;
596
+ const server = manifest.server;
597
+ const entry = typeof server?.entry_point === "string" ? path.join(dir, server.entry_point) : null;
598
+ const browsers = readJson(path.join(dir, "node_modules", "playwright-core", "browsers.json"))?.browsers;
599
+ const shell = Array.isArray(browsers)
600
+ ? browsers.find((b) => b?.name === "chromium-headless-shell")
601
+ : undefined;
602
+ return {
603
+ dir,
604
+ version: typeof manifest.version === "string" ? manifest.version : null,
605
+ entry,
606
+ entryPresent: entry !== null && fs.existsSync(entry),
607
+ headlessShellRevision: typeof shell?.revision === "string" && /^\d+$/.test(shell.revision) ? shell.revision : null,
608
+ };
609
+ }
610
+ }
611
+ return null;
612
+ }
613
+ /** The check doctor reports, passing, when only the desktop extension is set up and Claude Code's skill and registration are not needed. */
614
+ export const CLAUDE_CODE_NOT_NEEDED = "Claude Code setup";
615
+ /** What doctor says about a desktop extension install. */
616
+ function desktopExtensionChecks(extension, ownShellDir, ownVersion) {
617
+ const reinstall = "in Claude Desktop, remove SceneScout under Settings > Extensions, then install the .mcpb file from the latest release again";
618
+ const label = `SceneScout ${extension.version ?? "(no version in its manifest)"}`;
619
+ const checks = [
620
+ {
621
+ name: "desktop extension installed",
622
+ ok: extension.entryPresent,
623
+ detail: extension.entryPresent
624
+ ? `${label} in Claude Desktop: ${extension.dir}`
625
+ : `${label} in Claude Desktop, but its server is missing: ${extension.entry ?? "the manifest names none"}`,
626
+ fix: reinstall,
627
+ },
628
+ ];
629
+ // The browser check above is for this copy's Playwright. The extension's may
630
+ // launch another revision, downloaded into its own folder beside this one.
631
+ if (extension.headlessShellRevision && ownShellDir) {
632
+ const dir = path.join(path.dirname(ownShellDir), `chromium_headless_shell-${extension.headlessShellRevision}`);
633
+ if (!samePath(dir, ownShellDir)) {
634
+ const present = fs.existsSync(path.join(dir, "INSTALLATION_COMPLETE"));
635
+ const byHand = extension.version ? `npx -y scenescout@${extension.version} install --browser-only` : "npx -y scenescout install --browser-only";
636
+ const onFirstUse = !present && atLeastVersion(extension.version, ownVersion);
637
+ checks.push({
638
+ name: "browser downloaded (desktop extension)",
639
+ // An extension that downloads its own browser on its first test is ready to use without it.
640
+ ok: present || onFirstUse,
641
+ detail: present
642
+ ? dir
643
+ : onFirstUse
644
+ ? `not yet: the test browser downloads on first use (one-time, about ${APPROX_DISK_MB["chromium-headless-shell"]} MB). To have it ready now: ${byHand}`
645
+ : `the extension launches another Chromium build than this copy, and it is not at ${dir}`,
646
+ fix: byHand,
647
+ });
648
+ }
649
+ }
650
+ return checks;
651
+ }
652
+ /** Whether version `a` (x.y.z) is at least `b`. False when either is missing or not of that shape, so an unknown version is never assumed to be new. */
653
+ export function atLeastVersion(a, b) {
654
+ const parse = (v) => {
655
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v?.trim() ?? "");
656
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
657
+ };
658
+ const x = parse(a);
659
+ const y = parse(b);
660
+ if (!x || !y)
661
+ return false;
662
+ for (let i = 0; i < 3; i++)
663
+ if (x[i] !== y[i])
664
+ return x[i] > y[i];
665
+ return true;
666
+ }
667
+ /** The last thing `install` prints when every step worked. */
668
+ export function installClosing(opts) {
669
+ // A plugin install, or a browser download for a server already set up. A
670
+ // plugin's tools appear in a chat started after it was installed; in a chat
671
+ // that already has them, asking again is enough.
672
+ if (opts.browserOnly)
673
+ return "The browser is ready. Start a new chat to use SceneScout.\n(If SceneScout asked you for this download in a chat it already works in, ask it to try again there.)";
674
+ if (opts.forClaude)
675
+ return "Start a new chat in Claude Code to use SceneScout. In any project, run: /scenescout";
676
+ return null;
677
+ }
678
+ /** The line doctor ends on when every check passed. */
679
+ export function doctorAllGood(opts) {
680
+ if (opts.desktopOnly)
681
+ return "All good. In Claude Desktop, start a new chat and ask: Use SceneScout to test http://localhost:3000";
682
+ if (opts.engineOnly)
683
+ return "All good. Ask your agent: Use SceneScout to test http://localhost:3000";
684
+ return "All good. In any project, run: /scenescout (or ask: Use SceneScout to test http://localhost:3000)";
685
+ }
package/dist/intake.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The questions a run starts with when the person gave no settings.
3
+ *
4
+ * Someone who is not a developer should not have to know what "observe" or a
5
+ * storage state is. When the skill is invoked with none of its flags, the agent
6
+ * asks four plain questions instead, and each answer chooses a setting: the
7
+ * address chooses the URL, the sign-in answer chooses whether to call
8
+ * scout_login and attach by role, what to check chooses the objective (and, for
9
+ * tickets, the criteria scout_tickets reads so the report answers each), and real
10
+ * data chooses the write mode. A developer who passes flags is asked nothing.
11
+ *
12
+ * The skill (skills/scenescout/SKILL.md) states the same questions and the same
13
+ * mapping in words; guide-test holds that text to this module, so the two
14
+ * cannot drift. The `explore` prompt asks them through `introQuestions`.
15
+ */
16
+ /** The flags the skill reads. Any one given means the person chose settings themselves. */
17
+ export const SKILL_FLAGS = ["url", "role", "focus", "level", "observe", "read-only", "safe-write", "allow-destructive"];
18
+ export const INTAKE_QUESTIONS = [
19
+ { id: "address", ask: "What is the address of the site?", sets: "url", flags: ["url"] },
20
+ {
21
+ id: "sign-in",
22
+ ask: "Do you need to sign in to use it? If so, how: Google or Microsoft single sign-on, an email and password, or a one-time code?",
23
+ sets: "role",
24
+ flags: ["role"],
25
+ },
26
+ {
27
+ id: "what-to-check",
28
+ ask: 'What should I check? Upload or paste the tickets, or describe it in a sentence. Say "everything" to look at the whole site.',
29
+ sets: "objective",
30
+ flags: ["focus", "level"],
31
+ },
32
+ {
33
+ id: "real-data",
34
+ ask: "Does the site hold real data, such as real customers, orders or records?",
35
+ sets: "mode",
36
+ flags: ["observe", "read-only", "safe-write", "allow-destructive"],
37
+ },
38
+ ];
39
+ /** The skill's flags present in what the person typed after the command, in the order SKILL_FLAGS lists them. */
40
+ export function flagsGiven(argumentText) {
41
+ const seen = new Set();
42
+ // A flag is `--name` standing alone or followed by `=` or a space; `--urlx` and a word inside a value are not flags.
43
+ for (const m of argumentText.matchAll(/(?<![\w-])--([a-z][a-z-]*)(?![\w-])/g))
44
+ seen.add(m[1]);
45
+ return SKILL_FLAGS.filter((f) => seen.has(f));
46
+ }
47
+ /**
48
+ * The questions to ask before setup. None when any flag was given: a person who
49
+ * passes flags has chosen settings, and what they left out takes its default.
50
+ */
51
+ export function questionsToAsk(argumentText) {
52
+ return flagsGiven(argumentText).length > 0 ? [] : [...INTAKE_QUESTIONS];
53
+ }
54
+ /** The role a plain-question run saves its sign-in under; any later run that asks for it reuses that login. */
55
+ export const INTAKE_ROLE = "user";
56
+ /** What a sign-in answer tells the person before the window opens. */
57
+ export const SIGN_IN_HINT = {
58
+ sso: "sign in with Google or Microsoft as you normally would",
59
+ password: "sign in with your email and password as you normally would",
60
+ "one-time-code": "sign in as you normally would and enter the code when it arrives",
61
+ };
62
+ /**
63
+ * The settings the four answers choose. Real data, or an answer that is not
64
+ * sure, is observe: nothing but GET requests leaves the page. Only a plain no
65
+ * allows read-only, and plain answers never reach safe-write or destructive,
66
+ * which need the person to ask for them by name.
67
+ */
68
+ export function settingsFromAnswers(a) {
69
+ const url = a.address.trim();
70
+ if (!url)
71
+ throw new Error("the address is empty: ask for it again before attaching");
72
+ const mode = a.realData === "no" ? "read-only" : "observe";
73
+ const login = a.signIn === "none" ? null : { tool: "scout_login", url, role: INTAKE_ROLE, tellUser: `A browser window will open: ${SIGN_IN_HINT[a.signIn]}.` };
74
+ let objective = "Explore the whole site";
75
+ let focus;
76
+ let readTickets;
77
+ if (a.whatToCheck !== "everything") {
78
+ if ("tickets" in a.whatToCheck) {
79
+ const tickets = a.whatToCheck.tickets.map((t) => t.trim()).filter(Boolean);
80
+ if (tickets.length === 0)
81
+ throw new Error("no tickets were given: ask what to check again, or explore the whole site");
82
+ focus = tickets.join("; ");
83
+ objective = `Check the tickets: ${focus}`;
84
+ // A rule between them, so each one given is read as its own ticket.
85
+ readTickets = { tool: "scout_tickets", text: tickets.join("\n\n---\n\n") };
86
+ }
87
+ else {
88
+ focus = a.whatToCheck.description.trim();
89
+ if (!focus)
90
+ throw new Error("the description is empty: ask what to check again, or explore the whole site");
91
+ objective = focus;
92
+ }
93
+ }
94
+ return {
95
+ login,
96
+ attach: { url, mode, ...(login ? { role: login.role } : {}), objective },
97
+ ...(focus ? { focus } : {}),
98
+ ...(readTickets ? { readTickets } : {}),
99
+ };
100
+ }
101
+ /** The questions as a numbered list, the way the explore prompt and the skill put them. */
102
+ export function introQuestions() {
103
+ return INTAKE_QUESTIONS.map((q, i) => `${i + 1}. ${q.ask}`).join("\n");
104
+ }