feat: TypeScript installation wizard with @clack/prompts TUI (#1)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-02-21 18:25:51 +00:00
committed by jason.woltje
parent e3ec3e32e5
commit 6a84f7e210
56 changed files with 20647 additions and 31 deletions

View File

@@ -0,0 +1,26 @@
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<SoulConfig>;
readUser(): Promise<UserConfig>;
readTools(): Promise<ToolsConfig>;
writeSoul(config: SoulConfig): Promise<void>;
writeUser(config: UserConfig): Promise<void>;
writeTools(config: ToolsConfig): Promise<void>;
syncFramework(action: InstallAction): Promise<void>;
}
export function createConfigService(
mosaicHome: string,
sourceDir: string,
): ConfigService {
return new FileConfigAdapter(mosaicHome, sourceDir);
}

163
src/config/file-adapter.ts Normal file
View File

@@ -0,0 +1,163 @@
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) config.agentName = nameMatch[1];
const roleMatch = content.match(/Role identity: (.+)/);
if (roleMatch) 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) config.userName = nameMatch[1];
const pronounsMatch = content.match(/\*\*Pronouns:\*\* (.+)/);
if (pronounsMatch) config.pronouns = pronounsMatch[1];
const tzMatch = content.match(/\*\*Timezone:\*\* (.+)/);
if (tzMatch) 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) 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,
});
}
/**
* 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;
}
}

51
src/config/schemas.ts Normal file
View File

@@ -0,0 +1,51 @@
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();