Skip to main content

is_ai_agent/
lib.rs

1//! Detect whether the current process is being invoked by an AI coding agent,
2//! and identify which one.
3//!
4//! Detection order:
5//! 1. The proposed standard `AGENT` env var (see agentsmd/agents.md#136).
6//! 2. Tool-specific env vars (`CLAUDECODE`, `CURSOR_AGENT`, ...).
7//! 3. Filesystem signals (e.g. `/opt/.devin`).
8//!
9//! ```no_run
10//! if is_ai_agent::is_ai_agent() {
11//!     // emit structured output
12//! }
13//!
14//! if let Some(agent) = is_ai_agent::detect() {
15//!     eprintln!("running under {}", agent.name);
16//! }
17//! ```
18
19use std::env;
20use std::path::Path;
21
22/// A detected AI agent and the signal that revealed it.
23#[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/// Canonical identifier for a known agent, or `Unknown` when an agent is
32/// present but its specific identity can't be determined (e.g. `AGENT=1`).
33#[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    /// A stable, URL-safe lowercase slug for this agent.
56    ///
57    /// These slugs round-trip through the `AGENT` env var convention:
58    /// setting `AGENT=<slug>` will be classified back to the same `AgentId`.
59    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/// What signal triggered the detection.
82#[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
112/// Returns `true` if any AI agent signal is present.
113pub fn is_ai_agent() -> bool {
114    detect().is_some()
115}
116
117/// Detect the AI agent, if any.
118pub fn detect() -> Option<Agent> {
119    detect_with(|name| env::var(name).ok(), |path| Path::new(path).exists())
120}
121
122/// Detection with injectable lookups, useful for tests and for callers that
123/// want to consult a captured environment instead of the live process.
124pub 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    // Special-cased value match: Cursor's extension host signals an agent
139    // execution context only when the value equals "agent-exec".
140    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}