use schemars::JsonSchema;
use serde::Serialize;
pub trait EnvSource {
fn get(&self, key: &str) -> Option<String>;
}
pub struct StdEnvSource;
impl EnvSource for StdEnvSource {
fn get(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub enum AgentName {
ClaudeCode,
Cursor,
Gemini,
Codex,
Cline,
Augment,
OpenCode,
Trae,
Goose,
Amp,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub enum DetectionSignal {
AiAgent,
Agent,
ToolSpecific,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct DetectedAgent {
pub name: AgentName,
pub signal: DetectionSignal,
pub raw_value: String,
}
pub fn detect_calling_agent(env: &dyn EnvSource) -> Option<DetectedAgent> {
if env.get("CI").is_some_and(|v| !v.is_empty()) {
return None;
}
if let Some(value) = env.get("AI_AGENT").filter(|v| !v.is_empty()) {
let name = parse_ai_agent_value(&value);
return Some(DetectedAgent {
name,
signal: DetectionSignal::AiAgent,
raw_value: value,
});
}
if let Some(value) = env.get("AGENT") {
let agent_name = match value.trim() {
"goose" => Some(AgentName::Goose),
"amp" => Some(AgentName::Amp),
_ => None,
};
if let Some(name) = agent_name {
return Some(DetectedAgent {
name,
signal: DetectionSignal::Agent,
raw_value: value,
});
}
}
let tool_specific: &[(&str, AgentName)] = &[
("CLAUDECODE", AgentName::ClaudeCode),
("CURSOR_AGENT", AgentName::Cursor),
("GEMINI_CLI", AgentName::Gemini),
("CODEX_SANDBOX", AgentName::Codex),
("CLINE_ACTIVE", AgentName::Cline),
("AUGMENT_AGENT", AgentName::Augment),
("OPENCODE_CLIENT", AgentName::OpenCode),
("TRAE_AI_SHELL_ID", AgentName::Trae),
];
for (var, name) in tool_specific {
if let Some(value) = env.get(var).filter(|v| !v.is_empty()) {
return Some(DetectedAgent {
name: name.clone(),
signal: DetectionSignal::ToolSpecific,
raw_value: value,
});
}
}
None
}
fn parse_ai_agent_value(value: &str) -> AgentName {
let tool_version = value.strip_suffix("_agent").unwrap_or(value);
let tool = tool_version
.split('_')
.take_while(|seg| !seg.starts_with(|c: char| c.is_ascii_digit()))
.collect::<Vec<_>>()
.join("-");
match tool.as_str() {
"claude-code" => AgentName::ClaudeCode,
"cursor" => AgentName::Cursor,
"gemini-cli" => AgentName::Gemini,
"codex" => AgentName::Codex,
"cline" => AgentName::Cline,
"augment" => AgentName::Augment,
"opencode" => AgentName::OpenCode,
"trae" => AgentName::Trae,
"goose" => AgentName::Goose,
"amp" => AgentName::Amp,
_ => AgentName::Unknown,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
struct StubEnv(HashMap<&'static str, &'static str>);
impl EnvSource for StubEnv {
fn get(&self, key: &str) -> Option<String> {
self.0.get(key).map(|v| v.to_string())
}
}
fn env(pairs: &[(&'static str, &'static str)]) -> StubEnv {
StubEnv(pairs.iter().copied().collect())
}
fn empty() -> StubEnv {
StubEnv(HashMap::new())
}
#[test]
fn it_detects_claude_code_via_claudecode_var() {
let result = detect_calling_agent(&env(&[("CLAUDECODE", "1")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::ClaudeCode,
signal: DetectionSignal::ToolSpecific,
raw_value: "1".to_string(),
})
);
}
#[test]
fn it_detects_cursor_via_cursor_agent_var() {
let result = detect_calling_agent(&env(&[("CURSOR_AGENT", "1")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::Cursor,
signal: DetectionSignal::ToolSpecific,
raw_value: "1".to_string(),
})
);
}
#[test]
fn it_detects_gemini_via_gemini_cli_var() {
let result = detect_calling_agent(&env(&[("GEMINI_CLI", "1")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::Gemini,
signal: DetectionSignal::ToolSpecific,
raw_value: "1".to_string(),
})
);
}
#[test]
fn it_detects_codex_via_codex_sandbox_var() {
let result = detect_calling_agent(&env(&[("CODEX_SANDBOX", "seatbelt")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::Codex,
signal: DetectionSignal::ToolSpecific,
raw_value: "seatbelt".to_string(),
})
);
}
#[test]
fn it_detects_cline_via_cline_active_var() {
let result = detect_calling_agent(&env(&[("CLINE_ACTIVE", "true")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::Cline,
signal: DetectionSignal::ToolSpecific,
raw_value: "true".to_string(),
})
);
}
#[test]
fn it_detects_augment_via_augment_agent_var() {
let result = detect_calling_agent(&env(&[("AUGMENT_AGENT", "1")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::Augment,
signal: DetectionSignal::ToolSpecific,
raw_value: "1".to_string(),
})
);
}
#[test]
fn it_detects_opencode_via_opencode_client_var() {
let result = detect_calling_agent(&env(&[("OPENCODE_CLIENT", "1")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::OpenCode,
signal: DetectionSignal::ToolSpecific,
raw_value: "1".to_string(),
})
);
}
#[test]
fn it_detects_trae_via_trae_ai_shell_id_var() {
let result = detect_calling_agent(&env(&[("TRAE_AI_SHELL_ID", "session-123")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::Trae,
signal: DetectionSignal::ToolSpecific,
raw_value: "session-123".to_string(),
})
);
}
#[test]
fn it_detects_goose_via_agent_var() {
let result = detect_calling_agent(&env(&[("AGENT", "goose")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::Goose,
signal: DetectionSignal::Agent,
raw_value: "goose".to_string(),
})
);
}
#[test]
fn it_detects_amp_via_agent_var() {
let result = detect_calling_agent(&env(&[("AGENT", "amp")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::Amp,
signal: DetectionSignal::Agent,
raw_value: "amp".to_string(),
})
);
}
#[test]
fn it_tolerates_whitespace_in_agent_var_value() {
let result = detect_calling_agent(&env(&[("AGENT", "goose ")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::Goose,
signal: DetectionSignal::Agent,
raw_value: "goose ".to_string(),
})
);
}
#[test]
fn it_ignores_agent_var_with_non_allowlisted_value() {
let result = detect_calling_agent(&env(&[("AGENT", "1")]));
assert_eq!(result, None);
}
#[test]
fn it_detects_claude_code_via_ai_agent_var() {
let result = detect_calling_agent(&env(&[("AI_AGENT", "claude-code_2-1-141_agent")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::ClaudeCode,
signal: DetectionSignal::AiAgent,
raw_value: "claude-code_2-1-141_agent".to_string(),
})
);
}
#[test]
fn it_prefers_ai_agent_over_tool_specific_markers() {
let result = detect_calling_agent(&env(&[
("AI_AGENT", "claude-code_2-1-141_agent"),
("CLAUDECODE", "1"),
]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::ClaudeCode,
signal: DetectionSignal::AiAgent,
raw_value: "claude-code_2-1-141_agent".to_string(),
})
);
}
#[test]
fn it_produces_unknown_agent_for_unrecognized_ai_agent_tool() {
let result = detect_calling_agent(&env(&[("AI_AGENT", "sometool_1-0_agent")]));
let detected = result.expect("should detect an agent");
assert_eq!(detected.signal, DetectionSignal::AiAgent);
assert!(matches!(detected.name, AgentName::Unknown));
}
#[test]
fn it_treats_empty_ci_var_as_not_set() {
let result = detect_calling_agent(&env(&[("CI", ""), ("CLAUDECODE", "1")]));
assert_eq!(
result,
Some(DetectedAgent {
name: AgentName::ClaudeCode,
signal: DetectionSignal::ToolSpecific,
raw_value: "1".to_string(),
})
);
}
#[test]
fn it_returns_none_when_ci_is_set_alongside_claudecode() {
let result = detect_calling_agent(&env(&[("CI", "true"), ("CLAUDECODE", "1")]));
assert_eq!(result, None);
}
#[test]
fn it_returns_none_when_ci_is_set_alongside_ai_agent() {
let result = detect_calling_agent(&env(&[
("CI", "true"),
("AI_AGENT", "claude-code_2-1-141_agent"),
]));
assert_eq!(result, None);
}
#[test]
fn it_returns_none_in_empty_environment() {
let result = detect_calling_agent(&empty());
assert_eq!(result, None);
}
#[test]
fn it_ignores_empty_claudecode_var() {
let result = detect_calling_agent(&env(&[("CLAUDECODE", "")]));
assert_eq!(result, None);
}
#[test]
fn it_ignores_empty_ai_agent_var() {
let result = detect_calling_agent(&env(&[("AI_AGENT", "")]));
assert_eq!(result, None);
}
#[test]
fn it_ignores_agent_var_with_uppercase_goose() {
let result = detect_calling_agent(&env(&[("AGENT", "GOOSE")]));
assert_eq!(result, None);
}
#[test]
fn it_detects_ai_agent_without_agent_suffix() {
let result = detect_calling_agent(&env(&[("AI_AGENT", "claude-code")]));
let detected = result.expect("should detect an agent");
assert_eq!(detected.name, AgentName::ClaudeCode);
assert_eq!(detected.signal, DetectionSignal::AiAgent);
}
#[test]
fn it_serializes_unknown_agent_name_as_a_json_string() {
let name = AgentName::Unknown;
let value = serde_json::to_value(&name).expect("serialize");
assert!(
value.is_string(),
"AgentName::Unknown must serialize as a JSON string, got: {value}"
);
assert_eq!(value.as_str(), Some("Unknown"));
}
#[test]
fn it_serializes_detected_agent_to_json_with_expected_fields() {
let detected = DetectedAgent {
name: AgentName::ClaudeCode,
signal: DetectionSignal::ToolSpecific,
raw_value: "1".to_string(),
};
let value = serde_json::to_value(&detected).expect("serialize");
assert_eq!(
value,
serde_json::json!({
"name": "ClaudeCode",
"signal": "ToolSpecific",
"raw_value": "1",
})
);
}
#[test]
fn it_serializes_unknown_agent_to_json_with_expected_fields() {
let detected = DetectedAgent {
name: AgentName::Unknown,
signal: DetectionSignal::AiAgent,
raw_value: "sometool_1-0_agent".to_string(),
};
let value = serde_json::to_value(&detected).expect("serialize");
assert_eq!(
value,
serde_json::json!({
"name": "Unknown",
"signal": "AiAgent",
"raw_value": "sometool_1-0_agent",
})
);
}
}