feat: TypeScript installation wizard with @clack/prompts TUI (#1)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-02-21 18:25:51 +00:00
committed by jason.woltje
parent e3ec3e32e5
commit 6a84f7e210
56 changed files with 20647 additions and 31 deletions

View File

@@ -0,0 +1,95 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState, InstallAction } from '../types.js';
function detectExistingInstall(mosaicHome: string): boolean {
if (!existsSync(mosaicHome)) return false;
return (
existsSync(join(mosaicHome, 'bin/mosaic')) ||
existsSync(join(mosaicHome, 'AGENTS.md')) ||
existsSync(join(mosaicHome, 'SOUL.md'))
);
}
function detectExistingIdentity(mosaicHome: string): {
hasSoul: boolean;
hasUser: boolean;
hasTools: boolean;
agentName?: string;
} {
const soulPath = join(mosaicHome, 'SOUL.md');
const hasSoul = existsSync(soulPath);
let agentName: string | undefined;
if (hasSoul) {
try {
const content = readFileSync(soulPath, 'utf-8');
const match = content.match(/You are \*\*(.+?)\*\*/);
agentName = match?.[1];
} catch {
// Non-fatal
}
}
return {
hasSoul,
hasUser: existsSync(join(mosaicHome, 'USER.md')),
hasTools: existsSync(join(mosaicHome, 'TOOLS.md')),
agentName,
};
}
export async function detectInstallStage(
p: WizardPrompter,
state: WizardState,
config: ConfigService,
): Promise<void> {
const existing = detectExistingInstall(state.mosaicHome);
if (!existing) {
state.installAction = 'fresh';
return;
}
const identity = detectExistingIdentity(state.mosaicHome);
const identitySummary = identity.agentName
? `Agent: ${identity.agentName}`
: 'No identity configured';
p.note(
`Found existing Mosaic installation at:\n${state.mosaicHome}\n\n` +
`${identitySummary}\n` +
`SOUL.md: ${identity.hasSoul ? 'yes' : 'no'}\n` +
`USER.md: ${identity.hasUser ? 'yes' : 'no'}\n` +
`TOOLS.md: ${identity.hasTools ? 'yes' : 'no'}`,
'Existing Installation Detected',
);
state.installAction = await p.select<InstallAction>({
message: 'What would you like to do?',
options: [
{
value: 'keep',
label: 'Keep identity, update framework',
hint: 'Preserves SOUL.md, USER.md, TOOLS.md, memory/',
},
{
value: 'reconfigure',
label: 'Reconfigure identity',
hint: 'Re-run identity setup, update framework',
},
{
value: 'reset',
label: 'Fresh install',
hint: 'Replace everything',
},
],
});
if (state.installAction === 'keep') {
state.soul = await config.readSoul();
state.user = await config.readUser();
state.tools = await config.readTools();
}
}

177
src/stages/finalize.ts Normal file
View File

@@ -0,0 +1,177 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { platform } from 'node:os';
import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js';
function linkRuntimeAssets(mosaicHome: string): void {
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
if (existsSync(script)) {
try {
spawnSync('bash', [script], { timeout: 30000, stdio: 'pipe' });
} catch {
// Non-fatal: wizard continues
}
}
}
function syncSkills(mosaicHome: string): void {
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
if (existsSync(script)) {
try {
spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
} catch {
// Non-fatal
}
}
}
interface DoctorResult {
warnings: number;
output: string;
}
function runDoctor(mosaicHome: string): DoctorResult {
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
if (!existsSync(script)) {
return { warnings: 0, output: 'mosaic-doctor not found' };
}
try {
const result = spawnSync('bash', [script], {
timeout: 30000,
encoding: 'utf-8',
stdio: 'pipe',
});
const output = result.stdout ?? '';
const warnings = (output.match(/WARN/g) ?? []).length;
return { warnings, output };
} catch {
return { warnings: 1, output: 'Doctor check failed' };
}
}
type PathAction = 'already' | 'added' | 'skipped';
function setupPath(
mosaicHome: string,
p: WizardPrompter,
): PathAction {
const binDir = join(mosaicHome, 'bin');
const currentPath = process.env.PATH ?? '';
if (currentPath.includes(binDir)) {
return 'already';
}
const profilePath = getShellProfilePath();
if (!profilePath) return 'skipped';
const isWindows = platform() === 'win32';
const exportLine = isWindows
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
// Check if already in profile
if (existsSync(profilePath)) {
const content = readFileSync(profilePath, 'utf-8');
if (content.includes(binDir)) {
return 'already';
}
}
try {
appendFileSync(profilePath, exportLine, 'utf-8');
return 'added';
} catch {
return 'skipped';
}
}
export async function finalizeStage(
p: WizardPrompter,
state: WizardState,
config: ConfigService,
): Promise<void> {
p.separator();
const spin = p.spinner();
// 1. Sync framework files (before config writes so identity files aren't overwritten)
spin.update('Syncing framework files...');
await config.syncFramework(state.installAction);
// 2. Write config files (after sync so they aren't overwritten by source templates)
if (state.installAction !== 'keep') {
spin.update('Writing configuration files...');
await config.writeSoul(state.soul);
await config.writeUser(state.user);
await config.writeTools(state.tools);
}
// 3. Link runtime assets
spin.update('Linking runtime assets...');
linkRuntimeAssets(state.mosaicHome);
// 4. Sync skills
if (state.selectedSkills.length > 0) {
spin.update('Syncing skills...');
syncSkills(state.mosaicHome);
}
// 5. Run doctor
spin.update('Running health audit...');
const doctorResult = runDoctor(state.mosaicHome);
spin.stop('Installation complete');
// 6. PATH setup
const pathAction = setupPath(state.mosaicHome, p);
// 7. Summary
const summary: string[] = [
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
`Skills: ${state.selectedSkills.length} selected`,
`Config: ${state.mosaicHome}`,
];
if (doctorResult.warnings > 0) {
summary.push(
`Health: ${doctorResult.warnings} warning(s) — run 'mosaic doctor' for details`,
);
} else {
summary.push('Health: all checks passed');
}
p.note(summary.join('\n'), 'Installation Summary');
// 8. Next steps
const nextSteps: string[] = [];
if (pathAction === 'added') {
const profilePath = getShellProfilePath();
nextSteps.push(
`Reload shell: source ${profilePath ?? '~/.profile'}`,
);
}
if (state.runtimes.detected.length === 0) {
nextSteps.push(
'Install at least one runtime (claude, codex, or opencode)',
);
}
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
nextSteps.push(
'Edit identity files directly in ~/.config/mosaic/ for fine-tuning',
);
p.note(
nextSteps.map((s, i) => `${i + 1}. ${s}`).join('\n'),
'Next Steps',
);
p.outro('Mosaic is ready.');
}

23
src/stages/mode-select.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState, WizardMode } from '../types.js';
export async function modeSelectStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
state.mode = await p.select<WizardMode>({
message: 'Installation mode',
options: [
{
value: 'quick',
label: 'Quick Start',
hint: 'Sensible defaults, minimal questions (~2 min)',
},
{
value: 'advanced',
label: 'Advanced',
hint: 'Full customization of identity, runtimes, and skills',
},
],
});
}

View File

@@ -0,0 +1,70 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState, RuntimeName } from '../types.js';
import { detectRuntime, type RuntimeInfo } from '../runtime/detector.js';
import { formatInstallInstructions } from '../runtime/installer.js';
import { configureMcpForRuntime } from '../runtime/mcp-config.js';
const RUNTIME_NAMES: RuntimeName[] = ['claude', 'codex', 'opencode'];
export async function runtimeSetupStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
p.separator();
const spin = p.spinner();
spin.update('Detecting installed runtimes...');
const runtimes: RuntimeInfo[] = RUNTIME_NAMES.map(detectRuntime);
spin.stop('Runtime detection complete');
const detected = runtimes.filter((r) => r.installed);
const notDetected = runtimes.filter((r) => !r.installed);
if (detected.length > 0) {
const summary = detected
.map(
(r) =>
` ${r.label}: ${r.version ?? 'installed'} (${r.path})`,
)
.join('\n');
p.note(summary, 'Detected Runtimes');
} else {
p.warn('No runtimes detected. Install at least one to use Mosaic.');
}
state.runtimes.detected = detected.map((r) => r.name);
// Offer installation info for missing runtimes in advanced mode
if (state.mode === 'advanced' && notDetected.length > 0) {
const showInstall = await p.confirm({
message: `${notDetected.length} runtime(s) not found. Show install instructions?`,
initialValue: false,
});
if (showInstall) {
for (const rt of notDetected) {
p.note(formatInstallInstructions(rt.name), `Install ${rt.label}`);
}
}
}
// Configure MCP sequential-thinking for detected runtimes
if (detected.length > 0) {
const spin2 = p.spinner();
spin2.update('Configuring sequential-thinking MCP...');
try {
for (const rt of detected) {
configureMcpForRuntime(rt.name);
}
spin2.stop('MCP sequential-thinking configured');
state.runtimes.mcpConfigured = true;
} catch (err) {
spin2.stop('MCP configuration failed (non-fatal)');
p.warn(
`MCP setup failed: ${err instanceof Error ? err.message : String(err)}. Run 'mosaic seq fix' later.`,
);
}
}
}

View File

@@ -0,0 +1,84 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.js';
import { loadSkillsCatalog } from '../skills/catalog.js';
import { SKILL_CATEGORIES, categorizeSkill } from '../skills/categories.js';
function truncate(str: string, max: number): string {
if (str.length <= max) return str;
return str.slice(0, max - 1) + '\u2026';
}
export async function skillsSelectStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
p.separator();
const spin = p.spinner();
spin.update('Loading skills catalog...');
const catalog = loadSkillsCatalog(state.mosaicHome);
spin.stop(`Found ${catalog.length} available skills`);
if (catalog.length === 0) {
p.warn(
"No skills found. Run 'mosaic sync' after installation to fetch skills.",
);
state.selectedSkills = [];
return;
}
if (state.mode === 'quick') {
const defaults = catalog
.filter((s) => s.recommended)
.map((s) => s.name);
state.selectedSkills = defaults;
p.note(
`Selected ${defaults.length} recommended skills.\n` +
`Run 'mosaic sync' later to browse the full catalog.`,
'Skills',
);
return;
}
// Advanced mode: categorized browsing
p.note(
'Skills give agents domain expertise for specific tasks.\n' +
'Browse by category and select the ones you want.\n' +
"You can always change this later with 'mosaic sync'.",
'Skills Selection',
);
// Build grouped options
const grouped: Record<
string,
{ value: string; label: string; hint?: string; selected?: boolean }[]
> = {};
// Initialize all categories
for (const categoryName of Object.keys(SKILL_CATEGORIES)) {
grouped[categoryName] = [];
}
for (const skill of catalog) {
const category = categorizeSkill(skill.name, skill.description);
if (!grouped[category]) grouped[category] = [];
grouped[category].push({
value: skill.name,
label: skill.name,
hint: truncate(skill.description, 60),
selected: skill.recommended,
});
}
// Remove empty categories
for (const key of Object.keys(grouped)) {
if (grouped[key].length === 0) delete grouped[key];
}
state.selectedSkills = await p.groupMultiselect({
message: 'Select skills (space to toggle)',
options: grouped,
required: false,
});
}

73
src/stages/soul-setup.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState, CommunicationStyle } from '../types.js';
import { DEFAULTS } from '../constants.js';
export async function soulSetupStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
if (state.installAction === 'keep') return;
p.separator();
p.note(
'Your agent identity defines how AI assistants behave,\n' +
'their principles, and communication style.\n' +
'This creates SOUL.md.',
'Agent Identity',
);
if (!state.soul.agentName) {
state.soul.agentName = await p.text({
message: 'What name should agents use?',
placeholder: 'e.g., Jarvis, Assistant, Mosaic',
defaultValue: DEFAULTS.agentName,
validate: (v) => {
if (v.length === 0) return 'Name cannot be empty';
if (v.length > 50) return 'Name must be under 50 characters';
},
});
}
if (state.mode === 'advanced') {
if (!state.soul.roleDescription) {
state.soul.roleDescription = await p.text({
message: 'Agent role description',
placeholder: 'e.g., execution partner and visibility engine',
defaultValue: DEFAULTS.roleDescription,
});
}
} else {
state.soul.roleDescription ??= DEFAULTS.roleDescription;
}
if (!state.soul.communicationStyle) {
state.soul.communicationStyle = await p.select<CommunicationStyle>({
message: 'Communication style',
options: [
{ value: 'direct', label: 'Direct', hint: 'Concise, no fluff, actionable' },
{ value: 'friendly', label: 'Friendly', hint: 'Warm but efficient, conversational' },
{ value: 'formal', label: 'Formal', hint: 'Professional, structured, thorough' },
],
initialValue: 'direct',
});
}
if (state.mode === 'advanced') {
if (!state.soul.accessibility) {
state.soul.accessibility = await p.text({
message: 'Accessibility preferences',
placeholder:
"e.g., ADHD-friendly chunking, dyslexia-aware formatting, or 'none'",
defaultValue: 'none',
});
}
if (!state.soul.customGuardrails) {
state.soul.customGuardrails = await p.text({
message: 'Custom guardrails (optional)',
placeholder: 'e.g., Never auto-commit to main',
defaultValue: '',
});
}
}
}

76
src/stages/tools-setup.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState, GitProvider } from '../types.js';
import { DEFAULTS } from '../constants.js';
export async function toolsSetupStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
if (state.installAction === 'keep') return;
if (state.mode === 'quick') {
state.tools.gitProviders = [];
state.tools.credentialsLocation = DEFAULTS.credentialsLocation;
state.tools.customToolsSection = DEFAULTS.customToolsSection;
return;
}
p.separator();
p.note(
'Tool configuration tells agents about your git providers,\n' +
'credential locations, and custom tools.\n' +
'This creates TOOLS.md.',
'Tool Reference',
);
const addProviders = await p.confirm({
message: 'Configure git providers?',
initialValue: false,
});
state.tools.gitProviders = [];
if (addProviders) {
let addMore = true;
while (addMore) {
const name = await p.text({
message: 'Provider name',
placeholder: 'e.g., Gitea, GitHub',
});
const url = await p.text({
message: 'Provider URL',
placeholder: 'e.g., https://github.com',
});
const cli = await p.select<string>({
message: 'CLI tool',
options: [
{ value: 'gh', label: 'gh (GitHub CLI)' },
{ value: 'tea', label: 'tea (Gitea CLI)' },
{ value: 'glab', label: 'glab (GitLab CLI)' },
],
});
const purpose = await p.text({
message: 'Purpose',
placeholder: 'e.g., Primary code hosting',
defaultValue: 'Code hosting',
});
state.tools.gitProviders.push({
name,
url,
cli,
purpose,
} satisfies GitProvider);
addMore = await p.confirm({
message: 'Add another provider?',
initialValue: false,
});
}
}
state.tools.credentialsLocation = await p.text({
message: 'Credential file path',
placeholder: "e.g., ~/.secrets/credentials.env, or 'none'",
defaultValue: DEFAULTS.credentialsLocation,
});
}

80
src/stages/user-setup.ts Normal file
View File

@@ -0,0 +1,80 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.js';
import { DEFAULTS } from '../constants.js';
import { buildCommunicationPrefs } from '../template/builders.js';
export async function userSetupStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
if (state.installAction === 'keep') return;
p.separator();
p.note(
'Your user profile helps agents understand your context,\n' +
'accessibility needs, and communication preferences.\n' +
'This creates USER.md.',
'User Profile',
);
if (!state.user.userName) {
state.user.userName = await p.text({
message: 'Your name',
placeholder: 'How agents should address you',
defaultValue: '',
});
}
if (!state.user.pronouns) {
state.user.pronouns = await p.text({
message: 'Your pronouns',
placeholder: 'e.g., He/Him, She/Her, They/Them',
defaultValue: DEFAULTS.pronouns,
});
}
// Auto-detect timezone
let detectedTz: string;
try {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
detectedTz = DEFAULTS.timezone;
}
if (!state.user.timezone) {
state.user.timezone = await p.text({
message: 'Your timezone',
placeholder: `e.g., ${detectedTz}`,
defaultValue: detectedTz,
});
}
if (state.mode === 'advanced') {
state.user.background = await p.text({
message: 'Professional background (brief)',
placeholder: 'e.g., Full-stack developer, 10 years TypeScript/React',
defaultValue: DEFAULTS.background,
});
state.user.accessibilitySection = await p.text({
message: 'Neurodivergence / accessibility accommodations',
placeholder: 'e.g., ADHD-friendly chunking, or press Enter to skip',
defaultValue: DEFAULTS.accessibilitySection,
});
state.user.personalBoundaries = await p.text({
message: 'Personal boundaries for agents',
placeholder: 'e.g., No unsolicited career advice, or press Enter to skip',
defaultValue: DEFAULTS.personalBoundaries,
});
} else {
state.user.background = DEFAULTS.background;
state.user.accessibilitySection = DEFAULTS.accessibilitySection;
state.user.personalBoundaries = DEFAULTS.personalBoundaries;
}
// Derive communication preferences from SOUL style
state.user.communicationPrefs = buildCommunicationPrefs(
state.soul.communicationStyle ?? 'direct',
);
}

18
src/stages/welcome.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.js';
import { VERSION } from '../constants.js';
export async function welcomeStage(
p: WizardPrompter,
_state: WizardState,
): Promise<void> {
p.intro(`Mosaic Installation Wizard v${VERSION}`);
p.note(
`Mosaic is an agent framework that gives AI coding assistants\n` +
`a persistent identity, shared skills, and structured workflows.\n\n` +
`It works with Claude Code, Codex, and OpenCode.\n\n` +
`All config is stored locally in ~/.config/mosaic/.\n` +
`No data is sent anywhere. No accounts required.`,
'What is Mosaic?',
);
}