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 if let Ok(dir) = std::env::var("CLINE_DATA_DIR") {
199 if !dir.is_empty() {
200 return Ok(PathBuf::from(dir)
201 .join("settings")
202 .join("cline_mcp_settings.json"));
203 }
204 }
205 if let Ok(dir) = std::env::var("CLINE_DIR") {
206 if !dir.is_empty() {
207 return Ok(PathBuf::from(dir)
208 .join("data")
209 .join("settings")
210 .join("cline_mcp_settings.json"));
211 }
212 }
213 Ok(home_dir()?
214 .join(".cline")
215 .join("data")
216 .join("settings")
217 .join("cline_mcp_settings.json"))
218}
219
220pub fn legacy_cline_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
226 Ok(vscode_global_storage("saoudrizwan.claude-dev")?
227 .join("settings")
228 .join("cline_mcp_settings.json"))
229}
230
231pub fn roo_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
237 Ok(vscode_global_storage("rooveterinaryinc.roo-cline")?
238 .join("settings")
239 .join("mcp_settings.json"))
240}
241
242pub fn antigravity_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
253 let gemini = gemini_home()?;
254 let documented = gemini.join("config").join("mcp_config.json");
255 let metadata = match fs::symlink_metadata(&documented) {
256 Ok(metadata) => metadata,
257 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(documented),
258 Err(error) => return Err(AgentConfigError::io(&documented, error)),
259 };
260 if !metadata.file_type().is_symlink() {
261 return Ok(documented);
262 }
263
264 let canonical_gemini = fs::canonicalize(&gemini).map_err(|error| {
265 AgentConfigError::PathResolution(format!(
266 "could not resolve Antigravity config root {}: {error}",
267 gemini.display()
268 ))
269 })?;
270 let target = fs::canonicalize(&documented).map_err(|error| {
271 AgentConfigError::PathResolution(format!(
272 "could not resolve Antigravity MCP config symlink {}: {error}",
273 documented.display()
274 ))
275 })?;
276 if !target.starts_with(&canonical_gemini) {
277 return Err(AgentConfigError::PathResolution(format!(
278 "refusing to resolve Antigravity MCP config symlink {} outside {}",
279 documented.display(),
280 canonical_gemini.display()
281 )));
282 }
283 Ok(target)
284}
285
286pub fn antigravity_cli_home() -> Result<PathBuf, AgentConfigError> {
292 Ok(gemini_home()?.join("antigravity-cli"))
293}
294
295pub fn antigravity_cli_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
301 Ok(antigravity_cli_home()?.join("mcp_config.json"))
302}
303
304pub fn windsurf_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
310 Ok(home_dir()?
311 .join(".codeium")
312 .join("windsurf")
313 .join("mcp_config.json"))
314}
315
316pub fn crush_home() -> Result<PathBuf, AgentConfigError> {
328 if let Some(p) = env_path("CRUSH_GLOBAL_CONFIG") {
329 return Ok(p);
330 }
331 Ok(config_dir()?.join("crush"))
332}
333
334pub fn pi_home() -> Result<PathBuf, AgentConfigError> {
343 Ok(home_dir()?.join(".pi").join("agent"))
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use std::sync::{Mutex, OnceLock};
350
351 fn env_lock() -> &'static Mutex<()> {
357 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
358 LOCK.get_or_init(|| Mutex::new(()))
359 }
360
361 #[test]
362 fn home_dir_is_resolvable_in_tests() {
363 let _ = home_dir().expect("home dir on test host");
365 }
366
367 #[test]
368 fn codex_home_respects_env_var() {
369 let _guard = env_lock().lock().unwrap();
370 let dir = tempfile::tempdir().unwrap();
371 let path = dir.path().to_path_buf();
372 let prev = std::env::var_os("CODEX_HOME");
373 std::env::set_var("CODEX_HOME", &path);
374 let resolved = codex_home().unwrap();
375 match prev {
376 Some(v) => std::env::set_var("CODEX_HOME", v),
377 None => std::env::remove_var("CODEX_HOME"),
378 }
379 assert_eq!(resolved, path);
380 }
381
382 #[test]
383 fn home_dirs_append_correct_suffix() {
384 let cases: Vec<(Result<PathBuf, AgentConfigError>, &str)> = vec![
385 (claude_home(), ".claude"),
386 (cursor_home(), ".cursor"),
387 (gemini_home(), ".gemini"),
388 (openclaw_home(), ".openclaw"),
389 (hermes_home(), ".hermes"),
390 ];
391 for (path, suffix) in cases {
392 let p = path.expect("path resolved");
393 assert!(
394 p.to_string_lossy().ends_with(suffix),
395 "{p:?} does not end with {suffix}"
396 );
397 }
398 let p = pi_home().expect("path resolved");
400 assert!(p.ends_with(PathBuf::from(".pi").join("agent")));
401 let p = crush_home().expect("path resolved");
404 assert!(p.ends_with("crush"));
405 }
406
407 #[test]
408 fn crush_home_respects_env_var() {
409 let _guard = env_lock().lock().unwrap();
410 let dir = tempfile::tempdir().unwrap();
411 let path = dir.path().to_path_buf();
412 let prev = std::env::var_os("CRUSH_GLOBAL_CONFIG");
413 std::env::set_var("CRUSH_GLOBAL_CONFIG", &path);
414 let resolved = crush_home().unwrap();
415 match prev {
416 Some(v) => std::env::set_var("CRUSH_GLOBAL_CONFIG", v),
417 None => std::env::remove_var("CRUSH_GLOBAL_CONFIG"),
418 }
419 assert_eq!(resolved, path);
420 }
421
422 #[test]
423 fn opencode_plugins_dir_ends_correctly() {
424 let p = opencode_plugins_dir().expect("path resolved");
425 assert!(p.ends_with(PathBuf::from(".config").join("opencode").join("plugins")));
426 }
427
428 #[test]
429 fn mcp_paths_end_correctly() {
430 let _guard = env_lock().lock().unwrap();
431 let home = tempfile::tempdir().unwrap();
432 let home_path = home.path().to_path_buf();
433 let prev_home = std::env::var_os("HOME");
434 let prev_userprofile = std::env::var_os("USERPROFILE");
435
436 #[cfg(windows)]
437 std::env::set_var("USERPROFILE", &home_path);
438 #[cfg(not(windows))]
439 std::env::set_var("HOME", &home_path);
440
441 assert!(claude_mcp_user_file()
442 .unwrap()
443 .to_string_lossy()
444 .ends_with(".claude.json"));
445 assert!(kilo_config_file()
446 .unwrap()
447 .ends_with(PathBuf::from(".config").join("kilo").join("kilo.jsonc")));
448 assert!(cline_mcp_global_file().unwrap().ends_with(
449 PathBuf::from(".cline")
450 .join("data")
451 .join("settings")
452 .join("cline_mcp_settings.json")
453 ));
454 assert!(legacy_cline_mcp_global_file().unwrap().ends_with(
455 PathBuf::from("Code")
456 .join("User")
457 .join("globalStorage")
458 .join("saoudrizwan.claude-dev")
459 .join("settings")
460 .join("cline_mcp_settings.json")
461 ));
462 assert!(roo_mcp_global_file().unwrap().ends_with(
463 PathBuf::from("Code")
464 .join("User")
465 .join("globalStorage")
466 .join("rooveterinaryinc.roo-cline")
467 .join("settings")
468 .join("mcp_settings.json")
469 ));
470 assert!(antigravity_mcp_global_file().unwrap().ends_with(
471 PathBuf::from(".gemini")
472 .join("config")
473 .join("mcp_config.json")
474 ));
475 assert!(antigravity_cli_mcp_global_file().unwrap().ends_with(
476 PathBuf::from(".gemini")
477 .join("antigravity-cli")
478 .join("mcp_config.json")
479 ));
480 assert!(windsurf_mcp_global_file().unwrap().ends_with(
481 PathBuf::from(".codeium")
482 .join("windsurf")
483 .join("mcp_config.json")
484 ));
485
486 match prev_home {
487 Some(value) => std::env::set_var("HOME", value),
488 None => std::env::remove_var("HOME"),
489 }
490 match prev_userprofile {
491 Some(value) => std::env::set_var("USERPROFILE", value),
492 None => std::env::remove_var("USERPROFILE"),
493 }
494 }
495}