mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-15 06:25:32 +02:00
* refactor(cli): list workspaces natively instead of via the worker image * feat(cli): preflight that Docker is installed and running * feat(cli): stop scans by workspace or --all, terminating their Temporal workflows * fix(worker): abort the running agent on cancellation so Temporal cancel takes effect * refactor(cli): split destructive teardown out of stop into a reset command * refactor(cli): centralise flag parsing and confirmation across commands * fix(cli): pass provider credentials to docker by name to keep secrets out of argv * feat(cli): add per-command help via <command> --help/-h and help <command> * feat(cli): replace raw docker output with clack spinners for infra and scan teardown * fix(cli): verify scan stop by re-querying container and workflow state instead of assuming success * fix(cli): resolve running state before prompting on stop and report no-op stops honestly * refactor(cli): show splash first and drive start with one spinner resolving to a clean line * fix(cli): validate --url up front so a bad value fails cleanly instead of a late crash * refactor(cli): centralize error reporting with fail() for expected errors and a crash handler that logs the stack and links the issue tracker * feat(cli): add --json/--plain machine-readable output to workspaces and status * refactor(cli): remove the workspaces command * refactor(cli): remove the status command * feat(cli): add 'progress <workspace>' — live scan progress from Temporal * fix(cli): mark metric-less agents as skipped in progress, not done * feat(cli): animate running agents in progress with a clack-style spinner * feat(cli): rename progress->status, reveal agents as they run, show live per-agent elapsed * fix(cli): mark passed-over phases as skipped live, not pending * style(cli): rename status footer 'Wall-clock' to 'Time Taken', drop the parenthetical * style(cli): drop '(sum of agents)' from status total cost line * style(cli): green filled circle for completed, Shannon gold for running * style(cli): use Shannon gold in place of green in status * feat(cli): suggest closest command or flag on typo * refactor(cli): single-source start help and drop ./repos bare-name shortcut * feat(cli): name providers and fix in multi-provider credential error * feat(cli): support --flag=value syntax and expand leading ~ in paths * refactor(cli): centralize ANSI color codes in colors.ts * feat(cli): add scans command listing completed scans with cost and duration * fix(cli): keep stdout clean off-TTY for logs and start * feat(cli): add repo link to top-level help * feat(worker): record auth-validation metrics and register resume attempts early * refactor(cli): share resume-aware workflow-id resolution and surface root-cause failures * feat(cli): add status --json, auth phase, dashboard link, and stable live redraw * refactor(cli): drop cost from status and scans output * feat(worker): surface both PDF and markdown report at run root * refactor(cli): normalize error/warning prefixing through fail and warn * feat(cli): add version --json for machine-readable output * refactor(cli): rename start --debug to --keep-container * refactor(cli): point start's progress hint at status instead of the Temporal dashboard * refactor(cli): centralize the mode-aware command prefix * refactor(cli): trim start and logs output to durable facts off-TTY * feat(cli): require typed confirmation for reset instead of --yes reset permanently wipes all Temporal data and volumes — a severe, irreversible action. Replace its default y/N confirm (bypassable with --yes) with a typed-word confirmation that has no bypass, so the wipe can only be triggered by a deliberate interactive answer. * feat(cli): surface logs and status hints after start on a TTY * feat(cli): exit 2 on usage errors, distinct from operational failures * feat(cli): add start --follow to stream logs and exit on scan outcome * refactor(cli): redesign splash with sunset-gradient wordmark and truecolor * refactor(cli): remove the uninstall command * docs: sync CLI docs with removed uninstall/workspaces, new scans and --follow * docs: fix reset confirmation — typed confirm, not --yes/-y * style(cli): restructure status footer with divider, aligned Logs/Temporal rows * feat(cli): show splash in the status command * fix(worker): validate auth-state shape, not entry count * docs: correct reset confirmation and add markdown report to run-root docs
273 lines
8.8 KiB
TypeScript
273 lines
8.8 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 { fail } from '../errors.js';
|
|
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');
|
|
fail(
|
|
`Your config file is readable by other users on this machine (${actual}). Lock it down: chmod 600 ${configPath}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
return parseTOML(content) as TOMLConfig;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
fail(`Failed to parse ${configPath}: ${message}`, `Run 'npx @keygraph/shannon setup' to reconfigure.`);
|
|
}
|
|
}
|
|
|
|
// === 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) {
|
|
fail(
|
|
'Invalid configuration:',
|
|
...errors.map((err) => ` - ${err}`),
|
|
`Run 'npx @keygraph/shannon setup' to reconfigure.`,
|
|
);
|
|
}
|
|
|
|
for (const mapping of CONFIG_MAP) {
|
|
if (process.env[mapping.env]) continue;
|
|
|
|
const value = getTomlValue(toml, mapping);
|
|
if (value) {
|
|
process.env[mapping.env] = value;
|
|
}
|
|
}
|
|
}
|