//! JSON file–backed `AppConfigRepository` with Claude credential auto-detection. use std::path::Path; use serde::{Deserialize, Serialize}; use zesdex_domain::cms::{AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError}; use crate::utils::write_json_atomic; /// File-based `AppConfigRepository` that reads/writes `app_config.json`. #[derive(Debug, Clone, Default)] pub struct JsonAppConfigRepository; impl JsonAppConfigRepository { pub fn new() -> Self { Self } } #[derive(Debug, Clone, Serialize, Deserialize)] struct ClaudeEnv { #[serde(alias = "ANTHROPIC_BASE_URL")] anthropic_base_url: Option, #[serde(alias = "ANTHROPIC_API_KEY")] anthropic_api_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ClaudeSettings { env: Option, #[serde(alias = "customModel", alias = "model")] custom_model: Option, } fn claude_settings_from_file() -> Option { let path = dirs::home_dir()?.join(".claude").join("settings.json"); let content = std::fs::read_to_string(&path).ok()?; serde_json::from_str(&content).ok() } fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option)> { let settings = claude_settings_from_file(); let file_creds = settings.as_ref().and_then(|s| { let env = s.env.as_ref()?; Some((env.anthropic_base_url.clone()?, env.anthropic_api_key.clone()?)) }); let env_creds = || -> Option<(String, String)> { let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?; let key = std::env::var("ANTHROPIC_API_KEY").ok()?; Some((base_url, key)) }; let custom_model = settings.and_then(|s| s.custom_model); let (base_url, key) = file_creds.or_else(env_creds)?; Some(( ProviderConfig { api_base: base_url, api_key_env: Some("ANTHROPIC_API_KEY".to_string()), default_model: custom_model.clone(), default_api_key: Some(key), }, custom_model )) } impl AppConfigRepository for JsonAppConfigRepository { fn load(&self, base_dir: &Path) -> Result { let path = base_dir.join("app_config.json"); let mut cfg: AppConfig = match std::fs::read_to_string(&path) { Ok(s) => serde_json::from_str(&s)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { AppConfig::default() } Err(e) => return Err(RepositoryError::Io(e)), }; let defaults = AppConfig::default(); for (name, provider) in defaults.providers { cfg.providers.entry(name).or_insert(provider); } if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() { cfg.providers .entry("claude".to_string()) .or_insert(claude_provider); let claude_models: [(&str, &str); 3] = [ ("claude-opus-4-8", "claude-opus-4-8"), ("claude-sonnet-5", "claude-sonnet-5"), ("claude-haiku-4-5", "claude-haiku-4-5-20251001"), ]; for (role_name, model_name) in &claude_models { cfg.model_roles .entry(role_name.to_string()) .or_insert(ModelRole { provider: "claude".to_string(), model: model_name.to_string(), max_tokens: Some(8192), context_window: Some(200_000), temperature: Some(0.7), }); } if let Some(custom) = &custom_model { cfg.model_roles .entry(custom.clone()) .or_insert(ModelRole { provider: "claude".to_string(), model: custom.clone(), max_tokens: Some(8192), context_window: Some(200_000), temperature: Some(0.7), }); } if cfg.default_provider == defaults.default_provider { cfg.default_provider = "claude".to_string(); cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-4-8".to_string()); } } Ok(cfg) } fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> { std::fs::create_dir_all(base_dir)?; let path = base_dir.join("app_config.json"); write_json_atomic(&path, config, None)?; Ok(()) } }