diff --git a/bin.ts b/bin.ts index fa2846f8..fa328cc3 100644 --- a/bin.ts +++ b/bin.ts @@ -19,12 +19,11 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { process.exit(1); } -import { runWizard } from './src/run'; import { isNonInteractiveEnvironment } from './src/utils/environment'; import { getUI, setUI } from './src/ui'; import { LoggingUI } from './src/ui/logging-ui'; -import type { Integration } from './src/lib/constants'; -import type { FrameworkConfig } from './src/lib/framework-config'; +import { getSubcommandWorkflows } from './src/lib/workflows/workflow-registry'; +import type { WorkflowConfig } from './src/lib/workflows/workflow-step'; if (process.env.NODE_ENV === 'test') { void (async () => { @@ -39,7 +38,36 @@ if (process.env.NODE_ENV === 'test') { })(); } -yargs(hideBin(process.argv)) +/** Shared yargs options for skill-based workflow subcommands. */ +const skillSubcommandOptions = { + debug: { + default: false, + describe: 'Enable verbose logging', + type: 'boolean' as const, + }, + 'install-dir': { + describe: 'Directory to install in', + type: 'string' as const, + }, + 'local-mcp': { + default: false, + describe: 'Use local MCP server', + type: 'boolean' as const, + }, + benchmark: { + default: false, + describe: 'Run in benchmark mode', + type: 'boolean' as const, + }, + 'yara-report': { + default: false, + describe: 'Print YARA scanner summary', + type: 'boolean' as const, + hidden: true, + }, +}; + +const cli = yargs(hideBin(process.argv)) .env('POSTHOG_WIZARD') // global options .options({ @@ -141,6 +169,11 @@ yargs(hideBin(process.argv)) type: 'boolean', hidden: true, }, + skill: { + describe: + 'Run a specific context-mill skill by ID\nenv: POSTHOG_WIZARD_SKILL', + type: 'string', + }, }); }, (argv) => { @@ -169,7 +202,113 @@ yargs(hideBin(process.argv)) process.exit(1); } - void runWizard(options as Parameters[0]); + void (async () => { + const path = await import('path'); + const { buildSession } = await import('./src/lib/wizard-session.js'); + const { readEnvironment } = await import( + './src/utils/environment.js' + ); + const { readApiKeyFromEnv } = await import( + './src/utils/env-api-key.js' + ); + const { configureLogFileFromEnvironment } = await import( + './src/utils/debug.js' + ); + const { FRAMEWORK_REGISTRY } = await import('./src/lib/registry.js'); + const { detectFramework, gatherFrameworkContext } = await import( + './src/lib/detection/index.js' + ); + const { analytics } = await import('./src/utils/analytics.js'); + const { posthogIntegrationConfig } = await import( + './src/lib/workflows/posthog-integration/index.js' + ); + const { wizardAbort } = await import('./src/utils/wizard-abort.js'); + const { logToFile } = await import('./src/utils/debug.js'); + + configureLogFileFromEnvironment(); + + const env = readEnvironment(); + const apiKey = + (options.apiKey as string) ?? readApiKeyFromEnv() ?? undefined; + const installDir = path.isAbsolute(options.installDir as string) + ? (options.installDir as string) + : path.join(process.cwd(), options.installDir as string); + + const session = buildSession({ + debug: options.debug as boolean | undefined, + forceInstall: options.forceInstall as boolean | undefined, + installDir, + ci: true, + signup: options.signup as boolean | undefined, + localMcp: options.localMcp as boolean | undefined, + apiKey, + menu: options.menu as boolean | undefined, + integration: options.integration as any, + benchmark: options.benchmark as boolean | undefined, + yaraReport: options.yaraReport as boolean | undefined, + projectId: options.projectId as string | undefined, + ...env, + }); + + getUI().intro('Welcome to the PostHog setup wizard'); + getUI().log.info('Running in CI mode'); + + // Detect framework + const integration = + session.integration ?? (await detectFramework(installDir)); + if (!integration) { + return wizardAbort({ + message: + 'Could not auto-detect your framework. Please specify --integration on the command line.', + }); + } + session.integration = integration; + analytics.setTag('integration', integration); + + const frameworkConfig = FRAMEWORK_REGISTRY[integration]; + session.frameworkConfig = frameworkConfig; + + // Gather context + const context = await gatherFrameworkContext(frameworkConfig, { + installDir, + debug: session.debug, + forceInstall: session.forceInstall, + default: false, + signup: session.signup, + localMcp: session.localMcp, + ci: true, + menu: session.menu, + benchmark: session.benchmark, + yaraReport: session.yaraReport, + }); + for (const [key, value] of Object.entries(context)) { + if (!(key in session.frameworkContext)) { + session.frameworkContext[key] = value; + } + } + + try { + const { runAgent } = await import( + './src/lib/agent/agent-runner.js' + ); + await runAgent(posthogIntegrationConfig, session); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = + error instanceof Error && error.stack ? error.stack : undefined; + + logToFile(`[bin.ts CI] ERROR: ${errorMessage}`); + if (errorStack) logToFile(`[bin.ts CI] STACK: ${errorStack}`); + + const debugInfo = + session.debug && errorStack ? `\n\n${errorStack}` : ''; + await wizardAbort({ + message: `Something went wrong: ${errorMessage}\n\nYou can read the documentation at ${frameworkConfig.metadata.docsUrl} to set up PostHog manually.${debugInfo}`, + error: error as Error, + }); + } + })(); } else if (isNonInteractiveEnvironment()) { // Non-interactive non-CI: error out getUI().intro(`PostHog Wizard`); @@ -189,204 +328,35 @@ yargs(hideBin(process.argv)) ); (startPlayground as (version: string) => void)(WIZARD_VERSION); })(); + } else if (options.skill) { + // Run a specific skill by ID + void (async () => { + const { createSkillWorkflow } = await import( + './src/lib/workflows/agent-skill/index.js' + ); + const skillId = options.skill as string; + const config = createSkillWorkflow({ + skillId, + command: 'skill', + flowKey: 'agent-skill', + description: `Run skill: ${skillId}`, + integrationLabel: skillId, + successMessage: `${skillId} completed!`, + reportFile: `posthog-${skillId}-report.md`, + docsUrl: 'https://posthog.com/docs', + spinnerMessage: `Running ${skillId}...`, + estimatedDurationMinutes: 5, + }); + runWizard(config, options); + })(); } else { - // Interactive TTY: launch the Ink TUI + // Interactive TTY: run core-integration through the unified workflow path. + // Same codepath as `npx @posthog/wizard integrate`. void (async () => { - try { - const { startTUI } = await import('./src/ui/tui/start-tui.js'); - const { buildSession } = await import( - './src/lib/wizard-session.js' - ); - - const tui = startTUI(WIZARD_VERSION); - - // Build session from CLI args and attach to store - const session = buildSession({ - debug: options.debug as boolean | undefined, - forceInstall: options.forceInstall as boolean | undefined, - installDir: options.installDir as string | undefined, - ci: false, - signup: options.signup as boolean | undefined, - localMcp: options.localMcp as boolean | undefined, - apiKey: options.apiKey as string | undefined, - menu: options.menu as boolean | undefined, - integration: options.integration as Parameters< - typeof buildSession - >[0]['integration'], - benchmark: options.benchmark as boolean | undefined, - yaraReport: options.yaraReport as boolean | undefined, - projectId: options.projectId as string | undefined, - }); - tui.store.session = session; - - // Detect framework while IntroScreen shows its spinner. - // Runs concurrently — IntroScreen reacts when detection completes. - const { FRAMEWORK_REGISTRY } = (await import( - './src/lib/registry.js' - )) as { FRAMEWORK_REGISTRY: Record }; - const { detectIntegration } = (await import('./src/run.js')) as { - detectIntegration: ( - installDir: string, - ) => Promise; - }; - const installDir = session.installDir ?? process.cwd(); - - const { DETECTION_TIMEOUT_MS } = (await import( - './src/lib/constants.js' - )) as { DETECTION_TIMEOUT_MS: number }; - - const detectedIntegration = await Promise.race([ - detectIntegration(installDir), - new Promise((resolve) => - setTimeout(() => resolve(undefined), DETECTION_TIMEOUT_MS), - ), - ]); - - if (detectedIntegration) { - const config = FRAMEWORK_REGISTRY[detectedIntegration]; - - // Run gatherContext for the friendly variant label - if (config.metadata.gatherContext) { - try { - const context = await Promise.race([ - config.metadata.gatherContext({ - installDir, - debug: session.debug, - forceInstall: session.forceInstall, - default: false, - signup: session.signup, - localMcp: session.localMcp, - ci: session.ci, - menu: session.menu, - benchmark: session.benchmark, - yaraReport: session.yaraReport, - }), - new Promise>((resolve) => - setTimeout(() => resolve({}), DETECTION_TIMEOUT_MS), - ), - ]); - for (const [key, value] of Object.entries(context)) { - if (!(key in session.frameworkContext)) { - tui.store.setFrameworkContext(key, value); - } - } - } catch { - // Detection failed — will show generic name - } - } - - tui.store.setFrameworkConfig(detectedIntegration, config); - - if (!session.detectedFrameworkLabel) { - tui.store.setDetectedFramework(config.metadata.name); - } - - // Early version check — surface on IntroScreen before user proceeds - if ( - config.detection.minimumVersion && - config.detection.getInstalledVersion - ) { - const semver = await import('semver'); - const version = await config.detection.getInstalledVersion({ - installDir, - debug: session.debug, - forceInstall: session.forceInstall, - default: false, - signup: session.signup, - localMcp: session.localMcp, - ci: session.ci, - menu: session.menu, - benchmark: session.benchmark, - yaraReport: session.yaraReport, - }); - if (version) { - const coerced = semver.coerce(version); - if ( - coerced && - semver.lt(coerced, config.detection.minimumVersion) - ) { - tui.store.setUnsupportedVersion({ - current: version, - minimum: config.detection.minimumVersion, - docsUrl: - config.metadata.unsupportedVersionDocsUrl ?? - config.metadata.docsUrl, - }); - } - } - } - } - - // Feature discovery — deterministic scan of package.json deps - try { - const { readFileSync } = await import('fs'); - const pkgPath = require('path').join(installDir, 'package.json'); - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); - const allDeps = { - ...pkg.dependencies, - ...pkg.devDependencies, - }; - const depNames = Object.keys(allDeps); - - const { DiscoveredFeature } = await import( - './src/lib/wizard-session.js' - ); - - if ( - depNames.some((d) => - ['stripe', '@stripe/stripe-js'].includes(d), - ) - ) { - tui.store.addDiscoveredFeature(DiscoveredFeature.Stripe); - } - - // LLM SDK detection — sourced from PostHog LLM analytics skill - const LLM_PACKAGES = [ - 'openai', - '@anthropic-ai/sdk', - 'ai', - '@ai-sdk/openai', - 'langchain', - '@langchain/openai', - '@langchain/langgraph', - '@google/generative-ai', - '@google/genai', - '@instructor-ai/instructor', - '@mastra/core', - 'portkey-ai', - ]; - if (depNames.some((d) => LLM_PACKAGES.includes(d))) { - tui.store.addDiscoveredFeature(DiscoveredFeature.LLM); - } - } catch { - // No package.json or parse error — skip feature discovery - } - - // Signal detection is done — IntroScreen shows picker or results - tui.store.setDetectionComplete(); - - // Wait for IntroScreen confirmation - await tui.waitForSetup(); - - // Ensure health check has completed before starting the wizard. - // The flow gate on Intro (readinessResult !== null) keeps the - // TUI on IntroScreen until this resolves. If blocking, the - // outage overlay was already pushed in the .then() callback. - await tui.store.getGate('health-check'); - - await runWizard( - options as Parameters[0], - tui.store.session, - ); - - // Keep the outro screen visible — let process.exit() handle cleanup - } catch (err) { - // TUI unavailable (e.g., in test environment) — continue with default UI - if (process.env.DEBUG || process.env.POSTHOG_WIZARD_DEBUG) { - console.error('TUI init failed:', err); // eslint-disable-line no-console - } - await runWizard(options as Parameters[0]); - } + const { posthogIntegrationConfig } = await import( + './src/lib/workflows/posthog-integration/index.js' + ); + runWizard(posthogIntegrationConfig, options); })(); } }, @@ -503,168 +473,91 @@ yargs(hideBin(process.argv)) ) .demandCommand(1, 'You must specify a subcommand (add or remove)') .help(); - }) - .command( - 'revenue', - 'Set up PostHog revenue analytics (e.g. Stripe integration)', - (yargs) => { - return yargs.options({ - debug: { - default: false, - describe: 'Enable verbose logging', - type: 'boolean', - }, - 'install-dir': { - describe: 'Directory to install in', - type: 'string', - }, - 'local-mcp': { - default: false, - describe: 'Use local MCP server', - type: 'boolean', - }, - benchmark: { - default: false, - describe: 'Run in benchmark mode', - type: 'boolean', - }, - 'yara-report': { - default: false, - describe: 'Print YARA scanner summary', - type: 'boolean', - hidden: true, - }, - }); - }, - (argv) => { - const options = { ...argv }; - - // CI mode: validate required flags, use LoggingUI, skip the TUI entirely - if (options.ci) { - setUI(new LoggingUI()); - if (!options.region) { - options.region = 'us'; - } - if (!options.apiKey) { - getUI().intro(`PostHog Wizard`); - getUI().log.error( - 'CI mode requires --api-key (personal API key phx_xxx)', - ); - process.exit(1); - } - if (!options.installDir) { - getUI().intro(`PostHog Wizard`); - getUI().log.error( - 'CI mode requires --install-dir (directory to install PostHog in)', - ); - process.exit(1); - } - - void (async () => { - const { buildSession } = await import('./src/lib/wizard-session.js'); - const session = buildSession({ - debug: options.debug as boolean | undefined, - installDir: options.installDir as string, - ci: true, - apiKey: options.apiKey as string, - localMcp: options.localMcp as boolean | undefined, - benchmark: options.benchmark as boolean | undefined, - yaraReport: options.yaraReport as boolean | undefined, - projectId: options.projectId as string | undefined, - }); - - // Run detection inline (no store / TUI to register an onReady hook on) - const { detectRevenuePrerequisites } = await import( - './src/lib/workflows/revenue-analytics.js' - ); - detectRevenuePrerequisites(session, (k: string, v: unknown) => { - session.frameworkContext = { - ...session.frameworkContext, - [k]: v, - }; - }); - const detectError = session.frameworkContext.detectError; - if (detectError) { - getUI().log.error( - typeof detectError === 'string' - ? detectError - : JSON.stringify(detectError), - ); - process.exit(1); - } - - const { runRevenueWizard } = await import( - './src/lib/revenue-runner.js' - ); - await runRevenueWizard(session); - process.exit(0); - })(); - return; - } - - // Interactive TTY: launch the Ink TUI - void (async () => { - try { - const installDir = (options.installDir as string) || process.cwd(); - - const { startTUI } = await import('./src/ui/tui/start-tui.js'); - const { buildSession } = await import('./src/lib/wizard-session.js'); - const { Flow } = await import('./src/ui/tui/router.js'); + }); + +// ── Skill-based workflow subcommands (derived from registry) ───────── +for (const wfConfig of getSubcommandWorkflows()) { + cli.command( + wfConfig.command!, + wfConfig.description, + (y) => y.options(skillSubcommandOptions), + (argv) => runWizard(wfConfig, { ...argv }), + ); +} - const tui = startTUI(WIZARD_VERSION, Flow.Revenue); +cli + .help() + .alias('help', 'h') + .version() + .alias('version', 'v') + .wrap(process.stdout.isTTY ? yargs.terminalWidth() : 80).argv; - const session = buildSession({ - debug: options.debug, - localMcp: options.localMcp as boolean | undefined, - installDir, - ci: false, - benchmark: options.benchmark as boolean | undefined, - yaraReport: options.yaraReport as boolean | undefined, - }); - tui.store.session = session; +/** + * Run a full wizard workflow in the TUI. Handles the full lifecycle: start TUI, + * build session, run detection, wait for intro gate, execute the + * agent pipeline, wait for outro dismissal, then exit. + */ +function runWizard( + config: WorkflowConfig, + options: Record, +): void { + void (async () => { + try { + const installDir = (options.installDir as string) || process.cwd(); + + const { startTUI } = await import('./src/ui/tui/start-tui.js'); + const { buildSession } = await import('./src/lib/wizard-session.js'); + + // flowKey values match Flow enum values by convention + const tui = startTUI(WIZARD_VERSION, config.flowKey as any); + + const session = buildSession({ + debug: options.debug as boolean | undefined, + forceInstall: options.forceInstall as boolean | undefined, + localMcp: options.localMcp as boolean | undefined, + installDir, + ci: false, + signup: options.signup as boolean | undefined, + apiKey: options.apiKey as string | undefined, + projectId: options.projectId as string | undefined, + menu: options.menu as boolean | undefined, + integration: options.integration as any, + benchmark: options.benchmark as boolean | undefined, + yaraReport: options.yaraReport as boolean | undefined, + }); + // Set workflow metadata for TUI display + session.workflowLabel = config.flowKey; + const runDef = typeof config.run === 'object' ? config.run : null; + session.skillId = runDef?.skillId ?? null; - // Run any workflow-declared pre-flow work (e.g. prerequisite - // detection + skill download for the revenue flow). - await tui.store.runReadyHooks(); + tui.store.session = session; - // Wait for the intro screen — it handles both the success state - // (detected SDKs + confirm) and the error state (detectError + exit). - await tui.store.getGate('intro'); + await tui.store.runReadyHooks(); + await tui.store.getGate('intro'); - const { runRevenueWizard } = await import( - './src/lib/revenue-runner.js' - ); - await runRevenueWizard(tui.store.session); + const { runAgent } = await import('./src/lib/agent/agent-runner.js'); + await runAgent(config, tui.store.session); - // Outro is now visible. Wait for the user to press a key to dismiss. - // Revenue flow has no skills step after outro, so we exit here. - tui.store.onEnterScreen('outro' as any, () => { - // Screen is already outro — listen for dismissal - }); - await new Promise((resolve) => { - const unsub = tui.store.subscribe(() => { - if (tui.store.session.outroDismissed) { - unsub(); - resolve(); - } - }); - // Check if already dismissed - if (tui.store.session.outroDismissed) { - unsub(); - resolve(); - } - }); - process.exit(0); - } catch (err) { - if (process.env.DEBUG || process.env.POSTHOG_WIZARD_DEBUG) { - console.error('TUI init failed:', err); // eslint-disable-line no-console + tui.store.onEnterScreen('outro' as any, () => { + // Screen is already outro — listen for dismissal + }); + await new Promise((resolve) => { + const unsub = tui.store.subscribe(() => { + if (tui.store.session.outroDismissed) { + unsub(); + resolve(); } + }); + if (tui.store.session.outroDismissed) { + unsub(); + resolve(); } - })(); - }, - ) - .help() - .alias('help', 'h') - .version() - .alias('version', 'v') - .wrap(process.stdout.isTTY ? yargs.terminalWidth() : 80).argv; + }); + process.exit(0); + } catch (err) { + if (process.env.DEBUG || process.env.POSTHOG_WIZARD_DEBUG) { + console.error('TUI init failed:', err); // eslint-disable-line no-console + } + } + })(); +} diff --git a/src/__tests__/run.test.ts b/src/__tests__/run.test.ts deleted file mode 100644 index b1e65795..00000000 --- a/src/__tests__/run.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { runWizard } from '../run'; -import { runAgentWizard } from '../lib/agent-runner'; -import { analytics } from '../utils/analytics'; -import { Integration } from '../lib/constants'; - -jest.mock('../lib/agent-runner'); -jest.mock('../utils/analytics'); -jest.mock('../lib/wizard-session', () => ({ - buildSession: (args: Record) => ({ - debug: false, - forceInstall: false, - installDir: process.cwd(), - ci: false, - signup: false, - localMcp: false, - menu: false, - setupConfirmed: false, - integration: null, - frameworkContext: {}, - typescript: false, - credentials: null, - readinessResult: null, - outageDismissed: false, - outroData: null, - frameworkConfig: null, - ...args, - }), -})); -jest.mock('../ui', () => ({ - getUI: jest.fn().mockReturnValue({ - log: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - success: jest.fn(), - step: jest.fn(), - }, - intro: jest.fn(), - outro: jest.fn(), - cancel: jest.fn(), - note: jest.fn(), - spinner: jest.fn().mockReturnValue({ - start: jest.fn(), - stop: jest.fn(), - message: jest.fn(), - }), - setDetectedFramework: jest.fn(), - setCredentials: jest.fn(), - pushStatus: jest.fn(), - syncTodos: jest.fn(), - setLoginUrl: jest.fn(), - showBlockingOutage: jest.fn(), - setReadinessWarnings: jest.fn(), - showSettingsOverride: jest.fn(), - startRun: jest.fn(), - }), - setUI: jest.fn(), -})); - -const mockRunAgentWizard = runAgentWizard as jest.MockedFunction< - typeof runAgentWizard ->; -const mockAnalytics = analytics as jest.Mocked; - -describe('runWizard error handling', () => { - beforeEach(() => { - jest.clearAllMocks(); - - mockAnalytics.setTag = jest.fn(); - mockAnalytics.captureException = jest.fn(); - mockAnalytics.shutdown = jest.fn().mockResolvedValue(undefined); - - jest.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should capture exception and shutdown analytics on wizard error', async () => { - const testError = new Error('Wizard failed'); - const testArgs = { - integration: Integration.nextjs, - debug: true, - forceInstall: false, - }; - - mockRunAgentWizard.mockRejectedValue(testError); - - await expect(runWizard(testArgs)).rejects.toThrow('process.exit called'); - - expect(mockAnalytics.captureException).toHaveBeenCalledWith(testError, {}); - - expect(mockAnalytics.shutdown).toHaveBeenCalledWith('error'); - }); - - it('should not call captureException when wizard succeeds', async () => { - const testArgs = { integration: Integration.nextjs }; - - mockRunAgentWizard.mockResolvedValue(undefined); - - await runWizard(testArgs); - - expect(mockAnalytics.captureException).not.toHaveBeenCalled(); - expect(mockAnalytics.shutdown).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/__tests__/agent-interface.test.ts b/src/lib/__tests__/agent-interface.test.ts index e1ba0d83..e819c64b 100644 --- a/src/lib/__tests__/agent-interface.test.ts +++ b/src/lib/__tests__/agent-interface.test.ts @@ -1,4 +1,4 @@ -import { runAgent, createStopHook } from '../agent-interface'; +import { runAgent, createStopHook } from '../agent/agent-interface'; import type { WizardOptions } from '../../utils/types'; import type { SpinnerHandle } from '../../ui'; import { diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts deleted file mode 100644 index 94208bf6..00000000 --- a/src/lib/agent-runner.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { - DEFAULT_PACKAGE_INSTALLATION, - SPINNER_MESSAGE, - type FrameworkConfig, -} from './framework-config'; -import { type WizardSession, OutroKind } from './wizard-session'; -import { - tryGetPackageJson, - isUsingTypeScript, - getOrAskForProjectData, -} from '../utils/setup-utils'; -import type { PackageDotJson } from '../utils/package-json'; -import type { WizardOptions } from '../utils/types'; -import { WIZARD_INTERACTION_EVENT_NAME, getSkillsBaseUrl } from './constants'; -import { analytics } from '../utils/analytics'; -import { getUI } from '../ui'; -import { - initializeAgent, - runAgent, - AgentSignals, - AgentErrorType, - buildWizardMetadata, - checkAllSettingsConflicts, - backupAndFixClaudeSettings, - restoreClaudeSettings, -} from './agent-interface'; -import { getCloudUrlFromRegion } from '../utils/urls'; - -import * as semver from 'semver'; -import { - evaluateWizardReadiness, - WizardReadiness, -} from './health-checks/readiness'; -import { enableDebugLogs, initLogFile, logToFile } from '../utils/debug'; -import { createBenchmarkPipeline } from './middleware/benchmark'; -import { - wizardAbort, - WizardError, - registerCleanup, -} from '../utils/wizard-abort'; -import { formatScanReport, writeScanReport } from './yara-hooks'; - -/** - * Build a WizardOptions bag from a WizardSession (for code that still expects WizardOptions). - */ -function sessionToOptions(session: WizardSession): WizardOptions { - return { - installDir: session.installDir, - debug: session.debug, - forceInstall: session.forceInstall, - default: false, - signup: session.signup, - localMcp: session.localMcp, - ci: session.ci, - menu: session.menu, - benchmark: session.benchmark, - projectId: session.projectId, - apiKey: session.apiKey, - yaraReport: session.yaraReport, - }; -} - -/** - * Universal agent-powered wizard runner. - * Handles the complete flow for any framework using PostHog MCP integration. - * - * All user decisions come from the session — no UI prompts. - */ -export async function runAgentWizard( - config: FrameworkConfig, - session: WizardSession, -): Promise { - initLogFile(); - logToFile(`[agent-runner] START integration=${config.metadata.integration}`); - - if (session.debug) { - enableDebugLogs(); - } - - // Version check - if (config.detection.minimumVersion && config.detection.getInstalledVersion) { - logToFile('[agent-runner] checking version'); - const version = await config.detection.getInstalledVersion( - sessionToOptions(session), - ); - if (version) { - logToFile( - `[agent-runner] version=${version} minimum=${config.detection.minimumVersion}`, - ); - const coerced = semver.coerce(version); - if (coerced && semver.lt(coerced, config.detection.minimumVersion)) { - const docsUrl = - config.metadata.unsupportedVersionDocsUrl ?? config.metadata.docsUrl; - await wizardAbort({ - message: - `Sorry: the wizard can't help you with ${config.metadata.name} ${version}. ` + - `Upgrade to ${config.metadata.name} ${config.detection.minimumVersion} or later, ` + - `or check out the manual setup guide.\n\n` + - `Setup ${config.metadata.name} manually: ${docsUrl}`, - }); - } - } - } - - const skillsBaseUrl = getSkillsBaseUrl(session.localMcp); - - // Check all external service health (skip if TUI already ran it in bin.ts) - if (!session.readinessResult) { - logToFile('[agent-runner] evaluating wizard readiness'); - const readiness = await evaluateWizardReadiness(); - logToFile(`[agent-runner] readiness=${readiness.decision}`); - if (readiness.decision === WizardReadiness.No) { - await getUI().showBlockingOutage(readiness); - } else if (readiness.decision === WizardReadiness.YesWithWarnings) { - getUI().setReadinessWarnings(readiness); - } - } - - // Check ALL settings sources for blocking overrides before login. - const settingsConflicts = checkAllSettingsConflicts(session.installDir); - logToFile( - `[agent-runner] settings conflicts: ${ - settingsConflicts.length > 0 - ? settingsConflicts - .map((c) => `${c.source}(${c.keys.join(',')})`) - .join('; ') - : 'none' - }`, - ); - - if (settingsConflicts.length > 0) { - // Capture analytics for each conflict variation - for (const conflict of settingsConflicts) { - const level = conflict.source === 'managed' ? 'org' : conflict.source; - analytics.wizardCapture('settings conflict detected', { - level, - keys: conflict.keys, - }); - } - - await getUI().showSettingsOverride(settingsConflicts, () => - backupAndFixClaudeSettings(session.installDir), - ); - logToFile('[agent-runner] settings override resolved'); - } - - const typeScriptDetected = isUsingTypeScript({ - installDir: session.installDir, - }); - session.typescript = typeScriptDetected; - - // Framework detection and version - const usesPackageJson = config.detection.usesPackageJson !== false; - let packageJson: PackageDotJson | null = null; - let frameworkVersion: string | undefined; - - if (usesPackageJson) { - packageJson = await tryGetPackageJson({ installDir: session.installDir }); - if (packageJson) { - const { hasPackageInstalled } = await import('../utils/package-json.js'); - if (!hasPackageInstalled(config.detection.packageName, packageJson)) { - getUI().log.warn( - `${config.detection.packageDisplayName} does not seem to be installed. Continuing anyway — the agent will handle it.`, - ); - } - frameworkVersion = config.detection.getVersion(packageJson); - } else { - getUI().log.warn( - 'Could not find package.json. Continuing anyway — the agent will handle it.', - ); - } - } else { - frameworkVersion = config.detection.getVersion(null); - } - - // Set analytics tags for framework version - if (frameworkVersion && config.detection.getVersionBucket) { - const versionBucket = config.detection.getVersionBucket(frameworkVersion); - analytics.setTag(`${config.metadata.integration}-version`, versionBucket); - } - - analytics.wizardCapture('agent started', { - integration: config.metadata.integration, - }); - - // Get PostHog credentials (region auto-detected from token) - logToFile('[agent-runner] starting OAuth'); - const { projectApiKey, host, accessToken, projectId, cloudRegion } = - await getOrAskForProjectData({ - signup: session.signup, - ci: session.ci, - apiKey: session.apiKey, - projectId: session.projectId, - }); - - session.credentials = { accessToken, projectApiKey, host, projectId }; - - // Notify TUI that credentials are available (resolves past AuthScreen) - getUI().setCredentials(session.credentials); - - // Framework context was already gathered by SetupScreen + detection - const frameworkContext = session.frameworkContext; - - // Set analytics tags from framework context - const contextTags = config.analytics.getTags(frameworkContext); - Object.entries(contextTags).forEach(([key, value]) => { - analytics.setTag(key, value); - }); - - const integrationPrompt = buildIntegrationPrompt( - config, - { - frameworkVersion: frameworkVersion || 'latest', - typescript: typeScriptDetected, - projectApiKey, - host, - projectId, - }, - frameworkContext, - ); - - // Initialize and run agent - const spinner = getUI().spinner(); - - // Evaluate all feature flags at the start of the run so they can be sent to the LLM gateway - const wizardFlags = await analytics.getAllFlagsForWizard(); - const wizardMetadata = buildWizardMetadata(wizardFlags); - - // Determine MCP URL: CLI flag > env var > production default - const mcpUrl = session.localMcp - ? 'http://localhost:8787/mcp' - : process.env.MCP_URL || - (cloudRegion === 'eu' - ? 'https://mcp-eu.posthog.com/mcp' - : 'https://mcp.posthog.com/mcp'); - - const restoreSettings = () => restoreClaudeSettings(session.installDir); - getUI().onEnterScreen('outro', restoreSettings); - - // Register YARA report as cleanup so it fires on any exit path (including wizardAbort) - if (session.yaraReport) { - registerCleanup(() => { - const reportPath = writeScanReport(); - if (reportPath) { - const summary = formatScanReport(); - getUI().log.info(`YARA scan report: ${reportPath}${summary ?? ''}`); - } - }); - } - - getUI().startRun(); - - const agent = await initializeAgent( - { - workingDirectory: session.installDir, - posthogMcpUrl: mcpUrl, - posthogApiKey: accessToken, - posthogApiHost: host, - additionalMcpServers: config.metadata.additionalMcpServers, - detectPackageManager: config.detection.detectPackageManager, - skillsBaseUrl, - wizardFlags, - wizardMetadata, - }, - sessionToOptions(session), - ); - - const middleware = session.benchmark - ? createBenchmarkPipeline(spinner, sessionToOptions(session)) - : undefined; - - const agentResult = await runAgent( - agent, - integrationPrompt, - sessionToOptions(session), - spinner, - { - estimatedDurationMinutes: config.ui.estimatedDurationMinutes, - spinnerMessage: SPINNER_MESSAGE, - successMessage: config.ui.successMessage, - errorMessage: 'Integration failed', - additionalFeatureQueue: session.additionalFeatureQueue, - }, - middleware, - ); - - // Handle error cases detected in agent output - if (agentResult.error === AgentErrorType.MCP_MISSING) { - await wizardAbort({ - message: `Could not access the PostHog MCP server\n\nThe wizard was unable to connect to the PostHog MCP server.\nThis could be due to a network issue or a configuration problem.\n\nPlease try again, or set up ${config.metadata.name} manually by following our documentation:\n${config.metadata.docsUrl}`, - error: new WizardError('Agent could not access PostHog MCP server', { - integration: config.metadata.integration, - error_type: AgentErrorType.MCP_MISSING, - signal: AgentSignals.ERROR_MCP_MISSING, - }), - }); - } - - if (agentResult.error === AgentErrorType.RESOURCE_MISSING) { - await wizardAbort({ - message: `Could not access the setup resource\n\nThe wizard could not access the setup resource. This may indicate a version mismatch or a temporary service issue.\n\nPlease try again, or set up ${config.metadata.name} manually by following our documentation:\n${config.metadata.docsUrl}`, - error: new WizardError('Agent could not access setup resource', { - integration: config.metadata.integration, - error_type: AgentErrorType.RESOURCE_MISSING, - signal: AgentSignals.ERROR_RESOURCE_MISSING, - }), - }); - } - - if (agentResult.error === AgentErrorType.YARA_VIOLATION) { - await wizardAbort({ - message: - 'Security violation detected\n\nThe YARA scanner terminated the session after detecting a security violation.\nThis may indicate prompt injection, poisoned skill files, or a policy breach.\n\nPlease report this to: wizard@posthog.com', - error: new WizardError('YARA scanner terminated session', { - integration: config.metadata.integration, - error_type: AgentErrorType.YARA_VIOLATION, - }), - }); - } - - if ( - agentResult.error === AgentErrorType.RATE_LIMIT || - agentResult.error === AgentErrorType.API_ERROR - ) { - analytics.wizardCapture('agent api error', { - integration: config.metadata.integration, - error_type: agentResult.error, - error_message: agentResult.message, - }); - - await wizardAbort({ - message: `API Error\n\n${ - agentResult.message || 'Unknown error' - }\n\nPlease report this error to: wizard@posthog.com`, - error: new WizardError(`API error: ${agentResult.message}`, { - integration: config.metadata.integration, - error_type: agentResult.error, - }), - }); - } - - // Build environment variables from OAuth credentials - const envVars = config.environment.getEnvVars(projectApiKey, host); - - // Upload environment variables to hosting providers (auto-accept) - let uploadedEnvVars: string[] = []; - if (config.environment.uploadToHosting) { - const { uploadEnvironmentVariablesStep } = await import( - '../steps/index.js' - ); - uploadedEnvVars = await uploadEnvironmentVariablesStep(envVars, { - integration: config.metadata.integration, - session, - }); - if (uploadedEnvVars.length > 0) { - analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { - action: 'wizard_env_vars_uploaded', - integration: config.metadata.integration, - variable_count: uploadedEnvVars.length, - variable_keys: uploadedEnvVars, - }); - } - } - - // MCP installation is handled by McpScreen — no prompt here - - // Build outro data and store it for OutroScreen - const continueUrl = session.signup - ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` - : undefined; - - const changes = [ - ...config.ui.getOutroChanges(frameworkContext), - Object.keys(envVars).length > 0 - ? `Added environment variables to .env file` - : '', - uploadedEnvVars.length > 0 - ? `Uploaded environment variables to your hosting provider` - : '', - ].filter(Boolean); - - session.outroData = { - kind: OutroKind.Success, - message: 'Successfully installed PostHog!', - reportFile: 'posthog-setup-report.md', - changes, - docsUrl: config.metadata.docsUrl, - continueUrl, - }; - - getUI().outro('Successfully installed PostHog!'); - - await analytics.shutdown('success'); -} - -/** - * Build the integration prompt for the agent. - */ -function buildIntegrationPrompt( - config: FrameworkConfig, - context: { - frameworkVersion: string; - typescript: boolean; - projectApiKey: string; - host: string; - projectId: number; - }, - frameworkContext: Record, -): string { - const additionalLines = config.prompts.getAdditionalContextLines - ? config.prompts.getAdditionalContextLines(frameworkContext) - : []; - - const additionalContext = - additionalLines.length > 0 - ? '\n' + additionalLines.map((line) => `- ${line}`).join('\n') - : ''; - - return `You have access to the PostHog MCP server which provides skills to integrate PostHog into this ${ - config.metadata.name - } project. - -Project context: -- PostHog Project ID: ${context.projectId} -- Framework: ${config.metadata.name} ${context.frameworkVersion} -- TypeScript: ${context.typescript ? 'Yes' : 'No'} -- PostHog public token: ${context.projectApiKey} -- PostHog Host: ${context.host} -- Project type: ${config.prompts.projectTypeDetection} -- Package installation: ${ - config.prompts.packageInstallation ?? DEFAULT_PACKAGE_INSTALLATION - }${additionalContext} - -Instructions (follow these steps IN ORDER - do not skip or reorder): - -STEP 1: Call load_skill_menu (from the wizard-tools MCP server) to see available skills. - If the tool fails, emit: ${ - AgentSignals.ERROR_MCP_MISSING - } Could not load skill menu and halt. - - Choose a skill from the \`integration\` category that matches this project's framework. Do NOT pick skills from other categories (llm-analytics, error-tracking, feature-flags, omnibus, etc.) — those are handled separately. - If no suitable integration skill is found, emit: ${ - AgentSignals.ERROR_RESOURCE_MISSING - } Could not find a suitable skill for this project. - -STEP 2: Call install_skill (from the wizard-tools MCP server) with the chosen skill ID (e.g., "integration-nextjs-app-router"). - Do NOT run any shell commands to install skills. - -STEP 3: Load the installed skill's SKILL.md file to understand what references are available. - -STEP 4: Follow the skill's workflow files in sequence. Look for numbered workflow files in the references (e.g., files with patterns like "1.0-", "1.1-", "1.2-"). Start with the first one and proceed through each step until completion. Each workflow file will tell you what to do and which file comes next. Never directly write PostHog tokens directly to code files; always use environment variables. - -STEP 5: Set up environment variables for PostHog using the wizard-tools MCP server (this runs locally — secret values never leave the machine): - - Use check_env_keys to see which keys already exist in the project's .env file (e.g. .env.local or .env). - - Use set_env_values to create or update the PostHog public token and host, using the appropriate environment variable naming convention for ${ - config.metadata.name - }, which you'll find in example code. The tool will also ensure .gitignore coverage. Don't assume the presence of keys means the value is up to date. Write the correct value each time. - - Reference these environment variables in the code files you create instead of hardcoding the public token and host. - -Important: Use the detect_package_manager tool (from the wizard-tools MCP server) to determine which package manager the project uses. Do not manually search for lockfiles or config files. Always install packages as a background task. Don't await completion; proceed with other work immediately after starting the installation. You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure. - - -`; -} diff --git a/src/lib/agent-interface.ts b/src/lib/agent/agent-interface.ts similarity index 98% rename from src/lib/agent-interface.ts rename to src/lib/agent/agent-interface.ts index 1dca0cce..f9c9a57d 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -5,36 +5,41 @@ import path from 'path'; import * as fs from 'fs'; -import { getUI, type SpinnerHandle } from '../ui'; -import { debug, logToFile, initLogFile, getLogFilePath } from '../utils/debug'; -import type { WizardOptions } from '../utils/types'; -import { analytics } from '../utils/analytics'; +import { getUI, type SpinnerHandle } from '../../ui'; +import { + debug, + logToFile, + initLogFile, + getLogFilePath, +} from '../../utils/debug'; +import type { WizardOptions } from '../../utils/types'; +import { analytics } from '../../utils/analytics'; import { WIZARD_REMARK_EVENT_NAME, POSTHOG_PROPERTY_HEADER_PREFIX, WIZARD_VARIANT_FLAG_KEY, WIZARD_VARIANTS, WIZARD_USER_AGENT, -} from './constants'; +} from '../constants'; import { type AdditionalFeature, ADDITIONAL_FEATURE_PROMPTS, -} from './wizard-session'; +} from '../wizard-session'; import { registerCleanup, wizardAbort, WizardError, -} from '../utils/wizard-abort'; -import { createCustomHeaders } from '../utils/custom-headers'; -import { getLlmGatewayUrlFromHost } from '../utils/urls'; -import { LINTING_TOOLS } from './safe-tools'; -import { createWizardToolsServer, WIZARD_TOOL_NAMES } from './wizard-tools'; +} from '../../utils/wizard-abort'; +import { createCustomHeaders } from '../../utils/custom-headers'; +import { getLlmGatewayUrlFromHost } from '../../utils/urls'; +import { LINTING_TOOLS } from '../safe-tools'; +import { createWizardToolsServer, WIZARD_TOOL_NAMES } from '../wizard-tools'; import { createPreToolUseYaraHooks, createPostToolUseYaraHooks, -} from './yara-hooks'; -import { getWizardCommandments } from './commandments'; -import type { PackageManagerDetector } from './package-manager-detection'; +} from '../yara-hooks'; +import { getWizardCommandments } from '../commandments'; +import type { PackageManagerDetector } from '../package-manager-detection'; // Dynamic import cache for ESM module let _sdkModule: any = null; @@ -438,7 +443,7 @@ const SAFE_SCRIPTS = [ const DANGEROUS_OPERATORS = /[;`$()]/; // Re-export for backwards compatibility — canonical source is skill-install.ts -export { isSkillInstallCommand } from './skill-install'; +export { isSkillInstallCommand } from '../skill-install'; /** * Check if command is an allowed package manager command. diff --git a/src/lib/agent/agent-prompt.ts b/src/lib/agent/agent-prompt.ts new file mode 100644 index 00000000..ac591cf7 --- /dev/null +++ b/src/lib/agent/agent-prompt.ts @@ -0,0 +1,66 @@ +/** + * Agent prompt assembly. + * + * Three sections, always in this order: + * 1. Default project prompt — credentials and base context (always included) + * 2. Custom prompt — additional workflow-specific instructions (if set) + * 3. Skill prompt — "follow SKILL.md" instructions (if a skill was installed) + */ + +import type { WorkflowRun } from './agent-runner.js'; + +/** + * Values available to prompt builders after OAuth completes. + */ +export interface PromptContext { + projectId: number; + projectApiKey: string; + host: string; + /** Set when skillId was provided and the skill was installed successfully. */ + skillPath?: string; +} + +function defaultProjectPrompt(ctx: PromptContext): string { + return `You have access to the PostHog MCP server. + +Project context: +- PostHog Project ID: ${ctx.projectId} +- PostHog public token: ${ctx.projectApiKey} +- PostHog Host: ${ctx.host}`; +} + +function skillPrompt(skillPath: string, reportFile: string): string { + return `A PostHog skill has been installed at ${skillPath}/. Read ${skillPath}/SKILL.md and follow its instructions completely. + +After completing the skill workflow, write a brief markdown report to ./${reportFile} summarizing: +- What changes were made to the project +- Which files were modified or created +- Any manual steps the user should take next + +Important: You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure.`; +} + +/** + * Assemble the final agent prompt from the workflow's run config. + */ +export function assemblePrompt( + runDef: WorkflowRun, + ctx: PromptContext, +): string { + const parts: string[] = []; + + // Always include the default project prompt + parts.push(defaultProjectPrompt(ctx)); + + // Additional workflow-specific instructions + if (runDef.customPrompt) { + parts.push(runDef.customPrompt(ctx)); + } + + // Skill prompt (appended when a skill was pre-installed) + if (ctx.skillPath) { + parts.push(skillPrompt(ctx.skillPath, runDef.reportFile)); + } + + return parts.join('\n\n'); +} diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts new file mode 100644 index 00000000..2f7be6f7 --- /dev/null +++ b/src/lib/agent/agent-runner.ts @@ -0,0 +1,436 @@ +/** + * Unified workflow runner. + * + * Single configurable pipeline for all workflows. Each workflow + * provides a WorkflowRun (via the `run` field on WorkflowConfig) + * that controls: + * - Whether a skill is pre-installed or discovered at runtime + * - How the agent prompt is built + * - What MCP servers and package manager detector to use + * - What happens after the agent completes + * + * The pipeline itself is fixed: + * init → health check → settings → OAuth → [skill install] → + * agent init → prompt → run → errors → [postRun] → outro + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; +import { + type WizardSession, + type AdditionalFeature, + type Credentials, + OutroKind, +} from '../wizard-session'; +import { getOrAskForProjectData } from '../../utils/setup-utils'; +import { analytics } from '../../utils/analytics'; +import { getUI } from '../../ui'; +import { + initializeAgent, + runAgent as executeAgent, + AgentErrorType, + AgentSignals, + buildWizardMetadata, + checkAllSettingsConflicts, + backupAndFixClaudeSettings, + restoreClaudeSettings, +} from './agent-interface'; +import { getCloudUrlFromRegion } from '../../utils/urls'; +import { + evaluateWizardReadiness, + WizardReadiness, +} from '../health-checks/readiness'; +import { enableDebugLogs, initLogFile, logToFile } from '../../utils/debug'; +import { createBenchmarkPipeline } from '../middleware/benchmark'; +import { + wizardAbort, + WizardError, + registerCleanup, +} from '../../utils/wizard-abort'; +import { formatScanReport, writeScanReport } from '../yara-hooks'; +import { detectNodePackageManagers } from '../package-manager-detection'; +import type { PackageManagerDetector } from '../package-manager-detection'; +import { getSkillsBaseUrl } from '../constants'; +import { installSkillById, type InstallSkillResult } from '../wizard-tools'; +import type { WizardOptions } from '../../utils/types'; + +import type { WorkflowConfig } from '../workflows/workflow-step'; +import { assemblePrompt, type PromptContext } from './agent-prompt'; + +export type { PromptContext }; + +// ── Types ──────────────────────────────────────────────────────────── + +export type { Credentials }; + +/** + * Unified agent run configuration. + * + * Every workflow provides one of these — either as a static object + * or via a function that builds one from the session. The runner + * assembles the final prompt from `prompt` + `skillId`. + */ +export interface WorkflowRun { + /** Analytics label (e.g. 'revenue-analytics', 'nextjs') */ + integrationLabel: string; + /** Skill ID to pre-install. Omit for agent-driven skill discovery. */ + skillId?: string; + /** Additional workflow-specific prompt instructions. Appended after the default project prompt. */ + customPrompt?: (ctx: PromptContext) => string; + /** Additional MCP servers (e.g. Svelte MCP) */ + additionalMcpServers?: Record; + /** Package manager detector. Defaults to detectNodePackageManagers. */ + detectPackageManager?: PackageManagerDetector; + spinnerMessage: string; + successMessage: string; + estimatedDurationMinutes: number; + reportFile: string; + docsUrl: string; + errorMessage?: string; + additionalFeatureQueue?: readonly AdditionalFeature[]; + /** Runs after agent completes, before outro (e.g. env var upload). */ + postRun?: (session: WizardSession, credentials: Credentials) => Promise; + /** Custom outro data. Omit for default built from successMessage/reportFile/docsUrl. */ + buildOutroData?: ( + session: WizardSession, + credentials: Credentials, + cloudRegion: import('../../utils/types').CloudRegion | undefined, + ) => WizardSession['outroData']; +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function sessionToOptions(session: WizardSession): WizardOptions { + return { + installDir: session.installDir, + debug: session.debug, + forceInstall: session.forceInstall, + default: false, + signup: session.signup, + localMcp: session.localMcp, + ci: session.ci, + menu: session.menu, + benchmark: session.benchmark, + projectId: session.projectId, + apiKey: session.apiKey, + yaraReport: session.yaraReport, + }; +} + +// ── Runner ─────────────────────────────────────────────────────────── + +/** + * Resolve a WorkflowConfig's agent run definition and execute the pipeline. + * Entry point for bin.ts — handles buildRunConfig, bootstrap, and (future) run field. + */ +export async function runAgent( + workflowConfig: WorkflowConfig, + session: WizardSession, +): Promise { + if (!workflowConfig.run) { + throw new Error( + `Workflow "${workflowConfig.flowKey}" has no run configuration.`, + ); + } + + const runDef = + typeof workflowConfig.run === 'function' + ? await workflowConfig.run(session) + : workflowConfig.run; + + await runWorkflow(session, runDef); +} + +/** + * Run a workflow's agent pipeline. + * + * This is the single execution path for all workflows — both skill-based + * (revenue analytics) and framework-based (core integration). The + * `WorkflowRun` controls what varies between them. + */ +export async function runWorkflow( + session: WizardSession, + config: WorkflowRun, +): Promise { + // 1. Init logging + debug + initLogFile(); + logToFile(`[agent-runner] START ${config.integrationLabel}`); + + if (session.debug) { + enableDebugLogs(); + } + + const skillsBaseUrl = getSkillsBaseUrl(session.localMcp); + + // 2. Health check (guarded — skip if TUI already ran it) + if (!session.readinessResult) { + logToFile('[agent-runner] evaluating wizard readiness'); + const readiness = await evaluateWizardReadiness(); + logToFile(`[agent-runner] readiness=${readiness.decision}`); + if (readiness.decision === WizardReadiness.No) { + await getUI().showBlockingOutage(readiness); + } else if (readiness.decision === WizardReadiness.YesWithWarnings) { + getUI().setReadinessWarnings(readiness); + } + } + + // 3. Settings conflicts + const settingsConflicts = checkAllSettingsConflicts(session.installDir); + logToFile( + `[agent-runner] settings conflicts: ${ + settingsConflicts.length > 0 + ? settingsConflicts + .map((c) => `${c.source}(${c.keys.join(',')})`) + .join('; ') + : 'none' + }`, + ); + + if (settingsConflicts.length > 0) { + for (const conflict of settingsConflicts) { + const level = conflict.source === 'managed' ? 'org' : conflict.source; + analytics.wizardCapture('settings conflict detected', { + level, + keys: conflict.keys, + }); + } + await getUI().showSettingsOverride(settingsConflicts, () => + backupAndFixClaudeSettings(session.installDir), + ); + logToFile('[agent-runner] settings override resolved'); + } + + analytics.wizardCapture('agent started', { + integration: config.integrationLabel, + }); + + // 4. OAuth + logToFile('[agent-runner] starting OAuth'); + const { projectApiKey, host, accessToken, projectId, cloudRegion } = + await getOrAskForProjectData({ + signup: session.signup, + ci: session.ci, + apiKey: session.apiKey, + projectId: session.projectId, + }); + + session.credentials = { accessToken, projectApiKey, host, projectId }; + getUI().setCredentials(session.credentials); + + // 5. Skill install (if skillId provided) + let skillPath: string | undefined; + if (config.skillId) { + logToFile(`[agent-runner] installing skill ${config.skillId}`); + const installResult = await installSkillById( + config.skillId, + session.installDir, + skillsBaseUrl, + ); + if (installResult.kind !== 'ok') { + await abortOnInstallFailure(config.integrationLabel, installResult); + return; + } + skillPath = installResult.path; + logToFile(`[agent-runner] skill installed at ${skillPath}`); + } + + // 6. Initialize agent + const spinner = getUI().spinner(); + const wizardFlags = await analytics.getAllFlagsForWizard(); + const wizardMetadata = buildWizardMetadata(wizardFlags); + + const mcpUrl = session.localMcp + ? 'http://localhost:8787/mcp' + : process.env.MCP_URL || + (cloudRegion === 'eu' + ? 'https://mcp-eu.posthog.com/mcp' + : 'https://mcp.posthog.com/mcp'); + + const restoreSettings = () => restoreClaudeSettings(session.installDir); + getUI().onEnterScreen('outro', restoreSettings); + + if (session.yaraReport) { + registerCleanup(() => { + const reportPath = writeScanReport(); + if (reportPath) { + const summary = formatScanReport(); + getUI().log.info(`YARA scan report: ${reportPath}${summary ?? ''}`); + } + }); + } + + getUI().startRun(); + + const agent = await initializeAgent( + { + workingDirectory: session.installDir, + posthogMcpUrl: mcpUrl, + posthogApiKey: accessToken, + posthogApiHost: host, + additionalMcpServers: config.additionalMcpServers, + detectPackageManager: + config.detectPackageManager ?? detectNodePackageManagers, + skillsBaseUrl, + wizardFlags, + wizardMetadata, + }, + sessionToOptions(session), + ); + + const middleware = session.benchmark + ? createBenchmarkPipeline(spinner, sessionToOptions(session)) + : undefined; + + // 7. Build prompt + const prompt = assemblePrompt(config, { + projectId, + projectApiKey, + host, + skillPath, + }); + + // 8. Run agent + const agentResult = await executeAgent( + agent, + prompt, + sessionToOptions(session), + spinner, + { + estimatedDurationMinutes: config.estimatedDurationMinutes, + spinnerMessage: config.spinnerMessage, + successMessage: config.successMessage, + errorMessage: config.errorMessage ?? `${config.integrationLabel} failed`, + additionalFeatureQueue: config.additionalFeatureQueue ?? [], + }, + middleware, + ); + + // 9. Error handling (full set from both runners) + if (agentResult.error === AgentErrorType.MCP_MISSING) { + await wizardAbort({ + message: + 'Could not access the PostHog MCP server\n\n' + + 'The wizard was unable to connect to the PostHog MCP server.\n' + + 'This could be due to a network issue or a configuration problem.\n\n' + + `Please try again, or check the documentation:\n${config.docsUrl}`, + error: new WizardError('Agent could not access PostHog MCP server', { + integration: config.integrationLabel, + error_type: AgentErrorType.MCP_MISSING, + signal: AgentSignals.ERROR_MCP_MISSING, + }), + }); + } + + if (agentResult.error === AgentErrorType.RESOURCE_MISSING) { + await wizardAbort({ + message: + 'Could not access the setup resource\n\n' + + 'This may indicate a version mismatch or a temporary service issue.\n\n' + + `Please try again, or check the documentation:\n${config.docsUrl}`, + error: new WizardError('Agent could not access setup resource', { + integration: config.integrationLabel, + error_type: AgentErrorType.RESOURCE_MISSING, + signal: AgentSignals.ERROR_RESOURCE_MISSING, + }), + }); + } + + if (agentResult.error === AgentErrorType.YARA_VIOLATION) { + await wizardAbort({ + message: + 'Security violation detected.\nPlease report this to: wizard@posthog.com', + error: new WizardError('YARA scanner terminated session', { + integration: config.integrationLabel, + error_type: AgentErrorType.YARA_VIOLATION, + }), + }); + } + + if ( + agentResult.error === AgentErrorType.RATE_LIMIT || + agentResult.error === AgentErrorType.API_ERROR + ) { + analytics.wizardCapture('agent api error', { + integration: config.integrationLabel, + error_type: agentResult.error, + error_message: agentResult.message, + }); + + await wizardAbort({ + message: `API Error\n\n${ + agentResult.message || 'Unknown error' + }\n\nPlease report this to: wizard@posthog.com`, + error: new WizardError(`API error: ${agentResult.message}`, { + integration: config.integrationLabel, + error_type: agentResult.error, + }), + }); + } + + // 10. Post-run hooks + if (config.postRun) { + await config.postRun(session, { + accessToken, + projectApiKey, + host, + projectId, + }); + } + + // 11. Outro + if (config.buildOutroData) { + session.outroData = config.buildOutroData( + session, + { accessToken, projectApiKey, host, projectId }, + cloudRegion, + ); + } else { + const continueUrl = session.signup + ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` + : undefined; + + const reportPath = join(session.installDir, config.reportFile); + const reportExists = existsSync(reportPath); + + session.outroData = { + kind: OutroKind.Success, + message: config.successMessage, + reportFile: reportExists ? config.reportFile : undefined, + docsUrl: config.docsUrl, + continueUrl, + }; + } + + getUI().outro(config.successMessage); + + // 12. Analytics shutdown + await analytics.shutdown('success'); +} + +// ── Shared error helpers ───────────────────────────────────────────── + +async function abortOnInstallFailure( + integrationLabel: string, + result: InstallSkillResult, +): Promise { + if (result.kind === 'ok') return; + + const message = (() => { + switch (result.kind) { + case 'menu-fetch-failed': + return 'Could not fetch the skill menu from context-mill.\nCheck your network connection and try again.'; + case 'skill-not-found': + return `Could not find the "${result.skillId}" skill in the context-mill menu.\nPlease try again later.`; + case 'download-failed': + return `Failed to install skill: ${result.message}\nPlease try again.`; + } + })(); + + await wizardAbort({ + message, + error: new WizardError(`Skill install failed: ${result.kind}`, { + integration: integrationLabel, + error_type: result.kind, + }), + }); +} diff --git a/src/lib/detection/context.ts b/src/lib/detection/context.ts new file mode 100644 index 00000000..e8a207a8 --- /dev/null +++ b/src/lib/detection/context.ts @@ -0,0 +1,82 @@ +/** + * Framework context gathering — run gatherContext and version checks + * for a detected framework. + * + * Pure functions: take a framework config and options, return results. + * No store mutations, no UI calls. + */ + +import * as semver from 'semver'; +import { DETECTION_TIMEOUT_MS } from '../constants.js'; +import type { FrameworkConfig } from '../framework-config.js'; +import type { WizardOptions } from '../../utils/types.js'; + +/** + * Run a framework's `gatherContext()` to collect variant-specific + * metadata (e.g., router type for Next.js, Expo vs bare for React Native). + * + * Returns the gathered context, or an empty object on failure/timeout. + */ +export async function gatherFrameworkContext( + config: FrameworkConfig, + options: WizardOptions, +): Promise> { + if (!config.metadata.gatherContext) return {}; + + try { + return await Promise.race([ + config.metadata.gatherContext(options), + new Promise>((resolve) => + setTimeout(() => resolve({}), DETECTION_TIMEOUT_MS), + ), + ]); + } catch { + return {}; + } +} + +export interface VersionCheckResult { + /** Whether the installed version is supported */ + supported: + | true + | { + current: string; + minimum: string; + docsUrl: string; + }; +} + +/** + * Check whether the installed framework version meets the minimum requirement. + * + * Returns `{ supported: true }` if the version is fine (or no check is needed). + * Returns the version details if unsupported. + */ +export async function checkFrameworkVersion( + config: FrameworkConfig, + options: WizardOptions, +): Promise { + if ( + !config.detection.minimumVersion || + !config.detection.getInstalledVersion + ) { + return { supported: true }; + } + + const version = await config.detection.getInstalledVersion(options); + if (!version) return { supported: true }; + + const coerced = semver.coerce(version); + if (coerced && semver.lt(coerced, config.detection.minimumVersion)) { + return { + supported: { + current: version, + minimum: config.detection.minimumVersion, + docsUrl: + config.metadata.unsupportedVersionDocsUrl ?? config.metadata.docsUrl, + }, + }; + } + + return { supported: true }; +} diff --git a/src/lib/detection/features.ts b/src/lib/detection/features.ts new file mode 100644 index 00000000..8d2b1c88 --- /dev/null +++ b/src/lib/detection/features.ts @@ -0,0 +1,64 @@ +/** + * Feature discovery — scan project dependencies for known SDK patterns + * that indicate additional PostHog workflows are relevant. + * + * Pure function: takes an install dir, returns a set of discovered features. + * No store mutations, no UI calls. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { DiscoveredFeature } from '../wizard-session.js'; + +const STRIPE_PACKAGES = ['stripe', '@stripe/stripe-js']; + +const LLM_PACKAGES = [ + 'openai', + '@anthropic-ai/sdk', + 'ai', + '@ai-sdk/openai', + 'langchain', + '@langchain/openai', + '@langchain/langgraph', + '@google/generative-ai', + '@google/genai', + '@instructor-ai/instructor', + '@mastra/core', + 'portkey-ai', +]; + +/** + * Scan `package.json` at `installDir` for dependencies that indicate + * additional PostHog features (Stripe revenue analytics, LLM observability, etc.) + * + * Returns an array of discovered features, or empty if nothing found + * or no package.json exists. + */ +export function discoverFeatures(installDir: string): DiscoveredFeature[] { + const features: DiscoveredFeature[] = []; + + try { + const pkg = JSON.parse( + readFileSync(join(installDir, 'package.json'), 'utf-8'), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + const depNames = Object.keys({ + ...pkg.dependencies, + ...pkg.devDependencies, + }); + + if (depNames.some((d) => STRIPE_PACKAGES.includes(d))) { + features.push(DiscoveredFeature.Stripe); + } + + if (depNames.some((d) => LLM_PACKAGES.includes(d))) { + features.push(DiscoveredFeature.LLM); + } + } catch { + // No package.json or parse error — skip feature discovery + } + + return features; +} diff --git a/src/lib/detection/framework.ts b/src/lib/detection/framework.ts new file mode 100644 index 00000000..61587f93 --- /dev/null +++ b/src/lib/detection/framework.ts @@ -0,0 +1,36 @@ +/** + * Framework detection — identify which PostHog-supported framework + * is present in the project directory. + * + * Pure function: takes an install dir, returns the detected integration + * (or undefined). No store mutations, no UI calls. + */ + +import { Integration, DETECTION_TIMEOUT_MS } from '../constants.js'; +import { FRAMEWORK_REGISTRY } from '../registry.js'; + +/** + * Loop through all registered frameworks and return the first one + * whose `detect()` predicate matches the given directory. + * Returns undefined if no framework is detected or detection times out. + */ +export async function detectFramework( + installDir: string, +): Promise { + for (const integration of Object.values(Integration)) { + const config = FRAMEWORK_REGISTRY[integration]; + try { + const detected = await Promise.race([ + config.detection.detect({ installDir }), + new Promise((resolve) => + setTimeout(() => resolve(false), DETECTION_TIMEOUT_MS), + ), + ]); + if (detected) { + return integration; + } + } catch { + // Skip frameworks whose detection throws + } + } +} diff --git a/src/lib/detection/index.ts b/src/lib/detection/index.ts new file mode 100644 index 00000000..ecc446cc --- /dev/null +++ b/src/lib/detection/index.ts @@ -0,0 +1,7 @@ +export { detectFramework } from './framework.js'; +export { discoverFeatures } from './features.js'; +export { + gatherFrameworkContext, + checkFrameworkVersion, + type VersionCheckResult, +} from './context.js'; diff --git a/src/lib/middleware/benchmark.ts b/src/lib/middleware/benchmark.ts index 099219e1..01c624a8 100644 --- a/src/lib/middleware/benchmark.ts +++ b/src/lib/middleware/benchmark.ts @@ -15,7 +15,7 @@ import { loadBenchmarkConfig } from './config'; import { createPluginsFromConfig } from './benchmarks'; import type { BenchmarkConfig } from './config'; import type { WizardOptions } from '../../utils/types'; -import { AgentSignals } from '../agent-interface'; +import { AgentSignals } from '../agent/agent-interface'; // ── Types ────────────────────────────────────────────────────────────── diff --git a/src/lib/middleware/benchmarks/compaction-tracker.ts b/src/lib/middleware/benchmarks/compaction-tracker.ts index 844f1b6d..8e3dd410 100644 --- a/src/lib/middleware/benchmarks/compaction-tracker.ts +++ b/src/lib/middleware/benchmarks/compaction-tracker.ts @@ -7,7 +7,7 @@ import type { Middleware, MiddlewareContext, MiddlewareStore } from '../types'; import { logToFile } from '../../../utils/debug'; -import { AgentSignals } from '../../agent-interface'; +import { AgentSignals } from '../../agent/agent-interface'; export interface CompactionData { phaseCompactions: number; diff --git a/src/lib/middleware/benchmarks/json-writer.ts b/src/lib/middleware/benchmarks/json-writer.ts index fe5fb016..4ea5f49d 100644 --- a/src/lib/middleware/benchmarks/json-writer.ts +++ b/src/lib/middleware/benchmarks/json-writer.ts @@ -8,7 +8,7 @@ import fs from 'fs'; import { getUI } from '../../../ui'; import { logToFile } from '../../../utils/debug'; -import { AgentSignals } from '../../agent-interface'; +import { AgentSignals } from '../../agent/agent-interface'; import type { Middleware, MiddlewareContext, MiddlewareStore } from '../types'; import type { TokenData } from './token-tracker'; import type { CacheData } from './cache-tracker'; diff --git a/src/lib/middleware/benchmarks/summary.ts b/src/lib/middleware/benchmarks/summary.ts index ac01eebe..f8255368 100644 --- a/src/lib/middleware/benchmarks/summary.ts +++ b/src/lib/middleware/benchmarks/summary.ts @@ -1,5 +1,5 @@ import { getUI, type SpinnerHandle } from '../../../ui'; -import { AgentSignals } from '../../agent-interface'; +import { AgentSignals } from '../../agent/agent-interface'; import type { Middleware, MiddlewareContext, MiddlewareStore } from '../types'; import type { TokenData } from './token-tracker'; import type { TurnData } from './turn-counter'; diff --git a/src/lib/middleware/config.ts b/src/lib/middleware/config.ts index 40294568..9306e5ac 100644 --- a/src/lib/middleware/config.ts +++ b/src/lib/middleware/config.ts @@ -8,7 +8,7 @@ import fs from 'fs'; import path from 'path'; import { logToFile } from '../../utils/debug'; -import { AgentSignals } from '../agent-interface'; +import { AgentSignals } from '../agent/agent-interface'; export interface BenchmarkConfig { /** Enable/disable individual metric plugins */ diff --git a/src/lib/revenue-runner.ts b/src/lib/revenue-runner.ts deleted file mode 100644 index 38e9f9d6..00000000 --- a/src/lib/revenue-runner.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Revenue analytics wizard runner. - * - * Thin config wrapper around the generic skill bootstrap runner. - * The revenue workflow's detect step has already verified prerequisites - * (PostHog + Stripe); the bootstrap runner handles skill install + agent run. - */ - -import { runSkillBootstrap } from './skill-runner'; -import type { WizardSession } from './wizard-session'; - -export async function runRevenueWizard(session: WizardSession): Promise { - await runSkillBootstrap(session, { - skillId: 'revenue-analytics-setup', - integrationLabel: 'revenue-analytics', - promptContext: 'Set up revenue analytics for this project.', - successMessage: 'Revenue analytics configured!', - reportFile: 'posthog-revenue-report.md', - docsUrl: 'https://posthog.com/docs/revenue-analytics', - spinnerMessage: 'Setting up revenue analytics...', - estimatedDurationMinutes: 5, - }); -} diff --git a/src/lib/skill-runner.ts b/src/lib/skill-runner.ts deleted file mode 100644 index c8cb3889..00000000 --- a/src/lib/skill-runner.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * Generic skill bootstrap runner. - * - * Given a skill ID, installs it from context-mill and runs the agent - * against it. Callers (like revenue-runner) just pass config — the - * whole install + OAuth + run + outro pipeline lives here. - */ - -import { existsSync } from 'fs'; -import { join } from 'path'; -import { type WizardSession, OutroKind } from './wizard-session'; -import { getOrAskForProjectData } from '../utils/setup-utils'; -import { analytics } from '../utils/analytics'; -import { getUI } from '../ui'; -import { - initializeAgent, - runAgent, - AgentErrorType, - buildWizardMetadata, - checkAllSettingsConflicts, - backupAndFixClaudeSettings, - restoreClaudeSettings, -} from './agent-interface'; -import { getCloudUrlFromRegion } from '../utils/urls'; -import { - evaluateWizardReadiness, - WizardReadiness, -} from './health-checks/readiness'; -import { enableDebugLogs, initLogFile, logToFile } from '../utils/debug'; -import { createBenchmarkPipeline } from './middleware/benchmark'; -import { - wizardAbort, - WizardError, - registerCleanup, -} from '../utils/wizard-abort'; -import { formatScanReport, writeScanReport } from './yara-hooks'; -import { detectNodePackageManagers } from './package-manager-detection'; -import { getSkillsBaseUrl } from './constants'; -import { installSkillById, type InstallSkillResult } from './wizard-tools'; -import type { WizardOptions } from '../utils/types'; - -/** - * Configuration for a skill-based workflow. - */ -export interface SkillBootstrapConfig { - /** Context-mill skill ID to install (e.g. 'revenue-analytics-setup') */ - skillId: string; - /** Analytics integration label */ - integrationLabel: string; - /** Extra context prepended to the agent prompt */ - promptContext?: string; - /** Outro success message */ - successMessage: string; - /** Report file the agent should write */ - reportFile: string; - /** Docs URL for the outro */ - docsUrl: string; - /** Spinner message during agent run */ - spinnerMessage: string; - /** Estimated duration in minutes */ - estimatedDurationMinutes: number; -} - -function sessionToOptions(session: WizardSession): WizardOptions { - return { - installDir: session.installDir, - debug: session.debug, - forceInstall: false, - default: false, - signup: session.signup, - localMcp: session.localMcp, - ci: session.ci, - menu: false, - benchmark: session.benchmark, - yaraReport: session.yaraReport, - }; -} - -export async function runSkillBootstrap( - session: WizardSession, - config: SkillBootstrapConfig, -): Promise { - initLogFile(); - logToFile(`[skill-runner] START ${config.integrationLabel}`); - - if (session.debug) { - enableDebugLogs(); - } - - const skillsBaseUrl = getSkillsBaseUrl(session.localMcp); - - // Health check - if (!session.readinessResult) { - logToFile('[skill-runner] evaluating wizard readiness'); - const readiness = await evaluateWizardReadiness(); - logToFile(`[skill-runner] readiness=${readiness.decision}`); - if (readiness.decision === WizardReadiness.No) { - await getUI().showBlockingOutage(readiness); - } else if (readiness.decision === WizardReadiness.YesWithWarnings) { - getUI().setReadinessWarnings(readiness); - } - } - - // Settings conflicts - const settingsConflicts = checkAllSettingsConflicts(session.installDir); - if (settingsConflicts.length > 0) { - for (const conflict of settingsConflicts) { - const level = conflict.source === 'managed' ? 'org' : conflict.source; - analytics.wizardCapture('settings conflict detected', { - level, - keys: conflict.keys, - }); - } - await getUI().showSettingsOverride(settingsConflicts, () => - backupAndFixClaudeSettings(session.installDir), - ); - } - - analytics.wizardCapture('agent started', { - integration: config.integrationLabel, - }); - - // OAuth - logToFile('[skill-runner] starting OAuth'); - const { projectApiKey, host, accessToken, projectId, cloudRegion } = - await getOrAskForProjectData({ - signup: session.signup, - ci: session.ci, - apiKey: session.apiKey, - projectId: session.projectId, - }); - - session.credentials = { accessToken, projectApiKey, host, projectId }; - getUI().setCredentials(session.credentials); - - const spinner = getUI().spinner(); - const wizardFlags = await analytics.getAllFlagsForWizard(); - const wizardMetadata = buildWizardMetadata(wizardFlags); - - const mcpUrl = session.localMcp - ? 'http://localhost:8787/mcp' - : process.env.MCP_URL || - (cloudRegion === 'eu' - ? 'https://mcp-eu.posthog.com/mcp' - : 'https://mcp.posthog.com/mcp'); - - const restoreSettings = () => restoreClaudeSettings(session.installDir); - getUI().onEnterScreen('outro', restoreSettings); - - if (session.yaraReport) { - registerCleanup(() => { - const reportPath = writeScanReport(); - if (reportPath) { - const summary = formatScanReport(); - getUI().log.info(`YARA scan report: ${reportPath}${summary ?? ''}`); - } - }); - } - - // Install the skill from context-mill before running the agent. - logToFile(`[skill-runner] installing skill ${config.skillId}`); - const installResult = await installSkillById( - config.skillId, - session.installDir, - skillsBaseUrl, - ); - if (installResult.kind !== 'ok') { - await abortOnInstallFailure(config.integrationLabel, installResult); - return; - } - logToFile(`[skill-runner] skill installed at ${installResult.path}`); - - getUI().startRun(); - - const agent = await initializeAgent( - { - workingDirectory: session.installDir, - posthogMcpUrl: mcpUrl, - posthogApiKey: accessToken, - posthogApiHost: host, - detectPackageManager: detectNodePackageManagers, - skillsBaseUrl, - wizardFlags, - wizardMetadata, - }, - sessionToOptions(session), - ); - - const middleware = session.benchmark - ? createBenchmarkPipeline(spinner, sessionToOptions(session)) - : undefined; - - const prompt = buildBootstrapPrompt( - config, - installResult.path, - projectId, - projectApiKey, - host, - ); - - const agentResult = await runAgent( - agent, - prompt, - sessionToOptions(session), - spinner, - { - estimatedDurationMinutes: config.estimatedDurationMinutes, - spinnerMessage: config.spinnerMessage, - successMessage: config.successMessage, - errorMessage: `${config.integrationLabel} setup failed`, - additionalFeatureQueue: [], - }, - middleware, - ); - - // Error handling - if (agentResult.error === AgentErrorType.YARA_VIOLATION) { - await wizardAbort({ - message: - 'Security violation detected.\nPlease report this to: wizard@posthog.com', - error: new WizardError('YARA scanner terminated session', { - integration: config.integrationLabel, - error_type: AgentErrorType.YARA_VIOLATION, - }), - }); - } - - if ( - agentResult.error === AgentErrorType.RATE_LIMIT || - agentResult.error === AgentErrorType.API_ERROR - ) { - await wizardAbort({ - message: `API Error\n\n${ - agentResult.message || 'Unknown error' - }\n\nPlease report this to: wizard@posthog.com`, - error: new WizardError(`API error: ${agentResult.message}`, { - integration: config.integrationLabel, - error_type: agentResult.error, - }), - }); - } - - // Outro — check if agent wrote the report - const continueUrl = session.signup - ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` - : undefined; - - const reportPath = join(session.installDir, config.reportFile); - const reportExists = existsSync(reportPath); - - session.outroData = { - kind: OutroKind.Success, - message: config.successMessage, - reportFile: reportExists ? config.reportFile : undefined, - docsUrl: config.docsUrl, - continueUrl, - }; - - getUI().outro(config.successMessage); - await analytics.shutdown('success'); -} - -/** - * Bootstrap prompt — skill is already installed, just follow it. - */ -function buildBootstrapPrompt( - config: SkillBootstrapConfig, - skillPath: string, - projectId: number, - projectApiKey: string, - host: string, -): string { - return `You have access to the PostHog MCP server.${ - config.promptContext ? ' ' + config.promptContext : '' - } - -Project context: -- PostHog Project ID: ${projectId} -- PostHog public token: ${projectApiKey} -- PostHog Host: ${host} - -A PostHog skill has been installed at ${skillPath}/. Read ${skillPath}/SKILL.md and follow its instructions completely. - -After completing the skill workflow, write a brief markdown report to ./${ - config.reportFile - } summarizing: -- What changes were made to the project -- Which files were modified or created -- Any manual steps the user should take next - -Important: You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure. -`; -} - -/** - * Map an installSkillById failure to a user-facing error message and abort. - */ -async function abortOnInstallFailure( - integrationLabel: string, - result: InstallSkillResult, -): Promise { - if (result.kind === 'ok') return; - - const message = (() => { - switch (result.kind) { - case 'menu-fetch-failed': - return 'Could not fetch the skill menu from context-mill.\nCheck your network connection and try again.'; - case 'skill-not-found': - return `Could not find the "${result.skillId}" skill in the context-mill menu.\nPlease try again later.`; - case 'download-failed': - return `Failed to install skill: ${result.message}\nPlease try again.`; - } - })(); - - await wizardAbort({ - message, - error: new WizardError(`Skill install failed: ${result.kind}`, { - integration: integrationLabel, - error_type: result.kind, - }), - }); -} diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 195fc2fc..81bed22c 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -13,7 +13,14 @@ import type { Integration } from './constants'; import type { FrameworkConfig } from './framework-config'; import type { WizardReadinessResult } from './health-checks/readiness'; -import type { SettingsConflict } from './agent-interface'; +import type { SettingsConflict } from './agent/agent-interface'; + +export interface Credentials { + accessToken: string; + projectApiKey: string; + host: string; + projectId: number; +} function parseProjectIdArg(value: string | undefined): number | undefined { if (value === undefined || value === '') return undefined; @@ -116,12 +123,7 @@ export interface WizardSession { } | null; // From OAuth - credentials: { - accessToken: string; - projectApiKey: string; - host: string; - projectId: number; - } | null; + credentials: Credentials | null; // Lifecycle runPhase: RunPhase; @@ -149,6 +151,10 @@ export interface WizardSession { // Additional features queue (drained via stop hook after main integration) additionalFeatureQueue: AdditionalFeature[]; + // Workflow metadata (set by runWizard in bin.ts) + workflowLabel: string | null; + skillId: string | null; + // Resolved framework config (set after integration is known) frameworkConfig: FrameworkConfig | null; } @@ -210,6 +216,8 @@ export function buildSession(args: { portConflictProcess: null, outroData: null, additionalFeatureQueue: [], + workflowLabel: null, + skillId: null, frameworkConfig: null, }; } diff --git a/src/lib/workflows/agent-skill/index.ts b/src/lib/workflows/agent-skill/index.ts new file mode 100644 index 00000000..fe0f34a3 --- /dev/null +++ b/src/lib/workflows/agent-skill/index.ts @@ -0,0 +1,69 @@ +/** + * Generic agent skill workflow factory. + * + * Creates a WorkflowConfig for any context-mill skill. Provide a + * skill ID and basic UI config — the factory handles the rest. + * + * Usage: + * createSkillWorkflow({ + * skillId: 'error-tracking-setup', + * command: 'errors', + * flowKey: 'error-tracking', + * description: 'Set up PostHog error tracking', + * integrationLabel: 'error-tracking', + * successMessage: 'Error tracking configured!', + * reportFile: 'posthog-error-tracking-report.md', + * docsUrl: 'https://posthog.com/docs/error-tracking', + * spinnerMessage: 'Setting up error tracking...', + * estimatedDurationMinutes: 5, + * }) + */ + +import type { WorkflowConfig } from '../workflow-step.js'; +import { AGENT_SKILL_STEPS } from './steps.js'; + +export interface SkillWorkflowOptions { + /** Context-mill skill ID to install */ + skillId: string; + /** CLI subcommand name */ + command: string; + /** Unique flow key — must match a Flow enum entry */ + flowKey: string; + /** CLI description shown in --help */ + description: string; + /** Analytics integration label */ + integrationLabel: string; + /** Custom prompt instruction. Appended after default project prompt. */ + customPrompt?: string; + successMessage: string; + reportFile: string; + docsUrl: string; + spinnerMessage: string; + estimatedDurationMinutes: number; + /** Other workflow flowKeys that must be satisfied first */ + requires?: string[]; +} + +export function createSkillWorkflow( + opts: SkillWorkflowOptions, +): WorkflowConfig { + return { + command: opts.command, + description: opts.description, + flowKey: opts.flowKey, + steps: AGENT_SKILL_STEPS, + run: { + skillId: opts.skillId, + integrationLabel: opts.integrationLabel, + customPrompt: opts.customPrompt ? () => opts.customPrompt! : undefined, + successMessage: opts.successMessage, + reportFile: opts.reportFile, + docsUrl: opts.docsUrl, + spinnerMessage: opts.spinnerMessage, + estimatedDurationMinutes: opts.estimatedDurationMinutes, + }, + requires: opts.requires, + }; +} + +export { AGENT_SKILL_STEPS } from './steps.js'; diff --git a/src/lib/workflows/agent-skill/steps.ts b/src/lib/workflows/agent-skill/steps.ts new file mode 100644 index 00000000..39003852 --- /dev/null +++ b/src/lib/workflows/agent-skill/steps.ts @@ -0,0 +1,32 @@ +/** + * Generic agent skill step list. + * + * Minimal flow: intro → auth → run → outro. + * No detection, no setup, no MCP, no skills screen. + */ + +import type { Workflow } from '../workflow-step.js'; +import { RunPhase } from '../../wizard-session.js'; + +export const AGENT_SKILL_STEPS: Workflow = [ + { + id: 'auth', + label: 'Authentication', + screen: 'auth', + isComplete: (session) => session.credentials !== null, + }, + { + id: 'run', + label: 'Running', + screen: 'run', + isComplete: (session) => + session.runPhase === RunPhase.Completed || + session.runPhase === RunPhase.Error, + }, + { + id: 'outro', + label: 'Done', + screen: 'outro', + isComplete: (session) => session.outroDismissed, + }, +]; diff --git a/src/lib/workflows/posthog-integration/detect.ts b/src/lib/workflows/posthog-integration/detect.ts new file mode 100644 index 00000000..6751fae7 --- /dev/null +++ b/src/lib/workflows/posthog-integration/detect.ts @@ -0,0 +1,72 @@ +/** + * Core integration detection step. + * + * Runs framework detection, context gathering, version checking, + * and feature discovery. Writes results to the store via the + * WorkflowReadyContext so the IntroScreen can display them. + * + * This is the same work that bin.ts $0 handler does inline — + * extracted here so the `integrate` subcommand can reuse it. + */ + +import type { WorkflowReadyContext } from '../workflow-step.js'; +import { FRAMEWORK_REGISTRY } from '../../registry.js'; +import { + detectFramework, + discoverFeatures, + gatherFrameworkContext, + checkFrameworkVersion, +} from '../../detection/index.js'; + +export async function detectCoreIntegration( + ctx: WorkflowReadyContext, +): Promise { + const session = ctx.session; + const installDir = session.installDir; + + const detectedIntegration = await detectFramework(installDir); + + if (detectedIntegration) { + const config = FRAMEWORK_REGISTRY[detectedIntegration]; + + const sessionOptions = { + installDir, + debug: session.debug, + forceInstall: session.forceInstall, + default: false, + signup: session.signup, + localMcp: session.localMcp, + ci: session.ci, + menu: session.menu, + benchmark: session.benchmark, + yaraReport: session.yaraReport, + }; + + // Gather framework-specific context (e.g., router type) + const context = await gatherFrameworkContext(config, sessionOptions); + for (const [key, value] of Object.entries(context)) { + if (!(key in session.frameworkContext)) { + ctx.setFrameworkContext(key, value); + } + } + + ctx.setFrameworkConfig(detectedIntegration, config); + + if (!session.detectedFrameworkLabel) { + ctx.setDetectedFramework(config.metadata.name); + } + + // Version check + const versionResult = await checkFrameworkVersion(config, sessionOptions); + if (versionResult.supported !== true) { + ctx.setUnsupportedVersion(versionResult.supported); + } + } + + // Feature discovery + for (const feature of discoverFeatures(installDir)) { + ctx.addDiscoveredFeature(feature); + } + + ctx.setDetectionComplete(); +} diff --git a/src/lib/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts new file mode 100644 index 00000000..5ad8ccdb --- /dev/null +++ b/src/lib/workflows/posthog-integration/index.ts @@ -0,0 +1,197 @@ +import type { WorkflowConfig } from '../workflow-step.js'; +import type { WorkflowRun } from '../../agent/agent-runner.js'; +import type { WizardSession } from '../../wizard-session.js'; +import { OutroKind } from '../../wizard-session.js'; +import { AgentSignals } from '../../agent/agent-interface.js'; +import { + DEFAULT_PACKAGE_INSTALLATION, + SPINNER_MESSAGE, +} from '../../framework-config.js'; +import { + tryGetPackageJson, + isUsingTypeScript, +} from '../../../utils/setup-utils.js'; +import { analytics } from '../../../utils/analytics.js'; +import { WIZARD_INTERACTION_EVENT_NAME } from '../../constants.js'; +import { getUI } from '../../../ui/index.js'; +import { getCloudUrlFromRegion } from '../../../utils/urls.js'; +import { POSTHOG_INTEGRATION_WORKFLOW } from './steps.js'; + +export const posthogIntegrationConfig: WorkflowConfig = { + command: 'integrate', + description: 'Set up PostHog SDK integration', + flowKey: 'core-integration', + steps: POSTHOG_INTEGRATION_WORKFLOW, + + run: async (session: WizardSession): Promise => { + const config = session.frameworkConfig!; + + const typeScriptDetected = isUsingTypeScript({ + installDir: session.installDir, + }); + session.typescript = typeScriptDetected; + + // Read package.json and resolve framework version + const usesPackageJson = config.detection.usesPackageJson !== false; + let frameworkVersion: string | undefined; + + if (usesPackageJson) { + const packageJson = await tryGetPackageJson({ + installDir: session.installDir, + }); + if (packageJson) { + const { hasPackageInstalled } = await import( + '../../../utils/package-json.js' + ); + if (!hasPackageInstalled(config.detection.packageName, packageJson)) { + getUI().log.warn( + `${config.detection.packageDisplayName} does not seem to be installed. Continuing anyway — the agent will handle it.`, + ); + } + frameworkVersion = config.detection.getVersion(packageJson); + } else { + getUI().log.warn( + 'Could not find package.json. Continuing anyway — the agent will handle it.', + ); + } + } else { + frameworkVersion = config.detection.getVersion(null); + } + + // Analytics tags + if (frameworkVersion && config.detection.getVersionBucket) { + const versionBucket = config.detection.getVersionBucket(frameworkVersion); + analytics.setTag(`${config.metadata.integration}-version`, versionBucket); + } + const frameworkContext = session.frameworkContext; + const contextTags = config.analytics.getTags(frameworkContext); + Object.entries(contextTags).forEach(([key, value]) => { + analytics.setTag(key, value); + }); + + return { + integrationLabel: config.metadata.integration, + additionalMcpServers: config.metadata.additionalMcpServers, + detectPackageManager: config.detection.detectPackageManager, + spinnerMessage: SPINNER_MESSAGE, + successMessage: config.ui.successMessage, + estimatedDurationMinutes: config.ui.estimatedDurationMinutes, + reportFile: 'posthog-setup-report.md', + docsUrl: config.metadata.docsUrl, + errorMessage: 'Integration failed', + additionalFeatureQueue: session.additionalFeatureQueue, + + customPrompt: (ctx) => { + const additionalLines = config.prompts.getAdditionalContextLines + ? config.prompts.getAdditionalContextLines(frameworkContext) + : []; + const additionalContext = + additionalLines.length > 0 + ? '\n' + additionalLines.map((line) => `- ${line}`).join('\n') + : ''; + + return `You have access to the PostHog MCP server which provides skills to integrate PostHog into this ${ + config.metadata.name + } project. + +Project context: +- PostHog Project ID: ${ctx.projectId} +- Framework: ${config.metadata.name} ${frameworkVersion || 'latest'} +- TypeScript: ${typeScriptDetected ? 'Yes' : 'No'} +- PostHog public token: ${ctx.projectApiKey} +- PostHog Host: ${ctx.host} +- Project type: ${config.prompts.projectTypeDetection} +- Package installation: ${ + config.prompts.packageInstallation ?? DEFAULT_PACKAGE_INSTALLATION + }${additionalContext} + +Instructions (follow these steps IN ORDER - do not skip or reorder): + +STEP 1: Call load_skill_menu (from the wizard-tools MCP server) to see available skills. + If the tool fails, emit: ${ + AgentSignals.ERROR_MCP_MISSING + } Could not load skill menu and halt. + + Choose a skill from the \`integration\` category that matches this project's framework. Do NOT pick skills from other categories (llm-analytics, error-tracking, feature-flags, omnibus, etc.) — those are handled separately. + If no suitable integration skill is found, emit: ${ + AgentSignals.ERROR_RESOURCE_MISSING + } Could not find a suitable skill for this project. + +STEP 2: Call install_skill (from the wizard-tools MCP server) with the chosen skill ID (e.g., "integration-nextjs-app-router"). + Do NOT run any shell commands to install skills. + +STEP 3: Load the installed skill's SKILL.md file to understand what references are available. + +STEP 4: Follow the skill's workflow files in sequence. Look for numbered workflow files in the references (e.g., files with patterns like "1.0-", "1.1-", "1.2-"). Start with the first one and proceed through each step until completion. Each workflow file will tell you what to do and which file comes next. Never directly write PostHog tokens directly to code files; always use environment variables. + +STEP 5: Set up environment variables for PostHog using the wizard-tools MCP server (this runs locally — secret values never leave the machine): + - Use check_env_keys to see which keys already exist in the project's .env file (e.g. .env.local or .env). + - Use set_env_values to create or update the PostHog public token and host, using the appropriate environment variable naming convention for ${ + config.metadata.name + }, which you'll find in example code. The tool will also ensure .gitignore coverage. Don't assume the presence of keys means the value is up to date. Write the correct value each time. + - Reference these environment variables in the code files you create instead of hardcoding the public token and host. + +Important: Use the detect_package_manager tool (from the wizard-tools MCP server) to determine which package manager the project uses. Do not manually search for lockfiles or config files. Always install packages as a background task. Don't await completion; proceed with other work immediately after starting the installation. You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure. + + +`; + }, + + postRun: async (sess, credentials) => { + const envVars = config.environment.getEnvVars( + credentials.projectApiKey, + credentials.host, + ); + if (config.environment.uploadToHosting) { + const { uploadEnvironmentVariablesStep } = await import( + '../../../steps/index.js' + ); + const uploadedEnvVars = await uploadEnvironmentVariablesStep( + envVars, + { + integration: config.metadata.integration, + session: sess, + }, + ); + if (uploadedEnvVars.length > 0) { + analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { + action: 'wizard_env_vars_uploaded', + integration: config.metadata.integration, + variable_count: uploadedEnvVars.length, + variable_keys: uploadedEnvVars, + }); + } + } + }, + + buildOutroData: (sess, credentials, cloudRegion) => { + const envVars = config.environment.getEnvVars( + credentials.projectApiKey, + credentials.host, + ); + const continueUrl = + sess.signup && cloudRegion + ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` + : undefined; + + const changes = [ + ...config.ui.getOutroChanges(frameworkContext), + Object.keys(envVars).length > 0 + ? 'Added environment variables to .env file' + : '', + ].filter(Boolean); + + return { + kind: OutroKind.Success as const, + message: 'Successfully installed PostHog!', + reportFile: 'posthog-setup-report.md', + changes, + docsUrl: config.metadata.docsUrl, + continueUrl, + }; + }, + }; + }, +}; + +export { POSTHOG_INTEGRATION_WORKFLOW } from './steps.js'; diff --git a/src/lib/workflows/posthog-integration.ts b/src/lib/workflows/posthog-integration/steps.ts similarity index 79% rename from src/lib/workflows/posthog-integration.ts rename to src/lib/workflows/posthog-integration/steps.ts index c30ffe6a..426a90bd 100644 --- a/src/lib/workflows/posthog-integration.ts +++ b/src/lib/workflows/posthog-integration/steps.ts @@ -7,12 +7,13 @@ */ import type { Workflow } from '../workflow-step.js'; -import type { WizardSession } from '../wizard-session.js'; -import { RunPhase } from '../wizard-session.js'; +import type { WizardSession } from '../../wizard-session.js'; +import { RunPhase } from '../../wizard-session.js'; import { evaluateWizardReadiness, WizardReadiness, -} from '../health-checks/readiness.js'; +} from '../../health-checks/readiness.js'; +import { detectCoreIntegration } from './detect.js'; function needsSetup(session: WizardSession): boolean { const config = session.frameworkConfig; @@ -31,6 +32,15 @@ function healthCheckReady(session: WizardSession): boolean { } export const POSTHOG_INTEGRATION_WORKFLOW: Workflow = [ + { + id: 'detect', + label: 'Detecting framework', + // Headless step: no screen. onReady fires after bin.ts assigns the + // session — runs framework detection, context gathering, version + // check, and feature discovery. Results are written to the store + // for the IntroScreen to render. + onReady: (ctx) => detectCoreIntegration(ctx), + }, { id: 'intro', label: 'Welcome', diff --git a/src/lib/workflows/revenue-analytics.ts b/src/lib/workflows/revenue-analytics/detect.ts similarity index 77% rename from src/lib/workflows/revenue-analytics.ts rename to src/lib/workflows/revenue-analytics/detect.ts index d0191e7f..e1a975f6 100644 --- a/src/lib/workflows/revenue-analytics.ts +++ b/src/lib/workflows/revenue-analytics/detect.ts @@ -1,17 +1,15 @@ /** - * Revenue analytics workflow. + * Revenue analytics prerequisite detection. * - * The detect step checks for PostHog + Stripe SDKs. The skill install - * and agent run live in the bootstrap runner (see skill-runner.ts). + * Scans the project for PostHog + Stripe SDKs and writes results + * into frameworkContext for the intro screen to render. */ -import type { Workflow } from '../workflow-step.js'; -import type { WizardSession } from '../wizard-session.js'; -import { RunPhase } from '../wizard-session.js'; import type { Dirent } from 'fs'; import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; import { join, relative } from 'path'; -import { IGNORED_DIRS } from '../../utils/file-utils.js'; +import { IGNORED_DIRS } from '../../../utils/file-utils.js'; +import type { WizardSession } from '../../wizard-session.js'; export const POSTHOG_SDKS = [ 'posthog-js', @@ -176,42 +174,3 @@ export function detectRevenuePrerequisites( .map((m) => m.path), ); } - -export const REVENUE_ANALYTICS_WORKFLOW: Workflow = [ - { - id: 'detect', - label: 'Detecting prerequisites', - // Headless step: no screen, no gate. onReady fires after bin.ts - // assigns the session — the hook scans for PostHog + Stripe SDKs - // and writes the results (or a detectError) to frameworkContext - // for the intro screen to render. - onReady: (ctx) => - detectRevenuePrerequisites(ctx.session, ctx.setFrameworkContext), - }, - { - id: 'intro', - label: 'Welcome', - screen: 'revenue-intro', - gate: (session) => session.setupConfirmed, - }, - { - id: 'auth', - label: 'Authentication', - screen: 'auth', - isComplete: (session) => session.credentials !== null, - }, - { - id: 'run', - label: 'Revenue analytics', - screen: 'run', - isComplete: (session) => - session.runPhase === RunPhase.Completed || - session.runPhase === RunPhase.Error, - }, - { - id: 'outro', - label: 'Done', - screen: 'outro', - isComplete: (session) => session.outroDismissed, - }, -]; diff --git a/src/lib/workflows/revenue-analytics/index.ts b/src/lib/workflows/revenue-analytics/index.ts new file mode 100644 index 00000000..64e0380b --- /dev/null +++ b/src/lib/workflows/revenue-analytics/index.ts @@ -0,0 +1,28 @@ +import type { WorkflowConfig } from '../workflow-step.js'; +import { REVENUE_ANALYTICS_WORKFLOW } from './steps.js'; + +export const revenueAnalyticsConfig: WorkflowConfig = { + command: 'revenue', + description: 'Set up PostHog revenue analytics (e.g. Stripe integration)', + flowKey: 'revenue-analytics', + steps: REVENUE_ANALYTICS_WORKFLOW, + run: { + skillId: 'revenue-analytics-setup', + integrationLabel: 'revenue-analytics', + customPrompt: () => 'Set up revenue analytics for this project.', + successMessage: 'Revenue analytics configured!', + reportFile: 'posthog-revenue-report.md', + docsUrl: 'https://posthog.com/docs/revenue-analytics', + spinnerMessage: 'Setting up revenue analytics...', + estimatedDurationMinutes: 5, + }, + requires: ['posthog-integration'], +}; + +export { REVENUE_ANALYTICS_WORKFLOW } from './steps.js'; +export { + detectRevenuePrerequisites, + POSTHOG_SDKS, + STRIPE_SDKS, + type RevenueDetectError, +} from './detect.js'; diff --git a/src/lib/workflows/revenue-analytics/steps.ts b/src/lib/workflows/revenue-analytics/steps.ts new file mode 100644 index 00000000..2f840910 --- /dev/null +++ b/src/lib/workflows/revenue-analytics/steps.ts @@ -0,0 +1,49 @@ +/** + * Revenue analytics workflow step list. + * + * The detect step checks for PostHog + Stripe SDKs. The skill install + * and agent run live in the workflow runner (see agent-runner.ts). + */ + +import type { Workflow } from '../workflow-step.js'; +import { RunPhase } from '../../wizard-session.js'; +import { detectRevenuePrerequisites } from './detect.js'; + +export const REVENUE_ANALYTICS_WORKFLOW: Workflow = [ + { + id: 'detect', + label: 'Detecting prerequisites', + // Headless step: no screen, no gate. onReady fires after bin.ts + // assigns the session — the hook scans for PostHog + Stripe SDKs + // and writes the results (or a detectError) to frameworkContext + // for the intro screen to render. + onReady: (ctx) => + detectRevenuePrerequisites(ctx.session, ctx.setFrameworkContext), + }, + { + id: 'intro', + label: 'Welcome', + screen: 'revenue-intro', + gate: (session) => session.setupConfirmed, + }, + { + id: 'auth', + label: 'Authentication', + screen: 'auth', + isComplete: (session) => session.credentials !== null, + }, + { + id: 'run', + label: 'Revenue analytics', + screen: 'run', + isComplete: (session) => + session.runPhase === RunPhase.Completed || + session.runPhase === RunPhase.Error, + }, + { + id: 'outro', + label: 'Done', + screen: 'outro', + isComplete: (session) => session.outroDismissed, + }, +]; diff --git a/src/lib/workflows/workflow-registry.ts b/src/lib/workflows/workflow-registry.ts new file mode 100644 index 00000000..63297ccf --- /dev/null +++ b/src/lib/workflows/workflow-registry.ts @@ -0,0 +1,31 @@ +/** + * Central registry of all wizard workflows. + * + * Adding a new workflow: + * 1. Create src/lib/workflows// with index.ts exporting a WorkflowConfig + * 2. Import and add it to WORKFLOW_REGISTRY below + * 3. Add a matching Flow enum entry in src/ui/tui/flows.ts + * 4. (If custom intro screen) add to src/ui/tui/screen-registry.tsx + * + * flows.ts, store.ts, and bin.ts all derive their wiring from this array — + * no need to touch those files when adding a workflow. + */ + +import type { WorkflowConfig } from './workflow-step.js'; +import { posthogIntegrationConfig } from './posthog-integration/index.js'; +import { revenueAnalyticsConfig } from './revenue-analytics/index.js'; + +export const WORKFLOW_REGISTRY: WorkflowConfig[] = [ + posthogIntegrationConfig, + revenueAnalyticsConfig, +]; + +/** Look up a workflow config by its flowKey. */ +export function getWorkflowConfig(flowKey: string): WorkflowConfig | undefined { + return WORKFLOW_REGISTRY.find((c) => c.flowKey === flowKey); +} + +/** All workflow configs that are exposed as CLI subcommands. */ +export function getSubcommandWorkflows(): WorkflowConfig[] { + return WORKFLOW_REGISTRY.filter((c) => c.command != null); +} diff --git a/src/lib/workflow-step.ts b/src/lib/workflows/workflow-step.ts similarity index 71% rename from src/lib/workflow-step.ts rename to src/lib/workflows/workflow-step.ts index 277383fd..f31ebdda 100644 --- a/src/lib/workflow-step.ts +++ b/src/lib/workflows/workflow-step.ts @@ -1,5 +1,8 @@ -import type { WizardSession } from './wizard-session'; -import type { WizardReadinessResult } from './health-checks/readiness.js'; +import type { WizardSession, DiscoveredFeature } from '../wizard-session'; +import type { WizardReadinessResult } from '../health-checks/readiness.js'; +import type { WorkflowRun } from '../agent/agent-runner.js'; +import type { Integration } from '../constants.js'; +import type { FrameworkConfig } from '../framework-config.js'; /** * A workflow step is the primary unit of the wizard's execution model. @@ -31,6 +34,20 @@ export interface StoreInitContext { export interface WorkflowReadyContext { readonly session: WizardSession; readonly setFrameworkContext: (key: string, value: unknown) => void; + + // Detection-specific methods — used by core-integration's detect step + readonly setFrameworkConfig: ( + integration: Integration, + config: FrameworkConfig, + ) => void; + readonly setDetectedFramework: (label: string) => void; + readonly setUnsupportedVersion: (info: { + current: string; + minimum: string; + docsUrl: string; + }) => void; + readonly addDiscoveredFeature: (feature: DiscoveredFeature) => void; + readonly setDetectionComplete: () => void; } export interface WorkflowStep { @@ -87,6 +104,27 @@ export interface WorkflowStep { */ export type Workflow = WorkflowStep[]; +/** + * Uniform configuration for a wizard workflow. + * + * Each workflow directory exports one of these. The system uses it + * for CLI registration, flow/step wiring, and skill bootstrap. + */ +export interface WorkflowConfig { + /** CLI command name (e.g. 'revenue'). Omit for the default flow. */ + command?: string; + /** CLI description shown in --help */ + description: string; + /** Unique flow key — matches the Flow enum value */ + flowKey: string; + /** The ordered step list */ + steps: Workflow; + /** Agent run config. Static object or async function for dynamic config. */ + run?: WorkflowRun | ((session: WizardSession) => Promise); + /** Prerequisites: other workflow flowKeys that must have run first */ + requires?: string[]; +} + /** * Project a Workflow into the narrower FlowEntry shape the router consumes. * diff --git a/src/run.ts b/src/run.ts deleted file mode 100644 index bb689227..00000000 --- a/src/run.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { type WizardSession, buildSession } from './lib/wizard-session'; - -import type { CloudRegion } from './utils/types'; - -import { - Integration, - DETECTION_TIMEOUT_MS, - WIZARD_INTERACTION_EVENT_NAME, -} from './lib/constants'; -import { readEnvironment } from './utils/environment'; -import { getUI } from './ui'; -import path from 'path'; -import { FRAMEWORK_REGISTRY } from './lib/registry'; -import { analytics } from './utils/analytics'; -import { runAgentWizard } from './lib/agent-runner'; -import { EventEmitter } from 'events'; -import { logToFile, configureLogFileFromEnvironment } from './utils/debug'; -import { wizardAbort } from './utils/wizard-abort'; -import { readApiKeyFromEnv } from './utils/env-api-key'; - -EventEmitter.defaultMaxListeners = 50; - -type Args = { - integration?: Integration; - debug?: boolean; - forceInstall?: boolean; - installDir?: string; - region?: CloudRegion; - default?: boolean; - signup?: boolean; - localMcp?: boolean; - ci?: boolean; - apiKey?: string; - projectId?: string; - menu?: boolean; - benchmark?: boolean; - yaraReport?: boolean; -}; - -export async function runWizard(argv: Args, session?: WizardSession) { - // Apply log file env overrides for all modes (CI, benchmark, and interactive). - configureLogFileFromEnvironment(); - - const finalArgs = { - ...argv, - ...readEnvironment(), - apiKey: argv.apiKey ?? readApiKeyFromEnv(), - }; - - let resolvedInstallDir: string; - if (finalArgs.installDir) { - if (path.isAbsolute(finalArgs.installDir)) { - resolvedInstallDir = finalArgs.installDir; - } else { - resolvedInstallDir = path.join(process.cwd(), finalArgs.installDir); - } - } else { - resolvedInstallDir = process.cwd(); - } - - // Build session if not provided (CI mode passes one pre-built) - if (!session) { - session = buildSession({ - debug: finalArgs.debug, - forceInstall: finalArgs.forceInstall, - installDir: resolvedInstallDir, - ci: finalArgs.ci, - signup: finalArgs.signup, - localMcp: finalArgs.localMcp, - apiKey: finalArgs.apiKey, - menu: finalArgs.menu, - integration: finalArgs.integration, - benchmark: finalArgs.benchmark, - yaraReport: finalArgs.yaraReport, - projectId: finalArgs.projectId, - }); - } - - session.installDir = resolvedInstallDir; - - getUI().intro(`Welcome to the PostHog setup wizard`); - - if (session.ci) { - getUI().log.info('Running in CI mode'); - } - - const integration = - session.integration ?? (await detectAndResolveIntegration(session)); - - session.integration = integration; - analytics.setTag('integration', integration); - - const config = FRAMEWORK_REGISTRY[integration]; - session.frameworkConfig = config; - - // Run gatherContext if the framework has it and it hasn't already run - // (bin.ts runs it early so IntroScreen can show the friendly label) - const contextAlreadyGathered = - Object.keys(session.frameworkContext).length > 0; - if (config.metadata.gatherContext && !contextAlreadyGathered) { - try { - const context = await config.metadata.gatherContext({ - installDir: session.installDir, - debug: session.debug, - forceInstall: session.forceInstall, - default: false, - signup: session.signup, - localMcp: session.localMcp, - ci: session.ci, - menu: session.menu, - benchmark: session.benchmark, - yaraReport: session.yaraReport, - }); - for (const [key, value] of Object.entries(context)) { - if (!(key in session.frameworkContext)) { - session.frameworkContext[key] = value; - } - } - } catch { - // Detection failed — SetupScreen or agent will handle it - } - } - - try { - await runAgentWizard(config, session); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = - error instanceof Error && error.stack ? error.stack : undefined; - - logToFile(`[Wizard run.ts] ERROR MESSAGE: ${errorMessage} `); - if (errorStack) { - logToFile(`[Wizard run.ts] ERROR STACK: ${errorStack}`); - } - - const debugInfo = session.debug && errorStack ? `\n\n${errorStack}` : ''; - - await wizardAbort({ - message: `Something went wrong: ${errorMessage}\n\nYou can read the documentation at ${config.metadata.docsUrl} to set up PostHog manually.${debugInfo}`, - error: error as Error, - }); - } -} - -export async function detectIntegration( - installDir: string, -): Promise { - for (const integration of Object.values(Integration)) { - const config = FRAMEWORK_REGISTRY[integration]; - try { - const detected = await Promise.race([ - config.detection.detect({ installDir }), - new Promise((resolve) => - setTimeout(() => resolve(false), DETECTION_TIMEOUT_MS), - ), - ]); - if (detected) { - return integration; - } - } catch { - // Skip frameworks whose detection throws - } - } -} - -async function detectAndResolveIntegration( - session: WizardSession, -): Promise { - if (!session.menu) { - const detectedIntegration = await detectIntegration(session.installDir); - - if (detectedIntegration) { - getUI().setDetectedFramework( - FRAMEWORK_REGISTRY[detectedIntegration].metadata.name, - ); - analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { - action: 'wizard_framework_detected', - integration: detectedIntegration, - framework_name: FRAMEWORK_REGISTRY[detectedIntegration].metadata.name, - }); - return detectedIntegration; - } - - analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { - action: 'wizard_framework_detection_failed', - }); - getUI().log.info( - "I couldn't detect your framework. Please choose one to get started.", - ); - } - - // Fallback: in TUI mode the IntroScreen would handle this, - // but for CI mode or when detection fails, abort with guidance. - return wizardAbort({ - message: - 'Could not auto-detect your framework. Please specify --integration on the command line.', - }); -} diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index de27ecc6..1fc10aa8 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -5,7 +5,7 @@ */ import { TaskStatus, type WizardUI, type SpinnerHandle } from './wizard-ui'; -import type { SettingsConflict } from '../lib/agent-interface'; +import type { SettingsConflict } from '../lib/agent/agent-interface'; import type { WizardReadinessResult } from '../lib/health-checks/readiness.js'; export class LoggingUI implements WizardUI { diff --git a/src/ui/tui/__tests__/flows.test.ts b/src/ui/tui/__tests__/flows.test.ts index 009a2402..db700bfe 100644 --- a/src/ui/tui/__tests__/flows.test.ts +++ b/src/ui/tui/__tests__/flows.test.ts @@ -14,7 +14,7 @@ describe('FLOWS', () => { describe('Wizard setup predicate', () => { it('hides setup when there are no setup questions', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Setup); + const entry = getEntry(Flow.CoreIntegration, Screen.Setup); expect(entry.show?.(session)).toBe(false); expect(entry.isComplete?.(session)).toBe(true); @@ -22,7 +22,7 @@ describe('FLOWS', () => { it('shows setup when framework questions are missing answers', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Setup); + const entry = getEntry(Flow.CoreIntegration, Screen.Setup); session.frameworkConfig = { metadata: { @@ -39,7 +39,7 @@ describe('FLOWS', () => { it('marks setup complete once all required answers are present', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Setup); + const entry = getEntry(Flow.CoreIntegration, Screen.Setup); session.frameworkConfig = { metadata: { @@ -61,14 +61,14 @@ describe('FLOWS', () => { describe('Wizard health-check predicate', () => { it('stays incomplete before readiness exists', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.HealthCheck); + const entry = getEntry(Flow.CoreIntegration, Screen.HealthCheck); expect(entry.isComplete?.(session)).toBe(false); }); it('stays incomplete for blocking readiness until outage is dismissed', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.HealthCheck); + const entry = getEntry(Flow.CoreIntegration, Screen.HealthCheck); session.readinessResult = { decision: WizardReadiness.No, @@ -85,7 +85,7 @@ describe('FLOWS', () => { it('completes immediately for non-blocking readiness', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.HealthCheck); + const entry = getEntry(Flow.CoreIntegration, Screen.HealthCheck); session.readinessResult = { decision: WizardReadiness.YesWithWarnings, @@ -100,7 +100,7 @@ describe('FLOWS', () => { describe('Wizard run predicate', () => { it('stays incomplete while run is idle or running', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Run); + const entry = getEntry(Flow.CoreIntegration, Screen.Run); session.runPhase = RunPhase.Idle; expect(entry.isComplete?.(session)).toBe(false); @@ -111,7 +111,7 @@ describe('FLOWS', () => { it('completes when run finishes or errors', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Run); + const entry = getEntry(Flow.CoreIntegration, Screen.Run); session.runPhase = RunPhase.Completed; expect(entry.isComplete?.(session)).toBe(true); diff --git a/src/ui/tui/__tests__/router.test.ts b/src/ui/tui/__tests__/router.test.ts index 0c3b0b58..3f339135 100644 --- a/src/ui/tui/__tests__/router.test.ts +++ b/src/ui/tui/__tests__/router.test.ts @@ -9,7 +9,7 @@ function baseWizardSession() { describe('WizardRouter', () => { describe('resolve', () => { it('returns the first incomplete visible screen for the wizard flow', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.CoreIntegration); const session = baseWizardSession(); expect(router.resolve(session)).toBe(Screen.Intro); @@ -31,7 +31,7 @@ describe('WizardRouter', () => { }); it('skips the setup screen when there are no unanswered framework questions', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.CoreIntegration); const session = baseWizardSession(); session.setupConfirmed = true; @@ -53,7 +53,7 @@ describe('WizardRouter', () => { }); it('returns the last flow screen when every entry is complete', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.CoreIntegration); const session = baseWizardSession(); session.setupConfirmed = true; @@ -75,7 +75,7 @@ describe('WizardRouter', () => { }); it('gives the topmost overlay precedence over the flow screen', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.CoreIntegration); const session = baseWizardSession(); router.pushOverlay(Overlay.SettingsOverride); @@ -96,7 +96,7 @@ describe('WizardRouter', () => { }); it('returns the top overlay when overlays are active', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.CoreIntegration); router.pushOverlay(Overlay.ManagedSettings); diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index 452b59fb..fc8dd06b 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -77,7 +77,7 @@ describe('WizardStore', () => { it('defaults to Wizard flow', () => { const store = createStore(); - expect(store.router.activeFlow).toBe(Flow.Wizard); + expect(store.router.activeFlow).toBe(Flow.CoreIntegration); }); it('accepts a custom flow', () => { diff --git a/src/ui/tui/flows.ts b/src/ui/tui/flows.ts index 63913221..42ff3954 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -4,17 +4,18 @@ * Owns the Screen and Flow enums (re-exported by router.ts) to avoid * circular imports between router ↔ flows. * - * Each flow is derived from a Workflow definition via workflowToFlowEntries(). - * MCP add/remove flows are standalone since they don't go through the agent runner. + * Workflow-based flows are derived from WORKFLOW_REGISTRY via + * workflowToFlowEntries(). MCP add/remove flows are standalone since + * they don't go through the agent runner. */ import type { WizardSession } from '../../lib/wizard-session.js'; import { workflowToFlowEntries, type Workflow, -} from '../../lib/workflow-step.js'; -import { POSTHOG_INTEGRATION_WORKFLOW } from '../../lib/workflows/posthog-integration.js'; -import { REVENUE_ANALYTICS_WORKFLOW } from '../../lib/workflows/revenue-analytics.js'; +} from '../../lib/workflows/workflow-step.js'; +import { WORKFLOW_REGISTRY } from '../../lib/workflows/workflow-registry.js'; +import { AGENT_SKILL_STEPS } from '../../lib/workflows/agent-skill/index.js'; // ── Screen + Flow enums ────────────────────────────────────────────── @@ -35,8 +36,9 @@ export enum Screen { /** Named flows the router can run */ export enum Flow { - Wizard = 'wizard', - Revenue = 'revenue', + CoreIntegration = 'core-integration', + RevenueAnalytics = 'revenue-analytics', + AgentSkill = 'agent-skill', McpAdd = 'mcp-add', McpRemove = 'mcp-remove', } @@ -52,27 +54,35 @@ export interface FlowEntry { isComplete?: (session: WizardSession) => boolean; } +// ── Derived from WORKFLOW_REGISTRY ─────────────────────────────────── + /** Raw workflow step arrays — used by the store for gate/onInit definitions. */ export const WORKFLOW_STEPS: Partial> = { - [Flow.Wizard]: POSTHOG_INTEGRATION_WORKFLOW, - [Flow.Revenue]: REVENUE_ANALYTICS_WORKFLOW, + ...(Object.fromEntries( + WORKFLOW_REGISTRY.map((c) => [c.flowKey, c.steps]), + ) as Partial>), + [Flow.AgentSkill]: AGENT_SKILL_STEPS, }; /** * All flow pipelines. * - * Integration and Revenue flows are derived from their workflow definitions. + * Workflow-based flows are derived from the registry. * MCP add/remove flows are standalone. */ export const FLOWS: Record = { - [Flow.Wizard]: workflowToFlowEntries( - POSTHOG_INTEGRATION_WORKFLOW, - ) as FlowEntry[], + // Derive workflow flows from registry + ...(Object.fromEntries( + WORKFLOW_REGISTRY.map((c) => [ + c.flowKey, + workflowToFlowEntries(c.steps) as FlowEntry[], + ]), + ) as Record), - [Flow.Revenue]: workflowToFlowEntries( - REVENUE_ANALYTICS_WORKFLOW, - ) as FlowEntry[], + // Generic agent skill flow + [Flow.AgentSkill]: workflowToFlowEntries(AGENT_SKILL_STEPS) as FlowEntry[], + // Standalone MCP flows [Flow.McpAdd]: [ { screen: Screen.McpAdd, diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 8b55f7ed..85627ef1 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -8,7 +8,7 @@ import type { WizardUI, SpinnerHandle } from '../wizard-ui.js'; import type { WizardStore } from './store.js'; -import type { SettingsConflict } from '../../lib/agent-interface.js'; +import type { SettingsConflict } from '../../lib/agent/agent-interface.js'; import type { WizardReadinessResult } from '../../lib/health-checks/readiness.js'; import { RunPhase, OutroKind } from '../../lib/wizard-session.js'; diff --git a/src/ui/tui/primitives/ProgressList.tsx b/src/ui/tui/primitives/ProgressList.tsx index f7d28d46..42d2baf2 100644 --- a/src/ui/tui/primitives/ProgressList.tsx +++ b/src/ui/tui/primitives/ProgressList.tsx @@ -17,9 +17,16 @@ export interface ProgressItem { interface ProgressListProps { items: ProgressItem[]; title?: string; + workflowLabel?: string | null; + skillId?: string | null; } -export const ProgressList = ({ items, title }: ProgressListProps) => { +export const ProgressList = ({ + items, + title, + workflowLabel, + skillId, +}: ProgressListProps) => { const completed = items.filter((t) => t.status === 'completed').length; const total = items.length; @@ -67,6 +74,12 @@ export const ProgressList = ({ items, title }: ProgressListProps) => { )} + {(workflowLabel || skillId) && ( + + {workflowLabel && workflow: {workflowLabel}} + {skillId && skill: {skillId}} + + )} ); }; diff --git a/src/ui/tui/router.ts b/src/ui/tui/router.ts index 39c4a306..95200097 100644 --- a/src/ui/tui/router.ts +++ b/src/ui/tui/router.ts @@ -39,7 +39,7 @@ export class WizardRouter { private flowName: Flow; private overlays: Overlay[] = []; - constructor(flowName: Flow = Flow.Wizard) { + constructor(flowName: Flow = Flow.CoreIntegration) { this.flowName = flowName; this.flow = FLOWS[flowName]; } diff --git a/src/ui/tui/screens/ManagedSettingsScreen.tsx b/src/ui/tui/screens/ManagedSettingsScreen.tsx index 0f5278c5..4aa39b0d 100644 --- a/src/ui/tui/screens/ManagedSettingsScreen.tsx +++ b/src/ui/tui/screens/ManagedSettingsScreen.tsx @@ -11,7 +11,7 @@ import { useSyncExternalStore } from 'react'; import type { WizardStore } from '../store.js'; import { ConfirmationInput, ModalOverlay } from '../primitives/index.js'; import { Icons } from '../styles.js'; -import type { SettingsConflict } from '../../../lib/agent-interface.js'; +import type { SettingsConflict } from '../../../lib/agent/agent-interface.js'; function sourceLabel(source: SettingsConflict['source']): string { switch (source) { diff --git a/src/ui/tui/screens/RevenueIntroScreen.tsx b/src/ui/tui/screens/RevenueIntroScreen.tsx index 430f9791..1c2f870a 100644 --- a/src/ui/tui/screens/RevenueIntroScreen.tsx +++ b/src/ui/tui/screens/RevenueIntroScreen.tsx @@ -18,7 +18,7 @@ import { POSTHOG_SDKS, STRIPE_SDKS, type RevenueDetectError, -} from '../../../lib/workflows/revenue-analytics.js'; +} from '../../../lib/workflows/revenue-analytics/index.js'; interface RevenueIntroScreenProps { store: WizardStore; diff --git a/src/ui/tui/screens/RunScreen.tsx b/src/ui/tui/screens/RunScreen.tsx index 7ba6ff5c..24a6fee5 100644 --- a/src/ui/tui/screens/RunScreen.tsx +++ b/src/ui/tui/screens/RunScreen.tsx @@ -68,7 +68,14 @@ export const RunScreen = ({ store }: RunScreenProps) => { ) : ( store.setLearnCardComplete()} /> ); - const progressList = ; + const progressList = ( + + ); // On narrow terminals, drop the learn pane and show only progress const statusComponent = diff --git a/src/ui/tui/screens/SettingsOverrideScreen.tsx b/src/ui/tui/screens/SettingsOverrideScreen.tsx index faf1ed0b..ab0ba2c1 100644 --- a/src/ui/tui/screens/SettingsOverrideScreen.tsx +++ b/src/ui/tui/screens/SettingsOverrideScreen.tsx @@ -3,7 +3,7 @@ import { useState, useSyncExternalStore } from 'react'; import type { WizardStore } from '../store.js'; import { ConfirmationInput, ModalOverlay } from '../primitives/index.js'; import { Icons } from '../styles.js'; -import type { SettingsConflictSource } from '../../../lib/agent-interface.js'; +import type { SettingsConflictSource } from '../../../lib/agent/agent-interface.js'; function sourcePath(source: SettingsConflictSource): string { switch (source) { diff --git a/src/ui/tui/start-tui.ts b/src/ui/tui/start-tui.ts index a65c6ed7..64927e25 100644 --- a/src/ui/tui/start-tui.ts +++ b/src/ui/tui/start-tui.ts @@ -20,7 +20,7 @@ const FORCE_DARK = BG_BLACK + CLEAR_SCREEN + CURSOR_HOME; export function startTUI( version: string, - flow: Flow = Flow.Wizard, + flow: Flow = Flow.CoreIntegration, ): { unmount: () => void; store: WizardStore; diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index eb03b138..e4446cd3 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -24,7 +24,7 @@ import { RunPhase, buildSession, } from '../../lib/wizard-session.js'; -import type { SettingsConflict } from '../../lib/agent-interface.js'; +import type { SettingsConflict } from '../../lib/agent/agent-interface.js'; import type { WizardReadinessResult } from '../../lib/health-checks/readiness.js'; import { WizardRouter, @@ -37,7 +37,7 @@ import { analytics, sessionProperties } from '../../utils/analytics.js'; import type { StoreInitContext, WorkflowReadyContext, -} from '../../lib/workflow-step.js'; +} from '../../lib/workflows/workflow-step.js'; import { WORKFLOW_STEPS } from './flows.js'; export { TaskStatus, Screen, Overlay, Flow, RunPhase, McpOutcome }; @@ -95,7 +95,7 @@ export class WizardStore { /** Blocks OAuth flow until the port-conflict overlay is dismissed. */ private _resolvePortConflict: (() => void) | null = null; - constructor(flow: Flow = Flow.Wizard) { + constructor(flow: Flow = Flow.CoreIntegration) { this.router = new WizardRouter(flow); this._initFromWorkflow(flow); } @@ -153,6 +153,11 @@ export class WizardStore { const ctx: WorkflowReadyContext = { session: this.session, setFrameworkContext: (k, v) => this.setFrameworkContext(k, v), + setFrameworkConfig: (i, c) => this.setFrameworkConfig(i, c), + setDetectedFramework: (l) => this.setDetectedFramework(l), + setUnsupportedVersion: (info) => this.setUnsupportedVersion(info), + addDiscoveredFeature: (f) => this.addDiscoveredFeature(f), + setDetectionComplete: () => this.setDetectionComplete(), }; for (const step of steps) { if (step.onReady) { diff --git a/src/ui/wizard-ui.ts b/src/ui/wizard-ui.ts index 0853790f..aabbb3cd 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -8,7 +8,7 @@ * Session-mutating methods trigger reactive screen resolution in the TUI. */ -import type { SettingsConflict } from '../lib/agent-interface'; +import type { SettingsConflict } from '../lib/agent/agent-interface'; import type { WizardReadinessResult } from '../lib/health-checks/readiness.js'; export enum TaskStatus {