Skip to main content

muster/domain/process/
agent_tool.rs

1use serde::{Deserialize, Serialize};
2use strum::{AsRefStr, Display, EnumIter, EnumString, IntoEnumIterator};
3
4use crate::domain::value::CommandLine;
5
6/// Supported coding-agent command presets.
7#[derive(
8    AsRefStr,
9    Debug,
10    Clone,
11    Copy,
12    PartialEq,
13    Eq,
14    Hash,
15    Display,
16    EnumIter,
17    EnumString,
18    Serialize,
19    Deserialize,
20)]
21#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
22#[serde(rename_all = "lowercase")]
23pub enum AgentTool {
24    /// Anthropic Claude Code.
25    #[strum(to_string = "Claude")]
26    Claude,
27    /// OpenAI Codex CLI.
28    #[strum(to_string = "Codex")]
29    Codex,
30    /// Google Gemini CLI.
31    #[strum(to_string = "Gemini")]
32    Gemini,
33    /// Sourcegraph Amp.
34    #[strum(to_string = "Amp")]
35    Amp,
36    /// OpenCode.
37    #[strum(to_string = "OpenCode")]
38    Opencode,
39    /// GitHub Copilot CLI.
40    #[strum(to_string = "Copilot")]
41    Copilot,
42    /// Moonshot Kimi CLI.
43    #[strum(to_string = "Kimi")]
44    Kimi,
45    /// A user-supplied agent command.
46    #[strum(to_string = "Custom agent", serialize = "custom")]
47    Custom,
48}
49
50impl AgentTool {
51    /// Iterates providers in the order used by the launcher.
52    pub fn options() -> impl Iterator<Item = Self> {
53        Self::iter()
54    }
55
56    /// Returns the stable lowercase token used in provider hook payloads.
57    pub const fn protocol_token(self) -> &'static str {
58        match self {
59            Self::Claude => "claude",
60            Self::Codex => "codex",
61            Self::Gemini => "gemini",
62            Self::Amp => "amp",
63            Self::Opencode => "opencode",
64            Self::Copilot => "copilot",
65            Self::Kimi => "kimi",
66            Self::Custom => "custom",
67        }
68    }
69
70    /// Returns the preset executable, or `None` when a custom command is needed.
71    pub const fn default_command(self) -> Option<&'static str> {
72        match self {
73            Self::Claude => Some("claude"),
74            Self::Codex => Some("codex"),
75            Self::Gemini => Some("gemini"),
76            Self::Amp => Some("amp"),
77            Self::Opencode => Some("opencode"),
78            Self::Copilot => Some("copilot"),
79            Self::Kimi => Some("kimi"),
80            Self::Custom => None,
81        }
82    }
83
84    /// Infers a preset from the executable at the start of `command`.
85    pub fn from_command(command: Option<&CommandLine>) -> Self {
86        let Some(executable) = command
87            .and_then(|command| command.as_ref().split_whitespace().next())
88            .and_then(|executable| executable.rsplit(['/', '\\']).next())
89            .map(|executable| {
90                executable
91                    .strip_suffix(".exe")
92                    .or_else(|| executable.strip_suffix(".cmd"))
93                    .or_else(|| executable.strip_suffix(".bat"))
94                    .unwrap_or(executable)
95            })
96        else {
97            return Self::Custom;
98        };
99
100        Self::iter()
101            .find(|tool| {
102                tool.default_command().is_some_and(|default_command| {
103                    #[cfg(windows)]
104                    {
105                        executable.eq_ignore_ascii_case(default_command)
106                    }
107                    #[cfg(not(windows))]
108                    {
109                        executable == default_command
110                    }
111                })
112            })
113            .unwrap_or(Self::Custom)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::domain::{agent_session::NativeSessionId, process::AgentProtocol};
121
122    /// A path-qualified known executable still selects its preset.
123    #[test]
124    fn infers_known_executables_from_commands() {
125        let command = CommandLine::try_new("/usr/local/bin/codex --full-auto").unwrap();
126
127        assert_eq!(AgentTool::from_command(Some(&command)), AgentTool::Codex);
128    }
129
130    /// Windows-style paths and command-launcher suffixes identify presets too.
131    #[test]
132    fn infers_known_windows_executables_from_commands() {
133        let command = CommandLine::try_new(r"C:\Tools\claude.cmd").unwrap();
134
135        assert_eq!(AgentTool::from_command(Some(&command)), AgentTool::Claude);
136    }
137
138    /// An unrecognized executable uses generic agent behavior.
139    #[test]
140    fn treats_unknown_commands_as_custom() {
141        let command = CommandLine::try_new("my-agent").unwrap();
142
143        assert_eq!(AgentTool::from_command(Some(&command)), AgentTool::Custom);
144    }
145
146    /// Every choice exposed by the launcher maps back to an agent tool.
147    #[test]
148    fn every_launcher_option_parses() {
149        for tool in AgentTool::options() {
150            assert_eq!(tool.to_string().parse::<AgentTool>().unwrap(), tool);
151        }
152    }
153
154    /// Every provider can select its own parseable launcher option even when
155    /// its human-facing label is more descriptive.
156    #[test]
157    fn every_tool_maps_to_its_launcher_option() {
158        for tool in AgentTool::options() {
159            assert_eq!(tool.to_string().parse::<AgentTool>().unwrap(), tool);
160        }
161    }
162
163    /// Human-facing provider labels retain their established capitalization.
164    #[test]
165    fn displays_title_cased_provider_labels() {
166        assert_eq!(AgentTool::Claude.to_string(), "Claude");
167        assert_eq!(AgentTool::Codex.to_string(), "Codex");
168        assert_eq!(AgentTool::Gemini.to_string(), "Gemini");
169        assert_eq!(AgentTool::Opencode.to_string(), "OpenCode");
170    }
171
172    /// Protocol tokens stay separate from title-cased display labels.
173    #[test]
174    fn exposes_lowercase_protocol_tokens() {
175        assert_eq!(AgentTool::Claude.protocol_token(), "claude");
176        assert_eq!(AgentTool::Opencode.protocol_token(), "opencode");
177        assert_eq!(AgentTool::Custom.protocol_token(), "custom");
178    }
179
180    /// The public protocol's machine token remains accepted for custom providers.
181    #[test]
182    fn custom_protocol_token_parses() {
183        assert_eq!("custom".parse::<AgentTool>().unwrap(), AgentTool::Custom);
184    }
185
186    /// Every known provider has an explicit native resume strategy.
187    #[test]
188    fn builds_provider_resume_commands() {
189        let cases = [
190            (AgentTool::Claude, "claude --resume abc"),
191            (AgentTool::Codex, "codex resume abc"),
192            (AgentTool::Gemini, "gemini --resume abc"),
193            (AgentTool::Amp, "amp threads continue abc"),
194            (AgentTool::Opencode, "opencode --session abc"),
195            (AgentTool::Copilot, "copilot --resume abc"),
196            (AgentTool::Kimi, "kimi --session abc"),
197        ];
198        let native_id = NativeSessionId::try_new("abc").unwrap();
199        for (tool, expected) in cases {
200            let launch = CommandLine::try_new(tool.default_command().unwrap()).unwrap();
201            assert_eq!(
202                tool.resume_command(&launch, &native_id).unwrap().as_ref(),
203                expected
204            );
205        }
206    }
207}