import { type ExtensionAPI, type ExtensionContext, SettingsManager, } from "@earendil-works/pi-coding-agent"; /** * Extension-owned setting declaration. * Resolution order per key: flag, env, trusted project settings, user settings, default. * When project and user values are both plain objects, they deep-merge with project * winning per key; arrays and scalars replace. */ export interface SettingDeclaration { /** Dotted path inside Pi settings.json, for example `nushell.enable`. */ key: string; /** * Convert a raw flag, env, or settings value. * Return undefined or throw an Error to reject it; thrown messages are reported. */ parse: (raw: unknown, source: SettingSource) => T | undefined; default: T; /** Pi CLI flag. Pi flags are boolean or string; boolean flags can only express true. */ flag?: { name: string; type: "boolean" | "string"; description: string }; /** Namespaced environment variable. An empty value is present, not missing. */ env?: string; } export type SettingSource = "flag" | "env" | "project" | "user" | "default"; /** Invalid input never falls through to a lower-priority source. */ export type SettingResult = | { ok: true; value: T; source: SettingSource } | { ok: false; source: SettingSource; error: string }; const MISSING = Symbol("missing"); /** Boolean parser: booleans anywhere; `true|false|1|0` strings from flags or env only. */ export function parseBooleanSetting( raw: unknown, source: SettingSource, ): boolean | undefined { if (typeof raw === "boolean") return raw; if (source !== "flag" && source !== "env") return undefined; if (raw === "true" || raw === "1") return true; if (raw === "false" || raw === "0") return false; return undefined; } /** Largest delay Node timers and execFile timeouts accept; larger values fire after 1 ms. */ export const MAX_TIMER_MS = 2_147_483_647; /** Finite number parser: numbers anywhere; numeric strings from flags or env only. */ export function parseNumberSetting( raw: unknown, source: SettingSource, ): number | undefined { const value = typeof raw === "number" ? raw : typeof raw === "string" && (source === "flag" || source === "env") && raw.trim() !== "" ? Number(raw) : Number.NaN; return Number.isFinite(value) ? value : undefined; } /** Register the declared flag without a default, keeping omission distinct from values. */ export function registerSettingFlag( pi: Pick, declaration: SettingDeclaration, ): void { const { flag } = declaration; if (flag) pi.registerFlag(flag.name, { description: flag.description, type: flag.type, }); } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function deepMerge( base: Record, override: Record, ): Record { const merged: Record = { ...base }; for (const [key, value] of Object.entries(override)) { const current = Object.hasOwn(merged, key) ? merged[key] : undefined; // defineProperty keeps JSON keys such as __proto__ as own data properties. Object.defineProperty(merged, key, { value: isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value, enumerable: true, writable: true, configurable: true, }); } return merged; } function settingAt(settings: unknown, key: string): unknown { let value = settings; for (const part of key.split(".")) { if ( typeof value !== "object" || value === null || Array.isArray(value) || !Object.hasOwn(value, part) ) return MISSING; value = (value as Record)[part]; } return value; } /** Resolve one setting when both flags and project trust are available. */ export function resolveSetting( pi: Pick, ctx: Pick, declaration: SettingDeclaration, ): SettingResult { const { key, flag, env } = declaration; const candidates: [SettingSource, string, () => unknown][] = []; if (flag) candidates.push([ "flag", `flag --${flag.name}`, () => pi.getFlag(flag.name) ?? MISSING, ]); if (env) candidates.push([ "env", `environment variable ${env}`, () => (Object.hasOwn(process.env, env) ? process.env[env] : MISSING), ]); let settings: SettingsManager | undefined; const load = () => { settings ??= SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted(), }); return settings; }; candidates.push( [ "project", "project settings", () => settingAt(load().getProjectSettings(), key), ], ["user", "user settings", () => settingAt(load().getGlobalSettings(), key)], ); for (const [index, [source, label, read]] of candidates.entries()) { let raw = read(); if (raw === MISSING) continue; let from = label; if (source === "project") { const user = candidates[index + 1]?.[2](); if (isPlainObject(raw) && isPlainObject(user)) { raw = deepMerge(user, raw); from = "merged project and user settings"; } } let value: T | undefined; try { value = declaration.parse(raw, source); } catch (error) { if (!(error instanceof Error)) throw error; return { ok: false, source, error: `Invalid ${key} from ${from}: ${error.message}`, }; } if (value === undefined) return { ok: false, source, error: `Invalid ${key} from ${from}` }; return { ok: true, value, source }; } return { ok: true, value: declaration.default, source: "default" }; }