import fs from 'fs'; import path from 'path'; import chalk from 'chalk'; import { fileURLToPath } from 'url'; import { log, resolveEnvVariable, findProjectRoot } from './utils.js'; // Calculate __dirname in ESM const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Load supported models from JSON file using the calculated __dirname let MODEL_MAP; try { const supportedModelsRaw = fs.readFileSync( path.join(__dirname, 'supported-models.json'), 'utf-8' ); MODEL_MAP = JSON.parse(supportedModelsRaw); } catch (error) { console.error( chalk.red( 'FATAL ERROR: Could not load supported-models.json. Please ensure the file exists and is valid JSON.' ), error ); MODEL_MAP = {}; // Default to empty map on error to avoid crashing, though functionality will be limited process.exit(1); // Exit if models can't be loaded } const CONFIG_FILE_NAME = '.taskmasterconfig'; // Define valid providers dynamically from the loaded MODEL_MAP const VALID_PROVIDERS = Object.keys(MODEL_MAP); // Default configuration values (used if .taskmasterconfig is missing or incomplete) const DEFAULTS = { models: { main: { provider: 'anthropic', modelId: 'claude-3-7-sonnet-20250219', maxTokens: 64000, temperature: 0.2 }, research: { provider: 'perplexity', modelId: 'sonar-pro', maxTokens: 8700, temperature: 0.1 }, fallback: { // No default fallback provider/model initially provider: 'anthropic', modelId: 'claude-3-5-sonnet', maxTokens: 64000, // Default parameters if fallback IS configured temperature: 0.2 } }, global: { logLevel: 'info', debug: false, defaultSubtasks: 5, defaultPriority: 'medium', projectName: 'Task Master', ollamaBaseUrl: 'http://localhost:11434/api' } }; // --- Internal Config Loading --- let loadedConfig = null; let loadedConfigRoot = null; // Track which root loaded the config // Custom Error for configuration issues class ConfigurationError extends Error { constructor(message) { super(message); this.name = 'ConfigurationError'; } } function _loadAndValidateConfig(explicitRoot = null) { const defaults = DEFAULTS; // Use the defined defaults // If no explicit root is provided (e.g., during initial server load), // return defaults immediately and silently. if (!explicitRoot) { return defaults; } // --- Proceed with loading from the provided explicitRoot --- const configPath = path.join(explicitRoot, CONFIG_FILE_NAME); let config = { ...defaults }; // Start with a deep copy of defaults let configExists = false; if (fs.existsSync(configPath)) { configExists = true; try { const rawData = fs.readFileSync(configPath, 'utf-8'); const parsedConfig = JSON.parse(rawData); // Deep merge parsed config onto defaults config = { models: { main: { ...defaults.models.main, ...parsedConfig?.models?.main }, research: { ...defaults.models.research, ...parsedConfig?.models?.research }, fallback: parsedConfig?.models?.fallback?.provider && parsedConfig?.models?.fallback?.modelId ? { ...defaults.models.fallback, ...parsedConfig.models.fallback } : { ...defaults.models.fallback } }, global: { ...defaults.global, ...parsedConfig?.global } }; // --- Validation (Warn if file content is invalid) --- // Only use console.warn here, as this part runs only when an explicitRoot *is* provided if (!validateProvider(config.models.main.provider)) { console.warn( chalk.yellow( `Warning: Invalid main provider "${config.models.main.provider}" in ${configPath}. Falling back to default.` ) ); config.models.main = { ...defaults.models.main }; } if (!validateProvider(config.models.research.provider)) { console.warn( chalk.yellow( `Warning: Invalid research provider "${config.models.research.provider}" in ${configPath}. Falling back to default.` ) ); config.models.research = { ...defaults.models.research }; } if ( config.models.fallback?.provider && !validateProvider(config.models.fallback.provider) ) { console.warn( chalk.yellow( `Warning: Invalid fallback provider "${config.models.fallback.provider}" in ${configPath}. Fallback model configuration will be ignored.` ) ); config.models.fallback.provider = undefined; config.models.fallback.modelId = undefined; } } catch (error) { // Use console.error for actual errors during parsing console.error( chalk.red( `Error reading or parsing ${configPath}: ${error.message}. Using default configuration.` ) ); config = { ...defaults }; // Reset to defaults on parse error } } else { // Config file doesn't exist at the provided explicitRoot. // Use console.warn because an explicit root *was* given. console.warn( chalk.yellow( `Warning: ${CONFIG_FILE_NAME} not found at provided project root (${explicitRoot}). Using default configuration. Run 'task-master models --setup' to configure.` ) ); // Keep config as defaults config = { ...defaults }; } return config; } /** * Gets the current configuration, loading it if necessary. * Handles MCP initialization context gracefully. * @param {string|null} explicitRoot - Optional explicit path to the project root. * @param {boolean} forceReload - Force reloading the config file. * @returns {object} The loaded configuration object. */ function getConfig(explicitRoot = null, forceReload = false) { // Determine if a reload is necessary const needsLoad = !loadedConfig || forceReload || (explicitRoot && explicitRoot !== loadedConfigRoot); if (needsLoad) { const newConfig = _loadAndValidateConfig(explicitRoot); // _load handles null explicitRoot // Only update the global cache if loading was forced or if an explicit root // was provided (meaning we attempted to load a specific project's config). // We avoid caching the initial default load triggered without an explicitRoot. if (forceReload || explicitRoot) { loadedConfig = newConfig; loadedConfigRoot = explicitRoot; // Store the root used for this loaded config } return newConfig; // Return the newly loaded/default config } // If no load was needed, return the cached config return loadedConfig; } /** * Validates if a provider name is in the list of supported providers. * @param {string} providerName The name of the provider. * @returns {boolean} True if the provider is valid, false otherwise. */ function validateProvider(providerName) { return VALID_PROVIDERS.includes(providerName); } /** * Optional: Validates if a modelId is known for a given provider based on MODEL_MAP. * This is a non-strict validation; an unknown model might still be valid. * @param {string} providerName The name of the provider. * @param {string} modelId The model ID. * @returns {boolean} True if the modelId is in the map for the provider, false otherwise. */ function validateProviderModelCombination(providerName, modelId) { // If provider isn't even in our map, we can't validate the model if (!MODEL_MAP[providerName]) { return true; // Allow unknown providers or those without specific model lists } // If the provider is known, check if the model is in its list OR if the list is empty (meaning accept any) return ( MODEL_MAP[providerName].length === 0 || // Use .some() to check the 'id' property of objects in the array MODEL_MAP[providerName].some((modelObj) => modelObj.id === modelId) ); } // --- Role-Specific Getters --- function getModelConfigForRole(role, explicitRoot = null) { const config = getConfig(explicitRoot); const roleConfig = config?.models?.[role]; if (!roleConfig) { // This shouldn't happen if _loadAndValidateConfig ensures defaults // But as a safety net, log and return defaults log( 'warn', `No model configuration found for role: ${role}. Returning default.` ); return DEFAULTS.models[role] || {}; } return roleConfig; } function getMainProvider(explicitRoot = null) { return getModelConfigForRole('main', explicitRoot).provider; } function getMainModelId(explicitRoot = null) { return getModelConfigForRole('main', explicitRoot).modelId; } function getMainMaxTokens(explicitRoot = null) { // Directly return value from config (which includes defaults) return getModelConfigForRole('main', explicitRoot).maxTokens; } function getMainTemperature(explicitRoot = null) { // Directly return value from config return getModelConfigForRole('main', explicitRoot).temperature; } function getResearchProvider(explicitRoot = null) { return getModelConfigForRole('research', explicitRoot).provider; } function getResearchModelId(explicitRoot = null) { return getModelConfigForRole('research', explicitRoot).modelId; } function getResearchMaxTokens(explicitRoot = null) { // Directly return value from config return getModelConfigForRole('research', explicitRoot).maxTokens; } function getResearchTemperature(explicitRoot = null) { // Directly return value from config return getModelConfigForRole('research', explicitRoot).temperature; } function getFallbackProvider(explicitRoot = null) { // Directly return value from config (will be undefined if not set) return getModelConfigForRole('fallback', explicitRoot).provider; } function getFallbackModelId(explicitRoot = null) { // Directly return value from config return getModelConfigForRole('fallback', explicitRoot).modelId; } function getFallbackMaxTokens(explicitRoot = null) { // Directly return value from config return getModelConfigForRole('fallback', explicitRoot).maxTokens; } function getFallbackTemperature(explicitRoot = null) { // Directly return value from config return getModelConfigForRole('fallback', explicitRoot).temperature; } // --- Global Settings Getters --- function getGlobalConfig(explicitRoot = null) { const config = getConfig(explicitRoot); // Ensure global defaults are applied if global section is missing return { ...DEFAULTS.global, ...(config?.global || {}) }; } function getLogLevel(explicitRoot = null) { // Directly return value from config return getGlobalConfig(explicitRoot).logLevel.toLowerCase(); } function getDebugFlag(explicitRoot = null) { // Directly return value from config, ensure boolean return getGlobalConfig(explicitRoot).debug === true; } function getDefaultSubtasks(explicitRoot = null) { // Directly return value from config, ensure integer const val = getGlobalConfig(explicitRoot).defaultSubtasks; const parsedVal = parseInt(val, 10); return isNaN(parsedVal) ? DEFAULTS.global.defaultSubtasks : parsedVal; } function getDefaultPriority(explicitRoot = null) { // Directly return value from config return getGlobalConfig(explicitRoot).defaultPriority; } function getProjectName(explicitRoot = null) { // Directly return value from config return getGlobalConfig(explicitRoot).projectName; } function getOllamaBaseUrl(explicitRoot = null) { // Directly return value from config return getGlobalConfig(explicitRoot).ollamaBaseUrl; } /** * Gets model parameters (maxTokens, temperature) for a specific role. * @param {string} role - The role ('main', 'research', 'fallback'). * @param {string|null} explicitRoot - Optional explicit path to the project root. * @returns {{maxTokens: number, temperature: number}} */ function getParametersForRole(role, explicitRoot = null) { const roleConfig = getModelConfigForRole(role, explicitRoot); return { maxTokens: roleConfig.maxTokens, temperature: roleConfig.temperature }; } /** * Checks if the API key for a given provider is set in the environment. * Checks process.env first, then session.env if session is provided. * @param {string} providerName - The name of the provider (e.g., 'openai', 'anthropic'). * @param {object|null} [session=null] - The MCP session object (optional). * @returns {boolean} True if the API key is set, false otherwise. */ function isApiKeySet(providerName, session = null) { // Define the expected environment variable name for each provider const keyMap = { openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', google: 'GOOGLE_API_KEY', perplexity: 'PERPLEXITY_API_KEY', mistral: 'MISTRAL_API_KEY', azure: 'AZURE_OPENAI_API_KEY', // Azure needs endpoint too, but key presence is a start openrouter: 'OPENROUTER_API_KEY', xai: 'XAI_API_KEY', ollama: 'OLLAMA_API_KEY' // Add other providers as needed }; const providerKey = providerName?.toLowerCase(); if (!providerKey || !keyMap[providerKey]) { log('warn', `Unknown provider name: ${providerName} in isApiKeySet check.`); return false; } const envVarName = keyMap[providerKey]; // Use resolveEnvVariable to check both process.env and session.env return !!resolveEnvVariable(envVarName, session); } /** * Checks the API key status within .cursor/mcp.json for a given provider. * Reads the mcp.json file, finds the taskmaster-ai server config, and checks the relevant env var. * @param {string} providerName The name of the provider. * @returns {boolean} True if the key exists and is not a placeholder, false otherwise. */ function getMcpApiKeyStatus(providerName) { const rootDir = findProjectRoot(); // Use existing root finding if (!rootDir) { console.warn( chalk.yellow('Warning: Could not find project root to check mcp.json.') ); return false; // Cannot check without root } const mcpConfigPath = path.join(rootDir, '.cursor', 'mcp.json'); if (!fs.existsSync(mcpConfigPath)) { // console.warn(chalk.yellow('Warning: .cursor/mcp.json not found.')); return false; // File doesn't exist } try { const mcpConfigRaw = fs.readFileSync(mcpConfigPath, 'utf-8'); const mcpConfig = JSON.parse(mcpConfigRaw); const mcpEnv = mcpConfig?.mcpServers?.['taskmaster-ai']?.env; if (!mcpEnv) { // console.warn(chalk.yellow('Warning: Could not find taskmaster-ai env in mcp.json.')); return false; // Structure missing } let apiKeyToCheck = null; let placeholderValue = null; switch (providerName) { case 'anthropic': apiKeyToCheck = mcpEnv.ANTHROPIC_API_KEY; placeholderValue = 'YOUR_ANTHROPIC_API_KEY_HERE'; break; case 'openai': apiKeyToCheck = mcpEnv.OPENAI_API_KEY; placeholderValue = 'YOUR_OPENAI_API_KEY_HERE'; // Assuming placeholder matches OPENAI break; case 'openrouter': apiKeyToCheck = mcpEnv.OPENROUTER_API_KEY; placeholderValue = 'YOUR_OPENROUTER_API_KEY_HERE'; break; case 'google': apiKeyToCheck = mcpEnv.GOOGLE_API_KEY; placeholderValue = 'YOUR_GOOGLE_API_KEY_HERE'; break; case 'perplexity': apiKeyToCheck = mcpEnv.PERPLEXITY_API_KEY; placeholderValue = 'YOUR_PERPLEXITY_API_KEY_HERE'; break; case 'xai': apiKeyToCheck = mcpEnv.XAI_API_KEY; placeholderValue = 'YOUR_XAI_API_KEY_HERE'; break; case 'ollama': return true; // No key needed case 'mistral': apiKeyToCheck = mcpEnv.MISTRAL_API_KEY; placeholderValue = 'YOUR_MISTRAL_API_KEY_HERE'; break; case 'azure': apiKeyToCheck = mcpEnv.AZURE_OPENAI_API_KEY; placeholderValue = 'YOUR_AZURE_OPENAI_API_KEY_HERE'; default: return false; // Unknown provider } return !!apiKeyToCheck && apiKeyToCheck !== placeholderValue; } catch (error) { console.error( chalk.red(`Error reading or parsing .cursor/mcp.json: ${error.message}`) ); return false; } } /** * Gets a list of available models based on the MODEL_MAP. * @returns {Array<{id: string, name: string, provider: string, swe_score: number|null, cost_per_1m_tokens: {input: number|null, output: number|null}|null, allowed_roles: string[]}>} */ function getAvailableModels() { const available = []; for (const [provider, models] of Object.entries(MODEL_MAP)) { if (models.length > 0) { models.forEach((modelObj) => { // Basic name generation - can be improved const modelId = modelObj.id; const sweScore = modelObj.swe_score; const cost = modelObj.cost_per_1m_tokens; const allowedRoles = modelObj.allowed_roles || ['main', 'fallback']; const nameParts = modelId .split('-') .map((p) => p.charAt(0).toUpperCase() + p.slice(1)); // Handle specific known names better if needed let name = nameParts.join(' '); if (modelId === 'claude-3.5-sonnet-20240620') name = 'Claude 3.5 Sonnet'; if (modelId === 'claude-3-7-sonnet-20250219') name = 'Claude 3.7 Sonnet'; if (modelId === 'gpt-4o') name = 'GPT-4o'; if (modelId === 'gpt-4-turbo') name = 'GPT-4 Turbo'; if (modelId === 'sonar-pro') name = 'Perplexity Sonar Pro'; if (modelId === 'sonar-mini') name = 'Perplexity Sonar Mini'; available.push({ id: modelId, name: name, provider: provider, swe_score: sweScore, cost_per_1m_tokens: cost, allowed_roles: allowedRoles }); }); } else { // For providers with empty lists (like ollama), maybe add a placeholder or skip available.push({ id: `[${provider}-any]`, name: `Any (${provider})`, provider: provider }); } } return available; } /** * Writes the configuration object to the file. * @param {Object} config The configuration object to write. * @param {string|null} explicitRoot - Optional explicit path to the project root. * @returns {boolean} True if successful, false otherwise. */ function writeConfig(config, explicitRoot = null) { const rootPath = explicitRoot || findProjectRoot(); if (!rootPath) { console.error( chalk.red( 'Error: Could not determine project root. Configuration not saved.' ) ); return false; } const configPath = path.basename(rootPath) === CONFIG_FILE_NAME ? rootPath : path.join(rootPath, CONFIG_FILE_NAME); try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); loadedConfig = config; // Update the cache after successful write return true; } catch (error) { console.error( chalk.red( `Error writing configuration to ${configPath}: ${error.message}` ) ); return false; } } /** * Checks if the .taskmasterconfig file exists at the project root * @param {string|null} explicitRoot - Optional explicit path to the project root * @returns {boolean} True if the file exists, false otherwise */ function isConfigFilePresent(explicitRoot = null) { const rootPath = explicitRoot || findProjectRoot(); if (!rootPath) { return false; } const configPath = path.join(rootPath, CONFIG_FILE_NAME); return fs.existsSync(configPath); } export { // Core config access getConfig, writeConfig, ConfigurationError, // Export custom error type isConfigFilePresent, // Add the new function export // Validation validateProvider, validateProviderModelCombination, VALID_PROVIDERS, MODEL_MAP, getAvailableModels, // Role-specific getters (No env var overrides) getMainProvider, getMainModelId, getMainMaxTokens, getMainTemperature, getResearchProvider, getResearchModelId, getResearchMaxTokens, getResearchTemperature, getFallbackProvider, getFallbackModelId, getFallbackMaxTokens, getFallbackTemperature, // Global setting getters (No env var overrides) getLogLevel, getDebugFlag, getDefaultSubtasks, getDefaultPriority, getProjectName, getOllamaBaseUrl, getParametersForRole, // API Key Checkers (still relevant) isApiKeySet, getMcpApiKeyStatus };