import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, parse, resolve } from "node:path"; import { parse as parseJsonc } from "jsonc-parser"; import { DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC } from "./default-pi-tools-suite-config.js"; export interface PiToolsSuiteConfig { enabled: boolean; disabledModules: string[]; todoThinking: boolean; todoThinkingOverrides: Record; /** Vision-capable model used by the coding-discipline lookup tool; unset disables lookup. */ lookupModel?: string; /** Ordered lookup fallbacks tried after lookupModel. Always present, even when empty. */ lookupFallbackModels: string[]; resourceRegistry: ResourceRegistryConfig; } export interface ResourceRegistryConfig { /** Git remote containing reusable skills/agents and project-scoped state under projects/. */ remote?: string; /** Branch used for installs, updates, and pushes. */ branch: string; /** Optional explicit project key for project-scoped registry state when Git remote discovery is unavailable or should be overridden. */ projectKey?: string; } type MutableConfig = { enabled: boolean; disabledModules: Set; todoThinking: boolean; todoThinkingOverrides: Map; lookupModel: string | undefined; lookupFallbackModels: string[]; resourceRegistry: ResourceRegistryConfig; }; const TODO_THINKING_OVERRIDE_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; export type TodoThinkingLevel = (typeof TODO_THINKING_OVERRIDE_LEVELS)[number]; type Env = Record; const TRUE_VALUES = new Set(["1", "true", "on", "yes"]); const FALSE_VALUES = new Set(["0", "false", "off", "no"]); const DISABLED_LIST_KEYS = ["disabledModules", "disabledExtensions"]; const ENABLED_LIST_KEYS = ["enabledModules", "enabledExtensions"]; const MODULE_MAP_KEYS = ["modules", "extensions"]; const DEFAULT_DISABLED_MODULES = new Set(["credential-firewall", "truncation-metadata-normalizer"]); const DEFAULT_TODO_THINKING_OVERRIDES = new Map([["zai/glm-5.3", "max"]]); const DEFAULT_RESOURCE_REGISTRY_BRANCH = "main"; export function getPiToolsSuiteUserConfigPath(homeDir = process.env.HOME?.trim() || homedir()): string { return join(homeDir, ".config", "pi", "pi-tools-suite.jsonc"); } function ensureUserConfig(filePath: string): void { if (existsSync(filePath)) return; try { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC, { encoding: "utf8", flag: "wx" }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return; // Config creation is best-effort; loading should still work in read-only homes. } } export function ensurePiToolsSuiteUserConfig(_moduleNames: readonly string[] = [], options: { homeDir?: string } = {}): string { const filePath = getPiToolsSuiteUserConfigPath(options.homeDir); ensureUserConfig(filePath); return filePath; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function normalizeLookupModel(raw: unknown): string | undefined { if (raw === null || raw === false) return undefined; if (typeof raw !== "string") return undefined; const trimmed = raw.trim(); return trimmed ? trimmed : undefined; } function normalizeModelList(raw: unknown): string[] { if (!Array.isArray(raw)) return []; return [...new Set(raw .filter((value): value is string => typeof value === "string") .map((value) => value.trim()) .filter(Boolean))]; } function normalizeNonEmptyString(raw: unknown): string | undefined { if (typeof raw !== "string") return undefined; const trimmed = raw.trim(); return trimmed ? trimmed : undefined; } function mergeResourceRegistry(config: MutableConfig, raw: unknown): void { if (!isRecord(raw)) return; if (Object.prototype.hasOwnProperty.call(raw, "remote")) { config.resourceRegistry.remote = normalizeNonEmptyString(raw.remote); } const branch = normalizeNonEmptyString(raw.branch); if (branch) config.resourceRegistry.branch = branch; if (Object.prototype.hasOwnProperty.call(raw, "projectKey")) { config.resourceRegistry.projectKey = normalizeNonEmptyString(raw.projectKey); } } function isTodoThinkingLevel(raw: unknown): raw is TodoThinkingLevel { return TODO_THINKING_OVERRIDE_LEVELS.includes(raw as TodoThinkingLevel); } function mergeTodoThinkingOverrides(config: MutableConfig, raw: unknown): void { if (!isRecord(raw)) return; for (const [rawPattern, value] of Object.entries(raw)) { const pattern = rawPattern.trim().toLowerCase(); if (!pattern) continue; if (value === null) { config.todoThinkingOverrides.delete(pattern); continue; } if (isTodoThinkingLevel(value)) config.todoThinkingOverrides.set(pattern, value); } } function boolFromEnv(value: string | undefined): boolean | undefined { if (value === undefined) return undefined; const normalized = value.trim().toLowerCase(); if (TRUE_VALUES.has(normalized)) return true; if (FALSE_VALUES.has(normalized)) return false; return undefined; } function readJsonc(filePath: string): Record { if (!existsSync(filePath)) return {}; try { const parsed = parseJsonc(readFileSync(filePath, "utf8")); return isRecord(parsed) ? parsed : {}; } catch { return {}; } } function splitNameList(value: unknown): string[] { if (typeof value === "string") { if (FALSE_VALUES.has(value.trim().toLowerCase())) return []; return value .split(/[\s,;]+/g) .map((item) => item.trim()) .filter(Boolean); } if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean); } function canonicalModuleName(name: string, knownModules: ReadonlySet): string | undefined { const normalized = name.trim().toLowerCase(); if (!normalized) return undefined; return knownModules.has(normalized) ? normalized : undefined; } function addDisabled(config: MutableConfig, value: unknown, knownModules: ReadonlySet): void { const names = splitNameList(value); if (names.some((name) => name === "*" || name.toLowerCase() === "all")) { for (const moduleName of knownModules) config.disabledModules.add(moduleName); return; } for (const name of names) { const moduleName = canonicalModuleName(name, knownModules); if (moduleName) config.disabledModules.add(moduleName); } } function removeDisabled(config: MutableConfig, value: unknown, knownModules: ReadonlySet): void { for (const name of splitNameList(value)) { const moduleName = canonicalModuleName(name, knownModules); if (moduleName) config.disabledModules.delete(moduleName); } } function mergeConfigLayer(config: MutableConfig, raw: Record, knownModules: ReadonlySet): MutableConfig { if (typeof raw.enabled === "boolean") config.enabled = raw.enabled; if (typeof raw.todoThinking === "boolean") config.todoThinking = raw.todoThinking; mergeTodoThinkingOverrides(config, raw.todoThinkingOverrides); if (Object.prototype.hasOwnProperty.call(raw, "lookupModel")) config.lookupModel = normalizeLookupModel(raw.lookupModel); if (Object.prototype.hasOwnProperty.call(raw, "lookupFallbackModels")) config.lookupFallbackModels = normalizeModelList(raw.lookupFallbackModels); mergeResourceRegistry(config, raw.resourceRegistry); for (const key of DISABLED_LIST_KEYS) addDisabled(config, raw[key], knownModules); for (const key of ENABLED_LIST_KEYS) removeDisabled(config, raw[key], knownModules); for (const key of MODULE_MAP_KEYS) { const modules = raw[key]; if (!isRecord(modules)) continue; for (const [name, value] of Object.entries(modules)) { const moduleName = canonicalModuleName(name, knownModules); if (!moduleName || typeof value !== "boolean") continue; if (value) config.disabledModules.delete(moduleName); else config.disabledModules.add(moduleName); } } return config; } function findProjectConfig(startDir: string): string | undefined { let dir = resolve(startDir); const root = parse(dir).root; while (true) { const candidate = join(dir, ".pi", "pi-tools-suite.jsonc"); if (existsSync(candidate)) return candidate; if (dir === root) return undefined; const parent = dirname(dir); if (parent === dir) return undefined; dir = parent; } } function applyEnv(config: MutableConfig, env: Env, knownModules: ReadonlySet): MutableConfig { const enabled = boolFromEnv(env.PI_TOOLS_SUITE_ENABLED); if (enabled !== undefined) config.enabled = enabled; const disabled = boolFromEnv(env.PI_TOOLS_SUITE_DISABLED); if (disabled === true) config.enabled = false; else if (disabled === false && enabled === undefined) config.enabled = true; addDisabled(config, env.PI_TOOLS_SUITE_DISABLED_MODULES, knownModules); addDisabled(config, env.PI_TOOLS_SUITE_DISABLED_EXTENSIONS, knownModules); const todoThinking = boolFromEnv(env.PI_TOOLS_SUITE_TODO_THINKING); if (todoThinking !== undefined) config.todoThinking = todoThinking; const registryRemote = normalizeNonEmptyString(env.PI_RESOURCE_REGISTRY_REMOTE); if (registryRemote) config.resourceRegistry.remote = registryRemote; const registryBranch = normalizeNonEmptyString(env.PI_RESOURCE_REGISTRY_BRANCH); if (registryBranch) config.resourceRegistry.branch = registryBranch; const registryProjectKey = normalizeNonEmptyString(env.PI_RESOURCE_REGISTRY_PROJECT_KEY); if (registryProjectKey) config.resourceRegistry.projectKey = registryProjectKey; return config; } export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options: { cwd?: string; env?: Env; homeDir?: string } = {}): PiToolsSuiteConfig { const env = options.env ?? process.env; const knownModules = new Set(moduleNames.map((name) => name.toLowerCase())); const config: MutableConfig = { enabled: true, disabledModules: new Set([...DEFAULT_DISABLED_MODULES].filter((name) => knownModules.has(name))), todoThinking: false, todoThinkingOverrides: new Map(DEFAULT_TODO_THINKING_OVERRIDES), lookupModel: undefined, lookupFallbackModels: [], resourceRegistry: { branch: DEFAULT_RESOURCE_REGISTRY_BRANCH }, }; const userConfigPath = getPiToolsSuiteUserConfigPath(options.homeDir); ensureUserConfig(userConfigPath); mergeConfigLayer(config, readJsonc(userConfigPath), knownModules); const piConfigDir = env.PI_CONFIG_DIR; if (piConfigDir) mergeConfigLayer(config, readJsonc(join(piConfigDir, "pi-tools-suite.jsonc")), knownModules); const projectConfig = findProjectConfig(options.cwd ?? process.cwd()); if (projectConfig) mergeConfigLayer(config, readJsonc(projectConfig), knownModules); applyEnv(config, env, knownModules); return { enabled: config.enabled, disabledModules: [...config.disabledModules].sort(), todoThinking: config.todoThinking, todoThinkingOverrides: Object.fromEntries(config.todoThinkingOverrides), ...(config.lookupModel ? { lookupModel: config.lookupModel } : {}), lookupFallbackModels: [...config.lookupFallbackModels], resourceRegistry: { ...config.resourceRegistry }, }; }