diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c7c901a4..b41da937 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -57,6 +57,7 @@ export const DEFAULT_HOST_URL = IS_DEV ? 'http://localhost:8010' : 'https://us.i.posthog.com'; export const ISSUES_URL = 'https://github.com/posthog/wizard/issues'; +export const CONTEXT_MILL_URL = 'https://github.com/PostHog/context-mill'; // ── Analytics (internal) ────────────────────────────────────────────── diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index df07fcde..7bce7c7a 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -133,6 +133,8 @@ export interface WizardSession { mcpComplete: boolean; mcpOutcome: McpOutcome | null; mcpInstalledClients: string[]; + skillsComplete: boolean; + outroDismissed: boolean; // Runtime readinessResult: WizardReadinessResult | null; @@ -195,6 +197,8 @@ export function buildSession(args: { mcpComplete: false, mcpOutcome: null, mcpInstalledClients: [], + skillsComplete: false, + outroDismissed: false, loginUrl: null, credentials: null, readinessResult: null, diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index d40e8344..fc3147cb 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -262,11 +262,13 @@ describe('WizardStore', () => { store.setLoginUrl('url'); store.setReadinessResult(null); store.setMcpComplete(); + store.setOutroDismissed(); + store.setSkillsComplete(true); store.setOutroData({ kind: OutroKind.Success }); store.setFrameworkContext('k', 'v'); store.setFrameworkConfig(null, null); - expect(cb).toHaveBeenCalledTimes(11); + expect(cb).toHaveBeenCalledTimes(13); }); }); @@ -395,6 +397,26 @@ describe('WizardStore', () => { expect(store.currentScreen).toBe(Screen.Outro); }); + it('advances to skills after outro dismissed', () => { + const store = createStore(); + store.completeSetup(); + store.setReadinessResult({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }); + store.setCredentials({ + accessToken: 'tok', + projectApiKey: 'pk', + host: 'h', + projectId: 1, + }); + store.setRunPhase(RunPhase.Completed); + store.setMcpComplete(); + store.setOutroDismissed(); + expect(store.currentScreen).toBe(Screen.Skills); + }); + it('starts at McpAdd for McpAdd flow', () => { const store = createStore(Flow.McpAdd); expect(store.currentScreen).toBe(Screen.McpAdd); @@ -919,8 +941,12 @@ describe('WizardStore', () => { store.setMcpComplete(); expect(store.currentScreen).toBe(Screen.Outro); + // Step 6: Dismiss outro + store.setOutroDismissed(); + expect(store.currentScreen).toBe(Screen.Skills); + // Verify version was bumped for each setter call - expect(store.getVersion()).toBe(6); + expect(store.getVersion()).toBe(7); }); }); diff --git a/src/ui/tui/flows.ts b/src/ui/tui/flows.ts index d4ddcf93..14a848c6 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -22,6 +22,7 @@ export enum Screen { Auth = 'auth', Run = 'run', Mcp = 'mcp', + Skills = 'skills', Outro = 'outro', McpAdd = 'mcp-add', McpRemove = 'mcp-remove', @@ -91,7 +92,11 @@ export const FLOWS: Record = { screen: Screen.Mcp, isComplete: (s) => s.mcpComplete, }, - { screen: Screen.Outro }, + { + screen: Screen.Outro, + isComplete: (s) => s.outroDismissed, + }, + { screen: Screen.Skills }, ], [Flow.McpAdd]: [ diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 3959a3d1..d1b25467 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -22,6 +22,7 @@ import { SetupScreen } from './screens/SetupScreen.js'; import { AuthScreen } from './screens/AuthScreen.js'; import { RunScreen } from './screens/RunScreen.js'; import { McpScreen } from './screens/McpScreen.js'; +import { SkillsScreen } from './screens/SkillsScreen.js'; import { OutroScreen } from './screens/OutroScreen.js'; import { AuthErrorScreen } from './screens/AuthErrorScreen.js'; import { createMcpInstaller } from './services/mcp-installer.js'; @@ -55,6 +56,7 @@ export function createScreens( [Screen.Auth]: , [Screen.Run]: , [Screen.Mcp]: , + [Screen.Skills]: , [Screen.Outro]: , // Standalone MCP flows diff --git a/src/ui/tui/screens/OutroScreen.tsx b/src/ui/tui/screens/OutroScreen.tsx index c9365597..82c51c48 100644 --- a/src/ui/tui/screens/OutroScreen.tsx +++ b/src/ui/tui/screens/OutroScreen.tsx @@ -21,7 +21,7 @@ export const OutroScreen = ({ store }: OutroScreenProps) => { ); useInput(() => { - process.exit(0); + store.setOutroDismissed(); }); const outroData = store.session.outroData; @@ -124,7 +124,7 @@ export const OutroScreen = ({ store }: OutroScreenProps) => { )} - Press any key to exit + Press any key to continue ); diff --git a/src/ui/tui/screens/SkillsScreen.tsx b/src/ui/tui/screens/SkillsScreen.tsx new file mode 100644 index 00000000..254de20c --- /dev/null +++ b/src/ui/tui/screens/SkillsScreen.tsx @@ -0,0 +1,142 @@ +/** + * SkillsScreen — Ask whether to keep installed skills in .claude/skills/. + * + * Shown after the outro summary so users see the agent's output first, + * then decide whether to keep the skills that powered it. + * + * When done, calls store.setSkillsComplete() and exits the process. + */ + +import { Box, Text } from 'ink'; +import { useState, useEffect } from 'react'; +import { useSyncExternalStore } from 'react'; +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { WizardStore } from '../store.js'; +import { ConfirmationInput } from '../primitives/index.js'; +import { Colors } from '../styles.js'; +import { CONTEXT_MILL_URL } from '../../../lib/constants.js'; + +interface SkillsScreenProps { + store: WizardStore; +} + +interface SkillEntry { + name: string; + children: string[]; +} + +enum Phase { + Loading = 'loading', + Ask = 'ask', + Removing = 'removing', + Done = 'done', +} + +export const SkillsScreen = ({ store }: SkillsScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const [phase, setPhase] = useState(Phase.Loading); + const [skills, setSkills] = useState([]); + + const skillsDir = join(store.session.installDir, '.claude', 'skills'); + + useEffect(() => { + void (async () => { + try { + const entries = await readdir(skillsDir, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()); + const result: SkillEntry[] = []; + for (const dir of dirs) { + const children = await readdir(join(skillsDir, dir.name)); + result.push({ name: dir.name, children }); + } + if (result.length === 0) { + store.setSkillsComplete(true); + process.exit(0); + } + setSkills(result); + setPhase(Phase.Ask); + } catch { + store.setSkillsComplete(true); + process.exit(0); + } + })(); + }, []); // eslint-disable-line + + const handleKeep = () => { + store.setSkillsComplete(true); + process.exit(0); + }; + + const handleRemove = async () => { + setPhase(Phase.Removing); + try { + await rm(skillsDir, { recursive: true, force: true }); + } catch { + // Best-effort removal + } + setPhase(Phase.Done); + store.setSkillsComplete(false); + process.exit(0); + }; + + return ( + + + Keep the skills? + + + + {phase === Phase.Loading && ( + Checking installed skills... + )} + + {phase === Phase.Ask && ( + <> + + The wizard installed open-source skills that help AI coding agents + integrate PostHog into your project: + + + .claude/ + skills/ + {skills.map((skill) => ( + + {skill.name}/ + {skill.children.map((child) => ( + + {' '} + {child} + + ))} + + ))} + + + + Source: {CONTEXT_MILL_URL} + + + + void handleRemove()} + /> + + + )} + + {phase === Phase.Removing && Removing skills...} + + {phase === Phase.Done && Skills removed.} + + + ); +}; diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index b5c3f1d2..26c9fd75 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -353,6 +353,20 @@ export class WizardStore { this.emitChange(); } + setSkillsComplete(kept: boolean): void { + this.$session.setKey('skillsComplete', true); + analytics.wizardCapture('skills complete', { + skills_kept: kept, + ...sessionProperties(this.session), + }); + this.emitChange(); + } + + setOutroDismissed(): void { + this.$session.setKey('outroDismissed', true); + this.emitChange(); + } + setOutroData(data: OutroData): void { this.$session.setKey('outroData', data); this.emitChange();