import { readFileSync, existsSync, statSync, copyFileSync } from 'node:fs'; import { join } from 'node:path'; /** * Framework-contract files that `syncFramework` seeds from `framework/defaults/` * into the mosaic home root on first install. These are the only files the * wizard is allowed to touch as a one-time seed — SOUL.md and USER.md are * generated from templates by their respective wizard stages with * user-supplied values, and anything else under `defaults/` (README.md, * audit snapshots, etc.) is framework-internal and must not leak into the * user's mosaic home. * * This list must match the explicit seed loop in * packages/mosaic/framework/install.sh. */ export const DEFAULT_SEED_FILES = ['AGENTS.md', 'STANDARDS.md', 'TOOLS.md'] as const; import type { ConfigService, ConfigSection, ResolvedConfig } 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 { // Must match PRESERVE_PATHS in packages/mosaic/framework/install.sh so // the bash and TS install paths have the same upgrade-preservation // semantics. Contract files (AGENTS.md, STANDARDS.md, TOOLS.md) are // seeded from defaults/ on first install and preserved thereafter; // identity files (SOUL.md, USER.md) are generated by wizard stages and // must never be touched by the framework sync. const preservePaths = action === 'keep' || action === 'reconfigure' ? [ 'AGENTS.md', 'SOUL.md', 'USER.md', 'TOOLS.md', 'STANDARDS.md', 'memory', 'sources', 'credentials', ] : []; syncDirectory(this.sourceDir, this.mosaicHome, { preserve: preservePaths, excludeGit: true, }); // Copy framework-contract files (AGENTS.md, STANDARDS.md, TOOLS.md) // from framework/defaults/ into the mosaic home root if they don't // exist yet. These are written on first install only and are never // overwritten afterwards — the user may have customized them. // // SOUL.md and USER.md are deliberately NOT seeded here. They are // generated from templates by the soul/user wizard stages with // user-supplied values; seeding them from defaults would clobber the // identity flow and leak placeholder content into the mosaic home. const defaultsDir = join(this.sourceDir, 'defaults'); if (existsSync(defaultsDir)) { for (const entry of DEFAULT_SEED_FILES) { const src = join(defaultsDir, entry); const dest = join(this.mosaicHome, entry); if (existsSync(dest)) continue; if (!existsSync(src) || !statSync(src).isFile()) continue; copyFileSync(src, dest); } } } async readAll(): Promise { const [soul, user, tools] = await Promise.all([ this.readSoul(), this.readUser(), this.readTools(), ]); return { soul, user, tools }; } async getValue(dottedKey: string): Promise { const parts = dottedKey.split('.'); const section = parts[0] ?? ''; const field = parts.slice(1).join('.'); const config = await this.readAll(); if (!this.isValidSection(section)) return undefined; const sectionData = config[section as ConfigSection] as Record; return field ? sectionData[field] : sectionData; } async setValue(dottedKey: string, value: string): Promise { const parts = dottedKey.split('.'); const section = parts[0] ?? ''; const field = parts.slice(1).join('.'); if (!this.isValidSection(section) || !field) { throw new Error( `Invalid key "${dottedKey}". Use format
. (e.g. soul.agentName).`, ); } const previous = await this.getValue(dottedKey); if (section === 'soul') { const current = await this.readSoul(); await this.writeSoul({ ...current, [field]: value }); } else if (section === 'user') { const current = await this.readUser(); await this.writeUser({ ...current, [field]: value }); } else { const current = await this.readTools(); await this.writeTools({ ...current, [field]: value }); } return previous; } getConfigPath(section?: ConfigSection): string { if (!section) return this.mosaicHome; const fileMap: Record = { soul: join(this.mosaicHome, 'SOUL.md'), user: join(this.mosaicHome, 'USER.md'), tools: join(this.mosaicHome, 'TOOLS.md'), }; return fileMap[section]; } isInitialized(): boolean { return ( existsSync(join(this.mosaicHome, 'SOUL.md')) || existsSync(join(this.mosaicHome, 'USER.md')) || existsSync(join(this.mosaicHome, 'TOOLS.md')) ); } private isValidSection(s: string): s is ConfigSection { return s === 'soul' || s === 'user' || s === 'tools'; } /** * 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; } }