feat(mosaic): drill-down main menu + provider-first flow + quick start (#446)
This commit was merged in pull request #446.
This commit is contained in:
227
docs/scratchpads/iuv-m03-design.md
Normal file
227
docs/scratchpads/iuv-m03-design.md
Normal file
@@ -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.
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
129
packages/mosaic/src/stages/agent-intent.spec.ts
Normal file
129
packages/mosaic/src/stages/agent-intent.spec.ts
Normal file
@@ -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<Record<string, unknown>> = {}) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
64
packages/mosaic/src/stages/agent-intent.ts
Normal file
64
packages/mosaic/src/stages/agent-intent.ts
Normal file
@@ -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<void> {
|
||||
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<AgentIntent>({
|
||||
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}`);
|
||||
}
|
||||
@@ -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(
|
||||
});
|
||||
}
|
||||
|
||||
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,8 +527,12 @@ async function collectAndWriteConfig(
|
||||
}
|
||||
|
||||
if (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 });
|
||||
p.log(`Config written to ${opts.envFile}`);
|
||||
|
||||
118
packages/mosaic/src/stages/provider-setup.spec.ts
Normal file
118
packages/mosaic/src/stages/provider-setup.spec.ts
Normal file
@@ -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<Record<string, unknown>> = {}) {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
54
packages/mosaic/src/stages/provider-setup.ts
Normal file
54
packages/mosaic/src/stages/provider-setup.ts
Normal file
@@ -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<void> {
|
||||
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`.');
|
||||
}
|
||||
}
|
||||
98
packages/mosaic/src/stages/quick-start.ts
Normal file
98
packages/mosaic/src/stages/quick-start.ts
Normal file
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
118
packages/mosaic/src/stages/wizard-menu.spec.ts
Normal file
118
packages/mosaic/src/stages/wizard-menu.spec.ts
Normal file
@@ -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<MenuSection>();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<MenuSection>;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
completedSections: new Set<MenuSection>(),
|
||||
};
|
||||
|
||||
// Apply CLI overrides (strip undefined values)
|
||||
@@ -90,55 +94,304 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
// 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
|
||||
await soulSetupStage(prompter, state);
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 5: USER.md
|
||||
await userSetupStage(prompter, state);
|
||||
// ── Menu-driven interactive flow ────────────────────────────────────────────
|
||||
|
||||
// Stage 6: TOOLS.md
|
||||
await toolsSetupStage(prompter, state);
|
||||
type MenuChoice =
|
||||
| 'quick-start'
|
||||
| 'providers'
|
||||
| 'identity'
|
||||
| 'skills'
|
||||
| 'gateway-config'
|
||||
| 'advanced'
|
||||
| 'finish';
|
||||
|
||||
// Stage 7: Runtime Detection & Installation
|
||||
await runtimeSetupStage(prompter, state);
|
||||
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
||||
const labels: Record<MenuChoice, string> = {
|
||||
'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;
|
||||
}
|
||||
|
||||
// Stage 8: Hooks preview (Claude only — skipped if Claude not detected)
|
||||
await hooksPreviewStage(prompter, state);
|
||||
async function runMenuLoop(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
const completed = state.completedSections!;
|
||||
|
||||
// Stage 9: Skills Selection
|
||||
for (;;) {
|
||||
const choice = await prompter.select<MenuChoice>({
|
||||
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;
|
||||
|
||||
// Stage 10: Finalize (writes configs, links runtime assets, runs doctor)
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// 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);
|
||||
}
|
||||
} else {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
@@ -150,12 +403,53 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
} 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<void> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user