1use std::fs;
9use std::path::PathBuf;
10
11use crate::error::AgentConfigError;
12
13pub fn home_dir() -> Result<PathBuf, AgentConfigError> {
21 #[cfg(windows)]
22 if let Some(home) = env_path("USERPROFILE") {
23 return Ok(home);
24 }
25
26 #[cfg(not(windows))]
27 if let Some(home) = env_path("HOME") {
28 return Ok(home);
29 }
30
31 dirs::home_dir().ok_or_else(|| {
32 AgentConfigError::PathResolution("could not determine user home directory".into())
33 })
34}
35
36pub fn config_dir() -> Result<PathBuf, AgentConfigError> {
48 if let Some(config) = env_path("XDG_CONFIG_HOME") {
49 return Ok(config);
50 }
51
52 #[cfg(windows)]
53 if let Some(config) = env_path("APPDATA") {
54 return Ok(config);
55 }
56
57 dirs::config_dir().ok_or_else(|| {
58 AgentConfigError::PathResolution("could not determine user config directory".into())
59 })
60}
61
62fn env_path(key: &str) -> Option<PathBuf> {
63 std::env::var_os(key)
64 .filter(|value| !value.is_empty())
65 .map(PathBuf::from)
66}
67
68pub fn claude_home() -> Result<PathBuf, AgentConfigError> {
74 Ok(home_dir()?.join(".claude"))
75}
76
77pub fn cursor_home() -> Result<PathBuf, AgentConfigError> {
83 Ok(home_dir()?.join(".cursor"))
84}
85
86pub fn gemini_home() -> Result<PathBuf, AgentConfigError> {
92 Ok(home_dir()?.join(".gemini"))
93}
94
95pub fn codex_home() -> Result<PathBuf, AgentConfigError> {
102 if let Some(h) = std::env::var_os("CODEX_HOME") {
103 return Ok(PathBuf::from(h));
104 }
105 Ok(home_dir()?.join(".codex"))
106}
107
108pub fn openclaw_home() -> Result<PathBuf, AgentConfigError> {
114 Ok(home_dir()?.join(".openclaw"))
115}
116
117pub fn hermes_home() -> Result<PathBuf, AgentConfigError> {
123 Ok(home_dir()?.join(".hermes"))
124}
125
126pub fn opencode_plugins_dir() -> Result<PathBuf, AgentConfigError> {
133 Ok(home_dir()?.join(".config").join("opencode").join("plugins"))
134}
135
136pub fn opencode_config_file() -> Result<PathBuf, AgentConfigError> {
143 Ok(home_dir()?
144 .join(".config")
145 .join("opencode")
146 .join("opencode.json"))
147}
148
149pub fn kilo_config_file() -> Result<PathBuf, AgentConfigError> {
155 Ok(home_dir()?.join(".config").join("kilo").join("kilo.jsonc"))
156}
157
158pub fn claude_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
164 Ok(home_dir()?.join(".claude.json"))
165}
166
167pub fn cursor_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
173 Ok(cursor_home()?.join("mcp.json"))
174}
175
176pub fn vscode_global_storage(extension_id: &str) -> Result<PathBuf, AgentConfigError> {
185 Ok(config_dir()?
186 .join("Code")
187 .join("User")
188 .join("globalStorage")
189 .join(extension_id))
190}
191
192pub fn cline_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
198 Ok(vscode_global_storage("saoudrizwan.claude-dev")?
199 .join("settings")
200 .join("cline_mcp_settings.json"))
201}
202
203pub fn roo_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
209 Ok(vscode_global_storage("rooveterinaryinc.roo-cline")?
210 .join("settings")
211 .join("mcp_settings.json"))
212}
213
214pub fn antigravity_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
225 let gemini = gemini_home()?;
226 let documented = gemini.join("config").join("mcp_config.json");
227 let metadata = match fs::symlink_metadata(&documented) {
228 Ok(metadata) => metadata,
229 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(documented),
230 Err(error) => return Err(AgentConfigError::io(&documented, error)),
231 };
232 if !metadata.file_type().is_symlink() {
233 return Ok(documented);
234 }
235
236 let canonical_gemini = fs::canonicalize(&gemini).map_err(|error| {
237 AgentConfigError::PathResolution(format!(
238 "could not resolve Antigravity config root {}: {error}",
239 gemini.display()
240 ))
241 })?;
242 let target = fs::canonicalize(&documented).map_err(|error| {
243 AgentConfigError::PathResolution(format!(
244 "could not resolve Antigravity MCP config symlink {}: {error}",
245 documented.display()
246 ))
247 })?;
248 if !target.starts_with(&canonical_gemini) {
249 return Err(AgentConfigError::PathResolution(format!(
250 "refusing to resolve Antigravity MCP config symlink {} outside {}",
251 documented.display(),
252 canonical_gemini.display()
253 )));
254 }
255 Ok(target)
256}
257
258pub fn antigravity_cli_home() -> Result<PathBuf, AgentConfigError> {
264 Ok(gemini_home()?.join("antigravity-cli"))
265}
266
267pub fn antigravity_cli_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
273 Ok(antigravity_cli_home()?.join("mcp_config.json"))
274}
275
276pub fn windsurf_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
282 Ok(home_dir()?
283 .join(".codeium")
284 .join("windsurf")
285 .join("mcp_config.json"))
286}
287
288pub fn crush_home() -> Result<PathBuf, AgentConfigError> {
300 if let Some(p) = env_path("CRUSH_GLOBAL_CONFIG") {
301 return Ok(p);
302 }
303 Ok(config_dir()?.join("crush"))
304}
305
306pub fn pi_home() -> Result<PathBuf, AgentConfigError> {
315 Ok(home_dir()?.join(".pi").join("agent"))
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use std::sync::{Mutex, OnceLock};
322
323 fn env_lock() -> &'static Mutex<()> {
329 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
330 LOCK.get_or_init(|| Mutex::new(()))
331 }
332
333 #[test]
334 fn home_dir_is_resolvable_in_tests() {
335 let _ = home_dir().expect("home dir on test host");
337 }
338
339 #[test]
340 fn codex_home_respects_env_var() {
341 let _guard = env_lock().lock().unwrap();
342 let dir = tempfile::tempdir().unwrap();
343 let path = dir.path().to_path_buf();
344 let prev = std::env::var_os("CODEX_HOME");
345 std::env::set_var("CODEX_HOME", &path);
346 let resolved = codex_home().unwrap();
347 match prev {
348 Some(v) => std::env::set_var("CODEX_HOME", v),
349 None => std::env::remove_var("CODEX_HOME"),
350 }
351 assert_eq!(resolved, path);
352 }
353
354 #[test]
355 fn home_dirs_append_correct_suffix() {
356 let cases: Vec<(Result<PathBuf, AgentConfigError>, &str)> = vec![
357 (claude_home(), ".claude"),
358 (cursor_home(), ".cursor"),
359 (gemini_home(), ".gemini"),
360 (openclaw_home(), ".openclaw"),
361 (hermes_home(), ".hermes"),
362 ];
363 for (path, suffix) in cases {
364 let p = path.expect("path resolved");
365 assert!(
366 p.to_string_lossy().ends_with(suffix),
367 "{p:?} does not end with {suffix}"
368 );
369 }
370 let p = pi_home().expect("path resolved");
372 assert!(p.ends_with(PathBuf::from(".pi").join("agent")));
373 let p = crush_home().expect("path resolved");
376 assert!(p.ends_with("crush"));
377 }
378
379 #[test]
380 fn crush_home_respects_env_var() {
381 let _guard = env_lock().lock().unwrap();
382 let dir = tempfile::tempdir().unwrap();
383 let path = dir.path().to_path_buf();
384 let prev = std::env::var_os("CRUSH_GLOBAL_CONFIG");
385 std::env::set_var("CRUSH_GLOBAL_CONFIG", &path);
386 let resolved = crush_home().unwrap();
387 match prev {
388 Some(v) => std::env::set_var("CRUSH_GLOBAL_CONFIG", v),
389 None => std::env::remove_var("CRUSH_GLOBAL_CONFIG"),
390 }
391 assert_eq!(resolved, path);
392 }
393
394 #[test]
395 fn opencode_plugins_dir_ends_correctly() {
396 let p = opencode_plugins_dir().expect("path resolved");
397 assert!(p.ends_with(PathBuf::from(".config").join("opencode").join("plugins")));
398 }
399
400 #[test]
401 fn mcp_paths_end_correctly() {
402 let _guard = env_lock().lock().unwrap();
403 let home = tempfile::tempdir().unwrap();
404 let home_path = home.path().to_path_buf();
405 let prev_home = std::env::var_os("HOME");
406 let prev_userprofile = std::env::var_os("USERPROFILE");
407
408 #[cfg(windows)]
409 std::env::set_var("USERPROFILE", &home_path);
410 #[cfg(not(windows))]
411 std::env::set_var("HOME", &home_path);
412
413 assert!(claude_mcp_user_file()
414 .unwrap()
415 .to_string_lossy()
416 .ends_with(".claude.json"));
417 assert!(kilo_config_file()
418 .unwrap()
419 .ends_with(PathBuf::from(".config").join("kilo").join("kilo.jsonc")));
420 assert!(cline_mcp_global_file().unwrap().ends_with(
421 PathBuf::from("Code")
422 .join("User")
423 .join("globalStorage")
424 .join("saoudrizwan.claude-dev")
425 .join("settings")
426 .join("cline_mcp_settings.json")
427 ));
428 assert!(roo_mcp_global_file().unwrap().ends_with(
429 PathBuf::from("Code")
430 .join("User")
431 .join("globalStorage")
432 .join("rooveterinaryinc.roo-cline")
433 .join("settings")
434 .join("mcp_settings.json")
435 ));
436 assert!(antigravity_mcp_global_file().unwrap().ends_with(
437 PathBuf::from(".gemini")
438 .join("config")
439 .join("mcp_config.json")
440 ));
441 assert!(antigravity_cli_mcp_global_file().unwrap().ends_with(
442 PathBuf::from(".gemini")
443 .join("antigravity-cli")
444 .join("mcp_config.json")
445 ));
446 assert!(windsurf_mcp_global_file().unwrap().ends_with(
447 PathBuf::from(".codeium")
448 .join("windsurf")
449 .join("mcp_config.json")
450 ));
451
452 match prev_home {
453 Some(value) => std::env::set_var("HOME", value),
454 None => std::env::remove_var("HOME"),
455 }
456 match prev_userprofile {
457 Some(value) => std::env::set_var("USERPROFILE", value),
458 None => std::env::remove_var("USERPROFILE"),
459 }
460 }
461}