//! Live configuration operations //! //! Handles reading and writing live configuration files for Claude, Codex, and Gemini. 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::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 pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { match app_type { AppType::Claude => { let path = get_claude_settings_path(); write_json_file(&path, &provider.settings_config)?; } AppType::Codex => { let obj = provider .settings_config .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 write_gemini_live(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) = provider.settings_config.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 到数据库。 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) { write_live_snapshot(&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) }