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::path::PathBuf;
9
10use crate::error::AgentConfigError;
11
12/// Returns the user's home directory or a [`AgentConfigError::PathResolution`] if
13/// the platform doesn't expose one.
14///
15/// # Errors
16///
17/// Returns [`AgentConfigError::PathResolution`] when neither `$HOME`
18/// (`%USERPROFILE%` on Windows) nor [`dirs::home_dir`] yields a value.
19pub 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
35/// Returns `$XDG_CONFIG_HOME` (or its platform default) — used by OpenCode.
36///
37/// On macOS this is `~/Library/Application Support`. OpenCode, however, uses
38/// `~/.config/opencode` even on macOS, so callers that need OpenCode's path
39/// should prefer [`opencode_plugins_dir`] which encodes that quirk.
40///
41/// # Errors
42///
43/// Returns [`AgentConfigError::PathResolution`] when neither
44/// `$XDG_CONFIG_HOME` (`%APPDATA%` on Windows) nor [`dirs::config_dir`]
45/// yields a value.
46pub 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
67/// `~/.claude` (all platforms).
68///
69/// # Errors
70///
71/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
72pub fn claude_home() -> Result<PathBuf, AgentConfigError> {
73    Ok(home_dir()?.join(".claude"))
74}
75
76/// `~/.cursor` (all platforms).
77///
78/// # Errors
79///
80/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
81pub fn cursor_home() -> Result<PathBuf, AgentConfigError> {
82    Ok(home_dir()?.join(".cursor"))
83}
84
85/// `~/.gemini` (all platforms).
86///
87/// # Errors
88///
89/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
90pub fn gemini_home() -> Result<PathBuf, AgentConfigError> {
91    Ok(home_dir()?.join(".gemini"))
92}
93
94/// `$CODEX_HOME` if set, else `~/.codex`.
95///
96/// # Errors
97///
98/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`] when
99/// `CODEX_HOME` is unset and the home directory cannot be resolved.
100pub 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
107/// `~/.openclaw` (all platforms).
108///
109/// # Errors
110///
111/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
112pub fn openclaw_home() -> Result<PathBuf, AgentConfigError> {
113    Ok(home_dir()?.join(".openclaw"))
114}
115
116/// `~/.hermes` (all platforms).
117///
118/// # Errors
119///
120/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
121pub fn hermes_home() -> Result<PathBuf, AgentConfigError> {
122    Ok(home_dir()?.join(".hermes"))
123}
124
125/// OpenCode forces its plugin directory under `~/.config/opencode/plugins`
126/// regardless of platform conventions. Returns that path.
127///
128/// # Errors
129///
130/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
131pub fn opencode_plugins_dir() -> Result<PathBuf, AgentConfigError> {
132    Ok(home_dir()?.join(".config").join("opencode").join("plugins"))
133}
134
135/// `~/.config/opencode/opencode.json` — OpenCode's main config, where the
136/// object-based `mcp` map lives.
137///
138/// # Errors
139///
140/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
141pub fn opencode_config_file() -> Result<PathBuf, AgentConfigError> {
142    Ok(home_dir()?
143        .join(".config")
144        .join("opencode")
145        .join("opencode.json"))
146}
147
148/// `~/.config/kilo/kilo.jsonc` — Kilo Code's global JSONC config.
149///
150/// # Errors
151///
152/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
153pub fn kilo_config_file() -> Result<PathBuf, AgentConfigError> {
154    Ok(home_dir()?.join(".config").join("kilo").join("kilo.jsonc"))
155}
156
157/// `~/.claude.json` — Claude Code's user/local MCP config file.
158///
159/// # Errors
160///
161/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
162pub fn claude_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
163    Ok(home_dir()?.join(".claude.json"))
164}
165
166/// `~/.cursor/mcp.json` — Cursor's MCP user-config file.
167///
168/// # Errors
169///
170/// Propagates [`AgentConfigError::PathResolution`] from [`cursor_home`].
171pub fn cursor_mcp_user_file() -> Result<PathBuf, AgentConfigError> {
172    Ok(cursor_home()?.join("mcp.json"))
173}
174
175/// VS Code globalStorage directory for an extension in the stable `Code`
176/// profile. `extension_id` is appended verbatim and is not validated; pass the
177/// publisher.name string used in the VS Code marketplace
178/// (e.g. `"saoudrizwan.claude-dev"`).
179///
180/// # Errors
181///
182/// Propagates [`AgentConfigError::PathResolution`] from [`config_dir`].
183pub 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
191/// Cline's global MCP settings file inside VS Code globalStorage.
192///
193/// # Errors
194///
195/// Propagates [`AgentConfigError::PathResolution`] from [`vscode_global_storage`].
196pub 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
202/// Roo Code's global MCP settings file inside VS Code globalStorage.
203///
204/// # Errors
205///
206/// Propagates [`AgentConfigError::PathResolution`] from [`vscode_global_storage`].
207pub 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
213/// `~/.gemini/antigravity/mcp_config.json` — Antigravity's global MCP config.
214///
215/// # Errors
216///
217/// Propagates [`AgentConfigError::PathResolution`] from [`gemini_home`].
218pub fn antigravity_mcp_global_file() -> Result<PathBuf, AgentConfigError> {
219    Ok(gemini_home()?.join("antigravity").join("mcp_config.json"))
220}
221
222/// `~/.codeium/windsurf/mcp_config.json` — Windsurf's global MCP config.
223///
224/// # Errors
225///
226/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
227pub 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
234/// Charm Crush's per-user config directory.
235///
236/// Honors `$CRUSH_GLOBAL_CONFIG` if set (Crush's documented override). Falls
237/// back to `$XDG_CONFIG_HOME/crush` on Unix and `%APPDATA%\crush` on Windows
238/// via [`config_dir`]. The single `crush.json` file lives directly under this
239/// directory.
240///
241/// # Errors
242///
243/// Propagates [`AgentConfigError::PathResolution`] when no usable directory
244/// is found.
245pub 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
252/// Pi coding-agent's per-user config directory: `~/.pi/agent`.
253///
254/// Pi keeps its global memory file (`AGENTS.md`), MCP file (`mcp.json` for the
255/// `pi-mcp-adapter`), skills (`skills/`), and extensions all under this root.
256///
257/// # Errors
258///
259/// Propagates [`AgentConfigError::PathResolution`] from [`home_dir`].
260pub 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    // Serializes env-var mutations across the tests below. CODEX_HOME is the
270    // only var read by these tests, but other tests in the suite that share
271    // the process can mutate HOME / USERPROFILE / APPDATA, so any test that
272    // mutates env vars must hold this mutex to avoid cross-test interference
273    // under parallel execution.
274    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        // CI environments always have $HOME set; smoke check that we don't panic.
282        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        // pi_home is a two-segment suffix.
317        let p = pi_home().expect("path resolved");
318        assert!(p.ends_with(PathBuf::from(".pi").join("agent")));
319        // crush_home ends in `crush` whether sourced from $XDG_CONFIG_HOME or
320        // platform default.
321        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}