1use serde::{Deserialize, Serialize};
8
9#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
11pub enum AdapterKind {
12 #[serde(rename = "agy", alias = "native")]
13 Native,
14 #[serde(rename = "acp")]
15 Acp,
16}
17
18#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
20pub struct AgentDefinition {
21 pub identity: String,
22 pub name: String,
23 pub short_name: String,
24 #[serde(default)]
25 pub aliases: Vec<String>,
26 pub adapter: AdapterKind,
27 #[serde(alias = "run_command")]
28 pub command: String,
29 #[serde(default)]
30 pub detect_command: Option<String>,
31 #[serde(default)]
35 pub full_access_startup_argument: Option<String>,
36 #[serde(default = "default_active")]
37 pub active: bool,
38}
39
40fn default_active() -> bool {
41 true
42}
43
44impl AgentDefinition {
45 pub fn new(
46 identity: impl Into<String>,
47 name: impl Into<String>,
48 short_name: impl Into<String>,
49 adapter: AdapterKind,
50 command: impl Into<String>,
51 ) -> Self {
52 let identity = identity.into();
53 let command = command.into();
54 Self {
55 name: name.into(),
56 short_name: short_name.into(),
57 aliases: Vec::new(),
58 adapter,
59 detect_command: Some(command.clone()),
60 full_access_startup_argument: None,
61 active: true,
62 identity,
63 command,
64 }
65 }
66}
67
68pub fn default_catalog() -> Vec<AgentDefinition> {
70 #[allow(clippy::too_many_arguments)]
71 fn builtin(
72 identity: &str,
73 name: &str,
74 short_name: &str,
75 adapter: AdapterKind,
76 command: &str,
77 detect_command: &str,
78 aliases: &[&str],
79 full_access_startup_argument: Option<&str>,
80 ) -> AgentDefinition {
81 let mut agent = AgentDefinition::new(identity, name, short_name, adapter, command);
82 agent.detect_command = Some(detect_command.into());
83 agent.aliases = aliases.iter().map(|alias| (*alias).into()).collect();
84 agent.full_access_startup_argument = full_access_startup_argument.map(str::to_owned);
85 agent
86 }
87
88 vec![
89 builtin(
90 "antigravity.google.com",
91 "Antigravity",
92 "antigravity",
93 AdapterKind::Native,
94 "agy",
95 "agy",
96 &["agy"],
97 Some("--dangerously-skip-permissions"),
98 ),
99 builtin(
100 "claude.com",
101 "Claude",
102 "claude",
103 AdapterKind::Acp,
104 "npx -y @agentclientprotocol/claude-agent-acp",
105 "claude",
106 &[],
107 None,
108 ),
109 builtin(
110 "geminicli.com",
111 "Gemini",
112 "gemini",
113 AdapterKind::Acp,
114 "gemini --experimental-acp",
115 "gemini",
116 &[],
117 None,
118 ),
119 builtin(
120 "openai.com",
121 "Codex",
122 "codex",
123 AdapterKind::Acp,
124 "npx -y --package=@agentclientprotocol/codex-acp codex-acp",
125 "codex",
126 &["openai"],
127 None,
128 ),
129 builtin(
130 "opencode.ai",
131 "OpenCode",
132 "opencode",
133 AdapterKind::Acp,
134 "opencode acp",
135 "opencode",
136 &[],
137 None,
138 ),
139 builtin(
140 "qwen.ai",
141 "Qwen",
142 "qwen",
143 AdapterKind::Acp,
144 "qwen --acp",
145 "qwen",
146 &[],
147 None,
148 ),
149 ]
150}
151
152#[derive(Deserialize)]
153struct SettingsFile {
154 agents: Option<serde_json::Value>,
155}
156
157pub fn catalog_from_settings(settings_json: &str) -> Vec<AgentDefinition> {
164 let mut catalog = default_catalog();
165 let Ok(settings) = serde_json::from_str::<SettingsFile>(settings_json) else {
166 return catalog;
167 };
168 let Some(value) = settings.agents else {
169 return catalog;
170 };
171 let entries = match value {
172 serde_json::Value::Array(entries) => entries,
173 serde_json::Value::Object(entries) => entries
174 .into_iter()
175 .filter_map(|(identity, mut value)| {
176 let object = value.as_object_mut()?;
177 object
178 .entry("identity".to_owned())
179 .or_insert(serde_json::Value::String(identity));
180 Some(value)
181 })
182 .collect(),
183 _ => return catalog,
184 };
185 for value in entries {
186 let Ok(entry) = serde_json::from_value::<AgentDefinition>(value) else {
187 continue;
188 };
189 if entry.identity.trim().is_empty() || entry.command.trim().is_empty() {
190 continue;
191 }
192 if let Some(existing) = catalog
193 .iter_mut()
194 .find(|candidate| candidate.identity.eq_ignore_ascii_case(&entry.identity))
195 {
196 *existing = entry;
197 } else {
198 catalog.push(entry);
199 }
200 }
201 catalog
202}
203
204pub fn catalog_from_path(path: impl AsRef<std::path::Path>) -> Vec<AgentDefinition> {
208 std::fs::read_to_string(path).map_or_else(
209 |_| default_catalog(),
210 |settings| catalog_from_settings(&settings),
211 )
212}
213
214pub fn active_catalog(catalog: impl IntoIterator<Item = AgentDefinition>) -> Vec<AgentDefinition> {
216 catalog.into_iter().filter(|agent| agent.active).collect()
217}
218
219#[cfg(test)]
220mod tests {
221 use super::{AdapterKind, active_catalog, catalog_from_settings, default_catalog};
222
223 #[test]
224 fn builtins_cover_native_and_acp_agents() {
225 let catalog = default_catalog();
226 assert_eq!(catalog.len(), 6);
227 assert!(
228 catalog
229 .iter()
230 .any(|agent| agent.adapter == AdapterKind::Native)
231 );
232 assert!(
233 catalog
234 .iter()
235 .any(|agent| agent.adapter == AdapterKind::Acp)
236 );
237 }
238
239 #[test]
240 fn custom_entries_are_added_and_builtin_entries_can_be_replaced() {
241 let settings = r#"{
242 "agents": [
243 {"identity":"openai.com","name":"Codex local","short_name":"codex","adapter":"acp","command":"codex --acp","active":true},
244 {"identity":"custom.example","name":"Custom","short_name":"custom","adapter":"native","command":"my-agent","aliases":["mine"]}
245 ]
246 }"#;
247 let catalog = catalog_from_settings(settings);
248 assert_eq!(catalog.len(), 7);
249 assert_eq!(
250 catalog
251 .iter()
252 .find(|a| a.identity == "openai.com")
253 .unwrap()
254 .name,
255 "Codex local"
256 );
257 assert!(catalog.iter().any(|a| a.identity == "custom.example"));
258 }
259
260 #[test]
261 fn object_form_uses_the_map_key_as_identity() {
262 let settings = r#"{"agents":{"mine.example":{"name":"Mine","short_name":"mine","adapter":"acp","command":"mine --acp"}}}"#;
263 let catalog = catalog_from_settings(settings);
264 assert_eq!(
265 catalog
266 .iter()
267 .find(|a| a.identity == "mine.example")
268 .unwrap()
269 .short_name,
270 "mine"
271 );
272 }
273
274 #[test]
275 fn malformed_entries_do_not_poison_builtin_catalog_and_inactive_is_filterable() {
276 let settings = r#"{"agents":[42,{"identity":"hidden","name":"Hidden","short_name":"hidden","adapter":"acp","command":"hidden","active":false}]}"#;
277 let catalog = catalog_from_settings(settings);
278 assert_eq!(catalog.len(), 7);
279 assert!(
280 !active_catalog(catalog)
281 .iter()
282 .any(|a| a.identity == "hidden")
283 );
284 }
285
286 #[test]
287 fn builtins_keep_python_aliases_and_detect_real_cli_not_npx_bridge() {
288 let catalog = default_catalog();
289 let antigravity = catalog
290 .iter()
291 .find(|agent| agent.identity == "antigravity.google.com")
292 .expect("antigravity");
293 assert_eq!(antigravity.aliases, ["agy"]);
294 assert_eq!(antigravity.detect_command.as_deref(), Some("agy"));
295 assert_eq!(
296 antigravity.full_access_startup_argument.as_deref(),
297 Some("--dangerously-skip-permissions")
298 );
299
300 let codex = catalog
301 .iter()
302 .find(|agent| agent.identity == "openai.com")
303 .expect("codex");
304 assert_eq!(codex.aliases, ["openai"]);
305 assert_eq!(codex.detect_command.as_deref(), Some("codex"));
306 assert_ne!(
307 codex.detect_command.as_deref(),
308 Some(codex.command.as_str())
309 );
310 let gemini = catalog
311 .iter()
312 .find(|agent| agent.identity == "geminicli.com")
313 .expect("gemini");
314 assert_eq!(gemini.command, "gemini --experimental-acp");
315 }
316}