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 amp_config_dir() -> Result<PathBuf, AgentConfigError> {
166 Ok(home_dir()?.join(".config").join("amp"))
167}
168
169pub fn claude_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
175 Ok(home_dir()?.join(".claude.json"))
176}
177
178pub fn cursor_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
184 Ok(cursor_home()?.join("mcp.json"))
185}
186
187pub fn vscode_global_storage(extension_id: &str) -> Result<PathBuf, AgentConfigError> {
196 Ok(config_dir()?
197 .join("Code")
198 .join("User")
199 .join("globalStorage")
200 .join(extension_id))
201}
202
203pub fn cline_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
209 if let Ok(dir) = std::env::var("CLINE_DATA_DIR") {
210 if !dir.is_empty() {
211 return Ok(PathBuf::from(dir)
212 .join("settings")
213 .join("cline_mcp_settings.json"));
214 }
215 }
216 if let Ok(dir) = std::env::var("CLINE_DIR") {
217 if !dir.is_empty() {
218 return Ok(PathBuf::from(dir)
219 .join("data")
220 .join("settings")
221 .join("cline_mcp_settings.json"));
222 }
223 }
224 Ok(home_dir()?
225 .join(".cline")
226 .join("data")
227 .join("settings")
228 .join("cline_mcp_settings.json"))
229}
230
231pub fn legacy_cline_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
237 Ok(vscode_global_storage("saoudrizwan.claude-dev")?
238 .join("settings")
239 .join("cline_mcp_settings.json"))
240}
241
242pub fn roo_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
248 Ok(vscode_global_storage("rooveterinaryinc.roo-cline")?
249 .join("settings")
250 .join("mcp_settings.json"))
251}
252
253pub fn antigravity_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
264 let gemini = gemini_home()?;
265 let documented = gemini.join("config").join("mcp_config.json");
266 let metadata = match fs::symlink_metadata(&documented) {
267 Ok(metadata) => metadata,
268 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(documented),
269 Err(error) => return Err(AgentConfigError::io(&documented, error)),
270 };
271 if !metadata.file_type().is_symlink() {
272 return Ok(documented);
273 }
274
275 let canonical_gemini = fs::canonicalize(&gemini).map_err(|error| {
276 AgentConfigError::PathResolution(format!(
277 "could not resolve Antigravity config root {}: {error}",
278 gemini.display()
279 ))
280 })?;
281 let target = fs::canonicalize(&documented).map_err(|error| {
282 AgentConfigError::PathResolution(format!(
283 "could not resolve Antigravity MCP config symlink {}: {error}",
284 documented.display()
285 ))
286 })?;
287 if !target.starts_with(&canonical_gemini) {
288 return Err(AgentConfigError::PathResolution(format!(
289 "refusing to resolve Antigravity MCP config symlink {} outside {}",
290 documented.display(),
291 canonical_gemini.display()
292 )));
293 }
294 Ok(target)
295}
296
297pub fn antigravity_cli_home() -> Result<PathBuf, AgentConfigError> {
303 Ok(gemini_home()?.join("antigravity-cli"))
304}
305
306pub fn antigravity_cli_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
312 Ok(antigravity_cli_home()?.join("mcp_config.json"))
313}
314
315pub fn windsurf_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
321 Ok(home_dir()?
322 .join(".codeium")
323 .join("windsurf")
324 .join("mcp_config.json"))
325}
326
327pub fn crush_home() -> Result<PathBuf, AgentConfigError> {
339 if let Some(p) = env_path("CRUSH_GLOBAL_CONFIG") {
340 return Ok(p);
341 }
342 Ok(config_dir()?.join("crush"))
343}
344
345pub fn pi_home() -> Result<PathBuf, AgentConfigError> {
354 Ok(home_dir()?.join(".pi").join("agent"))
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use std::sync::{Mutex, OnceLock};
361
362 fn env_lock() -> &'static Mutex<()> {
368 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
369 LOCK.get_or_init(|| Mutex::new(()))
370 }
371
372 #[test]
373 fn home_dir_is_resolvable_in_tests() {
374 let _ = home_dir().expect("home dir on test host");
376 }
377
378 #[test]
379 fn codex_home_respects_env_var() {
380 let _guard = env_lock().lock().unwrap();
381 let dir = tempfile::tempdir().unwrap();
382 let path = dir.path().to_path_buf();
383 let prev = std::env::var_os("CODEX_HOME");
384 std::env::set_var("CODEX_HOME", &path);
385 let resolved = codex_home().unwrap();
386 match prev {
387 Some(v) => std::env::set_var("CODEX_HOME", v),
388 None => std::env::remove_var("CODEX_HOME"),
389 }
390 assert_eq!(resolved, path);
391 }
392
393 #[test]
394 fn home_dirs_append_correct_suffix() {
395 let cases: Vec<(Result<PathBuf, AgentConfigError>, &str)> = vec![
396 (claude_home(), ".claude"),
397 (cursor_home(), ".cursor"),
398 (gemini_home(), ".gemini"),
399 (openclaw_home(), ".openclaw"),
400 (hermes_home(), ".hermes"),
401 ];
402 for (path, suffix) in cases {
403 let p = path.expect("path resolved");
404 assert!(
405 p.to_string_lossy().ends_with(suffix),
406 "{p:?} does not end with {suffix}"
407 );
408 }
409 let p = pi_home().expect("path resolved");
411 assert!(p.ends_with(PathBuf::from(".pi").join("agent")));
412 let p = crush_home().expect("path resolved");
415 assert!(p.ends_with("crush"));
416 }
417
418 #[test]
419 fn crush_home_respects_env_var() {
420 let _guard = env_lock().lock().unwrap();
421 let dir = tempfile::tempdir().unwrap();
422 let path = dir.path().to_path_buf();
423 let prev = std::env::var_os("CRUSH_GLOBAL_CONFIG");
424 std::env::set_var("CRUSH_GLOBAL_CONFIG", &path);
425 let resolved = crush_home().unwrap();
426 match prev {
427 Some(v) => std::env::set_var("CRUSH_GLOBAL_CONFIG", v),
428 None => std::env::remove_var("CRUSH_GLOBAL_CONFIG"),
429 }
430 assert_eq!(resolved, path);
431 }
432
433 #[test]
434 fn opencode_plugins_dir_ends_correctly() {
435 let p = opencode_plugins_dir().expect("path resolved");
436 assert!(p.ends_with(PathBuf::from(".config").join("opencode").join("plugins")));
437 }
438
439 #[test]
440 fn mcp_paths_end_correctly() {
441 let _guard = env_lock().lock().unwrap();
442 let home = tempfile::tempdir().unwrap();
443 let home_path = home.path().to_path_buf();
444 let prev_home = std::env::var_os("HOME");
445 let prev_userprofile = std::env::var_os("USERPROFILE");
446
447 #[cfg(windows)]
448 std::env::set_var("USERPROFILE", &home_path);
449 #[cfg(not(windows))]
450 std::env::set_var("HOME", &home_path);
451
452 assert!(claude_mcp_user_file()
453 .unwrap()
454 .to_string_lossy()
455 .ends_with(".claude.json"));
456 assert!(kilo_config_file()
457 .unwrap()
458 .ends_with(PathBuf::from(".config").join("kilo").join("kilo.jsonc")));
459 assert!(cline_mcp_global_file().unwrap().ends_with(
460 PathBuf::from(".cline")
461 .join("data")
462 .join("settings")
463 .join("cline_mcp_settings.json")
464 ));
465 assert!(legacy_cline_mcp_global_file().unwrap().ends_with(
466 PathBuf::from("Code")
467 .join("User")
468 .join("globalStorage")
469 .join("saoudrizwan.claude-dev")
470 .join("settings")
471 .join("cline_mcp_settings.json")
472 ));
473 assert!(roo_mcp_global_file().unwrap().ends_with(
474 PathBuf::from("Code")
475 .join("User")
476 .join("globalStorage")
477 .join("rooveterinaryinc.roo-cline")
478 .join("settings")
479 .join("mcp_settings.json")
480 ));
481 assert!(antigravity_mcp_global_file().unwrap().ends_with(
482 PathBuf::from(".gemini")
483 .join("config")
484 .join("mcp_config.json")
485 ));
486 assert!(antigravity_cli_mcp_global_file().unwrap().ends_with(
487 PathBuf::from(".gemini")
488 .join("antigravity-cli")
489 .join("mcp_config.json")
490 ));
491 assert!(windsurf_mcp_global_file().unwrap().ends_with(
492 PathBuf::from(".codeium")
493 .join("windsurf")
494 .join("mcp_config.json")
495 ));
496
497 match prev_home {
498 Some(value) => std::env::set_var("HOME", value),
499 None => std::env::remove_var("HOME"),
500 }
501 match prev_userprofile {
502 Some(value) => std::env::set_var("USERPROFILE", value),
503 None => std::env::remove_var("USERPROFILE"),
504 }
505 }
506}