176 lines
5.6 KiB
TypeScript
176 lines
5.6 KiB
TypeScript
import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } 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<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> {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|