1use std::env;
20use std::path::Path;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct Agent {
26 pub id: AgentId,
27 pub name: &'static str,
28 pub signal: Signal,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum AgentId {
36 ClaudeCode,
37 Cursor,
38 CursorCli,
39 GeminiCli,
40 Codex,
41 Augment,
42 Cline,
43 OpenCode,
44 Trae,
45 Goose,
46 Amp,
47 Devin,
48 Replit,
49 Antigravity,
50 GitHubCopilot,
51 Unknown,
52}
53
54impl AgentId {
55 pub fn as_str(&self) -> &'static str {
60 match self {
61 AgentId::ClaudeCode => "claude-code",
62 AgentId::Cursor => "cursor",
63 AgentId::CursorCli => "cursor-cli",
64 AgentId::GeminiCli => "gemini-cli",
65 AgentId::Codex => "codex",
66 AgentId::Augment => "augment",
67 AgentId::Cline => "cline",
68 AgentId::OpenCode => "opencode",
69 AgentId::Trae => "trae",
70 AgentId::Goose => "goose",
71 AgentId::Amp => "amp",
72 AgentId::Devin => "devin",
73 AgentId::Replit => "replit",
74 AgentId::Antigravity => "antigravity",
75 AgentId::GitHubCopilot => "github-copilot",
76 AgentId::Unknown => "unknown",
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum Signal {
85 EnvVar { name: &'static str, value: String },
86 File { path: &'static str },
87}
88
89const TOOL_VARS: &[(&str, AgentId, &str)] = &[
90 ("CLAUDECODE", AgentId::ClaudeCode, "Claude Code"),
91 ("CLAUDE_CODE", AgentId::ClaudeCode, "Claude Code"),
92 ("CURSOR_TRACE_ID", AgentId::Cursor, "Cursor"),
93 ("CURSOR_AGENT", AgentId::CursorCli, "Cursor CLI"),
94 ("GEMINI_CLI", AgentId::GeminiCli, "Gemini CLI"),
95 ("CODEX_SANDBOX", AgentId::Codex, "OpenAI Codex"),
96 ("CODEX_CI", AgentId::Codex, "OpenAI Codex"),
97 ("CODEX_THREAD_ID", AgentId::Codex, "OpenAI Codex"),
98 ("ANTIGRAVITY_AGENT", AgentId::Antigravity, "Antigravity"),
99 ("AUGMENT_AGENT", AgentId::Augment, "Augment"),
100 ("CLINE_ACTIVE", AgentId::Cline, "Cline"),
101 ("OPENCODE_CLIENT", AgentId::OpenCode, "OpenCode"),
102 ("TRAE_AI_SHELL_ID", AgentId::Trae, "TRAE AI"),
103 ("GOOSE_TERMINAL", AgentId::Goose, "Goose"),
104 ("REPL_ID", AgentId::Replit, "Replit"),
105 ("COPILOT_MODEL", AgentId::GitHubCopilot, "GitHub Copilot"),
106 ("COPILOT_ALLOW_ALL", AgentId::GitHubCopilot, "GitHub Copilot"),
107 ("COPILOT_GITHUB_TOKEN", AgentId::GitHubCopilot, "GitHub Copilot"),
108];
109
110const FILE_SIGNALS: &[(&str, AgentId, &str)] = &[("/opt/.devin", AgentId::Devin, "Devin")];
111
112pub fn is_ai_agent() -> bool {
114 detect().is_some()
115}
116
117pub fn detect() -> Option<Agent> {
119 detect_with(|name| env::var(name).ok(), |path| Path::new(path).exists())
120}
121
122pub fn detect_with<E, F>(env: E, file_exists: F) -> Option<Agent>
125where
126 E: Fn(&str) -> Option<String>,
127 F: Fn(&str) -> bool,
128{
129 if let Some(value) = nonempty(env("AGENT")) {
130 let (id, name) = classify_agent_value(&value);
131 return Some(Agent {
132 id,
133 name,
134 signal: Signal::EnvVar { name: "AGENT", value },
135 });
136 }
137
138 if let Some(value) = nonempty(env("CURSOR_EXTENSION_HOST_ROLE")) {
141 if value.trim() == "agent-exec" {
142 return Some(Agent {
143 id: AgentId::CursorCli,
144 name: "Cursor CLI",
145 signal: Signal::EnvVar {
146 name: "CURSOR_EXTENSION_HOST_ROLE",
147 value,
148 },
149 });
150 }
151 }
152
153 for &(var, id, name) in TOOL_VARS {
154 if let Some(value) = nonempty(env(var)) {
155 return Some(Agent {
156 id,
157 name,
158 signal: Signal::EnvVar { name: var, value },
159 });
160 }
161 }
162
163 for &(path, id, name) in FILE_SIGNALS {
164 if file_exists(path) {
165 return Some(Agent {
166 id,
167 name,
168 signal: Signal::File { path },
169 });
170 }
171 }
172
173 None
174}
175
176fn nonempty(v: Option<String>) -> Option<String> {
177 v.filter(|s| !s.is_empty())
178}
179
180fn classify_agent_value(value: &str) -> (AgentId, &'static str) {
181 match value.trim().to_ascii_lowercase().as_str() {
182 "goose" => (AgentId::Goose, "Goose"),
183 "amp" => (AgentId::Amp, "Amp"),
184 "claude" | "claude-code" | "claudecode" => (AgentId::ClaudeCode, "Claude Code"),
185 "cursor" => (AgentId::Cursor, "Cursor"),
186 "cursor-cli" => (AgentId::CursorCli, "Cursor CLI"),
187 "gemini" | "gemini-cli" => (AgentId::GeminiCli, "Gemini CLI"),
188 "codex" => (AgentId::Codex, "OpenAI Codex"),
189 "augment" | "augment-cli" => (AgentId::Augment, "Augment"),
190 "cline" => (AgentId::Cline, "Cline"),
191 "opencode" => (AgentId::OpenCode, "OpenCode"),
192 "trae" => (AgentId::Trae, "TRAE AI"),
193 "devin" => (AgentId::Devin, "Devin"),
194 "replit" => (AgentId::Replit, "Replit"),
195 "antigravity" => (AgentId::Antigravity, "Antigravity"),
196 "github-copilot" | "github-copilot-cli" => (AgentId::GitHubCopilot, "GitHub Copilot"),
197 _ => (AgentId::Unknown, "AI agent"),
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use std::collections::HashMap;
205
206 fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
207 let map: HashMap<String, String> = pairs
208 .iter()
209 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
210 .collect();
211 move |name| map.get(name).cloned()
212 }
213
214 #[test]
215 fn returns_none_when_nothing_set() {
216 let env = env_from(&[]);
217 assert!(detect_with(env, |_| false).is_none());
218 }
219
220 #[test]
221 fn agent_var_with_known_name_classifies() {
222 let env = env_from(&[("AGENT", "goose")]);
223 let agent = detect_with(env, |_| false).unwrap();
224 assert_eq!(agent.id, AgentId::Goose);
225 assert_eq!(
226 agent.signal,
227 Signal::EnvVar { name: "AGENT", value: "goose".to_string() }
228 );
229 }
230
231 #[test]
232 fn agent_var_normalizes_case_and_aliases() {
233 let env = env_from(&[("AGENT", "Claude-Code")]);
234 assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::ClaudeCode);
235 }
236
237 #[test]
238 fn agent_var_with_truthy_value_is_unknown() {
239 let env = env_from(&[("AGENT", "1")]);
240 let agent = detect_with(env, |_| false).unwrap();
241 assert_eq!(agent.id, AgentId::Unknown);
242 assert_eq!(agent.name, "AI agent");
243 }
244
245 #[test]
246 fn agent_var_takes_priority_over_tool_var() {
247 let env = env_from(&[("AGENT", "amp"), ("CLAUDECODE", "1")]);
248 assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Amp);
249 }
250
251 #[test]
252 fn tool_var_falls_back_when_agent_unset() {
253 let env = env_from(&[("CURSOR_AGENT", "1")]);
254 let agent = detect_with(env, |_| false).unwrap();
255 assert_eq!(agent.id, AgentId::CursorCli);
256 assert_eq!(
257 agent.signal,
258 Signal::EnvVar { name: "CURSOR_AGENT", value: "1".to_string() }
259 );
260 }
261
262 #[test]
263 fn empty_var_value_is_ignored() {
264 let env = env_from(&[("AGENT", ""), ("CLAUDECODE", "1")]);
265 assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::ClaudeCode);
266 }
267
268 #[test]
269 fn devin_marker_file_detected() {
270 let env = env_from(&[]);
271 let agent = detect_with(env, |p| p == "/opt/.devin").unwrap();
272 assert_eq!(agent.id, AgentId::Devin);
273 assert_eq!(agent.signal, Signal::File { path: "/opt/.devin" });
274 }
275
276 #[test]
277 fn env_vars_take_priority_over_files() {
278 let env = env_from(&[("CLAUDECODE", "1")]);
279 assert_eq!(
280 detect_with(env, |_| true).unwrap().id,
281 AgentId::ClaudeCode
282 );
283 }
284
285 #[test]
286 fn claude_code_alias_var_detected() {
287 let env = env_from(&[("CLAUDE_CODE", "1")]);
288 let agent = detect_with(env, |_| false).unwrap();
289 assert_eq!(agent.id, AgentId::ClaudeCode);
290 assert_eq!(
291 agent.signal,
292 Signal::EnvVar { name: "CLAUDE_CODE", value: "1".to_string() }
293 );
294 }
295
296 #[test]
297 fn cursor_editor_detected_via_trace_id() {
298 let env = env_from(&[("CURSOR_TRACE_ID", "abc123")]);
299 let agent = detect_with(env, |_| false).unwrap();
300 assert_eq!(agent.id, AgentId::Cursor);
301 }
302
303 #[test]
304 fn cursor_cli_detected_via_extension_host_role() {
305 let env = env_from(&[("CURSOR_EXTENSION_HOST_ROLE", "agent-exec")]);
306 let agent = detect_with(env, |_| false).unwrap();
307 assert_eq!(agent.id, AgentId::CursorCli);
308 }
309
310 #[test]
311 fn cursor_extension_host_role_other_value_ignored() {
312 let env = env_from(&[("CURSOR_EXTENSION_HOST_ROLE", "ui")]);
313 assert!(detect_with(env, |_| false).is_none());
314 }
315
316 #[test]
317 fn codex_alternate_signals_detected() {
318 for var in ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"] {
319 let env = env_from(&[(var, "1")]);
320 let agent = detect_with(env, |_| false).unwrap();
321 assert_eq!(agent.id, AgentId::Codex, "var={var}");
322 }
323 }
324
325 #[test]
326 fn antigravity_detected() {
327 let env = env_from(&[("ANTIGRAVITY_AGENT", "1")]);
328 assert_eq!(
329 detect_with(env, |_| false).unwrap().id,
330 AgentId::Antigravity
331 );
332 }
333
334 #[test]
335 fn replit_detected() {
336 let env = env_from(&[("REPL_ID", "x")]);
337 assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Replit);
338 }
339
340 #[test]
341 fn github_copilot_detected_via_each_var() {
342 for var in ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"] {
343 let env = env_from(&[(var, "1")]);
344 let agent = detect_with(env, |_| false).unwrap();
345 assert_eq!(agent.id, AgentId::GitHubCopilot, "var={var}");
346 }
347 }
348
349 #[test]
350 fn as_str_returns_url_safe_slug() {
351 assert_eq!(AgentId::ClaudeCode.as_str(), "claude-code");
352 assert_eq!(AgentId::CursorCli.as_str(), "cursor-cli");
353 assert_eq!(AgentId::GitHubCopilot.as_str(), "github-copilot");
354 assert_eq!(AgentId::Goose.as_str(), "goose");
355 assert_eq!(AgentId::Unknown.as_str(), "unknown");
356 }
357
358 #[test]
359 fn as_str_round_trips_through_agent_var() {
360 for id in [
361 AgentId::ClaudeCode,
362 AgentId::Cursor,
363 AgentId::CursorCli,
364 AgentId::GeminiCli,
365 AgentId::Codex,
366 AgentId::Augment,
367 AgentId::Cline,
368 AgentId::OpenCode,
369 AgentId::Trae,
370 AgentId::Goose,
371 AgentId::Amp,
372 AgentId::Devin,
373 AgentId::Replit,
374 AgentId::Antigravity,
375 AgentId::GitHubCopilot,
376 ] {
377 let slug = id.as_str();
378 let env = env_from(&[("AGENT", slug)]);
379 assert_eq!(
380 detect_with(env, |_| false).unwrap().id,
381 id,
382 "slug {slug} did not round-trip"
383 );
384 }
385 }
386
387 #[test]
388 fn agent_var_classifies_new_names() {
389 for (val, expected) in [
390 ("replit", AgentId::Replit),
391 ("antigravity", AgentId::Antigravity),
392 ("github-copilot", AgentId::GitHubCopilot),
393 ("github-copilot-cli", AgentId::GitHubCopilot),
394 ("cursor-cli", AgentId::CursorCli),
395 ("augment-cli", AgentId::Augment),
396 ] {
397 let env = env_from(&[("AGENT", val)]);
398 assert_eq!(detect_with(env, |_| false).unwrap().id, expected, "val={val}");
399 }
400 }
401}