1use std::path::PathBuf;
9
10use crate::error::AgentConfigError;
11
12pub fn home_dir() -> Result<PathBuf, AgentConfigError> {
20 #[cfg(windows)]
21 if let Some(home) = env_path("USERPROFILE") {
22 return Ok(home);
23 }
24
25 #[cfg(not(windows))]
26 if let Some(home) = env_path("HOME") {
27 return Ok(home);
28 }
29
30 dirs::home_dir().ok_or_else(|| {
31 AgentConfigError::PathResolution("could not determine user home directory".into())
32 })
33}
34
35pub fn config_dir() -> Result<PathBuf, AgentConfigError> {
47 if let Some(config) = env_path("XDG_CONFIG_HOME") {
48 return Ok(config);
49 }
50
51 #[cfg(windows)]
52 if let Some(config) = env_path("APPDATA") {
53 return Ok(config);
54 }
55
56 dirs::config_dir().ok_or_else(|| {
57 AgentConfigError::PathResolution("could not determine user config directory".into())
58 })
59}
60
61fn env_path(key: &str) -> Option<PathBuf> {
62 std::env::var_os(key)
63 .filter(|value| !value.is_empty())
64 .map(PathBuf::from)
65}
66
67pub fn claude_home() -> Result<PathBuf, AgentConfigError> {
73 Ok(home_dir()?.join(".claude"))
74}
75
76pub fn cursor_home() -> Result<PathBuf, AgentConfigError> {
82 Ok(home_dir()?.join(".cursor"))
83}
84
85pub fn gemini_home() -> Result<PathBuf, AgentConfigError> {
91 Ok(home_dir()?.join(".gemini"))
92}
93
94pub fn codex_home() -> Result<PathBuf, AgentConfigError> {
101 if let Some(h) = std::env::var_os("CODEX_HOME") {
102 return Ok(PathBuf::from(h));
103 }
104 Ok(home_dir()?.join(".codex"))
105}
106
107pub fn openclaw_home() -> Result<PathBuf, AgentConfigError> {
113 Ok(home_dir()?.join(".openclaw"))
114}
115
116pub fn hermes_home() -> Result<PathBuf, AgentConfigError> {
122 Ok(home_dir()?.join(".hermes"))
123}
124
125pub fn opencode_plugins_dir() -> Result<PathBuf, AgentConfigError> {
132 Ok(home_dir()?.join(".config").join("opencode").join("plugins"))
133}
134
135pub fn opencode_config_file() -> Result<PathBuf, AgentConfigError> {
142 Ok(home_dir()?
143 .join(".config")
144 .join("opencode")
145 .join("opencode.json"))
146}
147
148pub fn kilo_config_file() -> Result<PathBuf, AgentConfigError> {
154 Ok(home_dir()?.join(".config").join("kilo").join("kilo.jsonc"))
155}
156
157pub fn claude_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
163 Ok(home_dir()?.join(".claude.json"))
164}
165
166pub fn cursor_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
172 Ok(cursor_home()?.join("mcp.json"))
173}
174
175pub fn vscode_global_storage(extension_id: &str) -> Result<PathBuf, AgentConfigError> {
184 Ok(config_dir()?
185 .join("Code")
186 .join("User")
187 .join("globalStorage")
188 .join(extension_id))
189}
190
191pub fn cline_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
197 Ok(vscode_global_storage("saoudrizwan.claude-dev")?
198 .join("settings")
199 .join("cline_mcp_settings.json"))
200}
201
202pub fn roo_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
208 Ok(vscode_global_storage("rooveterinaryinc.roo-cline")?
209 .join("settings")
210 .join("mcp_settings.json"))
211}
212
213pub fn antigravity_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
219 Ok(gemini_home()?.join("antigravity").join("mcp_config.json"))
220}
221
222pub fn windsurf_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
228 Ok(home_dir()?
229 .join(".codeium")
230 .join("windsurf")
231 .join("mcp_config.json"))
232}
233
234pub fn crush_home() -> Result<PathBuf, AgentConfigError> {
246 if let Some(p) = env_path("CRUSH_GLOBAL_CONFIG") {
247 return Ok(p);
248 }
249 Ok(config_dir()?.join("crush"))
250}
251
252pub fn pi_home() -> Result<PathBuf, AgentConfigError> {
261 Ok(home_dir()?.join(".pi").join("agent"))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use std::sync::{Mutex, OnceLock};
268
269 fn env_lock() -> &'static Mutex<()> {
275 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
276 LOCK.get_or_init(|| Mutex::new(()))
277 }
278
279 #[test]
280 fn home_dir_is_resolvable_in_tests() {
281 let _ = home_dir().expect("home dir on test host");
283 }
284
285 #[test]
286 fn codex_home_respects_env_var() {
287 let _guard = env_lock().lock().unwrap();
288 let dir = tempfile::tempdir().unwrap();
289 let path = dir.path().to_path_buf();
290 let prev = std::env::var_os("CODEX_HOME");
291 std::env::set_var("CODEX_HOME", &path);
292 let resolved = codex_home().unwrap();
293 match prev {
294 Some(v) => std::env::set_var("CODEX_HOME", v),
295 None => std::env::remove_var("CODEX_HOME"),
296 }
297 assert_eq!(resolved, path);
298 }
299
300 #[test]
301 fn home_dirs_append_correct_suffix() {
302 let cases: Vec<(Result<PathBuf, AgentConfigError>, &str)> = vec![
303 (claude_home(), ".claude"),
304 (cursor_home(), ".cursor"),
305 (gemini_home(), ".gemini"),
306 (openclaw_home(), ".openclaw"),
307 (hermes_home(), ".hermes"),
308 ];
309 for (path, suffix) in cases {
310 let p = path.expect("path resolved");
311 assert!(
312 p.to_string_lossy().ends_with(suffix),
313 "{p:?} does not end with {suffix}"
314 );
315 }
316 let p = pi_home().expect("path resolved");
318 assert!(p.ends_with(PathBuf::from(".pi").join("agent")));
319 let p = crush_home().expect("path resolved");
322 assert!(p.ends_with("crush"));
323 }
324
325 #[test]
326 fn crush_home_respects_env_var() {
327 let _guard = env_lock().lock().unwrap();
328 let dir = tempfile::tempdir().unwrap();
329 let path = dir.path().to_path_buf();
330 let prev = std::env::var_os("CRUSH_GLOBAL_CONFIG");
331 std::env::set_var("CRUSH_GLOBAL_CONFIG", &path);
332 let resolved = crush_home().unwrap();
333 match prev {
334 Some(v) => std::env::set_var("CRUSH_GLOBAL_CONFIG", v),
335 None => std::env::remove_var("CRUSH_GLOBAL_CONFIG"),
336 }
337 assert_eq!(resolved, path);
338 }
339
340 #[test]
341 fn opencode_plugins_dir_ends_correctly() {
342 let p = opencode_plugins_dir().expect("path resolved");
343 assert!(p.ends_with(PathBuf::from(".config").join("opencode").join("plugins")));
344 }
345
346 #[test]
347 fn mcp_paths_end_correctly() {
348 assert!(claude_mcp_user_file()
349 .unwrap()
350 .to_string_lossy()
351 .ends_with(".claude.json"));
352 assert!(kilo_config_file()
353 .unwrap()
354 .ends_with(PathBuf::from(".config").join("kilo").join("kilo.jsonc")));
355 assert!(cline_mcp_global_file().unwrap().ends_with(
356 PathBuf::from("Code")
357 .join("User")
358 .join("globalStorage")
359 .join("saoudrizwan.claude-dev")
360 .join("settings")
361 .join("cline_mcp_settings.json")
362 ));
363 assert!(roo_mcp_global_file().unwrap().ends_with(
364 PathBuf::from("Code")
365 .join("User")
366 .join("globalStorage")
367 .join("rooveterinaryinc.roo-cline")
368 .join("settings")
369 .join("mcp_settings.json")
370 ));
371 assert!(antigravity_mcp_global_file().unwrap().ends_with(
372 PathBuf::from(".gemini")
373 .join("antigravity")
374 .join("mcp_config.json")
375 ));
376 assert!(windsurf_mcp_global_file().unwrap().ends_with(
377 PathBuf::from(".codeium")
378 .join("windsurf")
379 .join("mcp_config.json")
380 ));
381 }
382}