import { readFile, chmod } from 'fs/promises' import { readdir, stat } from 'fs/promises' import { existsSync, readFileSync } from 'fs' import { join } from 'path' import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getProfileDir } from './hermes/hermes-profile' import { logger } from './logger' import { safeFileStore } from './safe-file-store' // --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) --- export const PROVIDER_ENV_MAP: Record = { 'fun-codex': { api_key_env: '', base_url_env: '' }, 'fun-claude': { api_key_env: '', base_url_env: '' }, openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: '' }, 'glm-coding-plan': { api_key_env: '', base_url_env: '' }, zai: { api_key_env: 'GLM_API_KEY', base_url_env: '' }, 'kimi-coding-cn': { api_key_env: 'KIMI_CN_API_KEY', base_url_env: '' }, moonshot: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: '' }, minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: '' }, 'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: '' }, deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: '' }, alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: '' }, 'alibaba-coding-plan': { api_key_env: 'ALIBABA_CODING_PLAN_API_KEY', base_url_env: 'ALIBABA_CODING_PLAN_BASE_URL' }, anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' }, xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' }, 'xai-oauth': { api_key_env: '', base_url_env: '' }, xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' }, 'xiaomi-token-plan': { api_key_env: '', base_url_env: '' }, gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' }, kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: '' }, 'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: '' }, cliproxyapi: { api_key_env: '', base_url_env: '' }, 'opencode-zen': { api_key_env: 'OPENCODE_ZEN_API_KEY', base_url_env: '' }, 'opencode-go': { api_key_env: 'OPENCODE_GO_API_KEY', base_url_env: '' }, huggingface: { api_key_env: 'HF_TOKEN', base_url_env: '' }, arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' }, stepfun: { api_key_env: 'STEPFUN_API_KEY', base_url_env: '' }, 'ollama-cloud': { api_key_env: 'OLLAMA_API_KEY', base_url_env: '' }, nous: { api_key_env: '', base_url_env: '' }, 'openai-codex': { api_key_env: '', base_url_env: '' }, copilot: { api_key_env: '', base_url_env: '' }, longcat: { api_key_env: 'LONGCAT_API_KEY', base_url_env: 'LONGCAT_BASE_URL' }, } // --- Types --- export type SkillSource = 'builtin' | 'hub' | 'local' | 'external' export interface SkillInfo { name: string description: string enabled: boolean source?: SkillSource } export interface SkillCategory { name: string description: string skills: SkillInfo[] } export interface ModelInfo { id: string label: string } export interface ModelGroup { provider: string models: ModelInfo[] } // --- Config YAML helpers --- const configPath = () => getActiveConfigPath() const configPathForProfile = (profile: string) => join(getProfileDir(profile), 'config.yaml') const envPathForProfile = (profile: string) => join(getProfileDir(profile), '.env') export async function readConfigYaml(): Promise> { return safeFileStore.readYaml(configPath()) } export async function readConfigYamlForProfile(profile: string): Promise> { return safeFileStore.readYaml(configPathForProfile(profile)) } export async function writeConfigYaml(config: Record): Promise { await safeFileStore.writeYaml(configPath(), config, { backup: true }) } export async function updateConfigYaml( updater: (config: Record) => Record | { data: Record; result: T; write?: boolean } | Promise | { data: Record; result: T; write?: boolean }>, ): Promise { return safeFileStore.updateYaml(configPath(), updater, { backup: true }) } export async function updateConfigYamlForProfile( profile: string, updater: (config: Record) => Record | { data: Record; result: T; write?: boolean } | Promise | { data: Record; result: T; write?: boolean }>, ): Promise { return safeFileStore.updateYaml(configPathForProfile(profile), updater, { backup: true }) } export function stripLegacyApiServerGatewayConfig(config: Record): { config: Record; changed: boolean } { if (!config.platforms || typeof config.platforms !== 'object' || Array.isArray(config.platforms)) { return { config, changed: false } } if (config.platforms.api_server !== undefined) { delete config.platforms.api_server if (Object.keys(config.platforms).length === 0) delete config.platforms return { config, changed: true } } return { config, changed: false } } // --- .env helpers --- function assertValidEnvKey(key: string): void { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { throw new Error(`Invalid .env key: ${JSON.stringify(key)}`) } } async function saveEnvValueAtPath(envPath: string, key: string, value: string): Promise { assertValidEnvKey(key) await safeFileStore.updateText(envPath, (raw) => { const remove = !value const lines = raw.split('\n') let found = false const result: string[] = [] for (const line of lines) { const trimmed = line.trim() if (trimmed.startsWith('#') && trimmed.startsWith(`# ${key}=`)) { if (!remove) result.push(`${key}=${value}`) found = true } else { const eqIdx = trimmed.indexOf('=') if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) { if (!remove) result.push(`${key}=${value}`) found = true } else { result.push(line) } } } if (!found && !remove) { result.push(`${key}=${value}`) } return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' }) try { await chmod(envPath, 0o600) } catch { /* ignore */ } } export async function saveEnvValue(key: string, value: string): Promise { await saveEnvValueAtPath(getActiveEnvPath(), key, value) } export async function saveEnvValueForProfile(profile: string, key: string, value: string): Promise { await saveEnvValueAtPath(envPathForProfile(profile), key, value) } // --- File helpers --- export async function safeReadFile(filePath: string): Promise { try { return await readFile(filePath, 'utf-8') } catch { return null } } export async function safeStat(filePath: string): Promise<{ mtime: number } | null> { try { const s = await stat(filePath) return { mtime: Math.round(s.mtimeMs) } } catch { return null } } // --- Skill helpers --- export function extractDescription(content: string): string { const lines = content.split('\n') let inFrontmatter = false let bodyStarted = false for (const line of lines) { if (!bodyStarted && line.trim() === '---') { if (!inFrontmatter) { inFrontmatter = true continue } else { inFrontmatter = false bodyStarted = true continue } } if (inFrontmatter) continue if (line.trim() === '') continue if (line.startsWith('#')) continue return line.trim().slice(0, 80) } return '' } export async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> { const result: { path: string; name: string }[] = [] let entries try { entries = await readdir(dir, { withFileTypes: true }) } catch { return result } for (const entry of entries) { const relPath = prefix ? `${prefix}/${entry.name}` : entry.name if (entry.isDirectory()) { result.push(...await listFilesRecursive(join(dir, entry.name), relPath)) } else { result.push({ path: relPath, name: entry.name }) } } return result } // --- Provider model helpers --- export async function fetchProviderModels(baseUrl: string, apiKey: string, freeOnly = false): Promise { const base = baseUrl.replace(/\/+$/, '') const modelsUrl = /\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models` try { const res = await fetch(modelsUrl, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(8000), }) if (!res.ok) { logger.warn('available-models %s returned %d', modelsUrl, res.status) return [] } const data = await res.json() as { data?: Array<{ id: string }> } if (!Array.isArray(data.data)) { logger.warn('available-models %s returned unexpected format', modelsUrl) return [] } let models = data.data.map(m => m.id) if (freeOnly) models = models.filter(m => m.endsWith(':free')) return models.sort() } catch (err: any) { logger.error(err, 'available-models %s failed', modelsUrl) return [] } } export function buildModelGroups(config: Record): { default: string; groups: ModelGroup[] } { let defaultModel = '' const groups: ModelGroup[] = [] // 1. Extract current model const modelSection = config.model if (typeof modelSection === 'object' && modelSection !== null) { defaultModel = String(modelSection.default || '').trim() } else if (typeof modelSection === 'string') { defaultModel = modelSection.trim() } // 2. Extract custom_providers section const customProviders = config.custom_providers if (Array.isArray(customProviders)) { const customModels: ModelInfo[] = [] for (const entry of customProviders) { if (entry && typeof entry === 'object') { const cName = String(entry.name || '').trim() const cModel = String(entry.model || '').trim() if (cName && cModel) { customModels.push({ id: cModel, label: `${cName}: ${cModel}` }) } } } if (customModels.length > 0) { groups.push({ provider: 'Custom', models: customModels }) } } return { default: defaultModel, groups } } // --- Profile directory helper --- export const getHermesDir = () => getActiveProfileDir()