UNPKG

@meldscience/meld

Version:

pipeable one-shot prompt scripting toolkit

202 lines (175 loc) 6.18 kB
import { readFileSync, writeFileSync, existsSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; import { ToolError } from './errors'; import { execSync } from 'child_process'; export interface ToolConfig { defaultModel?: string; defaultOneshotModel?: string; defaultOneshotcatModel?: string; anthropicApiKey?: string; openaiApiKey?: string; modelAliases?: { [key: string]: string }; } export const RC_FILE = '.meldrc'; function resolveSecretReference(value: string): string { if (!value.startsWith('op://')) { return value; } try { return execSync(`op read "${value}"`, { encoding: 'utf-8' }).trim(); } catch (error: any) { throw new ToolError( `Failed to resolve 1Password secret reference: ${error.message}`, 'SECRET_REFERENCE_ERROR' ); } } export function loadConfig(options?: Partial<ToolConfig>): ToolConfig { // Start with empty config const config: ToolConfig = {}; // Try to load .meldrc from home directory (lowest priority) try { const rcPath = join(homedir(), RC_FILE); if (existsSync(rcPath)) { const rcContent = readFileSync(rcPath, 'utf-8'); const rcConfig = JSON.parse(rcContent); // Resolve any secret references in the config if (rcConfig.anthropicApiKey) { rcConfig.anthropicApiKey = resolveSecretReference(rcConfig.anthropicApiKey); } if (rcConfig.openaiApiKey) { rcConfig.openaiApiKey = resolveSecretReference(rcConfig.openaiApiKey); } Object.assign(config, rcConfig); } } catch (error) { if (error instanceof ToolError) { throw error; // Re-throw our own errors } // Ignore other errors reading config file - it's optional } // Load from environment variables (middle priority) if (process.env.DEFAULT_MODEL) { config.defaultModel = process.env.DEFAULT_MODEL; } if (process.env.ANTHROPIC_API_KEY) { config.anthropicApiKey = process.env.ANTHROPIC_API_KEY; } if (process.env.OPENAI_API_KEY) { config.openaiApiKey = process.env.OPENAI_API_KEY; } // Override with any passed options (highest priority) if (options) { // Only override values that are explicitly set in options Object.keys(options).forEach(key => { const value = options[key as keyof ToolConfig]; if (value !== undefined) { (config as any)[key] = value; } }); } return config; } export function setupConfig(options: { anthropicApiKey?: string; openaiApiKey?: string; defaultModel?: string; defaultOneshotModel?: string; defaultOneshotcatModel?: string; modelAliases?: { [key: string]: string }; }): void { // Don't create config if no options provided if (Object.keys(options).length === 0) { return; } const rcPath = join(homedir(), RC_FILE); let config: ToolConfig = {}; // Read existing config if it exists if (existsSync(rcPath)) { try { const rcContent = readFileSync(rcPath, 'utf-8'); config = JSON.parse(rcContent); } catch (error) { // If file exists but is invalid, we'll overwrite it console.warn('⚠️ Existing config file is invalid, creating new one'); } } // Set default model aliases if none exist if (!config.modelAliases) { config.modelAliases = { 'claude': 'claude-3-5-sonnet-latest', 'sonnet': 'claude-3-5-sonnet-latest', 'opus': 'claude-3-opus-latest', 'haiku': 'claude-3-5-haiku-latest', '4o': 'chatgpt-4o-latest', 'o1': 'o1', 'o1-mini': 'o1-mini' }; } // Set default models if not set if (!config.defaultOneshotModel) { config.defaultOneshotModel = 'claude-3-5-sonnet-latest'; } if (!config.defaultOneshotcatModel) { config.defaultOneshotcatModel = 'claude-3-opus-latest'; } // Update config with new values if (options.anthropicApiKey) config.anthropicApiKey = options.anthropicApiKey; if (options.openaiApiKey) config.openaiApiKey = options.openaiApiKey; if (options.defaultModel) config.defaultModel = options.defaultModel; if (options.defaultOneshotModel) config.defaultOneshotModel = options.defaultOneshotModel; if (options.defaultOneshotcatModel) config.defaultOneshotcatModel = options.defaultOneshotcatModel; if (options.modelAliases) { config.modelAliases = { ...config.modelAliases, ...options.modelAliases }; } // Write config file writeFileSync(rcPath, JSON.stringify(config, null, 2)); } export function checkAndPromptConfig(models: string[]): void { const config = loadConfig(); const rcPath = join(homedir(), RC_FILE); const needsAnthropicKey = models.some(m => m.startsWith('claude')) && !config.anthropicApiKey; const needsOpenAIKey = models.some(m => m.startsWith('gpt')) && !config.openaiApiKey; if (!needsAnthropicKey && !needsOpenAIKey) { return; // All required keys are present } console.log('\n⚠️ Missing API key configuration'); console.log('--------------------------------'); if (needsAnthropicKey) { console.log('• Anthropic API key required for Claude models'); } if (needsOpenAIKey) { console.log('• OpenAI API key required for GPT models'); } console.log('\nTo configure your API keys, use:'); if (needsAnthropicKey) { console.log(' meld-config --anthropic-key <your-key>'); } if (needsOpenAIKey) { console.log(' meld-config --openai-key <your-key>'); } console.log('\nOr use 1Password CLI secret references in .meldrc:'); console.log(' {'); if (needsAnthropicKey) { console.log(' "anthropicApiKey": "op://vault-name/anthropic/api-key"'); } if (needsOpenAIKey) { console.log(' "openaiApiKey": "op://vault-name/openai/api-key"'); } console.log(' }'); console.log('\nOr set environment variables:'); if (needsAnthropicKey) { console.log(' export ANTHROPIC_API_KEY=<your-key>'); } if (needsOpenAIKey) { console.log(' export OPENAI_API_KEY=<your-key>'); } console.log(`\nConfiguration will be saved to: ${rcPath}`); console.log('\nFor more options, run: meld-config --help'); // Exit with error throw new ToolError('API keys required', 'MISSING_API_KEYS'); }