import os from "node:os"; import path from "node:path"; import { ensureDirectory, fileExists, readJsonFile, writeJsonFile } from "@/lib/fs"; import type { ConfigFile, LoadedConfig, TenexAgents, TenexConfig, TenexLLMs, TenexMCP, } from "@/services/config/types"; import { TenexAgentsSchema, TenexConfigSchema, TenexLLMsSchema, TenexMCPSchema, } from "@/services/config/types"; import { logger } from "@/utils/logger"; import type { z } from "zod"; /** * Centralized configuration service for TENEX * Handles loading and saving of all configuration files * Pure file operations with validation - no business logic */ export class ConfigService { private static instance: ConfigService; private cache = new Map(); private readonly CACHE_TTL_MS = 5000; // 5 seconds private readonly cacheTTL = this.CACHE_TTL_MS; // Keep for backwards compatibility private constructor() {} static getInstance(): ConfigService { if (!ConfigService.instance) { ConfigService.instance = new ConfigService(); } return ConfigService.instance; } // ===================================================================================== // PATH UTILITIES // ===================================================================================== getGlobalPath(): string { return path.join(os.homedir(), ".tenex"); } getProjectPath(projectPath: string): string { return path.join(projectPath, ".tenex"); } private getConfigFilePath(basePath: string, configFile: ConfigFile): string { return path.join(basePath, configFile); } // ===================================================================================== // COMPLETE CONFIGURATION LOADING // ===================================================================================== async loadConfig(projectPath?: string): Promise { const globalPath = this.getGlobalPath(); const projPath = projectPath ? this.getProjectPath(projectPath) : undefined; // Load global config const globalConfig = await this.loadTenexConfig(globalPath); // Load project config if provided let projectConfig: TenexConfig = {}; if (projPath) { projectConfig = await this.loadTenexConfig(projPath); } // Merge configs (project overrides global) const config: TenexConfig = { ...globalConfig, ...projectConfig, // Merge arrays properly whitelistedPubkeys: [ ...(globalConfig.whitelistedPubkeys || []), ...(projectConfig.whitelistedPubkeys || []), ], }; // Load agents (merge global and project) const globalAgents = await this.loadTenexAgents(globalPath); const projectAgents = projPath ? await this.loadTenexAgents(projPath) : {}; const agents: TenexAgents = { ...globalAgents, ...projectAgents }; // Load LLMs (merge global and project) const globalLLMs = await this.loadTenexLLMs(globalPath); const projectLLMs = projPath ? await this.loadTenexLLMs(projPath) : { configurations: {}, defaults: {}, credentials: {} }; const llms: TenexLLMs = { configurations: { ...globalLLMs.configurations, ...projectLLMs.configurations }, defaults: { ...globalLLMs.defaults, ...projectLLMs.defaults }, credentials: { ...globalLLMs.credentials, ...projectLLMs.credentials }, }; // Load MCP (merge global and project) const globalMCP = await this.loadTenexMCP(globalPath); const projectMCP = projPath ? await this.loadTenexMCP(projPath) : { servers: {}, enabled: true }; const mcp: TenexMCP = { servers: { ...globalMCP.servers, ...projectMCP.servers }, enabled: projectMCP.enabled !== undefined ? projectMCP.enabled : globalMCP.enabled, }; return { config, agents, llms, mcp }; } // ===================================================================================== // INDIVIDUAL FILE LOADING // ===================================================================================== async loadTenexConfig(basePath: string): Promise { return this.loadConfigFile( this.getConfigFilePath(basePath, "config.json"), TenexConfigSchema, {} ); } async loadTenexAgents(basePath: string): Promise { return this.loadConfigFile( this.getConfigFilePath(basePath, "agents.json"), TenexAgentsSchema, {} ); } async loadTenexLLMs(basePath: string): Promise { return this.loadConfigFile(this.getConfigFilePath(basePath, "llms.json"), TenexLLMsSchema, { configurations: {}, defaults: {}, credentials: {}, }); } async loadTenexMCP(basePath: string): Promise { const result = await this.loadConfigFile( this.getConfigFilePath(basePath, "mcp.json"), TenexMCPSchema, { servers: {}, enabled: true, } ); // Ensure servers is always defined return { servers: result.servers || {}, enabled: result.enabled ?? true, }; } // ===================================================================================== // INDIVIDUAL FILE SAVING // ===================================================================================== async saveTenexConfig(basePath: string, config: TenexConfig): Promise { await this.saveConfigFile( this.getConfigFilePath(basePath, "config.json"), config, TenexConfigSchema ); } async saveTenexAgents(basePath: string, agents: TenexAgents): Promise { await this.saveConfigFile( this.getConfigFilePath(basePath, "agents.json"), agents, TenexAgentsSchema ); } async saveTenexLLMs(basePath: string, llms: TenexLLMs): Promise { await this.saveConfigFile( this.getConfigFilePath(basePath, "llms.json"), llms, TenexLLMsSchema ); } async saveTenexMCP(basePath: string, mcp: TenexMCP): Promise { await this.saveConfigFile( this.getConfigFilePath(basePath, "mcp.json"), mcp, TenexMCPSchema ); } // ===================================================================================== // BUSINESS LOGIC METHODS // ===================================================================================== /** * Get whitelisted pubkeys with CLI override support * If CLI option is provided, it ONLY uses those pubkeys (doesn't merge with config) * Otherwise, returns pubkeys from the configuration */ getWhitelistedPubkeys(cliOption?: string, config?: TenexConfig): string[] { const pubkeys: Set = new Set(); // If CLI option is provided, ONLY use those pubkeys (don't merge with config) if (cliOption) { for (const pk of cliOption.split(",")) { const trimmed = pk.trim(); if (trimmed) pubkeys.add(trimmed); } return Array.from(pubkeys); } // Otherwise, use config pubkeys if (config?.whitelistedPubkeys) { if (Array.isArray(config.whitelistedPubkeys)) { for (const pk of config.whitelistedPubkeys) { if (pk) pubkeys.add(pk); } } } return Array.from(pubkeys); } // ===================================================================================== // CONVENIENCE METHODS // ===================================================================================== async saveGlobalConfig(config: TenexConfig): Promise { const globalPath = this.getGlobalPath(); await ensureDirectory(globalPath); await this.saveTenexConfig(globalPath, config); } async saveProjectConfig(projectPath: string, config: TenexConfig): Promise { const projPath = this.getProjectPath(projectPath); await ensureDirectory(projPath); await this.saveTenexConfig(projPath, config); } async saveGlobalAgents(agents: TenexAgents): Promise { const globalPath = this.getGlobalPath(); await ensureDirectory(globalPath); await this.saveTenexAgents(globalPath, agents); } async loadProjectAgents(projectPath: string): Promise { const projPath = this.getProjectPath(projectPath); return this.loadTenexAgents(projPath); } async saveProjectAgents(projectPath: string, agents: TenexAgents): Promise { const projPath = this.getProjectPath(projectPath); await ensureDirectory(projPath); await this.saveTenexAgents(projPath, agents); } async saveGlobalLLMs(llms: TenexLLMs): Promise { const globalPath = this.getGlobalPath(); await ensureDirectory(globalPath); await this.saveTenexLLMs(globalPath, llms); } async saveProjectLLMs(projectPath: string, llms: TenexLLMs): Promise { const projPath = this.getProjectPath(projectPath); await ensureDirectory(projPath); await this.saveTenexLLMs(projPath, llms); } async saveGlobalMCP(mcp: TenexMCP): Promise { const globalPath = this.getGlobalPath(); await ensureDirectory(globalPath); await this.saveTenexMCP(globalPath, mcp); } async saveProjectMCP(projectPath: string, mcp: TenexMCP): Promise { const projPath = this.getProjectPath(projectPath); await ensureDirectory(projPath); await this.saveTenexMCP(projPath, mcp); } // ===================================================================================== // FILE EXISTENCE CHECKS // ===================================================================================== async configExists(basePath: string, configFile: ConfigFile): Promise { return fileExists(this.getConfigFilePath(basePath, configFile)); } async globalConfigExists(configFile: ConfigFile): Promise { return this.configExists(this.getGlobalPath(), configFile); } async projectConfigExists(projectPath: string, configFile: ConfigFile): Promise { return this.configExists(this.getProjectPath(projectPath), configFile); } // ===================================================================================== // PRIVATE IMPLEMENTATION // ===================================================================================== private async loadConfigFile( filePath: string, schema: z.ZodSchema, defaultValue: T ): Promise { // Check cache first const cached = this.getFromCache(filePath); if (cached) { return cached; } try { if (!(await fileExists(filePath))) { logger.debug(`Config file not found, using default: ${filePath}`); return defaultValue; } const data = await readJsonFile(filePath); const validated = schema.parse(data); this.addToCache(filePath, validated); return validated as T; } catch (error) { logger.error(`Failed to load config file: ${filePath}`, { error }); return defaultValue; } } private async saveConfigFile( filePath: string, data: T, schema: z.ZodSchema ): Promise { try { // Ensure directory exists await ensureDirectory(path.dirname(filePath)); // Validate before saving const validated = schema.parse(data); // Save to file await writeJsonFile(filePath, validated); // Update cache this.addToCache(filePath, validated); logger.debug(`Configuration saved: ${filePath}`); } catch (error) { logger.error(`Failed to save config file: ${filePath}`, { error }); throw error; } } private getFromCache(filePath: string): T | null { const entry = this.cache.get(filePath); if (!entry) { return null; } const now = Date.now(); if (now - entry.timestamp > this.cacheTTL) { this.cache.delete(filePath); return null; } return entry.data as T; } private addToCache(filePath: string, data: T): void { this.cache.set(filePath, { data, timestamp: Date.now(), }); } clearCache(filePath?: string): void { if (filePath) { this.cache.delete(filePath); } else { this.cache.clear(); } } } // Export singleton instance export const configService = ConfigService.getInstance();