405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
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<string, HookTrigger[]>;
|
|
[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 `<EventName>/<matcher>` (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<typeof createConfigService>): 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<string, unknown>, 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<string, unknown>, 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 <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<string, unknown>);
|
|
printTable(rows);
|
|
});
|
|
|
|
// ── config get <key> ────────────────────────────────────────────────────
|
|
|
|
cmd
|
|
.command('get <key>')
|
|
.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 <key> <value> ────────────────────────────────────────────
|
|
|
|
cmd
|
|
.command('set <key> <value>')
|
|
.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 <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 <name>')
|
|
.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 <name>')
|
|
.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 <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());
|
|
}
|
|
});
|
|
}
|