use std::fs;
use std::path::PathBuf;
use crate::error::AgentConfigError;
pub fn home_dir() -> Result<PathBuf, AgentConfigError> {
#[cfg(windows)]
if let Some(home) = env_path("USERPROFILE") {
return Ok(home);
}
#[cfg(not(windows))]
if let Some(home) = env_path("HOME") {
return Ok(home);
}
dirs::home_dir().ok_or_else(|| {
AgentConfigError::PathResolution("could not determine user home directory".into())
})
}
pub fn config_dir() -> Result<PathBuf, AgentConfigError> {
if let Some(config) = env_path("XDG_CONFIG_HOME") {
return Ok(config);
}
#[cfg(windows)]
if let Some(config) = env_path("APPDATA") {
return Ok(config);
}
dirs::config_dir().ok_or_else(|| {
AgentConfigError::PathResolution("could not determine user config directory".into())
})
}
fn env_path(key: &str) -> Option<PathBuf> {
std::env::var_os(key)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
pub fn claude_home() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?.join(".claude"))
}
pub fn cursor_home() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?.join(".cursor"))
}
pub fn gemini_home() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?.join(".gemini"))
}
pub fn codex_home() -> Result<PathBuf, AgentConfigError> {
if let Some(h) = std::env::var_os("CODEX_HOME") {
return Ok(PathBuf::from(h));
}
Ok(home_dir()?.join(".codex"))
}
pub fn openclaw_home() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?.join(".openclaw"))
}
pub fn hermes_home() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?.join(".hermes"))
}
pub fn opencode_plugins_dir() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?.join(".config").join("opencode").join("plugins"))
}
pub fn opencode_config_file() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?
.join(".config")
.join("opencode")
.join("opencode.json"))
}
pub fn kilo_config_file() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?.join(".config").join("kilo").join("kilo.jsonc"))
}
pub fn claude_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?.join(".claude.json"))
}
pub fn cursor_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
Ok(cursor_home()?.join("mcp.json"))
}
pub fn vscode_global_storage(extension_id: &str) -> Result<PathBuf, AgentConfigError> {
Ok(config_dir()?
.join("Code")
.join("User")
.join("globalStorage")
.join(extension_id))
}
pub fn cline_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
Ok(vscode_global_storage("saoudrizwan.claude-dev")?
.join("settings")
.join("cline_mcp_settings.json"))
}
pub fn roo_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
Ok(vscode_global_storage("rooveterinaryinc.roo-cline")?
.join("settings")
.join("mcp_settings.json"))
}
pub fn antigravity_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
let gemini = gemini_home()?;
let documented = gemini.join("config").join("mcp_config.json");
let metadata = match fs::symlink_metadata(&documented) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(documented),
Err(error) => return Err(AgentConfigError::io(&documented, error)),
};
if !metadata.file_type().is_symlink() {
return Ok(documented);
}
let canonical_gemini = fs::canonicalize(&gemini).map_err(|error| {
AgentConfigError::PathResolution(format!(
"could not resolve Antigravity config root {}: {error}",
gemini.display()
))
})?;
let target = fs::canonicalize(&documented).map_err(|error| {
AgentConfigError::PathResolution(format!(
"could not resolve Antigravity MCP config symlink {}: {error}",
documented.display()
))
})?;
if !target.starts_with(&canonical_gemini) {
return Err(AgentConfigError::PathResolution(format!(
"refusing to resolve Antigravity MCP config symlink {} outside {}",
documented.display(),
canonical_gemini.display()
)));
}
Ok(target)
}
pub fn antigravity_cli_home() -> Result<PathBuf, AgentConfigError> {
Ok(gemini_home()?.join("antigravity-cli"))
}
pub fn antigravity_cli_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
Ok(antigravity_cli_home()?.join("mcp_config.json"))
}
pub fn windsurf_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?
.join(".codeium")
.join("windsurf")
.join("mcp_config.json"))
}
pub fn crush_home() -> Result<PathBuf, AgentConfigError> {
if let Some(p) = env_path("CRUSH_GLOBAL_CONFIG") {
return Ok(p);
}
Ok(config_dir()?.join("crush"))
}
pub fn pi_home() -> Result<PathBuf, AgentConfigError> {
Ok(home_dir()?.join(".pi").join("agent"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn home_dir_is_resolvable_in_tests() {
let _ = home_dir().expect("home dir on test host");
}
#[test]
fn codex_home_respects_env_var() {
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
let prev = std::env::var_os("CODEX_HOME");
std::env::set_var("CODEX_HOME", &path);
let resolved = codex_home().unwrap();
match prev {
Some(v) => std::env::set_var("CODEX_HOME", v),
None => std::env::remove_var("CODEX_HOME"),
}
assert_eq!(resolved, path);
}
#[test]
fn home_dirs_append_correct_suffix() {
let cases: Vec<(Result<PathBuf, AgentConfigError>, &str)> = vec![
(claude_home(), ".claude"),
(cursor_home(), ".cursor"),
(gemini_home(), ".gemini"),
(openclaw_home(), ".openclaw"),
(hermes_home(), ".hermes"),
];
for (path, suffix) in cases {
let p = path.expect("path resolved");
assert!(
p.to_string_lossy().ends_with(suffix),
"{p:?} does not end with {suffix}"
);
}
let p = pi_home().expect("path resolved");
assert!(p.ends_with(PathBuf::from(".pi").join("agent")));
let p = crush_home().expect("path resolved");
assert!(p.ends_with("crush"));
}
#[test]
fn crush_home_respects_env_var() {
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
let prev = std::env::var_os("CRUSH_GLOBAL_CONFIG");
std::env::set_var("CRUSH_GLOBAL_CONFIG", &path);
let resolved = crush_home().unwrap();
match prev {
Some(v) => std::env::set_var("CRUSH_GLOBAL_CONFIG", v),
None => std::env::remove_var("CRUSH_GLOBAL_CONFIG"),
}
assert_eq!(resolved, path);
}
#[test]
fn opencode_plugins_dir_ends_correctly() {
let p = opencode_plugins_dir().expect("path resolved");
assert!(p.ends_with(PathBuf::from(".config").join("opencode").join("plugins")));
}
#[test]
fn mcp_paths_end_correctly() {
let _guard = env_lock().lock().unwrap();
let home = tempfile::tempdir().unwrap();
let home_path = home.path().to_path_buf();
let prev_home = std::env::var_os("HOME");
let prev_userprofile = std::env::var_os("USERPROFILE");
#[cfg(windows)]
std::env::set_var("USERPROFILE", &home_path);
#[cfg(not(windows))]
std::env::set_var("HOME", &home_path);
assert!(claude_mcp_user_file()
.unwrap()
.to_string_lossy()
.ends_with(".claude.json"));
assert!(kilo_config_file()
.unwrap()
.ends_with(PathBuf::from(".config").join("kilo").join("kilo.jsonc")));
assert!(cline_mcp_global_file().unwrap().ends_with(
PathBuf::from("Code")
.join("User")
.join("globalStorage")
.join("saoudrizwan.claude-dev")
.join("settings")
.join("cline_mcp_settings.json")
));
assert!(roo_mcp_global_file().unwrap().ends_with(
PathBuf::from("Code")
.join("User")
.join("globalStorage")
.join("rooveterinaryinc.roo-cline")
.join("settings")
.join("mcp_settings.json")
));
assert!(antigravity_mcp_global_file().unwrap().ends_with(
PathBuf::from(".gemini")
.join("config")
.join("mcp_config.json")
));
assert!(antigravity_cli_mcp_global_file().unwrap().ends_with(
PathBuf::from(".gemini")
.join("antigravity-cli")
.join("mcp_config.json")
));
assert!(windsurf_mcp_global_file().unwrap().ends_with(
PathBuf::from(".codeium")
.join("windsurf")
.join("mcp_config.json")
));
match prev_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match prev_userprofile {
Some(value) => std::env::set_var("USERPROFILE", value),
None => std::env::remove_var("USERPROFILE"),
}
}
}