use std::env;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Agent {
pub id: AgentId,
pub name: &'static str,
pub signal: Signal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum AgentId {
ClaudeCode,
Cursor,
CursorCli,
GeminiCli,
Codex,
Augment,
Cline,
OpenCode,
Trae,
Goose,
Amp,
Devin,
Replit,
Antigravity,
GitHubCopilot,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Signal {
EnvVar { name: &'static str, value: String },
File { path: &'static str },
}
const TOOL_VARS: &[(&str, AgentId, &str)] = &[
("CLAUDECODE", AgentId::ClaudeCode, "Claude Code"),
("CLAUDE_CODE", AgentId::ClaudeCode, "Claude Code"),
("CURSOR_TRACE_ID", AgentId::Cursor, "Cursor"),
("CURSOR_AGENT", AgentId::CursorCli, "Cursor CLI"),
("GEMINI_CLI", AgentId::GeminiCli, "Gemini CLI"),
("CODEX_SANDBOX", AgentId::Codex, "OpenAI Codex"),
("CODEX_CI", AgentId::Codex, "OpenAI Codex"),
("CODEX_THREAD_ID", AgentId::Codex, "OpenAI Codex"),
("ANTIGRAVITY_AGENT", AgentId::Antigravity, "Antigravity"),
("AUGMENT_AGENT", AgentId::Augment, "Augment"),
("CLINE_ACTIVE", AgentId::Cline, "Cline"),
("OPENCODE_CLIENT", AgentId::OpenCode, "OpenCode"),
("TRAE_AI_SHELL_ID", AgentId::Trae, "TRAE AI"),
("GOOSE_TERMINAL", AgentId::Goose, "Goose"),
("REPL_ID", AgentId::Replit, "Replit"),
("COPILOT_MODEL", AgentId::GitHubCopilot, "GitHub Copilot"),
("COPILOT_ALLOW_ALL", AgentId::GitHubCopilot, "GitHub Copilot"),
("COPILOT_GITHUB_TOKEN", AgentId::GitHubCopilot, "GitHub Copilot"),
];
const FILE_SIGNALS: &[(&str, AgentId, &str)] = &[("/opt/.devin", AgentId::Devin, "Devin")];
pub fn is_ai_agent() -> bool {
detect().is_some()
}
pub fn detect() -> Option<Agent> {
detect_with(|name| env::var(name).ok(), |path| Path::new(path).exists())
}
pub fn detect_with<E, F>(env: E, file_exists: F) -> Option<Agent>
where
E: Fn(&str) -> Option<String>,
F: Fn(&str) -> bool,
{
if let Some(value) = nonempty(env("AGENT")) {
let (id, name) = classify_agent_value(&value);
return Some(Agent {
id,
name,
signal: Signal::EnvVar { name: "AGENT", value },
});
}
if let Some(value) = nonempty(env("CURSOR_EXTENSION_HOST_ROLE")) {
if value.trim() == "agent-exec" {
return Some(Agent {
id: AgentId::CursorCli,
name: "Cursor CLI",
signal: Signal::EnvVar {
name: "CURSOR_EXTENSION_HOST_ROLE",
value,
},
});
}
}
for &(var, id, name) in TOOL_VARS {
if let Some(value) = nonempty(env(var)) {
return Some(Agent {
id,
name,
signal: Signal::EnvVar { name: var, value },
});
}
}
for &(path, id, name) in FILE_SIGNALS {
if file_exists(path) {
return Some(Agent {
id,
name,
signal: Signal::File { path },
});
}
}
None
}
fn nonempty(v: Option<String>) -> Option<String> {
v.filter(|s| !s.is_empty())
}
fn classify_agent_value(value: &str) -> (AgentId, &'static str) {
match value.trim().to_ascii_lowercase().as_str() {
"goose" => (AgentId::Goose, "Goose"),
"amp" => (AgentId::Amp, "Amp"),
"claude" | "claude-code" | "claudecode" => (AgentId::ClaudeCode, "Claude Code"),
"cursor" => (AgentId::Cursor, "Cursor"),
"cursor-cli" => (AgentId::CursorCli, "Cursor CLI"),
"gemini" | "gemini-cli" => (AgentId::GeminiCli, "Gemini CLI"),
"codex" => (AgentId::Codex, "OpenAI Codex"),
"augment" | "augment-cli" => (AgentId::Augment, "Augment"),
"cline" => (AgentId::Cline, "Cline"),
"opencode" => (AgentId::OpenCode, "OpenCode"),
"trae" => (AgentId::Trae, "TRAE AI"),
"devin" => (AgentId::Devin, "Devin"),
"replit" => (AgentId::Replit, "Replit"),
"antigravity" => (AgentId::Antigravity, "Antigravity"),
"github-copilot" | "github-copilot-cli" => (AgentId::GitHubCopilot, "GitHub Copilot"),
_ => (AgentId::Unknown, "AI agent"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |name| map.get(name).cloned()
}
#[test]
fn returns_none_when_nothing_set() {
let env = env_from(&[]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn agent_var_with_known_name_classifies() {
let env = env_from(&[("AGENT", "goose")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Goose);
assert_eq!(
agent.signal,
Signal::EnvVar { name: "AGENT", value: "goose".to_string() }
);
}
#[test]
fn agent_var_normalizes_case_and_aliases() {
let env = env_from(&[("AGENT", "Claude-Code")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::ClaudeCode);
}
#[test]
fn agent_var_with_truthy_value_is_unknown() {
let env = env_from(&[("AGENT", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Unknown);
assert_eq!(agent.name, "AI agent");
}
#[test]
fn agent_var_takes_priority_over_tool_var() {
let env = env_from(&[("AGENT", "amp"), ("CLAUDECODE", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Amp);
}
#[test]
fn tool_var_falls_back_when_agent_unset() {
let env = env_from(&[("CURSOR_AGENT", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::CursorCli);
assert_eq!(
agent.signal,
Signal::EnvVar { name: "CURSOR_AGENT", value: "1".to_string() }
);
}
#[test]
fn empty_var_value_is_ignored() {
let env = env_from(&[("AGENT", ""), ("CLAUDECODE", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::ClaudeCode);
}
#[test]
fn devin_marker_file_detected() {
let env = env_from(&[]);
let agent = detect_with(env, |p| p == "/opt/.devin").unwrap();
assert_eq!(agent.id, AgentId::Devin);
assert_eq!(agent.signal, Signal::File { path: "/opt/.devin" });
}
#[test]
fn env_vars_take_priority_over_files() {
let env = env_from(&[("CLAUDECODE", "1")]);
assert_eq!(
detect_with(env, |_| true).unwrap().id,
AgentId::ClaudeCode
);
}
#[test]
fn claude_code_alias_var_detected() {
let env = env_from(&[("CLAUDE_CODE", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::ClaudeCode);
assert_eq!(
agent.signal,
Signal::EnvVar { name: "CLAUDE_CODE", value: "1".to_string() }
);
}
#[test]
fn cursor_editor_detected_via_trace_id() {
let env = env_from(&[("CURSOR_TRACE_ID", "abc123")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Cursor);
}
#[test]
fn cursor_cli_detected_via_extension_host_role() {
let env = env_from(&[("CURSOR_EXTENSION_HOST_ROLE", "agent-exec")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::CursorCli);
}
#[test]
fn cursor_extension_host_role_other_value_ignored() {
let env = env_from(&[("CURSOR_EXTENSION_HOST_ROLE", "ui")]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn codex_alternate_signals_detected() {
for var in ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"] {
let env = env_from(&[(var, "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Codex, "var={var}");
}
}
#[test]
fn antigravity_detected() {
let env = env_from(&[("ANTIGRAVITY_AGENT", "1")]);
assert_eq!(
detect_with(env, |_| false).unwrap().id,
AgentId::Antigravity
);
}
#[test]
fn replit_detected() {
let env = env_from(&[("REPL_ID", "x")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Replit);
}
#[test]
fn github_copilot_detected_via_each_var() {
for var in ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"] {
let env = env_from(&[(var, "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::GitHubCopilot, "var={var}");
}
}
#[test]
fn agent_var_classifies_new_names() {
for (val, expected) in [
("replit", AgentId::Replit),
("antigravity", AgentId::Antigravity),
("github-copilot", AgentId::GitHubCopilot),
("github-copilot-cli", AgentId::GitHubCopilot),
("cursor-cli", AgentId::CursorCli),
("augment-cli", AgentId::Augment),
] {
let env = env_from(&[("AGENT", val)]);
assert_eq!(detect_with(env, |_| false).unwrap().id, expected, "val={val}");
}
}
}