Skip to main content

mise_agent_env/
lib.rs

1//! Detect active AI coding agents from environment variables.
2//!
3//! This crate is intentionally small and conservative. It detects agent-driven
4//! command execution, not merely an interactive terminal inside an editor that
5//! happens to offer AI features.
6
7#![forbid(unsafe_code)]
8
9use std::env;
10use std::ffi::{OsStr, OsString};
11use std::fmt;
12
13/// An AI coding agent known to the detector.
14#[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    /// An agent identified only through the vendor-neutral `AI_AGENT`
45    /// variable.
46    Other,
47}
48
49impl Agent {
50    /// A stable, lowercase identifier suitable for logs and serialized output.
51    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/// The result of agent detection.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[non_exhaustive]
94pub struct Detection {
95    pub agent: Agent,
96    /// The environment variable that established the detection.
97    ///
98    /// Its value is intentionally not retained because some agent markers can
99    /// contain session identifiers or credentials.
100    pub signal: &'static str,
101}
102
103/// Detect an agent in the current process environment.
104pub fn detect() -> Option<Detection> {
105    detect_with_env(|name| env::var_os(name))
106}
107
108/// Return whether the current process appears to be driven by an AI agent.
109pub fn is_agent() -> bool {
110    detect().is_some()
111}
112
113/// Detect an agent using a caller-supplied environment lookup.
114///
115/// This is useful for deterministic tests and for programs that already hold
116/// an environment snapshot.
117pub 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    // A named generic value is authoritative. A flag-like value proves an
129    // agent is present but lets a specific marker below identify it first.
130    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    // More-specific derivatives precede the agents whose compatibility
147    // variables they inherit.
148    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}