import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import JSON5 from "json5"; import { DATA_DIR } from "../shared/paths.js"; type ProviderConfig = { apiKey?: string | undefined; baseUrl?: string | undefined; model?: string | undefined; }; type ToolConfig = { apiKey?: string | undefined; baseUrl?: string | undefined; model?: string | undefined; }; export type CredentialsConfig = { version?: number | undefined; llm?: { provider?: string | undefined; providers?: Record | undefined; /** Explicit profile ordering per provider (e.g. { anthropic: ["anthropic", "anthropic:backup"] }) */ order?: Record | undefined; } | undefined; tools?: Record | undefined; }; type SkillsEnvConfig = { env?: Record | undefined; }; const DEFAULT_CREDENTIALS_PATH = join(DATA_DIR, "credentials.json5"); const DEFAULT_SKILLS_ENV_PATH = join(DATA_DIR, "skills.env.json5"); function expandHome(value: string): string { if (value === "~") return homedir(); if (value.startsWith("~/")) { return join(homedir(), value.slice(2)); } return value; } function isTestEnv(): boolean { return ( process.env.NODE_ENV === "test" || process.env.VITEST !== undefined || process.env.VITEST_WORKER_ID !== undefined ); } function isString(value: unknown): value is string { return typeof value === "string"; } function setEnvValue(target: Record, key: string, value: unknown): void { if (isString(value)) { target[key] = value; } } function applyEnvMap(target: Record, env?: Record): void { if (!env) return; for (const [key, value] of Object.entries(env)) { setEnvValue(target, key, value); } } export function getCredentialsPath(): string { const raw = process.env.SMC_CREDENTIALS_PATH ?? DEFAULT_CREDENTIALS_PATH; return expandHome(raw); } export function getSkillsEnvPath(): string { const raw = process.env.SMC_SKILLS_ENV_PATH ?? DEFAULT_SKILLS_ENV_PATH; return expandHome(raw); } export class CredentialManager { private corePath: string | null = null; private skillsPath: string | null = null; private disabledState: boolean | null = null; private coreConfig: CredentialsConfig | null = null; private skillsConfig: SkillsEnvConfig | null = null; private resolvedSkillsEnv: Record | null = null; private isDisabled(): boolean { if (process.env.SMC_CREDENTIALS_DISABLE === "1") return true; return isTestEnv(); } private loadCore(): void { const path = getCredentialsPath(); const disabled = this.isDisabled(); if (this.corePath === path && this.disabledState === disabled && this.coreConfig) { return; } this.corePath = path; this.disabledState = disabled; this.coreConfig = null; if (disabled) return; if (!existsSync(path)) return; const raw = readFileSync(path, "utf8"); try { this.coreConfig = JSON5.parse(raw) as CredentialsConfig; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to parse credentials file (${path}): ${message}`); } } private loadSkillsEnv(): void { const path = getSkillsEnvPath(); const disabled = this.isDisabled(); if (this.skillsPath === path && this.disabledState === disabled && this.resolvedSkillsEnv) { return; } this.skillsPath = path; this.disabledState = disabled; this.skillsConfig = null; this.resolvedSkillsEnv = null; if (disabled) return; if (!existsSync(path)) return; const raw = readFileSync(path, "utf8"); try { this.skillsConfig = JSON5.parse(raw) as SkillsEnvConfig; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to parse skills env file (${path}): ${message}`); } } private buildSkillsEnv(): Record { const env: Record = {}; if (!this.skillsConfig) return env; applyEnvMap(env, this.skillsConfig.env); return env; } private getResolvedSkillsEnv(): Record { this.loadSkillsEnv(); if (!this.resolvedSkillsEnv) { this.resolvedSkillsEnv = this.buildSkillsEnv(); } return this.resolvedSkillsEnv; } getLlmProvider(): string | undefined { this.loadCore(); return this.coreConfig?.llm?.provider; } getLlmProviderConfig(provider: string): ProviderConfig | undefined { this.loadCore(); return this.coreConfig?.llm?.providers?.[provider]; } getToolConfig(toolName: string): ToolConfig | undefined { this.loadCore(); return this.coreConfig?.tools?.[toolName]; } getEnv(name: string): string | undefined { const resolved = this.getResolvedSkillsEnv(); if (Object.prototype.hasOwnProperty.call(resolved, name)) { return resolved[name]; } return process.env[name]; } hasEnv(name: string): boolean { const resolved = this.getResolvedSkillsEnv(); if (Object.prototype.hasOwnProperty.call(resolved, name)) { return true; } return name in process.env; } /** * Get explicit profile order for a provider from credentials.json5 `llm.order`. * Returns undefined if no explicit order is configured. */ getLlmOrder(provider: string): string[] | undefined { this.loadCore(); return this.coreConfig?.llm?.order?.[provider]; } /** * List all profile IDs from `llm.providers` that belong to a given provider. * A profile matches if its key equals the provider exactly or starts with "provider:". */ listProfileIdsForProvider(provider: string): string[] { this.loadCore(); const providers = this.coreConfig?.llm?.providers; if (!providers) return []; const prefix = `${provider}:`; return Object.keys(providers).filter( (key) => key === provider || key.startsWith(prefix), ); } getResolvedEnvSnapshot(): Record { return { ...this.getResolvedSkillsEnv() }; } reset(): void { this.corePath = null; this.skillsPath = null; this.disabledState = null; this.coreConfig = null; this.skillsConfig = null; this.resolvedSkillsEnv = null; } } export const credentialManager = new CredentialManager();