Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────────────────────────────────

Expand Down
4 changes: 4 additions & 0 deletions src/lib/wizard-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export interface WizardSession {
mcpComplete: boolean;
mcpOutcome: McpOutcome | null;
mcpInstalledClients: string[];
skillsComplete: boolean;
outroDismissed: boolean;

// Runtime
readinessResult: WizardReadinessResult | null;
Expand Down Expand Up @@ -195,6 +197,8 @@ export function buildSession(args: {
mcpComplete: false,
mcpOutcome: null,
mcpInstalledClients: [],
skillsComplete: false,
outroDismissed: false,
loginUrl: null,
credentials: null,
readinessResult: null,
Expand Down
30 changes: 28 additions & 2 deletions src/ui/tui/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});

Expand Down
7 changes: 6 additions & 1 deletion src/ui/tui/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export enum Screen {
Auth = 'auth',
Run = 'run',
Mcp = 'mcp',
Skills = 'skills',
Outro = 'outro',
McpAdd = 'mcp-add',
McpRemove = 'mcp-remove',
Expand Down Expand Up @@ -91,7 +92,11 @@ export const FLOWS: Record<Flow, FlowEntry[]> = {
screen: Screen.Mcp,
isComplete: (s) => s.mcpComplete,
},
{ screen: Screen.Outro },
{
screen: Screen.Outro,
isComplete: (s) => s.outroDismissed,
},
{ screen: Screen.Skills },
],

[Flow.McpAdd]: [
Expand Down
2 changes: 2 additions & 0 deletions src/ui/tui/screen-registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,6 +56,7 @@ export function createScreens(
[Screen.Auth]: <AuthScreen store={store} />,
[Screen.Run]: <RunScreen store={store} />,
[Screen.Mcp]: <McpScreen store={store} installer={services.mcpInstaller} />,
[Screen.Skills]: <SkillsScreen store={store} />,
[Screen.Outro]: <OutroScreen store={store} />,

// Standalone MCP flows
Expand Down
4 changes: 2 additions & 2 deletions src/ui/tui/screens/OutroScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const OutroScreen = ({ store }: OutroScreenProps) => {
);

useInput(() => {
process.exit(0);
store.setOutroDismissed();
});

const outroData = store.session.outroData;
Expand Down Expand Up @@ -124,7 +124,7 @@ export const OutroScreen = ({ store }: OutroScreenProps) => {
)}

<Box marginTop={1}>
<Text color={Colors.muted}>Press any key to exit</Text>
<Text color={Colors.muted}>Press any key to continue</Text>
</Box>
</Box>
);
Expand Down
142 changes: 142 additions & 0 deletions src/ui/tui/screens/SkillsScreen.tsx
Original file line number Diff line number Diff line change
@@ -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>(Phase.Loading);
const [skills, setSkills] = useState<SkillEntry[]>([]);

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 (
<Box flexDirection="column" flexGrow={1}>
<Text bold color={Colors.accent}>
Keep the skills?
</Text>

<Box marginTop={1} flexDirection="column">
{phase === Phase.Loading && (
<Text dimColor>Checking installed skills...</Text>
)}

{phase === Phase.Ask && (
<>
<Text dimColor>
The wizard installed open-source skills that help AI coding agents
integrate PostHog into your project:
</Text>
<Box marginTop={1} flexDirection="column" marginLeft={2}>
<Text dimColor>.claude/</Text>
<Text dimColor> skills/</Text>
{skills.map((skill) => (
<Box key={skill.name} flexDirection="column">
<Text dimColor> {skill.name}/</Text>
{skill.children.map((child) => (
<Text key={child} dimColor>
{' '}
{child}
</Text>
))}
</Box>
))}
</Box>
<Box marginTop={1}>
<Text dimColor>
Source: <Text color="cyan">{CONTEXT_MILL_URL}</Text>
</Text>
</Box>
<Box marginTop={1}>
<ConfirmationInput
message="Keep the installed skills?"
confirmLabel="Keep [Enter]"
cancelLabel="Remove [Esc]"
onConfirm={handleKeep}
onCancel={() => void handleRemove()}
/>
</Box>
</>
)}

{phase === Phase.Removing && <Text dimColor>Removing skills...</Text>}

{phase === Phase.Done && <Text dimColor>Skills removed.</Text>}
</Box>
</Box>
);
};
14 changes: 14 additions & 0 deletions src/ui/tui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading