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