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!(
244            path,
245            PathBuf::from("/project/.claude/skills/my-tool/SKILL.md")
246        );
247    }
248
249    #[test]
250    fn skill_path_generic_without_root() {
251        let env = Environment::Generic;
252        let path = env.skill_path("my-tool", None);
253        assert_eq!(path, PathBuf::from("AGENTS.md"));
254    }
255
256    #[test]
257    fn skill_path_per_environment() {
258        assert_eq!(
259            Environment::ClaudeCode.skill_rel_path("tool"),
260            PathBuf::from(".claude/skills/tool/SKILL.md")
261        );
262        assert_eq!(
263            Environment::OpenCode.skill_rel_path("tool"),
264            PathBuf::from(".opencode/skills/tool/SKILL.md")
265        );
266        assert_eq!(
267            Environment::Codex.skill_rel_path("tool"),
268            PathBuf::from(".codex/AGENTS.md")
269        );
270        assert_eq!(
271            Environment::Cursor.skill_rel_path("tool"),
272            PathBuf::from(".cursor/rules/tool.md")
273        );
274        assert_eq!(
275            Environment::Generic.skill_rel_path("tool"),
276            PathBuf::from("AGENTS.md")
277        );
278    }
279
280    #[test]
281    fn from_name_parses_variants() {
282        assert_eq!(
283            Environment::from_name("claude"),
284            Some(Environment::ClaudeCode)
285        );
286        assert_eq!(
287            Environment::from_name("claude-code"),
288            Some(Environment::ClaudeCode)
289        );
290        assert_eq!(
291            Environment::from_name("opencode"),
292            Some(Environment::OpenCode)
293        );
294        assert_eq!(Environment::from_name("codex"), Some(Environment::Codex));
295        assert_eq!(Environment::from_name("cursor"), Some(Environment::Cursor));
296        assert_eq!(
297            Environment::from_name("generic"),
298            Some(Environment::Generic)
299        );
300        assert_eq!(Environment::from_name("unknown"), None);
301    }
302
303    #[test]
304    fn all_skill_rel_paths_returns_four() {
305        let paths = Environment::all_skill_rel_paths("tool");
306        assert_eq!(paths.len(), 4);
307    }
308
309    #[test]
310    fn display_variants() {
311        assert_eq!(Environment::ClaudeCode.to_string(), "Claude Code");
312        assert_eq!(Environment::OpenCode.to_string(), "OpenCode");
313        assert_eq!(Environment::Codex.to_string(), "Codex");
314        assert_eq!(Environment::Cursor.to_string(), "Cursor");
315        assert_eq!(Environment::Generic.to_string(), "Generic");
316    }
317
318    #[test]
319    fn rules_dir_cursor_specific() {
320        assert_eq!(
321            Environment::Cursor.rules_dir(),
322            PathBuf::from(".cursor/rules")
323        );
324    }
325
326    #[test]
327    fn rules_dir_generic() {
328        assert_eq!(
329            Environment::ClaudeCode.rules_dir(),
330            PathBuf::from(".agent/rules")
331        );
332        assert_eq!(
333            Environment::Generic.rules_dir(),
334            PathBuf::from(".agent/rules")
335        );
336    }
337
338    #[test]
339    fn runbooks_dir_universal() {
340        assert_eq!(
341            Environment::ClaudeCode.runbooks_dir(),
342            PathBuf::from(".agent/runbooks")
343        );
344        assert_eq!(
345            Environment::Cursor.runbooks_dir(),
346            PathBuf::from(".agent/runbooks")
347        );
348        assert_eq!(
349            Environment::Generic.runbooks_dir(),
350            PathBuf::from(".agent/runbooks")
351        );
352    }
353
354    #[test]
355    fn memories_dir_universal() {
356        assert_eq!(
357            Environment::ClaudeCode.memories_dir(),
358            PathBuf::from(".agent/memories")
359        );
360        assert_eq!(
361            Environment::Generic.memories_dir(),
362            PathBuf::from(".agent/memories")
363        );
364    }
365
366    #[test]
367    fn skills_dir_per_environment() {
368        assert_eq!(
369            Environment::ClaudeCode.skills_dir(),
370            PathBuf::from(".claude/skills")
371        );
372        assert_eq!(
373            Environment::OpenCode.skills_dir(),
374            PathBuf::from(".opencode/skills")
375        );
376        assert_eq!(
377            Environment::Cursor.skills_dir(),
378            PathBuf::from(".cursor/rules")
379        );
380        assert_eq!(
381            Environment::Generic.skills_dir(),
382            PathBuf::from(".agent/skills")
383        );
384    }
385}