import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs'; import { join } from 'node:path'; 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 { const preservePaths = action === 'keep' || action === 'reconfigure' ? ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory'] : []; syncDirectory(this.sourceDir, this.mosaicHome, { preserve: preservePaths, excludeGit: true, }); // Copy default root-level .md files (AGENTS.md, STANDARDS.md, etc.) // from framework/defaults/ into mosaicHome root if they don't exist yet. // These are framework contracts — only written on first install, never // overwritten (user may have customized them). const defaultsDir = join(this.sourceDir, 'defaults'); if (existsSync(defaultsDir)) { for (const entry of readdirSync(defaultsDir)) { const dest = join(this.mosaicHome, entry); if (!existsSync(dest)) { const src = join(defaultsDir, entry); if (statSync(src).isFile()) { 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; } }