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    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
220/// Cline's legacy global MCP settings file inside VS Code globalStorage.
221///
222/// # Errors
223///
224/// Propagates [`AgentConfigError::PathResolution`] from [`vscode_global_storage`].
225pub 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
231/// Roo Code's global MCP settings file inside VS Code globalStorage.
232///
233/// # Errors
234///
235/// Propagates [`AgentConfigError::PathResolution`] from [`vscode_global_storage`].
236pub 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
242/// `~/.gemini/antigravity/mcp_config.json` — Antigravity's global MCP config.
243///
244/// If Antigravity has installed that documented file as a symlink to another
245/// file under `~/.gemini`, this returns the resolved target. Antigravity uses
246/// this symlink for its shared config store, and resolving only this leaf keeps
247/// global write symlink rejection strict for every other path.
248///
249/// # Errors
250///
251/// Propagates [`AgentConfigError::PathResolution`] from [`gemini_home`].
252pub 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
286/// `~/.gemini/antigravity-cli` — Antigravity CLI's global config directory.
287///
288/// # Errors
289///
290/// Propagates [`AgentConfigError::PathResolution`] from [`gemini_home`].
291pub fn antigravity_cli_home() -> Result<PathBuf, AgentConfigError> {
292    Ok(gemini_home()?.join("antigravity-cli"))
293}
294
295/// `~/.gemini/antigravity-cli/mcp_config.json` — Antigravity CLI's global MCP config.
296///
297/// # Errors
298///
299/// Propagates [`AgentConfigError::PathResolution`] from [`antigravity_cli_home`].
300pub fn antigravity_cli_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
301    Ok(antigravity_cli_home()?.join("mcp_config.json"))
302}
303
304/// `~/.codeium/windsurf/mcp_config.json` — Windsurf's global MCP config.
305///
306/// # Errors
307///
308/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
309pub 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
316/// Charm Crush's per-user config directory.
317///
318/// Honors `$CRUSH_GLOBAL_CONFIG` if set (Crush's documented override). Falls
319/// back to `$XDG_CONFIG_HOME/crush` on Unix and `%APPDATA%\crush` on Windows
320/// via [`config_dir`]. The single `crush.json` file lives directly under this
321/// directory.
322///
323/// # Errors
324///
325/// Propagates [`AgentConfigError::PathResolution`] when no usable directory
326/// is found.
327pub 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
334/// Pi coding-agent's per-user config directory: `~/.pi/agent`.
335///
336/// Pi keeps its global memory file (`AGENTS.md`), MCP file (`mcp.json` for the
337/// `pi-mcp-adapter`), skills (`skills/`), and extensions all under this root.
338///
339/// # Errors
340///
341/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
342pub 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    // Serializes env-var mutations across the tests below. CODEX_HOME is the
352    // only var read by these tests, but other tests in the suite that share
353    // the process can mutate HOME / USERPROFILE / APPDATA, so any test that
354    // mutates env vars must hold this mutex to avoid cross-test interference
355    // under parallel execution.
356    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        // CI environments always have $HOME set; smoke check that we don't panic.
364        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        // pi_home is a two-segment suffix.
399        let p = pi_home().expect("path resolved");
400        assert!(p.ends_with(PathBuf::from(".pi").join("agent")));
401        // crush_home ends in `crush` whether sourced from $XDG_CONFIG_HOME or
402        // platform default.
403        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}