1use serde::{Deserialize, Serialize};
7use smol_str::SmolStr;
8use std::convert::Infallible;
9use std::fmt;
10use std::str::FromStr;
11
12#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(from = "SmolStr", into = "SmolStr")]
14pub enum AgentId {
15 #[default]
16 Opencode,
17 CodexCli,
18 ClaudeCode,
19 Cline,
20 CopilotCli,
21 Other(SmolStr),
22}
23
24impl AgentId {
25 pub fn as_str(&self) -> &str {
26 match self {
27 Self::Opencode => "opencode",
28 Self::CodexCli => "codex-cli",
29 Self::ClaudeCode => "claude-code",
30 Self::Cline => "cline",
31 Self::CopilotCli => "copilot-cli",
32 Self::Other(value) => value.as_str(),
33 }
34 }
35
36 pub fn from_slug(slug: &str) -> Option<Self> {
37 match slug {
38 "opencode" => Some(Self::Opencode),
39 "codex_exec" | "codex-tui" | "codex" | "codex-cli" => Some(Self::CodexCli),
40 "claude-cli" | "claude-code" => Some(Self::ClaudeCode),
41 "cline" => Some(Self::Cline),
42 "copilot" | "copilot-cli" => Some(Self::CopilotCli),
43 _ => None,
44 }
45 }
46
47 pub fn provider_default(provider_id: &str) -> Option<Self> {
48 match provider_id {
49 "openai" | "deepseek" | "llama-cpp" | "zai" | "zai-coding-plan" | "zhipuai" | "zhipuai-coding-plan" => {
50 Some(Self::Opencode)
51 }
52 "codex" => Some(Self::CodexCli),
53 "copilot" | "github-copilot" => Some(Self::CopilotCli),
54 _ => None,
55 }
56 }
57}
58
59impl From<&str> for AgentId {
60 fn from(s: &str) -> Self {
61 Self::from_slug(s).unwrap_or_else(|| Self::Other(SmolStr::new(s)))
62 }
63}
64
65impl From<String> for AgentId {
66 fn from(s: String) -> Self {
67 Self::from(s.as_str())
68 }
69}
70
71impl From<SmolStr> for AgentId {
72 fn from(s: SmolStr) -> Self {
73 Self::from(s.as_str())
74 }
75}
76
77impl From<AgentId> for SmolStr {
78 fn from(value: AgentId) -> Self {
79 match value {
80 AgentId::Other(value) => value,
81 other => SmolStr::new(other.as_str()),
82 }
83 }
84}
85
86impl FromStr for AgentId {
87 type Err = Infallible;
88
89 fn from_str(s: &str) -> Result<Self, Self::Err> {
90 Ok(Self::from(s))
91 }
92}
93
94impl fmt::Display for AgentId {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.write_str(self.as_str())
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn known_ids_round_trip() {
106 for (slug, expected) in [
107 ("opencode", AgentId::Opencode),
108 ("codex-cli", AgentId::CodexCli),
109 ("claude-code", AgentId::ClaudeCode),
110 ("cline", AgentId::Cline),
111 ("copilot-cli", AgentId::CopilotCli),
112 ] {
113 assert_eq!(AgentId::from_slug(slug), Some(expected.clone()));
114 assert_eq!(expected.as_str(), slug);
115 assert_eq!(expected.to_string(), slug);
116 }
117 }
118
119 #[test]
120 fn aliases_normalize() {
121 assert_eq!(AgentId::from_slug("codex"), Some(AgentId::CodexCli));
122 assert_eq!(AgentId::from_slug("codex_exec"), Some(AgentId::CodexCli));
123 assert_eq!(AgentId::from_slug("claude-cli"), Some(AgentId::ClaudeCode));
124 assert_eq!(AgentId::from_slug("copilot"), Some(AgentId::CopilotCli));
125 }
126
127 #[test]
128 fn unknown_slug_falls_back_to_other() {
129 let agent_id = AgentId::from("my-bespoke-tool");
130 assert_eq!(agent_id, AgentId::Other(SmolStr::new("my-bespoke-tool")));
131 assert_eq!(agent_id.as_str(), "my-bespoke-tool");
132 }
133
134 #[test]
135 fn serde_round_trip_other() {
136 let agent_id = AgentId::Other(SmolStr::new("custom-tool"));
137 let encoded = serde_json::to_string(&agent_id).unwrap();
138 assert_eq!(encoded, "\"custom-tool\"");
139 let decoded: AgentId = serde_json::from_str(&encoded).unwrap();
140 assert_eq!(decoded, agent_id);
141 }
142
143 #[test]
144 fn provider_defaults_cover_known_providers() {
145 assert_eq!(AgentId::provider_default("openai"), Some(AgentId::Opencode));
146 assert_eq!(AgentId::provider_default("llama-cpp"), Some(AgentId::Opencode));
147 assert_eq!(AgentId::provider_default("codex"), Some(AgentId::CodexCli));
148 assert_eq!(AgentId::provider_default("github-copilot"), Some(AgentId::CopilotCli));
149 assert_eq!(AgentId::provider_default("nonesuch"), None);
150 }
151}