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 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 pub fn runbooks_dir(&self) -> PathBuf {
92 PathBuf::from(".agent/runbooks")
93 }
94
95 pub fn memories_dir(&self) -> PathBuf {
99 PathBuf::from(".agent/memories")
100 }
101
102 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 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
146pub fn detect() -> Environment {
150 Environment::detect()
151}
152
153fn 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}