1use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Environment {
11 ClaudeCode,
13 OpenCode,
15 Codex,
17 Cursor,
19 Generic,
21}
22
23impl Environment {
24 pub fn detect() -> Self {
26 detect_from(|key| std::env::var_os(key))
27 }
28
29 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 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 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 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
100pub fn detect() -> Environment {
104 Environment::detect()
105}
106
107fn 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}