Skip to main content

codeswarm_core/
agents.rs

1//! Agent catalog and user configuration.
2//!
3//! The catalog is deliberately small and data-only.  Adapters remain free to
4//! implement their own protocol, while the launcher can discover and restore
5//! both built-in and user-defined commands without depending on the TUI.
6
7use serde::{Deserialize, Serialize};
8
9/// The adapter implementation used to launch an agent.
10#[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/// A launchable agent entry.
19#[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    /// Optional argument appended when launching a native adapter in its
32    /// default full-access mode.  This mirrors the Python catalog field while
33    /// keeping custom adapters free to omit it.
34    #[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
68/// The built-in catalog shipped with CodeSwarm.
69pub 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
157/// Load built-ins plus valid user entries from a settings document.
158///
159/// User entries replace a built-in with the same identity (case-insensitive),
160/// and may add custom identities. Invalid entries are ignored so a typo in a
161/// config file cannot prevent the store from opening. Set `active` to false
162/// to hide a built-in or custom entry from the launcher.
163pub 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
204/// Read a catalog from a settings file, falling back to built-ins on IO or
205/// parse errors. The returned list includes inactive entries for callers that
206/// need to display/modify them; use [`active_catalog`] for launch choices.
207pub 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
214/// Return only entries enabled for launch.
215pub 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}