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
74impl Environment {
75    /// Parse from a string name (for --harness flag).
76    pub fn from_name(name: &str) -> Option<Self> {
77        match name.to_lowercase().as_str() {
78            "claude" | "claude-code" | "claudecode" => Some(Self::ClaudeCode),
79            "opencode" | "open-code" => Some(Self::OpenCode),
80            "codex" => Some(Self::Codex),
81            "cursor" => Some(Self::Cursor),
82            "generic" => Some(Self::Generic),
83            _ => None,
84        }
85    }
86}
87
88impl std::fmt::Display for Environment {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::ClaudeCode => write!(f, "Claude Code"),
92            Self::OpenCode => write!(f, "OpenCode"),
93            Self::Codex => write!(f, "Codex"),
94            Self::Cursor => write!(f, "Cursor"),
95            Self::Generic => write!(f, "Generic"),
96        }
97    }
98}
99
100/// Auto-detect the active agent environment.
101///
102/// Convenience wrapper around [`Environment::detect`].
103pub fn detect() -> Environment {
104    Environment::detect()
105}
106
107/// Internal detection logic, parameterized for testability.
108fn detect_from<F, V>(var: F) -> Environment
109where
110    F: Fn(&str) -> Option<V>,
111    V: AsRef<std::ffi::OsStr>,
112{
113    if var("CLAUDE_CODE").is_some() || var("CLAUDE_CODE_ENTRYPOINT").is_some() {
114        return Environment::ClaudeCode;
115    }
116    if var("OPENCODE").is_some() {
117        return Environment::OpenCode;
118    }
119    if var("CODEX_CLI").is_some() || var("CODEX").is_some() {
120        return Environment::Codex;
121    }
122    if var("CURSOR_SESSION_ID").is_some() || var("CURSOR").is_some() {
123        return Environment::Cursor;
124    }
125    Environment::Generic
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::collections::HashMap;
132    use std::ffi::OsString;
133
134    fn env_with(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
135        let map: HashMap<String, OsString> = pairs
136            .iter()
137            .map(|(k, v)| (k.to_string(), OsString::from(v)))
138            .collect();
139        move |key: &str| map.get(key).cloned()
140    }
141
142    #[test]
143    fn detects_claude_code_via_claude_code_var() {
144        let detect = detect_from(env_with(&[("CLAUDE_CODE", "1")]));
145        assert_eq!(detect, Environment::ClaudeCode);
146    }
147
148    #[test]
149    fn detects_claude_code_via_entrypoint() {
150        let detect = detect_from(env_with(&[("CLAUDE_CODE_ENTRYPOINT", "/usr/bin/claude")]));
151        assert_eq!(detect, Environment::ClaudeCode);
152    }
153
154    #[test]
155    fn detects_opencode() {
156        let detect = detect_from(env_with(&[("OPENCODE", "1")]));
157        assert_eq!(detect, Environment::OpenCode);
158    }
159
160    #[test]
161    fn detects_codex_cli() {
162        let detect = detect_from(env_with(&[("CODEX_CLI", "1")]));
163        assert_eq!(detect, Environment::Codex);
164    }
165
166    #[test]
167    fn detects_codex_var() {
168        let detect = detect_from(env_with(&[("CODEX", "1")]));
169        assert_eq!(detect, Environment::Codex);
170    }
171
172    #[test]
173    fn falls_back_to_generic() {
174        let detect = detect_from(env_with(&[]));
175        assert_eq!(detect, Environment::Generic);
176    }
177
178    #[test]
179    fn claude_code_takes_priority_over_others() {
180        let detect = detect_from(env_with(&[("CLAUDE_CODE", "1"), ("OPENCODE", "1")]));
181        assert_eq!(detect, Environment::ClaudeCode);
182    }
183
184    #[test]
185    fn skill_rel_path_format() {
186        let env = Environment::ClaudeCode;
187        assert_eq!(
188            env.skill_rel_path("agent-doc"),
189            PathBuf::from(".claude/skills/agent-doc/SKILL.md")
190        );
191    }
192
193    #[test]
194    fn skill_path_claude_with_root() {
195        let env = Environment::ClaudeCode;
196        let path = env.skill_path("my-tool", Some(Path::new("/project")));
197        assert_eq!(path, PathBuf::from("/project/.claude/skills/my-tool/SKILL.md"));
198    }
199
200    #[test]
201    fn skill_path_generic_without_root() {
202        let env = Environment::Generic;
203        let path = env.skill_path("my-tool", None);
204        assert_eq!(path, PathBuf::from("AGENTS.md"));
205    }
206
207    #[test]
208    fn skill_path_per_environment() {
209        assert_eq!(
210            Environment::ClaudeCode.skill_rel_path("tool"),
211            PathBuf::from(".claude/skills/tool/SKILL.md")
212        );
213        assert_eq!(
214            Environment::OpenCode.skill_rel_path("tool"),
215            PathBuf::from(".opencode/skills/tool/SKILL.md")
216        );
217        assert_eq!(
218            Environment::Codex.skill_rel_path("tool"),
219            PathBuf::from(".codex/AGENTS.md")
220        );
221        assert_eq!(
222            Environment::Cursor.skill_rel_path("tool"),
223            PathBuf::from(".cursor/rules/tool.md")
224        );
225        assert_eq!(
226            Environment::Generic.skill_rel_path("tool"),
227            PathBuf::from("AGENTS.md")
228        );
229    }
230
231    #[test]
232    fn from_name_parses_variants() {
233        assert_eq!(Environment::from_name("claude"), Some(Environment::ClaudeCode));
234        assert_eq!(Environment::from_name("claude-code"), Some(Environment::ClaudeCode));
235        assert_eq!(Environment::from_name("opencode"), Some(Environment::OpenCode));
236        assert_eq!(Environment::from_name("codex"), Some(Environment::Codex));
237        assert_eq!(Environment::from_name("cursor"), Some(Environment::Cursor));
238        assert_eq!(Environment::from_name("generic"), Some(Environment::Generic));
239        assert_eq!(Environment::from_name("unknown"), None);
240    }
241
242    #[test]
243    fn all_skill_rel_paths_returns_four() {
244        let paths = Environment::all_skill_rel_paths("tool");
245        assert_eq!(paths.len(), 4);
246    }
247
248    #[test]
249    fn display_variants() {
250        assert_eq!(Environment::ClaudeCode.to_string(), "Claude Code");
251        assert_eq!(Environment::OpenCode.to_string(), "OpenCode");
252        assert_eq!(Environment::Codex.to_string(), "Codex");
253        assert_eq!(Environment::Cursor.to_string(), "Cursor");
254        assert_eq!(Environment::Generic.to_string(), "Generic");
255    }
256}