1use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Environment {
11 ClaudeCode,
13 OpenCode,
15 Codex,
17 Generic,
19}
20
21impl Environment {
22 pub fn detect() -> Self {
24 detect_from(|key| std::env::var_os(key))
25 }
26
27 pub fn skill_rel_path(&self, name: &str) -> PathBuf {
32 PathBuf::from(format!(".claude/skills/{name}/SKILL.md"))
35 }
36
37 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
60pub fn detect() -> Environment {
64 Environment::detect()
65}
66
67fn 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}