Files
stack/packages/mosaic/src/config/file-adapter.ts
Jason Woltje c3f810bbd1
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
fix(mosaic): seed TOOLS.md from defaults on install (#458)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-12 02:02:21 +00:00

275 lines
9.2 KiB
TypeScript

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<SoulConfig> {
const path = join(this.mosaicHome, 'SOUL.md');
if (!existsSync(path)) return {};
return parseSoulFromMarkdown(readFileSync(path, 'utf-8'));
}
async readUser(): Promise<UserConfig> {
const path = join(this.mosaicHome, 'USER.md');
if (!existsSync(path)) return {};
return parseUserFromMarkdown(readFileSync(path, 'utf-8'));
}
async readTools(): Promise<ToolsConfig> {
const path = join(this.mosaicHome, 'TOOLS.md');
if (!existsSync(path)) return {};
return parseToolsFromMarkdown(readFileSync(path, 'utf-8'));
}
async writeSoul(config: SoulConfig): Promise<void> {
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<void> {
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<void> {
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<void> {
// 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<ResolvedConfig> {
const [soul, user, tools] = await Promise.all([
this.readSoul(),
this.readUser(),
this.readTools(),
]);
return { soul, user, tools };
}
async getValue(dottedKey: string): Promise<unknown> {
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<string, unknown>;
return field ? sectionData[field] : sectionData;
}
async setValue(dottedKey: string, value: string): Promise<unknown> {
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 <section>.<field> (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<ConfigSection, string> = {
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;
}
}