import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import type { Command } from 'commander'; import { createConfigService } from '../config/config-service.js'; import { DEFAULT_MOSAIC_HOME } from '../constants.js'; // ── Hooks management helpers ────────────────────────────────────────────────── const DISABLED_PREFIX = '_disabled_'; /** Resolve the ~/.claude directory (allow override via CLAUDE_HOME env var). */ function getClaudeHome(): string { return process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude'); } interface HookEntry { type?: string; command?: string; args?: unknown[]; [key: string]: unknown; } interface HookTrigger { matcher?: string; hooks?: HookEntry[]; } interface HooksConfig { name?: string; hooks?: Record; [key: string]: unknown; } function readInstalledHooksConfig(claudeHome: string): HooksConfig | null { const p = join(claudeHome, 'hooks-config.json'); if (!existsSync(p)) return null; try { return JSON.parse(readFileSync(p, 'utf-8')) as HooksConfig; } catch { return null; } } function writeInstalledHooksConfig(claudeHome: string, config: HooksConfig): void { const p = join(claudeHome, 'hooks-config.json'); writeFileSync(p, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); } /** * Collect a flat list of hook "names" for display purposes. * A hook name is `/` (e.g. `PostToolUse/Write|Edit`). */ function listHookNames(config: HooksConfig): Array<{ name: string; enabled: boolean }> { const results: Array<{ name: string; enabled: boolean }> = []; const events = config.hooks ?? {}; for (const [rawEvent, triggers] of Object.entries(events)) { const enabled = !rawEvent.startsWith(DISABLED_PREFIX); const event = enabled ? rawEvent : rawEvent.slice(DISABLED_PREFIX.length); for (const trigger of triggers) { const matcher = trigger.matcher ?? '(any)'; results.push({ name: `${event}/${matcher}`, enabled }); } } return results; } /** * 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 hooks ──────────────────────────────────────────────────────── const hookCmd = cmd.command('hooks').description('Manage Mosaic hooks installed in ~/.claude/'); hookCmd .command('list') .description('List installed hooks and their enabled/disabled status') .action(() => { const claudeHome = getClaudeHome(); const config = readInstalledHooksConfig(claudeHome); if (!config) { console.log( `No hooks-config.json found at ${claudeHome}.\n` + 'Run `mosaic wizard` to install hooks, or copy hooks-config.json manually.', ); return; } const entries = listHookNames(config); if (entries.length === 0) { console.log('No hooks defined in hooks-config.json.'); return; } const maxName = Math.max(...entries.map((e) => e.name.length)); const header = `${'Hook'.padEnd(maxName)} Status`; console.log(header); console.log('-'.repeat(header.length)); for (const { name, enabled } of entries) { console.log(`${name.padEnd(maxName)} ${enabled ? 'enabled' : 'disabled'}`); } }); hookCmd .command('disable ') .description('Disable a hook by name (prefix with _disabled_). Use "list" to see hook names.') .action((name: string) => { const claudeHome = getClaudeHome(); const config = readInstalledHooksConfig(claudeHome); if (!config) { console.error( `No hooks-config.json found at ${claudeHome}.\n` + 'Nothing to disable. Run `mosaic wizard` to install hooks first.', ); process.exit(1); } const events = config.hooks ?? {}; // Support matching by event key or by event/matcher composite const [targetEvent, targetMatcher] = name.split('/'); // Find the event key (may already have DISABLED_PREFIX) const existingKey = Object.keys(events).find( (k) => k === targetEvent || k === `${DISABLED_PREFIX}${targetEvent}` || k.replace(DISABLED_PREFIX, '') === targetEvent, ); if (!existingKey) { console.error(`Hook event "${targetEvent}" not found.`); console.error('Run `mosaic config hooks list` to see available hooks.'); process.exit(1); } if (existingKey.startsWith(DISABLED_PREFIX)) { console.log(`Hook "${name}" is already disabled.`); return; } const disabledKey = `${DISABLED_PREFIX}${existingKey}`; const triggers = events[existingKey]; delete events[existingKey]; // If a matcher was specified, only disable that trigger if (targetMatcher && triggers) { events[disabledKey] = triggers.filter((t) => t.matcher === targetMatcher); events[existingKey] = triggers.filter((t) => t.matcher !== targetMatcher); if ((events[existingKey] ?? []).length === 0) delete events[existingKey]; } else { events[disabledKey] = triggers ?? []; } config.hooks = events; writeInstalledHooksConfig(claudeHome, config); console.log(`Hook "${name}" disabled.`); }); hookCmd .command('enable ') .description('Re-enable a previously disabled hook.') .action((name: string) => { const claudeHome = getClaudeHome(); const config = readInstalledHooksConfig(claudeHome); if (!config) { console.error( `No hooks-config.json found at ${claudeHome}.\n` + 'Nothing to enable. Run `mosaic wizard` to install hooks first.', ); process.exit(1); } const events = config.hooks ?? {}; const targetEvent = name.split('/')[0] ?? name; const disabledKey = `${DISABLED_PREFIX}${targetEvent}`; if (!events[disabledKey]) { // Check if it's already enabled if (events[targetEvent]) { console.log(`Hook "${name}" is already enabled.`); } else { console.error(`Disabled hook "${name}" not found.`); console.error('Run `mosaic config hooks list` to see available hooks.'); process.exit(1); } return; } const triggers = events[disabledKey]; delete events[disabledKey]; events[targetEvent] = triggers ?? []; config.hooks = events; writeInstalledHooksConfig(claudeHome, config); console.log(`Hook "${name}" enabled.`); }); // ── 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()); } }); }