From c4e52085e3bdf3e320c9a89e5d165c4b99c31eb2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 00:59:42 +0000 Subject: [PATCH] feat(mosaic): migrate install wizard from v0 to v1 (#103) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- packages/mosaic/package.json | 15 +- packages/mosaic/src/config/config-service.ts | 23 +++ packages/mosaic/src/config/file-adapter.ts | 158 +++++++++++++++++ packages/mosaic/src/config/schemas.ts | 43 +++++ packages/mosaic/src/constants.ts | 38 ++++ packages/mosaic/src/errors.ts | 20 +++ packages/mosaic/src/index.ts | 74 +++++++- packages/mosaic/src/platform/detect.ts | 39 +++++ packages/mosaic/src/platform/file-ops.ts | 114 ++++++++++++ .../mosaic/src/prompter/clack-prompter.ts | 152 ++++++++++++++++ .../mosaic/src/prompter/headless-prompter.ts | 131 ++++++++++++++ packages/mosaic/src/prompter/interface.ts | 49 ++++++ packages/mosaic/src/runtime/detector.ts | 82 +++++++++ packages/mosaic/src/runtime/installer.ts | 12 ++ packages/mosaic/src/runtime/mcp-config.ts | 95 ++++++++++ packages/mosaic/src/skills/catalog.ts | 96 ++++++++++ packages/mosaic/src/skills/categories.ts | 143 +++++++++++++++ packages/mosaic/src/stages/detect-install.ts | 95 ++++++++++ packages/mosaic/src/stages/finalize.ts | 165 ++++++++++++++++++ packages/mosaic/src/stages/mode-select.ts | 20 +++ packages/mosaic/src/stages/runtime-setup.ts | 64 +++++++ packages/mosaic/src/stages/skills-select.ts | 77 ++++++++ packages/mosaic/src/stages/soul-setup.ts | 70 ++++++++ packages/mosaic/src/stages/tools-setup.ts | 73 ++++++++ packages/mosaic/src/stages/user-setup.ts | 77 ++++++++ packages/mosaic/src/stages/welcome.ts | 15 ++ packages/mosaic/src/template/builders.ts | 144 +++++++++++++++ packages/mosaic/src/template/engine.ts | 23 +++ packages/mosaic/src/types.ts | 53 ++++++ packages/mosaic/src/wizard.ts | 95 ++++++++++ pnpm-lock.yaml | 19 ++ 31 files changed, 2272 insertions(+), 2 deletions(-) create mode 100644 packages/mosaic/src/config/config-service.ts create mode 100644 packages/mosaic/src/config/file-adapter.ts create mode 100644 packages/mosaic/src/config/schemas.ts create mode 100644 packages/mosaic/src/constants.ts create mode 100644 packages/mosaic/src/errors.ts create mode 100644 packages/mosaic/src/platform/detect.ts create mode 100644 packages/mosaic/src/platform/file-ops.ts create mode 100644 packages/mosaic/src/prompter/clack-prompter.ts create mode 100644 packages/mosaic/src/prompter/headless-prompter.ts create mode 100644 packages/mosaic/src/prompter/interface.ts create mode 100644 packages/mosaic/src/runtime/detector.ts create mode 100644 packages/mosaic/src/runtime/installer.ts create mode 100644 packages/mosaic/src/runtime/mcp-config.ts create mode 100644 packages/mosaic/src/skills/catalog.ts create mode 100644 packages/mosaic/src/skills/categories.ts create mode 100644 packages/mosaic/src/stages/detect-install.ts create mode 100644 packages/mosaic/src/stages/finalize.ts create mode 100644 packages/mosaic/src/stages/mode-select.ts create mode 100644 packages/mosaic/src/stages/runtime-setup.ts create mode 100644 packages/mosaic/src/stages/skills-select.ts create mode 100644 packages/mosaic/src/stages/soul-setup.ts create mode 100644 packages/mosaic/src/stages/tools-setup.ts create mode 100644 packages/mosaic/src/stages/user-setup.ts create mode 100644 packages/mosaic/src/stages/welcome.ts create mode 100644 packages/mosaic/src/template/builders.ts create mode 100644 packages/mosaic/src/template/engine.ts create mode 100644 packages/mosaic/src/types.ts create mode 100644 packages/mosaic/src/wizard.ts diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 4c903f7..aebc6ee 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -1,8 +1,13 @@ { "name": "@mosaic/mosaic", - "version": "0.0.0", + "version": "0.1.0", + "description": "Mosaic installation wizard", + "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "mosaic-wizard": "dist/index.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -15,7 +20,15 @@ "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests" }, + "dependencies": { + "@clack/prompts": "^0.9.1", + "commander": "^12.1.0", + "picocolors": "^1.1.1", + "yaml": "^2.6.1", + "zod": "^3.23.8" + }, "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.8.0", "vitest": "^2.0.0" } diff --git a/packages/mosaic/src/config/config-service.ts b/packages/mosaic/src/config/config-service.ts new file mode 100644 index 0000000..16cc80d --- /dev/null +++ b/packages/mosaic/src/config/config-service.ts @@ -0,0 +1,23 @@ +import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js'; +import { FileConfigAdapter } from './file-adapter.js'; + +/** + * ConfigService interface — abstracts config read/write operations. + * Currently backed by FileConfigAdapter (writes .md files from templates). + * Designed for future swap to SqliteConfigAdapter or PostgresConfigAdapter. + */ +export interface ConfigService { + readSoul(): Promise; + readUser(): Promise; + readTools(): Promise; + + writeSoul(config: SoulConfig): Promise; + writeUser(config: UserConfig): Promise; + writeTools(config: ToolsConfig): Promise; + + syncFramework(action: InstallAction): Promise; +} + +export function createConfigService(mosaicHome: string, sourceDir: string): ConfigService { + return new FileConfigAdapter(mosaicHome, sourceDir); +} diff --git a/packages/mosaic/src/config/file-adapter.ts b/packages/mosaic/src/config/file-adapter.ts new file mode 100644 index 0000000..46d8364 --- /dev/null +++ b/packages/mosaic/src/config/file-adapter.ts @@ -0,0 +1,158 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ConfigService } from './config-service.js'; +import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js'; +import { soulSchema, userSchema, toolsSchema } from './schemas.js'; +import { renderTemplate } from '../template/engine.js'; +import { + buildSoulTemplateVars, + buildUserTemplateVars, + buildToolsTemplateVars, +} from '../template/builders.js'; +import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.js'; + +/** + * Parse a SoulConfig from an existing SOUL.md file. + */ +function parseSoulFromMarkdown(content: string): SoulConfig { + const config: SoulConfig = {}; + + const nameMatch = content.match(/You are \*\*(.+?)\*\*/); + if (nameMatch?.[1]) config.agentName = nameMatch[1]; + + const roleMatch = content.match(/Role identity: (.+)/); + if (roleMatch?.[1]) config.roleDescription = roleMatch[1]; + + if (content.includes('Be direct, concise')) { + config.communicationStyle = 'direct'; + } else if (content.includes('Be warm and conversational')) { + config.communicationStyle = 'friendly'; + } else if (content.includes('Use professional, structured')) { + config.communicationStyle = 'formal'; + } + + return config; +} + +/** + * Parse a UserConfig from an existing USER.md file. + */ +function parseUserFromMarkdown(content: string): UserConfig { + const config: UserConfig = {}; + + const nameMatch = content.match(/\*\*Name:\*\* (.+)/); + if (nameMatch?.[1]) config.userName = nameMatch[1]; + + const pronounsMatch = content.match(/\*\*Pronouns:\*\* (.+)/); + if (pronounsMatch?.[1]) config.pronouns = pronounsMatch[1]; + + const tzMatch = content.match(/\*\*Timezone:\*\* (.+)/); + if (tzMatch?.[1]) config.timezone = tzMatch[1]; + + return config; +} + +/** + * Parse a ToolsConfig from an existing TOOLS.md file. + */ +function parseToolsFromMarkdown(content: string): ToolsConfig { + const config: ToolsConfig = {}; + + const credsMatch = content.match(/\*\*Location:\*\* (.+)/); + if (credsMatch?.[1]) config.credentialsLocation = credsMatch[1]; + + return config; +} + +export class FileConfigAdapter implements ConfigService { + constructor( + private mosaicHome: string, + private sourceDir: string, + ) {} + + async readSoul(): Promise { + const path = join(this.mosaicHome, 'SOUL.md'); + if (!existsSync(path)) return {}; + return parseSoulFromMarkdown(readFileSync(path, 'utf-8')); + } + + async readUser(): Promise { + const path = join(this.mosaicHome, 'USER.md'); + if (!existsSync(path)) return {}; + return parseUserFromMarkdown(readFileSync(path, 'utf-8')); + } + + async readTools(): Promise { + const path = join(this.mosaicHome, 'TOOLS.md'); + if (!existsSync(path)) return {}; + return parseToolsFromMarkdown(readFileSync(path, 'utf-8')); + } + + async writeSoul(config: SoulConfig): Promise { + const validated = soulSchema.parse(config); + const templatePath = this.findTemplate('SOUL.md.template'); + if (!templatePath) return; + + const template = readFileSync(templatePath, 'utf-8'); + const vars = buildSoulTemplateVars(validated); + const output = renderTemplate(template, vars); + + const outPath = join(this.mosaicHome, 'SOUL.md'); + backupFile(outPath); + atomicWrite(outPath, output); + } + + async writeUser(config: UserConfig): Promise { + const validated = userSchema.parse(config); + const templatePath = this.findTemplate('USER.md.template'); + if (!templatePath) return; + + const template = readFileSync(templatePath, 'utf-8'); + const vars = buildUserTemplateVars(validated); + const output = renderTemplate(template, vars); + + const outPath = join(this.mosaicHome, 'USER.md'); + backupFile(outPath); + atomicWrite(outPath, output); + } + + async writeTools(config: ToolsConfig): Promise { + const validated = toolsSchema.parse(config); + const templatePath = this.findTemplate('TOOLS.md.template'); + if (!templatePath) return; + + const template = readFileSync(templatePath, 'utf-8'); + const vars = buildToolsTemplateVars(validated); + const output = renderTemplate(template, vars); + + const outPath = join(this.mosaicHome, 'TOOLS.md'); + backupFile(outPath); + atomicWrite(outPath, output); + } + + async syncFramework(action: InstallAction): Promise { + const preservePaths = + action === 'keep' || action === 'reconfigure' + ? ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory'] + : []; + + syncDirectory(this.sourceDir, this.mosaicHome, { + preserve: preservePaths, + excludeGit: true, + }); + } + + /** + * Look for template in source dir first, then mosaic home. + */ + private findTemplate(name: string): string | null { + const candidates = [ + join(this.sourceDir, 'templates', name), + join(this.mosaicHome, 'templates', name), + ]; + for (const path of candidates) { + if (existsSync(path)) return path; + } + return null; + } +} diff --git a/packages/mosaic/src/config/schemas.ts b/packages/mosaic/src/config/schemas.ts new file mode 100644 index 0000000..0bd13e9 --- /dev/null +++ b/packages/mosaic/src/config/schemas.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +export const communicationStyleSchema = z.enum(['direct', 'friendly', 'formal']).default('direct'); + +export const soulSchema = z + .object({ + agentName: z.string().min(1).max(50).default('Assistant'), + roleDescription: z.string().default('execution partner and visibility engine'), + communicationStyle: communicationStyleSchema, + accessibility: z.string().default('none'), + customGuardrails: z.string().default(''), + }) + .partial(); + +export const gitProviderSchema = z.object({ + name: z.string().min(1), + url: z.string().min(1), + cli: z.string().min(1), + purpose: z.string().min(1), +}); + +export const userSchema = z + .object({ + userName: z.string().default(''), + pronouns: z.string().default('They/Them'), + timezone: z.string().default('UTC'), + background: z.string().default('(not configured)'), + accessibilitySection: z + .string() + .default('(No specific accommodations configured. Edit this section to add any.)'), + communicationPrefs: z.string().default(''), + personalBoundaries: z.string().default('(Edit this section to add any personal boundaries.)'), + projectsTable: z.string().default(''), + }) + .partial(); + +export const toolsSchema = z + .object({ + gitProviders: z.array(gitProviderSchema).default([]), + credentialsLocation: z.string().default('none'), + customToolsSection: z.string().default(''), + }) + .partial(); diff --git a/packages/mosaic/src/constants.ts b/packages/mosaic/src/constants.ts new file mode 100644 index 0000000..22502cc --- /dev/null +++ b/packages/mosaic/src/constants.ts @@ -0,0 +1,38 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const VERSION = '0.1.0'; + +export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic'); + +export const DEFAULTS = { + agentName: 'Assistant', + roleDescription: 'execution partner and visibility engine', + communicationStyle: 'direct' as const, + pronouns: 'They/Them', + timezone: 'UTC', + background: '(not configured)', + accessibilitySection: '(No specific accommodations configured. Edit this section to add any.)', + personalBoundaries: '(Edit this section to add any personal boundaries.)', + projectsTable: `| Project | Stack | Registry | +|---------|-------|----------| +| (none configured) | | |`, + credentialsLocation: 'none', + customToolsSection: `## Custom Tools + +(Add any machine-specific tools, scripts, or workflows here.)`, + gitProvidersTable: `| Instance | URL | CLI | Purpose | +|----------|-----|-----|---------| +| (add your git providers here) | | | |`, +}; + +export const RECOMMENDED_SKILLS = new Set([ + 'brainstorming', + 'code-review-excellence', + 'lint', + 'systematic-debugging', + 'verification-before-completion', + 'writing-plans', + 'executing-plans', + 'architecture-patterns', +]); diff --git a/packages/mosaic/src/errors.ts b/packages/mosaic/src/errors.ts new file mode 100644 index 0000000..d73f9ee --- /dev/null +++ b/packages/mosaic/src/errors.ts @@ -0,0 +1,20 @@ +export class WizardCancelledError extends Error { + override name = 'WizardCancelledError'; + constructor() { + super('Wizard cancelled by user'); + } +} + +export class ValidationError extends Error { + override name = 'ValidationError'; + constructor(message: string) { + super(message); + } +} + +export class TemplateError extends Error { + override name = 'TemplateError'; + constructor(templatePath: string, message: string) { + super(`Template error in ${templatePath}: ${message}`); + } +} diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index 0c18d5d..17a51a3 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -1 +1,73 @@ -export const VERSION = '0.0.0'; +#!/usr/bin/env node +import { Command } from 'commander'; +import { ClackPrompter } from './prompter/clack-prompter.js'; +import { HeadlessPrompter } from './prompter/headless-prompter.js'; +import { createConfigService } from './config/config-service.js'; +import { runWizard } from './wizard.js'; +import { WizardCancelledError } from './errors.js'; +import { VERSION, DEFAULT_MOSAIC_HOME } from './constants.js'; +import type { CommunicationStyle } from './types.js'; + +export { VERSION }; + +const program = new Command() + .name('mosaic-wizard') + .description('Mosaic Installation Wizard') + .version(VERSION); + +program + .option('--non-interactive', 'Run without prompts (uses defaults + flags)') + .option('--source-dir ', 'Source directory for framework files') + .option('--mosaic-home ', 'Target config directory', DEFAULT_MOSAIC_HOME) + // SOUL.md overrides + .option('--name ', 'Agent name') + .option('--role ', 'Agent role description') + .option('--style