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    /// Case-insensitive substring the CLI's TUI renders once its input UI is
30    /// initialized (e.g. kimi's "Welcome to Kimi Code"). The worker waits for
31    /// this marker before the first `write_input` so keystrokes typed during
32    /// TUI boot are not dropped. `None` disables the gate (CLIs that accept
33    /// the initial prompt via spawn args, or adapters that gate themselves).
34    pub tui_ready_marker: Option<&'static str>,
35    /// Whether the worker injects `TERM=xterm-256color` when the inherited
36    /// TERM is missing/empty/`dumb` (codex/traex require it).
37    pub inject_term_xterm: bool,
38}
39
40/// All supported CLIs, in setup-wizard display order.
41pub const CLI_SPECS: &[CliSpec] = &[
42    CliSpec {
43        cli_id: "claude-code",
44        label: "Claude",
45        bin_candidates: &["claude"],
46        default_cli_args: &[],
47        adopt_command_patterns: &["claude"],
48        supports_resume: true,
49        passes_initial_prompt_via_args: false,
50        tui_ready_marker: Some("Welcome"),
51        inject_term_xterm: false,
52    },
53    CliSpec {
54        cli_id: "codex",
55        label: "Codex",
56        bin_candidates: &["codex"],
57        default_cli_args: &[
58            "--dangerously-bypass-approvals-and-sandbox",
59            "--no-alt-screen",
60        ],
61        adopt_command_patterns: &["codex"],
62        supports_resume: true,
63        passes_initial_prompt_via_args: false,
64        tui_ready_marker: None,
65        inject_term_xterm: true,
66    },
67    CliSpec {
68        cli_id: "traex",
69        label: "Traex",
70        bin_candidates: &["traex"],
71        default_cli_args: &["-y"],
72        adopt_command_patterns: &["traex"],
73        supports_resume: true,
74        passes_initial_prompt_via_args: false,
75        tui_ready_marker: None,
76        inject_term_xterm: true,
77    },
78    CliSpec {
79        cli_id: "coco",
80        label: "CoCo",
81        bin_candidates: &["coco"],
82        default_cli_args: &[],
83        adopt_command_patterns: &[],
84        supports_resume: true,
85        passes_initial_prompt_via_args: false,
86        tui_ready_marker: Some("Welcome"),
87        inject_term_xterm: false,
88    },
89    CliSpec {
90        cli_id: "gemini",
91        label: "Gemini",
92        bin_candidates: &["gemini"],
93        default_cli_args: &[],
94        adopt_command_patterns: &["gemini"],
95        supports_resume: false,
96        passes_initial_prompt_via_args: true,
97        tui_ready_marker: None,
98        inject_term_xterm: false,
99    },
100    CliSpec {
101        cli_id: "opencode",
102        label: "OpenCode",
103        bin_candidates: &["opencode-cli", "opencode"],
104        default_cli_args: &[],
105        adopt_command_patterns: &["opencode"],
106        supports_resume: false,
107        passes_initial_prompt_via_args: true,
108        tui_ready_marker: None,
109        inject_term_xterm: false,
110    },
111    CliSpec {
112        cli_id: "hermes",
113        label: "Hermes",
114        bin_candidates: &["hermes"],
115        default_cli_args: &[],
116        adopt_command_patterns: &["hermes"],
117        supports_resume: true,
118        passes_initial_prompt_via_args: false,
119        tui_ready_marker: Some("Welcome"),
120        inject_term_xterm: false,
121    },
122    CliSpec {
123        cli_id: "antigravity",
124        label: "Antigravity",
125        bin_candidates: &["agy"],
126        default_cli_args: &[],
127        adopt_command_patterns: &[],
128        supports_resume: true,
129        passes_initial_prompt_via_args: false,
130        tui_ready_marker: Some("Welcome"),
131        inject_term_xterm: false,
132    },
133    CliSpec {
134        cli_id: "kimi",
135        label: "Kimi",
136        bin_candidates: &["kimi"],
137        default_cli_args: &[],
138        adopt_command_patterns: &["kimi"],
139        supports_resume: true,
140        passes_initial_prompt_via_args: false,
141        tui_ready_marker: Some("Welcome to Kimi Code"),
142        inject_term_xterm: false,
143    },
144];
145
146/// Look up the spec for a CLI id. Returns `None` for unknown ids.
147pub fn cli_spec(cli_id: &str) -> Option<&'static CliSpec> {
148    CLI_SPECS.iter().find(|spec| spec.cli_id == cli_id)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn every_spec_has_id_label_and_bins() {
157        for spec in CLI_SPECS {
158            assert!(!spec.cli_id.is_empty(), "empty cli_id");
159            assert!(!spec.label.is_empty(), "{}: empty label", spec.cli_id);
160            assert!(
161                !spec.bin_candidates.is_empty(),
162                "{}: empty bin_candidates",
163                spec.cli_id
164            );
165        }
166    }
167
168    #[test]
169    fn cli_spec_lookup() {
170        assert_eq!(cli_spec("kimi").map(|s| s.label), Some("Kimi"));
171        assert!(cli_spec("no-such-cli").is_none());
172    }
173
174    #[test]
175    fn cli_ids_are_unique() {
176        let mut ids: Vec<_> = CLI_SPECS.iter().map(|s| s.cli_id).collect();
177        ids.sort_unstable();
178        ids.dedup();
179        assert_eq!(ids.len(), CLI_SPECS.len(), "duplicate cli_id in CLI_SPECS");
180    }
181
182    // The assertions below lock the flags to the behavior that was previously
183    // hard-coded at the individual call sites. Update them only when a CLI
184    // genuinely changes capability.
185
186    #[test]
187    fn resume_support_matches_legacy_allow_list() {
188        let resume: Vec<_> = CLI_SPECS
189            .iter()
190            .filter(|s| s.supports_resume)
191            .map(|s| s.cli_id)
192            .collect();
193        assert_eq!(
194            resume,
195            vec![
196                "claude-code",
197                "codex",
198                "traex",
199                "coco",
200                "hermes",
201                "antigravity",
202                "kimi"
203            ]
204        );
205    }
206
207    #[test]
208    fn initial_prompt_via_args_matches_legacy_list() {
209        let pass: Vec<_> = CLI_SPECS
210            .iter()
211            .filter(|s| s.passes_initial_prompt_via_args)
212            .map(|s| s.cli_id)
213            .collect();
214        assert_eq!(pass, vec!["gemini", "opencode"]);
215    }
216
217    #[test]
218    fn tui_ready_markers_match_expected_list() {
219        let gated: Vec<_> = CLI_SPECS
220            .iter()
221            .filter_map(|s| s.tui_ready_marker.map(|marker| (s.cli_id, marker)))
222            .collect();
223        assert_eq!(
224            gated,
225            vec![
226                ("claude-code", "Welcome"),
227                ("coco", "Welcome"),
228                ("hermes", "Welcome"),
229                ("antigravity", "Welcome"),
230                ("kimi", "Welcome to Kimi Code"),
231            ]
232        );
233    }
234
235    #[test]
236    fn term_injection_matches_legacy_list() {
237        let term: Vec<_> = CLI_SPECS
238            .iter()
239            .filter(|s| s.inject_term_xterm)
240            .map(|s| s.cli_id)
241            .collect();
242        assert_eq!(term, vec!["codex", "traex"]);
243    }
244
245    #[test]
246    fn default_args_match_legacy_values() {
247        assert_eq!(
248            cli_spec("codex").unwrap().default_cli_args,
249            &[
250                "--dangerously-bypass-approvals-and-sandbox",
251                "--no-alt-screen"
252            ]
253        );
254        assert_eq!(cli_spec("traex").unwrap().default_cli_args, &["-y"]);
255        for spec in CLI_SPECS {
256            if spec.cli_id != "codex" && spec.cli_id != "traex" {
257                assert!(
258                    spec.default_cli_args.is_empty(),
259                    "{}: unexpected default args",
260                    spec.cli_id
261                );
262            }
263        }
264    }
265
266    #[test]
267    fn adopt_patterns_match_legacy_recognition() {
268        let recognized: Vec<_> = CLI_SPECS
269            .iter()
270            .filter(|s| !s.adopt_command_patterns.is_empty())
271            .map(|s| s.cli_id)
272            .collect();
273        assert_eq!(
274            recognized,
275            vec![
276                "claude-code",
277                "codex",
278                "traex",
279                "gemini",
280                "opencode",
281                "hermes",
282                "kimi"
283            ]
284        );
285    }
286
287    #[test]
288    fn adopt_patterns_do_not_overlap_across_clis() {
289        // A pattern of one CLI must not be a substring of another CLI's
290        // pattern, otherwise CLI_SPECS ordering would decide recognition.
291        for (i, a) in CLI_SPECS.iter().enumerate() {
292            for b in CLI_SPECS.iter().skip(i + 1) {
293                for pa in a.adopt_command_patterns {
294                    for pb in b.adopt_command_patterns {
295                        assert!(
296                            !pa.contains(pb) && !pb.contains(pa),
297                            "adopt patterns overlap: {} ({}) vs {} ({})",
298                            a.cli_id,
299                            pa,
300                            b.cli_id,
301                            pb
302                        );
303                    }
304                }
305            }
306        }
307    }
308}