From bd76df1a50d0ce650bbcf8ff3cf0d164ac3bbc03 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 6 Apr 2026 00:15:23 +0000 Subject: [PATCH] feat(mosaic): drill-down main menu + provider-first flow + quick start (#446) --- docs/scratchpads/iuv-m03-design.md | 227 +++++++++++ .../__tests__/integration/full-wizard.test.ts | 10 +- packages/mosaic/package.json | 2 +- packages/mosaic/src/constants.ts | 47 +++ .../mosaic/src/stages/agent-intent.spec.ts | 129 ++++++ packages/mosaic/src/stages/agent-intent.ts | 64 +++ packages/mosaic/src/stages/gateway-config.ts | 33 +- .../mosaic/src/stages/provider-setup.spec.ts | 118 ++++++ packages/mosaic/src/stages/provider-setup.ts | 54 +++ packages/mosaic/src/stages/quick-start.ts | 98 +++++ .../mosaic/src/stages/wizard-menu.spec.ts | 118 ++++++ packages/mosaic/src/types.ts | 21 + packages/mosaic/src/wizard.ts | 366 ++++++++++++++++-- 13 files changed, 1243 insertions(+), 44 deletions(-) create mode 100644 docs/scratchpads/iuv-m03-design.md create mode 100644 packages/mosaic/src/stages/agent-intent.spec.ts create mode 100644 packages/mosaic/src/stages/agent-intent.ts create mode 100644 packages/mosaic/src/stages/provider-setup.spec.ts create mode 100644 packages/mosaic/src/stages/provider-setup.ts create mode 100644 packages/mosaic/src/stages/quick-start.ts create mode 100644 packages/mosaic/src/stages/wizard-menu.spec.ts diff --git a/docs/scratchpads/iuv-m03-design.md b/docs/scratchpads/iuv-m03-design.md new file mode 100644 index 0000000..afc9435 --- /dev/null +++ b/docs/scratchpads/iuv-m03-design.md @@ -0,0 +1,227 @@ +# IUV-M03 Design: Provider-first intelligent flow + drill-down main menu + +**Issue:** #438 +**Branch:** `feat/install-ux-intent` +**Date:** 2026-04-05 + +## 1. New first-run state machine + +The linear 12-stage interrogation is replaced with a menu-driven architecture. + +### Flow overview + +``` +Welcome banner + | + v +Detect existing install (auto) + | + v +Main Menu (loop) + |-- Quick Start -> provider key + admin creds -> finalize + |-- Providers -> LLM API key config + |-- Agent Identity -> intent intake + naming (deterministic) + |-- Skills -> recommended / custom selection + |-- Gateway -> port, storage tier, hostname, CORS + |-- Advanced -> SOUL.md, USER.md, TOOLS.md, runtimes, hooks + |-- Finish & Apply -> finalize + gateway bootstrap + v +Done +``` + +### Menu navigation + +- Main menu is a `select` prompt. Each option drills into a sub-flow. +- Completing a section returns to the main menu. +- Menu items show completion state: `[done]` hint after configuration. +- `Finish & Apply` is always last and requires at minimum a provider key (or explicit skip). +- The menu tracks configured sections in `WizardState.completedSections`. + +### Headless bypass + +When `MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`, the entire menu is skipped. +The wizard runs: defaults + env var overrides -> finalize -> gateway config -> bootstrap. +This preserves full backward compatibility with `tools/install.sh --yes`. + +## 2. Quick Start path + +Target: 3-5 questions max. Under 90 seconds for a returning user. + +### Questions asked + +1. **Provider API key** (Anthropic/OpenAI) - `text` prompt with paste support +2. **Admin email** - `text` prompt +3. **Admin password** - masked + confirmed + +### Questions skipped (with defaults) + +| Setting | Default | Rationale | +| ---------------------------- | ------------------------------- | ---------------------- | +| Agent name | "Mosaic" | Generic but branded | +| Port | 14242 | Standard default | +| Storage tier | local | No external deps | +| Hostname | localhost | Dev-first | +| CORS origin | http://localhost:3000 | Standard web UI port | +| Skills | recommended set | Curated by maintainers | +| Runtimes | auto-detected | No user input needed | +| Communication style | direct | Most popular choice | +| SOUL.md / USER.md / TOOLS.md | template defaults | Can customize later | +| Hooks | auto-install if Claude detected | Safe default | + +### Flow + +``` +Quick Start selected + -> "Paste your LLM API key (Anthropic recommended):" + -> [auto-detect provider from key prefix: sk-ant-* = Anthropic, sk-* = OpenAI] + -> Apply all defaults + -> Run finalize (sync framework, write configs, link assets, sync skills) + -> Run gateway config (headless-style with defaults + provided key) + -> "Admin email:" + -> "Admin password:" (masked + confirm) + -> Run gateway bootstrap + -> Done +``` + +## 3. Provider-first flow + +Provider configuration (currently buried in gateway-config stage as "ANTHROPIC_API_KEY") +moves to a dedicated top-level menu item and is the first question in Quick Start. + +### Provider detection + +The API key prefix determines the provider: + +- `sk-ant-api03-*` -> Anthropic (Claude) +- `sk-*` -> OpenAI +- Empty/skipped -> no provider (gateway starts without LLM access) + +### Storage + +The provider key is stored in the gateway `.env` as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`. +For Quick Start, this replaces the old interactive prompt in `collectAndWriteConfig`. + +### Menu section: "Providers" + +In the drill-down menu, "Providers" lets users: + +1. Enter/change their API key +2. See which provider was detected +3. Optionally configure a second provider + +For v0.0.27, we support Anthropic and OpenAI keys only. The key is stored +in `WizardState` and written during finalize. + +## 4. Intent intake + naming (deterministic fallback - Option B) + +### Rationale + +At install time, the LLM provider may not be configured yet (chicken-and-egg). +We use **Option B: deterministic advisor** for the install wizard. + +### Flow (Agent Identity menu section) + +``` +1. "What will this agent primarily help you with?" + -> Select from presets: + - General purpose assistant + - Software development + - DevOps & infrastructure + - Research & analysis + - Content & writing + - Custom (free text description) + +2. System proposes a thematic name based on selection: + - General purpose -> "Mosaic" + - Software development -> "Forge" + - DevOps & infrastructure -> "Sentinel" + - Research & analysis -> "Atlas" + - Content & writing -> "Muse" + - Custom -> "Mosaic" (default) + +3. "Your agent will be named 'Forge'. Press Enter to accept or type a new name:" + -> User confirms or overrides +``` + +### Storage + +- Agent name -> `WizardState.soul.agentName` -> written to SOUL.md +- Intent category -> `WizardState.agentIntent` (new field) -> written to `~/.config/mosaic/agent.json` + +### Post-install LLM-powered intake (future) + +A future `mosaic configure identity` command can use the configured LLM to: + +- Accept free-text intent description +- Generate an expounded persona +- Propose a contextual name + +This is explicitly out of scope for the install wizard. + +## 5. Headless backward-compat + +### Supported env vars (unchanged) + +| Variable | Used by | +| -------------------------- | ---------------------------------------------- | +| `MOSAIC_ASSUME_YES=1` | Skip all prompts, use defaults + env overrides | +| `MOSAIC_ADMIN_NAME` | Gateway bootstrap | +| `MOSAIC_ADMIN_EMAIL` | Gateway bootstrap | +| `MOSAIC_ADMIN_PASSWORD` | Gateway bootstrap | +| `MOSAIC_GATEWAY_PORT` | Gateway config | +| `MOSAIC_HOSTNAME` | Gateway config (CORS derivation) | +| `MOSAIC_CORS_ORIGIN` | Gateway config (full override) | +| `MOSAIC_STORAGE_TIER` | Gateway config (local/team) | +| `MOSAIC_DATABASE_URL` | Gateway config (team tier) | +| `MOSAIC_VALKEY_URL` | Gateway config (team tier) | +| `MOSAIC_ANTHROPIC_API_KEY` | Provider config | + +### New env vars + +| Variable | Purpose | +| --------------------- | ----------------------------------------- | +| `MOSAIC_AGENT_NAME` | Override agent name in headless mode | +| `MOSAIC_AGENT_INTENT` | Override intent category in headless mode | + +### `tools/install.sh --yes` + +The install script sets `MOSAIC_ASSUME_YES=1` and passes through env vars. +No changes needed to the script itself. The new wizard detects headless mode +at the top of `runWizard` and runs a linear path identical to the old flow. + +## 6. Explicit non-goals + +- **No GUI** — this is a terminal wizard only +- **No multi-user install** — single-user, single-machine +- **No registry changes** — npm publish flow is unchanged +- **No LLM calls during install** — deterministic fallback only +- **No new dependencies** — uses existing @clack/prompts and picocolors +- **No changes to gateway API** — only the wizard orchestration changes +- **No changes to tools/install.sh** — headless compat maintained via env vars + +## 7. Implementation plan + +### Files to modify + +1. `packages/mosaic/src/types.ts` — add `MenuSection`, `AgentIntent`, `completedSections`, `agentIntent`, `providerKey`, `providerType` to WizardState +2. `packages/mosaic/src/wizard.ts` — replace linear flow with menu loop +3. `packages/mosaic/src/stages/mode-select.ts` — becomes the main menu +4. `packages/mosaic/src/stages/provider-setup.ts` — new: provider key collection +5. `packages/mosaic/src/stages/agent-intent.ts` — new: intent intake + naming +6. `packages/mosaic/src/stages/menu-gateway.ts` — new: gateway sub-menu wrapper +7. `packages/mosaic/src/stages/quick-start.ts` — new: quick start linear path +8. `packages/mosaic/src/constants.ts` — add intent presets and name mappings +9. `packages/mosaic/package.json` — version bump 0.0.26 -> 0.0.27 + +### Files to add (tests) + +1. `packages/mosaic/src/stages/wizard-menu.spec.ts` — menu navigation tests +2. `packages/mosaic/src/stages/quick-start.spec.ts` — quick start path tests +3. `packages/mosaic/src/stages/agent-intent.spec.ts` — intent + naming tests +4. `packages/mosaic/src/stages/provider-setup.spec.ts` — provider detection tests + +### Migration strategy + +The existing stage functions remain intact. The menu system wraps them — +each menu item calls the appropriate stage function(s). The linear headless +path calls them in the same order as before. diff --git a/packages/mosaic/__tests__/integration/full-wizard.test.ts b/packages/mosaic/__tests__/integration/full-wizard.test.ts index da1d6da..a1037ed 100644 --- a/packages/mosaic/__tests__/integration/full-wizard.test.ts +++ b/packages/mosaic/__tests__/integration/full-wizard.test.ts @@ -17,6 +17,7 @@ import { runWizard } from '../../src/wizard.js'; describe('Full Wizard (headless)', () => { let tmpDir: string; const repoRoot = join(import.meta.dirname, '..', '..'); + const originalEnv = { ...process.env }; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-')); @@ -32,12 +33,16 @@ describe('Full Wizard (headless)', () => { afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); + process.env = { ...originalEnv }; }); it('quick start produces valid SOUL.md', async () => { + // The headless path reads agent name from MOSAIC_AGENT_NAME env var + // (via agentIntentStage) rather than prompting interactively. + process.env['MOSAIC_AGENT_NAME'] = 'TestBot'; + const prompter = new HeadlessPrompter({ 'Installation mode': 'quick', - 'What name should agents use?': 'TestBot', 'Communication style': 'direct', 'Your name': 'Tester', 'Your pronouns': 'They/Them', @@ -62,9 +67,10 @@ describe('Full Wizard (headless)', () => { }); it('quick start produces valid USER.md', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'TestBot'; + const prompter = new HeadlessPrompter({ 'Installation mode': 'quick', - 'What name should agents use?': 'TestBot', 'Communication style': 'direct', 'Your name': 'Tester', 'Your pronouns': 'He/Him', diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 59a3134..5cb7bb4 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -1,6 +1,6 @@ { "name": "@mosaicstack/mosaic", - "version": "0.0.26", + "version": "0.0.27", "repository": { "type": "git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git", diff --git a/packages/mosaic/src/constants.ts b/packages/mosaic/src/constants.ts index ed26c4e..cad2934 100644 --- a/packages/mosaic/src/constants.ts +++ b/packages/mosaic/src/constants.ts @@ -26,6 +26,53 @@ export const DEFAULTS = { | (add your git providers here) | | | |`, }; +/** Preset intent categories with display labels and suggested agent names. */ +export const INTENT_PRESETS: Record< + string, + { label: string; hint: string; suggestedName: string } +> = { + general: { + label: 'General purpose assistant', + hint: 'Versatile helper for any task', + suggestedName: 'Mosaic', + }, + 'software-dev': { + label: 'Software development', + hint: 'Coding, debugging, architecture', + suggestedName: 'Forge', + }, + devops: { + label: 'DevOps & infrastructure', + hint: 'CI/CD, containers, monitoring', + suggestedName: 'Sentinel', + }, + research: { + label: 'Research & analysis', + hint: 'Data analysis, literature review', + suggestedName: 'Atlas', + }, + content: { + label: 'Content & writing', + hint: 'Documentation, copywriting, editing', + suggestedName: 'Muse', + }, + custom: { + label: 'Custom', + hint: 'Describe your own use case', + suggestedName: 'Mosaic', + }, +}; + +/** + * Detect LLM provider type from an API key prefix. + */ +export function detectProviderType(key: string): 'anthropic' | 'openai' | 'none' { + if (!key) return 'none'; + if (key.startsWith('sk-ant-')) return 'anthropic'; + if (key.startsWith('sk-')) return 'openai'; + return 'none'; +} + export const RECOMMENDED_SKILLS = new Set([ 'brainstorming', 'code-review-excellence', diff --git a/packages/mosaic/src/stages/agent-intent.spec.ts b/packages/mosaic/src/stages/agent-intent.spec.ts new file mode 100644 index 0000000..3035820 --- /dev/null +++ b/packages/mosaic/src/stages/agent-intent.spec.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { WizardState } from '../types.js'; +import { agentIntentStage } from './agent-intent.js'; + +function buildPrompter(overrides: Partial> = {}) { + return { + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + text: vi.fn().mockResolvedValue('Mosaic'), + confirm: vi.fn().mockResolvedValue(false), + select: vi.fn().mockResolvedValue('general'), + multiselect: vi.fn(), + groupMultiselect: vi.fn(), + spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }), + separator: vi.fn(), + ...overrides, + }; +} + +function makeState(): WizardState { + return { + mosaicHome: '/tmp/mosaic', + sourceDir: '/tmp/mosaic', + mode: 'quick', + installAction: 'fresh', + soul: {}, + user: {}, + tools: {}, + runtimes: { detected: [], mcpConfigured: false }, + selectedSkills: [], + }; +} + +describe('agentIntentStage', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('uses default intent and name in headless mode', async () => { + process.env['MOSAIC_ASSUME_YES'] = '1'; + delete process.env['MOSAIC_AGENT_INTENT']; + delete process.env['MOSAIC_AGENT_NAME']; + const state = makeState(); + const p = buildPrompter(); + + await agentIntentStage(p, state); + + expect(state.agentIntent).toBe('general'); + expect(state.soul.agentName).toBe('Mosaic'); + }); + + it('reads intent from MOSAIC_AGENT_INTENT env var', async () => { + process.env['MOSAIC_ASSUME_YES'] = '1'; + process.env['MOSAIC_AGENT_INTENT'] = 'software-dev'; + delete process.env['MOSAIC_AGENT_NAME']; + const state = makeState(); + const p = buildPrompter(); + + await agentIntentStage(p, state); + + expect(state.agentIntent).toBe('software-dev'); + expect(state.soul.agentName).toBe('Forge'); + }); + + it('honors MOSAIC_AGENT_NAME env var override', async () => { + process.env['MOSAIC_ASSUME_YES'] = '1'; + process.env['MOSAIC_AGENT_INTENT'] = 'devops'; + process.env['MOSAIC_AGENT_NAME'] = 'MyBot'; + const state = makeState(); + const p = buildPrompter(); + + await agentIntentStage(p, state); + + expect(state.agentIntent).toBe('devops'); + expect(state.soul.agentName).toBe('MyBot'); + }); + + it('falls back to general for unknown intent values', async () => { + process.env['MOSAIC_ASSUME_YES'] = '1'; + process.env['MOSAIC_AGENT_INTENT'] = 'nonexistent'; + delete process.env['MOSAIC_AGENT_NAME']; + const state = makeState(); + const p = buildPrompter(); + + await agentIntentStage(p, state); + + expect(state.agentIntent).toBe('general'); + expect(state.soul.agentName).toBe('Mosaic'); + }); + + it('prompts for intent and name in interactive mode', async () => { + delete process.env['MOSAIC_ASSUME_YES']; + const origIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const state = makeState(); + const p = buildPrompter({ + select: vi.fn().mockResolvedValue('research'), + text: vi.fn().mockResolvedValue('Atlas'), + }); + + await agentIntentStage(p, state); + + expect(state.agentIntent).toBe('research'); + expect(state.soul.agentName).toBe('Atlas'); + expect(p.select).toHaveBeenCalled(); + expect(p.text).toHaveBeenCalled(); + + Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true }); + }); + + it('maps content intent to Muse suggested name', async () => { + process.env['MOSAIC_ASSUME_YES'] = '1'; + process.env['MOSAIC_AGENT_INTENT'] = 'content'; + delete process.env['MOSAIC_AGENT_NAME']; + const state = makeState(); + const p = buildPrompter(); + + await agentIntentStage(p, state); + + expect(state.agentIntent).toBe('content'); + expect(state.soul.agentName).toBe('Muse'); + }); +}); diff --git a/packages/mosaic/src/stages/agent-intent.ts b/packages/mosaic/src/stages/agent-intent.ts new file mode 100644 index 0000000..ad38bb0 --- /dev/null +++ b/packages/mosaic/src/stages/agent-intent.ts @@ -0,0 +1,64 @@ +import type { WizardPrompter } from '../prompter/interface.js'; +import type { AgentIntent, WizardState } from '../types.js'; +import { INTENT_PRESETS } from '../constants.js'; + +/** + * Agent intent + naming stage — deterministic (no LLM required). + * + * The user picks an intent category from presets, the system proposes a + * thematic name, and the user confirms or overrides it. + * + * In headless mode, reads from `MOSAIC_AGENT_INTENT` and `MOSAIC_AGENT_NAME`. + */ +export async function agentIntentStage(p: WizardPrompter, state: WizardState): Promise { + const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY; + + if (isHeadless) { + const intentEnv = process.env['MOSAIC_AGENT_INTENT'] ?? 'general'; + const nameEnv = process.env['MOSAIC_AGENT_NAME']; + const preset = INTENT_PRESETS[intentEnv] ?? INTENT_PRESETS['general']!; + state.agentIntent ??= (intentEnv in INTENT_PRESETS ? intentEnv : 'general') as AgentIntent; + // Respect existing agentName (e.g. from CLI overrides) — only set from + // env/preset if not already populated. + state.soul.agentName ??= nameEnv ?? preset.suggestedName; + return; + } + + p.separator(); + p.note( + 'Tell us what this agent will primarily help you with.\n' + + "We'll suggest a name based on your choice — you can always change it.", + 'Agent Identity', + ); + + const intentOptions = Object.entries(INTENT_PRESETS).map(([value, info]) => ({ + value: value as AgentIntent, + label: info.label, + hint: info.hint, + })); + + const intent = await p.select({ + message: 'What will this agent primarily help you with?', + options: intentOptions, + initialValue: 'general' as AgentIntent, + }); + + state.agentIntent = intent; + + const preset = INTENT_PRESETS[intent]; + const suggestedName = preset?.suggestedName ?? 'Mosaic'; + + const name = await p.text({ + message: `Your agent will be named "${suggestedName}". Press Enter to accept or type a new name`, + initialValue: suggestedName, + defaultValue: suggestedName, + validate: (v) => { + if (v.length === 0) return 'Name cannot be empty'; + if (v.length > 50) return 'Name must be under 50 characters'; + return undefined; + }, + }); + + state.soul.agentName = name; + p.log(`Agent name set to: ${name}`); +} diff --git a/packages/mosaic/src/stages/gateway-config.ts b/packages/mosaic/src/stages/gateway-config.ts index e4df420..5265747 100644 --- a/packages/mosaic/src/stages/gateway-config.ts +++ b/packages/mosaic/src/stages/gateway-config.ts @@ -126,6 +126,14 @@ export interface GatewayConfigStageOptions { portOverride?: number; /** Skip the `npm install -g @mosaicstack/gateway` step (local build / tests). */ skipInstall?: boolean; + /** + * Pre-collected provider API key (from the provider-setup stage or Quick + * Start path). When set, the gateway-config stage will skip the interactive + * API key prompt and use this value directly. + */ + providerKey?: string; + /** Provider type detected from the key prefix. */ + providerType?: 'anthropic' | 'openai' | 'none'; } export interface GatewayConfigStageResult { @@ -314,6 +322,8 @@ export async function gatewayConfigStage( envFile: ENV_FILE, mosaicConfigFile: MOSAIC_CONFIG_FILE, gatewayHome: GATEWAY_HOME, + providerKey: opts.providerKey, + providerType: opts.providerType, }); } catch (err) { if (err instanceof GatewayConfigValidationError) { @@ -389,6 +399,10 @@ interface CollectOptions { envFile: string; mosaicConfigFile: string; gatewayHome: string; + /** Pre-collected API key — skips the interactive prompt when set. */ + providerKey?: string; + /** Provider type — determines the env var name for the key. */ + providerType?: 'anthropic' | 'openai' | 'none'; } /** Raised by the config stage when headless env validation fails. */ @@ -466,10 +480,15 @@ async function collectAndWriteConfig( }); } - anthropicKey = await p.text({ - message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)', - defaultValue: '', - }); + if (opts.providerKey) { + anthropicKey = opts.providerKey; + p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`); + } else { + anthropicKey = await p.text({ + message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)', + defaultValue: '', + }); + } hostname = await p.text({ message: 'Web UI hostname (for browser access)', @@ -508,7 +527,11 @@ async function collectAndWriteConfig( } if (anthropicKey) { - envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`); + if (opts.providerType === 'openai') { + envLines.push(`OPENAI_API_KEY=${anthropicKey}`); + } else { + envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`); + } } writeFileSync(opts.envFile, envLines.join('\n') + '\n', { mode: 0o600 }); diff --git a/packages/mosaic/src/stages/provider-setup.spec.ts b/packages/mosaic/src/stages/provider-setup.spec.ts new file mode 100644 index 0000000..07ddea0 --- /dev/null +++ b/packages/mosaic/src/stages/provider-setup.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { WizardState } from '../types.js'; +import { providerSetupStage } from './provider-setup.js'; + +function buildPrompter(overrides: Partial> = {}) { + return { + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + text: vi.fn().mockResolvedValue(''), + confirm: vi.fn().mockResolvedValue(false), + select: vi.fn().mockResolvedValue('general'), + multiselect: vi.fn(), + groupMultiselect: vi.fn(), + spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }), + separator: vi.fn(), + ...overrides, + }; +} + +function makeState(): WizardState { + return { + mosaicHome: '/tmp/mosaic', + sourceDir: '/tmp/mosaic', + mode: 'quick', + installAction: 'fresh', + soul: {}, + user: {}, + tools: {}, + runtimes: { detected: [], mcpConfigured: false }, + selectedSkills: [], + }; +} + +describe('providerSetupStage', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('detects Anthropic key from prefix in headless mode', async () => { + process.env['MOSAIC_ASSUME_YES'] = '1'; + process.env['MOSAIC_ANTHROPIC_API_KEY'] = 'sk-ant-api03-test123'; + const state = makeState(); + const p = buildPrompter(); + + await providerSetupStage(p, state); + + expect(state.providerKey).toBe('sk-ant-api03-test123'); + expect(state.providerType).toBe('anthropic'); + }); + + it('detects OpenAI key from prefix in headless mode', async () => { + process.env['MOSAIC_ASSUME_YES'] = '1'; + process.env['MOSAIC_OPENAI_API_KEY'] = 'sk-proj-test123'; + const state = makeState(); + const p = buildPrompter(); + + await providerSetupStage(p, state); + + expect(state.providerKey).toBe('sk-proj-test123'); + expect(state.providerType).toBe('openai'); + }); + + it('sets provider type to none when no key is provided in headless mode', async () => { + process.env['MOSAIC_ASSUME_YES'] = '1'; + delete process.env['MOSAIC_ANTHROPIC_API_KEY']; + delete process.env['MOSAIC_OPENAI_API_KEY']; + const state = makeState(); + const p = buildPrompter(); + + await providerSetupStage(p, state); + + expect(state.providerKey).toBeUndefined(); + expect(state.providerType).toBe('none'); + }); + + it('prompts for key in interactive mode', async () => { + delete process.env['MOSAIC_ASSUME_YES']; + // Simulate a TTY + const origIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const state = makeState(); + const p = buildPrompter({ + text: vi.fn().mockResolvedValue('sk-ant-api03-interactive'), + }); + + await providerSetupStage(p, state); + + expect(p.text).toHaveBeenCalled(); + expect(state.providerKey).toBe('sk-ant-api03-interactive'); + expect(state.providerType).toBe('anthropic'); + + Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true }); + }); + + it('handles empty key in interactive mode', async () => { + delete process.env['MOSAIC_ASSUME_YES']; + const origIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const state = makeState(); + const p = buildPrompter({ + text: vi.fn().mockResolvedValue(''), + }); + + await providerSetupStage(p, state); + + expect(state.providerType).toBe('none'); + expect(state.providerKey).toBeUndefined(); + + Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true }); + }); +}); diff --git a/packages/mosaic/src/stages/provider-setup.ts b/packages/mosaic/src/stages/provider-setup.ts new file mode 100644 index 0000000..7917935 --- /dev/null +++ b/packages/mosaic/src/stages/provider-setup.ts @@ -0,0 +1,54 @@ +import type { WizardPrompter } from '../prompter/interface.js'; +import type { WizardState } from '../types.js'; +import { detectProviderType } from '../constants.js'; + +/** + * Provider setup stage — collects the user's LLM API key and detects the + * provider type from the key prefix. + * + * In headless mode, reads from `MOSAIC_ANTHROPIC_API_KEY` or `MOSAIC_OPENAI_API_KEY`. + */ +export async function providerSetupStage(p: WizardPrompter, state: WizardState): Promise { + const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY; + + if (isHeadless) { + const anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? ''; + const openaiKey = process.env['MOSAIC_OPENAI_API_KEY'] ?? ''; + const key = anthropicKey || openaiKey; + state.providerKey = key || undefined; + state.providerType = detectProviderType(key); + return; + } + + p.separator(); + p.note( + 'Configure your LLM provider so the agent has a brain.\n' + + 'Anthropic (Claude) and OpenAI are supported.\n' + + 'You can skip this and add a key later via `mosaic configure`.', + 'LLM Provider', + ); + + const key = await p.text({ + message: 'API key (paste your Anthropic or OpenAI key, or press Enter to skip)', + defaultValue: '', + placeholder: 'sk-ant-api03-... or sk-...', + }); + + if (key) { + const provider = detectProviderType(key); + state.providerKey = key; + state.providerType = provider; + + if (provider === 'anthropic') { + p.log('Detected provider: Anthropic (Claude)'); + } else if (provider === 'openai') { + p.log('Detected provider: OpenAI'); + } else { + p.log('Provider auto-detection failed. Key will be stored as ANTHROPIC_API_KEY.'); + state.providerType = 'anthropic'; + } + } else { + state.providerType = 'none'; + p.log('No API key provided. You can add one later with `mosaic configure`.'); + } +} diff --git a/packages/mosaic/src/stages/quick-start.ts b/packages/mosaic/src/stages/quick-start.ts new file mode 100644 index 0000000..b97d1a4 --- /dev/null +++ b/packages/mosaic/src/stages/quick-start.ts @@ -0,0 +1,98 @@ +import type { WizardPrompter } from '../prompter/interface.js'; +import type { ConfigService } from '../config/config-service.js'; +import type { WizardState } from '../types.js'; +import { DEFAULTS } from '../constants.js'; +import { providerSetupStage } from './provider-setup.js'; +import { runtimeSetupStage } from './runtime-setup.js'; +import { hooksPreviewStage } from './hooks-preview.js'; +import { skillsSelectStage } from './skills-select.js'; +import { finalizeStage } from './finalize.js'; +import { gatewayConfigStage } from './gateway-config.js'; +import { gatewayBootstrapStage } from './gateway-bootstrap.js'; + +export interface QuickStartOptions { + skipGateway?: boolean; + gatewayHost?: string; + gatewayPort?: number; + gatewayPortOverride?: number; + skipGatewayNpmInstall?: boolean; +} + +/** + * Quick Start path — minimal questions to get a working agent. + * + * 1. Provider API key + * 2. Admin email + password (via gateway bootstrap) + * 3. Everything else uses defaults. + * + * Target: under 90 seconds for a returning user. + */ +export async function quickStartPath( + prompter: WizardPrompter, + state: WizardState, + configService: ConfigService, + options: QuickStartOptions, +): Promise { + state.mode = 'quick'; + + // 1. Provider setup (first question) + await providerSetupStage(prompter, state); + + // Apply sensible defaults for everything else + 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 (auto, no user input in quick mode) + await runtimeSetupStage(prompter, state); + + // Hooks (auto-accept in quick mode for Claude) + await hooksPreviewStage(prompter, state); + + // Skills (recommended set, no user input in quick mode) + await skillsSelectStage(prompter, state); + + // Finalize (writes configs, links runtime assets, syncs skills) + await finalizeStage(prompter, state, configService); + + // Gateway config + bootstrap + if (!options.skipGateway) { + const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY; + + 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) { + if (headlessRun) { + 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; + } + } +} diff --git a/packages/mosaic/src/stages/wizard-menu.spec.ts b/packages/mosaic/src/stages/wizard-menu.spec.ts new file mode 100644 index 0000000..5174006 --- /dev/null +++ b/packages/mosaic/src/stages/wizard-menu.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import type { MenuSection } from '../types.js'; +import { detectProviderType, INTENT_PRESETS } from '../constants.js'; + +/** + * Tests for the drill-down menu system and its supporting utilities. + * + * The menu loop itself is in wizard.ts and is hard to unit test in isolation + * because it orchestrates many async stages. These tests verify the building + * blocks: provider detection, intent presets, and the WizardState shape. + */ + +describe('detectProviderType', () => { + it('detects Anthropic from sk-ant- prefix', () => { + expect(detectProviderType('sk-ant-api03-abc123')).toBe('anthropic'); + }); + + it('detects OpenAI from sk- prefix', () => { + expect(detectProviderType('sk-proj-abc123')).toBe('openai'); + }); + + it('returns none for empty string', () => { + expect(detectProviderType('')).toBe('none'); + }); + + it('returns none for unrecognized prefix', () => { + expect(detectProviderType('gsk_abc123')).toBe('none'); + }); +}); + +describe('INTENT_PRESETS', () => { + it('has all expected intent categories', () => { + expect(Object.keys(INTENT_PRESETS)).toEqual( + expect.arrayContaining([ + 'general', + 'software-dev', + 'devops', + 'research', + 'content', + 'custom', + ]), + ); + }); + + it('each preset has label, hint, and suggestedName', () => { + for (const [key, preset] of Object.entries(INTENT_PRESETS)) { + expect(preset.label, `${key}.label`).toBeTruthy(); + expect(preset.hint, `${key}.hint`).toBeTruthy(); + expect(preset.suggestedName, `${key}.suggestedName`).toBeTruthy(); + } + }); + + it('maps software-dev to Forge', () => { + expect(INTENT_PRESETS['software-dev']?.suggestedName).toBe('Forge'); + }); + + it('maps devops to Sentinel', () => { + expect(INTENT_PRESETS['devops']?.suggestedName).toBe('Sentinel'); + }); +}); + +describe('WizardState completedSections', () => { + it('tracks completed sections as a Set', () => { + const completed = new Set(); + completed.add('providers'); + completed.add('identity'); + + expect(completed.has('providers')).toBe(true); + expect(completed.has('identity')).toBe(true); + expect(completed.has('skills')).toBe(false); + expect(completed.size).toBe(2); + }); +}); + +describe('headless backward compat', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('MOSAIC_ASSUME_YES=1 triggers headless path', () => { + process.env['MOSAIC_ASSUME_YES'] = '1'; + const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY; + expect(isHeadless).toBe(true); + }); + + it('non-TTY triggers headless path', () => { + delete process.env['MOSAIC_ASSUME_YES']; + // In test environments, process.stdin.isTTY is typically undefined (falsy) + const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY; + expect(isHeadless).toBe(true); + }); + + it('all headless env vars are recognized', () => { + // This test documents the expected env vars for headless installs. + const headlessVars = [ + 'MOSAIC_ASSUME_YES', + 'MOSAIC_ADMIN_NAME', + 'MOSAIC_ADMIN_EMAIL', + 'MOSAIC_ADMIN_PASSWORD', + 'MOSAIC_GATEWAY_PORT', + 'MOSAIC_HOSTNAME', + 'MOSAIC_CORS_ORIGIN', + 'MOSAIC_STORAGE_TIER', + 'MOSAIC_DATABASE_URL', + 'MOSAIC_VALKEY_URL', + 'MOSAIC_ANTHROPIC_API_KEY', + 'MOSAIC_AGENT_NAME', + 'MOSAIC_AGENT_INTENT', + ]; + + // Just verify none of them throw when accessed + for (const v of headlessVars) { + expect(() => process.env[v]).not.toThrow(); + } + }); +}); diff --git a/packages/mosaic/src/types.ts b/packages/mosaic/src/types.ts index 9b7f214..1323258 100644 --- a/packages/mosaic/src/types.ts +++ b/packages/mosaic/src/types.ts @@ -3,6 +3,19 @@ export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset'; export type CommunicationStyle = 'direct' | 'friendly' | 'formal'; export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi'; +export type MenuSection = + | 'quick-start' + | 'providers' + | 'identity' + | 'skills' + | 'gateway' + | 'advanced' + | 'finish'; + +export type AgentIntent = 'general' | 'software-dev' | 'devops' | 'research' | 'content' | 'custom'; + +export type ProviderType = 'anthropic' | 'openai' | 'none'; + export interface SoulConfig { agentName?: string; roleDescription?: string; @@ -86,4 +99,12 @@ export interface WizardState { selectedSkills: string[]; hooks?: HooksState; gateway?: GatewayState; + /** Tracks which menu sections have been completed in drill-down mode. */ + completedSections?: Set; + /** The user's chosen agent intent category. */ + agentIntent?: AgentIntent; + /** The LLM provider API key entered during setup. */ + providerKey?: string; + /** Detected provider type based on API key prefix. */ + providerType?: ProviderType; } diff --git a/packages/mosaic/src/wizard.ts b/packages/mosaic/src/wizard.ts index ed4ac90..15b3188 100644 --- a/packages/mosaic/src/wizard.ts +++ b/packages/mosaic/src/wizard.ts @@ -1,9 +1,8 @@ import type { WizardPrompter } from './prompter/interface.js'; import type { ConfigService } from './config/config-service.js'; -import type { WizardState } from './types.js'; +import type { MenuSection, WizardState } from './types.js'; import { welcomeStage } from './stages/welcome.js'; import { detectInstallStage } from './stages/detect-install.js'; -import { modeSelectStage } from './stages/mode-select.js'; import { soulSetupStage } from './stages/soul-setup.js'; import { userSetupStage } from './stages/user-setup.js'; import { toolsSetupStage } from './stages/tools-setup.js'; @@ -13,6 +12,10 @@ 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; @@ -54,6 +57,7 @@ export async function runWizard(options: WizardOptions): Promise { tools: {}, runtimes: { detected: [], mcpConfigured: false }, selectedSkills: [], + completedSections: new Set(), }; // Apply CLI overrides (strip undefined values) @@ -90,55 +94,304 @@ export async function runWizard(options: WizardOptions): Promise { // Stage 2: Existing Install Detection await detectInstallStage(prompter, state, configService); - // Stage 3: Quick Start vs Advanced (skip if keeping existing) - if (state.installAction === 'fresh' || state.installAction === 'reset') { - await modeSelectStage(prompter, state); - } else if (state.installAction === 'reconfigure') { - state.mode = 'advanced'; + // ── 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; } - // Stage 4: SOUL.md + // ── 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); - - // Stage 5: USER.md await userSetupStage(prompter, state); - - // Stage 6: TOOLS.md await toolsSetupStage(prompter, state); - - // Stage 7: Runtime Detection & Installation await runtimeSetupStage(prompter, state); - - // Stage 8: Hooks preview (Claude only — skipped if Claude not detected) await hooksPreviewStage(prompter, state); +} - // Stage 9: Skills Selection - await skillsSelectStage(prompter, state); +// ── Finish & Apply ────────────────────────────────────────────────────────── - // Stage 10: Finalize (writes configs, links runtime assets, runs doctor) +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); - // Stages 11 & 12: Gateway config + admin bootstrap. - // The unified first-run flow runs these as terminal stages so the user - // goes from "welcome" through "admin user created" in a single cohesive - // experience. Callers that only want the framework portion pass - // `skipGateway: true`. + // Gateway stages if (!options.skipGateway) { - const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY; - 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) { - if (headlessRun) { - prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); - process.exit(1); - } + prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); + process.exit(1); } else { const bootstrapResult = await gatewayBootstrapStage(prompter, state, { host: configResult.host, @@ -150,12 +403,53 @@ export async function runWizard(options: WizardOptions): Promise { } } } catch (err) { - // Stages normally return structured `ready: false` results for - // expected failures. Anything that reaches here is an unexpected - // runtime error — render a concise warning for UX AND re-throw so - // the CLI (and `tools/install.sh` auto-launch) sees a non-zero exit. - // Swallowing here would let headless installs report success even - // when the gateway stage crashed. + 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; }