Skip to main content

agent_kit/
detect.rs

1//! Agent environment detection — identify which AI agent loop is active.
2//!
3//! Checks environment variables to determine the active agent environment,
4//! then provides environment-specific paths and configuration.
5
6use std::path::{Path, PathBuf};
7
8/// The detected AI agent environment.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Environment {
11    /// Anthropic's Claude Code CLI.
12    ClaudeCode,
13    /// OpenCode CLI.
14    OpenCode,
15    /// OpenAI's Codex CLI.
16    Codex,
17    /// Cursor AI editor.
18    Cursor,
19    /// Unknown or generic environment.
20    Generic,
21}
22
23impl Environment {
24    /// Auto-detect the active agent environment from environment variables.
25    pub fn detect() -> Self {
26        detect_from(|key| std::env::var_os(key))
27    }
28
29    /// Return the skill file path pattern for this environment.
30    ///
31    /// Given a skill `name`, returns the relative path where the skill file
32    /// should be installed. Each environment has its own convention:
33    ///
34    /// | Environment | Path |
35    /// |-------------|------|
36    /// | Claude Code | `.claude/skills/{name}/SKILL.md` |
37    /// | OpenCode | `.opencode/skills/{name}/SKILL.md` |
38    /// | Codex | `.codex/AGENTS.md` |
39    /// | Cursor | `.cursor/rules/{name}.md` |
40    /// | Generic | `AGENTS.md` |
41    pub fn skill_rel_path(&self, name: &str) -> PathBuf {
42        match self {
43            Self::ClaudeCode => PathBuf::from(format!(".claude/skills/{name}/SKILL.md")),
44            Self::OpenCode => PathBuf::from(format!(".opencode/skills/{name}/SKILL.md")),
45            Self::Codex => PathBuf::from(".codex/AGENTS.md"),
46            Self::Cursor => PathBuf::from(format!(".cursor/rules/{name}.md")),
47            Self::Generic => PathBuf::from("AGENTS.md"),
48        }
49    }
50
51    /// Return all skill file paths for installing across all environments.
52    /// Used by `install --all` to write skill files for every supported harness.
53    pub fn all_skill_rel_paths(name: &str) -> Vec<(Environment, PathBuf)> {
54        vec![
55            (Self::ClaudeCode, Self::ClaudeCode.skill_rel_path(name)),
56            (Self::OpenCode, Self::OpenCode.skill_rel_path(name)),
57            (Self::Codex, Self::Codex.skill_rel_path(name)),
58            (Self::Cursor, Self::Cursor.skill_rel_path(name)),
59        ]
60    }
61
62    /// Resolve the skill file path under a given root directory.
63    ///
64    /// When `root` is `None`, the returned path is relative to CWD.
65    pub fn skill_path(&self, name: &str, root: Option<&Path>) -> PathBuf {
66        let rel = self.skill_rel_path(name);
67        match root {
68            Some(r) => r.join(rel),
69            None => rel,
70        }
71    }
72
73    /// Return the rules/instruction file directory for this environment.
74    ///
75    /// | Environment | Directory |
76    /// |-------------|-----------|
77    /// | Claude Code | `.claude/` (CLAUDE.md lives at root) |
78    /// | Cursor | `.cursor/rules/` |
79    /// | Windsurf | `.windsurf/rules/` |
80    /// | Others | `.agent/rules/` |
81    pub fn rules_dir(&self) -> PathBuf {
82        match self {
83            Self::Cursor => PathBuf::from(".cursor/rules"),
84            _ => PathBuf::from(".agent/rules"),
85        }
86    }
87
88    /// Return the runbooks directory for this environment.
89    ///
90    /// Runbooks use `.agent/runbooks/` universally — they're tool-agnostic.
91    pub fn runbooks_dir(&self) -> PathBuf {
92        PathBuf::from(".agent/runbooks")
93    }
94
95    /// Return the memories directory for this environment.
96    ///
97    /// Memories use `.agent/memories/` universally — they're tool-agnostic.
98    pub fn memories_dir(&self) -> PathBuf {
99        PathBuf::from(".agent/memories")
100    }
101
102    /// Return the skills directory for this environment.
103    ///
104    /// | Environment | Directory |
105    /// |-------------|-----------|
106    /// | Claude Code | `.claude/skills/` |
107    /// | OpenCode | `.opencode/skills/` |
108    /// | Cursor | `.cursor/rules/` (skills map to rules) |
109    /// | Others | `.agent/skills/` |
110    pub fn skills_dir(&self) -> PathBuf {
111        match self {
112            Self::ClaudeCode => PathBuf::from(".claude/skills"),
113            Self::OpenCode => PathBuf::from(".opencode/skills"),
114            Self::Cursor => PathBuf::from(".cursor/rules"),
115            _ => PathBuf::from(".agent/skills"),
116        }
117    }
118}
119
120impl Environment {
121    /// Parse from a string name (for --harness flag).
122    pub fn from_name(name: &str) -> Option<Self> {
123        match name.to_lowercase().as_str() {
124            "claude" | "claude-code" | "claudecode" => Some(Self::ClaudeCode),
125            "opencode" | "open-code" => Some(Self::OpenCode),
126            "codex" => Some(Self::Codex),
127            "cursor" => Some(Self::Cursor),
128            "generic" => Some(Self::Generic),
129            _ => None,
130        }
131    }
132}
133
134impl std::fmt::Display for Environment {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::ClaudeCode => write!(f, "Claude Code"),
138            Self::OpenCode => write!(f, "OpenCode"),
139            Self::Codex => write!(f, "Codex"),
140            Self::Cursor => write!(f, "Cursor"),
141            Self::Generic => write!(f, "Generic"),
142        }
143    }
144}
145
146/// Auto-detect the active agent environment.
147///
148/// Convenience wrapper around [`Environment::detect`].
149pub fn detect() -> Environment {
150    Environment::detect()
151}
152
153/// Internal detection logic, parameterized for testability.
154fn detect_from<F, V>(var: F) -> Environment
155where
156    F: Fn(&str) -> Option<V>,
157    V: AsRef<std::ffi::OsStr>,
158{
159    if var("CLAUDE_CODE").is_some() || var("CLAUDE_CODE_ENTRYPOINT").is_some() {
160        return Environment::ClaudeCode;
161    }
162    if var("OPENCODE").is_some() {
163        return Environment::OpenCode;
164    }
165    if var("CODEX_CLI").is_some() || var("CODEX").is_some() {
166        return Environment::Codex;
167    }
168    if var("CURSOR_SESSION_ID").is_some() || var("CURSOR").is_some() {
169        return Environment::Cursor;
170    }
171    Environment::Generic
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::collections::HashMap;
178    use std::ffi::OsString;
179
180    fn env_with(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
181        let map: HashMap<String, OsString> = pairs
182            .iter()
183            .map(|(k, v)| (k.to_string(), OsString::from(v)))
184            .collect();
185        move |key: &str| map.get(key).cloned()
186    }
187
188    #[test]
189    fn detects_claude_code_via_claude_code_var() {
190        let detect = detect_from(env_with(&[("CLAUDE_CODE", "1")]));
191        assert_eq!(detect, Environment::ClaudeCode);
192    }
193
194    #[test]
195    fn detects_claude_code_via_entrypoint() {
196        let detect = detect_from(env_with(&[("CLAUDE_CODE_ENTRYPOINT", "/usr/bin/claude")]));
197        assert_eq!(detect, Environment::ClaudeCode);
198    }
199
200    #[test]
201    fn detects_opencode() {
202        let detect = detect_from(env_with(&[("OPENCODE", "1")]));
203        assert_eq!(detect, Environment::OpenCode);
204    }
205
206    #[test]
207    fn detects_codex_cli() {
208        let detect = detect_from(env_with(&[("CODEX_CLI", "1")]));
209        assert_eq!(detect, Environment::Codex);
210    }
211
212    #[test]
213    fn detects_codex_var() {
214        let detect = detect_from(env_with(&[("CODEX", "1")]));
215        assert_eq!(detect, Environment::Codex);
216    }
217
218    #[test]
219    fn falls_back_to_generic() {
220        let detect = detect_from(env_with(&[]));
221        assert_eq!(detect, Environment::Generic);
222    }
223
224    #[test]
225    fn claude_code_takes_priority_over_others() {
226        let detect = detect_from(env_with(&[("CLAUDE_CODE", "1"), ("OPENCODE", "1")]));
227        assert_eq!(detect, Environment::ClaudeCode);
228    }
229
230    #[test]
231    fn skill_rel_path_format() {
232        let env = Environment::ClaudeCode;
233        assert_eq!(
234            env.skill_rel_path("agent-doc"),
235            PathBuf::from(".claude/skills/agent-doc/SKILL.md")
236        );
237    }
238
239    #[test]
240    fn skill_path_claude_with_root() {
241        let env = Environment::ClaudeCode;
242        let path = env.skill_path("my-tool", Some(Path::new("/project")));
243        assert_eq!(path, PathBuf::from("/project/.claude/skills/my-tool/SKILL.md"));
244    }
245
246    #[test]
247    fn skill_path_generic_without_root() {
248        let env = Environment::Generic;
249        let path = env.skill_path("my-tool", None);
250        assert_eq!(path, PathBuf::from("AGENTS.md"));
251    }
252
253    #[test]
254    fn skill_path_per_environment() {
255        assert_eq!(
256            Environment::ClaudeCode.skill_rel_path("tool"),
257            PathBuf::from(".claude/skills/tool/SKILL.md")
258        );
259        assert_eq!(
260            Environment::OpenCode.skill_rel_path("tool"),
261            PathBuf::from(".opencode/skills/tool/SKILL.md")
262        );
263        assert_eq!(
264            Environment::Codex.skill_rel_path("tool"),
265            PathBuf::from(".codex/AGENTS.md")
266        );
267        assert_eq!(
268            Environment::Cursor.skill_rel_path("tool"),
269            PathBuf::from(".cursor/rules/tool.md")
270        );
271        assert_eq!(
272            Environment::Generic.skill_rel_path("tool"),
273            PathBuf::from("AGENTS.md")
274        );
275    }
276
277    #[test]
278    fn from_name_parses_variants() {
279        assert_eq!(Environment::from_name("claude"), Some(Environment::ClaudeCode));
280        assert_eq!(Environment::from_name("claude-code"), Some(Environment::ClaudeCode));
281        assert_eq!(Environment::from_name("opencode"), Some(Environment::OpenCode));
282        assert_eq!(Environment::from_name("codex"), Some(Environment::Codex));
283        assert_eq!(Environment::from_name("cursor"), Some(Environment::Cursor));
284        assert_eq!(Environment::from_name("generic"), Some(Environment::Generic));
285        assert_eq!(Environment::from_name("unknown"), None);
286    }
287
288    #[test]
289    fn all_skill_rel_paths_returns_four() {
290        let paths = Environment::all_skill_rel_paths("tool");
291        assert_eq!(paths.len(), 4);
292    }
293
294    #[test]
295    fn display_variants() {
296        assert_eq!(Environment::ClaudeCode.to_string(), "Claude Code");
297        assert_eq!(Environment::OpenCode.to_string(), "OpenCode");
298        assert_eq!(Environment::Codex.to_string(), "Codex");
299        assert_eq!(Environment::Cursor.to_string(), "Cursor");
300        assert_eq!(Environment::Generic.to_string(), "Generic");
301    }
302
303    #[test]
304    fn rules_dir_cursor_specific() {
305        assert_eq!(Environment::Cursor.rules_dir(), PathBuf::from(".cursor/rules"));
306    }
307
308    #[test]
309    fn rules_dir_generic() {
310        assert_eq!(Environment::ClaudeCode.rules_dir(), PathBuf::from(".agent/rules"));
311        assert_eq!(Environment::Generic.rules_dir(), PathBuf::from(".agent/rules"));
312    }
313
314    #[test]
315    fn runbooks_dir_universal() {
316        assert_eq!(Environment::ClaudeCode.runbooks_dir(), PathBuf::from(".agent/runbooks"));
317        assert_eq!(Environment::Cursor.runbooks_dir(), PathBuf::from(".agent/runbooks"));
318        assert_eq!(Environment::Generic.runbooks_dir(), PathBuf::from(".agent/runbooks"));
319    }
320
321    #[test]
322    fn memories_dir_universal() {
323        assert_eq!(Environment::ClaudeCode.memories_dir(), PathBuf::from(".agent/memories"));
324        assert_eq!(Environment::Generic.memories_dir(), PathBuf::from(".agent/memories"));
325    }
326
327    #[test]
328    fn skills_dir_per_environment() {
329        assert_eq!(Environment::ClaudeCode.skills_dir(), PathBuf::from(".claude/skills"));
330        assert_eq!(Environment::OpenCode.skills_dir(), PathBuf::from(".opencode/skills"));
331        assert_eq!(Environment::Cursor.skills_dir(), PathBuf::from(".cursor/rules"));
332        assert_eq!(Environment::Generic.skills_dir(), PathBuf::from(".agent/skills"));
333    }
334}