use std::env; use std::path::PathBuf; #[derive(Debug, Clone)] pub struct Config { pub api_key: String, pub base_url: String, pub model: String, } /// Copy GROKBOY_* process env into LAZYBOY_* when the new name is unset. pub fn alias_legacy_env() { let inherited: Vec<(String, String)> = env::vars() .filter_map(|(key, value)| { key.strip_prefix("GROKBOY_") .map(|rest| (format!("LAZYBOY_{rest}"), value)) }) .collect(); for (key, value) in inherited { if env::var_os(&key).is_none() { unsafe { env::set_var(key, value) }; } } } /// ~/.lazyboy, or ~/.grokboy if that already has data and the new dir does not. pub fn home_config_dir() -> Option { let home = home_dir()?; let neu = home.join(".lazyboy"); let old = home.join(".grokboy"); if neu.exists() || !old.exists() { Some(neu) } else { Some(old) } } /// Load `.env` files and map legacy GROKBOY_* names to LAZYBOY_*. pub fn bootstrap_env() { load_dotenv_files(); alias_legacy_env(); } impl Config { /// Resolve from environment, then dotenv files, then a logged-in Grok CLI session. pub fn from_env() -> Result { bootstrap_env(); let api_key = env::var("LAZYBOY_API_KEY") .or_else(|_| env::var("XAI_API_KEY")) .or_else(|_| env::var("OPENAI_API_KEY")) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .or_else(grok_cli_auth_key) .ok_or_else(|| { "沒有 API key。設 LAZYBOY_API_KEY(或 XAI_API_KEY),或先用 Grok CLI 登入(~/.grok/auth.json)。" .to_string() })?; let base_url = env::var("LAZYBOY_BASE_URL") .or_else(|_| env::var("OPENAI_BASE_URL")) .unwrap_or_else(|_| "https://api.x.ai/v1".into()); let model = env::var("LAZYBOY_MODEL").unwrap_or_else(|_| "grok-4.6".into()); Ok(Self { api_key, base_url: base_url.trim_end_matches('/').to_string(), model, }) } } fn home_dir() -> Option { env::var_os("HOME") .or_else(|| env::var_os("USERPROFILE")) .map(PathBuf::from) } fn load_dotenv_files() { let mut paths = Vec::new(); if let Ok(cwd) = env::current_dir() { paths.push(cwd.join(".env")); } if let Some(home) = home_dir() { paths.push(home.join(".lazyboy").join(".env")); paths.push(home.join(".lazyboy").join("env")); paths.push(home.join(".grokboy").join(".env")); paths.push(home.join(".grokboy").join("env")); } for path in paths { let Ok(text) = std::fs::read_to_string(&path) else { continue; }; for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((key, value)) = line.split_once('=') else { continue; }; let key = key.trim(); let canonical = if let Some(rest) = key.strip_prefix("GROKBOY_") { format!("LAZYBOY_{rest}") } else { key.to_string() }; if !canonical.starts_with("LAZYBOY_") && !matches!( canonical.as_str(), "XAI_API_KEY" | "OPENAI_API_KEY" | "OPENAI_BASE_URL" ) { continue; } if env::var_os(&canonical).is_some() { continue; } let value = value .trim() .trim_matches('"') .trim_matches('\'') .to_string(); if !value.is_empty() { // dotenv only fills keys that are not already in the process environment. unsafe { env::set_var(canonical, value) }; } } } } fn grok_cli_auth_key() -> Option { let path = home_dir()?.join(".grok").join("auth.json"); let raw = std::fs::read_to_string(path).ok()?; let value: serde_json::Value = serde_json::from_str(&raw).ok()?; grok_cli_key_from_value(&value) } pub(crate) fn grok_cli_key_from_value(value: &serde_json::Value) -> Option { let obj = value.as_object()?; let mut newest: Option<(String, String)> = None; for (_id, entry) in obj { let Some(key) = entry .get("key") .and_then(|v| v.as_str()) .map(str::trim) .filter(|s| !s.is_empty()) else { continue; }; let created = entry .get("create_time") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if newest .as_ref() .is_none_or(|(prev, _)| created.as_str() >= prev.as_str()) { newest = Some((created, key.to_string())); } } newest.map(|(_, key)| key) } #[cfg(test)] mod tests { use super::*; #[test] fn default_model_is_grok_4_6() { let cfg = Config { api_key: "x".into(), base_url: "https://api.x.ai/v1".into(), model: "grok-4.6".into(), }; assert_eq!(cfg.model, "grok-4.6"); } #[test] fn rejects_missing_key() { let _ = Config { api_key: "x".into(), base_url: "https://api.x.ai/v1".into(), model: "grok-4.6".into(), }; } #[test] fn grok_cli_auth_json_yields_key() { let value = serde_json::json!({ "https://auth.x.ai::older": { "key": "old-token", "create_time": "2026-01-01T00:00:00.000000Z" }, "https://auth.x.ai::newer": { "key": "new-token", "create_time": "2026-09-01T00:00:00.000000Z" } }); assert_eq!(grok_cli_key_from_value(&value).as_deref(), Some("new-token")); } }