import { cancel, confirm, isCancel, password, select, text } from '@clack/prompts'; import type { Option as ClackOption } from '@clack/prompts'; import { resolve } from 'node:path'; import { inspectDemoProjectState } from './demo-assets.js'; import type { KtxDemoInputMode } from './demo.js'; import { withMenuOptionsSpacing } from './prompt-navigation.js'; type DemoPromptOption = ClackOption; export interface DemoPromptAdapter { select(options: { message: string; options: Array> }): Promise; confirm(options: { message: string; initialValue?: boolean }): Promise; password(options: { message: string }): Promise; text(options: { message: string; placeholder?: string }): Promise; cancel(message: string): void; } interface DemoInteractiveIo { stdin?: { isTTY?: boolean }; stdout: { isTTY?: boolean }; } type DemoProjectDecision = | { action: 'use'; projectDir: string; reset: boolean } | { action: 'cancel' }; type FullCredentialDecision = | { action: 'full'; env: NodeJS.ProcessEnv } | { action: 'run-mode'; mode: 'seeded' | 'replay' } | { action: 'cancel' }; function isInteractive(inputMode: KtxDemoInputMode | undefined, io: DemoInteractiveIo): boolean { return inputMode !== 'disabled' && io.stdin?.isTTY === true && io.stdout.isTTY === true; } function cloneEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return { ...env }; } function ensureNotCancelled(value: T | symbol, prompts: Pick): T { if (isCancel(value)) { prompts.cancel('Demo cancelled.'); throw new Error('Demo cancelled.'); } return value as T; } export function createClackDemoPromptAdapter(): DemoPromptAdapter { return { async select(options: { message: string; options: Array> }): Promise { return ensureNotCancelled(await select(withMenuOptionsSpacing(options)), this); }, async confirm(options: { message: string; initialValue?: boolean }): Promise { return ensureNotCancelled(await confirm(options), this); }, async password(options: { message: string }): Promise { return ensureNotCancelled(await password(options), this); }, async text(options: { message: string; placeholder?: string }): Promise { return ensureNotCancelled(await text(options), this); }, cancel(message: string): void { cancel(message); }, }; } export function createTestDemoPromptAdapter(options: { choices?: string[]; confirms?: boolean[]; passwords?: string[]; texts?: string[]; }): DemoPromptAdapter { const choices = [...(options.choices ?? [])]; const confirms = [...(options.confirms ?? [])]; const passwords = [...(options.passwords ?? [])]; const texts = [...(options.texts ?? [])]; return { async select(): Promise { return choices.shift() as T; }, async confirm(): Promise { return confirms.shift() ?? false; }, async password(): Promise { return passwords.shift() ?? ''; }, async text(): Promise { return texts.shift() ?? ''; }, cancel(): void { return; }, }; } export async function chooseDemoProjectForInteractiveRun(options: { projectDir: string; inputMode?: KtxDemoInputMode; io: DemoInteractiveIo; prompts?: DemoPromptAdapter; }): Promise { const prompts = options.prompts ?? createClackDemoPromptAdapter(); const projectDir = resolve(options.projectDir); const state = await inspectDemoProjectState(projectDir); if (!isInteractive(options.inputMode, options.io)) { if (state.status === 'corrupt') { throw new Error( `Demo project is not ready at ${projectDir}: missing ${state.missing.join(', ')}. Run ktx setup demo reset --project-dir ${projectDir} --force --no-input`, ); } return { action: 'use', projectDir, reset: false }; } if (state.status === 'missing') { return { action: 'use', projectDir, reset: false }; } const choices = state.status === 'ready' ? [ { value: 'reuse', label: 'Reuse existing demo project' }, { value: 'reset', label: 'Reset demo project' }, { value: 'other', label: 'Choose another directory' }, { value: 'cancel', label: 'Cancel' }, ] : [ { value: 'reset', label: 'Reset corrupted demo project', hint: `Missing ${state.missing.join(', ')}` }, { value: 'other', label: 'Choose another directory' }, { value: 'cancel', label: 'Cancel' }, ]; const choice = await prompts.select({ message: state.status === 'ready' ? `Demo project exists at ${projectDir}` : `Demo project is not ready at ${projectDir}`, options: choices, }); if (choice === 'cancel') { prompts.cancel('Demo cancelled.'); return { action: 'cancel' }; } if (choice === 'other') { const nextProjectDir = await prompts.text({ message: 'Demo project directory', placeholder: projectDir, }); return { action: 'use', projectDir: resolve(nextProjectDir), reset: false }; } if (choice === 'reset') { const confirmed = await prompts.confirm({ message: `Recreate ${projectDir}? Existing demo artifacts under that directory will be removed.`, initialValue: false, }); return confirmed ? { action: 'use', projectDir, reset: true } : { action: 'cancel' }; } return { action: 'use', projectDir, reset: false }; } export async function resolveFullCredentialDecision(options: { needsAnthropicKey: boolean; inputMode?: KtxDemoInputMode; io: DemoInteractiveIo; env: NodeJS.ProcessEnv; prompts?: DemoPromptAdapter; }): Promise { const env = cloneEnv(options.env); if (!options.needsAnthropicKey || env.ANTHROPIC_API_KEY) { return { action: 'full', env }; } if (!isInteractive(options.inputMode, options.io)) { return { action: 'full', env }; } const prompts = options.prompts ?? createClackDemoPromptAdapter(); const choice = await prompts.select({ message: 'Anthropic credentials are missing for the full demo', options: [ { value: 'process_key', label: 'Enter key for this process only' }, { value: 'seeded', label: 'Run pre-seeded demo without LLM' }, { value: 'replay', label: 'Run packaged replay' }, { value: 'cancel', label: 'Cancel' }, ], }); if (choice === 'cancel') { prompts.cancel('Demo cancelled.'); return { action: 'cancel' }; } if (choice === 'seeded' || choice === 'replay') { return { action: 'run-mode', mode: choice }; } const key = await prompts.password({ message: 'ANTHROPIC_API_KEY' }); return { action: 'full', env: { ...env, ANTHROPIC_API_KEY: key } }; }