1#![forbid(unsafe_code)]
8
9use std::env;
10use std::ffi::{OsStr, OsString};
11use std::fmt;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum Agent {
17 Amp,
18 AmazonQ,
19 Antigravity,
20 Augment,
21 ClaudeCode,
22 ClaudeCowork,
23 Cline,
24 CodeBuddy,
25 Codex,
26 Crush,
27 Cursor,
28 Firebender,
29 GeminiCli,
30 GitHubCopilot,
31 Goose,
32 Grok,
33 IflowCli,
34 Kiro,
35 OpenCode,
36 OpenHands,
37 Pi,
38 QwenCode,
39 Replit,
40 RooCode,
41 Trae,
42 VeCli,
43 Warp,
44 Other,
47}
48
49impl Agent {
50 pub const fn as_str(self) -> &'static str {
52 match self {
53 Self::Amp => "amp",
54 Self::AmazonQ => "amazon-q",
55 Self::Antigravity => "antigravity",
56 Self::Augment => "augment",
57 Self::ClaudeCode => "claude-code",
58 Self::ClaudeCowork => "claude-cowork",
59 Self::Cline => "cline",
60 Self::CodeBuddy => "codebuddy",
61 Self::Codex => "codex",
62 Self::Crush => "crush",
63 Self::Cursor => "cursor",
64 Self::Firebender => "firebender",
65 Self::GeminiCli => "gemini-cli",
66 Self::GitHubCopilot => "github-copilot",
67 Self::Goose => "goose",
68 Self::Grok => "grok",
69 Self::IflowCli => "iflow-cli",
70 Self::Kiro => "kiro",
71 Self::OpenCode => "opencode",
72 Self::OpenHands => "openhands",
73 Self::Pi => "pi",
74 Self::QwenCode => "qwen-code",
75 Self::Replit => "replit",
76 Self::RooCode => "roo-code",
77 Self::Trae => "trae",
78 Self::VeCli => "vecli",
79 Self::Warp => "warp",
80 Self::Other => "other",
81 }
82 }
83}
84
85impl fmt::Display for Agent {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 f.write_str(self.as_str())
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[non_exhaustive]
94pub struct Detection {
95 pub agent: Agent,
96 pub signal: &'static str,
101}
102
103pub fn detect() -> Option<Detection> {
105 detect_with_env(|name| env::var_os(name))
106}
107
108pub fn is_agent() -> bool {
110 detect().is_some()
111}
112
113pub fn detect_with_env<F>(env: F) -> Option<Detection>
118where
119 F: Fn(&str) -> Option<OsString>,
120{
121 let value = |name| env(name).filter(|value| !value.is_empty());
122 let present = |name| value(name).is_some();
123 let enabled = |name| value(name).is_some_and(|value| !is_false(&value));
124 let equals = |name, expected| value(name).is_some_and(|value| value == OsStr::new(expected));
125 let contains =
126 |name, needle| value(name).is_some_and(|value| value.to_string_lossy().contains(needle));
127
128 let mut generic = None;
131 let signal = "AI_AGENT";
132 if let Some(raw) = value(signal)
133 && !is_false(&raw)
134 {
135 let mut agent = classify_generic(&raw);
136 if agent == Agent::ClaudeCode && enabled("CLAUDE_CODE_IS_COWORK") {
137 agent = Agent::ClaudeCowork;
138 }
139 let detection = Detection { agent, signal };
140 if agent != Agent::Other {
141 return Some(detection);
142 }
143 generic = Some(detection);
144 }
145
146 const SIGNALS: &[(&str, Agent)] = &[
149 ("AMP_CURRENT_THREAD_ID", Agent::Amp),
150 ("CODEBUDDY", Agent::CodeBuddy),
151 ("CODEBUDDY_SESSION_ID", Agent::CodeBuddy),
152 ("CODEBUDDY_PROJECT_DIR", Agent::CodeBuddy),
153 ("CLAUDE_CODE_IS_COWORK", Agent::ClaudeCowork),
154 ("CLAUDECODE", Agent::ClaudeCode),
155 ("CLAUDE_CODE", Agent::ClaudeCode),
156 ("CLAUDE_CODE_ENTRYPOINT", Agent::ClaudeCode),
157 ("CLAUDE_CODE_SESSION_ID", Agent::ClaudeCode),
158 ("CLAUDE_CODE_EXECPATH", Agent::ClaudeCode),
159 ("CURSOR_AGENT", Agent::Cursor),
160 ("CURSOR_SANDBOX", Agent::Cursor),
161 ("QWEN_CODE", Agent::QwenCode),
162 ("VECLI_SANDBOX", Agent::VeCli),
163 ("VECLI_DIR", Agent::VeCli),
164 ("GEMINI_CLI", Agent::GeminiCli),
165 ("CODEX_THREAD_ID", Agent::Codex),
166 ("CODEX_SANDBOX", Agent::Codex),
167 ("CODEX_CI", Agent::Codex),
168 ("CODEX_SANDBOX_NETWORK_DISABLED", Agent::Codex),
169 ("ANTIGRAVITY_AGENT", Agent::Antigravity),
170 ("AUGMENT_AGENT", Agent::Augment),
171 ("CLINE_ACTIVE", Agent::Cline),
172 ("CLINE_TASK_ID", Agent::Cline),
173 ("ROO_CODE_TASK_ID", Agent::RooCode),
174 ("CRUSH", Agent::Crush),
175 ("IFLOW_CLI", Agent::IflowCli),
176 ("GROK_AGENT", Agent::Grok),
177 ("OZ_RUN_ID", Agent::Warp),
178 ("PI_CODING_AGENT", Agent::Pi),
179 ("KIRO_AGENT_PATH", Agent::Kiro),
180 ("FIREBENDER_TERMINAL", Agent::Firebender),
181 ("OPENCODE", Agent::OpenCode),
182 ("OPENCODE_PID", Agent::OpenCode),
183 ("OPENCODE_BIN_PATH", Agent::OpenCode),
184 ("OPENCODE_SERVER", Agent::OpenCode),
185 ("OPENCODE_APP_INFO", Agent::OpenCode),
186 ("OPENCODE_MODES", Agent::OpenCode),
187 ("OPENCODE_CLIENT", Agent::OpenCode),
188 ("TRAE_AI_SHELL_ID", Agent::Trae),
189 ("GOOSE_TERMINAL", Agent::Goose),
190 ("COPILOT_AGENT_SESSION_ID", Agent::GitHubCopilot),
191 ("COPILOT_AGENT", Agent::GitHubCopilot),
192 ("COPILOT_CLI", Agent::GitHubCopilot),
193 ("COPILOT_AGENT_JOB_ID", Agent::GitHubCopilot),
194 ];
195 if let Some(&(signal, agent)) = SIGNALS.iter().find(|(signal, _)| enabled(signal)) {
196 return Some(Detection { agent, signal });
197 }
198
199 if equals("CURSOR_EXTENSION_HOST_ROLE", "agent-exec") {
200 return Some(Detection {
201 agent: Agent::Cursor,
202 signal: "CURSOR_EXTENSION_HOST_ROLE",
203 });
204 }
205 if present("CURSOR_TRACE_ID") && equals("PAGER", "head -n 10000 | cat") {
206 return Some(Detection {
207 agent: Agent::Cursor,
208 signal: "CURSOR_TRACE_ID",
209 });
210 }
211 if contains("AWS_EXECUTION_ENV", "AmazonQ-For-CLI") {
212 return Some(Detection {
213 agent: Agent::AmazonQ,
214 signal: "AWS_EXECUTION_ENV",
215 });
216 }
217 if present("AGENT_CONTEXT_OUT") && present("AGENT_DISPLAY_OUT") {
218 return Some(Detection {
219 agent: Agent::Kiro,
220 signal: "AGENT_CONTEXT_OUT",
221 });
222 }
223 if equals("REPLIT_MODE", "assistant") && present("REPL_ID") {
224 return Some(Detection {
225 agent: Agent::Replit,
226 signal: "REPLIT_MODE",
227 });
228 }
229 for signal in ["PS1", "PROMPT_COMMAND"] {
230 if contains(signal, "###PS1JSON###") {
231 return Some(Detection {
232 agent: Agent::OpenHands,
233 signal,
234 });
235 }
236 }
237
238 generic
239}
240
241fn is_false(value: &OsStr) -> bool {
242 matches!(
243 value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
244 "0" | "false" | "no" | "off"
245 )
246}
247
248fn classify_generic(value: &OsStr) -> Agent {
249 let value = value.to_string_lossy();
250 let normalized = value.trim().to_ascii_lowercase();
251 if normalized == "github_copilot_vscode_agent" {
252 return Agent::GitHubCopilot;
253 }
254 let name = normalized
255 .split('@')
256 .next()
257 .unwrap_or(&normalized)
258 .split('_')
259 .next()
260 .unwrap_or(&normalized);
261 match name {
262 "amp" => Agent::Amp,
263 "amazonq" | "amazon-q" | "amazon-q-cli" => Agent::AmazonQ,
264 "antigravity" => Agent::Antigravity,
265 "augment" | "augment-cli" => Agent::Augment,
266 "claude" | "claude-code" | "claudecode" => Agent::ClaudeCode,
267 "cowork" | "claude-cowork" => Agent::ClaudeCowork,
268 "cline" => Agent::Cline,
269 "codebuddy" => Agent::CodeBuddy,
270 "codex" | "codex-cli" => Agent::Codex,
271 "crush" => Agent::Crush,
272 "cursor" | "cursor-cli" => Agent::Cursor,
273 "firebender" => Agent::Firebender,
274 "gemini" | "gemini-cli" => Agent::GeminiCli,
275 "github-copilot" | "github-copilot-cli" => Agent::GitHubCopilot,
276 "goose" => Agent::Goose,
277 "grok" | "grok-cli" => Agent::Grok,
278 "iflow" | "iflow-cli" => Agent::IflowCli,
279 "kiro" | "kiro-cli" => Agent::Kiro,
280 "opencode" => Agent::OpenCode,
281 "openhands" => Agent::OpenHands,
282 "pi" => Agent::Pi,
283 "qwen" | "qwen-code" | "qwencode" => Agent::QwenCode,
284 "replit" => Agent::Replit,
285 "roo" | "roo-code" | "roocode" => Agent::RooCode,
286 "trae" => Agent::Trae,
287 "vecli" => Agent::VeCli,
288 "warp" | "oz" => Agent::Warp,
289 _ => Agent::Other,
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use std::collections::HashMap;
297
298 fn detect(values: &[(&str, &str)]) -> Option<Detection> {
299 let values = values
300 .iter()
301 .map(|(key, value)| ((*key).to_string(), OsString::from(value)))
302 .collect::<HashMap<_, _>>();
303 detect_with_env(|name| values.get(name).cloned())
304 }
305
306 #[test]
307 fn detects_representative_agents() {
308 for (signal, value, expected) in [
309 ("CLAUDECODE", "1", Agent::ClaudeCode),
310 ("CODEX_THREAD_ID", "thread-1", Agent::Codex),
311 ("GEMINI_CLI", "1", Agent::GeminiCli),
312 ("OPENCODE", "1", Agent::OpenCode),
313 (
314 "COPILOT_AGENT_SESSION_ID",
315 "session-1",
316 Agent::GitHubCopilot,
317 ),
318 ] {
319 assert_eq!(detect(&[(signal, value)]).unwrap().agent, expected);
320 }
321 }
322
323 #[test]
324 fn generic_name_is_authoritative() {
325 let detected = detect(&[("AI_AGENT", "codex@1.2.3"), ("CLAUDECODE", "1")]).unwrap();
326 assert_eq!(detected.agent, Agent::Codex);
327 assert_eq!(detected.signal, "AI_AGENT");
328
329 let detected = detect(&[("AI_AGENT", "github_copilot_vscode_agent")]).unwrap();
330 assert_eq!(detected.agent, Agent::GitHubCopilot);
331
332 let detected = detect(&[
333 ("AI_AGENT", "claude-code_2.1.0_cli"),
334 ("CLAUDE_CODE_IS_COWORK", "1"),
335 ])
336 .unwrap();
337 assert_eq!(detected.agent, Agent::ClaudeCowork);
338 }
339
340 #[test]
341 fn generic_flag_yields_to_a_specific_signal() {
342 let detected = detect(&[("AI_AGENT", "1"), ("OPENCODE", "1")]).unwrap();
343 assert_eq!(detected.agent, Agent::OpenCode);
344 assert_eq!(detected.signal, "OPENCODE");
345 }
346
347 #[test]
348 fn ignores_ambiguous_agent_variable() {
349 assert_eq!(detect(&[("AGENT", "1")]), None);
350 assert_eq!(detect(&[("AGENT", "build-runner")]), None);
351 }
352
353 #[test]
354 fn false_generic_values_are_ignored() {
355 for value in ["", "0", "false", "no", "off"] {
356 assert_eq!(detect(&[("AI_AGENT", value)]), None);
357 }
358 }
359
360 #[test]
361 fn false_provider_values_are_ignored() {
362 for value in ["0", "false", "no", "off"] {
363 assert_eq!(detect(&[("CLAUDECODE", value)]), None);
364 assert_eq!(detect(&[("CLAUDE_CODE_IS_COWORK", value)]), None);
365 assert_eq!(
366 detect(&[
367 ("AI_AGENT", "claude-code"),
368 ("CLAUDE_CODE_IS_COWORK", value)
369 ])
370 .unwrap()
371 .agent,
372 Agent::ClaudeCode
373 );
374 assert_eq!(detect(&[("OPENCODE", value)]), None);
375 }
376 }
377
378 #[test]
379 fn avoids_interactive_environment_false_positives() {
380 assert_eq!(detect(&[("CURSOR_TRACE_ID", "trace-1")]), None);
381 assert_eq!(detect(&[("REPL_ID", "workspace-1")]), None);
382 assert_eq!(detect(&[("TERM_PROGRAM", "WarpTerminal")]), None);
383 }
384
385 #[test]
386 fn detects_combined_agent_signals() {
387 assert_eq!(
388 detect(&[
389 ("CURSOR_TRACE_ID", "trace-1"),
390 ("PAGER", "head -n 10000 | cat")
391 ])
392 .unwrap()
393 .agent,
394 Agent::Cursor
395 );
396 assert_eq!(
397 detect(&[("REPL_ID", "workspace-1"), ("REPLIT_MODE", "assistant")])
398 .unwrap()
399 .agent,
400 Agent::Replit
401 );
402 assert_eq!(
403 detect(&[
404 ("AGENT_CONTEXT_OUT", "fifo-1"),
405 ("AGENT_DISPLAY_OUT", "fifo-2")
406 ])
407 .unwrap()
408 .agent,
409 Agent::Kiro
410 );
411 }
412
413 #[test]
414 fn signal_does_not_retain_its_value() {
415 let detection = detect(&[("CODEX_THREAD_ID", "sensitive-session-id")]).unwrap();
416 assert_eq!(
417 format!("{detection:?}"),
418 "Detection { agent: Codex, signal: \"CODEX_THREAD_ID\" }"
419 );
420 }
421}