diff --git a/e2e-harness/__tests__/wizard-ci-driver.test.ts b/e2e-harness/__tests__/wizard-ci-driver.test.ts index f5368d6a..39937cee 100644 --- a/e2e-harness/__tests__/wizard-ci-driver.test.ts +++ b/e2e-harness/__tests__/wizard-ci-driver.test.ts @@ -22,6 +22,8 @@ import { Program } from '@lib/programs/program-registry'; import { WizardCiDriver, UnknownActionError } from '../wizard-ci-driver'; import { ACTION_REGISTRY, NO_ACTION_SCREENS } from '../action-registry'; import { SOURCE_MAPS_CONTEXT_KEYS } from '@lib/programs/error-tracking-upload-source-maps/index'; +import { ErrorCodes } from '@lib/errors/codes'; +import { OutroKind } from '@lib/wizard-session'; function freshStore(): WizardStore { const store = new WizardStore(Program.PostHogIntegration); @@ -45,6 +47,26 @@ const cleanReadiness = { }; describe('WizardCiDriver — full integration flow', () => { + it('keeps a late mint failure open until it is dismissed again', async () => { + const store = freshStore(); + const ui = new InkUI(store); + const driver = new WizardCiDriver(store); + store.setOutroDismissed(); + ui.outroError({ + kind: OutroKind.Error, + errorCode: ErrorCodes.GatewayMintFailed, + }); + expect(driver.readState().currentScreen).toBe(ScreenId.MintFailure); + expect(store.session.outroDismissed).toBe(false); + const dismissed = vi.fn(); + const waiting = ui.waitForOutroDismissed().then(dismissed); + await Promise.resolve(); + expect(dismissed).not.toHaveBeenCalled(); + driver.performAction('dismiss_outro'); + await waiting; + expect(dismissed).toHaveBeenCalledOnce(); + }); + it('walks intro → setup → run → outro → mcp → slack → keep-skills', () => { const store = freshStore(); const driver = new WizardCiDriver(store); diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index 71918cdc..784ee7ad 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -207,6 +207,13 @@ export const ACTION_REGISTRY: Partial> = { apply: (store) => store.setOutroDismissed(), }, ], + [ScreenId.MintFailure]: [ + { + id: 'dismiss_outro', + description: 'Dismiss the mint failure so the wizard can exit.', + apply: (store) => store.setOutroDismissed(), + }, + ], // ── MCP install ─────────────────────────────────────────────────────── [ScreenId.Mcp]: [ diff --git a/src/lib/__tests__/wizard-spellbook.test.ts b/src/lib/__tests__/wizard-spellbook.test.ts new file mode 100644 index 00000000..e3b98ab2 --- /dev/null +++ b/src/lib/__tests__/wizard-spellbook.test.ts @@ -0,0 +1,252 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { Integration } from '../constants'; +import type { ProgramConfig } from '../programs/program-step'; +import { getProgramConfig, Program } from '../programs/program-registry'; +import { buildAgentRunContext, buildSession } from '../wizard-session'; +import { writeWizardSpellbook } from '../wizard-spellbook'; +import { downloadSkill, fetchSkillMenu } from '../wizard-tools/tools'; +import { InkUI } from '@ui/tui/ink-ui'; +import { createServices } from '@ui/tui/screen-registry'; +import { WizardStore } from '@ui/tui/store'; + +vi.mock('../wizard-tools/tools', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSkillMenu: vi.fn(), + downloadSkill: vi.fn(), +})); + +const program: ProgramConfig = { + id: 'example-setup', + description: 'Set up the example integration.', + agentFlow: 'example-flow', + steps: [], +}; + +const skill = { + id: 'example-skill', + name: 'Example skill', + downloadUrl: 'https://example.com/skill.zip', +}; + +describe('writeWizardSpellbook', () => { + let installDir: string; + + beforeEach(async () => { + installDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'wizard-spellbook-test-'), + ); + vi.clearAllMocks(); + vi.mocked(fetchSkillMenu).mockResolvedValue(null); + vi.mocked(downloadSkill).mockResolvedValue({ success: true }); + }); + + afterEach(async () => { + await fs.rm(installDir, { recursive: true, force: true }); + }); + + it('leaves useful instructions without credentials or a successful network request', async () => { + const session = buildSession({ installDir }); + session.apiKey = 'personal-api-secret'; + session.email = 'private-user@example.com'; + session.frameworkContext = { secret: 'framework-secret' }; + session.credentials = { + accessToken: 'access-token-secret', + refreshToken: 'refresh-token-secret', + } as typeof session.credentials; + const dynamicRun = vi.fn(); + + const result = await writeWizardSpellbook(session, { + ...program, + run: dynamicRun, + }); + const readme = await fs.readFile(result.path, 'utf8'); + + expect(result.skillsIncluded).toBe(false); + expect(readme).toContain(program.description); + expect(readme).toContain('https://posthog.com/docs'); + expect(readme).toContain('No matching skills could be downloaded'); + expect(readme).not.toMatch(/secret|private-user/); + expect(downloadSkill).not.toHaveBeenCalled(); + expect(dynamicRun).not.toHaveBeenCalled(); + }); + + it('downloads the selected skill without gateway triage and links the local instructions', async () => { + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { skills: [skill] }, + }); + const session = buildSession({ installDir }); + session.skillId = skill.id; + + const result = await writeWizardSpellbook(session, program); + + expect(downloadSkill).toHaveBeenCalledExactlyOnceWith( + skill, + path.dirname(result.path), + { + skillsRoot: 'skills', + triage: undefined, + }, + ); + expect(result.skillsIncluded).toBe(true); + expect(await fs.readFile(result.path, 'utf8')).toContain( + '[example-skill](skills/example-skill/SKILL.md)', + ); + }); + + it('leaves a composed integration handoff in its child project using its own program', async () => { + const store = new WizardStore(Program.SelfDriving); + store.session = buildSession({ installDir }); + const child = buildSession({ installDir: path.join(installDir, 'app') }); + child.skillId = skill.id; + child.apiKey = 'private-api-key'; + child.frameworkContext = { secret: 'private-framework-value' }; + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { skills: [skill] }, + }); + const context = buildAgentRunContext(child, Program.PostHogIntegration); + new InkUI(store).setAgentRunContext(context); + + const result = await createServices(store).leaveSpellbook(); + const readme = await fs.readFile(result.path, 'utf8'); + + expect( + result.path.startsWith( + path.join(child.installDir, '.posthog') + path.sep, + ), + ).toBe(true); + expect(readme).toContain( + getProgramConfig(Program.PostHogIntegration).description, + ); + expect(readme).toContain(`Wizard program: ${Program.PostHogIntegration}`); + expect(readme).not.toContain(`Wizard program: ${Program.SelfDriving}`); + expect(JSON.stringify(context)).not.toContain('private-'); + expect(downloadSkill).toHaveBeenCalledExactlyOnceWith( + skill, + path.dirname(result.path), + { + skillsRoot: 'skills', + triage: undefined, + }, + ); + await expect(fs.stat(path.join(installDir, '.posthog'))).rejects.toThrow(); + }); + + it('uses program metadata and the detected framework to select default variants', async () => { + const session = buildSession({ installDir }); + session.integration = Integration.nextjs; + const entry = ( + id: string, + group: string, + framework?: string, + isDefault?: boolean, + ) => ({ + ...skill, + id, + group, + framework, + default: isDefault, + }); + const integration = entry( + 'integration-nextjs', + 'integration', + 'nextjs', + true, + ); + const setup = entry( + 'example-flow-setup-nextjs', + 'example-flow-setup', + 'nextjs', + true, + ); + const shared = entry('example-flow-finish', 'example-flow-finish'); + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { + integration: [ + entry('integration-nextjs-alt', 'integration', 'nextjs'), + integration, + ], + steps: [ + setup, + shared, + entry('example-flow-setup-django', 'example-flow-setup', 'django'), + entry('unrelated-skill', 'other-flow', 'nextjs'), + entry('../escaped', 'example-flow-unsafe'), + ], + }, + }); + + await writeWizardSpellbook(session, program); + + expect( + vi.mocked(downloadSkill).mock.calls.map(([selected]) => selected.id), + ).toEqual([setup.id, shared.id]); + }); + + it('resolves a bare framework skill to its integration reference without using it for other programs', async () => { + const session = buildSession({ installDir }); + session.integration = Integration.nextjs; + session.skillId = Integration.nextjs; + const reference = { + ...skill, + id: 'integration-nextjs-app-router', + group: 'integration', + framework: 'nextjs', + default: true, + }; + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { integration: [reference] }, + }); + + await writeWizardSpellbook(session, program); + expect(downloadSkill).toHaveBeenCalledTimes(1); + expect(vi.mocked(downloadSkill).mock.calls[0][0]).toEqual(reference); + + vi.mocked(downloadSkill).mockClear(); + await writeWizardSpellbook(session, { + ...program, + skillId: 'missing-audit-skill', + }); + expect(downloadSkill).not.toHaveBeenCalled(); + }); + + it('removes incomplete or rejected skills and reports a docs-only handoff', async () => { + const session = buildSession({ installDir }); + session.skillId = skill.id; + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { skills: [skill] }, + }); + vi.mocked(downloadSkill).mockImplementation(async (_skill, directory) => { + const destination = path.join(directory, 'skills', skill.id); + await fs.mkdir(destination, { recursive: true }); + await fs.writeFile( + path.join(destination, 'SKILL.md'), + 'Partial unverified download', + ); + return { success: false }; + }); + + const result = await writeWizardSpellbook(session, program); + + expect(result.skillsIncluded).toBe(false); + expect(await fs.readFile(result.path, 'utf8')).not.toContain('(skills/'); + await expect( + fs.stat(path.join(path.dirname(result.path), 'skills', skill.id)), + ).rejects.toThrow(); + }); + + it('preserves earlier handoffs and existing project instructions', async () => { + const existing = path.join(installDir, 'AGENTS.md'); + await fs.writeFile(existing, 'Project instructions'); + const session = buildSession({ installDir }); + const first = await writeWizardSpellbook(session, program); + await fs.writeFile(first.path, 'Earlier handoff'); + + const second = await writeWizardSpellbook(session, program); + + expect(second.path).not.toBe(first.path); + expect(await fs.readFile(first.path, 'utf8')).toBe('Earlier handoff'); + expect(await fs.readFile(existing, 'utf8')).toBe('Project instructions'); + }); +}); diff --git a/src/lib/agent/__tests__/gateway-auth.test.ts b/src/lib/agent/__tests__/gateway-auth.test.ts new file mode 100644 index 00000000..960cabb5 --- /dev/null +++ b/src/lib/agent/__tests__/gateway-auth.test.ts @@ -0,0 +1,115 @@ +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; +import { + gatewayAuth, + GatewayMintFailed, + GatewayMintRefused, + type GatewayAuth, +} from '@lib/gateway-session'; +import type { HostResolution } from '@lib/host-resolution'; +import { wizardAbort } from '@utils/wizard-abort'; + +vi.mock('@lib/gateway-session', async (importOriginal) => ({ + ...(await importOriginal()), + gatewayAuth: vi.fn(), +})); + +vi.mock('@utils/wizard-abort', async (importOriginal) => ({ + ...(await importOriginal()), + wizardAbort: vi.fn(), +})); + +const host = { apiHost: 'https://us.posthog.com' } as HostResolution; +const auth: GatewayAuth = { + gatewayUrl: 'https://gateway.us.posthog.com', + token: 'phe_test', + refreshAtMs: Date.now() + 3600_000, +}; + +describe('requireGatewayAuth', () => { + beforeEach(() => { + vi.mocked(gatewayAuth).mockReset(); + vi.mocked(wizardAbort).mockReset(); + }); + + it('returns a usable credential unchanged', async () => { + vi.mocked(gatewayAuth).mockResolvedValue(auth); + + await expect( + requireGatewayAuth(host, 'pha_test', 'integration'), + ).resolves.toBe(auth); + expect(gatewayAuth).toHaveBeenCalledWith(host, 'pha_test', 'integration'); + expect(wizardAbort).not.toHaveBeenCalled(); + }); + + it.each([ + new GatewayMintRefused(403, 'Access denied', 'blocked'), + new GatewayMintFailed('Mint unavailable'), + ])('preserves the coded failure in the fatal outro: $name', async (error) => { + const exit = new Error('Wizard exited'); + vi.mocked(gatewayAuth).mockRejectedValue(error); + vi.mocked(wizardAbort).mockRejectedValue(exit); + + await expect( + requireGatewayAuth(host, 'pha_test', 'integration'), + ).rejects.toBe(exit); + expect(wizardAbort).toHaveBeenCalledExactlyOnceWith({ + message: error.message, + error, + }); + }); + + it('parks concurrent callers behind one fatal outro until it exits', async () => { + const error = new GatewayMintRefused(429, 'Daily limit reached'); + const exit = new Error('Wizard exited'); + let exitAbort: (error: Error) => void; + const abort = new Promise((_, reject) => { + exitAbort = reject; + }); + vi.mocked(gatewayAuth).mockRejectedValue(error); + vi.mocked(wizardAbort).mockReturnValue(abort); + + const calls = [ + requireGatewayAuth(host, 'pha_test', 'integration'), + requireGatewayAuth(host, 'pha_test', 'integration'), + ]; + const settled = vi.fn(); + const results = Promise.allSettled(calls).then(settled); + await vi.waitFor(() => expect(wizardAbort).toHaveBeenCalledTimes(1)); + expect(settled).not.toHaveBeenCalled(); + + exitAbort!(exit); + await results; + expect(settled).toHaveBeenCalledExactlyOnceWith([ + { status: 'rejected', reason: exit }, + { status: 'rejected', reason: exit }, + ]); + }); + + it('also aborts a failed renewal after an earlier mint succeeded', async () => { + const error = new GatewayMintFailed('Renewal unavailable'); + const exit = new Error('Wizard exited'); + vi.mocked(gatewayAuth) + .mockResolvedValueOnce(auth) + .mockRejectedValueOnce(error); + vi.mocked(wizardAbort).mockRejectedValue(exit); + + await requireGatewayAuth(host, 'pha_test', 'integration'); + await expect( + requireGatewayAuth(host, 'pha_test', 'integration'), + ).rejects.toBe(exit); + expect(wizardAbort).toHaveBeenCalledExactlyOnceWith({ + message: error.message, + error, + }); + }); + + it('leaves unrelated errors to the existing caller handling', async () => { + const error = new Error('Unexpected failure'); + vi.mocked(gatewayAuth).mockRejectedValue(error); + + await expect( + requireGatewayAuth(host, 'pha_test', 'integration'), + ).rejects.toBe(error); + expect(wizardAbort).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 815498a2..4e4578fa 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -32,10 +32,10 @@ import { createCustomHeaders } from '@utils/custom-headers'; import type { HostResolution } from '@lib/host-resolution'; import { buildWizardPropertiesBlob, - gatewayAuth, isPastRefresh, type GatewayAuth, } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { evaluateBashCommand } from './bash-fence'; import { createWizardToolsServer, WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { @@ -539,7 +539,7 @@ export async function initializeAgent( // Disable experimental betas (like input_examples) the gateway doesn't support. process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 'true'; const currentGatewayAuth = () => - gatewayAuth(config.host, config.posthogApiKey, config.programId); + requireGatewayAuth(config.host, config.posthogApiKey, config.programId); const auth = await currentGatewayAuth(); const gatewayUrl = auth.gatewayUrl; process.env.ANTHROPIC_BASE_URL = gatewayUrl; diff --git a/src/lib/agent/gateway-auth.ts b/src/lib/agent/gateway-auth.ts new file mode 100644 index 00000000..254cab7a --- /dev/null +++ b/src/lib/agent/gateway-auth.ts @@ -0,0 +1,31 @@ +import { + gatewayAuth, + GatewayMintFailed, + GatewayMintRefused, + type GatewayAuth, +} from '@lib/gateway-session'; +import { wizardAbort } from '@utils/wizard-abort'; + +const pendingAborts = new WeakMap>(); + +/** End the wizard before a harness or security hook can swallow a mint failure. */ +export async function requireGatewayAuth( + ...args: Parameters +): Promise { + try { + return await gatewayAuth(...args); + } catch (error) { + if ( + !(error instanceof GatewayMintRefused) && + !(error instanceof GatewayMintFailed) + ) { + throw error; + } + let abort = pendingAborts.get(error); + if (!abort) { + abort = wizardAbort({ message: error.message, error }); + pendingAborts.set(error, abort); + } + return abort; + } +} diff --git a/src/lib/agent/mcp-prompt-streaming.ts b/src/lib/agent/mcp-prompt-streaming.ts index 1b9e5518..6fabf0c6 100644 --- a/src/lib/agent/mcp-prompt-streaming.ts +++ b/src/lib/agent/mcp-prompt-streaming.ts @@ -16,7 +16,7 @@ import type { AgentChunk } from '@ui/tui/services/mcp-suggested-prompts-services import type { Credentials } from '@lib/wizard-session'; import { DEFAULT_AGENT_MODEL, WIZARD_USER_AGENT } from '@lib/constants'; import { logToFile } from '@utils/debug'; -import { gatewayAuth } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { buildAgentEnv, buildRunTags } from '@lib/agent/agent-interface'; import { sanitizeAgentSubprocessEnv } from '@lib/agent/agent-env-isolation'; import { createIsolatedAgentConfigDir } from '@lib/agent/stored-login'; @@ -226,7 +226,7 @@ export async function* runMcpPromptViaSdk(args: { // The url and the bearer are one unit: a run must take both from the same // mint. - const auth = await gatewayAuth( + const auth = await requireGatewayAuth( credentials.host, credentials.accessToken, args.programId, diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index a6028ff0..acd27c1c 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -27,7 +27,8 @@ import { AgentErrorType } from '@lib/agent/agent-interface'; import { AgentSignals, REMARK_INSTRUCTION } from '@lib/agent/signals'; import { AgentOutputSignals } from '@lib/agent/output-signals'; import { assembleCommandments } from '../../switchboard/commandments'; -import { gatewayAuth, type GatewayAuth } from '@lib/gateway-session'; +import type { GatewayAuth } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { buildGatewayProvider, GATEWAY_PROVIDER, @@ -253,7 +254,7 @@ export const piBackend: AgentHarness = { // orchestrator's per-task sessions (gateway.ts). gatewayAuth mints the // run's scoped token. const refreshAuth = () => - gatewayAuth( + requireGatewayAuth( boot.credentials.host, boot.credentials.accessToken, boot.programId, diff --git a/src/lib/agent/runner/harness/pi/task.ts b/src/lib/agent/runner/harness/pi/task.ts index a6e575de..8818b2bc 100644 --- a/src/lib/agent/runner/harness/pi/task.ts +++ b/src/lib/agent/runner/harness/pi/task.ts @@ -36,7 +36,8 @@ import { AgentOutputSignals } from '@lib/agent/output-signals'; import { TaskStatus } from '../../sequence/orchestrator/queue'; import type { OrchestratorToolsContext } from '../../sequence/orchestrator/queue-tools'; import type { AgentResult, TaskRunInputs } from '../types'; -import { gatewayAuth, type GatewayAuth } from '@lib/gateway-session'; +import type { GatewayAuth } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { buildGatewayProvider, GATEWAY_PROVIDER, @@ -220,7 +221,7 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { } = sdk; const refreshAuth = () => - gatewayAuth( + requireGatewayAuth( boot.credentials.host, boot.credentials.accessToken, boot.programId, diff --git a/src/lib/agent/runner/shared/bootstrap.ts b/src/lib/agent/runner/shared/bootstrap.ts index 6c2bd794..8a45899a 100644 --- a/src/lib/agent/runner/shared/bootstrap.ts +++ b/src/lib/agent/runner/shared/bootstrap.ts @@ -7,13 +7,13 @@ * effects. Returns the values the arms still need. */ -import type { WizardSession } from '@lib/wizard-session'; +import { buildAgentRunContext, type WizardSession } from '@lib/wizard-session'; import { analytics } from '@utils/analytics'; import { getUI } from '@ui'; import { authenticate, refreshAccessTokenIfNeeded } from './authenticate'; import { maybeStampAiSdkDetected } from '@lib/programs/posthog-integration/detect'; import { createTriageLLMProvider } from '@lib/agent/triage-provider'; -import { gatewayAuth } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { resolveHarness } from '../switchboard'; import { buildRunTags } from '@lib/agent/agent-interface'; import { @@ -92,6 +92,7 @@ export async function bootstrapProgram( // 1. Init logging + debug initLogFile(); session.skillId = config.skillId ?? config.integrationLabel; + getUI().setAgentRunContext(buildAgentRunContext(session, programConfig.id)); logToFile( `[agent-runner] START ${config.integrationLabel} build=${analytics.build}` + `${session.ci ? ' (non-interactive)' : ''}`, @@ -311,7 +312,11 @@ export async function bootstrapProgram( // readers re-resolve through the cache, which re-mints past the refresh // point. const currentGatewayAuth = () => - gatewayAuth(credentials.host, credentials.accessToken, programConfig.id); + requireGatewayAuth( + credentials.host, + credentials.accessToken, + programConfig.id, + ); await currentGatewayAuth(); return { diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 40a704ab..c47cba21 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -18,6 +18,36 @@ import type { SettingsConflict } from './agent/claude-settings'; import type { ApiUser, ApiProject } from './api'; import type { HostResolution } from './host-resolution'; +export type AgentRunContext = Pick< + WizardSession, + 'installDir' | 'integration' | 'skillId' +> & { + programId: string; + frameworkConfig: { + metadata: Pick; + } | null; +}; + +export function buildAgentRunContext( + session: WizardSession, + programId: string, +): AgentRunContext { + return { + programId, + installDir: session.installDir, + integration: session.integration, + skillId: session.skillId, + frameworkConfig: session.frameworkConfig + ? { + metadata: { + name: session.frameworkConfig.metadata.name, + docsUrl: session.frameworkConfig.metadata.docsUrl, + }, + } + : null, + }; +} + export interface Credentials { accessToken: string; /** OAuth refresh token when the grant carried one; absent on CI api-key runs. */ @@ -448,6 +478,7 @@ export interface WizardSession { // Program metadata (set by runWizard in bin.ts) programLabel: string | null; skillId: string | null; + agentRunContext: AgentRunContext | null; // Resolved framework config (set after integration is known) frameworkConfig: FrameworkConfig | null; @@ -563,6 +594,7 @@ export function buildSession(args: { additionalFeatureQueue: [], programLabel: null, skillId: null, + agentRunContext: null, frameworkConfig: null, pendingQuestion: null, }; diff --git a/src/lib/wizard-spellbook.ts b/src/lib/wizard-spellbook.ts new file mode 100644 index 00000000..d4a8f8f2 --- /dev/null +++ b/src/lib/wizard-spellbook.ts @@ -0,0 +1,132 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { getSkillsBaseUrl, POSTHOG_DOCS_URL } from './constants'; +import type { ProgramConfig } from './programs/program-step'; +import type { AgentRunContext } from './wizard-session'; +import { + downloadSkill, + fetchSkillMenu, + type SkillEntry, + type SkillMenu, +} from './wizard-tools/tools'; + +export type WizardSpellbook = { path: string; skillsIncluded: boolean }; +type SpellbookSession = Omit; + +function selectSkills( + menu: SkillMenu, + session: SpellbookSession, + program: ProgramConfig, +): SkillEntry[] { + const entries = Object.values(menu.categories) + .flat() + .filter((entry) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id)); + const skillId = program.skillId ?? session.skillId; + const exact = entries.find((entry) => entry.id === skillId); + if (exact) return [exact]; + + const framework = session.integration; + const referenceGroup = skillId === framework ? 'integration' : skillId; + const references = entries.filter( + (entry) => entry.group === referenceGroup && entry.framework === framework, + ); + const reference = references.find((entry) => entry.default) ?? references[0]; + if (reference) return [reference]; + + const flow = program.agentFlow ?? program.id; + const groups = new Map(); + for (const entry of entries) { + const group = entry.group; + if ( + !group || + (group !== flow && !group.startsWith(`${flow}-`)) || + (entry.framework && entry.framework !== framework) + ) { + continue; + } + if (!groups.has(group) || entry.default) groups.set(group, entry); + } + return [...groups.values()]; +} + +function spellbookText( + session: SpellbookSession, + program: ProgramConfig, + skills: SkillEntry[], +): string { + const framework = session.frameworkConfig?.metadata; + const runDocs = + typeof program.run === 'object' ? program.run.docsUrl : undefined; + const docs = [ + ...new Set([framework?.docsUrl, runDocs, POSTHOG_DOCS_URL]), + ].filter((url): url is string => Boolean(url)); + + return [ + '# Wizard spell book', + '', + 'Complete this task in the project containing this spell book:', + '', + program.description, + '', + `Wizard program: ${program.id}`, + ...(framework ? [`Detected framework: ${framework.name}`] : []), + '', + 'Inspect the existing project and its instructions before making changes. Check what has already been done, complete the requested task within its stated scope, then run the relevant checks and explain any remaining manual steps.', + '', + 'Use your own coding agent and its authentication. The Wizard could not continue its inference session. No Wizard credentials are included; ask the user for any credentials the task requires and keep them out of source code.', + '', + '## Setup instructions', + '', + ...(skills.length + ? [ + 'Read the downloaded skills and their referenced files:', + '', + ...skills.map( + (skill) => `- [${skill.id}](skills/${skill.id}/SKILL.md)`, + ), + '', + 'Downloaded skills may cover only part of the task. Use the official documentation below to fill any gaps.', + ] + : [ + 'No matching skills could be downloaded. Use the official documentation below to complete the task.', + ]), + '', + '## Official documentation', + '', + ...docs.map((url) => `- ${url}`), + '', + ].join('\n'); +} + +export async function writeWizardSpellbook( + session: SpellbookSession, + program: ProgramConfig, +): Promise { + const parent = path.join(session.installDir, '.posthog'); + await fs.mkdir(parent, { recursive: true }); + const directory = await fs.mkdtemp(path.join(parent, 'wizard-spellbook-')); + const readme = path.join(directory, 'README.md'); + await fs.writeFile(readme, spellbookText(session, program, [])); + + const menu = await fetchSkillMenu(getSkillsBaseUrl(), { + timeoutMs: 10_000, + maxAttempts: 1, + }); + const installed: SkillEntry[] = []; + for (const skill of menu ? selectSkills(menu, session, program) : []) { + const result = await downloadSkill(skill, directory, { + skillsRoot: 'skills', + triage: undefined, + }); + if (result.success) { + installed.push(skill); + } else { + await fs.rm(path.join(directory, 'skills', skill.id), { + recursive: true, + force: true, + }); + } + } + await fs.writeFile(readme, spellbookText(session, program, installed)); + return { path: readme, skillsIncluded: installed.length > 0 }; +} diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index 30ff56da..abdea874 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -14,12 +14,14 @@ import { import type { SettingsConflict } from '@lib/agent/claude-settings'; import type { ApiUser } from '@lib/api'; import { OAUTH_TIMEOUT_MS } from '@lib/constants'; +import { isMintFailure, MINT_FAILURE_CONTACT } from './mint-failure'; import { type WizardReadinessResult, getBlockingServiceKeys, SERVICE_LABELS, } from '@lib/health-checks/readiness'; import type { + AgentRunContext, AskAnswers, Credentials, OutroData, @@ -40,6 +42,7 @@ export class LoggingUI implements WizardUI { console.log(`✖ ${data.message ?? 'Wizard aborted'}`); if (data.body) console.log(`│ ${data.body}`); if (data.docsUrl) console.log(`│ Docs: ${data.docsUrl}`); + if (isMintFailure(data)) console.log(`│ ${MINT_FAILURE_CONTACT}`); } waitForOutroDismissed(): Promise { @@ -95,6 +98,10 @@ export class LoggingUI implements WizardUI { console.log(`◇ ${message}`); } + setAgentRunContext(_context: AgentRunContext): void { + // The interactive handoff uses this context only in the TUI. + } + setDetectedFramework(label: string): void { console.log(`✔ Framework: ${label}`); } diff --git a/src/ui/mint-failure.ts b/src/ui/mint-failure.ts new file mode 100644 index 00000000..3b7bde97 --- /dev/null +++ b/src/ui/mint-failure.ts @@ -0,0 +1,16 @@ +import { ErrorCodes } from '@lib/errors/codes'; +import { OutroKind, type OutroData } from '@lib/wizard-session'; + +export const MINT_FAILURE_MESSAGE = + "The wizard's a little occupied right now, would you like the Wizard to leave it's spell book behind for your agent to complete the setup?"; + +export const MINT_FAILURE_CONTACT = + 'If you would really like to use the Wizard, please contact wizard@posthog.com.'; + +export function isMintFailure(data: OutroData | null | undefined): boolean { + return ( + data?.kind === OutroKind.Error && + (data.errorCode === ErrorCodes.GatewayMintRefused || + data.errorCode === ErrorCodes.GatewayMintFailed) + ); +} diff --git a/src/ui/tui/__tests__/exit-line.test.ts b/src/ui/tui/__tests__/exit-line.test.ts index 146f74af..20b5abab 100644 --- a/src/ui/tui/__tests__/exit-line.test.ts +++ b/src/ui/tui/__tests__/exit-line.test.ts @@ -1,6 +1,7 @@ import { getExitLine } from '@ui/tui/exit-line'; import { WizardStore, Program } from '@ui/tui/store'; import { OutroKind } from '@lib/wizard-session'; +import { ErrorCodes } from '@lib/errors/codes'; vi.mock('../../../utils/analytics.js', () => ({ analytics: { @@ -30,6 +31,27 @@ function setHudVisible(store: WizardStore, visible: boolean): void { } describe('getExitLine', () => { + it.each([ErrorCodes.GatewayMintRefused, ErrorCodes.GatewayMintFailed])( + 'keeps the failed setup handoff and contact in scrollback for %s', + (errorCode) => { + const handoffPrompt = + 'Read /project/.posthog/wizard-spellbook-123/README.md and finish setup.'; + const line = stripAnsi( + getExitLine( + storeWithOutro({ + kind: OutroKind.Error, + errorCode, + handoffPrompt, + }), + ), + ); + expect(line).toContain('Setup has not been completed.'); + expect(line.split('\n')).toContain(handoffPrompt); + expect(line).toContain('wizard@posthog.com'); + expect(line).not.toContain('successfully'); + }, + ); + it('echoes the handoff prompt on its own line so it survives in scrollback', () => { const prompt = 'Read `posthog-setup-report.md` and work through the checklist.'; diff --git a/src/ui/tui/__tests__/router.test.ts b/src/ui/tui/__tests__/router.test.ts index 5cbea805..4a86b03c 100644 --- a/src/ui/tui/__tests__/router.test.ts +++ b/src/ui/tui/__tests__/router.test.ts @@ -9,12 +9,31 @@ import { WizardReadiness } from '@lib/health-checks/readiness'; import { WizardRouter, ScreenId, Overlay, Program } from '@ui/tui/router'; import { Integration } from '@lib/constants'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { ErrorCodes } from '@lib/errors/codes'; +import { PROGRAM_REGISTRY } from '@lib/programs/program-registry'; function baseWizardSession() { return buildSession({}); } describe('WizardRouter', () => { + it.each(PROGRAM_REGISTRY.map((program) => program.id))( + 'shows mint failures over every step and overlay in %s', + (program) => { + const router = new WizardRouter(program); + router.pushOverlay(Overlay.WizardAsk); + for (const code of [ + ErrorCodes.GatewayMintRefused, + ErrorCodes.GatewayMintFailed, + ]) { + const session = baseWizardSession(); + session.outroDismissed = true; + session.outroData = { kind: OutroKind.Error, errorCode: code }; + expect(router.resolve(session)).toBe(ScreenId.MintFailure); + } + }, + ); + describe('resolve', () => { it('returns the first incomplete visible screen for the wizard flow', () => { const router = new WizardRouter(Program.PostHogIntegration); diff --git a/src/ui/tui/exit-line.ts b/src/ui/tui/exit-line.ts index 8ff25442..fee97acc 100644 --- a/src/ui/tui/exit-line.ts +++ b/src/ui/tui/exit-line.ts @@ -15,6 +15,7 @@ import { totalTokenCount, type WizardStore } from './store.js'; import { OutroKind } from '@lib/wizard-session'; +import { isMintFailure, MINT_FAILURE_CONTACT } from '@ui/mint-failure'; import { formatTokenCount, formatCostUsd } from '@lib/agent/token-pricing'; const RESET_ATTRS = '\x1b[0m'; @@ -69,6 +70,16 @@ export function getExitLine(store: WizardStore): string { const costLine = tokenCostLine(store); const loginBlock = mcpLoginBlock(store); + if (isMintFailure(outro)) { + return [ + 'The wizard is unavailable. Setup has not been completed.', + outro?.handoffPrompt, + MINT_FAILURE_CONTACT, + ] + .filter(Boolean) + .join('\n\n'); + } + if (outro?.kind === OutroKind.Success) { const message = outro.message ?? `${label} completed successfully.`; const reportSuffix = diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 60c9f27d..451af848 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -17,6 +17,7 @@ import type { SettingsConflict } from '@lib/agent/claude-settings'; import type { WizardReadinessResult } from '@lib/health-checks/readiness'; import type { ApiUser } from '@lib/api'; import type { + AgentRunContext, AskAnswers, Credentials, OutroData, @@ -61,6 +62,7 @@ export class InkUI implements WizardUI { } outroError(data: OutroData): void { + this.store.setOutroDismissed(false); this.store.setOutroData(data); // Advance router past the run step so the outro screen renders if (this.store.session.runPhase !== RunPhase.Error) { @@ -114,6 +116,10 @@ export class InkUI implements WizardUI { return this.store.session.frameworkContext[key]; } + setAgentRunContext(context: AgentRunContext): void { + this.store.setAgentRunContext(context); + } + setDetectedFramework(label: string): void { this.store.setDetectedFramework(label); } diff --git a/src/ui/tui/playground/PlaygroundApp.tsx b/src/ui/tui/playground/PlaygroundApp.tsx index 3c06e130..81f6d535 100644 --- a/src/ui/tui/playground/PlaygroundApp.tsx +++ b/src/ui/tui/playground/PlaygroundApp.tsx @@ -25,6 +25,7 @@ import { EndScreensDemo } from './demos/EndScreensDemo.js'; import { AiOptInDemo } from './demos/AiOptInDemo.js'; import { AskModalDemo } from './demos/AskModalDemo.js'; import { ViewportGuardDemo } from './demos/ViewportGuardDemo.js'; +import { MintFailureDemo } from './demos/MintFailureDemo.js'; interface PlaygroundAppProps { store: WizardStore; @@ -34,6 +35,11 @@ export const PlaygroundApp = ({ store }: PlaygroundAppProps) => { const tabs = [ { id: 'layout', label: 'Layout', component: }, { id: 'input', label: 'Input', component: }, + { + id: 'mint-failure', + label: 'Mint failure', + component: , + }, { id: 'ask-modal', label: 'Ask modal', component: }, { id: 'progress', label: 'Progress', component: }, { id: 'logs', label: 'Logs', component: }, diff --git a/src/ui/tui/playground/demos/MintFailureDemo.tsx b/src/ui/tui/playground/demos/MintFailureDemo.tsx new file mode 100644 index 00000000..80bcdaec --- /dev/null +++ b/src/ui/tui/playground/demos/MintFailureDemo.tsx @@ -0,0 +1,53 @@ +import { Box, Text } from 'ink'; +import { useMemo, useState, useSyncExternalStore } from 'react'; +import { OutroKind } from '@lib/wizard-session'; +import { useKeyBindings } from '@ui/tui/hooks/useKeyBindings'; +import { MintFailureScreen } from '@ui/tui/screens/MintFailureScreen'; +import { WizardStore } from '@ui/tui/store'; + +const leaveSpellbook = () => + Promise.resolve({ + path: '/example/project/POSTHOG_WIZARD_SPELLBOOK.md', + skillsIncluded: true, + }); + +export const MintFailureDemo = () => { + const [revision, setRevision] = useState(0); + const store = useMemo(() => { + const isolated = new WizardStore(); + isolated.setOutroData({ kind: OutroKind.Error }); + return isolated; + }, [revision]); + + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + useKeyBindings('mint-failure-demo', [ + { + match: 'r', + label: 'r', + action: 'replay preview', + handler: () => setRevision((current) => current + 1), + }, + ]); + + return ( + + + Preview only — no files are written. Press r to replay. + + {store.session.outroDismissed ? ( + + Preview complete. Press r to replay. + + ) : ( + + )} + + ); +}; diff --git a/src/ui/tui/router.ts b/src/ui/tui/router.ts index c9b88707..bfcf3a33 100644 --- a/src/ui/tui/router.ts +++ b/src/ui/tui/router.ts @@ -13,6 +13,7 @@ */ import { RunPhase, type WizardSession } from '@lib/wizard-session'; +import { isMintFailure } from '@ui/mint-failure'; import { Program, type ProgramId } from '@lib/programs/program-registry'; import { PROGRAM_SEQUENCES, @@ -60,6 +61,9 @@ export class WizardRouter { * returns the first incomplete screen. */ resolve(session: WizardSession): ScreenName { + // Fatal mint errors interrupt every program until wizardAbort exits. + if (isMintFailure(session.outroData)) return ScreenId.MintFailure; + if (this.overlays.length > 0) { return this.overlays[this.overlays.length - 1]; } diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 08d21b3b..da1e93d8 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -47,6 +47,9 @@ import { McpSuggestedPromptsScreen } from './screens/McpSuggestedPromptsScreen.j import { SlackConnectScreen } from './screens/SlackConnectScreen.js'; import { KeepSkillsScreen } from './screens/KeepSkillsScreen.js'; import { OutroScreen } from './screens/OutroScreen.js'; +import { MintFailureScreen } from './screens/MintFailureScreen.js'; +import { writeWizardSpellbook } from '@lib/wizard-spellbook'; +import { getProgramConfig } from '@lib/programs/program-registry'; import { ExitScreen } from './screens/ExitScreen.js'; import { AuthErrorScreen } from './screens/AuthErrorScreen.js'; import { SessionTimeoutScreen } from './screens/SessionTimeoutScreen.js'; @@ -57,12 +60,21 @@ import { createMcpSuggestedPromptsServices } from './services/mcp-suggested-prom import type { McpSuggestedPromptsServices } from './services/mcp-suggested-prompts-services.js'; export interface ScreenServices { + leaveSpellbook: () => ReturnType; mcpInstaller: McpInstaller; mcpSuggestedPromptsServices: McpSuggestedPromptsServices; } export function createServices(store: WizardStore): ScreenServices { return { + leaveSpellbook: () => + writeWizardSpellbook( + store.session.agentRunContext ?? store.session, + getProgramConfig( + store.session.agentRunContext?.programId ?? + store.router.activeProgram, + ), + ), mcpInstaller: createMcpInstaller(), mcpSuggestedPromptsServices: createMcpSuggestedPromptsServices(store), }; @@ -127,6 +139,12 @@ export function createScreens( [ScreenId.SlackConnect]: , [ScreenId.KeepSkills]: , [ScreenId.Outro]: , + [ScreenId.MintFailure]: ( + + ), [ScreenId.Exit]: , // Standalone MCP flows diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index 6b6b36cf..19836755 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -45,6 +45,7 @@ export enum ScreenId { SlackConnect = 'slack-connect', KeepSkills = 'keep-skills', Outro = 'outro', + MintFailure = 'mint-failure', Exit = 'exit', McpAdd = 'mcp-add', McpRemove = 'mcp-remove', diff --git a/src/ui/tui/screens/MintFailureScreen.tsx b/src/ui/tui/screens/MintFailureScreen.tsx new file mode 100644 index 00000000..47a4e54a --- /dev/null +++ b/src/ui/tui/screens/MintFailureScreen.tsx @@ -0,0 +1,131 @@ +import { Box, Text, measureElement, type DOMElement } from 'ink'; +import { useLayoutEffect, useRef, useState } from 'react'; +import type { WizardStore } from '@ui/tui/store'; +import { ConfirmationInput } from '@ui/tui/primitives/index'; +import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; +import { useDismissOnAnyKey } from '@ui/tui/hooks/useDismissOnAnyKey'; +import { MINT_FAILURE_MESSAGE, MINT_FAILURE_CONTACT } from '@ui/mint-failure'; +import type { writeWizardSpellbook } from '@lib/wizard-spellbook'; + +type Spellbook = Awaited>; + +type MintFailureScreenProps = { + store: WizardStore; + leaveSpellbook: () => Promise; +}; + +enum Phase { + Ask = 'ask', + Saving = 'saving', + Saved = 'saved', +} + +export function MintFailureScreen({ + store, + leaveSpellbook, +}: MintFailureScreenProps) { + useStdoutDimensions(); + const container = useRef(null); + const saving = useRef(false); + const [width, setWidth] = useState(0); + const [phase, setPhase] = useState(Phase.Ask); + const [spellbook, setSpellbook] = useState(null); + const [error, setError] = useState(false); + + useLayoutEffect(() => { + if (!container.current) return; + const measuredWidth = measureElement(container.current).width; + if (measuredWidth !== width) setWidth(measuredWidth); + }); + + const dismiss = () => store.setOutroDismissed(); + useDismissOnAnyKey(() => { + if (phase === Phase.Saved) dismiss(); + }); + const save = async () => { + if (saving.current) return; + saving.current = true; + setError(false); + setPhase(Phase.Saving); + try { + const result = await leaveSpellbook(); + const outro = store.session.outroData; + if (outro) { + store.setOutroData({ + ...outro, + handoffPrompt: `Read ${result.path} and complete the setup described in the Wizard's spell book.`, + }); + } + setSpellbook(result); + setPhase(Phase.Saved); + } catch { + setError(true); + setPhase(Phase.Ask); + } finally { + saving.current = false; + } + }; + + return ( + + + {phase === Phase.Saved && spellbook ? ( + <> + The Wizard left its spell book behind. + + + {spellbook.path} + + + + + {spellbook.skillsIncluded + ? 'Ask your agent to read it and complete the setup.' + : 'Skills could not be downloaded. The spell book includes instructions and links for your agent to continue.'} + + + + ) : ( + {MINT_FAILURE_MESSAGE} + )} + + {MINT_FAILURE_CONTACT} + + + {phase === Phase.Saving ? ( + Leaving the spell book... + ) : phase === Phase.Saved ? ( + Press any key to exit + ) : ( + <> + {error && ( + + Could not save the spell book. Check this folder is writable + and try again. + + )} + void save()} + onCancel={dismiss} + /> + + )} + + + + ); +} diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 12cf174a..dbc04565 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -22,6 +22,7 @@ import { type TokenUsageDelta, } from '@ui/wizard-ui'; import { + type AgentRunContext, type WizardSession, type OutroData, type DiscoveredFeature, @@ -533,6 +534,11 @@ export class WizardStore { this.emitChange(); } + setAgentRunContext(context: AgentRunContext): void { + this.$session.setKey('agentRunContext', context); + this.emitChange(); + } + setSkillId(skillId: string | null): void { this.$session.setKey('skillId', skillId); this.emitChange(); @@ -908,8 +914,8 @@ export class WizardStore { this.setRunPhase(RunPhase.Idle); } - setOutroDismissed(): void { - this.$session.setKey('outroDismissed', true); + setOutroDismissed(dismissed = true): void { + this.$session.setKey('outroDismissed', dismissed); this.emitChange(); } diff --git a/src/ui/wizard-ui.ts b/src/ui/wizard-ui.ts index 38ad2923..f3c45907 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -11,7 +11,11 @@ import type { SettingsConflict } from '@lib/agent/claude-settings'; import type { WizardReadinessResult } from '@lib/health-checks/readiness'; import type { ApiUser } from '@lib/api'; -import type { Credentials, TaskNotice } from '@lib/wizard-session'; +import type { + AgentRunContext, + Credentials, + TaskNotice, +} from '@lib/wizard-session'; import type { AskAnswers, OutroData, @@ -221,6 +225,7 @@ export interface WizardUI { // ── Display state ────────────────────────────────────────────────── /** Set the detected framework label (e.g., "Django with Wagtail CMS") */ setDetectedFramework(label: string): void; + setAgentRunContext(context: AgentRunContext): void; /** Register a callback to run when the TUI transitions onto the given screen. */ onEnterScreen(screen: string, fn: () => void): void;