use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MatrixConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default, alias = "ANTHROPIC_AUTH_TOKEN")]
pub api_key: Option<String>,
#[serde(default, alias = "ANTHROPIC_BASE_URL")]
pub base_url: Option<String>,
#[serde(default, alias = "ANTHROPIC_MODEL")]
pub model: Option<String>,
#[serde(default = "default_true")]
pub think: bool,
#[serde(default = "default_true")]
pub markdown: bool,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
#[serde(default)]
pub context_size: Option<u32>,
#[serde(default)]
pub multi_model: Option<bool>,
#[serde(default, alias = "ANTHROPIC_REASONING_MODEL")]
pub plan_model: Option<String>,
#[serde(default, alias = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
pub compress_model: Option<String>,
#[serde(default)]
pub fast_model: Option<String>,
#[serde(default = "default_approve_mode")]
pub approve_mode: Option<String>,
#[serde(default)]
pub extra_headers: Option<HashMap<String, String>>,
}
fn default_true() -> bool {
true
}
fn default_max_tokens() -> u32 {
16384
}
fn default_approve_mode() -> Option<String> {
Some("ask".to_string())
}
pub type Config = MatrixConfig;
#[derive(Debug, Clone, Deserialize)]
struct ClaudeSettings {
#[serde(default)]
env: Option<ClaudeEnv>,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(non_snake_case)]
struct ClaudeEnv {
#[serde(default)]
ANTHROPIC_AUTH_TOKEN: Option<String>,
#[serde(default)]
ANTHROPIC_BASE_URL: Option<String>,
#[serde(default)]
ANTHROPIC_MODEL: Option<String>,
#[serde(default)]
ANTHROPIC_DEFAULT_HAIKU_MODEL: Option<String>,
#[serde(default)]
ANTHROPIC_REASONING_MODEL: Option<String>,
}
impl MatrixConfig {
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
pub fn matrix_config_path() -> Option<PathBuf> {
Self::home_dir().map(|h| h.join(".matrix").join("config.json"))
}
pub fn claude_settings_path() -> Option<PathBuf> {
Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
}
fn load_matrix_config() -> Option<Self> {
let path = Self::matrix_config_path()?;
if !path.exists() {
return None;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
log::warn!("Failed to read ~/.matrix/config.json: {}", e);
return None;
}
};
let config: Self = match serde_json::from_str(&content) {
Ok(c) => c,
Err(e) => {
log::warn!("Failed to parse ~/.matrix/config.json: {}", e);
return None;
}
};
Some(config)
}
fn load_claude_settings() -> Option<Self> {
let path = Self::claude_settings_path()?;
if !path.exists() {
return None;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
log::warn!("Failed to read ~/.claude/settings.json: {}", e);
return None;
}
};
let settings: ClaudeSettings = match serde_json::from_str(&content) {
Ok(s) => s,
Err(e) => {
log::warn!("Failed to parse ~/.claude/settings.json: {}", e);
return None;
}
};
let env = settings.env?;
Some(Self {
provider: Some("anthropic".to_string()),
api_key: env.ANTHROPIC_AUTH_TOKEN,
base_url: env.ANTHROPIC_BASE_URL,
model: env.ANTHROPIC_MODEL,
think: true,
markdown: true,
max_tokens: 16384,
context_size: None,
multi_model: None,
plan_model: env.ANTHROPIC_REASONING_MODEL,
compress_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
fast_model: None,
approve_mode: Some("ask".to_string()),
extra_headers: None,
})
}
fn load_from_env() -> Self {
let extra_headers = env::var("EXTRA_HEADERS").ok()
.and_then(|json_str| serde_json::from_str::<HashMap<String, String>>(&json_str).ok());
Self {
provider: env::var("PROVIDER").ok(),
api_key: env::var("API_KEY").ok()
.or_else(|| env::var("ANTHROPIC_AUTH_TOKEN").ok())
.or_else(|| env::var("ANTHROPIC_API_KEY").ok()),
base_url: env::var("BASE_URL").ok()
.or_else(|| env::var("ANTHROPIC_BASE_URL").ok()),
model: env::var("MODEL").ok()
.or_else(|| env::var("ANTHROPIC_MODEL").ok())
.or_else(|| env::var("MODEL_NAME").ok()),
think: env::var("THINK").ok()
.map(|v| v != "false")
.unwrap_or(true),
markdown: env::var("MARKDOWN").ok()
.map(|v| v != "false")
.unwrap_or(true),
max_tokens: env::var("MAX_TOKENS").ok()
.and_then(|v| v.parse().ok())
.unwrap_or(16384),
context_size: env::var("CONTEXT_SIZE").ok()
.and_then(|v| v.parse().ok()),
multi_model: env::var("MULTI_MODEL").ok()
.map(|v| v == "true"),
plan_model: env::var("ANTHROPIC_REASONING_MODEL").ok(),
compress_model: env::var("ANTHROPIC_DEFAULT_HAIKU_MODEL").ok(),
fast_model: None,
approve_mode: env::var("APPROVE_MODE").ok()
.or(Some("ask".to_string())),
extra_headers,
}
}
pub fn load() -> Self {
let env_config = Self::load_from_env();
let matrix_config = Self::load_matrix_config();
let claude_config = Self::load_claude_settings();
if matrix_config.is_none() && claude_config.is_none() && env_config.api_key.is_none() {
let _ = create_example_config();
println!("[config: No config found. Example created at ~/.matrix/config.example.json]");
println!("\nTo configure, create ~/.matrix/config.json with:");
println!(" {{");
println!(" \"provider\": \"anthropic\",");
println!(" \"api_key\": \"your-api-key\",");
println!(" \"model\": \"claude-sonnet-4-20250514\"");
println!(" }}\n");
}
let has_env = env_config.api_key.is_some() || env_config.model.is_some();
let has_matrix = matrix_config.is_some();
let has_claude = claude_config.is_some();
let sources: Vec<&str> = [
has_env.then_some("env"),
has_matrix.then_some("~/.matrix/config.json"),
has_claude.then_some("~/.claude/settings.json"),
].iter().flatten().copied().collect();
println!("[config: {}]", sources.join(" + "));
let mut merged = Self::default();
if let Some(cc) = claude_config {
merged.provider = merged.provider.or(cc.provider);
merged.api_key = merged.api_key.or(cc.api_key);
merged.base_url = merged.base_url.or(cc.base_url);
merged.model = merged.model.or(cc.model);
merged.think = cc.think; merged.markdown = cc.markdown;
merged.max_tokens = cc.max_tokens;
merged.context_size = merged.context_size.or(cc.context_size);
merged.multi_model = merged.multi_model.or(cc.multi_model);
merged.plan_model = merged.plan_model.or(cc.plan_model);
merged.compress_model = merged.compress_model.or(cc.compress_model);
merged.fast_model = merged.fast_model.or(cc.fast_model);
merged.approve_mode = merged.approve_mode.or(cc.approve_mode);
merged.extra_headers = merged.extra_headers.or(cc.extra_headers);
}
if let Some(mx) = matrix_config {
merged.provider = merged.provider.or(mx.provider);
merged.api_key = merged.api_key.or(mx.api_key);
merged.base_url = merged.base_url.or(mx.base_url);
merged.model = merged.model.or(mx.model);
merged.think = mx.think;
merged.markdown = mx.markdown;
merged.max_tokens = mx.max_tokens;
merged.context_size = merged.context_size.or(mx.context_size);
merged.multi_model = merged.multi_model.or(mx.multi_model);
merged.plan_model = merged.plan_model.or(mx.plan_model);
merged.compress_model = merged.compress_model.or(mx.compress_model);
merged.fast_model = merged.fast_model.or(mx.fast_model);
merged.approve_mode = merged.approve_mode.or(mx.approve_mode);
merged.extra_headers = merged.extra_headers.or(mx.extra_headers);
}
merged.provider = env_config.provider.or(merged.provider);
merged.api_key = env_config.api_key.or(merged.api_key);
merged.base_url = env_config.base_url.or(merged.base_url);
merged.model = env_config.model.or(merged.model);
merged.think = env_config.think;
merged.markdown = env_config.markdown;
merged.max_tokens = env_config.max_tokens;
merged.context_size = env_config.context_size.or(merged.context_size);
merged.multi_model = env_config.multi_model.or(merged.multi_model);
merged.plan_model = env_config.plan_model.or(merged.plan_model);
merged.compress_model = env_config.compress_model.or(merged.compress_model);
merged.fast_model = env_config.fast_model.or(merged.fast_model);
merged.approve_mode = env_config.approve_mode.or(merged.approve_mode);
merged.extra_headers = env_config.extra_headers.or(merged.extra_headers);
merged.approve_mode = merged.approve_mode.or(Some("ask".to_string()));
merged
}
pub fn get_api_key(&self, provider: &str) -> Option<String> {
let env_key = env::var("API_KEY").ok()
.or_else(|| match provider {
"openai" => env::var("OPENAI_API_KEY").ok(),
_ => env::var("ANTHROPIC_AUTH_TOKEN").ok()
.or_else(|| env::var("ANTHROPIC_API_KEY").ok()),
});
env_key.or(self.api_key.clone())
}
pub fn get_model(&self, provider: &str) -> String {
env::var("MODEL").ok()
.or_else(|| env::var("ANTHROPIC_MODEL").ok())
.or_else(|| env::var("MODEL_NAME").ok())
.or(self.model.clone())
.unwrap_or_else(|| match provider {
"openai" => "gpt-4o".to_string(),
_ => "claude-sonnet-4-20250514".to_string(),
})
}
pub fn get_base_url(&self, provider: &str) -> String {
env::var("BASE_URL").ok()
.or_else(|| env::var("ANTHROPIC_BASE_URL").ok())
.or(self.base_url.clone())
.unwrap_or_else(|| match provider {
"openai" => "https://api.openai.com/v1".to_string(),
_ => "https://api.anthropic.com".to_string(),
})
}
pub fn save(&self) -> anyhow::Result<()> {
let path = Self::matrix_config_path()
.ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
let dir = path
.parent()
.ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content)?;
println!("[config saved to ~/.matrix/config.json]");
Ok(())
}
pub fn is_api_configured(&self) -> bool {
self.api_key.is_some()
|| env::var("API_KEY").ok().is_some()
|| env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some()
}
}
pub fn create_default_config() -> anyhow::Result<()> {
let config = MatrixConfig {
provider: Some("anthropic".to_string()),
api_key: None,
base_url: None,
model: None,
think: true,
markdown: true,
max_tokens: 16384,
context_size: None,
multi_model: Some(false),
plan_model: None,
compress_model: None,
fast_model: None,
approve_mode: Some("ask".to_string()),
extra_headers: None,
};
config.save()?;
create_example_config()?;
println!("\nConfig file created at ~/.matrix/config.json");
println!("Example config with documentation: ~/.matrix/config.example.json");
println!("\nRequired fields to fill:");
println!(" api_key - Your API key");
println!(" model - Model name (e.g. claude-sonnet-4-20250514, gpt-4o, glm-5)");
println!("\nOptional fields:");
println!(" provider - 'anthropic' or 'openai' (auto-detected from model if not set)");
println!(" base_url - API endpoint (uses default if not set)");
println!(" extra_headers - Custom HTTP headers for API requests");
Ok(())
}
pub fn create_example_config() -> anyhow::Result<()> {
let home = MatrixConfig::home_dir()
.ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
let path = home.join(".matrix").join("config.example.json");
let example = r#"{
"_comment": "MatrixCode Configuration Example - Copy this to config.json and fill in your values",
"provider": "anthropic",
"_provider_comment": "API provider: 'anthropic' or 'openai'. Auto-detected from model name if not set.",
"api_key": "your-api-key-here",
"_api_key_comment": "Your API key. Also supports env vars: API_KEY, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY",
"model": "claude-sonnet-4-20250514",
"_model_comment": "Model name. Examples: claude-sonnet-4, claude-opus-4, gpt-4o, glm-5",
"base_url": null,
"_base_url_comment": "API endpoint. Defaults: anthropic=https://api.anthropic.com, openai=https://api.openai.com/v1",
"_base_url_examples": ["https://dashscope.aliyuncs.com/compatible-mode/v1 for DashScope"],
"think": true,
"_think_comment": "Enable extended thinking (Anthropic only). Set false for non-Anthropic endpoints.",
"markdown": true,
"_markdown_comment": "Enable markdown rendering in TUI",
"max_tokens": 16384,
"_max_tokens_comment": "Maximum output tokens per request",
"approve_mode": "ask",
"_approve_mode_comment": "Tool approval: 'ask'=prompt each, 'auto'=approve safe, 'strict'=reject dangerous",
"multi_model": false,
"_multi_model_comment": "Enable multi-model configuration",
"plan_model": null,
"_plan_model_comment": "Planning/reasoning model for complex tasks",
"compress_model": null,
"_compress_model_comment": "Fast model for context compression",
"fast_model": null,
"_fast_model_comment": "Fast model for quick operations",
"extra_headers": {},
"_extra_headers_comment": "Custom HTTP headers for API requests (useful for proxy services)",
"_extra_headers_example": {"X-DashScope-SSE": "enable"}
}"#;
std::fs::write(&path, example)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_values() {
let config = MatrixConfig {
provider: None,
api_key: None,
base_url: None,
model: None,
think: true,
markdown: true,
max_tokens: 16384,
context_size: None,
multi_model: None,
plan_model: None,
compress_model: None,
fast_model: None,
approve_mode: None,
extra_headers: None,
};
assert!(config.api_key.is_none());
assert!(config.model.is_none());
assert!(config.think);
assert!(config.markdown);
assert_eq!(config.max_tokens, 16384);
}
#[test]
fn test_universal_field_names() {
let json = r#"{
"api_key": "test-key",
"base_url": "https://test.com",
"model": "test-model",
"plan_model": "reasoning-model",
"compress_model": "haiku-model"
}"#;
let config: MatrixConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.api_key, Some("test-key".to_string()));
assert_eq!(config.base_url, Some("https://test.com".to_string()));
assert_eq!(config.model, Some("test-model".to_string()));
assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
assert_eq!(config.compress_model, Some("haiku-model".to_string()));
}
#[test]
fn test_legacy_alias_names() {
let json = r#"{
"ANTHROPIC_AUTH_TOKEN": "test-key",
"ANTHROPIC_BASE_URL": "https://test.com",
"ANTHROPIC_MODEL": "test-model",
"ANTHROPIC_REASONING_MODEL": "reasoning-model",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
}"#;
let config: MatrixConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.api_key, Some("test-key".to_string()));
assert_eq!(config.base_url, Some("https://test.com".to_string()));
assert_eq!(config.model, Some("test-model".to_string()));
assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
assert_eq!(config.compress_model, Some("haiku-model".to_string()));
}
#[test]
fn test_serialization_uses_universal_names() {
let config = MatrixConfig {
api_key: Some("key".to_string()),
model: Some("model".to_string()),
extra_headers: None,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("api_key"));
assert!(json.contains("model"));
}
}