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:
@@ -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
|
||||
// ── 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<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;
|
||||
}
|
||||
|
||||
async function runMenuLoop(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
const completed = state.completedSections!;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
// 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<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);
|
||||
}
|
||||
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