@meldscience/meld
Version:
pipeable one-shot prompt scripting toolkit
202 lines (175 loc) • 6.18 kB
text/typescript
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');
}