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,
Crush,
QwenCode,
IflowCli,
AmazonQCli,
RooCode,
Unknown,
}
impl AgentId {
pub fn as_str(&self) -> &'static str {
match self {
AgentId::ClaudeCode => "claude-code",
AgentId::Cursor => "cursor",
AgentId::CursorCli => "cursor-cli",
AgentId::GeminiCli => "gemini-cli",
AgentId::Codex => "codex",
AgentId::Augment => "augment",
AgentId::Cline => "cline",
AgentId::OpenCode => "opencode",
AgentId::Trae => "trae",
AgentId::Goose => "goose",
AgentId::Amp => "amp",
AgentId::Devin => "devin",
AgentId::Replit => "replit",
AgentId::Antigravity => "antigravity",
AgentId::GitHubCopilot => "github-copilot",
AgentId::Crush => "crush",
AgentId::QwenCode => "qwen-code",
AgentId::IflowCli => "iflow-cli",
AgentId::AmazonQCli => "amazon-q-cli",
AgentId::RooCode => "roo-code",
AgentId::Unknown => "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)] = &[
("AMP_CURRENT_THREAD_ID", AgentId::Amp, "Amp"),
("CLAUDECODE", AgentId::ClaudeCode, "Claude Code"),
("CLAUDE_CODE_ENTRYPOINT", AgentId::ClaudeCode, "Claude Code"),
("CLAUDE_CODE_SESSION_ID", AgentId::ClaudeCode, "Claude Code"),
("CLAUDE_CODE_EXECPATH", AgentId::ClaudeCode, "Claude Code"),
("CURSOR_AGENT", AgentId::Cursor, "Cursor"),
("CURSOR_SANDBOX", AgentId::CursorCli, "Cursor CLI"),
("QWEN_CODE", AgentId::QwenCode, "Qwen Code"),
("GEMINI_CLI", AgentId::GeminiCli, "Gemini CLI"),
("CODEX_THREAD_ID", AgentId::Codex, "OpenAI Codex"),
("CODEX_SANDBOX", AgentId::Codex, "OpenAI Codex"),
(
"CODEX_SANDBOX_NETWORK_DISABLED",
AgentId::Codex,
"OpenAI Codex",
),
("CODEX_CI", AgentId::Codex, "OpenAI Codex"),
("ANTIGRAVITY_AGENT", AgentId::Antigravity, "Antigravity"),
("AUGMENT_AGENT", AgentId::Augment, "Augment"),
("CLINE_ACTIVE", AgentId::Cline, "Cline"),
("ROO_ACTIVE", AgentId::RooCode, "Roo Code"),
("CRUSH", AgentId::Crush, "Crush"),
("IFLOW_CLI", AgentId::IflowCli, "iFlow CLI"),
("OPENCODE", AgentId::OpenCode, "OpenCode"),
("OPENCODE_PID", AgentId::OpenCode, "OpenCode"),
("OPENCODE_CLIENT", AgentId::OpenCode, "OpenCode"),
("TRAE_AI_SHELL_ID", AgentId::Trae, "TRAE AI"),
("GOOSE_TERMINAL", AgentId::Goose, "Goose"),
("REPL_ID", AgentId::Replit, "Replit"),
(
"COPILOT_AGENT_SESSION_ID",
AgentId::GitHubCopilot,
"GitHub Copilot",
),
("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,
{
let mut generic_fallback = None;
for var in ["AGENT", "AI_AGENT"] {
if let Some(value) = nonempty(env(var)) {
let (id, name) = classify_agent_value(agent_name_part(&value));
let agent = Agent {
id,
name,
signal: Signal::EnvVar { name: var, value },
};
if id != AgentId::Unknown {
return Some(agent);
}
if generic_fallback.is_none() {
generic_fallback = Some(agent);
}
}
}
if let Some(value) = nonempty(env("CURSOR_EXTENSION_HOST_ROLE"))
&& value.trim() == "agent-exec"
{
return Some(Agent {
id: AgentId::CursorCli,
name: "Cursor CLI",
signal: Signal::EnvVar {
name: "CURSOR_EXTENSION_HOST_ROLE",
value,
},
});
}
if let Some(value) = nonempty(env("CURSOR_TRACE_ID"))
&& env("PAGER").as_deref() == Some("head -n 10000 | cat")
{
return Some(Agent {
id: AgentId::Cursor,
name: "Cursor",
signal: Signal::EnvVar {
name: "CURSOR_TRACE_ID",
value,
},
});
}
if let Some(value) = nonempty(env("AWS_EXECUTION_ENV"))
&& value.contains("AmazonQ-For-CLI")
{
return Some(Agent {
id: AgentId::AmazonQCli,
name: "Amazon Q Developer CLI",
signal: Signal::EnvVar {
name: "AWS_EXECUTION_ENV",
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 },
});
}
}
generic_fallback
}
fn nonempty(v: Option<String>) -> Option<String> {
v.filter(|s| !s.is_empty())
}
fn agent_name_part(value: &str) -> &str {
let name = value.trim();
let name = name.split('@').next().unwrap_or(name);
name.split('_').next().unwrap_or(name)
}
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"),
"crush" => (AgentId::Crush, "Crush"),
"qwen" | "qwen-code" | "qwencode" => (AgentId::QwenCode, "Qwen Code"),
"iflow" | "iflow-cli" => (AgentId::IflowCli, "iFlow CLI"),
"amazonq" | "amazon-q" | "amazon-q-cli" => (AgentId::AmazonQCli, "Amazon Q Developer CLI"),
"roo" | "roo-code" | "roocode" => (AgentId::RooCode, "Roo Code"),
_ => (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::Cursor);
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_sibling_vars_detected() {
for var in [
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_EXECPATH",
] {
let env = env_from(&[(var, "x")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::ClaudeCode, "var={var}");
}
}
#[test]
fn ai_agent_var_with_known_name_classifies() {
let env = env_from(&[("AI_AGENT", "crush")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Crush);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "AI_AGENT",
value: "crush".to_string()
}
);
}
#[test]
fn ai_agent_var_strips_version_suffixes() {
let env = env_from(&[("AI_AGENT", "claude-code_2.1.0_cli")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::ClaudeCode);
let env = env_from(&[("AI_AGENT", "goose@1.2.3")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Goose);
}
#[test]
fn truthy_agent_var_defers_to_tool_vars() {
let env = env_from(&[("AGENT", "1"), ("OPENCODE", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::OpenCode);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "OPENCODE",
value: "1".to_string()
}
);
}
#[test]
fn truthy_agent_var_defers_to_file_signals() {
let env = env_from(&[("AGENT", "true")]);
let agent = detect_with(env, |p| p == "/opt/.devin").unwrap();
assert_eq!(agent.id, AgentId::Devin);
}
#[test]
fn truthy_ai_agent_var_is_unknown_fallback() {
let env = env_from(&[("AI_AGENT", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Unknown);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "AI_AGENT",
value: "1".to_string()
}
);
}
#[test]
fn amp_marker_outranks_claudecode_compat_var() {
let env = env_from(&[("AMP_CURRENT_THREAD_ID", "t-1"), ("CLAUDECODE", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::Amp);
}
#[test]
fn qwen_code_outranks_gemini_cli_var() {
let env = env_from(&[("QWEN_CODE", "1"), ("GEMINI_CLI", "1")]);
assert_eq!(detect_with(env, |_| false).unwrap().id, AgentId::QwenCode);
}
#[test]
fn new_tool_vars_detected() {
for (var, expected) in [
("CRUSH", AgentId::Crush),
("QWEN_CODE", AgentId::QwenCode),
("IFLOW_CLI", AgentId::IflowCli),
("ROO_ACTIVE", AgentId::RooCode),
("OPENCODE", AgentId::OpenCode),
("OPENCODE_PID", AgentId::OpenCode),
("CURSOR_SANDBOX", AgentId::CursorCli),
("CODEX_SANDBOX_NETWORK_DISABLED", AgentId::Codex),
("AMP_CURRENT_THREAD_ID", AgentId::Amp),
("COPILOT_AGENT_SESSION_ID", AgentId::GitHubCopilot),
] {
let env = env_from(&[(var, "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, expected, "var={var}");
}
}
#[test]
fn amazon_q_detected_via_aws_execution_env_substring() {
let env = env_from(&[("AWS_EXECUTION_ENV", "AmazonQ-For-CLI Version/1.16.0")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::AmazonQCli);
}
#[test]
fn aws_execution_env_without_amazon_q_ignored() {
let env = env_from(&[("AWS_EXECUTION_ENV", "AWS_Lambda_nodejs22.x")]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn cursor_trace_id_with_pager_override_detected() {
let env = env_from(&[
("CURSOR_TRACE_ID", "abc123"),
("PAGER", "head -n 10000 | cat"),
]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Cursor);
assert_eq!(
agent.signal,
Signal::EnvVar {
name: "CURSOR_TRACE_ID",
value: "abc123".to_string()
}
);
}
#[test]
fn cursor_trace_id_alone_is_interactive_not_agent() {
let env = env_from(&[("CURSOR_TRACE_ID", "abc123")]);
assert!(detect_with(env, |_| false).is_none());
}
#[test]
fn cursor_agent_var_maps_to_generic_cursor() {
let env = env_from(&[("CURSOR_AGENT", "1")]);
let agent = detect_with(env, |_| false).unwrap();
assert_eq!(agent.id, AgentId::Cursor);
assert_eq!(agent.name, "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 as_str_returns_url_safe_slug() {
assert_eq!(AgentId::ClaudeCode.as_str(), "claude-code");
assert_eq!(AgentId::CursorCli.as_str(), "cursor-cli");
assert_eq!(AgentId::GitHubCopilot.as_str(), "github-copilot");
assert_eq!(AgentId::Goose.as_str(), "goose");
assert_eq!(AgentId::Unknown.as_str(), "unknown");
}
#[test]
fn as_str_round_trips_through_agent_var() {
for id in [
AgentId::ClaudeCode,
AgentId::Cursor,
AgentId::CursorCli,
AgentId::GeminiCli,
AgentId::Codex,
AgentId::Augment,
AgentId::Cline,
AgentId::OpenCode,
AgentId::Trae,
AgentId::Goose,
AgentId::Amp,
AgentId::Devin,
AgentId::Replit,
AgentId::Antigravity,
AgentId::GitHubCopilot,
AgentId::Crush,
AgentId::QwenCode,
AgentId::IflowCli,
AgentId::AmazonQCli,
AgentId::RooCode,
] {
let slug = id.as_str();
let env = env_from(&[("AGENT", slug)]);
assert_eq!(
detect_with(env, |_| false).unwrap().id,
id,
"slug {slug} did not round-trip"
);
}
}
#[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),
("crush", AgentId::Crush),
("qwen", AgentId::QwenCode),
("iflow", AgentId::IflowCli),
("amazonq", AgentId::AmazonQCli),
("roo", AgentId::RooCode),
] {
let env = env_from(&[("AGENT", val)]);
assert_eq!(
detect_with(env, |_| false).unwrap().id,
expected,
"val={val}"
);
}
}
}