Files
shannon/apps/cli/src/config/resolver.ts
T
ezl-keygraphandGitHub a1675f8390 feat(cli): support any Pi provider via generic SHANNON_AI_API_KEY (#415)
* feat(cli): support any Pi provider via generic SHANNON_AI_API_KEY

* docs(cli): point users to pi.dev/models for provider and model ids

* docs: document generic provider path and pi.dev catalogue
2026-08-07 00:28:23 +05:30

276 lines
8.9 KiB
TypeScript

/**
* 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';
import {
type CuratedProviderId,
DEFAULT_MODEL_SPEC,
GENERIC_API_KEY_ENV,
isCuratedProvider,
parseModelSpec,
} from '../model-spec.js';
// === TOML ↔ Env Mapping ===
type TOMLType = 'string' | 'number' | 'boolean';
interface ConfigMapping {
readonly env: string;
readonly toml: string;
readonly type: TOMLType;
readonly boolFormat?: 'numeric' | 'literal';
}
/** Maps every supported env var to its TOML path (section.key) and expected type. */
const CONFIG_MAP: readonly ConfigMapping[] = [
// Core — base_url points any provider at a proxy or gateway
{ env: 'SHANNON_AI_MODEL', toml: 'core.model', type: 'string' },
{ env: 'SHANNON_AI_BASE_URL', toml: 'core.base_url', type: 'string' },
// Anthropic
{ env: 'ANTHROPIC_API_KEY', toml: 'anthropic.api_key', type: 'string' },
{ env: 'CLAUDE_CODE_OAUTH_TOKEN', toml: 'anthropic.oauth_token', type: 'string' },
// OpenAI — format picks the wire API a gateway serves
{ env: 'OPENAI_API_KEY', toml: 'openai.api_key', type: 'string' },
{ env: 'SHANNON_AI_OPENAI_FORMAT', toml: 'openai.format', type: 'string' },
// xAI
{ env: 'XAI_API_KEY', toml: 'xai.api_key', type: 'string' },
// Bedrock
{ env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' },
{ env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' },
// Generic — credential for any provider Shannon does not curate
{ env: GENERIC_API_KEY_ENV, toml: 'provider.api_key', type: 'string' },
] as const;
/** TOML section holding each curated provider's credentials, keyed by provider id. */
const PROVIDER_SECTIONS: Readonly<Record<CuratedProviderId, string>> = {
anthropic: 'anthropic',
openai: 'openai',
xai: 'xai',
'amazon-bedrock': 'bedrock',
};
/** TOML section holding the generic credential for uncurated providers. */
const GENERIC_PROVIDER_SECTION = 'provider';
// === TOML Parsing ===
type TOMLValue = string | number | boolean;
type TOMLSection = Record<string, TOMLValue>;
type TOMLConfig = Record<string, TOMLSection>;
/** Read a nested TOML value for a given mapping. */
function getTomlValue(config: TOMLConfig, mapping: ConfigMapping): string | undefined {
const [section, key] = mapping.toml.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;
if (typeof value === 'boolean') {
if (mapping.boolFormat === 'literal') return value ? 'true' : 'false';
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(
`\nYour config file is readable by other users on this machine (${actual}). Lock it down: 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 @keygraph/shannon setup' to reconfigure.\n`);
process.exit(1);
}
}
// === Validation ===
/** Build a lookup of allowed keys per section from CONFIG_MAP. */
function buildSchema(): Map<string, Map<string, TOMLType>> {
const schema = new Map<string, Map<string, TOMLType>>();
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 the section backing the selected provider carries a usable
* credential. `core.model` names the provider, so only that section is required;
* other providers' sections are ignored and never forwarded. An uncurated
* provider draws its credential from the generic [provider] section.
*/
function validateProviderFields(config: TOMLConfig, providerId: string, errors: string[]): void {
if (!isCuratedProvider(providerId)) {
const section = config[GENERIC_PROVIDER_SECTION] as Record<string, unknown> | undefined;
if (!section || !Object.keys(section).includes('api_key')) {
errors.push(`[${GENERIC_PROVIDER_SECTION}] requires api_key for provider "${providerId}"`);
}
return;
}
const sectionName = PROVIDER_SECTIONS[providerId];
const section = config[sectionName] as Record<string, unknown> | undefined;
const keys = section ? Object.keys(section) : [];
if (providerId === 'amazon-bedrock') {
const missing = ['region', 'token'].filter((k) => !keys.includes(k));
if (missing.length > 0) {
errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`);
}
return;
}
if (providerId === 'anthropic') {
if (!keys.includes('api_key') && !keys.includes('oauth_token')) {
errors.push('[anthropic] requires either api_key or oauth_token');
}
return;
}
if (!keys.includes('api_key')) {
errors.push(`[${sectionName}] requires api_key`);
}
}
/**
* 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<string, unknown>)) {
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. core.model must parse and name a supported provider
const modelValue = config.core?.model;
if (modelValue !== undefined && typeof modelValue !== 'string') {
return errors;
}
const spec = parseModelSpec(modelValue || DEFAULT_MODEL_SPEC);
if (typeof spec === 'string') {
errors.push(`[core].model — ${spec}`);
return errors;
}
// 5. The selected provider's section must carry a credential
validateProviderFields(config, spec.providerId, 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 'npx @keygraph/shannon setup' to reconfigure.\n`);
process.exit(1);
}
for (const mapping of CONFIG_MAP) {
if (process.env[mapping.env]) continue;
const value = getTomlValue(toml, mapping);
if (value) {
process.env[mapping.env] = value;
}
}
}