/** * Configuration loading for matrix-welcome. * * Users must never have to edit the installed source: `omp plugin install` * replaces the package tree on every upgrade. Settings therefore live in a JSON * file outside the package. * * Search order, first existing file wins: * 1. /.omp/matrix-welcome.json (project) * 2. /matrix-welcome.json (user) * * The agent directory is `$PI_CODING_AGENT_DIR` when set, otherwise * `~/.omp/agent`, matching how omp resolves its own state directory. */ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; export type AnimationCharset = "katakana" | "ascii"; export interface WelcomeConfig { /** Seconds before the screen dismisses itself. 0 = wait for a keypress. */ countdown: number; /** Milliseconds between animation frames. Lower = faster rain. */ frameDelayMs: number; /** Rows the screen occupies. "full" uses the whole terminal height. */ height: "full" | number; /** Rain glyphs. "katakana" is classic, "ascii" is safest on odd terminals. */ charset: AnimationCharset; /** How many recent sessions to list. */ maxSessions: number; /** Chance per frame that a banner cell flickers into a rain glyph. */ bannerFlicker: number; /** Rain density: fraction of columns carrying a drop at any time. */ density: number; /** Text rendered in the block banner. Unsupported characters are skipped. */ bannerText: string; } export const DEFAULT_CONFIG: WelcomeConfig = { countdown: 8, frameDelayMs: 70, height: "full", charset: "katakana", maxSessions: 4, bannerFlicker: 0.012, density: 0.9, bannerText: "OH-MY-PI", }; export const CONFIG_FILENAME = "matrix-welcome.json"; export interface LoadedConfig { config: WelcomeConfig; /** Absolute path the config came from, or undefined when using defaults. */ source?: string; /** Human-readable problems. Every entry means one field fell back. */ warnings: string[]; } export function getConfigCandidates(cwd: string): string[] { const agentDir = process.env.PI_CODING_AGENT_DIR?.trim() || join(process.env.HOME ?? homedir(), ".omp", "agent"); return [ join(cwd, ".omp", CONFIG_FILENAME), join(agentDir, CONFIG_FILENAME), ]; } /** Reads one key without asserting a shape the compiler never checked. */ function readEntry(source: object, key: string): unknown { return key in source ? Reflect.get(source, key) : undefined; } function readNumber( source: object, key: string, min: number, max: number, warnings: string[], ): number | undefined { const value = readEntry(source, key); if (value === undefined) return undefined; if (typeof value !== "number" || !Number.isFinite(value)) { warnings.push(`${key}: expected a number, got ${JSON.stringify(value)}`); return undefined; } if (value < min || value > max) { warnings.push(`${key}: ${value} is outside ${min}..${max}`); return undefined; } return value; } export function applyOverrides( source: object, warnings: string[], ): WelcomeConfig { const config: WelcomeConfig = { ...DEFAULT_CONFIG }; const countdown = readNumber(source, "countdown", 0, 600, warnings); if (countdown !== undefined) config.countdown = Math.round(countdown); const frameDelayMs = readNumber(source, "frameDelayMs", 16, 1000, warnings); if (frameDelayMs !== undefined) config.frameDelayMs = Math.round(frameDelayMs); const maxSessions = readNumber(source, "maxSessions", 0, 20, warnings); if (maxSessions !== undefined) config.maxSessions = Math.round(maxSessions); const bannerFlicker = readNumber(source, "bannerFlicker", 0, 1, warnings); if (bannerFlicker !== undefined) config.bannerFlicker = bannerFlicker; const density = readNumber(source, "density", 0, 1, warnings); if (density !== undefined) config.density = density; const height = readEntry(source, "height"); if (height !== undefined) { if (height === "full") { config.height = "full"; } else if (typeof height === "number" && Number.isFinite(height) && height >= 8) { config.height = Math.round(height); } else { warnings.push(`height: expected "full" or a number >= 8, got ${JSON.stringify(height)}`); } } const charset = readEntry(source, "charset"); if (charset !== undefined) { if (charset === "katakana" || charset === "ascii") { config.charset = charset; } else { warnings.push(`charset: expected "katakana" or "ascii", got ${JSON.stringify(charset)}`); } } const bannerText = readEntry(source, "bannerText"); if (bannerText !== undefined) { if (typeof bannerText === "string" && bannerText.length > 0 && bannerText.length <= 16) { config.bannerText = bannerText; } else { warnings.push(`bannerText: expected a string of 1..16 characters, got ${JSON.stringify(bannerText)}`); } } return config; } /** * Never throws and never returns a partial config: on any failure the defaults * stand and the reason is reported in `warnings`. */ export function loadConfig(cwd: string = process.cwd()): LoadedConfig { const warnings: string[] = []; for (const path of getConfigCandidates(cwd)) { if (!existsSync(path)) continue; let text: string; try { text = readFileSync(path, "utf8"); } catch (error) { warnings.push(`${path}: unreadable (${String(error)})`); continue; } let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { warnings.push(`${path}: invalid JSON (${String(error)})`); continue; } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { warnings.push(`${path}: expected a JSON object at the top level`); continue; } return { config: applyOverrides(parsed, warnings), source: path, warnings }; } return { config: { ...DEFAULT_CONFIG }, warnings }; }