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/// How the worker decides a TUI CLI is ready for its first input.
10///
11/// The gate exists because TUI CLIs drop keystrokes typed before their input
12/// UI is initialized, so the first input of a fresh session waits for one of
13/// these signals. Keeping the signal structural ([`ReadyProbe::PromptLine`])
14/// rather than a literal prompt character means a CLI that restyles its
15/// composer (`›` -> `❯` -> `->`) keeps working without a code change.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ReadyProbe {
18    /// No gate: the CLI takes the initial prompt via spawn args, or the
19    /// adapter gates itself.
20    None,
21    /// Ready when any of these substrings appears in the viewport
22    /// (case-insensitive), e.g. a CLI's welcome banner.
23    Text(&'static [&'static str]),
24    /// Ready when a composer input line appears in the viewport: the leading
25    /// non-whitespace cell of a line is a prompt glyph (codex `›`, traex `❯`,
26    /// kimi `>`), optionally behind a box border.
27    PromptLine,
28}
29
30/// Static description of one supported CLI.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct CliSpec {
33    /// Canonical CLI identifier used in bot config and session records.
34    pub cli_id: &'static str,
35    /// Human-readable label shown in the setup wizard.
36    pub label: &'static str,
37    /// Binary names probed on PATH by the setup wizard, in priority order.
38    pub bin_candidates: &'static [&'static str],
39    /// Default launch args suggested by the setup wizard for this CLI.
40    pub default_cli_args: &'static [&'static str],
41    /// Lowercase substrings matched (in [`CLI_SPECS`] order) against a zellij
42    /// pane command basename to recognize this CLI during adopt. Empty means
43    /// the CLI is never auto-recognized from a pane command.
44    pub adopt_command_patterns: &'static [&'static str],
45    /// Whether the adapter implements `init.resume` (workflow resume).
46    pub supports_resume: bool,
47    /// Whether the CLI accepts an initial prompt via spawn args while staying
48    /// interactive (opencode `--prompt`, gemini `-i`).
49    pub passes_initial_prompt_via_args: bool,
50    /// Signal the CLI's TUI emits once its input UI is initialized. The worker
51    /// waits for it before the first `write_input` so keystrokes typed during
52    /// TUI boot are not dropped.
53    pub ready_probe: ReadyProbe,
54    /// Whether the worker injects `TERM=xterm-256color` when the inherited
55    /// TERM is missing/empty/`dumb` (codex/traex require it).
56    pub inject_term_xterm: bool,
57}
58
59/// All supported CLIs, in setup-wizard display order.
60pub const CLI_SPECS: &[CliSpec] = &[
61    CliSpec {
62        cli_id: "claude-code",
63        label: "Claude",
64        bin_candidates: &["claude"],
65        default_cli_args: &[
66            "--dangerously-skip-permissions",
67            "--settings",
68            r#"{"skipDangerousModePermissionPrompt":true,"permissions":{"defaultMode":"bypassPermissions"}}"#,
69            "--disallowed-tools",
70            "EnterPlanMode,ExitPlanMode",
71        ],
72        adopt_command_patterns: &["claude"],
73        supports_resume: true,
74        passes_initial_prompt_via_args: false,
75        ready_probe: ReadyProbe::Text(&["Welcome"]),
76        inject_term_xterm: false,
77    },
78    CliSpec {
79        cli_id: "codex",
80        label: "Codex",
81        bin_candidates: &["codex"],
82        default_cli_args: &[
83            "--dangerously-bypass-approvals-and-sandbox",
84            "--no-alt-screen",
85        ],
86        adopt_command_patterns: &["codex"],
87        supports_resume: true,
88        passes_initial_prompt_via_args: false,
89        ready_probe: ReadyProbe::PromptLine,
90        inject_term_xterm: true,
91    },
92    CliSpec {
93        cli_id: "traex",
94        label: "Traex",
95        bin_candidates: &["traex"],
96        default_cli_args: &["-y"],
97        adopt_command_patterns: &["traex"],
98        supports_resume: true,
99        passes_initial_prompt_via_args: false,
100        ready_probe: ReadyProbe::PromptLine,
101        inject_term_xterm: true,
102    },
103    CliSpec {
104        cli_id: "coco",
105        label: "CoCo",
106        bin_candidates: &["coco"],
107        default_cli_args: &[
108            "--yolo",
109            "--disallowed-tool",
110            "EnterPlanMode",
111            "--disallowed-tool",
112            "ExitPlanMode",
113        ],
114        adopt_command_patterns: &[],
115        supports_resume: true,
116        passes_initial_prompt_via_args: false,
117        ready_probe: ReadyProbe::Text(&["Welcome"]),
118        inject_term_xterm: false,
119    },
120    CliSpec {
121        cli_id: "gemini",
122        label: "Gemini",
123        bin_candidates: &["gemini"],
124        default_cli_args: &["--yolo"],
125        adopt_command_patterns: &["gemini"],
126        supports_resume: false,
127        passes_initial_prompt_via_args: true,
128        ready_probe: ReadyProbe::None,
129        inject_term_xterm: false,
130    },
131    CliSpec {
132        cli_id: "opencode",
133        label: "OpenCode",
134        bin_candidates: &["opencode-cli", "opencode"],
135        default_cli_args: &[],
136        adopt_command_patterns: &["opencode"],
137        supports_resume: false,
138        passes_initial_prompt_via_args: true,
139        ready_probe: ReadyProbe::None,
140        inject_term_xterm: false,
141    },
142    CliSpec {
143        cli_id: "hermes",
144        label: "Hermes",
145        bin_candidates: &["hermes"],
146        default_cli_args: &["--yolo", "--accept-hooks", "--pass-session-id"],
147        adopt_command_patterns: &["hermes"],
148        supports_resume: true,
149        passes_initial_prompt_via_args: false,
150        ready_probe: ReadyProbe::Text(&["Welcome"]),
151        inject_term_xterm: false,
152    },
153    CliSpec {
154        cli_id: "antigravity",
155        label: "Antigravity",
156        bin_candidates: &["agy"],
157        default_cli_args: &["--dangerously-skip-permissions"],
158        adopt_command_patterns: &[],
159        supports_resume: true,
160        passes_initial_prompt_via_args: false,
161        ready_probe: ReadyProbe::Text(&["Welcome"]),
162        inject_term_xterm: false,
163    },
164    CliSpec {
165        cli_id: "kimi",
166        label: "Kimi",
167        bin_candidates: &["kimi"],
168        default_cli_args: &["--yolo"],
169        adopt_command_patterns: &["kimi"],
170        supports_resume: true,
171        passes_initial_prompt_via_args: false,
172        ready_probe: ReadyProbe::Text(&["Welcome to Kimi Code"]),
173        inject_term_xterm: false,
174    },
175    CliSpec {
176        cli_id: "grok",
177        label: "Grok Build",
178        bin_candidates: &["grok"],
179        default_cli_args: &["--always-approve", "--no-alt-screen"],
180        adopt_command_patterns: &["grok"],
181        supports_resume: true,
182        passes_initial_prompt_via_args: false,
183        ready_probe: ReadyProbe::Text(&["Grok"]),
184        inject_term_xterm: false,
185    },
186];
187
188/// Look up the spec for a CLI id. Returns `None` for unknown ids.
189pub fn cli_spec(cli_id: &str) -> Option<&'static CliSpec> {
190    CLI_SPECS.iter().find(|spec| spec.cli_id == cli_id)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn every_spec_has_id_label_and_bins() {
199        for spec in CLI_SPECS {
200            assert!(!spec.cli_id.is_empty(), "empty cli_id");
201            assert!(!spec.label.is_empty(), "{}: empty label", spec.cli_id);
202            assert!(
203                !spec.bin_candidates.is_empty(),
204                "{}: empty bin_candidates",
205                spec.cli_id
206            );
207        }
208    }
209
210    #[test]
211    fn cli_spec_lookup() {
212        assert_eq!(cli_spec("kimi").map(|s| s.label), Some("Kimi"));
213        assert!(cli_spec("no-such-cli").is_none());
214    }
215
216    #[test]
217    fn cli_ids_are_unique() {
218        let mut ids: Vec<_> = CLI_SPECS.iter().map(|s| s.cli_id).collect();
219        ids.sort_unstable();
220        ids.dedup();
221        assert_eq!(ids.len(), CLI_SPECS.len(), "duplicate cli_id in CLI_SPECS");
222    }
223
224    // The assertions below lock the flags to the behavior that was previously
225    // hard-coded at the individual call sites. Update them only when a CLI
226    // genuinely changes capability.
227
228    #[test]
229    fn resume_support_matches_legacy_allow_list() {
230        let resume: Vec<_> = CLI_SPECS
231            .iter()
232            .filter(|s| s.supports_resume)
233            .map(|s| s.cli_id)
234            .collect();
235        assert_eq!(
236            resume,
237            vec![
238                "claude-code",
239                "codex",
240                "traex",
241                "coco",
242                "hermes",
243                "antigravity",
244                "kimi",
245                "grok"
246            ]
247        );
248    }
249
250    #[test]
251    fn initial_prompt_via_args_matches_legacy_list() {
252        let pass: Vec<_> = CLI_SPECS
253            .iter()
254            .filter(|s| s.passes_initial_prompt_via_args)
255            .map(|s| s.cli_id)
256            .collect();
257        assert_eq!(pass, vec!["gemini", "opencode"]);
258    }
259
260    #[test]
261    fn ready_probes_match_expected_list() {
262        let gated: Vec<_> = CLI_SPECS
263            .iter()
264            .filter_map(|s| match s.ready_probe {
265                ReadyProbe::None => None,
266                probe => Some((s.cli_id, probe)),
267            })
268            .collect();
269        assert_eq!(
270            gated,
271            vec![
272                ("claude-code", ReadyProbe::Text(&["Welcome"])),
273                ("codex", ReadyProbe::PromptLine),
274                ("traex", ReadyProbe::PromptLine),
275                ("coco", ReadyProbe::Text(&["Welcome"])),
276                ("hermes", ReadyProbe::Text(&["Welcome"])),
277                ("antigravity", ReadyProbe::Text(&["Welcome"])),
278                ("kimi", ReadyProbe::Text(&["Welcome to Kimi Code"])),
279                ("grok", ReadyProbe::Text(&["Grok"])),
280            ]
281        );
282    }
283
284    #[test]
285    fn term_injection_matches_legacy_list() {
286        let term: Vec<_> = CLI_SPECS
287            .iter()
288            .filter(|s| s.inject_term_xterm)
289            .map(|s| s.cli_id)
290            .collect();
291        assert_eq!(term, vec!["codex", "traex"]);
292    }
293
294    #[test]
295    fn default_args_match_legacy_values() {
296        assert_eq!(
297            cli_spec("claude-code").unwrap().default_cli_args,
298            &[
299                "--dangerously-skip-permissions",
300                "--settings",
301                r#"{"skipDangerousModePermissionPrompt":true,"permissions":{"defaultMode":"bypassPermissions"}}"#,
302                "--disallowed-tools",
303                "EnterPlanMode,ExitPlanMode",
304            ]
305        );
306        assert_eq!(
307            cli_spec("codex").unwrap().default_cli_args,
308            &[
309                "--dangerously-bypass-approvals-and-sandbox",
310                "--no-alt-screen"
311            ]
312        );
313        assert_eq!(cli_spec("traex").unwrap().default_cli_args, &["-y"]);
314        assert_eq!(
315            cli_spec("coco").unwrap().default_cli_args,
316            &[
317                "--yolo",
318                "--disallowed-tool",
319                "EnterPlanMode",
320                "--disallowed-tool",
321                "ExitPlanMode",
322            ]
323        );
324        assert_eq!(cli_spec("gemini").unwrap().default_cli_args, &["--yolo"]);
325        assert!(cli_spec("opencode").unwrap().default_cli_args.is_empty());
326        assert_eq!(
327            cli_spec("hermes").unwrap().default_cli_args,
328            &["--yolo", "--accept-hooks", "--pass-session-id"]
329        );
330        assert_eq!(
331            cli_spec("antigravity").unwrap().default_cli_args,
332            &["--dangerously-skip-permissions"]
333        );
334        assert_eq!(cli_spec("kimi").unwrap().default_cli_args, &["--yolo"]);
335        assert_eq!(
336            cli_spec("grok").unwrap().default_cli_args,
337            &["--always-approve", "--no-alt-screen"]
338        );
339    }
340
341    #[test]
342    fn adopt_patterns_match_legacy_recognition() {
343        let recognized: Vec<_> = CLI_SPECS
344            .iter()
345            .filter(|s| !s.adopt_command_patterns.is_empty())
346            .map(|s| s.cli_id)
347            .collect();
348        assert_eq!(
349            recognized,
350            vec![
351                "claude-code",
352                "codex",
353                "traex",
354                "gemini",
355                "opencode",
356                "hermes",
357                "kimi",
358                "grok"
359            ]
360        );
361    }
362
363    #[test]
364    fn adopt_patterns_do_not_overlap_across_clis() {
365        // A pattern of one CLI must not be a substring of another CLI's
366        // pattern, otherwise CLI_SPECS ordering would decide recognition.
367        for (i, a) in CLI_SPECS.iter().enumerate() {
368            for b in CLI_SPECS.iter().skip(i + 1) {
369                for pa in a.adopt_command_patterns {
370                    for pb in b.adopt_command_patterns {
371                        assert!(
372                            !pa.contains(pb) && !pb.contains(pa),
373                            "adopt patterns overlap: {} ({}) vs {} ({})",
374                            a.cli_id,
375                            pa,
376                            b.cli_id,
377                            pb
378                        );
379                    }
380                }
381            }
382        }
383    }
384}