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 fn load_from_env() -> Self {
212 let extra_headers = env::var("EXTRA_HEADERS").ok()
214 .and_then(|json_str| serde_json::from_str::<HashMap<String, String>>(&json_str).ok());
215
216 Self {
217 provider: env::var("PROVIDER").ok(),
218 api_key: env::var("API_KEY").ok()
219 .or_else(|| env::var("ANTHROPIC_AUTH_TOKEN").ok())
220 .or_else(|| env::var("ANTHROPIC_API_KEY").ok()),
221 base_url: env::var("BASE_URL").ok()
222 .or_else(|| env::var("ANTHROPIC_BASE_URL").ok()),
223 model: env::var("MODEL").ok()
224 .or_else(|| env::var("ANTHROPIC_MODEL").ok())
225 .or_else(|| env::var("MODEL_NAME").ok()),
226 think: env::var("THINK").ok()
227 .map(|v| v != "false")
228 .unwrap_or(true),
229 markdown: env::var("MARKDOWN").ok()
230 .map(|v| v != "false")
231 .unwrap_or(true),
232 max_tokens: env::var("MAX_TOKENS").ok()
233 .and_then(|v| v.parse().ok())
234 .unwrap_or(16384),
235 context_size: env::var("CONTEXT_SIZE").ok()
236 .and_then(|v| v.parse().ok()),
237 multi_model: env::var("MULTI_MODEL").ok()
238 .map(|v| v == "true"),
239 plan_model: env::var("ANTHROPIC_REASONING_MODEL").ok(),
240 compress_model: env::var("ANTHROPIC_DEFAULT_HAIKU_MODEL").ok(),
241 fast_model: None,
242 approve_mode: env::var("APPROVE_MODE").ok()
243 .or(Some("ask".to_string())),
244 extra_headers,
245 }
246 }
247
248 pub fn load() -> Self {
251 let env_config = Self::load_from_env();
253 let matrix_config = Self::load_matrix_config();
254 let claude_config = Self::load_claude_settings();
255
256 if matrix_config.is_none() && claude_config.is_none() && env_config.api_key.is_none() {
258 let _ = create_example_config();
259 println!("[config: No config found. Example created at ~/.matrix/config.example.json]");
260 println!("\nTo configure, create ~/.matrix/config.json with:");
261 println!(" {{");
262 println!(" \"provider\": \"anthropic\",");
263 println!(" \"api_key\": \"your-api-key\",");
264 println!(" \"model\": \"claude-sonnet-4-20250514\"");
265 println!(" }}\n");
266 }
267
268 let has_env = env_config.api_key.is_some() || env_config.model.is_some();
270 let has_matrix = matrix_config.is_some();
271 let has_claude = claude_config.is_some();
272
273 let sources: Vec<&str> = [
275 has_env.then_some("env"),
276 has_matrix.then_some("~/.matrix/config.json"),
277 has_claude.then_some("~/.claude/settings.json"),
278 ].iter().flatten().copied().collect();
279 println!("[config: {}]", sources.join(" + "));
280
281 let mut merged = Self::default();
284
285 if let Some(cc) = claude_config {
287 merged.provider = merged.provider.or(cc.provider);
288 merged.api_key = merged.api_key.or(cc.api_key);
289 merged.base_url = merged.base_url.or(cc.base_url);
290 merged.model = merged.model.or(cc.model);
291 merged.think = cc.think; merged.markdown = cc.markdown;
293 merged.max_tokens = cc.max_tokens;
294 merged.context_size = merged.context_size.or(cc.context_size);
295 merged.multi_model = merged.multi_model.or(cc.multi_model);
296 merged.plan_model = merged.plan_model.or(cc.plan_model);
297 merged.compress_model = merged.compress_model.or(cc.compress_model);
298 merged.fast_model = merged.fast_model.or(cc.fast_model);
299 merged.approve_mode = merged.approve_mode.or(cc.approve_mode);
300 merged.extra_headers = merged.extra_headers.or(cc.extra_headers);
301 }
302
303 if let Some(mx) = matrix_config {
305 merged.provider = merged.provider.or(mx.provider);
306 merged.api_key = merged.api_key.or(mx.api_key);
307 merged.base_url = merged.base_url.or(mx.base_url);
308 merged.model = merged.model.or(mx.model);
309 merged.think = mx.think;
310 merged.markdown = mx.markdown;
311 merged.max_tokens = mx.max_tokens;
312 merged.context_size = merged.context_size.or(mx.context_size);
313 merged.multi_model = merged.multi_model.or(mx.multi_model);
314 merged.plan_model = merged.plan_model.or(mx.plan_model);
315 merged.compress_model = merged.compress_model.or(mx.compress_model);
316 merged.fast_model = merged.fast_model.or(mx.fast_model);
317 merged.approve_mode = merged.approve_mode.or(mx.approve_mode);
318 merged.extra_headers = merged.extra_headers.or(mx.extra_headers);
319 }
320
321 merged.provider = env_config.provider.or(merged.provider);
323 merged.api_key = env_config.api_key.or(merged.api_key);
324 merged.base_url = env_config.base_url.or(merged.base_url);
325 merged.model = env_config.model.or(merged.model);
326 merged.think = env_config.think;
327 merged.markdown = env_config.markdown;
328 merged.max_tokens = env_config.max_tokens;
329 merged.context_size = env_config.context_size.or(merged.context_size);
330 merged.multi_model = env_config.multi_model.or(merged.multi_model);
331 merged.plan_model = env_config.plan_model.or(merged.plan_model);
332 merged.compress_model = env_config.compress_model.or(merged.compress_model);
333 merged.fast_model = env_config.fast_model.or(merged.fast_model);
334 merged.approve_mode = env_config.approve_mode.or(merged.approve_mode);
335 merged.extra_headers = env_config.extra_headers.or(merged.extra_headers);
336
337 merged.approve_mode = merged.approve_mode.or(Some("ask".to_string()));
339
340 merged
341 }
342
343 pub fn get_api_key(&self, provider: &str) -> Option<String> {
346 let env_key = env::var("API_KEY").ok()
348 .or_else(|| match provider {
350 "openai" => env::var("OPENAI_API_KEY").ok(),
351 _ => env::var("ANTHROPIC_AUTH_TOKEN").ok()
352 .or_else(|| env::var("ANTHROPIC_API_KEY").ok()),
353 });
354 env_key.or(self.api_key.clone())
356 }
357
358 pub fn get_model(&self, provider: &str) -> String {
361 env::var("MODEL").ok()
362 .or_else(|| env::var("ANTHROPIC_MODEL").ok())
363 .or_else(|| env::var("MODEL_NAME").ok())
364 .or(self.model.clone())
365 .unwrap_or_else(|| match provider {
366 "openai" => "gpt-4o".to_string(),
367 _ => "claude-sonnet-4-20250514".to_string(),
368 })
369 }
370
371 pub fn get_base_url(&self, provider: &str) -> String {
374 env::var("BASE_URL").ok()
375 .or_else(|| env::var("ANTHROPIC_BASE_URL").ok())
376 .or(self.base_url.clone())
377 .unwrap_or_else(|| match provider {
378 "openai" => "https://api.openai.com/v1".to_string(),
379 _ => "https://api.anthropic.com".to_string(),
380 })
381 }
382
383 pub fn save(&self) -> anyhow::Result<()> {
385 let path = Self::matrix_config_path()
386 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
387
388 let dir = path
390 .parent()
391 .ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
392 if !dir.exists() {
393 std::fs::create_dir_all(dir)?;
394 }
395
396 let content = serde_json::to_string_pretty(self)?;
397 std::fs::write(&path, content)?;
398
399 println!("[config saved to ~/.matrix/config.json]");
400 Ok(())
401 }
402
403 pub fn is_api_configured(&self) -> bool {
405 self.api_key.is_some()
406 || env::var("API_KEY").ok().is_some()
407 || env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some()
408 }
409}
410
411pub fn create_default_config() -> anyhow::Result<()> {
413 let config = MatrixConfig {
414 provider: Some("anthropic".to_string()),
415 api_key: None,
416 base_url: None,
417 model: None,
418 think: true,
419 markdown: true,
420 max_tokens: 16384,
421 context_size: None,
422 multi_model: Some(false),
423 plan_model: None,
424 compress_model: None,
425 fast_model: None,
426 approve_mode: Some("ask".to_string()),
427 extra_headers: None,
428 };
429
430 config.save()?;
431
432 create_example_config()?;
434
435 println!("\nConfig file created at ~/.matrix/config.json");
436 println!("Example config with documentation: ~/.matrix/config.example.json");
437 println!("\nRequired fields to fill:");
438 println!(" api_key - Your API key");
439 println!(" model - Model name (e.g. claude-sonnet-4-20250514, gpt-4o, glm-5)");
440 println!("\nOptional fields:");
441 println!(" provider - 'anthropic' or 'openai' (auto-detected from model if not set)");
442 println!(" base_url - API endpoint (uses default if not set)");
443 println!(" extra_headers - Custom HTTP headers for API requests");
444 Ok(())
445}
446
447pub fn create_example_config() -> anyhow::Result<()> {
449 let home = MatrixConfig::home_dir()
450 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
451 let path = home.join(".matrix").join("config.example.json");
452
453 let example = r#"{
454 "_comment": "MatrixCode Configuration Example - Copy this to config.json and fill in your values",
455
456 "provider": "anthropic",
457 "_provider_comment": "API provider: 'anthropic' or 'openai'. Auto-detected from model name if not set.",
458
459 "api_key": "your-api-key-here",
460 "_api_key_comment": "Your API key. Also supports env vars: API_KEY, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY",
461
462 "model": "claude-sonnet-4-20250514",
463 "_model_comment": "Model name. Examples: claude-sonnet-4, claude-opus-4, gpt-4o, glm-5",
464
465 "base_url": null,
466 "_base_url_comment": "API endpoint. Defaults: anthropic=https://api.anthropic.com, openai=https://api.openai.com/v1",
467 "_base_url_examples": ["https://dashscope.aliyuncs.com/compatible-mode/v1 for DashScope"],
468
469 "think": true,
470 "_think_comment": "Enable extended thinking (Anthropic only). Set false for non-Anthropic endpoints.",
471
472 "markdown": true,
473 "_markdown_comment": "Enable markdown rendering in TUI",
474
475 "max_tokens": 16384,
476 "_max_tokens_comment": "Maximum output tokens per request",
477
478 "approve_mode": "ask",
479 "_approve_mode_comment": "Tool approval: 'ask'=prompt each, 'auto'=approve safe, 'strict'=reject dangerous",
480
481 "multi_model": false,
482 "_multi_model_comment": "Enable multi-model configuration",
483
484 "plan_model": null,
485 "_plan_model_comment": "Planning/reasoning model for complex tasks",
486
487 "compress_model": null,
488 "_compress_model_comment": "Fast model for context compression",
489
490 "fast_model": null,
491 "_fast_model_comment": "Fast model for quick operations",
492
493 "extra_headers": {},
494 "_extra_headers_comment": "Custom HTTP headers for API requests (useful for proxy services)",
495 "_extra_headers_example": {"X-DashScope-SSE": "enable"}
496}"#;
497
498 std::fs::write(&path, example)?;
499 Ok(())
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn test_default_config_values() {
508 let config = MatrixConfig {
509 provider: None,
510 api_key: None,
511 base_url: None,
512 model: None,
513 think: true,
514 markdown: true,
515 max_tokens: 16384,
516 context_size: None,
517 multi_model: None,
518 plan_model: None,
519 compress_model: None,
520 fast_model: None,
521 approve_mode: None,
522 extra_headers: None,
523 };
524 assert!(config.api_key.is_none());
525 assert!(config.model.is_none());
526 assert!(config.think);
527 assert!(config.markdown);
528 assert_eq!(config.max_tokens, 16384);
529 }
530
531 #[test]
532 fn test_universal_field_names() {
533 let json = r#"{
535 "api_key": "test-key",
536 "base_url": "https://test.com",
537 "model": "test-model",
538 "plan_model": "reasoning-model",
539 "compress_model": "haiku-model"
540 }"#;
541
542 let config: MatrixConfig = serde_json::from_str(json).unwrap();
543 assert_eq!(config.api_key, Some("test-key".to_string()));
544 assert_eq!(config.base_url, Some("https://test.com".to_string()));
545 assert_eq!(config.model, Some("test-model".to_string()));
546 assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
547 assert_eq!(config.compress_model, Some("haiku-model".to_string()));
548 }
549
550 #[test]
551 fn test_legacy_alias_names() {
552 let json = r#"{
554 "ANTHROPIC_AUTH_TOKEN": "test-key",
555 "ANTHROPIC_BASE_URL": "https://test.com",
556 "ANTHROPIC_MODEL": "test-model",
557 "ANTHROPIC_REASONING_MODEL": "reasoning-model",
558 "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
559 }"#;
560
561 let config: MatrixConfig = serde_json::from_str(json).unwrap();
562 assert_eq!(config.api_key, Some("test-key".to_string()));
563 assert_eq!(config.base_url, Some("https://test.com".to_string()));
564 assert_eq!(config.model, Some("test-model".to_string()));
565 assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
566 assert_eq!(config.compress_model, Some("haiku-model".to_string()));
567 }
568
569 #[test]
570 fn test_serialization_uses_universal_names() {
571 let config = MatrixConfig {
572 api_key: Some("key".to_string()),
573 model: Some("model".to_string()),
574 extra_headers: None,
575 ..Default::default()
576 };
577
578 let json = serde_json::to_string(&config).unwrap();
579 assert!(json.contains("api_key"));
581 assert!(json.contains("model"));
582 }
583}