import { existsSync } from "node:fs"; import { join } from "node:path"; import { getAgentPath, readJsonFile } from "./agent-paths"; /** * Loaded config value plus the path or `defaults` marker that supplied it. * The `config` field is always the caller's merged output type, even when no file exists. * `source` is presentation/debug metadata and is not guaranteed to be a readable file when it equals `defaults`. */ export interface LoadedScopedConfig { config: T; source: string; } /** * Resolves a config-relative path to project-local and agent-level candidates. * The result includes `.pi/` under `cwd` and the agent path from `getAgentPath`. * No filesystem checks are performed here, so callers can inspect or load candidates in their own order. */ export function resolveScopedPath(cwd: string, relativePath: string): { projectPath: string; agentPath: string } { return { projectPath: join(cwd, ".pi", relativePath), agentPath: getAgentPath(relativePath), }; } /** * Loads the project-scoped config if present, otherwise the agent-scoped config, otherwise defaults. * The returned config is produced by the supplied merge function with parsed JSON or `null`. * This helper reads from disk and intentionally does not merge project and agent files together. */ export function loadScopedConfig( cwd: string, relativePath: string, defaults: TOutput, merge: (defaults: TOutput, input: TInput | null) => TOutput, ): LoadedScopedConfig { const { projectPath, agentPath } = resolveScopedPath(cwd, relativePath); if (existsSync(projectPath)) { return { config: merge(defaults, readJsonFile(projectPath, null)), source: projectPath, }; } if (existsSync(agentPath)) { return { config: merge(defaults, readJsonFile(agentPath, null)), source: agentPath, }; } return { config: merge(defaults, null), source: "defaults", }; }