From e59aeeaca0c48bae7678a0283fb3fc9338561f69 Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Mon, 13 Apr 2026 21:03:16 -0400 Subject: [PATCH 01/10] workflow directories and registries --- bin.ts | 199 +++++++++--------- src/lib/revenue-runner.ts | 23 -- .../workflows/posthog-integration/index.ts | 10 + .../steps.ts} | 6 +- .../detect.ts} | 53 +---- src/lib/workflows/revenue-analytics/index.ts | 28 +++ src/lib/workflows/revenue-analytics/steps.ts | 49 +++++ src/lib/workflows/workflow-registry.ts | 31 +++ src/lib/{ => workflows}/workflow-step.ts | 26 ++- src/ui/tui/__tests__/flows.test.ts | 16 +- src/ui/tui/__tests__/router.test.ts | 10 +- src/ui/tui/__tests__/store.test.ts | 2 +- src/ui/tui/flows.ts | 41 ++-- src/ui/tui/router.ts | 2 +- src/ui/tui/screens/RevenueIntroScreen.tsx | 2 +- src/ui/tui/start-tui.ts | 2 +- src/ui/tui/store.ts | 4 +- 17 files changed, 295 insertions(+), 209 deletions(-) delete mode 100644 src/lib/revenue-runner.ts create mode 100644 src/lib/workflows/posthog-integration/index.ts rename src/lib/workflows/{posthog-integration.ts => posthog-integration/steps.ts} (93%) rename src/lib/workflows/{revenue-analytics.ts => revenue-analytics/detect.ts} (77%) create mode 100644 src/lib/workflows/revenue-analytics/index.ts create mode 100644 src/lib/workflows/revenue-analytics/steps.ts create mode 100644 src/lib/workflows/workflow-registry.ts rename src/lib/{ => workflows}/workflow-step.ts (81%) diff --git a/bin.ts b/bin.ts index ffdf4054..a877a795 100644 --- a/bin.ts +++ b/bin.ts @@ -25,6 +25,8 @@ 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 +41,95 @@ if (process.env.NODE_ENV === 'test') { })(); } -yargs(hideBin(process.argv)) +/** + * Shared handler for skill-based workflow subcommands. + * Starts the TUI, runs ready hooks, waits for the intro gate, + * runs skill bootstrap, and waits for outro dismissal. + */ +function runSkillWorkflow( + 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, + 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; + + await tui.store.runReadyHooks(); + await tui.store.getGate('intro'); + + const { runSkillBootstrap } = await import('./src/lib/skill-runner.js'); + await runSkillBootstrap(tui.store.session, config.bootstrap!); + + 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(); + } + }); + 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 + } + } + })(); +} + +/** 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({ @@ -503,102 +593,19 @@ 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 }; - - 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'); - - const tui = startTUI(WIZARD_VERSION, Flow.Revenue); - - 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 any workflow-declared pre-flow work (e.g. prerequisite - // detection + skill download for the revenue flow). - await tui.store.runReadyHooks(); - - // 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'); - - const { runRevenueWizard } = await import( - './src/lib/revenue-runner.js' - ); - await runRevenueWizard(tui.store.session); + }); + +// ── Skill-based workflow subcommands (derived from registry) ───────── +for (const wfConfig of getSubcommandWorkflows()) { + cli.command( + wfConfig.command!, + wfConfig.description, + (y) => y.options(skillSubcommandOptions), + (argv) => runSkillWorkflow(wfConfig, { ...argv }), + ); +} - // 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 - } - } - })(); - }, - ) +cli .help() .alias('help', 'h') .version() 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/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts new file mode 100644 index 00000000..fb1d53da --- /dev/null +++ b/src/lib/workflows/posthog-integration/index.ts @@ -0,0 +1,10 @@ +import type { WorkflowConfig } from '../workflow-step.js'; +import { POSTHOG_INTEGRATION_WORKFLOW } from './steps.js'; + +export const posthogIntegrationConfig: WorkflowConfig = { + description: 'Run the PostHog setup wizard', + flowKey: 'core-integration', + steps: POSTHOG_INTEGRATION_WORKFLOW, +}; + +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 93% rename from src/lib/workflows/posthog-integration.ts rename to src/lib/workflows/posthog-integration/steps.ts index c30ffe6a..ed01ed5f 100644 --- a/src/lib/workflows/posthog-integration.ts +++ b/src/lib/workflows/posthog-integration/steps.ts @@ -7,12 +7,12 @@ */ 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'; function needsSetup(session: WizardSession): boolean { const config = session.frameworkConfig; 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 f92aa6e3..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', @@ -109,7 +107,7 @@ function findPackageJsons(installDir: string, maxDepth = 3): PackageMatch[] { * * The skill install happens later in the bootstrap runner, not here. */ -function detectRevenuePrerequisites( +export function detectRevenuePrerequisites( session: WizardSession, setFrameworkContext: (key: string, value: unknown) => void, ): void { @@ -176,42 +174,3 @@ 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..ef9a2687 --- /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, + bootstrap: { + 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, + }, + 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..79c408d8 --- /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 bootstrap runner (see skill-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 81% rename from src/lib/workflow-step.ts rename to src/lib/workflows/workflow-step.ts index 277383fd..ad50020c 100644 --- a/src/lib/workflow-step.ts +++ b/src/lib/workflows/workflow-step.ts @@ -1,5 +1,6 @@ -import type { WizardSession } from './wizard-session'; -import type { WizardReadinessResult } from './health-checks/readiness.js'; +import type { WizardSession } from '../wizard-session'; +import type { WizardReadinessResult } from '../health-checks/readiness.js'; +import type { SkillBootstrapConfig } from '../skill-runner.js'; /** * A workflow step is the primary unit of the wizard's execution model. @@ -87,6 +88,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; + /** The SkillBootstrapConfig, if this is a skill-based workflow */ + bootstrap?: SkillBootstrapConfig; + /** 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/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..339f4d50 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -4,17 +4,17 @@ * 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'; // ── Screen + Flow enums ────────────────────────────────────────────── @@ -35,8 +35,8 @@ export enum Screen { /** Named flows the router can run */ export enum Flow { - Wizard = 'wizard', - Revenue = 'revenue', + CoreIntegration = 'core-integration', + RevenueAnalytics = 'revenue-analytics', McpAdd = 'mcp-add', McpRemove = 'mcp-remove', } @@ -52,27 +52,30 @@ 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, -}; +export const WORKFLOW_STEPS: Partial> = + Object.fromEntries( + WORKFLOW_REGISTRY.map((c) => [c.flowKey, c.steps]), + ) as Partial>; /** * 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[], - - [Flow.Revenue]: workflowToFlowEntries( - REVENUE_ANALYTICS_WORKFLOW, - ) as FlowEntry[], + // Derive workflow flows from registry + ...(Object.fromEntries( + WORKFLOW_REGISTRY.map((c) => [ + c.flowKey, + workflowToFlowEntries(c.steps) as FlowEntry[], + ]), + ) as Record), + // Standalone MCP flows [Flow.McpAdd]: [ { screen: Screen.McpAdd, 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/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/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..b76ace85 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -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); } From a910c1fef69007dc1593af518c10d756c233dfe0 Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Mon, 13 Apr 2026 22:44:30 -0400 Subject: [PATCH 02/10] splitting out detection --- bin.ts | 154 ++++++++------------------------- src/lib/detection/context.ts | 82 ++++++++++++++++++ src/lib/detection/features.ts | 64 ++++++++++++++ src/lib/detection/framework.ts | 36 ++++++++ src/lib/detection/index.ts | 7 ++ src/run.ts | 74 +++++----------- 6 files changed, 250 insertions(+), 167 deletions(-) create mode 100644 src/lib/detection/context.ts create mode 100644 src/lib/detection/features.ts create mode 100644 src/lib/detection/framework.ts create mode 100644 src/lib/detection/index.ts diff --git a/bin.ts b/bin.ts index a877a795..25cadd05 100644 --- a/bin.ts +++ b/bin.ts @@ -27,6 +27,12 @@ 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'; +import { + detectFramework, + discoverFeatures, + gatherFrameworkContext, + checkFrameworkVersion, +} from './src/lib/detection'; if (process.env.NODE_ENV === 'test') { void (async () => { @@ -314,54 +320,34 @@ const cli = yargs(hideBin(process.argv)) 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), - ), - ]); + const detectedIntegration = await detectFramework(installDir); 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 + 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)) { + tui.store.setFrameworkContext(key, value); } } @@ -372,84 +358,18 @@ const cli = yargs(hideBin(process.argv)) } // 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' + const versionResult = await checkFrameworkVersion( + config, + sessionOptions, ); - - if ( - depNames.some((d) => - ['stripe', '@stripe/stripe-js'].includes(d), - ) - ) { - tui.store.addDiscoveredFeature(DiscoveredFeature.Stripe); + if (versionResult.supported !== true) { + tui.store.setUnsupportedVersion(versionResult.supported); } + } - // 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 + // Feature discovery — scan deps for Stripe, LLM, etc. + for (const feature of discoverFeatures(installDir)) { + tui.store.addDiscoveredFeature(feature); } // Signal detection is done — IntroScreen shows picker or results 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/run.ts b/src/run.ts index bb689227..73e6ad54 100644 --- a/src/run.ts +++ b/src/run.ts @@ -2,11 +2,7 @@ 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 { Integration, WIZARD_INTERACTION_EVENT_NAME } from './lib/constants'; import { readEnvironment } from './utils/environment'; import { getUI } from './ui'; import path from 'path'; @@ -17,6 +13,7 @@ import { EventEmitter } from 'events'; import { logToFile, configureLogFileFromEnvironment } from './utils/debug'; import { wizardAbort } from './utils/wizard-abort'; import { readApiKeyFromEnv } from './utils/env-api-key'; +import { detectFramework, gatherFrameworkContext } from './lib/detection'; EventEmitter.defaultMaxListeners = 50; @@ -85,7 +82,8 @@ export async function runWizard(argv: Args, session?: WizardSession) { } const integration = - session.integration ?? (await detectAndResolveIntegration(session)); + session.integration ?? + (await detectAndResolveIntegration(session.installDir, session.menu)); session.integration = integration; analytics.setTag('integration', integration); @@ -97,27 +95,23 @@ export async function runWizard(argv: Args, session?: WizardSession) { // (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; - } + if (!contextAlreadyGathered) { + const context = await gatherFrameworkContext(config, { + 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 } } @@ -142,32 +136,12 @@ export async function runWizard(argv: Args, session?: WizardSession) { } } -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, + installDir: string, + menu?: boolean, ): Promise { - if (!session.menu) { - const detectedIntegration = await detectIntegration(session.installDir); + if (!menu) { + const detectedIntegration = await detectFramework(installDir); if (detectedIntegration) { getUI().setDetectedFramework( From e5d70b87fcc907afe033c1fa811b8c0a33d6e7bd Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Mon, 13 Apr 2026 22:52:17 -0400 Subject: [PATCH 03/10] consolidate to one runner --- bin.ts | 9 +- src/lib/workflow-runner.ts | 500 +++++++++++++++++++++++++++++ src/lib/workflows/workflow-step.ts | 2 +- src/run.ts | 195 ++++++++++- 4 files changed, 700 insertions(+), 6 deletions(-) create mode 100644 src/lib/workflow-runner.ts diff --git a/bin.ts b/bin.ts index 25cadd05..ca3c55eb 100644 --- a/bin.ts +++ b/bin.ts @@ -79,8 +79,13 @@ function runSkillWorkflow( await tui.store.runReadyHooks(); await tui.store.getGate('intro'); - const { runSkillBootstrap } = await import('./src/lib/skill-runner.js'); - await runSkillBootstrap(tui.store.session, config.bootstrap!); + const { runWorkflow, bootstrapToRunConfig } = await import( + './src/lib/workflow-runner.js' + ); + await runWorkflow( + tui.store.session, + bootstrapToRunConfig(config.bootstrap!), + ); tui.store.onEnterScreen('outro' as any, () => { // Screen is already outro — listen for dismissal diff --git a/src/lib/workflow-runner.ts b/src/lib/workflow-runner.ts new file mode 100644 index 00000000..82c0b23d --- /dev/null +++ b/src/lib/workflow-runner.ts @@ -0,0 +1,500 @@ +/** + * Unified workflow runner. + * + * Replaces both agent-runner.ts and skill-runner.ts with a single + * configurable pipeline. Each workflow provides a WorkflowRunConfig + * 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, + OutroKind, +} from './wizard-session'; +import { getOrAskForProjectData } from '../utils/setup-utils'; +import { analytics } from '../utils/analytics'; +import { getUI } from '../ui'; +import { + initializeAgent, + runAgent, + 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'; + +// ── Types ──────────────────────────────────────────────────────────── + +/** + * 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; +} + +/** + * Credentials returned by OAuth, stored on the session. + */ +interface Credentials { + accessToken: string; + projectApiKey: string; + host: string; + projectId: number; +} + +/** + * Configuration for a single workflow run. Each workflow builds one of + * these — either directly via `buildRunConfig`, or indirectly via + * `SkillBootstrapConfig` which is expanded by `bootstrapToRunConfig`. + */ +export interface WorkflowRunConfig { + /** Analytics label for this run (e.g. 'revenue-analytics', 'nextjs') */ + integrationLabel: string; + + /** + * Context-mill skill ID to pre-install before the agent runs. + * Omit to let the agent discover and install skills at runtime. + */ + skillId?: string; + + /** + * Build the agent prompt. Pure string construction — reads project + * credentials and skill path from context, no auth dependency. + */ + buildPrompt: (ctx: PromptContext) => string; + + /** Additional MCP servers to attach to the agent (e.g., Svelte MCP). */ + additionalMcpServers?: Record; + + /** Package manager detector override. Defaults to detectNodePackageManagers. */ + detectPackageManager?: PackageManagerDetector; + + /** Spinner message during agent run */ + spinnerMessage: string; + /** Outro success message */ + successMessage: string; + /** Estimated duration in minutes */ + estimatedDurationMinutes: number; + /** Report file the agent should write */ + reportFile: string; + /** Docs URL for the outro */ + docsUrl: string; + /** Error message prefix for agent failures */ + errorMessage?: string; + + /** Feature queue for additional integrations (e.g., LLM, Stripe) */ + additionalFeatureQueue?: readonly AdditionalFeature[]; + + /** + * Post-agent hooks. Runs after the agent completes successfully, + * before outro. Use for env var upload, analytics tags, etc. + */ + postRun?: (session: WizardSession, credentials: Credentials) => Promise; + + /** + * Build outro data. If omitted, a default outro is built from + * successMessage, reportFile, and 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 ─────────────────────────────────────────────────────────── + +/** + * 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 + * `WorkflowRunConfig` controls what varies between them. + */ +export async function runWorkflow( + session: WizardSession, + config: WorkflowRunConfig, +): Promise { + // 1. Init logging + debug + initLogFile(); + logToFile(`[workflow-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('[workflow-runner] evaluating wizard readiness'); + const readiness = await evaluateWizardReadiness(); + logToFile(`[workflow-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( + `[workflow-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('[workflow-runner] settings override resolved'); + } + + analytics.wizardCapture('agent started', { + integration: config.integrationLabel, + }); + + // 4. OAuth + logToFile('[workflow-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(`[workflow-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(`[workflow-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 = config.buildPrompt({ + projectId, + projectApiKey, + host, + skillPath, + }); + + // 8. Run agent + const agentResult = await runAgent( + 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'); +} + +// ── SkillBootstrapConfig adapter ───────────────────────────────────── + +/** + * Re-export the SkillBootstrapConfig type so workflow-step.ts can + * import it from here instead of skill-runner.ts. + */ +export type { SkillBootstrapConfig } from './skill-runner'; + +/** + * Convert a SkillBootstrapConfig (the shorthand used by skill-based + * workflows) into a full WorkflowRunConfig. + */ +export function bootstrapToRunConfig( + bootstrap: import('./skill-runner').SkillBootstrapConfig, +): WorkflowRunConfig { + return { + integrationLabel: bootstrap.integrationLabel, + skillId: bootstrap.skillId, + buildPrompt: (ctx) => { + const lines = [ + `You have access to the PostHog MCP server.${ + bootstrap.promptContext ? ' ' + bootstrap.promptContext : '' + }`, + '', + 'Project context:', + `- PostHog Project ID: ${ctx.projectId}`, + `- PostHog public token: ${ctx.projectApiKey}`, + `- PostHog Host: ${ctx.host}`, + '', + `A PostHog skill has been installed at ${ctx.skillPath}/. Read ${ctx.skillPath}/SKILL.md and follow its instructions completely.`, + '', + `After completing the skill workflow, write a brief markdown report to ./${bootstrap.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.', + ]; + return lines.join('\n'); + }, + spinnerMessage: bootstrap.spinnerMessage, + successMessage: bootstrap.successMessage, + estimatedDurationMinutes: bootstrap.estimatedDurationMinutes, + reportFile: bootstrap.reportFile, + docsUrl: bootstrap.docsUrl, + }; +} + +// ── 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/workflows/workflow-step.ts b/src/lib/workflows/workflow-step.ts index ad50020c..c288a348 100644 --- a/src/lib/workflows/workflow-step.ts +++ b/src/lib/workflows/workflow-step.ts @@ -1,6 +1,6 @@ import type { WizardSession } from '../wizard-session'; import type { WizardReadinessResult } from '../health-checks/readiness.js'; -import type { SkillBootstrapConfig } from '../skill-runner.js'; +import type { SkillBootstrapConfig } from '../workflow-runner.js'; /** * A workflow step is the primary unit of the wizard's execution model. diff --git a/src/run.ts b/src/run.ts index 73e6ad54..36ae9404 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,4 +1,8 @@ -import { type WizardSession, buildSession } from './lib/wizard-session'; +import { + type WizardSession, + buildSession, + OutroKind, +} from './lib/wizard-session'; import type { CloudRegion } from './utils/types'; @@ -8,12 +12,20 @@ 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 { runWorkflow, type WorkflowRunConfig } from './lib/workflow-runner'; +import { AgentSignals } from './lib/agent-interface'; +import { + DEFAULT_PACKAGE_INSTALLATION, + SPINNER_MESSAGE, + type FrameworkConfig, +} from './lib/framework-config'; +import { tryGetPackageJson, isUsingTypeScript } from './utils/setup-utils'; import { EventEmitter } from 'events'; import { logToFile, configureLogFileFromEnvironment } from './utils/debug'; import { wizardAbort } from './utils/wizard-abort'; import { readApiKeyFromEnv } from './utils/env-api-key'; import { detectFramework, gatherFrameworkContext } from './lib/detection'; +import { getCloudUrlFromRegion } from './utils/urls'; EventEmitter.defaultMaxListeners = 50; @@ -116,7 +128,8 @@ export async function runWizard(argv: Args, session?: WizardSession) { } try { - await runAgentWizard(config, session); + const runConfig = await frameworkToRunConfig(config, session); + await runWorkflow(session, runConfig); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = @@ -136,6 +149,182 @@ export async function runWizard(argv: Args, session?: WizardSession) { } } +/** + * Build a WorkflowRunConfig from a FrameworkConfig. + * + * Does the framework-specific pre-agent work (TypeScript detection, + * package.json reading, version resolution, analytics tags) and + * captures the results in closures on the returned config. + */ +async function frameworkToRunConfig( + config: FrameworkConfig, + session: WizardSession, +): Promise { + 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 for framework version + if (frameworkVersion && config.detection.getVersionBucket) { + const versionBucket = config.detection.getVersionBucket(frameworkVersion); + analytics.setTag(`${config.metadata.integration}-version`, versionBucket); + } + + // Analytics tags from framework context + 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, + + buildPrompt: (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) => { + // Upload environment variables to hosting providers + 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, + }; + }, + }; +} + async function detectAndResolveIntegration( installDir: string, menu?: boolean, From d95e921b46e72b6daef932e18b2b9a1b9dc84c53 Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Mon, 13 Apr 2026 22:59:15 -0400 Subject: [PATCH 04/10] consolidating workflow context and configs --- bin.ts | 8 +- .../workflows/posthog-integration/detect.ts | 72 +++++++ .../workflows/posthog-integration/index.ts | 191 +++++++++++++++++- .../workflows/posthog-integration/steps.ts | 10 + src/lib/workflows/workflow-step.ts | 26 ++- src/ui/tui/store.ts | 5 + 6 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 src/lib/workflows/posthog-integration/detect.ts diff --git a/bin.ts b/bin.ts index ca3c55eb..52ff4f82 100644 --- a/bin.ts +++ b/bin.ts @@ -82,10 +82,10 @@ function runSkillWorkflow( const { runWorkflow, bootstrapToRunConfig } = await import( './src/lib/workflow-runner.js' ); - await runWorkflow( - tui.store.session, - bootstrapToRunConfig(config.bootstrap!), - ); + const runConfig = config.buildRunConfig + ? await config.buildRunConfig(tui.store.session) + : bootstrapToRunConfig(config.bootstrap!); + await runWorkflow(tui.store.session, runConfig); tui.store.onEnterScreen('outro' as any, () => { // Screen is already outro — listen for dismissal 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 index fb1d53da..79a66fd5 100644 --- a/src/lib/workflows/posthog-integration/index.ts +++ b/src/lib/workflows/posthog-integration/index.ts @@ -1,10 +1,199 @@ import type { WorkflowConfig } from '../workflow-step.js'; +import type { WorkflowRunConfig } from '../../workflow-runner.js'; +import type { WizardSession } from '../../wizard-session.js'; +import { OutroKind } from '../../wizard-session.js'; +import { AgentSignals } from '../../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 = { - description: 'Run the PostHog setup wizard', + command: 'integrate', + description: 'Set up PostHog SDK integration', flowKey: 'core-integration', steps: POSTHOG_INTEGRATION_WORKFLOW, + + buildRunConfig: 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, + + buildPrompt: (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/steps.ts b/src/lib/workflows/posthog-integration/steps.ts index ed01ed5f..426a90bd 100644 --- a/src/lib/workflows/posthog-integration/steps.ts +++ b/src/lib/workflows/posthog-integration/steps.ts @@ -13,6 +13,7 @@ import { evaluateWizardReadiness, WizardReadiness, } 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/workflow-step.ts b/src/lib/workflows/workflow-step.ts index c288a348..482811bd 100644 --- a/src/lib/workflows/workflow-step.ts +++ b/src/lib/workflows/workflow-step.ts @@ -1,6 +1,11 @@ -import type { WizardSession } from '../wizard-session'; +import type { WizardSession, DiscoveredFeature } from '../wizard-session'; import type { WizardReadinessResult } from '../health-checks/readiness.js'; -import type { SkillBootstrapConfig } from '../workflow-runner.js'; +import type { + SkillBootstrapConfig, + WorkflowRunConfig, +} from '../workflow-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. @@ -32,6 +37,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 { @@ -105,6 +124,9 @@ export interface WorkflowConfig { steps: Workflow; /** The SkillBootstrapConfig, if this is a skill-based workflow */ bootstrap?: SkillBootstrapConfig; + /** Build a WorkflowRunConfig for workflows that need custom agent behavior. + * Mutually exclusive with bootstrap — use one or the other. */ + buildRunConfig?: (session: WizardSession) => Promise; /** Prerequisites: other workflow flowKeys that must have run first */ requires?: string[]; } diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index b76ace85..17ae45c2 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -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) { From e536ab9623b4d7aab36a01616f849a66e7adaecc Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Mon, 13 Apr 2026 23:19:06 -0400 Subject: [PATCH 05/10] consolidated runner, no more run.ts --- bin.ts | 244 +++++----- src/__tests__/run.test.ts | 109 ----- src/lib/agent-runner.ts | 464 ------------------- src/lib/skill-runner.ts | 322 ------------- src/lib/workflow-runner.ts | 27 +- src/lib/workflows/revenue-analytics/steps.ts | 2 +- src/run.ts | 361 --------------- src/ui/tui/ink-ui.ts | 2 +- 8 files changed, 146 insertions(+), 1385 deletions(-) delete mode 100644 src/__tests__/run.test.ts delete mode 100644 src/lib/agent-runner.ts delete mode 100644 src/lib/skill-runner.ts delete mode 100644 src/run.ts diff --git a/bin.ts b/bin.ts index 52ff4f82..949854a0 100644 --- a/bin.ts +++ b/bin.ts @@ -19,20 +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'; -import { - detectFramework, - discoverFeatures, - gatherFrameworkContext, - checkFrameworkVersion, -} from './src/lib/detection'; if (process.env.NODE_ENV === 'test') { void (async () => { @@ -52,7 +43,7 @@ if (process.env.NODE_ENV === 'test') { * Starts the TUI, runs ready hooks, waits for the intro gate, * runs skill bootstrap, and waits for outro dismissal. */ -function runSkillWorkflow( +function runAgentWorkflow( config: WorkflowConfig, options: Record, ): void { @@ -68,9 +59,15 @@ function runSkillWorkflow( 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, }); @@ -270,7 +267,114 @@ const cli = 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 { runWorkflow } = await import('./src/lib/workflow-runner.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 runConfig = await posthogIntegrationConfig.buildRunConfig!( + session, + ); + await runWorkflow(session, runConfig); + } 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`); @@ -291,117 +395,13 @@ const cli = yargs(hideBin(process.argv)) (startPlayground as (version: string) => void)(WIZARD_VERSION); })(); } 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 installDir = session.installDir ?? process.cwd(); - - 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)) { - tui.store.setFrameworkContext(key, value); - } - } - - tui.store.setFrameworkConfig(detectedIntegration, config); - - if (!session.detectedFrameworkLabel) { - tui.store.setDetectedFramework(config.metadata.name); - } - - // Early version check — surface on IntroScreen before user proceeds - const versionResult = await checkFrameworkVersion( - config, - sessionOptions, - ); - if (versionResult.supported !== true) { - tui.store.setUnsupportedVersion(versionResult.supported); - } - } - - // Feature discovery — scan deps for Stripe, LLM, etc. - for (const feature of discoverFeatures(installDir)) { - tui.store.addDiscoveredFeature(feature); - } - - // 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' + ); + runAgentWorkflow(posthogIntegrationConfig, options); })(); } }, @@ -526,7 +526,7 @@ for (const wfConfig of getSubcommandWorkflows()) { wfConfig.command!, wfConfig.description, (y) => y.options(skillSubcommandOptions), - (argv) => runSkillWorkflow(wfConfig, { ...argv }), + (argv) => runAgentWorkflow(wfConfig, { ...argv }), ); } 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/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/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/workflow-runner.ts b/src/lib/workflow-runner.ts index 82c0b23d..9c0cb86e 100644 --- a/src/lib/workflow-runner.ts +++ b/src/lib/workflow-runner.ts @@ -423,20 +423,37 @@ export async function runWorkflow( await analytics.shutdown('success'); } -// ── SkillBootstrapConfig adapter ───────────────────────────────────── +// ── SkillBootstrapConfig ───────────────────────────────────────────── /** - * Re-export the SkillBootstrapConfig type so workflow-step.ts can - * import it from here instead of skill-runner.ts. + * Configuration for a skill-based workflow. + * Shorthand that gets expanded into a WorkflowRunConfig via bootstrapToRunConfig. */ -export type { SkillBootstrapConfig } from './skill-runner'; +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; +} /** * Convert a SkillBootstrapConfig (the shorthand used by skill-based * workflows) into a full WorkflowRunConfig. */ export function bootstrapToRunConfig( - bootstrap: import('./skill-runner').SkillBootstrapConfig, + bootstrap: SkillBootstrapConfig, ): WorkflowRunConfig { return { integrationLabel: bootstrap.integrationLabel, diff --git a/src/lib/workflows/revenue-analytics/steps.ts b/src/lib/workflows/revenue-analytics/steps.ts index 79c408d8..bdd76ca7 100644 --- a/src/lib/workflows/revenue-analytics/steps.ts +++ b/src/lib/workflows/revenue-analytics/steps.ts @@ -2,7 +2,7 @@ * Revenue analytics workflow step list. * * The detect step checks for PostHog + Stripe SDKs. The skill install - * and agent run live in the bootstrap runner (see skill-runner.ts). + * and agent run live in the workflow runner (see workflow-runner.ts). */ import type { Workflow } from '../workflow-step.js'; diff --git a/src/run.ts b/src/run.ts deleted file mode 100644 index 36ae9404..00000000 --- a/src/run.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { - type WizardSession, - buildSession, - OutroKind, -} from './lib/wizard-session'; - -import type { CloudRegion } from './utils/types'; - -import { Integration, 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 { runWorkflow, type WorkflowRunConfig } from './lib/workflow-runner'; -import { AgentSignals } from './lib/agent-interface'; -import { - DEFAULT_PACKAGE_INSTALLATION, - SPINNER_MESSAGE, - type FrameworkConfig, -} from './lib/framework-config'; -import { tryGetPackageJson, isUsingTypeScript } from './utils/setup-utils'; -import { EventEmitter } from 'events'; -import { logToFile, configureLogFileFromEnvironment } from './utils/debug'; -import { wizardAbort } from './utils/wizard-abort'; -import { readApiKeyFromEnv } from './utils/env-api-key'; -import { detectFramework, gatherFrameworkContext } from './lib/detection'; -import { getCloudUrlFromRegion } from './utils/urls'; - -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.installDir, session.menu)); - - 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 (!contextAlreadyGathered) { - const context = await gatherFrameworkContext(config, { - 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; - } - } - } - - try { - const runConfig = await frameworkToRunConfig(config, session); - await runWorkflow(session, runConfig); - } 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, - }); - } -} - -/** - * Build a WorkflowRunConfig from a FrameworkConfig. - * - * Does the framework-specific pre-agent work (TypeScript detection, - * package.json reading, version resolution, analytics tags) and - * captures the results in closures on the returned config. - */ -async function frameworkToRunConfig( - config: FrameworkConfig, - session: WizardSession, -): Promise { - 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 for framework version - if (frameworkVersion && config.detection.getVersionBucket) { - const versionBucket = config.detection.getVersionBucket(frameworkVersion); - analytics.setTag(`${config.metadata.integration}-version`, versionBucket); - } - - // Analytics tags from framework context - 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, - - buildPrompt: (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) => { - // Upload environment variables to hosting providers - 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, - }; - }, - }; -} - -async function detectAndResolveIntegration( - installDir: string, - menu?: boolean, -): Promise { - if (!menu) { - const detectedIntegration = await detectFramework(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/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 8b55f7ed..b133d537 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -68,7 +68,7 @@ export class InkUI implements WizardUI { showBlockingOutage(result: WizardReadinessResult): Promise { // In the TUI, the HealthCheckScreen handles outage display. - // This is only called from agent-runner for the CI fallback path. + // This is only called from workflow-runner for the CI fallback path. this.store.setReadinessResult(result); return Promise.resolve(); } From 0fc79bc3ea6029a4461ff5302fd1b0e0379a0cfc Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Mon, 13 Apr 2026 23:28:05 -0400 Subject: [PATCH 06/10] agent directory --- bin.ts | 6 ++- src/lib/__tests__/agent-interface.test.ts | 2 +- src/lib/{ => agent}/agent-interface.ts | 35 ++++++++------ .../agent-runner.ts} | 48 +++++++++---------- src/lib/middleware/benchmark.ts | 2 +- .../benchmarks/compaction-tracker.ts | 2 +- src/lib/middleware/benchmarks/json-writer.ts | 2 +- src/lib/middleware/benchmarks/summary.ts | 2 +- src/lib/middleware/config.ts | 2 +- src/lib/wizard-session.ts | 2 +- .../workflows/posthog-integration/index.ts | 4 +- src/lib/workflows/revenue-analytics/steps.ts | 2 +- src/lib/workflows/workflow-step.ts | 2 +- src/ui/logging-ui.ts | 2 +- src/ui/tui/ink-ui.ts | 4 +- src/ui/tui/screens/ManagedSettingsScreen.tsx | 2 +- src/ui/tui/screens/SettingsOverrideScreen.tsx | 2 +- src/ui/tui/store.ts | 2 +- src/ui/wizard-ui.ts | 2 +- 19 files changed, 66 insertions(+), 59 deletions(-) rename src/lib/{ => agent}/agent-interface.ts (98%) rename src/lib/{workflow-runner.ts => agent/agent-runner.ts} (91%) diff --git a/bin.ts b/bin.ts index 949854a0..e6eb0d8c 100644 --- a/bin.ts +++ b/bin.ts @@ -77,7 +77,7 @@ function runAgentWorkflow( await tui.store.getGate('intro'); const { runWorkflow, bootstrapToRunConfig } = await import( - './src/lib/workflow-runner.js' + './src/lib/agent/agent-runner.js' ); const runConfig = config.buildRunConfig ? await config.buildRunConfig(tui.store.session) @@ -284,7 +284,9 @@ const cli = yargs(hideBin(process.argv)) './src/lib/detection/index.js' ); const { analytics } = await import('./src/utils/analytics.js'); - const { runWorkflow } = await import('./src/lib/workflow-runner.js'); + const { runWorkflow } = await import( + './src/lib/agent/agent-runner.js' + ); const { posthogIntegrationConfig } = await import( './src/lib/workflows/posthog-integration/index.js' ); 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-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/workflow-runner.ts b/src/lib/agent/agent-runner.ts similarity index 91% rename from src/lib/workflow-runner.ts rename to src/lib/agent/agent-runner.ts index 9c0cb86e..a9a7a65d 100644 --- a/src/lib/workflow-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -20,10 +20,10 @@ import { type WizardSession, type AdditionalFeature, OutroKind, -} from './wizard-session'; -import { getOrAskForProjectData } from '../utils/setup-utils'; -import { analytics } from '../utils/analytics'; -import { getUI } from '../ui'; +} from '../wizard-session'; +import { getOrAskForProjectData } from '../../utils/setup-utils'; +import { analytics } from '../../utils/analytics'; +import { getUI } from '../../ui'; import { initializeAgent, runAgent, @@ -34,24 +34,24 @@ import { backupAndFixClaudeSettings, restoreClaudeSettings, } from './agent-interface'; -import { getCloudUrlFromRegion } from '../utils/urls'; +import { getCloudUrlFromRegion } from '../../utils/urls'; import { evaluateWizardReadiness, WizardReadiness, -} from './health-checks/readiness'; -import { enableDebugLogs, initLogFile, logToFile } from '../utils/debug'; -import { createBenchmarkPipeline } from './middleware/benchmark'; +} 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'; +} 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'; // ── Types ──────────────────────────────────────────────────────────── @@ -132,7 +132,7 @@ export interface WorkflowRunConfig { buildOutroData?: ( session: WizardSession, credentials: Credentials, - cloudRegion: import('../utils/types').CloudRegion | undefined, + cloudRegion: import('../../utils/types').CloudRegion | undefined, ) => WizardSession['outroData']; } @@ -170,7 +170,7 @@ export async function runWorkflow( ): Promise { // 1. Init logging + debug initLogFile(); - logToFile(`[workflow-runner] START ${config.integrationLabel}`); + logToFile(`[agent-runner] START ${config.integrationLabel}`); if (session.debug) { enableDebugLogs(); @@ -180,9 +180,9 @@ export async function runWorkflow( // 2. Health check (guarded — skip if TUI already ran it) if (!session.readinessResult) { - logToFile('[workflow-runner] evaluating wizard readiness'); + logToFile('[agent-runner] evaluating wizard readiness'); const readiness = await evaluateWizardReadiness(); - logToFile(`[workflow-runner] readiness=${readiness.decision}`); + logToFile(`[agent-runner] readiness=${readiness.decision}`); if (readiness.decision === WizardReadiness.No) { await getUI().showBlockingOutage(readiness); } else if (readiness.decision === WizardReadiness.YesWithWarnings) { @@ -193,7 +193,7 @@ export async function runWorkflow( // 3. Settings conflicts const settingsConflicts = checkAllSettingsConflicts(session.installDir); logToFile( - `[workflow-runner] settings conflicts: ${ + `[agent-runner] settings conflicts: ${ settingsConflicts.length > 0 ? settingsConflicts .map((c) => `${c.source}(${c.keys.join(',')})`) @@ -213,7 +213,7 @@ export async function runWorkflow( await getUI().showSettingsOverride(settingsConflicts, () => backupAndFixClaudeSettings(session.installDir), ); - logToFile('[workflow-runner] settings override resolved'); + logToFile('[agent-runner] settings override resolved'); } analytics.wizardCapture('agent started', { @@ -221,7 +221,7 @@ export async function runWorkflow( }); // 4. OAuth - logToFile('[workflow-runner] starting OAuth'); + logToFile('[agent-runner] starting OAuth'); const { projectApiKey, host, accessToken, projectId, cloudRegion } = await getOrAskForProjectData({ signup: session.signup, @@ -236,7 +236,7 @@ export async function runWorkflow( // 5. Skill install (if skillId provided) let skillPath: string | undefined; if (config.skillId) { - logToFile(`[workflow-runner] installing skill ${config.skillId}`); + logToFile(`[agent-runner] installing skill ${config.skillId}`); const installResult = await installSkillById( config.skillId, session.installDir, @@ -247,7 +247,7 @@ export async function runWorkflow( return; } skillPath = installResult.path; - logToFile(`[workflow-runner] skill installed at ${skillPath}`); + logToFile(`[agent-runner] skill installed at ${skillPath}`); } // 6. Initialize agent 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/wizard-session.ts b/src/lib/wizard-session.ts index 195fc2fc..41e30180 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -13,7 +13,7 @@ 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'; function parseProjectIdArg(value: string | undefined): number | undefined { if (value === undefined || value === '') return undefined; diff --git a/src/lib/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts index 79a66fd5..7746bb2a 100644 --- a/src/lib/workflows/posthog-integration/index.ts +++ b/src/lib/workflows/posthog-integration/index.ts @@ -1,8 +1,8 @@ import type { WorkflowConfig } from '../workflow-step.js'; -import type { WorkflowRunConfig } from '../../workflow-runner.js'; +import type { WorkflowRunConfig } from '../../agent/agent-runner.js'; import type { WizardSession } from '../../wizard-session.js'; import { OutroKind } from '../../wizard-session.js'; -import { AgentSignals } from '../../agent-interface.js'; +import { AgentSignals } from '../../agent/agent-interface.js'; import { DEFAULT_PACKAGE_INSTALLATION, SPINNER_MESSAGE, diff --git a/src/lib/workflows/revenue-analytics/steps.ts b/src/lib/workflows/revenue-analytics/steps.ts index bdd76ca7..2f840910 100644 --- a/src/lib/workflows/revenue-analytics/steps.ts +++ b/src/lib/workflows/revenue-analytics/steps.ts @@ -2,7 +2,7 @@ * 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 workflow-runner.ts). + * and agent run live in the workflow runner (see agent-runner.ts). */ import type { Workflow } from '../workflow-step.js'; diff --git a/src/lib/workflows/workflow-step.ts b/src/lib/workflows/workflow-step.ts index 482811bd..9d8a8c84 100644 --- a/src/lib/workflows/workflow-step.ts +++ b/src/lib/workflows/workflow-step.ts @@ -3,7 +3,7 @@ import type { WizardReadinessResult } from '../health-checks/readiness.js'; import type { SkillBootstrapConfig, WorkflowRunConfig, -} from '../workflow-runner.js'; +} from '../agent/agent-runner.js'; import type { Integration } from '../constants.js'; import type { FrameworkConfig } from '../framework-config.js'; 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/ink-ui.ts b/src/ui/tui/ink-ui.ts index b133d537..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'; @@ -68,7 +68,7 @@ export class InkUI implements WizardUI { showBlockingOutage(result: WizardReadinessResult): Promise { // In the TUI, the HealthCheckScreen handles outage display. - // This is only called from workflow-runner for the CI fallback path. + // This is only called from agent-runner for the CI fallback path. this.store.setReadinessResult(result); return Promise.resolve(); } 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/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/store.ts b/src/ui/tui/store.ts index 17ae45c2..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, 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 { From f015d2fddc65a5f96691412deb13b0ac5d94fb97 Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Tue, 14 Apr 2026 01:42:03 -0400 Subject: [PATCH 07/10] one run interface for workflow configs --- bin.ts | 18 +- src/lib/agent/agent-runner.ts | 207 ++++++++---------- .../workflows/posthog-integration/index.ts | 8 +- src/lib/workflows/revenue-analytics/index.ts | 4 +- src/lib/workflows/workflow-step.ts | 12 +- 5 files changed, 108 insertions(+), 141 deletions(-) diff --git a/bin.ts b/bin.ts index e6eb0d8c..f705cda2 100644 --- a/bin.ts +++ b/bin.ts @@ -76,13 +76,8 @@ function runAgentWorkflow( await tui.store.runReadyHooks(); await tui.store.getGate('intro'); - const { runWorkflow, bootstrapToRunConfig } = await import( - './src/lib/agent/agent-runner.js' - ); - const runConfig = config.buildRunConfig - ? await config.buildRunConfig(tui.store.session) - : bootstrapToRunConfig(config.bootstrap!); - await runWorkflow(tui.store.session, runConfig); + const { runAgent } = await import('./src/lib/agent/agent-runner.js'); + await runAgent(config, tui.store.session); tui.store.onEnterScreen('outro' as any, () => { // Screen is already outro — listen for dismissal @@ -284,9 +279,6 @@ const cli = yargs(hideBin(process.argv)) './src/lib/detection/index.js' ); const { analytics } = await import('./src/utils/analytics.js'); - const { runWorkflow } = await import( - './src/lib/agent/agent-runner.js' - ); const { posthogIntegrationConfig } = await import( './src/lib/workflows/posthog-integration/index.js' ); @@ -356,10 +348,10 @@ const cli = yargs(hideBin(process.argv)) } try { - const runConfig = await posthogIntegrationConfig.buildRunConfig!( - session, + const { runAgent } = await import( + './src/lib/agent/agent-runner.js' ); - await runWorkflow(session, runConfig); + await runAgent(posthogIntegrationConfig, session); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts index a9a7a65d..ed7efabf 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -1,8 +1,8 @@ /** * Unified workflow runner. * - * Replaces both agent-runner.ts and skill-runner.ts with a single - * configurable pipeline. Each workflow provides a WorkflowRunConfig + * 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 @@ -26,7 +26,7 @@ import { analytics } from '../../utils/analytics'; import { getUI } from '../../ui'; import { initializeAgent, - runAgent, + runAgent as executeAgent, AgentErrorType, AgentSignals, buildWizardMetadata, @@ -53,6 +53,8 @@ import { getSkillsBaseUrl } from '../constants'; import { installSkillById, type InstallSkillResult } from '../wizard-tools'; import type { WizardOptions } from '../../utils/types'; +import type { WorkflowConfig } from '../workflows/workflow-step'; + // ── Types ──────────────────────────────────────────────────────────── /** @@ -69,7 +71,7 @@ export interface PromptContext { /** * Credentials returned by OAuth, stored on the session. */ -interface Credentials { +export interface Credentials { accessToken: string; projectApiKey: string; host: string; @@ -77,58 +79,33 @@ interface Credentials { } /** - * Configuration for a single workflow run. Each workflow builds one of - * these — either directly via `buildRunConfig`, or indirectly via - * `SkillBootstrapConfig` which is expanded by `bootstrapToRunConfig`. + * 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 WorkflowRunConfig { - /** Analytics label for this run (e.g. 'revenue-analytics', 'nextjs') */ +export interface WorkflowRun { + /** Analytics label (e.g. 'revenue-analytics', 'nextjs') */ integrationLabel: string; - - /** - * Context-mill skill ID to pre-install before the agent runs. - * Omit to let the agent discover and install skills at runtime. - */ + /** Skill ID to pre-install. Omit for agent-driven skill discovery. */ skillId?: string; - - /** - * Build the agent prompt. Pure string construction — reads project - * credentials and skill path from context, no auth dependency. - */ - buildPrompt: (ctx: PromptContext) => string; - - /** Additional MCP servers to attach to the agent (e.g., Svelte MCP). */ + /** 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 override. Defaults to detectNodePackageManagers. */ + /** Package manager detector. Defaults to detectNodePackageManagers. */ detectPackageManager?: PackageManagerDetector; - - /** Spinner message during agent run */ spinnerMessage: string; - /** Outro success message */ successMessage: string; - /** Estimated duration in minutes */ estimatedDurationMinutes: number; - /** Report file the agent should write */ reportFile: string; - /** Docs URL for the outro */ docsUrl: string; - /** Error message prefix for agent failures */ errorMessage?: string; - - /** Feature queue for additional integrations (e.g., LLM, Stripe) */ additionalFeatureQueue?: readonly AdditionalFeature[]; - - /** - * Post-agent hooks. Runs after the agent completes successfully, - * before outro. Use for env var upload, analytics tags, etc. - */ + /** Runs after agent completes, before outro (e.g. env var upload). */ postRun?: (session: WizardSession, credentials: Credentials) => Promise; - - /** - * Build outro data. If omitted, a default outro is built from - * successMessage, reportFile, and docsUrl. - */ + /** Custom outro data. Omit for default built from successMessage/reportFile/docsUrl. */ buildOutroData?: ( session: WizardSession, credentials: Credentials, @@ -136,6 +113,55 @@ export interface WorkflowRunConfig { ) => WizardSession['outroData']; } +// ── Prompt resolution ──────────────────────────────────────────────── + +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 config. + * + * Three sections, always in this order: + * 1. Default project prompt — credentials and base context (always included) + * 2. Custom prompt — additional workflow-specific instructions (if prompt set) + * 3. Skill prompt — "follow SKILL.md" instructions (if a skill was installed) + */ +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'); +} + // ── Helpers ────────────────────────────────────────────────────────── function sessionToOptions(session: WizardSession): WizardOptions { @@ -157,16 +183,38 @@ function sessionToOptions(session: WizardSession): WizardOptions { // ── 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 - * `WorkflowRunConfig` controls what varies between them. + * `WorkflowRun` controls what varies between them. */ export async function runWorkflow( session: WizardSession, - config: WorkflowRunConfig, + config: WorkflowRun, ): Promise { // 1. Init logging + debug initLogFile(); @@ -298,7 +346,7 @@ export async function runWorkflow( : undefined; // 7. Build prompt - const prompt = config.buildPrompt({ + const prompt = assemblePrompt(config, { projectId, projectApiKey, host, @@ -306,7 +354,7 @@ export async function runWorkflow( }); // 8. Run agent - const agentResult = await runAgent( + const agentResult = await executeAgent( agent, prompt, sessionToOptions(session), @@ -423,71 +471,6 @@ export async function runWorkflow( await analytics.shutdown('success'); } -// ── SkillBootstrapConfig ───────────────────────────────────────────── - -/** - * Configuration for a skill-based workflow. - * Shorthand that gets expanded into a WorkflowRunConfig via bootstrapToRunConfig. - */ -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; -} - -/** - * Convert a SkillBootstrapConfig (the shorthand used by skill-based - * workflows) into a full WorkflowRunConfig. - */ -export function bootstrapToRunConfig( - bootstrap: SkillBootstrapConfig, -): WorkflowRunConfig { - return { - integrationLabel: bootstrap.integrationLabel, - skillId: bootstrap.skillId, - buildPrompt: (ctx) => { - const lines = [ - `You have access to the PostHog MCP server.${ - bootstrap.promptContext ? ' ' + bootstrap.promptContext : '' - }`, - '', - 'Project context:', - `- PostHog Project ID: ${ctx.projectId}`, - `- PostHog public token: ${ctx.projectApiKey}`, - `- PostHog Host: ${ctx.host}`, - '', - `A PostHog skill has been installed at ${ctx.skillPath}/. Read ${ctx.skillPath}/SKILL.md and follow its instructions completely.`, - '', - `After completing the skill workflow, write a brief markdown report to ./${bootstrap.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.', - ]; - return lines.join('\n'); - }, - spinnerMessage: bootstrap.spinnerMessage, - successMessage: bootstrap.successMessage, - estimatedDurationMinutes: bootstrap.estimatedDurationMinutes, - reportFile: bootstrap.reportFile, - docsUrl: bootstrap.docsUrl, - }; -} - // ── Shared error helpers ───────────────────────────────────────────── async function abortOnInstallFailure( diff --git a/src/lib/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts index 7746bb2a..5ad8ccdb 100644 --- a/src/lib/workflows/posthog-integration/index.ts +++ b/src/lib/workflows/posthog-integration/index.ts @@ -1,5 +1,5 @@ import type { WorkflowConfig } from '../workflow-step.js'; -import type { WorkflowRunConfig } from '../../agent/agent-runner.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'; @@ -23,9 +23,7 @@ export const posthogIntegrationConfig: WorkflowConfig = { flowKey: 'core-integration', steps: POSTHOG_INTEGRATION_WORKFLOW, - buildRunConfig: async ( - session: WizardSession, - ): Promise => { + run: async (session: WizardSession): Promise => { const config = session.frameworkConfig!; const typeScriptDetected = isUsingTypeScript({ @@ -83,7 +81,7 @@ export const posthogIntegrationConfig: WorkflowConfig = { errorMessage: 'Integration failed', additionalFeatureQueue: session.additionalFeatureQueue, - buildPrompt: (ctx) => { + customPrompt: (ctx) => { const additionalLines = config.prompts.getAdditionalContextLines ? config.prompts.getAdditionalContextLines(frameworkContext) : []; diff --git a/src/lib/workflows/revenue-analytics/index.ts b/src/lib/workflows/revenue-analytics/index.ts index ef9a2687..64e0380b 100644 --- a/src/lib/workflows/revenue-analytics/index.ts +++ b/src/lib/workflows/revenue-analytics/index.ts @@ -6,10 +6,10 @@ export const revenueAnalyticsConfig: WorkflowConfig = { description: 'Set up PostHog revenue analytics (e.g. Stripe integration)', flowKey: 'revenue-analytics', steps: REVENUE_ANALYTICS_WORKFLOW, - bootstrap: { + run: { skillId: 'revenue-analytics-setup', integrationLabel: 'revenue-analytics', - promptContext: 'Set up revenue analytics for this project.', + customPrompt: () => 'Set up revenue analytics for this project.', successMessage: 'Revenue analytics configured!', reportFile: 'posthog-revenue-report.md', docsUrl: 'https://posthog.com/docs/revenue-analytics', diff --git a/src/lib/workflows/workflow-step.ts b/src/lib/workflows/workflow-step.ts index 9d8a8c84..f31ebdda 100644 --- a/src/lib/workflows/workflow-step.ts +++ b/src/lib/workflows/workflow-step.ts @@ -1,9 +1,6 @@ import type { WizardSession, DiscoveredFeature } from '../wizard-session'; import type { WizardReadinessResult } from '../health-checks/readiness.js'; -import type { - SkillBootstrapConfig, - WorkflowRunConfig, -} from '../agent/agent-runner.js'; +import type { WorkflowRun } from '../agent/agent-runner.js'; import type { Integration } from '../constants.js'; import type { FrameworkConfig } from '../framework-config.js'; @@ -122,11 +119,8 @@ export interface WorkflowConfig { flowKey: string; /** The ordered step list */ steps: Workflow; - /** The SkillBootstrapConfig, if this is a skill-based workflow */ - bootstrap?: SkillBootstrapConfig; - /** Build a WorkflowRunConfig for workflows that need custom agent behavior. - * Mutually exclusive with bootstrap — use one or the other. */ - buildRunConfig?: (session: WizardSession) => Promise; + /** 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[]; } From f5157387e52538ffb396d8a87d366097f2f6151a Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Tue, 14 Apr 2026 01:50:09 -0400 Subject: [PATCH 08/10] agent-prompt.ts --- src/lib/agent/agent-prompt.ts | 66 +++++++++++++++++++++++++++++++ src/lib/agent/agent-runner.ts | 74 +++-------------------------------- src/lib/wizard-session.ts | 14 ++++--- 3 files changed, 79 insertions(+), 75 deletions(-) create mode 100644 src/lib/agent/agent-prompt.ts 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 index ed7efabf..2f7be6f7 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -19,6 +19,7 @@ import { join } from 'path'; import { type WizardSession, type AdditionalFeature, + type Credentials, OutroKind, } from '../wizard-session'; import { getOrAskForProjectData } from '../../utils/setup-utils'; @@ -54,29 +55,13 @@ 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'; -// ── Types ──────────────────────────────────────────────────────────── +export type { PromptContext }; -/** - * 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; -} +// ── Types ──────────────────────────────────────────────────────────── -/** - * Credentials returned by OAuth, stored on the session. - */ -export interface Credentials { - accessToken: string; - projectApiKey: string; - host: string; - projectId: number; -} +export type { Credentials }; /** * Unified agent run configuration. @@ -113,55 +98,6 @@ export interface WorkflowRun { ) => WizardSession['outroData']; } -// ── Prompt resolution ──────────────────────────────────────────────── - -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 config. - * - * Three sections, always in this order: - * 1. Default project prompt — credentials and base context (always included) - * 2. Custom prompt — additional workflow-specific instructions (if prompt set) - * 3. Skill prompt — "follow SKILL.md" instructions (if a skill was installed) - */ -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'); -} - // ── Helpers ────────────────────────────────────────────────────────── function sessionToOptions(session: WizardSession): WizardOptions { diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 41e30180..9e455ca0 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -15,6 +15,13 @@ import type { FrameworkConfig } from './framework-config'; import type { WizardReadinessResult } from './health-checks/readiness'; 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; const n = Number(value); @@ -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; From 9545af0d879a19d3d77178f078158761d4f97d11 Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Tue, 14 Apr 2026 10:12:00 -0400 Subject: [PATCH 09/10] generic skill workflow --- bin.ts | 160 ++++++++++++++----------- src/lib/workflows/agent-skill/index.ts | 69 +++++++++++ src/lib/workflows/agent-skill/steps.ts | 38 ++++++ src/ui/tui/flows.ts | 13 +- 4 files changed, 210 insertions(+), 70 deletions(-) create mode 100644 src/lib/workflows/agent-skill/index.ts create mode 100644 src/lib/workflows/agent-skill/steps.ts diff --git a/bin.ts b/bin.ts index f705cda2..78bab09a 100644 --- a/bin.ts +++ b/bin.ts @@ -38,71 +38,6 @@ if (process.env.NODE_ENV === 'test') { })(); } -/** - * Shared handler for skill-based workflow subcommands. - * Starts the TUI, runs ready hooks, waits for the intro gate, - * runs skill bootstrap, and waits for outro dismissal. - */ -function runAgentWorkflow( - 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, - }); - tui.store.session = session; - - await tui.store.runReadyHooks(); - await tui.store.getGate('intro'); - - const { runAgent } = await import('./src/lib/agent/agent-runner.js'); - await runAgent(config, tui.store.session); - - 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(); - } - }); - 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 - } - } - })(); -} - /** Shared yargs options for skill-based workflow subcommands. */ const skillSubcommandOptions = { debug: { @@ -234,6 +169,11 @@ const cli = 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) => { @@ -388,6 +328,27 @@ const cli = 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: run core-integration through the unified workflow path. // Same codepath as `npx @posthog/wizard integrate`. @@ -395,7 +356,7 @@ const cli = yargs(hideBin(process.argv)) const { posthogIntegrationConfig } = await import( './src/lib/workflows/posthog-integration/index.js' ); - runAgentWorkflow(posthogIntegrationConfig, options); + runWizard(posthogIntegrationConfig, options); })(); } }, @@ -520,7 +481,7 @@ for (const wfConfig of getSubcommandWorkflows()) { wfConfig.command!, wfConfig.description, (y) => y.options(skillSubcommandOptions), - (argv) => runAgentWorkflow(wfConfig, { ...argv }), + (argv) => runWizard(wfConfig, { ...argv }), ); } @@ -530,3 +491,68 @@ cli .version() .alias('version', 'v') .wrap(process.stdout.isTTY ? yargs.terminalWidth() : 80).argv; + +/** + * 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, + }); + tui.store.session = session; + + await tui.store.runReadyHooks(); + await tui.store.getGate('intro'); + + const { runAgent } = await import('./src/lib/agent/agent-runner.js'); + await runAgent(config, tui.store.session); + + 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(); + } + }); + 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/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..2025069f --- /dev/null +++ b/src/lib/workflows/agent-skill/steps.ts @@ -0,0 +1,38 @@ +/** + * 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: 'intro', + label: 'Welcome', + screen: 'intro', + gate: (session) => session.setupConfirmed, + }, + { + 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/ui/tui/flows.ts b/src/ui/tui/flows.ts index 339f4d50..42ff3954 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -15,6 +15,7 @@ import { type Workflow, } 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 ────────────────────────────────────────────── @@ -37,6 +38,7 @@ export enum Screen { export enum Flow { CoreIntegration = 'core-integration', RevenueAnalytics = 'revenue-analytics', + AgentSkill = 'agent-skill', McpAdd = 'mcp-add', McpRemove = 'mcp-remove', } @@ -55,10 +57,12 @@ export interface FlowEntry { // ── Derived from WORKFLOW_REGISTRY ─────────────────────────────────── /** Raw workflow step arrays — used by the store for gate/onInit definitions. */ -export const WORKFLOW_STEPS: Partial> = - Object.fromEntries( +export const WORKFLOW_STEPS: Partial> = { + ...(Object.fromEntries( WORKFLOW_REGISTRY.map((c) => [c.flowKey, c.steps]), - ) as Partial>; + ) as Partial>), + [Flow.AgentSkill]: AGENT_SKILL_STEPS, +}; /** * All flow pipelines. @@ -75,6 +79,9 @@ export const FLOWS: Record = { ]), ) as Record), + // Generic agent skill flow + [Flow.AgentSkill]: workflowToFlowEntries(AGENT_SKILL_STEPS) as FlowEntry[], + // Standalone MCP flows [Flow.McpAdd]: [ { From 3c25e617dd892e813aba0d0b9e150b30b19a0cc0 Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Tue, 14 Apr 2026 10:59:33 -0400 Subject: [PATCH 10/10] generic skill runner with skill id --- bin.ts | 5 +++++ src/lib/wizard-session.ts | 6 ++++++ src/lib/workflows/agent-skill/steps.ts | 6 ------ src/ui/tui/primitives/ProgressList.tsx | 15 ++++++++++++++- src/ui/tui/screens/RunScreen.tsx | 9 ++++++++- 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/bin.ts b/bin.ts index 78bab09a..fa328cc3 100644 --- a/bin.ts +++ b/bin.ts @@ -525,6 +525,11 @@ function runWizard( 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; + tui.store.session = session; await tui.store.runReadyHooks(); diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 9e455ca0..81bed22c 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -151,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; } @@ -212,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/steps.ts b/src/lib/workflows/agent-skill/steps.ts index 2025069f..39003852 100644 --- a/src/lib/workflows/agent-skill/steps.ts +++ b/src/lib/workflows/agent-skill/steps.ts @@ -9,12 +9,6 @@ import type { Workflow } from '../workflow-step.js'; import { RunPhase } from '../../wizard-session.js'; export const AGENT_SKILL_STEPS: Workflow = [ - { - id: 'intro', - label: 'Welcome', - screen: 'intro', - gate: (session) => session.setupConfirmed, - }, { id: 'auth', label: 'Authentication', 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/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 =