/** * Configuration resolver with environment-first, TOML-fallback precedence. * * Priority: process.env > ~/.shannon/config.toml * Env var names match .env.example exactly; TOML uses nested sections. */ import fs from 'node:fs'; import { parse as parseTOML } from 'smol-toml'; import { getConfigFile } from '../home.js'; import { getMode } from '../mode.js'; // === TOML ↔ Env Mapping === type TOMLType = 'string' | 'number' | 'boolean'; interface ConfigMapping { readonly env: string; readonly toml: string; readonly type: TOMLType; } /** Maps every supported env var to its TOML path (section.key) and expected type. */ const CONFIG_MAP: readonly ConfigMapping[] = [ // Core { env: 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', toml: 'core.max_tokens', type: 'number' }, // Anthropic { env: 'ANTHROPIC_API_KEY', toml: 'anthropic.api_key', type: 'string' }, { env: 'CLAUDE_CODE_OAUTH_TOKEN', toml: 'anthropic.oauth_token', type: 'string' }, // Bedrock { env: 'CLAUDE_CODE_USE_BEDROCK', toml: 'bedrock.use', type: 'boolean' }, { env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' }, { env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' }, // Vertex { env: 'CLAUDE_CODE_USE_VERTEX', toml: 'vertex.use', type: 'boolean' }, { env: 'CLOUD_ML_REGION', toml: 'vertex.region', type: 'string' }, { env: 'ANTHROPIC_VERTEX_PROJECT_ID', toml: 'vertex.project_id', type: 'string' }, { env: 'GOOGLE_APPLICATION_CREDENTIALS', toml: 'vertex.key_path', type: 'string' }, // Custom Base URL { env: 'ANTHROPIC_BASE_URL', toml: 'custom_base_url.base_url', type: 'string' }, { env: 'ANTHROPIC_AUTH_TOKEN', toml: 'custom_base_url.auth_token', type: 'string' }, // Model tiers { env: 'ANTHROPIC_SMALL_MODEL', toml: 'models.small', type: 'string' }, { env: 'ANTHROPIC_MEDIUM_MODEL', toml: 'models.medium', type: 'string' }, { env: 'ANTHROPIC_LARGE_MODEL', toml: 'models.large', type: 'string' }, ] as const; // === TOML Parsing === type TOMLValue = string | number | boolean; type TOMLSection = Record; type TOMLConfig = Record; /** Read a nested TOML value by dotted path (e.g. "anthropic.api_key"). */ function getTomlValue(config: TOMLConfig, path: string): string | undefined { const [section, key] = path.split('.'); if (!section || !key) return undefined; const sectionObj = config[section]; if (!sectionObj || typeof sectionObj !== 'object') return undefined; const value = sectionObj[key]; if (value === undefined || value === null) return undefined; // NOTE: env.ts checks bedrock/vertex via `=== '1'`, so booleans must map to "1"/"0" if (typeof value === 'boolean') return value ? '1' : '0'; return String(value); } /** Parse the global TOML config file, returning null if it doesn't exist. */ function loadTOML(): TOMLConfig | null { const configPath = getConfigFile(); if (!fs.existsSync(configPath)) return null; // Config contains secrets — refuse to read if group or others have any access. // Skip on Windows where POSIX permissions are not supported. if (process.platform !== 'win32') { const mode = fs.statSync(configPath).mode; if (mode & 0o077) { const actual = (mode & 0o777).toString(8).padStart(3, '0'); console.error(`\nInsecure permissions (${actual}) on ${configPath}. Run: chmod 600 ${configPath}\n`); process.exit(1); } } try { const content = fs.readFileSync(configPath, 'utf-8'); return parseTOML(content) as TOMLConfig; } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`\nFailed to parse ${configPath}: ${message}`); console.error(`\nRun 'npx @trebuchet/cli setup' to reconfigure.\n`); process.exit(1); } } // === Validation === /** Build a lookup of allowed keys per section from CONFIG_MAP. */ function buildSchema(): Map> { const schema = new Map>(); for (const mapping of CONFIG_MAP) { const [section, key] = mapping.toml.split('.'); if (!section || !key) continue; let keys = schema.get(section); if (!keys) { keys = new Map(); schema.set(section, keys); } keys.set(key, mapping.type); } return schema; } /** Check that a provider section has all required fields and dependencies. */ function validateProviderFields(config: TOMLConfig, provider: string, errors: string[]): void { const section = config[provider] as Record | undefined; if (!section) return; const keys = Object.keys(section); switch (provider) { case 'anthropic': if (!keys.includes('api_key') && !keys.includes('oauth_token')) { errors.push('[anthropic] requires either api_key or oauth_token'); } break; case 'custom_base_url': { const required = ['base_url', 'auth_token']; const missing = required.filter((k) => !keys.includes(k)); if (missing.length > 0) { errors.push(`[custom_base_url] missing required keys: ${missing.join(', ')}`); } break; } case 'bedrock': { const required = ['use', 'region', 'token']; const missing = required.filter((k) => !keys.includes(k)); if (missing.length > 0) { errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`); } validateModelTiers(config, 'bedrock', errors); break; } case 'vertex': { const required = ['use', 'region', 'project_id', 'key_path']; const missing = required.filter((k) => !keys.includes(k)); if (missing.length > 0) { errors.push(`[vertex] missing required keys: ${missing.join(', ')}`); } validateModelTiers(config, 'vertex', errors); break; } } } /** Bedrock and Vertex require a [models] section with all three tiers. */ function validateModelTiers(config: TOMLConfig, provider: string, errors: string[]): void { const models = config.models as Record | undefined; if (!models || typeof models !== 'object') { errors.push(`[${provider}] requires a [models] section with small, medium, and large`); return; } const required = ['small', 'medium', 'large']; const missing = required.filter((k) => !Object.keys(models).includes(k)); if (missing.length > 0) { errors.push(`[models] missing required keys for ${provider}: ${missing.join(', ')}`); } } /** * Validate a parsed TOML config against the known schema. * Returns an array of human-readable error messages (empty = valid). */ function validateConfig(config: TOMLConfig): string[] { const schema = buildSchema(); const errors: string[] = []; for (const [section, sectionObj] of Object.entries(config)) { // 1. Reject unknown sections const allowedKeys = schema.get(section); if (!allowedKeys) { const known = [...schema.keys()].join(', '); errors.push(`Unknown section [${section}]. Valid sections: ${known}`); continue; } // 2. Section value must be a table if (!sectionObj || typeof sectionObj !== 'object') { errors.push(`[${section}] must be a table, got ${typeof sectionObj}`); continue; } // 3. Validate each key in the section for (const [key, value] of Object.entries(sectionObj as Record)) { const expectedType = allowedKeys.get(key); if (!expectedType) { const known = [...allowedKeys.keys()].join(', '); errors.push(`Unknown key "${key}" in [${section}]. Valid keys: ${known}`); continue; } if (typeof value !== expectedType) { errors.push(`[${section}].${key} must be ${expectedType}, got ${typeof value}`); continue; } // Reject empty strings — they pass type checks but are never useful if (typeof value === 'string' && value.trim() === '') { errors.push(`[${section}].${key} must not be empty`); } } } // 4. Only one provider section allowed (ignore empty sections) const PROVIDER_SECTIONS = ['anthropic', 'custom_base_url', 'bedrock', 'vertex'] as const; const present = PROVIDER_SECTIONS.filter((s) => { const section = config[s]; return section && typeof section === 'object' && Object.keys(section).length > 0; }); if (present.length > 1) { errors.push( `Multiple providers configured: [${present.join('], [')}]. Only one provider section is allowed at a time`, ); } // 5. Required fields per provider const singleProvider = present.length === 1 ? present[0] : undefined; if (singleProvider) { validateProviderFields(config, singleProvider, errors); } return errors; } // === Public API === /** * Resolve all config values into process.env (npx mode only). * * For each mapped variable: if not already set in the environment, * look it up in ~/.shannon/config.toml and inject it into process.env. * Local mode uses .env exclusively — TOML is skipped. * Exits with an error if the TOML contains unknown or invalid keys. */ export function resolveConfig(): void { if (getMode() === 'local') return; const toml = loadTOML(); if (!toml) return; // Validate before injecting const errors = validateConfig(toml); if (errors.length > 0) { console.error('\nInvalid configuration:'); for (const err of errors) { console.error(` - ${err}`); } console.error(`\nRun 'shn setup' to reconfigure.\n`); process.exit(1); } for (const mapping of CONFIG_MAP) { if (process.env[mapping.env]) continue; const value = getTomlValue(toml, mapping.toml); if (value) { process.env[mapping.env] = value; } } }