1use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::env;
24use std::path::PathBuf;
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct MatrixConfig {
30 #[serde(default)]
32 pub provider: Option<String>,
33
34 #[serde(default, alias = "ANTHROPIC_AUTH_TOKEN")]
36 pub api_key: Option<String>,
37
38 #[serde(default, alias = "ANTHROPIC_BASE_URL")]
40 pub base_url: Option<String>,
41
42 #[serde(default, alias = "ANTHROPIC_MODEL")]
44 pub model: Option<String>,
45
46 #[serde(default = "default_true")]
48 pub think: bool,
49
50 #[serde(default = "default_true")]
52 pub markdown: bool,
53
54 #[serde(default = "default_max_tokens")]
56 pub max_tokens: u32,
57
58 #[serde(default)]
60 pub context_size: Option<u32>,
61
62 #[serde(default)]
64 pub multi_model: Option<bool>,
65
66 #[serde(default, alias = "ANTHROPIC_REASONING_MODEL")]
68 pub plan_model: Option<String>,
69
70 #[serde(default, alias = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
72 pub compress_model: Option<String>,
73
74 #[serde(default)]
76 pub fast_model: Option<String>,
77
78 #[serde(default = "default_approve_mode")]
80 pub approve_mode: Option<String>,
81
82 #[serde(default)]
85 pub extra_headers: Option<HashMap<String, String>>,
86}
87
88fn default_true() -> bool {
89 true
90}
91fn default_max_tokens() -> u32 {
92 16384
93}
94fn default_approve_mode() -> Option<String> {
95 Some("ask".to_string())
96}
97
98pub type Config = MatrixConfig;
100
101#[derive(Debug, Clone, Deserialize)]
103struct ClaudeSettings {
104 #[serde(default)]
105 env: Option<ClaudeEnv>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
110#[allow(non_snake_case)]
111struct ClaudeEnv {
112 #[serde(default)]
113 ANTHROPIC_AUTH_TOKEN: Option<String>,
114 #[serde(default)]
115 ANTHROPIC_BASE_URL: Option<String>,
116 #[serde(default)]
117 ANTHROPIC_MODEL: Option<String>,
118 #[serde(default)]
119 ANTHROPIC_DEFAULT_HAIKU_MODEL: Option<String>,
120 #[serde(default)]
121 ANTHROPIC_REASONING_MODEL: Option<String>,
122}
123
124impl MatrixConfig {
125 fn home_dir() -> Option<PathBuf> {
127 env::var_os("HOME")
128 .or_else(|| env::var_os("USERPROFILE"))
129 .map(PathBuf::from)
130 }
131
132 pub fn matrix_config_path() -> Option<PathBuf> {
134 Self::home_dir().map(|h| h.join(".matrix").join("config.json"))
135 }
136
137 pub fn claude_settings_path() -> Option<PathBuf> {
139 Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
140 }
141
142 fn load_matrix_config() -> Option<Self> {
144 let path = Self::matrix_config_path()?;
145 if !path.exists() {
146 return None;
147 }
148
149 let content = match std::fs::read_to_string(&path) {
150 Ok(c) => c,
151 Err(e) => {
152 log::warn!("Failed to read ~/.matrix/config.json: {}", e);
153 return None;
154 }
155 };
156 let config: Self = match serde_json::from_str(&content) {
157 Ok(c) => c,
158 Err(e) => {
159 log::warn!("Failed to parse ~/.matrix/config.json: {}", e);
160 return None;
161 }
162 };
163
164 Some(config)
165 }
166
167 fn load_claude_settings() -> Option<Self> {
169 let path = Self::claude_settings_path()?;
170 if !path.exists() {
171 return None;
172 }
173
174 let content = match std::fs::read_to_string(&path) {
175 Ok(c) => c,
176 Err(e) => {
177 log::warn!("Failed to read ~/.claude/settings.json: {}", e);
178 return None;
179 }
180 };
181 let settings: ClaudeSettings = match serde_json::from_str(&content) {
182 Ok(s) => s,
183 Err(e) => {
184 log::warn!("Failed to parse ~/.claude/settings.json: {}", e);
185 return None;
186 }
187 };
188
189 let env = settings.env?;
190 Some(Self {
191 provider: Some("anthropic".to_string()),
192 api_key: env.ANTHROPIC_AUTH_TOKEN,
193 base_url: env.ANTHROPIC_BASE_URL,
194 model: env.ANTHROPIC_MODEL,
195 think: true,
196 markdown: true,
197 max_tokens: 16384,
198 context_size: None,
199 multi_model: None,
200 plan_model: env.ANTHROPIC_REASONING_MODEL,
201 compress_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
202 fast_model: None,
203 approve_mode: Some("ask".to_string()),
204 extra_headers: None,
205 })
206 }
207
208 pub fn load() -> Self {
211 let matrix_config = Self::load_matrix_config();
212 let claude_config = Self::load_claude_settings();
213
214 if matrix_config.is_none() && claude_config.is_none() {
216 let _ = create_example_config();
217 println!("[config: No config found. Example created at ~/.matrix/config.example.json]");
218 println!("\nTo configure, create ~/.matrix/config.json with:");
219 println!(" {{");
220 println!(" \"provider\": \"anthropic\",");
221 println!(" \"api_key\": \"your-api-key\",");
222 println!(" \"model\": \"claude-sonnet-4-20250514\"");
223 println!(" }}\n");
224 }
225
226 match (matrix_config, claude_config) {
227 (Some(mx), Some(cc)) => {
228 let needs_fallback = mx.api_key.is_none() || mx.model.is_none() || mx.base_url.is_none();
230 if needs_fallback {
231 println!("[config: ~/.matrix/config.json + fallback from ~/.claude/settings.json]");
232 } else {
233 println!("[config: ~/.matrix/config.json]");
234 }
235 Self {
236 provider: mx.provider.or(cc.provider),
237 api_key: mx.api_key.or(cc.api_key),
238 base_url: mx.base_url.or(cc.base_url),
239 model: mx.model.or(cc.model),
240 think: mx.think,
241 markdown: mx.markdown,
242 max_tokens: mx.max_tokens,
243 context_size: mx.context_size.or(cc.context_size),
244 multi_model: mx.multi_model.or(cc.multi_model),
245 plan_model: mx.plan_model.or(cc.plan_model),
246 compress_model: mx.compress_model.or(cc.compress_model),
247 fast_model: mx.fast_model.or(cc.fast_model),
248 approve_mode: mx.approve_mode.or(Some("ask".to_string())),
249 extra_headers: mx.extra_headers.or(cc.extra_headers),
250 }
251 }
252 (Some(mx), None) => {
253 println!("[config: ~/.matrix/config.json]");
254 Self {
255 approve_mode: mx.approve_mode.or(Some("ask".to_string())),
256 ..mx
257 }
258 }
259 (None, Some(cc)) => {
260 println!("[config: ~/.claude/settings.json (Claude Code fallback)]");
261 cc
262 }
263 (None, None) => {
264 println!("[config: using environment variables and defaults]");
265 Self::default()
266 }
267 }
268 }
269
270 pub fn get_api_key(&self, provider: &str) -> Option<String> {
273 let env_key = env::var("API_KEY").ok()
275 .or_else(|| match provider {
277 "openai" => env::var("OPENAI_API_KEY").ok(),
278 _ => env::var("ANTHROPIC_AUTH_TOKEN").ok()
279 .or_else(|| env::var("ANTHROPIC_API_KEY").ok()),
280 });
281 env_key.or(self.api_key.clone())
283 }
284
285 pub fn get_model(&self, provider: &str) -> String {
288 env::var("MODEL").ok()
289 .or_else(|| env::var("ANTHROPIC_MODEL").ok())
290 .or_else(|| env::var("MODEL_NAME").ok())
291 .or(self.model.clone())
292 .unwrap_or_else(|| match provider {
293 "openai" => "gpt-4o".to_string(),
294 _ => "claude-sonnet-4-20250514".to_string(),
295 })
296 }
297
298 pub fn get_base_url(&self, provider: &str) -> String {
301 env::var("BASE_URL").ok()
302 .or_else(|| env::var("ANTHROPIC_BASE_URL").ok())
303 .or(self.base_url.clone())
304 .unwrap_or_else(|| match provider {
305 "openai" => "https://api.openai.com/v1".to_string(),
306 _ => "https://api.anthropic.com".to_string(),
307 })
308 }
309
310 pub fn save(&self) -> anyhow::Result<()> {
312 let path = Self::matrix_config_path()
313 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
314
315 let dir = path
317 .parent()
318 .ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
319 if !dir.exists() {
320 std::fs::create_dir_all(dir)?;
321 }
322
323 let content = serde_json::to_string_pretty(self)?;
324 std::fs::write(&path, content)?;
325
326 println!("[config saved to ~/.matrix/config.json]");
327 Ok(())
328 }
329
330 pub fn is_api_configured(&self) -> bool {
332 self.api_key.is_some()
333 || env::var("API_KEY").ok().is_some()
334 || env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some()
335 }
336}
337
338pub fn create_default_config() -> anyhow::Result<()> {
340 let config = MatrixConfig {
341 provider: Some("anthropic".to_string()),
342 api_key: None,
343 base_url: None,
344 model: None,
345 think: true,
346 markdown: true,
347 max_tokens: 16384,
348 context_size: None,
349 multi_model: Some(false),
350 plan_model: None,
351 compress_model: None,
352 fast_model: None,
353 approve_mode: Some("ask".to_string()),
354 extra_headers: None,
355 };
356
357 config.save()?;
358
359 create_example_config()?;
361
362 println!("\nConfig file created at ~/.matrix/config.json");
363 println!("Example config with documentation: ~/.matrix/config.example.json");
364 println!("\nRequired fields to fill:");
365 println!(" api_key - Your API key");
366 println!(" model - Model name (e.g. claude-sonnet-4-20250514, gpt-4o, glm-5)");
367 println!("\nOptional fields:");
368 println!(" provider - 'anthropic' or 'openai' (auto-detected from model if not set)");
369 println!(" base_url - API endpoint (uses default if not set)");
370 println!(" extra_headers - Custom HTTP headers for API requests");
371 Ok(())
372}
373
374pub fn create_example_config() -> anyhow::Result<()> {
376 let home = MatrixConfig::home_dir()
377 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
378 let path = home.join(".matrix").join("config.example.json");
379
380 let example = r#"{
381 "_comment": "MatrixCode Configuration Example - Copy this to config.json and fill in your values",
382
383 "provider": "anthropic",
384 "_provider_comment": "API provider: 'anthropic' or 'openai'. Auto-detected from model name if not set.",
385
386 "api_key": "your-api-key-here",
387 "_api_key_comment": "Your API key. Also supports env vars: API_KEY, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY",
388
389 "model": "claude-sonnet-4-20250514",
390 "_model_comment": "Model name. Examples: claude-sonnet-4, claude-opus-4, gpt-4o, glm-5",
391
392 "base_url": null,
393 "_base_url_comment": "API endpoint. Defaults: anthropic=https://api.anthropic.com, openai=https://api.openai.com/v1",
394 "_base_url_examples": ["https://dashscope.aliyuncs.com/compatible-mode/v1 for DashScope"],
395
396 "think": true,
397 "_think_comment": "Enable extended thinking (Anthropic only). Set false for non-Anthropic endpoints.",
398
399 "markdown": true,
400 "_markdown_comment": "Enable markdown rendering in TUI",
401
402 "max_tokens": 16384,
403 "_max_tokens_comment": "Maximum output tokens per request",
404
405 "approve_mode": "ask",
406 "_approve_mode_comment": "Tool approval: 'ask'=prompt each, 'auto'=approve safe, 'strict'=reject dangerous",
407
408 "multi_model": false,
409 "_multi_model_comment": "Enable multi-model configuration",
410
411 "plan_model": null,
412 "_plan_model_comment": "Planning/reasoning model for complex tasks",
413
414 "compress_model": null,
415 "_compress_model_comment": "Fast model for context compression",
416
417 "fast_model": null,
418 "_fast_model_comment": "Fast model for quick operations",
419
420 "extra_headers": {},
421 "_extra_headers_comment": "Custom HTTP headers for API requests (useful for proxy services)",
422 "_extra_headers_example": {"X-DashScope-SSE": "enable"}
423}"#;
424
425 std::fs::write(&path, example)?;
426 Ok(())
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn test_default_config_values() {
435 let config = MatrixConfig {
436 provider: None,
437 api_key: None,
438 base_url: None,
439 model: None,
440 think: true,
441 markdown: true,
442 max_tokens: 16384,
443 context_size: None,
444 multi_model: None,
445 plan_model: None,
446 compress_model: None,
447 fast_model: None,
448 approve_mode: None,
449 };
450 assert!(config.api_key.is_none());
451 assert!(config.model.is_none());
452 assert!(config.think);
453 assert!(config.markdown);
454 assert_eq!(config.max_tokens, 16384);
455 }
456
457 #[test]
458 fn test_universal_field_names() {
459 let json = r#"{
461 "api_key": "test-key",
462 "base_url": "https://test.com",
463 "model": "test-model",
464 "plan_model": "reasoning-model",
465 "compress_model": "haiku-model"
466 }"#;
467
468 let config: MatrixConfig = serde_json::from_str(json).unwrap();
469 assert_eq!(config.api_key, Some("test-key".to_string()));
470 assert_eq!(config.base_url, Some("https://test.com".to_string()));
471 assert_eq!(config.model, Some("test-model".to_string()));
472 assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
473 assert_eq!(config.compress_model, Some("haiku-model".to_string()));
474 }
475
476 #[test]
477 fn test_legacy_alias_names() {
478 let json = r#"{
480 "ANTHROPIC_AUTH_TOKEN": "test-key",
481 "ANTHROPIC_BASE_URL": "https://test.com",
482 "ANTHROPIC_MODEL": "test-model",
483 "ANTHROPIC_REASONING_MODEL": "reasoning-model",
484 "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
485 }"#;
486
487 let config: MatrixConfig = serde_json::from_str(json).unwrap();
488 assert_eq!(config.api_key, Some("test-key".to_string()));
489 assert_eq!(config.base_url, Some("https://test.com".to_string()));
490 assert_eq!(config.model, Some("test-model".to_string()));
491 assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
492 assert_eq!(config.compress_model, Some("haiku-model".to_string()));
493 }
494
495 #[test]
496 fn test_serialization_uses_universal_names() {
497 let config = MatrixConfig {
498 api_key: Some("key".to_string()),
499 model: Some("model".to_string()),
500 ..Default::default()
501 };
502
503 let json = serde_json::to_string(&config).unwrap();
504 assert!(json.contains("api_key"));
506 assert!(json.contains("model"));
507 }
508}