Skip to main content

matrixcode_core/
config.rs

1//! Configuration loading for matrixcode.
2//!
3//! Variable names are aligned with Claude Code for consistency:
4//! - ANTHROPIC_AUTH_TOKEN (API key)
5//! - ANTHROPIC_BASE_URL
6//! - ANTHROPIC_MODEL
7//! - ANTHROPIC_DEFAULT_SONNET_MODEL
8//! - ANTHROPIC_DEFAULT_HAIKU_MODEL (compress model)
9//! - ANTHROPIC_REASONING_MODEL (plan model)
10//!
11//! Priority:
12//! 1. CLI arguments (highest priority)
13//! 2. ~/.matrix/config.json (matrixcode's own config)
14//! 3. ~/.claude/settings.json (Claude Code config)
15//! 4. Environment variables
16//!
17//! This allows seamless sharing of settings between matrixcode and Claude Code.
18
19use std::path::PathBuf;
20use std::env;
21use serde::{Deserialize, Serialize};
22
23/// Matrixcode configuration file structure.
24/// Field names align with Claude Code conventions.
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct MatrixConfig {
27    /// LLM provider: "anthropic" or "openai"
28    #[serde(default)]
29    pub provider: Option<String>,
30    
31    /// API key (Claude Code style: ANTHROPIC_AUTH_TOKEN)
32    #[serde(default, rename = "ANTHROPIC_AUTH_TOKEN")]
33    pub api_key: Option<String>,
34    
35    /// Base URL for API endpoint
36    #[serde(default, rename = "ANTHROPIC_BASE_URL")]
37    pub base_url: Option<String>,
38    
39    /// Main model name
40    #[serde(default, rename = "ANTHROPIC_MODEL")]
41    pub model: Option<String>,
42    
43    /// Enable extended thinking
44    #[serde(default = "default_true")]
45    pub think: bool,
46    
47    /// Enable markdown rendering
48    #[serde(default = "default_true")]
49    pub markdown: bool,
50    
51    /// Maximum output tokens
52    #[serde(default = "default_max_tokens")]
53    pub max_tokens: u32,
54    
55    /// Context size
56    #[serde(default)]
57    pub context_size: Option<u32>,
58    
59    /// Multi-model configuration
60    #[serde(default)]
61    pub multi_model: Option<bool>,
62    
63    /// Plan/reasoning model (Claude Code style: ANTHROPIC_REASONING_MODEL)
64    #[serde(default, rename = "ANTHROPIC_REASONING_MODEL")]
65    pub plan_model: Option<String>,
66    
67    /// Compress/haiku model (Claude Code style: ANTHROPIC_DEFAULT_HAIKU_MODEL)
68    #[serde(default, rename = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
69    pub compress_model: Option<String>,
70    
71    /// Fast model
72    #[serde(default)]
73    pub fast_model: Option<String>,
74    
75    /// Approve mode: "ask", "auto", "strict"
76    #[serde(default = "default_approve_mode")]
77    pub approve_mode: Option<String>,
78}
79
80fn default_true() -> bool { true }
81fn default_max_tokens() -> u32 { 16384 }
82fn default_approve_mode() -> Option<String> { Some("ask".to_string()) }
83
84/// Type alias for compatibility
85pub type Config = MatrixConfig;
86
87/// Claude Code settings.json structure.
88#[derive(Debug, Clone, Deserialize)]
89struct ClaudeSettings {
90    #[serde(default)]
91    env: Option<ClaudeEnv>,
92    
93    /// If true, skip dangerous mode permission prompts -> approve_mode = "auto"
94    #[serde(default, rename = "skipDangerousModePermissionPrompt")]
95    skip_dangerous_mode_permission_prompt: Option<bool>,
96}
97
98/// Environment variables from Claude Code settings.
99/// Uses SCREAMING_SNAKE_CASE to match Claude Code convention.
100#[derive(Debug, Clone, Deserialize)]
101#[allow(non_snake_case)]
102struct ClaudeEnv {
103    #[serde(default)]
104    ANTHROPIC_AUTH_TOKEN: Option<String>,
105    
106    #[serde(default)]
107    ANTHROPIC_BASE_URL: Option<String>,
108    
109    #[serde(default)]
110    ANTHROPIC_MODEL: Option<String>,
111    
112    #[serde(default)]
113    ANTHROPIC_DEFAULT_HAIKU_MODEL: Option<String>,
114    
115    #[serde(default)]
116    ANTHROPIC_REASONING_MODEL: Option<String>,
117}
118
119impl MatrixConfig {
120    /// Get the home directory.
121    fn home_dir() -> Option<PathBuf> {
122        env::var_os("HOME")
123            .or_else(|| env::var_os("USERPROFILE"))
124            .map(PathBuf::from)
125    }
126    
127    /// Path to matrixcode config file.
128    pub fn matrix_config_path() -> Option<PathBuf> {
129        Self::home_dir().map(|h| h.join(".matrix").join("config.json"))
130    }
131    
132    /// Path to cc-switch settings file.
133    pub fn claude_settings_path() -> Option<PathBuf> {
134        Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
135    }
136    
137    /// Load matrixcode's own config file.
138    fn load_matrix_config() -> Option<Self> {
139        let path = Self::matrix_config_path()?;
140        if !path.exists() {
141            return None;
142        }
143        
144        let content = std::fs::read_to_string(&path).ok()?;
145        let config: Self = serde_json::from_str(&content).ok()?;
146        
147        // Don't print here - we'll print after merge
148        Some(config)
149    }
150    
151    /// Load Claude Code settings and convert to matrixcode config.
152    fn load_ccswitch_config() -> Option<Self> {
153        let path = Self::claude_settings_path()?;
154        if !path.exists() {
155            return None;
156        }
157        
158        let content = std::fs::read_to_string(&path).ok()?;
159        let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
160        
161        let env = settings.env?;
162        
163        // Convert skip_dangerous_mode_permission_prompt to approve_mode
164        let approve_mode = if settings.skip_dangerous_mode_permission_prompt == Some(true) {
165            Some("auto".to_string())
166        } else {
167            None
168        };
169        
170        // Convert Claude Code env to matrixcode config (same field names now)
171        let config = Self {
172            provider: Some("anthropic".to_string()),
173            api_key: env.ANTHROPIC_AUTH_TOKEN,
174            base_url: env.ANTHROPIC_BASE_URL,
175            model: env.ANTHROPIC_MODEL,
176            think: true,
177            markdown: true,
178            max_tokens: 16384,
179            context_size: None,
180            multi_model: None,
181            plan_model: env.ANTHROPIC_REASONING_MODEL,
182            compress_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
183            fast_model: None,
184            approve_mode,
185        };
186        
187        Some(config)
188    }
189    
190    /// Load configuration with fallback chain.
191    /// Priority: CLI args > ~/.matrix/config.json > ~/.claude/settings.json > env vars
192    /// 
193    /// Fields are merged: matrix config values take precedence, missing fields
194    /// fall back to Claude settings, then to defaults/env vars.
195    pub fn load() -> Self {
196        // Try matrixcode's own config first
197        let matrix_config = Self::load_matrix_config();
198        // Load Claude settings as fallback source
199        let claude_config = Self::load_ccswitch_config();
200        
201        // Merge: matrix config takes precedence, fallback to Claude for missing fields
202        match (matrix_config, claude_config) {
203            (Some(mx), Some(cc)) => {
204                // Check if we need fallback
205                let needs_fallback = mx.api_key.is_none() || mx.model.is_none() || mx.base_url.is_none();
206                
207                // Merge: matrix values take precedence
208                // For approve_mode: use matrix config, or default to "ask" (not Claude's auto)
209                let approve_mode = mx.approve_mode.or(Some("ask".to_string()));
210                
211                let merged = Self {
212                    provider: mx.provider.or(cc.provider),
213                    api_key: mx.api_key.or(cc.api_key),
214                    base_url: mx.base_url.or(cc.base_url),
215                    model: mx.model.or(cc.model),
216                    think: mx.think,
217                    markdown: mx.markdown,
218                    max_tokens: mx.max_tokens,
219                    context_size: mx.context_size.or(cc.context_size),
220                    multi_model: mx.multi_model.or(cc.multi_model),
221                    plan_model: mx.plan_model.or(cc.plan_model),
222                    compress_model: mx.compress_model.or(cc.compress_model),
223                    fast_model: mx.fast_model.or(cc.fast_model),
224                    approve_mode,
225                };
226                
227                // Show which config source(s) are being used
228                if needs_fallback {
229                    println!("[config: ~/.matrix/config.json + fallback from ~/.claude/settings.json]");
230                } else {
231                    println!("[config: ~/.matrix/config.json]");
232                }
233                merged
234            }
235            (Some(mx), None) => {
236                println!("[config: ~/.matrix/config.json]");
237                // Ensure approve_mode has default
238                if mx.approve_mode.is_none() {
239                    Self {
240                        approve_mode: Some("ask".to_string()),
241                        ..mx
242                    }
243                } else {
244                    mx
245                }
246            }
247            (None, Some(cc)) => {
248                println!("[config: ~/.claude/settings.json (Claude Code)]");
249                // Override Claude's approve_mode to "ask" by default
250                // MatrixCode defaults to ask mode for safety
251                Self {
252                    approve_mode: Some("ask".to_string()),
253                    ..cc
254                }
255            }
256            (None, None) => {
257                println!("[config: using defaults and environment variables]");
258                Self::default()
259            }
260        }
261    }
262    
263    /// Get API key, with fallback to environment variable.
264    /// Uses Claude Code style: ANTHROPIC_AUTH_TOKEN
265    pub fn get_api_key(&self, provider: &str) -> Option<String> {
266        match provider {
267            "openai" => env::var("OPENAI_API_KEY").ok(),
268            _ => env::var("ANTHROPIC_AUTH_TOKEN")
269                .or_else(|_| env::var("ANTHROPIC_API_KEY"))  // fallback for compatibility
270                .ok(),
271        }
272        .or(self.api_key.clone())
273    }
274    
275    /// Get model name, with fallback to environment variable.
276    /// Uses Claude Code style: ANTHROPIC_MODEL
277    pub fn get_model(&self, provider: &str) -> String {
278        env::var("ANTHROPIC_MODEL")
279            .or_else(|_| env::var("MODEL_NAME"))  // fallback for compatibility
280            .ok()
281            .or(self.model.clone())
282            .unwrap_or_else(|| match provider {
283                "openai" => "gpt-4o".to_string(),
284                _ => "claude-sonnet-4-20250514".to_string(),
285            })
286    }
287    
288    /// Get base URL, with fallback to environment variable.
289    /// Uses Claude Code style: ANTHROPIC_BASE_URL
290    pub fn get_base_url(&self, provider: &str) -> String {
291        env::var("ANTHROPIC_BASE_URL")
292            .or_else(|_| env::var("BASE_URL"))  // fallback for compatibility
293            .ok()
294            .or(self.base_url.clone())
295            .unwrap_or_else(|| match provider {
296                "openai" => "https://api.openai.com/v1".to_string(),
297                _ => "https://api.anthropic.com".to_string(),
298            })
299    }
300    
301    /// Save configuration to ~/.matrix/config.json.
302    pub fn save(&self) -> anyhow::Result<()> {
303        let path = Self::matrix_config_path()
304            .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
305        
306        // Create directory if needed
307        let dir = path.parent().ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
308        if !dir.exists() {
309            std::fs::create_dir_all(dir)?;
310        }
311        
312        let content = serde_json::to_string_pretty(self)?;
313        std::fs::write(&path, content)?;
314        
315        println!("[config saved to ~/.matrix/config.json]");
316        Ok(())
317    }
318}
319
320/// Create a default config file for new users.
321/// Uses Claude Code style field names.
322pub fn create_default_config() -> anyhow::Result<()> {
323    let config = MatrixConfig {
324        provider: Some("anthropic".to_string()),
325        api_key: None,  // ANTHROPIC_AUTH_TOKEN - user should fill
326        base_url: None, // ANTHROPIC_BASE_URL
327        model: None,    // ANTHROPIC_MODEL - will fallback to Claude settings
328        think: true,
329        markdown: true,
330        max_tokens: 16384,
331        context_size: None,
332        multi_model: Some(false),
333        plan_model: None,    // ANTHROPIC_REASONING_MODEL
334        compress_model: None, // ANTHROPIC_DEFAULT_HAIKU_MODEL
335        fast_model: None,
336        approve_mode: Some("ask".to_string()),
337    };
338    
339    config.save()?;
340    println!("\nConfig file created at ~/.matrix/config.json");
341    println!("Fields use Claude Code naming convention:");
342    println!("  ANTHROPIC_AUTH_TOKEN      - API key");
343    println!("  ANTHROPIC_BASE_URL        - API endpoint");
344    println!("  ANTHROPIC_MODEL           - Main model");
345    println!("  ANTHROPIC_REASONING_MODEL - Planning model");
346    println!("  ANTHROPIC_DEFAULT_HAIKU_MODEL - Compression model");
347    println!("\nLeave fields as null to fallback to ~/.claude/settings.json");
348    Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    
355    #[test]
356    fn test_default_config_values() {
357        let config = MatrixConfig {
358            provider: None,
359            api_key: None,
360            base_url: None,
361            model: None,
362            think: true,
363            markdown: true,
364            max_tokens: 16384,
365            context_size: None,
366            multi_model: None,
367            plan_model: None,
368            compress_model: None,
369            fast_model: None,
370            approve_mode: None,
371        };
372        assert!(config.api_key.is_none());
373        assert!(config.model.is_none());
374        assert!(config.think);
375        assert!(config.markdown);
376        assert_eq!(config.max_tokens, 16384);
377    }
378    
379    #[test]
380    fn test_claude_code_field_names() {
381        // Verify serde renames work correctly
382        let json = r#"{
383            "ANTHROPIC_AUTH_TOKEN": "test-key",
384            "ANTHROPIC_BASE_URL": "https://test.com",
385            "ANTHROPIC_MODEL": "test-model",
386            "ANTHROPIC_REASONING_MODEL": "reasoning-model",
387            "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
388        }"#;
389        
390        let config: MatrixConfig = serde_json::from_str(json).unwrap();
391        assert_eq!(config.api_key, Some("test-key".to_string()));
392        assert_eq!(config.base_url, Some("https://test.com".to_string()));
393        assert_eq!(config.model, Some("test-model".to_string()));
394        assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
395        assert_eq!(config.compress_model, Some("haiku-model".to_string()));
396    }
397    
398    #[test]
399    fn test_serialization_uses_claude_names() {
400        let config = MatrixConfig {
401            api_key: Some("key".to_string()),
402            model: Some("model".to_string()),
403            ..Default::default()
404        };
405        
406        let json = serde_json::to_string(&config).unwrap();
407        // Should use Claude Code field names
408        assert!(json.contains("ANTHROPIC_AUTH_TOKEN"));
409        assert!(json.contains("ANTHROPIC_MODEL"));
410    }
411}