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!(
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}