/** * config.ts * * Shared config loading for Pi extensions. * * Lives here (not in src/index.ts) so extensions can import it without * creating a circular dependency — src/index.ts imports from extensions, * so extensions must not import back from src/index.ts. */ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; // ─── Type ────────────────────────────────────────────────────────────────── export interface PiscesConfig { student?: { name?: string; year_of_study?: number; timezone?: string; }; explanations?: { default_depth?: "beginner" | "intermediate" | "advanced"; prefer_visuals?: boolean; use_analogies?: boolean; }; integrity?: { enabled?: boolean; strictness?: "strict" | "balanced" | "relaxed"; }; productivity?: { burnout_nudges?: boolean; session_warning_minutes?: number; weekly_summary?: boolean; }; model?: { default?: string; quick?: string; }; workspace?: { customPaths?: string[]; }; } // ─── Helpers ────────────────────────────────────────────────────────────── // Resolves from src/extensions/lib/ (dev) and dist/extensions/lib/ (compiled), // both of which are three levels below the package root. const DEFAULTS_PATH = path.join(__dirname, "../../../config/defaults.json"); export function getConfigSearchPaths(): string[] { return [ path.join(process.cwd(), ".pisces.json"), path.join(os.homedir(), ".pi", "pisces.json"), path.join(os.homedir(), ".config", "pisces", "config.json"), ]; } export function deepMerge(base: T, override: Partial): T { const result = { ...base }; for (const key of Object.keys(override) as (keyof T)[]) { const baseVal = base[key]; const overrideVal = override[key]; if ( overrideVal !== null && typeof overrideVal === "object" && !Array.isArray(overrideVal) && typeof baseVal === "object" && baseVal !== null ) { result[key] = deepMerge( baseVal as Record, overrideVal as Record ) as T[keyof T]; } else if (overrideVal !== undefined) { result[key] = overrideVal as T[keyof T]; } } return result; } // ─── Validator ───────────────────────────────────────────────────────────── /** * Validates a merged PiscesConfig against the schema constraints. * Invalid fields are replaced by their default values. * All violations are reported together via console.warn — never throws. */ export function validateConfig(config: PiscesConfig, defaults: PiscesConfig): PiscesConfig { const issues: string[] = []; const result: PiscesConfig = {}; // null = user explicitly cleared the field; skip validation, leave as-is. function set(val: T | null | undefined): val is NonNullable { return val !== undefined && val !== null; } function bad(field: string, expected: string): void { issues.push(`${field}: expected ${expected}`); } // ── student ──────────────────────────────────────────────────────────── if (config.student !== undefined) { if (typeof config.student !== "object" || Array.isArray(config.student)) { bad("student", "an object"); result.student = defaults.student; } else { const s = { ...config.student }; if (set(s.name) && typeof s.name !== "string") { bad("student.name", "a string"); s.name = defaults.student?.name; } if (set(s.year_of_study) && (!Number.isInteger(s.year_of_study) || s.year_of_study < 1 || s.year_of_study > 8)) { bad("student.year_of_study", "an integer between 1 and 8"); s.year_of_study = defaults.student?.year_of_study; } if (set(s.timezone) && typeof s.timezone !== "string") { bad("student.timezone", "a string"); s.timezone = defaults.student?.timezone; } result.student = s; } } // ── explanations ─────────────────────────────────────────────────────── if (config.explanations !== undefined) { if (typeof config.explanations !== "object" || Array.isArray(config.explanations)) { bad("explanations", "an object"); result.explanations = defaults.explanations; } else { const e = { ...config.explanations }; if (set(e.default_depth) && !["beginner", "intermediate", "advanced"].includes(e.default_depth)) { bad('explanations.default_depth', '"beginner", "intermediate", or "advanced"'); e.default_depth = defaults.explanations?.default_depth; } if (set(e.prefer_visuals) && typeof e.prefer_visuals !== "boolean") { bad("explanations.prefer_visuals", "a boolean"); e.prefer_visuals = defaults.explanations?.prefer_visuals; } if (set(e.use_analogies) && typeof e.use_analogies !== "boolean") { bad("explanations.use_analogies", "a boolean"); e.use_analogies = defaults.explanations?.use_analogies; } result.explanations = e; } } // ── integrity ────────────────────────────────────────────────────────── if (config.integrity !== undefined) { if (typeof config.integrity !== "object" || Array.isArray(config.integrity)) { bad("integrity", "an object"); result.integrity = defaults.integrity; } else { const i = { ...config.integrity }; if (set(i.enabled) && typeof i.enabled !== "boolean") { bad("integrity.enabled", "a boolean"); i.enabled = defaults.integrity?.enabled; } if (set(i.strictness) && !["strict", "balanced", "relaxed"].includes(i.strictness)) { bad('integrity.strictness', '"strict", "balanced", or "relaxed"'); i.strictness = defaults.integrity?.strictness; } result.integrity = i; } } // ── productivity ─────────────────────────────────────────────────────── if (config.productivity !== undefined) { if (typeof config.productivity !== "object" || Array.isArray(config.productivity)) { bad("productivity", "an object"); result.productivity = defaults.productivity; } else { const p = { ...config.productivity }; if (set(p.burnout_nudges) && typeof p.burnout_nudges !== "boolean") { bad("productivity.burnout_nudges", "a boolean"); p.burnout_nudges = defaults.productivity?.burnout_nudges; } if (set(p.session_warning_minutes) && (!Number.isInteger(p.session_warning_minutes) || p.session_warning_minutes < 30)) { bad("productivity.session_warning_minutes", "an integer >= 30"); p.session_warning_minutes = defaults.productivity?.session_warning_minutes; } if (set(p.weekly_summary) && typeof p.weekly_summary !== "boolean") { bad("productivity.weekly_summary", "a boolean"); p.weekly_summary = defaults.productivity?.weekly_summary; } result.productivity = p; } } // ── model ────────────────────────────────────────────────────────────── if (config.model !== undefined) { if (typeof config.model !== "object" || Array.isArray(config.model)) { bad("model", "an object"); result.model = defaults.model; } else { const m = { ...config.model }; if (set(m.default) && typeof m.default !== "string") { bad("model.default", "a string"); m.default = defaults.model?.default; } if (set(m.quick) && typeof m.quick !== "string") { bad("model.quick", "a string"); m.quick = defaults.model?.quick; } result.model = m; } } // ── workspace ────────────────────────────────────────────────────────── if (config.workspace !== undefined) { if (typeof config.workspace !== "object" || Array.isArray(config.workspace)) { bad("workspace", "an object"); result.workspace = defaults.workspace; } else { const w = { ...config.workspace }; if (set(w.customPaths)) { if (!Array.isArray(w.customPaths) || !(w.customPaths as unknown[]).every((p) => typeof p === "string")) { bad("workspace.customPaths", "an array of strings"); w.customPaths = defaults.workspace?.customPaths; } } result.workspace = w; } } if (issues.length > 0) { console.warn( `[Pisces] Config validation: ${issues.length} issue${issues.length === 1 ? "" : "s"} found` + ` — invalid values replaced with defaults:\n` + issues.map((v) => ` • ${v}`).join("\n") ); } return result; } // ─── Loader ──────────────────────────────────────────────────────────────── /** * Loads and merges user config over package defaults. * Never throws — falls back to defaults on any read/parse error. */ export function loadConfig(): PiscesConfig { let defaults: PiscesConfig = {}; try { const raw = fs.readFileSync(DEFAULTS_PATH, "utf-8"); defaults = JSON.parse(raw) as PiscesConfig; } catch { defaults = { explanations: { default_depth: "intermediate", prefer_visuals: true, use_analogies: true }, integrity: { enabled: true, strictness: "balanced" }, productivity: { burnout_nudges: true, session_warning_minutes: 180, weekly_summary: true }, }; } for (const configPath of getConfigSearchPaths()) { try { if (fs.existsSync(configPath)) { const raw = fs.readFileSync(configPath, "utf-8"); const userConfig = JSON.parse(raw) as PiscesConfig; return validateConfig(deepMerge(defaults, userConfig), defaults); } } catch { continue; } } return defaults; }