import type { WizardPrompter } from './prompter/interface.js'; import type { ConfigService } from './config/config-service.js'; import type { MenuSection, WizardState } from './types.js'; import { welcomeStage } from './stages/welcome.js'; import { detectInstallStage } from './stages/detect-install.js'; import { soulSetupStage } from './stages/soul-setup.js'; import { userSetupStage } from './stages/user-setup.js'; import { toolsSetupStage } from './stages/tools-setup.js'; import { runtimeSetupStage } from './stages/runtime-setup.js'; import { hooksPreviewStage } from './stages/hooks-preview.js'; import { skillsSelectStage } from './stages/skills-select.js'; import { finalizeStage } from './stages/finalize.js'; import { gatewayConfigStage } from './stages/gateway-config.js'; import { gatewayBootstrapStage } from './stages/gateway-bootstrap.js'; import { providerSetupStage } from './stages/provider-setup.js'; import { agentIntentStage } from './stages/agent-intent.js'; import { quickStartPath } from './stages/quick-start.js'; import { DEFAULTS } from './constants.js'; export interface WizardOptions { mosaicHome: string; sourceDir: string; prompter: WizardPrompter; configService: ConfigService; cliOverrides?: Partial; /** * Skip the terminal gateway stages. Used by callers that only want to * configure the framework (SOUL.md/USER.md/skills/hooks) without touching * the gateway daemon. Defaults to `false` — the unified first-run flow * runs everything end-to-end. */ skipGateway?: boolean; /** Host passed through to the gateway config stage. Defaults to localhost. */ gatewayHost?: string; /** Default gateway port (14242) — overridable by CLI flag. */ gatewayPort?: number; /** * Explicit port override from the caller. Honored even when resuming * from an existing `.env` (useful when the saved port conflicts with * another service). */ gatewayPortOverride?: number; /** Skip `npm install -g @mosaicstack/gateway` during the config stage. */ skipGatewayNpmInstall?: boolean; } export async function runWizard(options: WizardOptions): Promise { const { prompter, configService, mosaicHome, sourceDir } = options; const state: WizardState = { mosaicHome, sourceDir, mode: 'quick', installAction: 'fresh', soul: {}, user: {}, tools: {}, runtimes: { detected: [], mcpConfigured: false }, selectedSkills: [], completedSections: new Set(), }; // Apply CLI overrides (strip undefined values) if (options.cliOverrides) { if (options.cliOverrides.soul) { for (const [k, v] of Object.entries(options.cliOverrides.soul)) { if (v !== undefined) { (state.soul as Record)[k] = v; } } } if (options.cliOverrides.user) { for (const [k, v] of Object.entries(options.cliOverrides.user)) { if (v !== undefined) { (state.user as Record)[k] = v; } } } if (options.cliOverrides.tools) { for (const [k, v] of Object.entries(options.cliOverrides.tools)) { if (v !== undefined) { (state.tools as Record)[k] = v; } } } if (options.cliOverrides.mode) { state.mode = options.cliOverrides.mode; } } // Stage 1: Welcome await welcomeStage(prompter, state); // Stage 2: Existing Install Detection await detectInstallStage(prompter, state, configService); // ── Headless bypass ──────────────────────────────────────────────────────── // When MOSAIC_ASSUME_YES=1 or no TTY, run the linear headless path. // This preserves full backward compatibility with tools/install.sh --yes. const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY; if (headlessRun) { await runHeadlessPath(prompter, state, configService, options); return; } // ── Interactive: Main Menu ───────────────────────────────────────────────── if (state.installAction === 'fresh' || state.installAction === 'reset') { await runMenuLoop(prompter, state, configService, options); } else if (state.installAction === 'reconfigure') { state.mode = 'advanced'; await runMenuLoop(prompter, state, configService, options); } else { // 'keep' — skip identity setup, go straight to finalize + gateway await runKeepPath(prompter, state, configService, options); } } // ── Menu-driven interactive flow ──────────────────────────────────────────── type MenuChoice = | 'quick-start' | 'providers' | 'identity' | 'skills' | 'gateway-config' | 'advanced' | 'finish'; function menuLabel(section: MenuChoice, completed: Set): string { const labels: Record = { 'quick-start': 'Quick Start', providers: 'Providers', identity: 'Agent Identity', skills: 'Skills', 'gateway-config': 'Gateway', advanced: 'Advanced', finish: 'Finish & Apply', }; const base = labels[section]; const sectionKey: MenuSection = section === 'gateway-config' ? 'gateway' : (section as MenuSection); if (completed.has(sectionKey)) { return `${base} [done]`; } return base; } async function runMenuLoop( prompter: WizardPrompter, state: WizardState, configService: ConfigService, options: WizardOptions, ): Promise { const completed = state.completedSections!; for (;;) { const choice = await prompter.select({ message: 'What would you like to configure?', options: [ { value: 'quick-start', label: menuLabel('quick-start', completed), hint: 'Recommended defaults, minimal questions', }, { value: 'providers', label: menuLabel('providers', completed), hint: 'LLM API keys (Anthropic, OpenAI)', }, { value: 'identity', label: menuLabel('identity', completed), hint: 'Agent name, intent, persona', }, { value: 'skills', label: menuLabel('skills', completed), hint: 'Install agent skills', }, { value: 'gateway-config', label: menuLabel('gateway-config', completed), hint: 'Port, storage, database', }, { value: 'advanced', label: menuLabel('advanced', completed), hint: 'SOUL.md, USER.md, TOOLS.md, runtimes, hooks', }, { value: 'finish', label: menuLabel('finish', completed), hint: 'Write configs and start gateway', }, ], }); switch (choice) { case 'quick-start': await quickStartPath(prompter, state, configService, options); return; // Quick start is a complete flow — exit menu case 'providers': await providerSetupStage(prompter, state); completed.add('providers'); break; case 'identity': await agentIntentStage(prompter, state); completed.add('identity'); break; case 'skills': await skillsSelectStage(prompter, state); completed.add('skills'); break; case 'gateway-config': // Gateway config is handled during Finish — mark as "configured" // after user reviews settings. await runGatewaySubMenu(prompter, state, options); completed.add('gateway'); break; case 'advanced': await runAdvancedSubMenu(prompter, state); completed.add('advanced'); break; case 'finish': await runFinishPath(prompter, state, configService, options); return; // Done } } } // ── Gateway sub-menu ───────────────────────────────────────────────────────── async function runGatewaySubMenu( prompter: WizardPrompter, state: WizardState, _options: WizardOptions, ): Promise { prompter.note( 'Gateway settings will be applied when you select "Finish & Apply".\n' + 'Configure the settings you want to customize here.', 'Gateway Configuration', ); // For now, just let them know defaults will be used and they can // override during finish. The actual gateway config stage runs // during Finish & Apply. This menu item exists so users know // the gateway is part of the wizard. const port = await prompter.text({ message: 'Gateway port', initialValue: (_options.gatewayPort ?? 14242).toString(), defaultValue: (_options.gatewayPort ?? 14242).toString(), validate: (v) => { const n = parseInt(v, 10); if (Number.isNaN(n) || n < 1 || n > 65535) return 'Port must be 1-65535'; return undefined; }, }); // Store for later use in the gateway config stage _options.gatewayPort = parseInt(port, 10); prompter.log(`Gateway port set to ${port}. Will be applied during Finish & Apply.`); } // ── Advanced sub-menu ──────────────────────────────────────────────────────── async function runAdvancedSubMenu(prompter: WizardPrompter, state: WizardState): Promise { state.mode = 'advanced'; // Run the detailed setup stages await soulSetupStage(prompter, state); await userSetupStage(prompter, state); await toolsSetupStage(prompter, state); await runtimeSetupStage(prompter, state); await hooksPreviewStage(prompter, state); } // ── Finish & Apply ────────────────────────────────────────────────────────── async function runFinishPath( prompter: WizardPrompter, state: WizardState, configService: ConfigService, options: WizardOptions, ): Promise { // Apply defaults for anything not explicitly configured state.soul.agentName ??= 'Mosaic'; state.soul.roleDescription ??= DEFAULTS.roleDescription; state.soul.communicationStyle ??= 'direct'; state.user.background ??= DEFAULTS.background; state.user.accessibilitySection ??= DEFAULTS.accessibilitySection; state.user.personalBoundaries ??= DEFAULTS.personalBoundaries; state.tools.gitProviders ??= []; state.tools.credentialsLocation ??= DEFAULTS.credentialsLocation; state.tools.customToolsSection ??= DEFAULTS.customToolsSection; // Runtime detection if not already done if (state.runtimes.detected.length === 0 && !state.completedSections?.has('advanced')) { await runtimeSetupStage(prompter, state); await hooksPreviewStage(prompter, state); } // Skills defaults if not already configured if (!state.completedSections?.has('skills')) { await skillsSelectStage(prompter, state); } // Finalize (writes configs, links runtime assets, syncs skills) await finalizeStage(prompter, state, configService); // Gateway stages if (!options.skipGateway) { try { const configResult = await gatewayConfigStage(prompter, state, { host: options.gatewayHost ?? 'localhost', defaultPort: options.gatewayPort ?? 14242, portOverride: options.gatewayPortOverride, skipInstall: options.skipGatewayNpmInstall, providerKey: state.providerKey, providerType: state.providerType ?? 'none', }); if (configResult.ready && configResult.host && configResult.port) { const bootstrapResult = await gatewayBootstrapStage(prompter, state, { host: configResult.host, port: configResult.port, }); if (!bootstrapResult.completed) { prompter.warn('Admin bootstrap failed — aborting wizard.'); process.exit(1); } } } catch (err) { prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } } } // ── Headless linear path (backward compat) ────────────────────────────────── async function runHeadlessPath( prompter: WizardPrompter, state: WizardState, configService: ConfigService, options: WizardOptions, ): Promise { // Provider setup from env vars await providerSetupStage(prompter, state); // Agent intent from env vars await agentIntentStage(prompter, state); // SOUL.md await soulSetupStage(prompter, state); // USER.md await userSetupStage(prompter, state); // TOOLS.md await toolsSetupStage(prompter, state); // Runtime Detection await runtimeSetupStage(prompter, state); // Hooks await hooksPreviewStage(prompter, state); // Skills await skillsSelectStage(prompter, state); // Finalize await finalizeStage(prompter, state, configService); // Gateway stages if (!options.skipGateway) { try { const configResult = await gatewayConfigStage(prompter, state, { host: options.gatewayHost ?? 'localhost', defaultPort: options.gatewayPort ?? 14242, portOverride: options.gatewayPortOverride, skipInstall: options.skipGatewayNpmInstall, providerKey: state.providerKey, providerType: state.providerType ?? 'none', }); if (!configResult.ready || !configResult.host || !configResult.port) { prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); process.exit(1); } else { const bootstrapResult = await gatewayBootstrapStage(prompter, state, { host: configResult.host, port: configResult.port, }); if (!bootstrapResult.completed) { prompter.warn('Admin bootstrap failed — aborting wizard.'); process.exit(1); } } } catch (err) { prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } } } // ── Keep path (preserve existing identity) ────────────────────────────────── async function runKeepPath( prompter: WizardPrompter, state: WizardState, configService: ConfigService, options: WizardOptions, ): Promise { // Runtime detection await runtimeSetupStage(prompter, state); // Hooks await hooksPreviewStage(prompter, state); // Skills await skillsSelectStage(prompter, state); // Finalize await finalizeStage(prompter, state, configService); // Gateway stages if (!options.skipGateway) { try { const configResult = await gatewayConfigStage(prompter, state, { host: options.gatewayHost ?? 'localhost', defaultPort: options.gatewayPort ?? 14242, portOverride: options.gatewayPortOverride, skipInstall: options.skipGatewayNpmInstall, }); if (configResult.ready && configResult.host && configResult.port) { const bootstrapResult = await gatewayBootstrapStage(prompter, state, { host: configResult.host, port: configResult.port, }); if (!bootstrapResult.completed) { prompter.warn('Admin bootstrap failed — aborting wizard.'); process.exit(1); } } } catch (err) { prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } } }