From 7b4f1d249d434116eb34677bef5011c48f4c0088 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 5 Apr 2026 05:37:05 +0000 Subject: [PATCH] feat(mosaic): top-level mosaic config command (#408) --- packages/mosaic/src/cli.ts | 5 + packages/mosaic/src/commands/config.spec.ts | 289 +++++++++++++++++++ packages/mosaic/src/commands/config.ts | 206 +++++++++++++ packages/mosaic/src/config/config-service.ts | 39 +++ packages/mosaic/src/config/file-adapter.ts | 69 ++++- 5 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 packages/mosaic/src/commands/config.spec.ts create mode 100644 packages/mosaic/src/commands/config.ts diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 3f79224..f64570a 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -6,6 +6,7 @@ import { registerBrainCommand } from '@mosaicstack/brain'; import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQueueCommand } from '@mosaicstack/queue'; import { registerAgentCommand } from './commands/agent.js'; +import { registerConfigCommand } from './commands/config.js'; import { registerMissionCommand } from './commands/mission.js'; // prdy is registered via launch.ts import { registerLaunchCommands } from './commands/launch.js'; @@ -331,6 +332,10 @@ registerGatewayCommand(program); registerAgentCommand(program); +// ─── config ──────────────────────────────────────────────────────────── + +registerConfigCommand(program); + // ─── mission ─────────────────────────────────────────────────────────── registerMissionCommand(program); diff --git a/packages/mosaic/src/commands/config.spec.ts b/packages/mosaic/src/commands/config.spec.ts new file mode 100644 index 0000000..6ba066f --- /dev/null +++ b/packages/mosaic/src/commands/config.spec.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; +import { registerConfigCommand } from './config.js'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/** Build a fresh Command tree with the config command registered. */ +function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); // prevent process.exit during tests + registerConfigCommand(program); + return program; +} + +/** Locate the 'config' command registered on the root program. */ +function getConfigCmd(program: Command): Command { + const found = program.commands.find((c) => c.name() === 'config'); + if (!found) throw new Error('config command not found'); + return found; +} + +// ── subcommand registration ─────────────────────────────────────────────────── + +describe('registerConfigCommand', () => { + it('registers a "config" command on the program', () => { + const program = buildProgram(); + const names = program.commands.map((c) => c.name()); + expect(names).toContain('config'); + }); + + it('registers exactly the five required subcommands', () => { + const program = buildProgram(); + const config = getConfigCmd(program); + const subs = config.commands.map((c) => c.name()).sort(); + expect(subs).toEqual(['edit', 'get', 'path', 'set', 'show']); + }); +}); + +// ── mock config service ─────────────────────────────────────────────────────── + +const mockSoul = { + agentName: 'TestBot', + roleDescription: 'test role', + communicationStyle: 'direct' as const, +}; +const mockUser = { userName: 'Tester', pronouns: 'they/them', timezone: 'UTC' }; +const mockTools = { credentialsLocation: '/dev/null' }; + +const mockSvc = { + readSoul: vi.fn().mockResolvedValue(mockSoul), + readUser: vi.fn().mockResolvedValue(mockUser), + readTools: vi.fn().mockResolvedValue(mockTools), + writeSoul: vi.fn().mockResolvedValue(undefined), + writeUser: vi.fn().mockResolvedValue(undefined), + writeTools: vi.fn().mockResolvedValue(undefined), + syncFramework: vi.fn().mockResolvedValue(undefined), + readAll: vi.fn().mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools }), + getValue: vi.fn().mockResolvedValue('TestBot'), + setValue: vi.fn().mockResolvedValue('OldBot'), + getConfigPath: vi + .fn() + .mockImplementation((section?: string) => + section + ? `/home/user/.config/mosaic/${section.toUpperCase()}.md` + : '/home/user/.config/mosaic', + ), + isInitialized: vi.fn().mockReturnValue(true), +}; + +// Mock the config-service module so commands use our mock. +vi.mock('../config/config-service.js', () => ({ + createConfigService: vi.fn(() => mockSvc), +})); + +// Also mock child_process for the edit command. +vi.mock('node:child_process', () => ({ + spawnSync: vi.fn().mockReturnValue({ status: 0, error: undefined }), +})); + +// ── config show ─────────────────────────────────────────────────────────────── + +describe('config show', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.clearAllMocks(); + mockSvc.isInitialized.mockReturnValue(true); + mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools }); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('calls readAll() and prints a table by default', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'show']); + expect(mockSvc.readAll).toHaveBeenCalledOnce(); + // Should have printed something + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('prints JSON when --format json is passed', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'show', '--format', 'json']); + expect(mockSvc.readAll).toHaveBeenCalledOnce(); + // Verify JSON was logged + const allOutput = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(allOutput).toContain('"agentName"'); + }); +}); + +// ── config get ──────────────────────────────────────────────────────────────── + +describe('config get', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.clearAllMocks(); + mockSvc.isInitialized.mockReturnValue(true); + mockSvc.getValue.mockResolvedValue('TestBot'); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('delegates to getValue() with the provided key', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']); + expect(mockSvc.getValue).toHaveBeenCalledWith('soul.agentName'); + }); + + it('prints the returned value', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']); + expect(consoleSpy).toHaveBeenCalledWith('TestBot'); + }); +}); + +// ── config set ──────────────────────────────────────────────────────────────── + +describe('config set', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.clearAllMocks(); + mockSvc.isInitialized.mockReturnValue(true); + mockSvc.setValue.mockResolvedValue('OldBot'); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('delegates to setValue() with key and value', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']); + expect(mockSvc.setValue).toHaveBeenCalledWith('soul.agentName', 'NewBot'); + }); + + it('prints old and new values', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']); + const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('OldBot'); + expect(output).toContain('NewBot'); + }); +}); + +// ── config path ─────────────────────────────────────────────────────────────── + +describe('config path', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.clearAllMocks(); + mockSvc.getConfigPath.mockImplementation((section?: string) => + section + ? `/home/user/.config/mosaic/${section.toUpperCase()}.md` + : '/home/user/.config/mosaic', + ); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('prints the mosaicHome directory when no section is specified', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'path']); + expect(mockSvc.getConfigPath).toHaveBeenCalledWith(); + expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic'); + }); + + it('prints the section file path when --section is given', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'path', '--section', 'soul']); + expect(mockSvc.getConfigPath).toHaveBeenCalledWith('soul'); + expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic/SOUL.md'); + }); +}); + +// ── config edit ─────────────────────────────────────────────────────────────── + +describe('config edit', () => { + let consoleSpy: ReturnType; + let spawnSyncMock: ReturnType; + + beforeEach(async () => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.clearAllMocks(); + mockSvc.isInitialized.mockReturnValue(true); + mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools }); + mockSvc.getConfigPath.mockImplementation((section?: string) => + section + ? `/home/user/.config/mosaic/${section.toUpperCase()}.md` + : '/home/user/.config/mosaic', + ); + + // Re-import to get the mock reference + const cp = await import('node:child_process'); + spawnSyncMock = cp.spawnSync as ReturnType; + spawnSyncMock.mockReturnValue({ status: 0, error: undefined }); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('calls spawnSync with the editor binary and config path', async () => { + process.env['EDITOR'] = 'nano'; + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'edit']); + expect(spawnSyncMock).toHaveBeenCalledWith( + 'nano', + ['/home/user/.config/mosaic'], + expect.objectContaining({ stdio: 'inherit' }), + ); + delete process.env['EDITOR']; + }); + + it('falls back to "vi" when EDITOR is not set', async () => { + delete process.env['EDITOR']; + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'edit']); + expect(spawnSyncMock).toHaveBeenCalledWith('vi', expect.any(Array), expect.any(Object)); + }); + + it('opens the section-specific file when --section is provided', async () => { + process.env['EDITOR'] = 'code'; + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'config', 'edit', '--section', 'soul']); + expect(spawnSyncMock).toHaveBeenCalledWith( + 'code', + ['/home/user/.config/mosaic/SOUL.md'], + expect.any(Object), + ); + delete process.env['EDITOR']; + }); +}); + +// ── not-initialized guard ──────────────────────────────────────────────────── + +describe('not-initialized guard', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.clearAllMocks(); + mockSvc.isInitialized.mockReturnValue(false); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + mockSvc.isInitialized.mockReturnValue(true); + }); + + it('prints a helpful message when config is missing (show)', async () => { + const program = buildProgram(); + // process.exit is intercepted; catch the resulting error from exitOverride + await expect(program.parseAsync(['node', 'mosaic', 'config', 'show'])).rejects.toThrow(); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('mosaic wizard')); + }); +}); diff --git a/packages/mosaic/src/commands/config.ts b/packages/mosaic/src/commands/config.ts new file mode 100644 index 0000000..d699beb --- /dev/null +++ b/packages/mosaic/src/commands/config.ts @@ -0,0 +1,206 @@ +import { spawnSync } from 'node:child_process'; +import type { Command } from 'commander'; +import { createConfigService } from '../config/config-service.js'; +import { DEFAULT_MOSAIC_HOME } from '../constants.js'; + +/** + * Resolve mosaicHome from the MOSAIC_HOME env var or the default constant. + */ +function getMosaicHome(): string { + return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME; +} + +/** + * Guard: print an error and exit(1) if config has not been initialised. + */ +function assertInitialized(svc: ReturnType): void { + if (!svc.isInitialized()) { + console.error('No config found — run `mosaic wizard` first.'); + process.exit(1); + } +} + +/** + * Flatten a nested object into dotted-key rows for table display. + */ +function flattenConfig(obj: Record, prefix = ''): Array<[string, string]> { + const rows: Array<[string, string]> = []; + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + rows.push(...flattenConfig(v as Record, key)); + } else { + rows.push([key, v === undefined || v === null ? '' : String(v)]); + } + } + return rows; +} + +/** + * Print rows as a padded ASCII table. + */ +function printTable(rows: Array<[string, string]>): void { + if (rows.length === 0) { + console.log('(no config values)'); + return; + } + const maxKey = Math.max(...rows.map(([k]) => k.length)); + const header = `${'Key'.padEnd(maxKey)} Value`; + const divider = '-'.repeat(header.length); + console.log(header); + console.log(divider); + for (const [k, v] of rows) { + console.log(`${k.padEnd(maxKey)} ${v}`); + } +} + +export function registerConfigCommand(program: Command): void { + const cmd = program + .command('config') + .description('Manage Mosaic framework configuration') + .configureHelp({ sortSubcommands: true }); + + // ── config show ───────────────────────────────────────────────────────── + + cmd + .command('show') + .description('Print the current resolved config') + .option('-f, --format ', 'Output format: table or json', 'table') + .action(async (opts: { format: string }) => { + const mosaicHome = getMosaicHome(); + const svc = createConfigService(mosaicHome, mosaicHome); + assertInitialized(svc); + + const config = await svc.readAll(); + + if (opts.format === 'json') { + console.log(JSON.stringify(config, null, 2)); + return; + } + + // Default: table + const rows = flattenConfig(config as unknown as Record); + printTable(rows); + }); + + // ── config get ──────────────────────────────────────────────────── + + cmd + .command('get ') + .description('Print a single config value (supports dotted keys, e.g. soul.agentName)') + .action(async (key: string) => { + const mosaicHome = getMosaicHome(); + const svc = createConfigService(mosaicHome, mosaicHome); + assertInitialized(svc); + + const value = await svc.getValue(key); + if (value === undefined) { + console.error(`Key "${key}" not found.`); + process.exit(1); + } + if (typeof value === 'object') { + console.log(JSON.stringify(value, null, 2)); + } else { + console.log(String(value)); + } + }); + + // ── config set ──────────────────────────────────────────── + + cmd + .command('set ') + .description( + 'Set a config value and persist (supports dotted keys, e.g. soul.agentName "Jarvis")', + ) + .action(async (key: string, value: string) => { + const mosaicHome = getMosaicHome(); + const svc = createConfigService(mosaicHome, mosaicHome); + assertInitialized(svc); + + let previous: unknown; + try { + previous = await svc.setValue(key, value); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + const prevStr = previous === undefined ? '(unset)' : String(previous); + console.log(`${key}`); + console.log(` old: ${prevStr}`); + console.log(` new: ${value}`); + }); + + // ── config edit ───────────────────────────────────────────────────────── + + cmd + .command('edit') + .description('Open the config directory in $EDITOR (or vi)') + .option('-s, --section
', 'Open a specific section file: soul | user | tools') + .action(async (opts: { section?: string }) => { + const mosaicHome = getMosaicHome(); + const svc = createConfigService(mosaicHome, mosaicHome); + assertInitialized(svc); + + const editor = process.env['EDITOR'] ?? 'vi'; + + let targetPath: string; + if (opts.section) { + const validSections = ['soul', 'user', 'tools'] as const; + if (!validSections.includes(opts.section as (typeof validSections)[number])) { + console.error(`Invalid section "${opts.section}". Choose: soul, user, tools`); + process.exit(1); + } + targetPath = svc.getConfigPath(opts.section as 'soul' | 'user' | 'tools'); + } else { + targetPath = svc.getConfigPath(); + } + + const result = spawnSync(editor, [targetPath], { stdio: 'inherit' }); + + if (result.error) { + console.error(`Failed to open editor: ${result.error.message}`); + process.exit(1); + } + + if (result.status !== 0) { + console.error(`Editor exited with code ${String(result.status ?? 1)}`); + process.exit(result.status ?? 1); + } + + // Re-read after edit and report any issues + try { + await svc.readAll(); + console.log('Config looks valid.'); + } catch (err) { + console.error('Warning: config may have validation issues:'); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + // ── config path ───────────────────────────────────────────────────────── + + cmd + .command('path') + .description('Print the active config directory path (for scripting)') + .option( + '-s, --section
', + 'Print path for a specific section file: soul | user | tools', + ) + .action(async (opts: { section?: string }) => { + const mosaicHome = getMosaicHome(); + const svc = createConfigService(mosaicHome, mosaicHome); + + if (opts.section) { + const validSections = ['soul', 'user', 'tools'] as const; + if (!validSections.includes(opts.section as (typeof validSections)[number])) { + console.error(`Invalid section "${opts.section}". Choose: soul, user, tools`); + process.exit(1); + } + console.log(svc.getConfigPath(opts.section as 'soul' | 'user' | 'tools')); + } else { + console.log(svc.getConfigPath()); + } + }); +} diff --git a/packages/mosaic/src/config/config-service.ts b/packages/mosaic/src/config/config-service.ts index 16cc80d..1ff4f7b 100644 --- a/packages/mosaic/src/config/config-service.ts +++ b/packages/mosaic/src/config/config-service.ts @@ -1,6 +1,16 @@ import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js'; import { FileConfigAdapter } from './file-adapter.js'; +/** Supported top-level config sections for dotted-key access. */ +export type ConfigSection = 'soul' | 'user' | 'tools'; + +/** A resolved view of all config sections, keyed by section name. */ +export interface ResolvedConfig { + soul: SoulConfig; + user: UserConfig; + tools: ToolsConfig; +} + /** * ConfigService interface — abstracts config read/write operations. * Currently backed by FileConfigAdapter (writes .md files from templates). @@ -16,6 +26,35 @@ export interface ConfigService { writeTools(config: ToolsConfig): Promise; syncFramework(action: InstallAction): Promise; + + /** + * Return the resolved (merged) config across all sections. + */ + readAll(): Promise; + + /** + * Read a single value by dotted key (e.g. "soul.agentName"). + * Returns undefined if the key doesn't exist. + */ + getValue(dottedKey: string): Promise; + + /** + * Set a single value by dotted key (e.g. "soul.agentName") and persist. + * Returns the previous value (or undefined). + */ + setValue(dottedKey: string, value: string): Promise; + + /** + * Return the filesystem path for a given config section file. + * When no section is provided, returns the mosaicHome directory. + */ + getConfigPath(section?: ConfigSection): string; + + /** + * Returns true if the mosaicHome directory exists and at least one + * config file (SOUL.md, USER.md, TOOLS.md) is present. + */ + isInitialized(): boolean; } export function createConfigService(mosaicHome: string, sourceDir: string): ConfigService { diff --git a/packages/mosaic/src/config/file-adapter.ts b/packages/mosaic/src/config/file-adapter.ts index f10cf40..147b73b 100644 --- a/packages/mosaic/src/config/file-adapter.ts +++ b/packages/mosaic/src/config/file-adapter.ts @@ -1,6 +1,6 @@ import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs'; import { join } from 'node:path'; -import type { ConfigService } from './config-service.js'; +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'; @@ -159,6 +159,73 @@ export class FileConfigAdapter implements ConfigService { } } + 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. */