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    /// Unknown or generic environment.
18    Generic,
19}
20
21impl Environment {
22    /// Auto-detect the active agent environment from environment variables.
23    pub fn detect() -> Self {
24        detect_from(|key| std::env::var_os(key))
25    }
26
27    /// Return the skill file path pattern for this environment.
28    ///
29    /// Given a skill `name`, returns the relative path where the skill file
30    /// should be installed (e.g., `.claude/skills/{name}/SKILL.md`).
31    pub fn skill_rel_path(&self, name: &str) -> PathBuf {
32        // All environments currently use the Claude Code layout.
33        // Future: adapt per environment as conventions emerge.
34        PathBuf::from(format!(".claude/skills/{name}/SKILL.md"))
35    }
36
37    /// Resolve the skill file path under a given root directory.
38    ///
39    /// When `root` is `None`, the returned path is relative to CWD.
40    pub fn skill_path(&self, name: &str, root: Option<&Path>) -> PathBuf {
41        let rel = self.skill_rel_path(name);
42        match root {
43            Some(r) => r.join(rel),
44            None => rel,
45        }
46    }
47}
48
49impl std::fmt::Display for Environment {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::ClaudeCode => write!(f, "Claude Code"),
53            Self::OpenCode => write!(f, "OpenCode"),
54            Self::Codex => write!(f, "Codex"),
55            Self::Generic => write!(f, "Generic"),
56        }
57    }
58}
59
60/// Auto-detect the active agent environment.
61///
62/// Convenience wrapper around [`Environment::detect`].
63pub fn detect() -> Environment {
64    Environment::detect()
65}
66
67/// Internal detection logic, parameterized for testability.
68fn detect_from<F, V>(var: F) -> Environment
69where
70    F: Fn(&str) -> Option<V>,
71    V: AsRef<std::ffi::OsStr>,
72{
73    if var("CLAUDE_CODE").is_some() || var("CLAUDE_CODE_ENTRYPOINT").is_some() {
74        return Environment::ClaudeCode;
75    }
76    if var("OPENCODE").is_some() {
77        return Environment::OpenCode;
78    }
79    if var("CODEX_CLI").is_some() || var("CODEX").is_some() {
80        return Environment::Codex;
81    }
82    Environment::Generic
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::collections::HashMap;
89    use std::ffi::OsString;
90
91    fn env_with(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
92        let map: HashMap<String, OsString> = pairs
93            .iter()
94            .map(|(k, v)| (k.to_string(), OsString::from(v)))
95            .collect();
96        move |key: &str| map.get(key).cloned()
97    }
98
99    #[test]
100    fn detects_claude_code_via_claude_code_var() {
101        let detect = detect_from(env_with(&[("CLAUDE_CODE", "1")]));
102        assert_eq!(detect, Environment::ClaudeCode);
103    }
104
105    #[test]
106    fn detects_claude_code_via_entrypoint() {
107        let detect = detect_from(env_with(&[("CLAUDE_CODE_ENTRYPOINT", "/usr/bin/claude")]));
108        assert_eq!(detect, Environment::ClaudeCode);
109    }
110
111    #[test]
112    fn detects_opencode() {
113        let detect = detect_from(env_with(&[("OPENCODE", "1")]));
114        assert_eq!(detect, Environment::OpenCode);
115    }
116
117    #[test]
118    fn detects_codex_cli() {
119        let detect = detect_from(env_with(&[("CODEX_CLI", "1")]));
120        assert_eq!(detect, Environment::Codex);
121    }
122
123    #[test]
124    fn detects_codex_var() {
125        let detect = detect_from(env_with(&[("CODEX", "1")]));
126        assert_eq!(detect, Environment::Codex);
127    }
128
129    #[test]
130    fn falls_back_to_generic() {
131        let detect = detect_from(env_with(&[]));
132        assert_eq!(detect, Environment::Generic);
133    }
134
135    #[test]
136    fn claude_code_takes_priority_over_others() {
137        let detect = detect_from(env_with(&[("CLAUDE_CODE", "1"), ("OPENCODE", "1")]));
138        assert_eq!(detect, Environment::ClaudeCode);
139    }
140
141    #[test]
142    fn skill_rel_path_format() {
143        let env = Environment::ClaudeCode;
144        assert_eq!(
145            env.skill_rel_path("agent-doc"),
146            PathBuf::from(".claude/skills/agent-doc/SKILL.md")
147        );
148    }
149
150    #[test]
151    fn skill_path_with_root() {
152        let env = Environment::Generic;
153        let path = env.skill_path("my-tool", Some(Path::new("/project")));
154        assert_eq!(path, PathBuf::from("/project/.claude/skills/my-tool/SKILL.md"));
155    }
156
157    #[test]
158    fn skill_path_without_root() {
159        let env = Environment::Generic;
160        let path = env.skill_path("my-tool", None);
161        assert_eq!(path, PathBuf::from(".claude/skills/my-tool/SKILL.md"));
162    }
163
164    #[test]
165    fn display_variants() {
166        assert_eq!(Environment::ClaudeCode.to_string(), "Claude Code");
167        assert_eq!(Environment::OpenCode.to_string(), "OpenCode");
168        assert_eq!(Environment::Codex.to_string(), "Codex");
169        assert_eq!(Environment::Generic.to_string(), "Generic");
170    }
171}