//! Live configuration operations //! //! Handles reading and writing live configuration files for Claude, Codex, and Gemini. //! //! ## Common Config Runtime Merge //! //! When writing to live files, this module performs runtime merge of: //! - `customConfig` (provider's settings_config) - provider-specific settings //! - `commonConfig` (from database settings table) - shared template settings //! //! The merge follows the rule: customConfig overrides commonConfig. use std::collections::HashMap; use serde_json::{json, Value}; use crate::app_config::AppType; use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file}; use crate::config_merge::{compute_final_json_config, compute_final_toml_config_str}; use crate::error::AppError; use crate::provider::Provider; use crate::services::mcp::McpService; use crate::store::AppState; use super::gemini_auth::{ detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType, }; use super::normalize_claude_models_in_value; /// Live configuration snapshot for backup/restore #[derive(Clone)] #[allow(dead_code)] pub(crate) enum LiveSnapshot { Claude { settings: Option, }, Codex { auth: Option, config: Option, }, Gemini { env: Option>, config: Option, }, } impl LiveSnapshot { #[allow(dead_code)] pub(crate) fn restore(&self) -> Result<(), AppError> { match self { LiveSnapshot::Claude { settings } => { let path = get_claude_settings_path(); if let Some(value) = settings { write_json_file(&path, value)?; } else if path.exists() { delete_file(&path)?; } } LiveSnapshot::Codex { auth, config } => { let auth_path = get_codex_auth_path(); let config_path = get_codex_config_path(); if let Some(value) = auth { write_json_file(&auth_path, value)?; } else if auth_path.exists() { delete_file(&auth_path)?; } if let Some(text) = config { crate::config::write_text_file(&config_path, text)?; } else if config_path.exists() { delete_file(&config_path)?; } } LiveSnapshot::Gemini { env, .. } => { use crate::gemini_config::{ get_gemini_env_path, get_gemini_settings_path, write_gemini_env_atomic, }; let path = get_gemini_env_path(); if let Some(env_map) = env { write_gemini_env_atomic(env_map)?; } else if path.exists() { delete_file(&path)?; } let settings_path = get_gemini_settings_path(); match self { LiveSnapshot::Gemini { config: Some(cfg), .. } => { write_json_file(&settings_path, cfg)?; } LiveSnapshot::Gemini { config: None, .. } if settings_path.exists() => { delete_file(&settings_path)?; } _ => {} } } } Ok(()) } } /// Write live configuration snapshot for a provider (raw, without common config merge) /// /// This function writes the provider's settings_config directly to the live file. /// Use `write_live_snapshot_with_merge` for runtime merge with common config. pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { write_live_snapshot_internal(app_type, provider, &provider.settings_config) } /// Write live configuration snapshot with common config runtime merge /// /// This function performs runtime merge of: /// - Provider's settings_config (custom config) /// - Common config snippet from database (shared template) /// /// The merge rule is: customConfig overrides commonConfig. pub(crate) fn write_live_snapshot_with_merge( state: &AppState, app_type: &AppType, provider: &Provider, ) -> Result<(), AppError> { // Get common config snippet from database let common_config_snippet = state.db.get_config_snippet(app_type.as_str())?; // Check if common config is enabled for this app let common_config_enabled = provider .meta .as_ref() .and_then(|m| { m.common_config_enabled_by_app .as_ref() .and_then(|by_app| match app_type { AppType::Claude => by_app.claude, AppType::Codex => by_app.codex, AppType::Gemini => by_app.gemini, AppType::OpenCode => None, // OpenCode doesn't support common config }) .or(m.common_config_enabled) }) .unwrap_or(false); // If common config is not enabled or snippet is empty, write raw config if !common_config_enabled || common_config_snippet .as_ref() .is_none_or(|s| s.trim().is_empty()) { return write_live_snapshot(app_type, provider); } let snippet = common_config_snippet.unwrap(); // Perform runtime merge based on app type let final_config = match app_type { AppType::Claude => { // Claude uses JSON format let common_value: Value = serde_json::from_str(&snippet).unwrap_or(json!({})); compute_final_json_config(&provider.settings_config, &common_value, true) } AppType::Codex => { // Codex uses TOML for config field, JSON for auth field // Merge only the config field (TOML), keep auth field unchanged let mut merged_config = provider.settings_config.clone(); if let Some(obj) = merged_config.as_object_mut() { if let Some(config_str) = obj.get("config").and_then(|v| v.as_str()) { let (merged_toml, error) = compute_final_toml_config_str(config_str, &snippet, true); if let Some(e) = error { log::warn!("Codex common config merge warning: {e}"); } obj.insert("config".to_string(), json!(merged_toml)); } } merged_config } AppType::Gemini => { // Gemini uses JSON format for env field let common_value: Value = serde_json::from_str(&snippet).unwrap_or(json!({})); let mut merged_config = provider.settings_config.clone(); // Merge only the env field if let (Some(merged_obj), Some(common_obj)) = (merged_config.as_object_mut(), common_value.as_object()) { if let Some(merged_env) = merged_obj.get_mut("env") { if let (Some(merged_env_obj), Some(common_env)) = ( merged_env.as_object_mut(), common_obj.get("env").and_then(|v| v.as_object()), ) { // Common env as base, custom env overrides let mut final_env = common_env.clone(); for (k, v) in merged_env_obj.iter() { final_env.insert(k.clone(), v.clone()); } *merged_env = json!(final_env); } } } merged_config } AppType::OpenCode => { // OpenCode doesn't support common config merge provider.settings_config.clone() } }; log::debug!( "Writing live config with common config merge for {:?} provider '{}'", app_type, provider.id ); // Write the merged config to live file let merged_provider = Provider { settings_config: final_config, ..provider.clone() }; write_live_snapshot_internal(app_type, &merged_provider, &merged_provider.settings_config) } /// Internal function to write live configuration fn write_live_snapshot_internal( app_type: &AppType, provider: &Provider, config_to_write: &Value, ) -> Result<(), AppError> { match app_type { AppType::Claude => { let path = get_claude_settings_path(); write_json_file(&path, config_to_write)?; } AppType::Codex => { let obj = config_to_write .as_object() .ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?; let auth = obj .get("auth") .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?; let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| { AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string()) })?; let auth_path = get_codex_auth_path(); write_json_file(&auth_path, auth)?; let config_path = get_codex_config_path(); std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?; } AppType::Gemini => { // Delegate to write_gemini_live which handles env file writing correctly // Create a temporary provider with the merged config let temp_provider = Provider { settings_config: config_to_write.clone(), ..provider.clone() }; write_gemini_live(&temp_provider)?; } AppType::OpenCode => { // OpenCode uses additive mode - write provider to config use crate::opencode_config; use crate::provider::OpenCodeProviderConfig; // Defensive check: if settings_config is a full config structure, extract provider fragment let config_to_write = if let Some(obj) = config_to_write.as_object() { // Detect full config structure (has $schema or top-level provider field) if obj.contains_key("$schema") || obj.contains_key("provider") { log::warn!( "OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment", provider.id ); // Try to extract from provider.{id} obj.get("provider") .and_then(|p| p.get(&provider.id)) .cloned() .unwrap_or_else(|| provider.settings_config.clone()) } else { provider.settings_config.clone() } } else { provider.settings_config.clone() }; // Convert settings_config to OpenCodeProviderConfig let opencode_config_result = serde_json::from_value::(config_to_write.clone()); match opencode_config_result { Ok(config) => { opencode_config::set_typed_provider(&provider.id, &config)?; log::info!("OpenCode provider '{}' written to live config", provider.id); } Err(e) => { log::warn!( "Failed to parse OpenCode provider config for '{}': {}", provider.id, e ); // Only write if config looks like a valid provider fragment if config_to_write.get("npm").is_some() || config_to_write.get("options").is_some() { opencode_config::set_provider(&provider.id, config_to_write)?; log::info!( "OpenCode provider '{}' written as raw JSON to live config", provider.id ); } else { log::error!( "OpenCode provider '{}' has invalid config structure, skipping write", provider.id ); } } } } } Ok(()) } /// Sync current provider to live configuration /// /// 使用有效的当前供应商 ID(验证过存在性)。 /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。 /// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。 /// /// This function uses `write_live_snapshot_with_merge` to perform runtime merge /// with common config when enabled. pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { // Use validated effective current provider let current_id = match crate::settings::get_effective_current_provider(&state.db, &app_type)? { Some(id) => id, None => continue, }; let providers = state.db.get_all_providers(app_type.as_str())?; if let Some(provider) = providers.get(¤t_id) { // Use write_live_snapshot_with_merge to support common config runtime merge write_live_snapshot_with_merge(state, &app_type, provider)?; } // Note: get_effective_current_provider already validates existence, // so providers.get() should always succeed here } // MCP sync McpService::sync_all_enabled(state)?; // Skill sync for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) { log::warn!("同步 Skill 到 {app_type:?} 失败: {e}"); // Continue syncing other apps, don't abort } } Ok(()) } /// Read current live settings for an app type pub fn read_live_settings(app_type: AppType) -> Result { match app_type { AppType::Codex => { let auth_path = get_codex_auth_path(); if !auth_path.exists() { return Err(AppError::localized( "codex.auth.missing", "Codex 配置文件不存在:缺少 auth.json", "Codex configuration missing: auth.json not found", )); } let auth: Value = read_json_file(&auth_path)?; let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?; Ok(json!({ "auth": auth, "config": cfg_text })) } AppType::Claude => { let path = get_claude_settings_path(); if !path.exists() { return Err(AppError::localized( "claude.live.missing", "Claude Code 配置文件不存在", "Claude settings file is missing", )); } read_json_file(&path) } AppType::Gemini => { use crate::gemini_config::{ env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env, }; // Read .env file (environment variables) let env_path = get_gemini_env_path(); if !env_path.exists() { return Err(AppError::localized( "gemini.env.missing", "Gemini .env 文件不存在", "Gemini .env file not found", )); } let env_map = read_gemini_env()?; let env_json = env_to_json(&env_map); let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({})); // Read settings.json file (MCP config etc.) let settings_path = get_gemini_settings_path(); let config_obj = if settings_path.exists() { read_json_file(&settings_path)? } else { json!({}) }; // Return complete structure: { "env": {...}, "config": {...} } Ok(json!({ "env": env_obj, "config": config_obj })) } AppType::OpenCode => { use crate::opencode_config::{get_opencode_config_path, read_opencode_config}; let config_path = get_opencode_config_path(); if !config_path.exists() { return Err(AppError::localized( "opencode.config.missing", "OpenCode 配置文件不存在", "OpenCode configuration file not found", )); } let config = read_opencode_config()?; Ok(config) } } } /// Import default configuration from live files /// /// Returns `Ok(true)` if a provider was actually imported, /// `Ok(false)` if skipped (providers already exist for this app). pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { { let providers = state.db.get_all_providers(app_type.as_str())?; if !providers.is_empty() { return Ok(false); // 已有供应商,跳过 } } let settings_config = match app_type { AppType::Codex => { let auth_path = get_codex_auth_path(); if !auth_path.exists() { return Err(AppError::localized( "codex.live.missing", "Codex 配置文件不存在", "Codex configuration file is missing", )); } let auth: Value = read_json_file(&auth_path)?; let config_str = crate::codex_config::read_and_validate_codex_config_text()?; json!({ "auth": auth, "config": config_str }) } AppType::Claude => { let settings_path = get_claude_settings_path(); if !settings_path.exists() { return Err(AppError::localized( "claude.live.missing", "Claude Code 配置文件不存在", "Claude settings file is missing", )); } let mut v = read_json_file::(&settings_path)?; let _ = normalize_claude_models_in_value(&mut v); v } AppType::Gemini => { use crate::gemini_config::{ env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env, }; // Read .env file (environment variables) let env_path = get_gemini_env_path(); if !env_path.exists() { return Err(AppError::localized( "gemini.live.missing", "Gemini 配置文件不存在", "Gemini configuration file is missing", )); } let env_map = read_gemini_env()?; let env_json = env_to_json(&env_map); let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({})); // Read settings.json file (MCP config etc.) let settings_path = get_gemini_settings_path(); let config_obj = if settings_path.exists() { read_json_file(&settings_path)? } else { json!({}) }; // Return complete structure: { "env": {...}, "config": {...} } json!({ "env": env_obj, "config": config_obj }) } AppType::OpenCode => { // OpenCode uses additive mode - import from live is not the same pattern // For now, return an empty config structure use crate::opencode_config::{get_opencode_config_path, read_opencode_config}; let config_path = get_opencode_config_path(); if !config_path.exists() { return Err(AppError::localized( "opencode.live.missing", "OpenCode 配置文件不存在", "OpenCode configuration file is missing", )); } // For OpenCode, we return the full config - but note that OpenCode // uses additive mode, so importing defaults works differently read_opencode_config()? } }; let mut provider = Provider::with_id( "default".to_string(), "default".to_string(), settings_config, None, ); provider.category = Some("custom".to_string()); state.db.save_provider(app_type.as_str(), &provider)?; state .db .set_current_provider(app_type.as_str(), &provider.id)?; Ok(true) // 真正导入了 } /// Write Gemini live configuration with authentication handling pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> { use crate::gemini_config::{ get_gemini_settings_path, json_to_env, validate_gemini_settings_strict, write_gemini_env_atomic, }; // One-time auth type detection to avoid repeated detection let auth_type = detect_gemini_auth_type(provider); let mut env_map = json_to_env(&provider.settings_config)?; // Prepare config to write to ~/.gemini/settings.json // Behavior: // - config is object: use it (merge with existing to preserve mcpServers etc.) // - config is null or absent: preserve existing file content let settings_path = get_gemini_settings_path(); let mut config_to_write: Option = None; if let Some(config_value) = provider.settings_config.get("config") { if config_value.is_object() { // Merge with existing settings to preserve mcpServers and other fields let mut merged = if settings_path.exists() { read_json_file::(&settings_path).unwrap_or_else(|_| json!({})) } else { json!({}) }; // Merge provider config into existing settings if let (Some(merged_obj), Some(config_obj)) = (merged.as_object_mut(), config_value.as_object()) { for (k, v) in config_obj { merged_obj.insert(k.clone(), v.clone()); } } config_to_write = Some(merged); } else if !config_value.is_null() { return Err(AppError::localized( "gemini.validation.invalid_config", "Gemini 配置格式错误: config 必须是对象或 null", "Gemini config invalid: config must be an object or null", )); } // config is null: don't modify existing settings.json (preserve mcpServers etc.) } // If no config specified or config is null, preserve existing file if config_to_write.is_none() && settings_path.exists() { config_to_write = Some(read_json_file(&settings_path)?); } match auth_type { GeminiAuthType::GoogleOfficial => { // Google official uses OAuth, clear env env_map.clear(); write_gemini_env_atomic(&env_map)?; } GeminiAuthType::Packycode => { // PackyCode provider, uses API Key (strict validation on switch) validate_gemini_settings_strict(&provider.settings_config)?; write_gemini_env_atomic(&env_map)?; } GeminiAuthType::Generic => { // Generic provider, uses API Key (strict validation on switch) validate_gemini_settings_strict(&provider.settings_config)?; write_gemini_env_atomic(&env_map)?; } } if let Some(config_value) = config_to_write { write_json_file(&settings_path, &config_value)?; } // Set security.auth.selectedType based on auth type // - Google Official: OAuth mode // - All others: API Key mode match auth_type { GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?, GeminiAuthType::Packycode | GeminiAuthType::Generic => { crate::gemini_config::write_packycode_settings()?; } } Ok(()) } /// Remove an OpenCode provider from the live configuration /// /// This is specific to OpenCode's additive mode - removing a provider /// from the opencode.json file. pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> { use crate::opencode_config; // Check if OpenCode config directory exists if !opencode_config::get_opencode_dir().exists() { log::debug!("OpenCode config directory doesn't exist, skipping removal of '{provider_id}'"); return Ok(()); } opencode_config::remove_provider(provider_id)?; log::info!("OpenCode provider '{provider_id}' removed from live config"); Ok(()) } /// Import all providers from OpenCode live config to database /// /// This imports existing providers from ~/.config/opencode/opencode.json /// into the CC Switch database. Each provider found will be added to the /// database with is_current set to false. pub fn import_opencode_providers_from_live(state: &AppState) -> Result { use crate::opencode_config; let providers = opencode_config::get_typed_providers()?; if providers.is_empty() { return Ok(0); } let mut imported = 0; let existing = state.db.get_all_providers("opencode")?; for (id, config) in providers { // Skip if already exists in database if existing.contains_key(&id) { log::debug!("OpenCode provider '{id}' already exists in database, skipping"); continue; } // Convert to Value for settings_config let settings_config = match serde_json::to_value(&config) { Ok(v) => v, Err(e) => { log::warn!("Failed to serialize OpenCode provider '{id}': {e}"); continue; } }; // Create provider let provider = Provider::with_id( id.clone(), config.name.clone().unwrap_or_else(|| id.clone()), settings_config, None, ); // Save to database if let Err(e) = state.db.save_provider("opencode", &provider) { log::warn!("Failed to import OpenCode provider '{id}': {e}"); continue; } imported += 1; log::info!("Imported OpenCode provider '{id}' from live config"); } Ok(imported) }