Skip to main content

beam_core/
cli_specs.rs

1//! Static metadata for supported CLI adapters.
2//!
3//! Single source of truth for per-CLI registration data shared by
4//! beam-cli (setup wizard), beam-daemon (zellij adopt, workflow resume),
5//! and beam-worker (spawn env, prompt passing). Adding a new CLI adapter
6//! requires exactly one new row in [`CLI_SPECS`] here (plus the adapter
7//! implementation in beam-worker).
8
9/// Static description of one supported CLI.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct CliSpec {
12    /// Canonical CLI identifier used in bot config and session records.
13    pub cli_id: &'static str,
14    /// Human-readable label shown in the setup wizard.
15    pub label: &'static str,
16    /// Binary names probed on PATH by the setup wizard, in priority order.
17    pub bin_candidates: &'static [&'static str],
18    /// Default launch args suggested by the setup wizard for this CLI.
19    pub default_cli_args: &'static [&'static str],
20    /// Lowercase substrings matched (in [`CLI_SPECS`] order) against a zellij
21    /// pane command basename to recognize this CLI during adopt. Empty means
22    /// the CLI is never auto-recognized from a pane command.
23    pub adopt_command_patterns: &'static [&'static str],
24    /// Whether the adapter implements `init.resume` (workflow resume).
25    pub supports_resume: bool,
26    /// Whether the CLI accepts an initial prompt via spawn args while staying
27    /// interactive (opencode `--prompt`, gemini `-i`).
28    pub passes_initial_prompt_via_args: bool,
29    /// Whether the worker injects `TERM=xterm-256color` when the inherited
30    /// TERM is missing/empty/`dumb` (codex/traex require it).
31    pub inject_term_xterm: bool,
32}
33
34/// All supported CLIs, in setup-wizard display order.
35pub const CLI_SPECS: &[CliSpec] = &[
36    CliSpec {
37        cli_id: "claude-code",
38        label: "Claude",
39        bin_candidates: &["claude"],
40        default_cli_args: &[],
41        adopt_command_patterns: &["claude"],
42        supports_resume: true,
43        passes_initial_prompt_via_args: false,
44        inject_term_xterm: false,
45    },
46    CliSpec {
47        cli_id: "codex",
48        label: "Codex",
49        bin_candidates: &["codex"],
50        default_cli_args: &["--dangerously-bypass-approvals-and-sandbox", "--no-alt-screen"],
51        adopt_command_patterns: &["codex"],
52        supports_resume: true,
53        passes_initial_prompt_via_args: false,
54        inject_term_xterm: true,
55    },
56    CliSpec {
57        cli_id: "traex",
58        label: "Traex",
59        bin_candidates: &["traex"],
60        default_cli_args: &["-y"],
61        adopt_command_patterns: &["traex"],
62        supports_resume: true,
63        passes_initial_prompt_via_args: false,
64        inject_term_xterm: true,
65    },
66    CliSpec {
67        cli_id: "coco",
68        label: "CoCo",
69        bin_candidates: &["coco"],
70        default_cli_args: &[],
71        adopt_command_patterns: &[],
72        supports_resume: true,
73        passes_initial_prompt_via_args: false,
74        inject_term_xterm: false,
75    },
76    CliSpec {
77        cli_id: "gemini",
78        label: "Gemini",
79        bin_candidates: &["gemini"],
80        default_cli_args: &[],
81        adopt_command_patterns: &["gemini"],
82        supports_resume: false,
83        passes_initial_prompt_via_args: true,
84        inject_term_xterm: false,
85    },
86    CliSpec {
87        cli_id: "opencode",
88        label: "OpenCode",
89        bin_candidates: &["opencode-cli", "opencode"],
90        default_cli_args: &[],
91        adopt_command_patterns: &["opencode"],
92        supports_resume: false,
93        passes_initial_prompt_via_args: true,
94        inject_term_xterm: false,
95    },
96    CliSpec {
97        cli_id: "hermes",
98        label: "Hermes",
99        bin_candidates: &["hermes"],
100        default_cli_args: &[],
101        adopt_command_patterns: &["hermes"],
102        supports_resume: true,
103        passes_initial_prompt_via_args: false,
104        inject_term_xterm: false,
105    },
106    CliSpec {
107        cli_id: "antigravity",
108        label: "Antigravity",
109        bin_candidates: &["agy"],
110        default_cli_args: &[],
111        adopt_command_patterns: &[],
112        supports_resume: true,
113        passes_initial_prompt_via_args: false,
114        inject_term_xterm: false,
115    },
116    CliSpec {
117        cli_id: "kimi",
118        label: "Kimi",
119        bin_candidates: &["kimi"],
120        default_cli_args: &[],
121        adopt_command_patterns: &["kimi"],
122        supports_resume: true,
123        passes_initial_prompt_via_args: false,
124        inject_term_xterm: false,
125    },
126];
127
128/// Look up the spec for a CLI id. Returns `None` for unknown ids.
129pub fn cli_spec(cli_id: &str) -> Option<&'static CliSpec> {
130    CLI_SPECS.iter().find(|spec| spec.cli_id == cli_id)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn every_spec_has_id_label_and_bins() {
139        for spec in CLI_SPECS {
140            assert!(!spec.cli_id.is_empty(), "empty cli_id");
141            assert!(!spec.label.is_empty(), "{}: empty label", spec.cli_id);
142            assert!(
143                !spec.bin_candidates.is_empty(),
144                "{}: empty bin_candidates",
145                spec.cli_id
146            );
147        }
148    }
149
150    #[test]
151    fn cli_spec_lookup() {
152        assert_eq!(cli_spec("kimi").map(|s| s.label), Some("Kimi"));
153        assert!(cli_spec("no-such-cli").is_none());
154    }
155
156    #[test]
157    fn cli_ids_are_unique() {
158        let mut ids: Vec<_> = CLI_SPECS.iter().map(|s| s.cli_id).collect();
159        ids.sort_unstable();
160        ids.dedup();
161        assert_eq!(ids.len(), CLI_SPECS.len(), "duplicate cli_id in CLI_SPECS");
162    }
163
164    // The assertions below lock the flags to the behavior that was previously
165    // hard-coded at the individual call sites. Update them only when a CLI
166    // genuinely changes capability.
167
168    #[test]
169    fn resume_support_matches_legacy_allow_list() {
170        let resume: Vec<_> = CLI_SPECS
171            .iter()
172            .filter(|s| s.supports_resume)
173            .map(|s| s.cli_id)
174            .collect();
175        assert_eq!(
176            resume,
177            vec![
178                "claude-code",
179                "codex",
180                "traex",
181                "coco",
182                "hermes",
183                "antigravity",
184                "kimi"
185            ]
186        );
187    }
188
189    #[test]
190    fn initial_prompt_via_args_matches_legacy_list() {
191        let pass: Vec<_> = CLI_SPECS
192            .iter()
193            .filter(|s| s.passes_initial_prompt_via_args)
194            .map(|s| s.cli_id)
195            .collect();
196        assert_eq!(pass, vec!["gemini", "opencode"]);
197    }
198
199    #[test]
200    fn term_injection_matches_legacy_list() {
201        let term: Vec<_> = CLI_SPECS
202            .iter()
203            .filter(|s| s.inject_term_xterm)
204            .map(|s| s.cli_id)
205            .collect();
206        assert_eq!(term, vec!["codex", "traex"]);
207    }
208
209    #[test]
210    fn default_args_match_legacy_values() {
211        assert_eq!(
212            cli_spec("codex").unwrap().default_cli_args,
213            &["--dangerously-bypass-approvals-and-sandbox", "--no-alt-screen"]
214        );
215        assert_eq!(cli_spec("traex").unwrap().default_cli_args, &["-y"]);
216        for spec in CLI_SPECS {
217            if spec.cli_id != "codex" && spec.cli_id != "traex" {
218                assert!(
219                    spec.default_cli_args.is_empty(),
220                    "{}: unexpected default args",
221                    spec.cli_id
222                );
223            }
224        }
225    }
226
227    #[test]
228    fn adopt_patterns_match_legacy_recognition() {
229        let recognized: Vec<_> = CLI_SPECS
230            .iter()
231            .filter(|s| !s.adopt_command_patterns.is_empty())
232            .map(|s| s.cli_id)
233            .collect();
234        assert_eq!(
235            recognized,
236            vec![
237                "claude-code",
238                "codex",
239                "traex",
240                "gemini",
241                "opencode",
242                "hermes",
243                "kimi"
244            ]
245        );
246    }
247
248    #[test]
249    fn adopt_patterns_do_not_overlap_across_clis() {
250        // A pattern of one CLI must not be a substring of another CLI's
251        // pattern, otherwise CLI_SPECS ordering would decide recognition.
252        for (i, a) in CLI_SPECS.iter().enumerate() {
253            for b in CLI_SPECS.iter().skip(i + 1) {
254                for pa in a.adopt_command_patterns {
255                    for pb in b.adopt_command_patterns {
256                        assert!(
257                            !pa.contains(pb) && !pb.contains(pa),
258                            "adopt patterns overlap: {} ({}) vs {} ({})",
259                            a.cli_id,
260                            pa,
261                            b.cli_id,
262                            pb
263                        );
264                    }
265                }
266            }
267        }
268    }
269}