From c853cbc6f7a2ddea8643346fb66af7fa70ccc34e Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 2 Apr 2026 14:51:29 -0400 Subject: [PATCH 1/4] Ask to remove skills if successful --- src/lib/wizard-session.ts | 2 + src/ui/tui/flows.ts | 5 ++ src/ui/tui/screen-registry.tsx | 2 + src/ui/tui/screens/SkillsScreen.tsx | 84 +++++++++++++++++++++++++++++ src/ui/tui/store.ts | 9 ++++ 5 files changed, 102 insertions(+) create mode 100644 src/ui/tui/screens/SkillsScreen.tsx diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index df07fcde..b522a3a8 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -133,6 +133,7 @@ export interface WizardSession { mcpComplete: boolean; mcpOutcome: McpOutcome | null; mcpInstalledClients: string[]; + skillsComplete: boolean; // Runtime readinessResult: WizardReadinessResult | null; @@ -195,6 +196,7 @@ export function buildSession(args: { mcpComplete: false, mcpOutcome: null, mcpInstalledClients: [], + skillsComplete: false, loginUrl: null, credentials: null, readinessResult: null, diff --git a/src/ui/tui/flows.ts b/src/ui/tui/flows.ts index d4ddcf93..6b706f50 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,6 +92,10 @@ export const FLOWS: Record = { screen: Screen.Mcp, isComplete: (s) => s.mcpComplete, }, + { + screen: Screen.Skills, + isComplete: (s) => s.skillsComplete, + }, { screen: Screen.Outro }, ], 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/SkillsScreen.tsx b/src/ui/tui/screens/SkillsScreen.tsx new file mode 100644 index 00000000..688d8726 --- /dev/null +++ b/src/ui/tui/screens/SkillsScreen.tsx @@ -0,0 +1,84 @@ +/** + * SkillsScreen — Ask whether to keep installed skills in .claude/skills/. + * + * Shown after MCP setup in the wizard flow. Default is "Keep". + * If the user declines, the skills directory is removed. + * + * When done, calls store.setSkillsComplete(). The router resolves to outro. + */ + +import { Box, Text } from 'ink'; +import { useState } from 'react'; +import { useSyncExternalStore } from 'react'; +import { 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'; + +interface SkillsScreenProps { + store: WizardStore; +} + +enum Phase { + Ask = 'ask', + Removing = 'removing', + Done = 'done', +} + +export const SkillsScreen = ({ store }: SkillsScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const [phase, setPhase] = useState(Phase.Ask); + + const handleKeep = () => { + store.setSkillsComplete(true); + }; + + const handleRemove = async () => { + setPhase(Phase.Removing); + try { + const skillsDir = join(store.session.installDir, '.claude', 'skills'); + await rm(skillsDir, { recursive: true, force: true }); + } catch { + // Best-effort removal + } + setPhase(Phase.Done); + store.setSkillsComplete(false); + }; + + return ( + + + Installed Skills + + + + {phase === Phase.Ask && ( + <> + + The wizard installed skills to .claude/skills/ that help AI coding + agents work with PostHog in your project. + + + 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..64812d1b 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -353,6 +353,15 @@ 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(); + } + setOutroData(data: OutroData): void { this.$session.setKey('outroData', data); this.emitChange(); From 7ba6d386d01e207ad00786b3a22513cf124ea4ba Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 2 Apr 2026 15:12:48 -0400 Subject: [PATCH 2/4] tweak copy --- src/ui/tui/screens/SkillsScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/tui/screens/SkillsScreen.tsx b/src/ui/tui/screens/SkillsScreen.tsx index 688d8726..0250e490 100644 --- a/src/ui/tui/screens/SkillsScreen.tsx +++ b/src/ui/tui/screens/SkillsScreen.tsx @@ -61,7 +61,7 @@ export const SkillsScreen = ({ store }: SkillsScreenProps) => { <> The wizard installed skills to .claude/skills/ that help AI coding - agents work with PostHog in your project. + agents integrate PostHog into your project. Date: Thu, 2 Apr 2026 15:32:18 -0400 Subject: [PATCH 3/4] Fix tests --- src/ui/tui/__tests__/store.test.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index d40e8344..2d1d610b 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -376,7 +376,7 @@ describe('WizardStore', () => { expect(store.currentScreen).toBe(Screen.Mcp); }); - it('advances to outro after mcp completes', () => { + it('advances to skills after mcp completes', () => { const store = createStore(); store.completeSetup(); store.setReadinessResult({ @@ -392,6 +392,26 @@ describe('WizardStore', () => { }); store.setRunPhase(RunPhase.Completed); store.setMcpComplete(); + expect(store.currentScreen).toBe(Screen.Skills); + }); + + it('advances to outro after skills completes', () => { + 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.setSkillsComplete(true); expect(store.currentScreen).toBe(Screen.Outro); }); @@ -917,10 +937,14 @@ describe('WizardStore', () => { // Step 5: Complete MCP store.setMcpComplete(); + expect(store.currentScreen).toBe(Screen.Skills); + + // Step 6: Complete Skills + store.setSkillsComplete(true); expect(store.currentScreen).toBe(Screen.Outro); // Verify version was bumped for each setter call - expect(store.getVersion()).toBe(6); + expect(store.getVersion()).toBe(7); }); }); From 12b096ae003188d3459fa9bc7ce0c4cd4bc87da2 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 3 Apr 2026 09:17:07 -0400 Subject: [PATCH 4/4] Comments --- src/lib/constants.ts | 1 + src/lib/wizard-session.ts | 2 + src/ui/tui/__tests__/store.test.ts | 22 ++++---- src/ui/tui/flows.ts | 6 +-- src/ui/tui/screens/OutroScreen.tsx | 4 +- src/ui/tui/screens/SkillsScreen.tsx | 78 +++++++++++++++++++++++++---- src/ui/tui/store.ts | 5 ++ 7 files changed, 93 insertions(+), 25 deletions(-) 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 b522a3a8..7bce7c7a 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -134,6 +134,7 @@ export interface WizardSession { mcpOutcome: McpOutcome | null; mcpInstalledClients: string[]; skillsComplete: boolean; + outroDismissed: boolean; // Runtime readinessResult: WizardReadinessResult | null; @@ -197,6 +198,7 @@ export function buildSession(args: { 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 2d1d610b..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); }); }); @@ -376,7 +378,7 @@ describe('WizardStore', () => { expect(store.currentScreen).toBe(Screen.Mcp); }); - it('advances to skills after mcp completes', () => { + it('advances to outro after mcp completes', () => { const store = createStore(); store.completeSetup(); store.setReadinessResult({ @@ -392,10 +394,10 @@ describe('WizardStore', () => { }); store.setRunPhase(RunPhase.Completed); store.setMcpComplete(); - expect(store.currentScreen).toBe(Screen.Skills); + expect(store.currentScreen).toBe(Screen.Outro); }); - it('advances to outro after skills completes', () => { + it('advances to skills after outro dismissed', () => { const store = createStore(); store.completeSetup(); store.setReadinessResult({ @@ -411,8 +413,8 @@ describe('WizardStore', () => { }); store.setRunPhase(RunPhase.Completed); store.setMcpComplete(); - store.setSkillsComplete(true); - expect(store.currentScreen).toBe(Screen.Outro); + store.setOutroDismissed(); + expect(store.currentScreen).toBe(Screen.Skills); }); it('starts at McpAdd for McpAdd flow', () => { @@ -937,12 +939,12 @@ describe('WizardStore', () => { // Step 5: Complete MCP store.setMcpComplete(); - expect(store.currentScreen).toBe(Screen.Skills); - - // Step 6: Complete Skills - store.setSkillsComplete(true); 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(7); }); diff --git a/src/ui/tui/flows.ts b/src/ui/tui/flows.ts index 6b706f50..14a848c6 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -93,10 +93,10 @@ export const FLOWS: Record = { isComplete: (s) => s.mcpComplete, }, { - screen: Screen.Skills, - isComplete: (s) => s.skillsComplete, + screen: Screen.Outro, + isComplete: (s) => s.outroDismissed, }, - { screen: Screen.Outro }, + { screen: Screen.Skills }, ], [Flow.McpAdd]: [ 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 index 0250e490..254de20c 100644 --- a/src/ui/tui/screens/SkillsScreen.tsx +++ b/src/ui/tui/screens/SkillsScreen.tsx @@ -1,26 +1,33 @@ /** * SkillsScreen — Ask whether to keep installed skills in .claude/skills/. * - * Shown after MCP setup in the wizard flow. Default is "Keep". - * If the user declines, the skills directory is removed. + * 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(). The router resolves to outro. + * When done, calls store.setSkillsComplete() and exits the process. */ import { Box, Text } from 'ink'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useSyncExternalStore } from 'react'; -import { rm } from 'node:fs/promises'; +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', @@ -32,37 +39,88 @@ export const SkillsScreen = ({ store }: SkillsScreenProps) => { () => store.getSnapshot(), ); - const [phase, setPhase] = useState(Phase.Ask); + 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 { - const skillsDir = join(store.session.installDir, '.claude', 'skills'); await rm(skillsDir, { recursive: true, force: true }); } catch { // Best-effort removal } setPhase(Phase.Done); store.setSkillsComplete(false); + process.exit(0); }; return ( - Installed Skills + Keep the skills? + {phase === Phase.Loading && ( + Checking installed skills... + )} + {phase === Phase.Ask && ( <> - The wizard installed skills to .claude/skills/ that help AI coding - agents integrate PostHog into your project. + 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} + +