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 serde::{Deserialize, Serialize};
20use std::env;
21use std::path::PathBuf;
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 {
81    true
82}
83fn default_max_tokens() -> u32 {
84    16384
85}
86fn default_approve_mode() -> Option<String> {
87    Some("ask".to_string())
88}
89
90/// Type alias for compatibility
91pub type Config = MatrixConfig;
92
93/// Claude Code settings.json structure.
94#[derive(Debug, Clone, Deserialize)]
95struct ClaudeSettings {
96    #[serde(default)]
97    env: Option<ClaudeEnv>,
98
99    /// If true, skip dangerous mode permission prompts -> approve_mode = "auto"
100    #[serde(default, rename = "skipDangerousModePermissionPrompt")]
101    skip_dangerous_mode_permission_prompt: Option<bool>,
102}
103
104/// Environment variables from Claude Code settings.
105/// Uses SCREAMING_SNAKE_CASE to match Claude Code convention.
106#[derive(Debug, Clone, Deserialize)]
107#[allow(non_snake_case)]
108struct ClaudeEnv {
109    #[serde(default)]
110    ANTHROPIC_AUTH_TOKEN: Option<String>,
111
112    #[serde(default)]
113    ANTHROPIC_BASE_URL: Option<String>,
114
115    #[serde(default)]
116    ANTHROPIC_MODEL: Option<String>,
117
118    #[serde(default)]
119    ANTHROPIC_DEFAULT_HAIKU_MODEL: Option<String>,
120
121    #[serde(default)]
122    ANTHROPIC_REASONING_MODEL: Option<String>,
123}
124
125impl MatrixConfig {
126    /// Get the home directory.
127    fn home_dir() -> Option<PathBuf> {
128        env::var_os("HOME")
129            .or_else(|| env::var_os("USERPROFILE"))
130            .map(PathBuf::from)
131    }
132
133    /// Path to matrixcode config file.
134    pub fn matrix_config_path() -> Option<PathBuf> {
135        Self::home_dir().map(|h| h.join(".matrix").join("config.json"))
136    }
137
138    /// Path to cc-switch settings file.
139    pub fn claude_settings_path() -> Option<PathBuf> {
140        Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
141    }
142
143    /// Load matrixcode's own config file.
144    fn load_matrix_config() -> Option<Self> {
145        let path = Self::matrix_config_path()?;
146        if !path.exists() {
147            return None;
148        }
149
150        let content = match std::fs::read_to_string(&path) {
151            Ok(c) => c,
152            Err(e) => {
153                log::warn!("Failed to read ~/.matrix/config.json: {}", e);
154                return None;
155            }
156        };
157        let config: Self = match serde_json::from_str(&content) {
158            Ok(c) => c,
159            Err(e) => {
160                log::warn!("Failed to parse ~/.matrix/config.json: {}", e);
161                return None;
162            }
163        };
164
165        Some(config)
166    }
167
168    /// Load Claude Code settings and convert to matrixcode config.
169    fn load_ccswitch_config() -> Option<Self> {
170        let path = Self::claude_settings_path()?;
171        if !path.exists() {
172            return None;
173        }
174
175        let content = match std::fs::read_to_string(&path) {
176            Ok(c) => c,
177            Err(e) => {
178                log::warn!("Failed to read ~/.claude/settings.json: {}", e);
179                return None;
180            }
181        };
182        let settings: ClaudeSettings = match serde_json::from_str(&content) {
183            Ok(s) => s,
184            Err(e) => {
185                log::warn!("Failed to parse ~/.claude/settings.json: {}", e);
186                return None;
187            }
188        };
189
190        let env = settings.env?;
191
192        // Convert skip_dangerous_mode_permission_prompt to approve_mode
193        let approve_mode = if settings.skip_dangerous_mode_permission_prompt == Some(true) {
194            Some("auto".to_string())
195        } else {
196            None
197        };
198
199        // Convert Claude Code env to matrixcode config (same field names now)
200        let config = Self {
201            provider: Some("anthropic".to_string()),
202            api_key: env.ANTHROPIC_AUTH_TOKEN,
203            base_url: env.ANTHROPIC_BASE_URL,
204            model: env.ANTHROPIC_MODEL,
205            think: true,
206            markdown: true,
207            max_tokens: 16384,
208            context_size: None,
209            multi_model: None,
210            plan_model: env.ANTHROPIC_REASONING_MODEL,
211            compress_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
212            fast_model: None,
213            approve_mode,
214        };
215
216        Some(config)
217    }
218
219    /// Load configuration with fallback chain.
220    /// Priority: CLI args > ~/.matrix/config.json > ~/.claude/settings.json > env vars
221    ///
222    /// Fields are merged: matrix config values take precedence, missing fields
223    /// fall back to Claude settings, then to defaults/env vars.
224    pub fn load() -> Self {
225        // Try matrixcode's own config first
226        let matrix_config = Self::load_matrix_config();
227        // Load Claude settings as fallback source
228        let claude_config = Self::load_ccswitch_config();
229
230        // Merge: matrix config takes precedence, fallback to Claude for missing fields
231        match (matrix_config, claude_config) {
232            (Some(mx), Some(cc)) => {
233                // Check if we need fallback
234                let needs_fallback =
235                    mx.api_key.is_none() || mx.model.is_none() || mx.base_url.is_none();
236
237                // Merge: matrix values take precedence
238                // For approve_mode: use matrix config, or default to "ask" (not Claude's auto)
239                let approve_mode = mx.approve_mode.or(Some("ask".to_string()));
240
241                let merged = Self {
242                    provider: mx.provider.or(cc.provider),
243                    api_key: mx.api_key.or(cc.api_key),
244                    base_url: mx.base_url.or(cc.base_url),
245                    model: mx.model.or(cc.model),
246                    think: mx.think,
247                    markdown: mx.markdown,
248                    max_tokens: mx.max_tokens,
249                    context_size: mx.context_size.or(cc.context_size),
250                    multi_model: mx.multi_model.or(cc.multi_model),
251                    plan_model: mx.plan_model.or(cc.plan_model),
252                    compress_model: mx.compress_model.or(cc.compress_model),
253                    fast_model: mx.fast_model.or(cc.fast_model),
254                    approve_mode,
255                };
256
257                // Show which config source(s) are being used
258                if needs_fallback {
259                    println!(
260                        "[config: ~/.matrix/config.json + fallback from ~/.claude/settings.json]"
261                    );
262                } else {
263                    println!("[config: ~/.matrix/config.json]");
264                }
265                merged
266            }
267            (Some(mx), None) => {
268                println!("[config: ~/.matrix/config.json]");
269                // Ensure approve_mode has default
270                if mx.approve_mode.is_none() {
271                    Self {
272                        approve_mode: Some("ask".to_string()),
273                        ..mx
274                    }
275                } else {
276                    mx
277                }
278            }
279            (None, Some(cc)) => {
280                println!("[config: ~/.claude/settings.json (Claude Code)]");
281                // Override Claude's approve_mode to "ask" by default
282                // MatrixCode defaults to ask mode for safety
283                Self {
284                    approve_mode: Some("ask".to_string()),
285                    ..cc
286                }
287            }
288            (None, None) => {
289                println!("[config: using defaults and environment variables]");
290                Self::default()
291            }
292        }
293    }
294
295    /// Get API key, with fallback to environment variable.
296    /// Uses Claude Code style: ANTHROPIC_AUTH_TOKEN
297    pub fn get_api_key(&self, provider: &str) -> Option<String> {
298        match provider {
299            "openai" => env::var("OPENAI_API_KEY").ok(),
300            _ => env::var("ANTHROPIC_AUTH_TOKEN")
301                .or_else(|_| env::var("ANTHROPIC_API_KEY")) // fallback for compatibility
302                .ok(),
303        }
304        .or(self.api_key.clone())
305    }
306
307    /// Get model name, with fallback to environment variable.
308    /// Uses Claude Code style: ANTHROPIC_MODEL
309    pub fn get_model(&self, provider: &str) -> String {
310        env::var("ANTHROPIC_MODEL")
311            .or_else(|_| env::var("MODEL_NAME")) // fallback for compatibility
312            .ok()
313            .or(self.model.clone())
314            .unwrap_or_else(|| match provider {
315                "openai" => "gpt-4o".to_string(),
316                _ => "claude-sonnet-4-20250514".to_string(),
317            })
318    }
319
320    /// Get base URL, with fallback to environment variable.
321    /// Uses Claude Code style: ANTHROPIC_BASE_URL
322    pub fn get_base_url(&self, provider: &str) -> String {
323        env::var("ANTHROPIC_BASE_URL")
324            .or_else(|_| env::var("BASE_URL")) // fallback for compatibility
325            .ok()
326            .or(self.base_url.clone())
327            .unwrap_or_else(|| match provider {
328                "openai" => "https://api.openai.com/v1".to_string(),
329                _ => "https://api.anthropic.com".to_string(),
330            })
331    }
332
333    /// Save configuration to ~/.matrix/config.json.
334    pub fn save(&self) -> anyhow::Result<()> {
335        let path = Self::matrix_config_path()
336            .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
337
338        // Create directory if needed
339        let dir = path
340            .parent()
341            .ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
342        if !dir.exists() {
343            std::fs::create_dir_all(dir)?;
344        }
345
346        let content = serde_json::to_string_pretty(self)?;
347        std::fs::write(&path, content)?;
348
349        println!("[config saved to ~/.matrix/config.json]");
350        Ok(())
351    }
352
353    /// Check if API is configured (either via config file or environment variable).
354    /// Uses Claude Code style: ANTHROPIC_AUTH_TOKEN
355    pub fn is_api_configured(&self) -> bool {
356        self.api_key.is_some() || env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some()
357    }
358}
359
360/// Create a default config file for new users.
361/// Uses Claude Code style field names.
362pub fn create_default_config() -> anyhow::Result<()> {
363    let config = MatrixConfig {
364        provider: Some("anthropic".to_string()),
365        api_key: None,  // ANTHROPIC_AUTH_TOKEN - user should fill
366        base_url: None, // ANTHROPIC_BASE_URL
367        model: None,    // ANTHROPIC_MODEL - will fallback to Claude settings
368        think: true,
369        markdown: true,
370        max_tokens: 16384,
371        context_size: None,
372        multi_model: Some(false),
373        plan_model: None,     // ANTHROPIC_REASONING_MODEL
374        compress_model: None, // ANTHROPIC_DEFAULT_HAIKU_MODEL
375        fast_model: None,
376        approve_mode: Some("ask".to_string()),
377    };
378
379    config.save()?;
380    println!("\nConfig file created at ~/.matrix/config.json");
381    println!("Fields use Claude Code naming convention:");
382    println!("  ANTHROPIC_AUTH_TOKEN      - API key");
383    println!("  ANTHROPIC_BASE_URL        - API endpoint");
384    println!("  ANTHROPIC_MODEL           - Main model");
385    println!("  ANTHROPIC_REASONING_MODEL - Planning model");
386    println!("  ANTHROPIC_DEFAULT_HAIKU_MODEL - Compression model");
387    println!("\nLeave fields as null to fallback to ~/.claude/settings.json");
388    Ok(())
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_default_config_values() {
397        let config = MatrixConfig {
398            provider: None,
399            api_key: None,
400            base_url: None,
401            model: None,
402            think: true,
403            markdown: true,
404            max_tokens: 16384,
405            context_size: None,
406            multi_model: None,
407            plan_model: None,
408            compress_model: None,
409            fast_model: None,
410            approve_mode: None,
411        };
412        assert!(config.api_key.is_none());
413        assert!(config.model.is_none());
414        assert!(config.think);
415        assert!(config.markdown);
416        assert_eq!(config.max_tokens, 16384);
417    }
418
419    #[test]
420    fn test_claude_code_field_names() {
421        // Verify serde renames work correctly
422        let json = r#"{
423            "ANTHROPIC_AUTH_TOKEN": "test-key",
424            "ANTHROPIC_BASE_URL": "https://test.com",
425            "ANTHROPIC_MODEL": "test-model",
426            "ANTHROPIC_REASONING_MODEL": "reasoning-model",
427            "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
428        }"#;
429
430        let config: MatrixConfig = serde_json::from_str(json).unwrap();
431        assert_eq!(config.api_key, Some("test-key".to_string()));
432        assert_eq!(config.base_url, Some("https://test.com".to_string()));
433        assert_eq!(config.model, Some("test-model".to_string()));
434        assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
435        assert_eq!(config.compress_model, Some("haiku-model".to_string()));
436    }
437
438    #[test]
439    fn test_serialization_uses_claude_names() {
440        let config = MatrixConfig {
441            api_key: Some("key".to_string()),
442            model: Some("model".to_string()),
443            ..Default::default()
444        };
445
446        let json = serde_json::to_string(&config).unwrap();
447        // Should use Claude Code field names
448        assert!(json.contains("ANTHROPIC_AUTH_TOKEN"));
449        assert!(json.contains("ANTHROPIC_MODEL"));
450    }
451}