muster/domain/process/
agent_tool.rs1use serde::{Deserialize, Serialize};
2use strum::{AsRefStr, Display, EnumIter, EnumString, IntoEnumIterator};
3
4use crate::domain::value::CommandLine;
5
6#[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 #[strum(to_string = "Claude")]
26 Claude,
27 #[strum(to_string = "Codex")]
29 Codex,
30 #[strum(to_string = "Gemini")]
32 Gemini,
33 #[strum(to_string = "Amp")]
35 Amp,
36 #[strum(to_string = "OpenCode")]
38 Opencode,
39 #[strum(to_string = "Copilot")]
41 Copilot,
42 #[strum(to_string = "Kimi")]
44 Kimi,
45 #[strum(to_string = "Custom agent", serialize = "custom")]
47 Custom,
48}
49
50impl AgentTool {
51 pub fn options() -> impl Iterator<Item = Self> {
53 Self::iter()
54 }
55
56 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
182 fn custom_protocol_token_parses() {
183 assert_eq!("custom".parse::<AgentTool>().unwrap(), AgentTool::Custom);
184 }
185
186 #[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}