Skip to main content

agent_config/
paths.rs

1//! Cross-platform resolution of the per-user directories that AI harnesses use.
2//!
3//! Most harnesses on macOS/Linux store config under `$HOME/.<name>` (dotdir
4//! convention) rather than the XDG config dir. On Windows, explicit
5//! `%USERPROFILE%`/`%APPDATA%` overrides are honored before falling back to
6//! shell-known folders, which is what those harnesses ship with too.
7
8use std::fs;
9use std::path::PathBuf;
10
11use crate::error::AgentConfigError;
12
13/// Returns the user's home directory or a [`AgentConfigError::PathResolution`] if
14/// the platform doesn't expose one.
15///
16/// # Errors
17///
18/// Returns [`AgentConfigError::PathResolution`] when neither `$HOME`
19/// (`%USERPROFILE%` on Windows) nor [`dirs::home_dir`] yields a value.
20pub 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
36/// Returns `$XDG_CONFIG_HOME` (or its platform default) — used by OpenCode.
37///
38/// On macOS this is `~/Library/Application Support`. OpenCode, however, uses
39/// `~/.config/opencode` even on macOS, so callers that need OpenCode's path
40/// should prefer [`opencode_plugins_dir`] which encodes that quirk.
41///
42/// # Errors
43///
44/// Returns [`AgentConfigError::PathResolution`] when neither
45/// `$XDG_CONFIG_HOME` (`%APPDATA%` on Windows) nor [`dirs::config_dir`]
46/// yields a value.
47pub 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
68/// `~/.claude` (all platforms).
69///
70/// # Errors
71///
72/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
73pub fn claude_home() -> Result<PathBuf, AgentConfigError> {
74    Ok(home_dir()?.join(".claude"))
75}
76
77/// `~/.cursor` (all platforms).
78///
79/// # Errors
80///
81/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
82pub fn cursor_home() -> Result<PathBuf, AgentConfigError> {
83    Ok(home_dir()?.join(".cursor"))
84}
85
86/// `~/.gemini` (all platforms).
87///
88/// # Errors
89///
90/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
91pub fn gemini_home() -> Result<PathBuf, AgentConfigError> {
92    Ok(home_dir()?.join(".gemini"))
93}
94
95/// `$CODEX_HOME` if set, else `~/.codex`.
96///
97/// # Errors
98///
99/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`] when
100/// `CODEX_HOME` is unset and the home directory cannot be resolved.
101pub 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
108/// `~/.openclaw` (all platforms).
109///
110/// # Errors
111///
112/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
113pub fn openclaw_home() -> Result<PathBuf, AgentConfigError> {
114    Ok(home_dir()?.join(".openclaw"))
115}
116
117/// `~/.hermes` (all platforms).
118///
119/// # Errors
120///
121/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
122pub fn hermes_home() -> Result<PathBuf, AgentConfigError> {
123    Ok(home_dir()?.join(".hermes"))
124}
125
126/// OpenCode forces its plugin directory under `~/.config/opencode/plugins`
127/// regardless of platform conventions. Returns that path.
128///
129/// # Errors
130///
131/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
132pub fn opencode_plugins_dir() -> Result<PathBuf, AgentConfigError> {
133    Ok(home_dir()?.join(".config").join("opencode").join("plugins"))
134}
135
136/// `~/.config/opencode/opencode.json` — OpenCode's main config, where the
137/// object-based `mcp` map lives.
138///
139/// # Errors
140///
141/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
142pub fn opencode_config_file() -> Result<PathBuf, AgentConfigError> {
143    Ok(home_dir()?
144        .join(".config")
145        .join("opencode")
146        .join("opencode.json"))
147}
148
149/// `~/.config/kilo/kilo.jsonc` — Kilo Code's global JSONC config.
150///
151/// # Errors
152///
153/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
154pub fn kilo_config_file() -> Result<PathBuf, AgentConfigError> {
155    Ok(home_dir()?.join(".config").join("kilo").join("kilo.jsonc"))
156}
157
158/// `~/.config/amp` — Amp's user config directory. Amp uses `~/.config/amp`
159/// literally on every platform (XDG-style), not the macOS `Application Support`
160/// dir, so this is built from [`home_dir`] rather than [`config_dir`].
161///
162/// # Errors
163///
164/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
165pub fn amp_config_dir() -> Result<PathBuf, AgentConfigError> {
166    Ok(home_dir()?.join(".config").join("amp"))
167}
168
169/// `~/.claude.json` — Claude Code's user/local MCP config file.
170///
171/// # Errors
172///
173/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
174pub fn claude_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
175    Ok(home_dir()?.join(".claude.json"))
176}
177
178/// `~/.cursor/mcp.json` — Cursor's MCP user-config file.
179///
180/// # Errors
181///
182/// Propagates [`AgentConfigError::PathResolution`] from [`cursor_home`].
183pub fn cursor_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
184    Ok(cursor_home()?.join("mcp.json"))
185}
186
187/// VS Code globalStorage directory for an extension in the stable `Code`
188/// profile. `extension_id` is appended verbatim and is not validated; pass the
189/// publisher.name string used in the VS Code marketplace
190/// (e.g. `"saoudrizwan.claude-dev"`).
191///
192/// # Errors
193///
194/// Propagates [`AgentConfigError::PathResolution`] from [`config_dir`].
195pub 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
203/// Cline's global MCP settings file inside VS Code globalStorage.
204///
205/// # Errors
206///
207/// Propagates [`AgentConfigError::PathResolution`] from [`vscode_global_storage`].
208pub 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
231/// Cline's legacy global MCP settings file inside VS Code globalStorage.
232///
233/// # Errors
234///
235/// Propagates [`AgentConfigError::PathResolution`] from [`vscode_global_storage`].
236pub 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
242/// Roo Code's global MCP settings file inside VS Code globalStorage.
243///
244/// # Errors
245///
246/// Propagates [`AgentConfigError::PathResolution`] from [`vscode_global_storage`].
247pub 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
253/// `~/.gemini/antigravity/mcp_config.json` — Antigravity's global MCP config.
254///
255/// If Antigravity has installed that documented file as a symlink to another
256/// file under `~/.gemini`, this returns the resolved target. Antigravity uses
257/// this symlink for its shared config store, and resolving only this leaf keeps
258/// global write symlink rejection strict for every other path.
259///
260/// # Errors
261///
262/// Propagates [`AgentConfigError::PathResolution`] from [`gemini_home`].
263pub 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
297/// `~/.gemini/antigravity-cli` — Antigravity CLI's global config directory.
298///
299/// # Errors
300///
301/// Propagates [`AgentConfigError::PathResolution`] from [`gemini_home`].
302pub fn antigravity_cli_home() -> Result<PathBuf, AgentConfigError> {
303    Ok(gemini_home()?.join("antigravity-cli"))
304}
305
306/// `~/.gemini/antigravity-cli/mcp_config.json` — Antigravity CLI's global MCP config.
307///
308/// # Errors
309///
310/// Propagates [`AgentConfigError::PathResolution`] from [`antigravity_cli_home`].
311pub fn antigravity_cli_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
312    Ok(antigravity_cli_home()?.join("mcp_config.json"))
313}
314
315/// `~/.codeium/windsurf/mcp_config.json` — Windsurf's global MCP config.
316///
317/// # Errors
318///
319/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
320pub 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
327/// Charm Crush's per-user config directory.
328///
329/// Honors `$CRUSH_GLOBAL_CONFIG` if set (Crush's documented override). Falls
330/// back to `$XDG_CONFIG_HOME/crush` on Unix and `%APPDATA%\crush` on Windows
331/// via [`config_dir`]. The single `crush.json` file lives directly under this
332/// directory.
333///
334/// # Errors
335///
336/// Propagates [`AgentConfigError::PathResolution`] when no usable directory
337/// is found.
338pub 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
345/// Pi coding-agent's per-user config directory: `~/.pi/agent`.
346///
347/// Pi keeps its global memory file (`AGENTS.md`), MCP file (`mcp.json` for the
348/// `pi-mcp-adapter`), skills (`skills/`), and extensions all under this root.
349///
350/// # Errors
351///
352/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
353pub 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    // Serializes env-var mutations across the tests below. CODEX_HOME is the
363    // only var read by these tests, but other tests in the suite that share
364    // the process can mutate HOME / USERPROFILE / APPDATA, so any test that
365    // mutates env vars must hold this mutex to avoid cross-test interference
366    // under parallel execution.
367    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        // CI environments always have $HOME set; smoke check that we don't panic.
375        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        // pi_home is a two-segment suffix.
410        let p = pi_home().expect("path resolved");
411        assert!(p.ends_with(PathBuf::from(".pi").join("agent")));
412        // crush_home ends in `crush` whether sourced from $XDG_CONFIG_HOME or
413        // platform default.
414        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}