1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ReadyProbe {
18 None,
21 Text(&'static [&'static str]),
24 PromptLine,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct CliSpec {
33 pub cli_id: &'static str,
35 pub label: &'static str,
37 pub bin_candidates: &'static [&'static str],
39 pub default_cli_args: &'static [&'static str],
41 pub adopt_command_patterns: &'static [&'static str],
45 pub supports_resume: bool,
47 pub passes_initial_prompt_via_args: bool,
50 pub ready_probe: ReadyProbe,
54 pub inject_term_xterm: bool,
57}
58
59pub 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
188pub 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 #[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 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}