Skip to main content

hh_record/
agent.rs

1//! Agent kind detection (FR-1.2: `claude-code` | `codex-cli` | `gemini-cli` |
2//! `generic`).
3//!
4//! Detection is intentionally simple and string-based. We match on the basename
5//! of `command[0]` so that `npx claude`, `/usr/local/bin/claude`, and `claude`
6//! all detect as Claude Code; `codex` as Codex CLI; `gemini` as Gemini CLI.
7//! A forced `--adapter` flag bypasses this entirely (the CLI passes the
8//! adapter's `AgentKind` directly).
9
10use hh_core::AgentKind;
11use std::path::Path;
12
13/// Detect the agent kind from the command argv (FR-1.2).
14///
15/// Rules:
16/// - If `command` is empty, this is a caller bug → `Generic`.
17/// - If the basename of `command[0]` is `claude` (with optional `.exe` on
18///   Windows), → `ClaudeCode`.
19/// - If the basename is `codex` → `CodexCli`.
20/// - If the basename is `gemini` → `GeminiCli`.
21/// - Otherwise → `Generic`.
22///
23/// A forced `--adapter` flag bypasses this; the CLI wires that by passing the
24/// adapter's `AgentKind` directly, so detection only runs for the auto path.
25#[must_use]
26pub fn detect_agent(command: &[String]) -> AgentKind {
27    let Some(first) = command.first() else {
28        return AgentKind::Generic;
29    };
30    let basename = Path::new(first)
31        .file_name()
32        .map(std::ffi::OsStr::to_string_lossy)
33        .unwrap_or_default();
34    let stem = basename
35        .strip_suffix(".exe")
36        .unwrap_or(&basename)
37        .to_ascii_lowercase();
38    match stem.as_str() {
39        "claude" => AgentKind::ClaudeCode,
40        "claude-desktop" => AgentKind::ClaudeDesktop,
41        "codex" => AgentKind::CodexCli,
42        "gemini" => AgentKind::GeminiCli,
43        _ => AgentKind::Generic,
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn empty_command_is_generic() {
53        assert_eq!(detect_agent(&[]), AgentKind::Generic);
54    }
55
56    #[test]
57    fn claude_basename_detects_claude_code() {
58        assert_eq!(detect_agent(&["claude".into()]), AgentKind::ClaudeCode);
59        assert_eq!(
60            detect_agent(&["/usr/local/bin/claude".into()]),
61            AgentKind::ClaudeCode
62        );
63        // `.exe` suffix stripped before comparison (Windows binaries).
64        assert_eq!(detect_agent(&["claude.exe".into()]), AgentKind::ClaudeCode);
65    }
66
67    #[test]
68    fn codex_basename_detects_codex_cli() {
69        assert_eq!(detect_agent(&["codex".into()]), AgentKind::CodexCli);
70        assert_eq!(
71            detect_agent(&["/usr/local/bin/codex".into()]),
72            AgentKind::CodexCli
73        );
74        assert_eq!(detect_agent(&["codex.exe".into()]), AgentKind::CodexCli);
75    }
76
77    #[test]
78    fn gemini_basename_detects_gemini_cli() {
79        assert_eq!(detect_agent(&["gemini".into()]), AgentKind::GeminiCli);
80        assert_eq!(
81            detect_agent(&["/usr/local/bin/gemini".into()]),
82            AgentKind::GeminiCli
83        );
84        assert_eq!(detect_agent(&["gemini.exe".into()]), AgentKind::GeminiCli);
85    }
86
87    #[test]
88    fn other_commands_are_generic() {
89        assert_eq!(detect_agent(&["python3".into()]), AgentKind::Generic);
90        assert_eq!(
91            detect_agent(&["npx".into(), "tsx".into()]),
92            AgentKind::Generic
93        );
94    }
95}