use std::cell::RefCell;
use std::fmt;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
const CACHE_TTL: Duration = Duration::from_secs(300);
const WINDOWS_EXECUTABLE_SUFFIXES: &[&str] = &[".exe", ".cmd", ".bat", ".ps1"];
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Agent {
ClaudeCode,
AugmentCode,
Aider,
Continue,
CodexCli,
GeminiCli,
CopilotCli,
CursorIde,
Hermes,
Grok,
Custom(String),
Unknown,
}
impl Agent {
#[must_use]
pub fn config_key(&self) -> &str {
match self {
Self::ClaudeCode => "claude-code",
Self::AugmentCode => "augment-code",
Self::Aider => "aider",
Self::Continue => "continue",
Self::CodexCli => "codex-cli",
Self::GeminiCli => "gemini-cli",
Self::CopilotCli => "copilot-cli",
Self::CursorIde => "cursor-ide",
Self::Hermes => "hermes",
Self::Grok => "grok",
Self::Custom(name) => name,
Self::Unknown => "unknown",
}
}
#[must_use]
pub const fn is_known(&self) -> bool {
matches!(
self,
Self::ClaudeCode
| Self::AugmentCode
| Self::Aider
| Self::Continue
| Self::CodexCli
| Self::GeminiCli
| Self::CopilotCli
| Self::CursorIde
| Self::Hermes
| Self::Grok
)
}
#[must_use]
pub const fn is_explicit(&self) -> bool {
matches!(self, Self::Custom(_))
}
#[must_use]
pub fn from_name(name: &str) -> Self {
let normalized = name.to_lowercase().replace(['-', '_'], "");
match normalized.as_str() {
"claudecode" => Self::ClaudeCode,
"augmentcode" | "auggie" | "augment" => Self::AugmentCode,
"aider" => Self::Aider,
"continue" => Self::Continue,
"codexcli" | "codex" => Self::CodexCli,
"geminicli" | "gemini" => Self::GeminiCli,
"copilotcli" | "copilot" => Self::CopilotCli,
"cursoride" | "cursor" => Self::CursorIde,
"hermes" | "hermesagent" | "hermescli" => Self::Hermes,
"grok" | "grokcli" | "grokbuild" | "xai" | "xaigrok" => Self::Grok,
"unknown" => Self::Unknown,
_ => Self::Custom(name.to_string()),
}
}
}
impl fmt::Display for Agent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ClaudeCode => write!(f, "Claude Code"),
Self::AugmentCode => write!(f, "Augment Code"),
Self::Aider => write!(f, "Aider"),
Self::Continue => write!(f, "Continue"),
Self::CodexCli => write!(f, "Codex CLI"),
Self::GeminiCli => write!(f, "Gemini CLI"),
Self::CopilotCli => write!(f, "GitHub Copilot CLI"),
Self::CursorIde => write!(f, "Cursor IDE"),
Self::Hermes => write!(f, "Hermes Agent"),
Self::Grok => write!(f, "Grok (xAI)"),
Self::Custom(name) => write!(f, "{name}"),
Self::Unknown => write!(f, "Unknown"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectionResult {
pub agent: Agent,
pub method: DetectionMethod,
pub matched_value: Option<String>,
}
impl DetectionResult {
#[must_use]
pub const fn new(agent: Agent, method: DetectionMethod, matched_value: Option<String>) -> Self {
Self {
agent,
method,
matched_value,
}
}
#[must_use]
pub const fn unknown() -> Self {
Self {
agent: Agent::Unknown,
method: DetectionMethod::None,
matched_value: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetectionMethod {
Environment,
Explicit,
Process,
None,
}
impl fmt::Display for DetectionMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Environment => write!(f, "environment variable"),
Self::Explicit => write!(f, "explicit flag"),
Self::Process => write!(f, "parent process"),
Self::None => write!(f, "not detected"),
}
}
}
#[derive(Debug)]
struct CachedAgent {
result: DetectionResult,
cached_at: Instant,
}
impl CachedAgent {
fn is_valid(&self) -> bool {
self.cached_at.elapsed() < CACHE_TTL
}
}
thread_local! {
static AGENT_CACHE: RefCell<Option<CachedAgent>> = const { RefCell::new(None) };
}
#[must_use]
pub fn detect_agent() -> Agent {
detect_agent_with_details().agent
}
#[must_use]
pub fn detect_agent_with_details() -> DetectionResult {
let cached = AGENT_CACHE.with(|cache| {
let borrow = cache.borrow();
if let Some(ref entry) = *borrow {
if entry.is_valid() {
return Some(entry.result.clone());
}
}
None
});
if let Some(result) = cached {
return result;
}
let result = perform_detection();
AGENT_CACHE.with(|cache| {
*cache.borrow_mut() = Some(CachedAgent {
result: result.clone(),
cached_at: Instant::now(),
});
});
result
}
fn perform_detection() -> DetectionResult {
if let Some(agent_name) = explicit_agent_from_args(std::env::args()) {
return from_explicit(&agent_name);
}
if let Some(result) = detect_from_environment() {
return result;
}
if let Some(result) = detect_from_parent_process() {
return result;
}
DetectionResult::unknown()
}
fn explicit_agent_from_args<I, S>(args: I) -> Option<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut args = args.into_iter().skip(1);
while let Some(arg) = args.next() {
let arg = arg.as_ref();
if arg == "--" {
break;
}
if let Some(value) = arg.strip_prefix("--agent=") {
return non_empty_agent_name(value);
}
if arg == "--agent" {
return args
.next()
.and_then(|value| non_empty_agent_name(value.as_ref()));
}
}
None
}
fn non_empty_agent_name(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn detect_from_environment() -> Option<DetectionResult> {
if std::env::var("CLAUDE_CODE").is_ok() {
return Some(DetectionResult::new(
Agent::ClaudeCode,
DetectionMethod::Environment,
Some("CLAUDE_CODE".to_string()),
));
}
if std::env::var("CLAUDE_SESSION_ID").is_ok() {
return Some(DetectionResult::new(
Agent::ClaudeCode,
DetectionMethod::Environment,
Some("CLAUDE_SESSION_ID".to_string()),
));
}
if std::env::var("AUGMENT_AGENT").is_ok() {
return Some(DetectionResult::new(
Agent::AugmentCode,
DetectionMethod::Environment,
Some("AUGMENT_AGENT".to_string()),
));
}
if std::env::var("AUGMENT_CONVERSATION_ID").is_ok() {
return Some(DetectionResult::new(
Agent::AugmentCode,
DetectionMethod::Environment,
Some("AUGMENT_CONVERSATION_ID".to_string()),
));
}
if std::env::var("AIDER_SESSION").is_ok() {
return Some(DetectionResult::new(
Agent::Aider,
DetectionMethod::Environment,
Some("AIDER_SESSION".to_string()),
));
}
if std::env::var("CONTINUE_SESSION_ID").is_ok() {
return Some(DetectionResult::new(
Agent::Continue,
DetectionMethod::Environment,
Some("CONTINUE_SESSION_ID".to_string()),
));
}
if std::env::var("CODEX_CLI").is_ok() {
return Some(DetectionResult::new(
Agent::CodexCli,
DetectionMethod::Environment,
Some("CODEX_CLI".to_string()),
));
}
if std::env::var("GEMINI_CLI").is_ok() {
return Some(DetectionResult::new(
Agent::GeminiCli,
DetectionMethod::Environment,
Some("GEMINI_CLI".to_string()),
));
}
if std::env::var("COPILOT_CLI").is_ok() {
return Some(DetectionResult::new(
Agent::CopilotCli,
DetectionMethod::Environment,
Some("COPILOT_CLI".to_string()),
));
}
if std::env::var("COPILOT_AGENT_START_TIME_SEC").is_ok() {
return Some(DetectionResult::new(
Agent::CopilotCli,
DetectionMethod::Environment,
Some("COPILOT_AGENT_START_TIME_SEC".to_string()),
));
}
if std::env::var("CURSOR_IDE").is_ok() {
return Some(DetectionResult::new(
Agent::CursorIde,
DetectionMethod::Environment,
Some("CURSOR_IDE".to_string()),
));
}
if std::env::var("HERMES_AGENT").is_ok() {
return Some(DetectionResult::new(
Agent::Hermes,
DetectionMethod::Environment,
Some("HERMES_AGENT".to_string()),
));
}
if std::env::var("HERMES_SESSION_ID").is_ok() {
return Some(DetectionResult::new(
Agent::Hermes,
DetectionMethod::Environment,
Some("HERMES_SESSION_ID".to_string()),
));
}
if std::env::var("GROK_SESSION_ID").is_ok() {
return Some(DetectionResult::new(
Agent::Grok,
DetectionMethod::Environment,
Some("GROK_SESSION_ID".to_string()),
));
}
if std::env::var("GROK_HOOK_EVENT").is_ok() {
return Some(DetectionResult::new(
Agent::Grok,
DetectionMethod::Environment,
Some("GROK_HOOK_EVENT".to_string()),
));
}
if std::env::var("GROK_WORKSPACE_ROOT").is_ok() {
return Some(DetectionResult::new(
Agent::Grok,
DetectionMethod::Environment,
Some("GROK_WORKSPACE_ROOT".to_string()),
));
}
None
}
#[cfg(target_os = "linux")]
fn detect_from_parent_process() -> Option<DetectionResult> {
use std::fs;
use std::os::unix::process::parent_id;
let ppid = parent_id();
let comm_path = format!("/proc/{ppid}/comm");
if let Ok(process_name) = fs::read_to_string(&comm_path) {
if let Some(result) = detection_from_process_name(&process_name) {
return Some(result);
}
}
let cmdline_path = format!("/proc/{ppid}/cmdline");
let process_args = fs::read(&cmdline_path)
.ok()
.and_then(|bytes| nul_separated_args_to_string(&bytes))?;
detection_from_process_name(&process_args)
}
#[cfg(all(unix, not(target_os = "linux")))]
fn detect_from_parent_process() -> Option<DetectionResult> {
use std::os::unix::process::parent_id;
let ppid = parent_id();
parent_process_name_from_ps(ppid).and_then(|name| detection_from_process_name(&name))
}
#[cfg(all(unix, not(target_os = "linux")))]
fn parent_process_name_from_ps(pid: u32) -> Option<String> {
let pid = pid.to_string();
let output = std::process::Command::new("ps")
.args(["-p", &pid, "-o", "comm="])
.output()
.ok()?;
if output.status.success() {
if let Some(name) = first_non_empty_line(&String::from_utf8_lossy(&output.stdout)) {
if detection_from_process_name(name).is_some() {
return Some(name.to_string());
}
}
}
parent_process_args_from_ps(&pid)
}
#[cfg(all(unix, not(target_os = "linux")))]
fn parent_process_args_from_ps(pid: &str) -> Option<String> {
let output = std::process::Command::new("ps")
.args(["-p", pid, "-o", "args="])
.output()
.ok()?;
if !output.status.success() {
return None;
}
first_non_empty_line(&String::from_utf8_lossy(&output.stdout)).map(str::to_string)
}
#[cfg(windows)]
fn detect_from_parent_process() -> Option<DetectionResult> {
parent_process_name_from_windows(std::process::id())
.and_then(|name| detection_from_process_name(&name))
}
#[cfg(windows)]
fn parent_process_name_from_windows(current_pid: u32) -> Option<String> {
let script = format!(
"$p = Get-CimInstance Win32_Process -Filter 'ProcessId = {current_pid}'; \
if ($null -eq $p) {{ exit 1 }}; \
$parent = Get-CimInstance Win32_Process -Filter \"ProcessId = $($p.ParentProcessId)\"; \
if ($null -eq $parent) {{ exit 1 }}; \
Write-Output $parent.Name"
);
["powershell.exe", "powershell", "pwsh"]
.iter()
.find_map(|program| windows_parent_name_with(program, &script))
}
#[cfg(windows)]
fn windows_parent_name_with(program: &str, script: &str) -> Option<String> {
let output = std::process::Command::new(program)
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
.ok()?;
if !output.status.success() {
return None;
}
first_non_empty_line(&String::from_utf8_lossy(&output.stdout)).map(str::to_string)
}
#[cfg(not(any(target_os = "linux", unix, windows)))]
fn detect_from_parent_process() -> Option<DetectionResult> {
None
}
fn detection_from_process_name(raw_process_name: &str) -> Option<DetectionResult> {
let process_name = normalize_process_name(raw_process_name)?;
let agent = agent_from_process_name(&process_name)?;
Some(DetectionResult::new(
agent,
DetectionMethod::Process,
Some(process_name),
))
}
fn normalize_process_name(raw_process_name: &str) -> Option<String> {
first_non_empty_line(raw_process_name).map(str::to_lowercase)
}
fn first_non_empty_line(value: &str) -> Option<&str> {
value.lines().map(str::trim).find(|line| !line.is_empty())
}
fn nul_separated_args_to_string(bytes: &[u8]) -> Option<String> {
let mut args = Vec::new();
for arg in bytes.split(|byte| *byte == b'\0') {
if arg.is_empty() {
continue;
}
args.push(String::from_utf8_lossy(arg));
}
if args.is_empty() {
None
} else {
Some(args.join(" "))
}
}
fn agent_from_process_name(process_name: &str) -> Option<Agent> {
for (index, token) in process_name.split_whitespace().enumerate() {
if index > 0 && !is_path_like_process_token(token) {
continue;
}
if let Some(agent) = agent_for_basename(executable_basename(token).as_str()) {
return Some(agent);
}
}
None
}
fn is_path_like_process_token(token: &str) -> bool {
let token = token.trim_matches(['"', '\'']);
!token.starts_with('-')
&& !token.contains("://")
&& (token.contains('/') || token.contains('\\'))
}
fn executable_basename(token: &str) -> String {
let normalized = token
.trim_matches(['"', '\''])
.to_lowercase()
.replace('\\', "/");
let last = normalized.rsplit('/').next().unwrap_or(&normalized);
for suffix in WINDOWS_EXECUTABLE_SUFFIXES {
if let Some(stem) = last.strip_suffix(suffix) {
return stem.to_string();
}
}
last.to_string()
}
fn agent_for_basename(basename: &str) -> Option<Agent> {
match basename {
"claude" | "claude-code" | "claude_code" => Some(Agent::ClaudeCode),
"augment" | "augment-code" | "auggie" => Some(Agent::AugmentCode),
"aider" => Some(Agent::Aider),
"continue" | "continue-cli" => Some(Agent::Continue),
"codex" | "codex-cli" => Some(Agent::CodexCli),
"gemini" | "gemini-cli" => Some(Agent::GeminiCli),
"copilot" | "copilot-cli" | "gh-copilot" => Some(Agent::CopilotCli),
"cursor" | "cursor-ide" => Some(Agent::CursorIde),
"hermes" | "hermes-agent" | "hermes-cli" => Some(Agent::Hermes),
"grok" | "grok-cli" | "grok-build" => Some(Agent::Grok),
_ => None,
}
}
#[must_use]
pub fn from_explicit(name: &str) -> DetectionResult {
DetectionResult::new(
Agent::from_name(name),
DetectionMethod::Explicit,
Some(name.to_string()),
)
}
pub fn clear_cache() {
AGENT_CACHE.with(|cache| {
*cache.borrow_mut() = None;
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_config_keys() {
assert_eq!(Agent::ClaudeCode.config_key(), "claude-code");
assert_eq!(Agent::AugmentCode.config_key(), "augment-code");
assert_eq!(Agent::Aider.config_key(), "aider");
assert_eq!(Agent::Continue.config_key(), "continue");
assert_eq!(Agent::CodexCli.config_key(), "codex-cli");
assert_eq!(Agent::GeminiCli.config_key(), "gemini-cli");
assert_eq!(Agent::CopilotCli.config_key(), "copilot-cli");
assert_eq!(Agent::CursorIde.config_key(), "cursor-ide");
assert_eq!(Agent::Hermes.config_key(), "hermes");
assert_eq!(Agent::Grok.config_key(), "grok");
assert_eq!(Agent::Unknown.config_key(), "unknown");
assert_eq!(
Agent::Custom("my-agent".to_string()).config_key(),
"my-agent"
);
}
#[test]
fn test_agent_from_name() {
assert_eq!(Agent::from_name("claude-code"), Agent::ClaudeCode);
assert_eq!(Agent::from_name("augment-code"), Agent::AugmentCode);
assert_eq!(Agent::from_name("aider"), Agent::Aider);
assert_eq!(Agent::from_name("continue"), Agent::Continue);
assert_eq!(Agent::from_name("codex-cli"), Agent::CodexCli);
assert_eq!(Agent::from_name("gemini-cli"), Agent::GeminiCli);
assert_eq!(Agent::from_name("unknown"), Agent::Unknown);
assert_eq!(Agent::from_name("Claude-Code"), Agent::ClaudeCode);
assert_eq!(Agent::from_name("CLAUDE_CODE"), Agent::ClaudeCode);
assert_eq!(Agent::from_name("claudecode"), Agent::ClaudeCode);
assert_eq!(Agent::from_name("augmentcode"), Agent::AugmentCode);
assert_eq!(Agent::from_name("auggie"), Agent::AugmentCode);
assert_eq!(Agent::from_name("augment"), Agent::AugmentCode);
assert_eq!(Agent::from_name("codex"), Agent::CodexCli);
assert_eq!(Agent::from_name("gemini"), Agent::GeminiCli);
assert_eq!(Agent::from_name("copilot"), Agent::CopilotCli);
assert_eq!(Agent::from_name("copilotcli"), Agent::CopilotCli);
assert_eq!(Agent::from_name("copilot-cli"), Agent::CopilotCli);
assert_eq!(Agent::from_name("cursor"), Agent::CursorIde);
assert_eq!(Agent::from_name("cursor-ide"), Agent::CursorIde);
assert_eq!(Agent::from_name("cursor_ide"), Agent::CursorIde);
assert_eq!(Agent::from_name("hermes"), Agent::Hermes);
assert_eq!(Agent::from_name("Hermes"), Agent::Hermes);
assert_eq!(Agent::from_name("hermes-agent"), Agent::Hermes);
assert_eq!(Agent::from_name("hermes_cli"), Agent::Hermes);
assert_eq!(Agent::from_name("grok"), Agent::Grok);
assert_eq!(Agent::from_name("Grok"), Agent::Grok);
assert_eq!(Agent::from_name("grok-cli"), Agent::Grok);
assert_eq!(Agent::from_name("grok_build"), Agent::Grok);
assert_eq!(Agent::from_name("xai"), Agent::Grok);
assert_eq!(Agent::from_name("xai-grok"), Agent::Grok);
assert_eq!(
Agent::from_name("my-custom-agent"),
Agent::Custom("my-custom-agent".to_string())
);
}
#[test]
fn test_agent_display() {
assert_eq!(format!("{}", Agent::ClaudeCode), "Claude Code");
assert_eq!(format!("{}", Agent::AugmentCode), "Augment Code");
assert_eq!(format!("{}", Agent::Aider), "Aider");
assert_eq!(format!("{}", Agent::Continue), "Continue");
assert_eq!(format!("{}", Agent::CodexCli), "Codex CLI");
assert_eq!(format!("{}", Agent::GeminiCli), "Gemini CLI");
assert_eq!(format!("{}", Agent::CopilotCli), "GitHub Copilot CLI");
assert_eq!(format!("{}", Agent::CursorIde), "Cursor IDE");
assert_eq!(format!("{}", Agent::Hermes), "Hermes Agent");
assert_eq!(format!("{}", Agent::Grok), "Grok (xAI)");
assert_eq!(format!("{}", Agent::Unknown), "Unknown");
assert_eq!(
format!("{}", Agent::Custom("MyAgent".to_string())),
"MyAgent"
);
}
#[test]
fn test_agent_is_known() {
assert!(Agent::ClaudeCode.is_known());
assert!(Agent::AugmentCode.is_known());
assert!(Agent::Aider.is_known());
assert!(Agent::CopilotCli.is_known());
assert!(Agent::CursorIde.is_known());
assert!(Agent::Hermes.is_known());
assert!(Agent::Grok.is_known());
assert!(!Agent::Unknown.is_known());
assert!(!Agent::Custom("x".to_string()).is_known());
}
#[test]
fn test_detection_method_display() {
assert_eq!(
format!("{}", DetectionMethod::Environment),
"environment variable"
);
assert_eq!(format!("{}", DetectionMethod::Explicit), "explicit flag");
assert_eq!(format!("{}", DetectionMethod::Process), "parent process");
assert_eq!(format!("{}", DetectionMethod::None), "not detected");
}
#[test]
fn test_agent_from_process_name_recognizes_known_agents() {
assert_eq!(agent_from_process_name("claude"), Some(Agent::ClaudeCode));
assert_eq!(
agent_from_process_name("/Applications/Claude.app/Contents/MacOS/Claude"),
Some(Agent::ClaudeCode)
);
assert_eq!(agent_from_process_name("auggie"), Some(Agent::AugmentCode));
assert_eq!(
agent_from_process_name("augment-code"),
Some(Agent::AugmentCode)
);
assert_eq!(agent_from_process_name("aider"), Some(Agent::Aider));
assert_eq!(agent_from_process_name("continue"), Some(Agent::Continue));
assert_eq!(agent_from_process_name("codex"), Some(Agent::CodexCli));
assert_eq!(
agent_from_process_name("node /usr/local/bin/codex"),
Some(Agent::CodexCli)
);
assert_eq!(agent_from_process_name("gemini"), Some(Agent::GeminiCli));
assert_eq!(
agent_from_process_name("copilot.exe"),
Some(Agent::CopilotCli)
);
assert_eq!(
agent_from_process_name(r"C:\Users\dev\AppData\Local\Programs\Cursor\Cursor.exe"),
Some(Agent::CursorIde)
);
assert_eq!(
agent_from_process_name(r"C:\Users\dev\AppData\Roaming\npm\codex.cmd"),
Some(Agent::CodexCli)
);
assert_eq!(
agent_from_process_name("gemini.ps1"),
Some(Agent::GeminiCli)
);
assert_eq!(agent_from_process_name("hermes"), Some(Agent::Hermes));
assert_eq!(agent_from_process_name("hermes-agent"), Some(Agent::Hermes));
assert_eq!(
agent_from_process_name("/usr/local/bin/hermes"),
Some(Agent::Hermes)
);
assert_eq!(agent_from_process_name("grok"), Some(Agent::Grok));
assert_eq!(agent_from_process_name("grok-cli"), Some(Agent::Grok));
assert_eq!(agent_from_process_name("grok-build"), Some(Agent::Grok));
assert_eq!(
agent_from_process_name("/home/user/.local/bin/grok"),
Some(Agent::Grok)
);
}
#[test]
fn test_agent_from_process_name_ignores_unknown_processes() {
assert_eq!(agent_from_process_name("zsh"), None);
assert_eq!(agent_from_process_name("cargo test agent"), None);
assert_eq!(agent_from_process_name("cargo test codex"), None);
assert_eq!(agent_from_process_name("node"), None);
assert_eq!(agent_from_process_name("curl https://codex.com"), None);
assert_eq!(
agent_from_process_name("curl https://example.com/codex"),
None
);
assert_eq!(agent_from_process_name("git commit -m codex"), None);
}
#[test]
fn test_agent_from_process_name_rejects_substring_false_positives() {
assert_eq!(agent_from_process_name("claude-explorer"), None);
assert_eq!(agent_from_process_name("myclaude"), None);
assert_eq!(agent_from_process_name("anti-claude"), None);
assert_eq!(agent_from_process_name("aider-helper"), None);
assert_eq!(agent_from_process_name("myproject-continue"), None);
assert_eq!(agent_from_process_name("continue-on-error"), None);
assert_eq!(agent_from_process_name("cursor-ext"), None);
assert_eq!(agent_from_process_name("xcursor"), None);
assert_eq!(agent_from_process_name("codex-runner"), None);
assert_eq!(agent_from_process_name("gemini-tools"), None);
assert_eq!(agent_from_process_name("copilot-stub"), None);
assert_eq!(agent_from_process_name("augment-helpers"), None);
assert_eq!(agent_from_process_name("hermes-helper"), None);
assert_eq!(agent_from_process_name("xhermes"), None);
assert_eq!(agent_from_process_name("anti-hermes"), None);
assert_eq!(agent_from_process_name("grok-helper"), None);
assert_eq!(agent_from_process_name("xgrok"), None);
assert_eq!(agent_from_process_name("anti-grok"), None);
assert_eq!(agent_from_process_name("grokking"), None);
}
#[test]
fn test_agent_from_process_name_accepts_known_aliases() {
assert_eq!(
agent_from_process_name("claude_code"),
Some(Agent::ClaudeCode)
);
assert_eq!(
agent_from_process_name("claude-code"),
Some(Agent::ClaudeCode)
);
assert_eq!(agent_from_process_name("codex-cli"), Some(Agent::CodexCli));
assert_eq!(
agent_from_process_name("gemini-cli"),
Some(Agent::GeminiCli)
);
assert_eq!(
agent_from_process_name("gh-copilot"),
Some(Agent::CopilotCli)
);
assert_eq!(
agent_from_process_name("cursor-ide"),
Some(Agent::CursorIde)
);
assert_eq!(
agent_from_process_name("continue-cli"),
Some(Agent::Continue)
);
}
#[test]
fn test_agent_from_process_name_each_argv_token_checked() {
assert_eq!(
agent_from_process_name("node /usr/local/bin/codex --foo"),
Some(Agent::CodexCli)
);
assert_eq!(
agent_from_process_name("node ./bin/gemini --foo"),
Some(Agent::GeminiCli)
);
assert_eq!(
agent_from_process_name(r#"node "C:\Users\dev\AppData\Roaming\npm\codex.cmd""#),
Some(Agent::CodexCli)
);
assert_eq!(
agent_from_process_name("/opt/codex /opt/cursor"),
Some(Agent::CodexCli)
);
}
#[test]
fn test_detection_from_process_name_normalizes_matches() {
let result = detection_from_process_name("\n Codex.exe \n").unwrap();
assert_eq!(result.agent, Agent::CodexCli);
assert_eq!(result.method, DetectionMethod::Process);
assert_eq!(result.matched_value, Some("codex.exe".to_string()));
}
#[test]
fn test_detection_from_process_name_rejects_empty_output() {
assert_eq!(detection_from_process_name("\n\n "), None);
}
#[test]
fn test_nul_separated_args_to_string_preserves_wrapped_agent_argv() {
let args = nul_separated_args_to_string(b"node\0/usr/local/bin/codex\0--foo\0")
.expect("argv bytes should decode");
assert_eq!(args, "node /usr/local/bin/codex --foo");
assert_eq!(agent_from_process_name(&args), Some(Agent::CodexCli));
}
#[test]
fn test_nul_separated_args_to_string_rejects_empty_argv() {
assert_eq!(nul_separated_args_to_string(b"\0\0"), None);
}
#[test]
fn test_from_explicit() {
let result = from_explicit("claude-code");
assert_eq!(result.agent, Agent::ClaudeCode);
assert_eq!(result.method, DetectionMethod::Explicit);
assert_eq!(result.matched_value, Some("claude-code".to_string()));
}
#[test]
fn test_explicit_agent_from_args_accepts_separate_value() {
let agent = explicit_agent_from_args(["dcg", "--agent", "custom-agent", "--version"]);
assert_eq!(agent, Some("custom-agent".to_string()));
}
#[test]
fn test_explicit_agent_from_args_accepts_equals_value() {
let agent = explicit_agent_from_args(["dcg", "--agent=codex-cli", "test"]);
assert_eq!(agent, Some("codex-cli".to_string()));
}
#[test]
fn test_explicit_agent_from_args_ignores_blank_value() {
let agent = explicit_agent_from_args(["dcg", "--agent", " "]);
assert_eq!(agent, None);
}
#[test]
fn test_explicit_agent_from_args_stops_at_double_dash() {
let agent = explicit_agent_from_args(["dcg", "test", "--", "--agent=payload-agent"]);
assert_eq!(agent, None);
}
#[test]
fn test_cache_clear() {
clear_cache();
let _ = detect_agent();
clear_cache();
}
}
#[cfg(test)]
mod env_tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const AGENT_ENV_VARS: &[&str] = &[
"CLAUDE_CODE",
"CLAUDE_SESSION_ID",
"AUGMENT_AGENT",
"AUGMENT_CONVERSATION_ID",
"AIDER_SESSION",
"CONTINUE_SESSION_ID",
"CODEX_CLI",
"GEMINI_CLI",
"COPILOT_CLI",
"COPILOT_AGENT_START_TIME_SEC",
"CURSOR_IDE",
"HERMES_AGENT",
"HERMES_SESSION_ID",
"GROK_SESSION_ID",
"GROK_HOOK_EVENT",
"GROK_WORKSPACE_ROOT",
];
fn with_env_var<F, R>(key: &str, value: &str, f: F) -> R
where
F: FnOnce() -> R,
{
let _lock = ENV_LOCK.lock().unwrap();
clear_cache();
let saved: Vec<_> = AGENT_ENV_VARS
.iter()
.map(|&k| (k, std::env::var(k).ok()))
.collect();
unsafe {
for &k in AGENT_ENV_VARS {
std::env::remove_var(k);
}
std::env::set_var(key, value);
}
let result = f();
unsafe {
std::env::remove_var(key);
for (k, v) in saved {
if let Some(val) = v {
std::env::set_var(k, val);
}
}
}
clear_cache();
result
}
#[test]
fn test_detect_claude_code_env() {
with_env_var("CLAUDE_CODE", "1", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::ClaudeCode);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(result.matched_value, Some("CLAUDE_CODE".to_string()));
});
}
#[test]
fn test_detect_claude_session_id_env() {
with_env_var("CLAUDE_SESSION_ID", "abc123", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::ClaudeCode);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(result.matched_value, Some("CLAUDE_SESSION_ID".to_string()));
});
}
#[test]
fn test_detect_augment_agent_env() {
with_env_var("AUGMENT_AGENT", "1", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::AugmentCode);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(result.matched_value, Some("AUGMENT_AGENT".to_string()));
});
}
#[test]
fn test_detect_augment_conversation_id_env() {
with_env_var("AUGMENT_CONVERSATION_ID", "conv123", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::AugmentCode);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(
result.matched_value,
Some("AUGMENT_CONVERSATION_ID".to_string())
);
});
}
#[test]
fn test_detect_aider_env() {
with_env_var("AIDER_SESSION", "1", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::Aider);
assert_eq!(result.method, DetectionMethod::Environment);
});
}
#[test]
fn test_detect_continue_env() {
with_env_var("CONTINUE_SESSION_ID", "session123", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::Continue);
assert_eq!(result.method, DetectionMethod::Environment);
});
}
#[test]
fn test_detect_codex_cli_env() {
with_env_var("CODEX_CLI", "1", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::CodexCli);
assert_eq!(result.method, DetectionMethod::Environment);
});
}
#[test]
fn test_detect_gemini_cli_env() {
with_env_var("GEMINI_CLI", "1", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::GeminiCli);
assert_eq!(result.method, DetectionMethod::Environment);
});
}
#[test]
fn test_detect_copilot_cli_env() {
with_env_var("COPILOT_CLI", "1", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::CopilotCli);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(result.matched_value, Some("COPILOT_CLI".to_string()));
});
}
#[test]
fn test_detect_copilot_agent_start_time_env() {
with_env_var("COPILOT_AGENT_START_TIME_SEC", "1709573241", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::CopilotCli);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(
result.matched_value,
Some("COPILOT_AGENT_START_TIME_SEC".to_string())
);
});
}
#[test]
fn test_detect_cursor_ide_env() {
with_env_var("CURSOR_IDE", "1", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::CursorIde);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(result.matched_value, Some("CURSOR_IDE".to_string()));
});
}
#[test]
fn test_detect_grok_session_id_env() {
with_env_var("GROK_SESSION_ID", "sess-abc-123", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::Grok);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(result.matched_value, Some("GROK_SESSION_ID".to_string()));
});
}
#[test]
fn test_detect_grok_hook_event_env() {
with_env_var("GROK_HOOK_EVENT", "pre_tool_use", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::Grok);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(result.matched_value, Some("GROK_HOOK_EVENT".to_string()));
});
}
#[test]
fn test_detect_grok_workspace_root_env() {
with_env_var("GROK_WORKSPACE_ROOT", "/work/repo", || {
let result = detect_agent_with_details();
assert_eq!(result.agent, Agent::Grok);
assert_eq!(result.method, DetectionMethod::Environment);
assert_eq!(
result.matched_value,
Some("GROK_WORKSPACE_ROOT".to_string())
);
});
}
#[test]
fn test_detect_unknown_no_env() {
let _lock = ENV_LOCK.lock().unwrap();
let saved: Vec<_> = AGENT_ENV_VARS
.iter()
.map(|&k| (k, std::env::var(k).ok()))
.collect();
clear_cache();
unsafe {
for &k in AGENT_ENV_VARS {
std::env::remove_var(k);
}
}
let result = detect_agent_with_details();
unsafe {
for (k, v) in saved {
if let Some(val) = v {
std::env::set_var(k, val);
}
}
}
clear_cache();
assert!(
result.method == DetectionMethod::None || result.method == DetectionMethod::Process
);
}
}