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