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/// `~/.claude.json` — Claude Code's user/local MCP config file.
159///
160/// # Errors
161///
162/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
163pub fn claude_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
164    Ok(home_dir()?.join(".claude.json"))
165}
166
167/// `~/.cursor/mcp.json` — Cursor's MCP user-config file.
168///
169/// # Errors
170///
171/// Propagates [`AgentConfigError::PathResolution`] from [`cursor_home`].
172pub fn cursor_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
173    Ok(cursor_home()?.join("mcp.json"))
174}
175
176/// VS Code globalStorage directory for an extension in the stable `Code`
177/// profile. `extension_id` is appended verbatim and is not validated; pass the
178/// publisher.name string used in the VS Code marketplace
179/// (e.g. `"saoudrizwan.claude-dev"`).
180///
181/// # Errors
182///
183/// Propagates [`AgentConfigError::PathResolution`] from [`config_dir`].
184pub 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
192/// Cline's global MCP settings file inside VS Code globalStorage.
193///
194/// # Errors
195///
196/// Propagates [`AgentConfigError::PathResolution`] from [`vscode_global_storage`].
197pub 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
203/// Roo Code's global MCP settings file inside VS Code globalStorage.
204///
205/// # Errors
206///
207/// Propagates [`AgentConfigError::PathResolution`] from [`vscode_global_storage`].
208pub 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
214/// `~/.gemini/antigravity/mcp_config.json` — Antigravity's global MCP config.
215///
216/// If Antigravity has installed that documented file as a symlink to another
217/// file under `~/.gemini`, this returns the resolved target. Antigravity uses
218/// this symlink for its shared config store, and resolving only this leaf keeps
219/// global write symlink rejection strict for every other path.
220///
221/// # Errors
222///
223/// Propagates [`AgentConfigError::PathResolution`] from [`gemini_home`].
224pub 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
258/// `~/.gemini/antigravity-cli` — Antigravity CLI's global config directory.
259///
260/// # Errors
261///
262/// Propagates [`AgentConfigError::PathResolution`] from [`gemini_home`].
263pub fn antigravity_cli_home() -> Result<PathBuf, AgentConfigError> {
264    Ok(gemini_home()?.join("antigravity-cli"))
265}
266
267/// `~/.gemini/antigravity-cli/mcp_config.json` — Antigravity CLI's global MCP config.
268///
269/// # Errors
270///
271/// Propagates [`AgentConfigError::PathResolution`] from [`antigravity_cli_home`].
272pub fn antigravity_cli_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
273    Ok(antigravity_cli_home()?.join("mcp_config.json"))
274}
275
276/// `~/.codeium/windsurf/mcp_config.json` — Windsurf's global MCP config.
277///
278/// # Errors
279///
280/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
281pub 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
288/// Charm Crush's per-user config directory.
289///
290/// Honors `$CRUSH_GLOBAL_CONFIG` if set (Crush's documented override). Falls
291/// back to `$XDG_CONFIG_HOME/crush` on Unix and `%APPDATA%\crush` on Windows
292/// via [`config_dir`]. The single `crush.json` file lives directly under this
293/// directory.
294///
295/// # Errors
296///
297/// Propagates [`AgentConfigError::PathResolution`] when no usable directory
298/// is found.
299pub 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
306/// Pi coding-agent's per-user config directory: `~/.pi/agent`.
307///
308/// Pi keeps its global memory file (`AGENTS.md`), MCP file (`mcp.json` for the
309/// `pi-mcp-adapter`), skills (`skills/`), and extensions all under this root.
310///
311/// # Errors
312///
313/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
314pub 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    // Serializes env-var mutations across the tests below. CODEX_HOME is the
324    // only var read by these tests, but other tests in the suite that share
325    // the process can mutate HOME / USERPROFILE / APPDATA, so any test that
326    // mutates env vars must hold this mutex to avoid cross-test interference
327    // under parallel execution.
328    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        // CI environments always have $HOME set; smoke check that we don't panic.
336        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        // pi_home is a two-segment suffix.
371        let p = pi_home().expect("path resolved");
372        assert!(p.ends_with(PathBuf::from(".pi").join("agent")));
373        // crush_home ends in `crush` whether sourced from $XDG_CONFIG_HOME or
374        // platform default.
375        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}