Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/two-phase-first-asr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@subtextdev/subtext-wizard': minor
---

Split setup into two phases to reach a first agentic session review faster. The first hand-off now installs the capture snippet (and CSP) only — precheck, explore, plan, install — so the user can restart their dev server, capture a session, and review it right away. Afterward the wizard offers the enrichment step (user identification, analytics linkage, and PII masking); for terminal agents this is a second driven run in the same session, and for app/manual hand-offs it's a copyable follow-up prompt. The analytics-integration picker has moved out of the up-front flow into this enrichment step, where it's actually used, and now pre-selects the tools it finds in the app's `package.json` (dependencies + devDependencies) — the user can still uncheck those or add others, and when nothing is detected the picker behaves as before (leaving it empty hands detection to the agent). Each phase is now labeled ("Step 1 of 2", "Step 2 of 2") and gated by a single prompt: the pre-handoff prompt review carries the autonomy consent for terminal agents (no separate "run autonomously?" confirm), and the enrichment opt-in stands on its own (no second prompt review). The session-review-tools (plugin/MCP) consent for terminal agents is now asked up front alongside the handoff rather than mid-flow, so the post-install first-run guide isn't interrupted — the setup itself still runs after the install. The prompt-review is a lighter Yes / "review first" confirm instead of a three-option menu, and the opening banner and telemetry notice are trimmed to a line each. The install telemetry funnel is unchanged — one `start`/`complete` spanning both phases, each step reported exactly once.
53 changes: 28 additions & 25 deletions src/demo.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as p from '@clack/prompts';
import clipboard from 'clipboardy';
import pc from 'picocolors';
import { brandPink } from './logo.js';
import { brandPink, readableNoteBody } from './logo.js';

/**
* The wizard's closing section: a short "see it in action" guide. Capture is
Expand Down Expand Up @@ -46,47 +46,50 @@ export async function showDemoGuide(ctx: DemoGuideContext): Promise<void> {
const lead = ctx.installPending
? `Once the install finishes, make sure everything works.`
: `Installation complete — let's make sure everything works.`;
// clack renders note bodies dimmed; pc.reset per line undoes that (the same
// escape clack itself uses for note titles) so everything reads at full
// strength. The prompt is set apart by color — brand pink is the agent's
// text, plain is the human's steps. Only the closing aside stays dim, and
// re-dims inside the reset.
// clack renders note bodies dimmed; readableNoteBody resets per line so the
// steps read at full strength. The demo prompt itself is NOT in the box — it
// prints as a pink block in the timeline next to the copy action below, so
// it's clear which text the "copy?" question refers to.
p.note(
[
lead,
pc.bold('Follow these steps:'),
'',
'1. Start (or restart) your local dev server so the new snippet is live.',
'2. Open the app in your browser and click around for a minute —',
' Subtext is capturing your session as you go.',
`3. Open ${ctx.agentName} at this project and paste in the demo prompt`,
' below — that part is the agent\'s job:',
'',
...DEMO_PROMPT_LINES.map((line) => ` ${brandPink(line)}`),
'',
pc.dim('Captured sessions can take a minute or two to show up.'),
]
.map((line) => pc.reset(line))
.join('\n'),
readableNoteBody(
[
lead,
pc.bold('Follow these steps:'),
'',
'1. Start (or restart) your local dev server so the new snippet is live.',
'2. Open the app in your browser and click around for a minute —',
' Subtext is capturing your session as you go.',
`3. Open ${ctx.agentName} at this project and paste in the demo prompt`,
' shown below — that part is the agent\'s job.',
'',
pc.dim('Captured sessions can take a minute or two to show up.'),
].join('\n'),
),
'First run',
);
ctx.onEvent('demo_guide_shown', { install_pending: ctx.installPending });

// The agent-facing prompt, in brand pink, as its own timeline block right
// above the copy question — clack anchors the active prompt at the bottom,
// so the prompt has to sit just before it (nothing can render below a live
// question). This keeps it out of the box and directly beside the action.
p.log.message(DEMO_PROMPT_LINES.map((line) => brandPink(line)).join('\n'));

if (ctx.yes) return;

const answer = await p.confirm({
message: ctx.clipboardHoldsInstallPrompt
? `Copy the demo prompt to your clipboard? ${pc.dim(
? `Copy the demo prompt above to your clipboard? ${pc.dim(
'(replaces the install prompt currently on it)',
)}`
: 'Copy the demo prompt to your clipboard?',
: 'Copy the demo prompt above to your clipboard?',
});
if (p.isCancel(answer) || !answer) return;

try {
await clipboard.write(DEMO_PROMPT);
} catch {
p.log.warn('Could not write to the clipboard — copy the prompt from the note above.');
p.log.warn('Could not write to the clipboard — copy the prompt above.');
return;
}
ctx.onEvent('demo_prompt_copied');
Expand Down
73 changes: 73 additions & 0 deletions src/followUp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as p from '@clack/prompts';
import clipboard from 'clipboardy';
import pc from 'picocolors';
import { brandPink, readableNoteBody } from './logo.js';

/**
* The second half of setup, offered as a copyable prompt rather than a driven
* run. Terminal harnesses get a real second hand-off (the wizard re-launches
* the agent), but app and manual hand-offs never return control to the wizard —
* it can't drive them a second time — so their enrichment step (identify users,
* link analytics, mask PII) is handed over as a prompt the user pastes into
* their agent whenever they're ready. Informational plus an optional clipboard
* copy; it never throws, because the snippet install has already succeeded and
* a cancel here must not turn a finished install into a reported failure.
*/

export interface FollowUpContext {
/** The phase-2 (enrich) prompt to hand over. */
prompt: string;
/** Harness display name ("Cursor"), or "your coding agent" for the manual path. */
agentName: string;
/** The clipboard currently holds another prompt (install/demo), so copying
* now would clobber it — warn in the confirm. */
clipboardBusy?: boolean;
/** --yes (CI): show the note, skip the interactive copy offer. */
yes: boolean;
onEvent: (event: string, properties?: Record<string, unknown>) => void;
}

export async function offerFollowUpPrompt(ctx: FollowUpContext): Promise<void> {
p.note(
readableNoteBody(
[
'The snippet install is step 1 of 2. Once it finishes, complete setup by',
`pasting the prompt shown below into ${ctx.agentName} at this project — it`,
'identifies your users, links the session URL into your analytics tools,',
'and masks sensitive data.',
].join('\n'),
),
'Finish setup (step 2 of 2)',
);
ctx.onEvent('phase2_followup_offered');

// The prompt prints as a pink block in the timeline (outside the box) right
// above the copy question — clack anchors the active prompt at the bottom,
// so nothing can render below a live question; placing it just before keeps
// it out of the box and directly beside the action.
p.log.message(
ctx.prompt
.trim()
.split('\n')
.map((line) => brandPink(line))
.join('\n'),
);

if (ctx.yes) return;

const answer = await p.confirm({
message: ctx.clipboardBusy
? `Copy the follow-up prompt above to your clipboard? ${pc.dim('(replaces what\'s on it now)')}`
: 'Copy the follow-up prompt above to your clipboard?',
});
if (p.isCancel(answer) || !answer) return;

try {
await clipboard.write(ctx.prompt);
} catch {
p.log.warn('Could not write to the clipboard — copy the prompt above.');
return;
}
ctx.onEvent('phase2_followup_copied');
p.log.success(`Follow-up prompt copied — paste it into ${ctx.agentName} when you're ready.`);
}
51 changes: 49 additions & 2 deletions src/integrations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import * as p from '@clack/prompts';
import type { WizardOptions } from './config.js';

Expand Down Expand Up @@ -140,6 +142,33 @@ export interface IntegrationSelection {
other: string[];
}

/**
* Best-effort detection of which catalog tools this app already uses, by
* matching each integration's known npm package names against `<dir>`'s
* `package.json` (dependencies + devDependencies). Used only to PRE-SELECT the
* picker — it's a convenience, not a source of truth: the agent still detects
* analytics SDKs itself during the install, so anything missed here (script-tag
* / CDN installs, monorepo layouts) is caught then. Framework-agnostic — every
* JS project has a package.json. Never throws: a missing/unparseable file just
* means "detected nothing".
*/
export function detectInstalledIntegrations(dir: string): Integration[] {
let deps: Record<string, unknown> = {};
try {
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
deps = { ...pkg.dependencies, ...pkg.devDependencies };
} catch {
return [];
}
const installed = new Set(Object.keys(deps));
return INTEGRATIONS.filter((integration) =>
integration.packages.some((name) => installed.has(name)),
);
}

/**
* Ask which analytics/product tools the app uses so the agent knows what to
* look for during the install. Pre-seeded via --integrations for CI runs.
Expand All @@ -162,13 +191,31 @@ export async function selectIntegrations(
}

const OTHER = '__other__';
// Pre-select tools we can see in package.json; the user can still uncheck
// them or add others. When nothing is detected the picker opens with nothing
// checked — same as before — and leaving it empty hands detection to the
// agent (its Step 2 explore).
const detected = detectInstalledIntegrations(options.dir);
const detectedIds = new Set(detected.map((i) => i.id));
if (detected.length > 0) {
p.log.info(
`Found in package.json: ${detected
.map((i) => i.label)
.join(', ')} — pre-selected below. Add any others we should link.`,
);
}
const picked = await p.multiselect({
message:
'Which analytics or product tools does this app use? Subtext will link session URLs into each one. (space to select, enter to confirm)',
'Which analytics or product tools does this app use? Subtext will link session URLs into each one. (space to toggle, enter to confirm)',
options: [
...INTEGRATIONS.map((i) => ({ value: i.id, label: i.label })),
...INTEGRATIONS.map((i) => ({
value: i.id,
label: i.label,
hint: detectedIds.has(i.id) ? 'detected' : undefined,
})),
{ value: OTHER, label: 'Other', hint: 'name a tool not listed' },
],
initialValues: detected.length > 0 ? detected.map((i) => i.id) : undefined,
required: false,
});
if (p.isCancel(picked)) {
Expand Down
14 changes: 14 additions & 0 deletions src/logo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ export function brandPink(text: string): string {
return `${open}${text}\x1b[39m`;
}

/**
* Render a note body at full strength. clack dims note bodies; wrapping each
* line in a reset (the same escape clack uses for note titles) undoes that so
* the text reads at normal weight — the styling the "First run" guide uses.
* Any inline color (e.g. brandPink) applied to a line survives, since the
* leading reset only clears the ambient dim before the line's own codes run.
*/
export function readableNoteBody(body: string): string {
return body
.split('\n')
.map((line) => pc.reset(line))
.join('\n');
}

/**
* A shade along the logo's pink ramp: t=0 is deep pink, t=0.5 the base,
* t=1 the glow. Used for the agent-output gutter, whose shade drifts along
Expand Down
45 changes: 33 additions & 12 deletions src/pluginSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { runTerminalAgent } from './agents/helpers.js';
import { MANUAL_CHOICE } from './agents/index.js';
import type { DetectedAgent } from './agents/types.js';
import type { Region, WizardOptions } from './config.js';
import { readableNoteBody } from './logo.js';
import { subtextMcpUrl } from './plugin.js';

/**
Expand Down Expand Up @@ -400,7 +401,7 @@ async function applyConfigWrite(
if (outcome === 'unparseable') {
onEvent('plugin_setup_failed', { agent: agentId });
p.log.warn(`Could not update ${shownPath} — it may have a format we can't merge safely.`);
p.note(pluginInstructions(agentId, region).join('\n'), 'Add it by hand');
p.note(readableNoteBody(pluginInstructions(agentId, region).join('\n')), 'Add it by hand');
return;
}
onEvent('plugin_setup_completed', { agent: agentId, method });
Expand Down Expand Up @@ -460,7 +461,7 @@ async function confirmOrSkip(
}
if (!answer) {
onEvent('plugin_setup_declined', { agent: agentId });
p.note(pluginInstructions(agentId, region).join('\n'), laterTitle);
p.note(readableNoteBody(pluginInstructions(agentId, region).join('\n')), laterTitle);
return false;
}
return true;
Expand All @@ -473,6 +474,9 @@ async function packagedPluginSetup(
region: Region,
options: WizardOptions,
onEvent: (event: string, properties?: Record<string, unknown>) => void,
/** True when consent was already captured earlier (or --yes): proceed
* through the confirm gates without asking again. */
autoYes: boolean,
): Promise<void> {
const agentId = chosen.definition.id;
const agentName = chosen.definition.name;
Expand All @@ -487,11 +491,10 @@ async function packagedPluginSetup(
return;
}

// Only an explicit --yes (CI) skips this — matching the wizard's other
// confirm gates. --agent merely preselects the harness; it never
// authorizes changes to the user's config.
// --yes (CI) or consent already captured pre-handoff skips this. --agent
// merely preselects the harness; it never authorizes changes to the config.
if (
!options.yes &&
!autoYes &&
!(await confirmOrSkip(
`Install the Subtext plugin in ${agentName}? ${pc.dim(`(${plugin.confirmHint})`)}`,
agentId,
Expand Down Expand Up @@ -533,13 +536,13 @@ async function packagedPluginSetup(
// No writable config for this harness — don't crash on an invariant
// packagedPlugin() and configWrite() only uphold by convention.
onEvent('plugin_setup_failed', { agent: agentId });
p.note(pluginInstructions(agentId, region).join('\n'), 'Add it by hand');
p.note(readableNoteBody(pluginInstructions(agentId, region).join('\n')), 'Add it by hand');
return;
}
// The user approved the plugin install, not a config-file edit — ask
// again before touching a different file (same --yes bypass as above).
// again before touching a different file (same auto-yes bypass as above).
if (
!options.yes &&
!autoYes &&
!(await confirmOrSkip(
`Add the Subtext MCP server to ${prettyPath(target.file)} instead?`,
agentId,
Expand All @@ -566,14 +569,32 @@ export async function offerPluginSetup(
region: Region,
options: WizardOptions,
onEvent: (event: string, properties?: Record<string, unknown>) => void,
/** Consent captured earlier in the flow, so this step doesn't prompt again:
* `true` proceeds silently, `false` skips with "add it later" instructions,
* `undefined` asks as usual. --yes always proceeds regardless. */
preConsent?: boolean,
): Promise<void> {
const agentId = chosen === MANUAL_CHOICE ? MANUAL_CHOICE : chosen.definition.id;
onEvent('plugin_setup_offered', { agent: agentId });

// Consent was already declined earlier — don't set anything up, just leave
// instructions for doing it later. (Never reached for MANUAL_CHOICE, which
// isn't passed a preConsent, but handled for completeness.)
if (preConsent === false) {
onEvent('plugin_setup_declined', { agent: agentId });
const lines =
chosen === MANUAL_CHOICE
? manualChoiceInstructions(region)
: pluginInstructions(agentId, region);
p.note(readableNoteBody([WHY_PLUGIN, '', ...lines].join('\n')), 'Add review tools later');
return;
}
const autoYes = options.yes || preConsent === true;

if (chosen !== MANUAL_CHOICE) {
const plugin = packagedPlugin(chosen, options);
if (plugin) {
await packagedPluginSetup(plugin, chosen, region, options, onEvent);
await packagedPluginSetup(plugin, chosen, region, options, onEvent, autoYes);
return;
}
}
Expand All @@ -586,15 +607,15 @@ export async function offerPluginSetup(
? manualChoiceInstructions(region)
: pluginInstructions(agentId, region);
onEvent('plugin_setup_completed', { agent: agentId, method: 'instructions' });
p.note([WHY_PLUGIN, '', ...lines].join('\n'), 'Add the Subtext plugin');
p.note(readableNoteBody([WHY_PLUGIN, '', ...lines].join('\n')), 'Add the Subtext plugin');
return;
}

const agentName = chosen === MANUAL_CHOICE ? 'your agent' : chosen.definition.name;
const shownPath = prettyPath(target.file);

if (
!options.yes &&
!autoYes &&
!(await confirmOrSkip(
`Add the Subtext MCP server to ${shownPath}? ${pc.dim(
`(lets ${agentName} review captured sessions)`,
Expand Down
Loading