// Shared config system for SuPi extensions. // // Global config: ~/.pi/agent/supi/config.json // Project config: .pi/supi/config.json (relative to cwd) // Resolution: hardcoded defaults ← global ← project import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; const GLOBAL_CONFIG_DIR = `${CONFIG_DIR_NAME}/agent/supi`; const PROJECT_CONFIG_DIR = `${CONFIG_DIR_NAME}/supi`; const CONFIG_FILE = "config.json"; function getGlobalConfigPath(homeDir?: string): string { return path.join(homeDir ?? os.homedir(), GLOBAL_CONFIG_DIR, CONFIG_FILE); } function getProjectConfigPath(cwd: string): string { return path.join(cwd, PROJECT_CONFIG_DIR, CONFIG_FILE); } /** Return the SuPi config file path for one scope. */ export function getSupiConfigPath( scope: "global" | "project", cwd: string, options?: SupiConfigOptions, ): string { return scope === "global" ? getGlobalConfigPath(options?.homeDir) : getProjectConfigPath(cwd); } export function readJsonFile(filePath: string): Record | null { let content: string; try { content = fs.readFileSync(filePath, "utf-8"); } catch { // ENOENT or permission error — silent, file may not exist return null; } let parsed: unknown; try { parsed = JSON.parse(content); } catch { // biome-ignore lint/suspicious/noConsole: deliberate config parse warning console.warn(`[supi-core] Failed to parse config file, ignoring: ${filePath}`); return null; } if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { return parsed as Record; } // biome-ignore lint/suspicious/noConsole: deliberate config parse warning console.warn(`[supi-core] Config file root is not an object, ignoring: ${filePath}`); return null; } function shallowMerge(base: T, ...overrides: Array | null>): T { let result = { ...base }; for (const override of overrides) { if (!override) continue; result = { ...result, ...override }; } return result; } export interface SupiConfigOptions { homeDir?: string; } /** * Load and merge config for a given extension section. * * Resolution order: defaults ← global ← project */ export function loadSupiConfig( section: string, cwd: string, defaults: T, options?: SupiConfigOptions, ): T { const globalConfig = readJsonFile(getGlobalConfigPath(options?.homeDir)); const projectConfig = readJsonFile(getProjectConfigPath(cwd)); const globalSection = extractSection(globalConfig, section); const projectSection = extractSection(projectConfig, section); return shallowMerge(defaults, globalSection, projectSection); } /** * Load config for a single scope only. * * Resolution order: defaults ← selected scope * * This is useful for settings UIs that need to show the raw values stored in * one scope, rather than the effective merged config. */ export function loadSupiConfigForScope( section: string, cwd: string, defaults: T, options: { scope: "global" | "project" } & SupiConfigOptions, ): T { const config = readJsonFile(getSupiConfigPath(options.scope, cwd, { homeDir: options.homeDir })); const scopedSection = extractSection(config, section); return shallowMerge(defaults, scopedSection); } /** Load the raw object for one config section and one scope. */ export function loadSupiConfigSectionForScope( section: string, cwd: string, options: { scope: "global" | "project" } & SupiConfigOptions, ): Record | null { const config = readJsonFile(getSupiConfigPath(options.scope, cwd, { homeDir: options.homeDir })); return extractSection(config, section); } export interface SupiConfigLocation { section: string; scope: "global" | "project"; cwd: string; } /** * Write config values for a given extension section. */ export function writeSupiConfig( loc: SupiConfigLocation, value: Record, options?: SupiConfigOptions, ): void { const configPath = getSupiConfigPath(loc.scope, loc.cwd, options); const dir = path.dirname(configPath); fs.mkdirSync(dir, { recursive: true }); const existing = readJsonFile(configPath) ?? {}; existing[loc.section] = { ...((existing[loc.section] as Record) ?? {}), ...value, }; fs.writeFileSync(configPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8"); } /** * Replace one complete config section while preserving other sections. * * This is useful for nested settings that must remove stale keys as part of * one update. An empty section is removed from the config file. */ export function replaceSupiConfigSection( loc: SupiConfigLocation, value: Record, options?: SupiConfigOptions, ): void { const configPath = getSupiConfigPath(loc.scope, loc.cwd, options); const existing = readJsonFile(configPath) ?? {}; if (Object.keys(value).length > 0) existing[loc.section] = value; else delete existing[loc.section]; const content = Object.keys(existing).length > 0 ? `${JSON.stringify(existing, null, 2)}\n` : ""; if (content) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, content, "utf-8"); return; } try { fs.unlinkSync(configPath); } catch { // File may not exist. } } /** * Remove a key from a config section. * Used by `interval default` to remove the project override. */ export function removeSupiConfigKey( loc: SupiConfigLocation, key: string, options?: SupiConfigOptions, ): void { const configPath = getSupiConfigPath(loc.scope, loc.cwd, options); const existing = readJsonFile(configPath); if (!existing) return; const sectionData = existing[loc.section] as Record | undefined; if (!sectionData) return; delete sectionData[key]; if (Object.keys(sectionData).length === 0) { delete existing[loc.section]; } const dir = path.dirname(configPath); fs.mkdirSync(dir, { recursive: true }); const content = Object.keys(existing).length > 0 ? `${JSON.stringify(existing, null, 2)}\n` : ""; if (content) { // Directory guaranteed to exist since we just read from it fs.writeFileSync(configPath, content, "utf-8"); } else { try { fs.unlinkSync(configPath); } catch { // File may not exist } } } function extractSection( config: Record | null, section: string, ): Record | null { if (!config) return null; const data = config[section]; if (typeof data === "object" && data !== null && !Array.isArray(data)) { return data as Record; } return null; }