// 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; } /** Options for loading automatic code-intelligence exclusions. */ export interface AutomaticExclusionOptions extends SupiConfigOptions { /** Whether the project config is trusted and can apply. Defaults to true. */ projectTrusted?: boolean; } /** * Load the one configured exclusion list shared by automatic LSP work and * broad AST searches. * * Project values replace global values. An explicit empty project list clears * the global list. The old `lsp.exclude` key is rejected in every applicable * scope so automatic work cannot start without its former protection. */ export function loadAutomaticExclusionPatterns( cwd: string, options: AutomaticExclusionOptions = {}, ): readonly string[] { const projectTrusted = options.projectTrusted !== false; const globalCodeIntelligence = loadSupiConfigSectionForScope("code-intelligence", cwd, { scope: "global", homeDir: options.homeDir, }); const projectCodeIntelligence = projectTrusted ? loadSupiConfigSectionForScope("code-intelligence", cwd, { scope: "project" }) : null; const globalLsp = loadSupiConfigSectionForScope("lsp", cwd, { scope: "global", homeDir: options.homeDir, }); const projectLsp = projectTrusted ? loadSupiConfigSectionForScope("lsp", cwd, { scope: "project" }) : null; const legacyPath = findLegacyExclusionPath(cwd, options, globalLsp, projectLsp); if (legacyPath) { throw new Error( `Unsupported ${legacyPath.scope} configuration at ${legacyPath.path}: "lsp.exclude" was removed. Move its value to "code-intelligence.exclude". Automatic LSP and broad AST work did not start; reload after migration.`, ); } const source = findExclusionSource(cwd, options, { projectTrusted, globalCodeIntelligence, projectCodeIntelligence, }); return validateExclusionSource(source); } interface ExclusionSource { value: unknown; path: string; } interface LegacyExclusionPath { scope: "global" | "project"; path: string; } function findLegacyExclusionPath( cwd: string, options: AutomaticExclusionOptions, globalLsp: Record | null, projectLsp: Record | null, ): LegacyExclusionPath | null { if (globalLsp && Object.hasOwn(globalLsp, "exclude")) { return { scope: "global", path: getSupiConfigPath("global", cwd, options) }; } if (projectLsp && Object.hasOwn(projectLsp, "exclude")) { return { scope: "project", path: getSupiConfigPath("project", cwd) }; } return null; } function findExclusionSource( cwd: string, options: AutomaticExclusionOptions, sections: { projectTrusted: boolean; globalCodeIntelligence: Record | null; projectCodeIntelligence: Record | null; }, ): ExclusionSource | null { const { projectTrusted, globalCodeIntelligence, projectCodeIntelligence } = sections; if ( projectTrusted && projectCodeIntelligence && Object.hasOwn(projectCodeIntelligence, "exclude") ) { return { value: projectCodeIntelligence.exclude, path: getSupiConfigPath("project", cwd) }; } if (globalCodeIntelligence && Object.hasOwn(globalCodeIntelligence, "exclude")) { return { value: globalCodeIntelligence.exclude, path: getSupiConfigPath("global", cwd, options), }; } return null; } function validateExclusionSource(source: ExclusionSource | null): readonly string[] { if (!source) return Object.freeze([]); if (!Array.isArray(source.value) || !source.value.every((entry) => typeof entry === "string")) { throw new Error( `Invalid "code-intelligence.exclude" in ${source.path}: expected an array of gitignore-style strings. Automatic LSP and broad AST work did not start.`, ); } return Object.freeze([...source.value]); } /** * 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; }