Skip to main content

call_coding_clis/
parser.rs

1use std::collections::BTreeMap;
2use std::sync::LazyLock;
3use std::sync::RwLock;
4
5const PERMISSION_MODES: &[&str] = &["safe", "auto", "yolo", "plan"];
6const PROMPT_MODES: &[&str] = &["default", "prepend", "append"];
7const OUTPUT_MODES: &[&str] = &[
8    "text",
9    "stream-text",
10    "json",
11    "stream-json",
12    "formatted",
13    "stream-formatted",
14    "pass-text",
15    "stream-pass-text",
16    "pass-json",
17    "stream-pass-json",
18];
19const KIMI_OUTPUT_MODES: &[&str] = &[
20    "text",
21    "stream-text",
22    "stream-json",
23    "formatted",
24    "stream-formatted",
25    "pass-text",
26    "stream-pass-text",
27    "pass-json",
28    "stream-pass-json",
29];
30const TEXT_OUTPUT_MODES: &[&str] = &["text", "stream-text"];
31
32#[derive(Clone, Debug)]
33pub struct RunnerInfo {
34    pub binary: String,
35    pub extra_args: Vec<String>,
36    pub no_persist_flags: Vec<String>,
37    pub thinking_flags: BTreeMap<i32, Vec<String>>,
38    pub show_thinking_flags: BTreeMap<bool, Vec<String>>,
39    pub default_thinking: Option<i32>,
40    pub yolo_flags: Vec<String>,
41    pub fast_flags: BTreeMap<bool, Vec<String>>,
42    pub provider_flag: String,
43    pub model_flag: String,
44    pub agent_flag: String,
45    pub prompt_flag: String,
46}
47
48#[derive(Clone, Debug, Default)]
49pub struct ParsedArgs {
50    pub runner: Option<String>,
51    pub thinking: Option<i32>,
52    pub show_thinking: Option<bool>,
53    pub print_config: bool,
54    pub sanitize_osc: Option<bool>,
55    pub output_log_path: Option<bool>,
56    pub output_mode: Option<String>,
57    pub forward_unknown_json: bool,
58    pub save_session: bool,
59    pub cleanup_session: bool,
60    pub yolo: bool,
61    pub fast: Option<bool>,
62    pub permission_mode: Option<String>,
63    pub provider: Option<String>,
64    pub model: Option<String>,
65    pub alias: Option<String>,
66    pub prompt: String,
67    pub prompt_supplied: bool,
68    pub timeout_secs: Option<u64>,
69    pub runner_args: Vec<String>,
70}
71
72#[derive(Clone, Debug)]
73pub struct AliasDef {
74    pub runner: Option<String>,
75    pub thinking: Option<i32>,
76    pub show_thinking: Option<bool>,
77    pub sanitize_osc: Option<bool>,
78    pub output_mode: Option<String>,
79    pub provider: Option<String>,
80    pub model: Option<String>,
81    pub agent: Option<String>,
82    pub prompt: Option<String>,
83    pub prompt_mode: Option<String>,
84}
85
86impl Default for AliasDef {
87    fn default() -> Self {
88        Self {
89            runner: None,
90            thinking: None,
91            show_thinking: None,
92            sanitize_osc: None,
93            output_mode: None,
94            provider: None,
95            model: None,
96            agent: None,
97            prompt: None,
98            prompt_mode: None,
99        }
100    }
101}
102
103#[derive(Clone, Debug)]
104pub struct CccConfig {
105    pub default_runner: String,
106    pub default_provider: String,
107    pub default_model: String,
108    pub default_thinking: Option<i32>,
109    pub default_show_thinking: bool,
110    pub default_sanitize_osc: Option<bool>,
111    pub default_output_mode: String,
112    pub update_check: bool,
113    pub auto_update: bool,
114    pub update_interval_hours: u64,
115    pub aliases: BTreeMap<String, AliasDef>,
116    pub abbreviations: BTreeMap<String, String>,
117}
118
119impl Default for CccConfig {
120    fn default() -> Self {
121        Self {
122            default_runner: "oc".to_string(),
123            default_provider: String::new(),
124            default_model: String::new(),
125            default_thinking: Some(1),
126            default_show_thinking: true,
127            default_sanitize_osc: None,
128            default_output_mode: "text".to_string(),
129            update_check: true,
130            auto_update: false,
131            update_interval_hours: 24,
132            aliases: BTreeMap::new(),
133            abbreviations: BTreeMap::new(),
134        }
135    }
136}
137
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct OutputPlan {
140    pub runner_name: String,
141    pub mode: String,
142    pub stream: bool,
143    pub formatted: bool,
144    pub schema: Option<String>,
145    pub argv_flags: Vec<String>,
146    pub warnings: Vec<String>,
147}
148
149pub static RUNNER_REGISTRY: LazyLock<RwLock<BTreeMap<String, RunnerInfo>>> = LazyLock::new(|| {
150    let mut m = BTreeMap::new();
151    let opencode = RunnerInfo {
152        binary: "opencode".into(),
153        default_thinking: None,
154        fast_flags: BTreeMap::new(),
155        extra_args: vec!["--pure".into(), "run".into()],
156        no_persist_flags: vec![],
157        thinking_flags: BTreeMap::new(),
158        show_thinking_flags: {
159            let mut tf = BTreeMap::new();
160            tf.insert(true, vec!["--thinking".into()]);
161            tf
162        },
163        yolo_flags: vec![],
164        provider_flag: String::new(),
165        model_flag: "--model".into(),
166        agent_flag: "--agent".into(),
167        prompt_flag: String::new(),
168    };
169    let claude = RunnerInfo {
170        binary: "claude".into(),
171        default_thinking: None,
172        fast_flags: BTreeMap::new(),
173        extra_args: vec!["-p".into()],
174        no_persist_flags: vec!["--no-session-persistence".into()],
175        thinking_flags: {
176            let mut tf = BTreeMap::new();
177            tf.insert(0, vec!["--thinking".into(), "disabled".into()]);
178            tf.insert(
179                1,
180                vec![
181                    "--thinking".into(),
182                    "enabled".into(),
183                    "--effort".into(),
184                    "low".into(),
185                ],
186            );
187            tf.insert(
188                2,
189                vec![
190                    "--thinking".into(),
191                    "enabled".into(),
192                    "--effort".into(),
193                    "medium".into(),
194                ],
195            );
196            tf.insert(
197                3,
198                vec![
199                    "--thinking".into(),
200                    "enabled".into(),
201                    "--effort".into(),
202                    "high".into(),
203                ],
204            );
205            tf.insert(
206                4,
207                vec![
208                    "--thinking".into(),
209                    "enabled".into(),
210                    "--effort".into(),
211                    "max".into(),
212                ],
213            );
214            tf
215        },
216        show_thinking_flags: {
217            let mut tf = BTreeMap::new();
218            tf.insert(
219                true,
220                vec![
221                    "--thinking".into(),
222                    "enabled".into(),
223                    "--effort".into(),
224                    "low".into(),
225                ],
226            );
227            tf
228        },
229        yolo_flags: vec!["--dangerously-skip-permissions".into()],
230        provider_flag: String::new(),
231        model_flag: "--model".into(),
232        agent_flag: "--agent".into(),
233        prompt_flag: String::new(),
234    };
235    let kimi = RunnerInfo {
236        binary: "kimi".into(),
237        default_thinking: None,
238        fast_flags: BTreeMap::new(),
239        extra_args: vec![],
240        no_persist_flags: vec![],
241        thinking_flags: {
242            let mut tf = BTreeMap::new();
243            tf.insert(0, vec!["--no-thinking".into()]);
244            tf.insert(1, vec!["--thinking".into()]);
245            tf.insert(2, vec!["--thinking".into()]);
246            tf.insert(3, vec!["--thinking".into()]);
247            tf.insert(4, vec!["--thinking".into()]);
248            tf
249        },
250        show_thinking_flags: {
251            let mut tf = BTreeMap::new();
252            tf.insert(true, vec!["--thinking".into()]);
253            tf
254        },
255        yolo_flags: vec!["--yolo".into()],
256        provider_flag: String::new(),
257        model_flag: "--model".into(),
258        agent_flag: "--agent".into(),
259        prompt_flag: "--prompt".into(),
260    };
261    let codex = RunnerInfo {
262        binary: "codex".into(),
263        default_thinking: Some(2),
264        fast_flags: {
265            let mut ff = BTreeMap::new();
266            ff.insert(true, vec!["--enable".into(), "fast_mode".into()]);
267            ff.insert(false, vec!["--disable".into(), "fast_mode".into()]);
268            ff
269        },
270        extra_args: vec!["exec".into()],
271        no_persist_flags: vec!["--ephemeral".into()],
272        thinking_flags: {
273            let mut tf = BTreeMap::new();
274            tf.insert(0, vec!["-c".into(), "model_reasoning_effort=none".into()]);
275            tf.insert(1, vec!["-c".into(), "model_reasoning_effort=low".into()]);
276            tf.insert(2, vec!["-c".into(), "model_reasoning_effort=medium".into()]);
277            tf.insert(3, vec!["-c".into(), "model_reasoning_effort=high".into()]);
278            tf.insert(4, vec!["-c".into(), "model_reasoning_effort=xhigh".into()]);
279            tf.insert(5, vec!["-c".into(), "model_reasoning_effort=max".into()]);
280            tf
281        },
282        show_thinking_flags: BTreeMap::new(),
283        yolo_flags: vec!["--dangerously-bypass-approvals-and-sandbox".into()],
284        provider_flag: String::new(),
285        model_flag: "--model".into(),
286        agent_flag: String::new(),
287        prompt_flag: String::new(),
288    };
289    let roocode = RunnerInfo {
290        binary: "roocode".into(),
291        default_thinking: None,
292        fast_flags: BTreeMap::new(),
293        extra_args: vec![],
294        no_persist_flags: vec![],
295        thinking_flags: BTreeMap::new(),
296        show_thinking_flags: BTreeMap::new(),
297        yolo_flags: vec![],
298        provider_flag: String::new(),
299        model_flag: String::new(),
300        agent_flag: String::new(),
301        prompt_flag: String::new(),
302    };
303    let crush = RunnerInfo {
304        binary: "crush".into(),
305        default_thinking: None,
306        fast_flags: BTreeMap::new(),
307        extra_args: vec!["run".into()],
308        no_persist_flags: vec![],
309        thinking_flags: BTreeMap::new(),
310        show_thinking_flags: BTreeMap::new(),
311        yolo_flags: vec![],
312        provider_flag: String::new(),
313        model_flag: String::new(),
314        agent_flag: String::new(),
315        prompt_flag: String::new(),
316    };
317    let cursor = RunnerInfo {
318        binary: "cursor-agent".into(),
319        default_thinking: None,
320        fast_flags: BTreeMap::new(),
321        extra_args: vec!["--print".into(), "--trust".into()],
322        no_persist_flags: vec![],
323        thinking_flags: BTreeMap::new(),
324        show_thinking_flags: BTreeMap::new(),
325        yolo_flags: vec!["--yolo".into()],
326        provider_flag: String::new(),
327        model_flag: "--model".into(),
328        agent_flag: String::new(),
329        prompt_flag: String::new(),
330    };
331    let gemini = RunnerInfo {
332        binary: "gemini".into(),
333        default_thinking: None,
334        fast_flags: BTreeMap::new(),
335        extra_args: vec![],
336        no_persist_flags: vec![],
337        thinking_flags: BTreeMap::new(),
338        show_thinking_flags: BTreeMap::new(),
339        yolo_flags: vec![],
340        provider_flag: String::new(),
341        model_flag: "--model".into(),
342        agent_flag: String::new(),
343        prompt_flag: "--prompt".into(),
344    };
345    let pi = RunnerInfo {
346        binary: "pi".into(),
347        default_thinking: None,
348        fast_flags: BTreeMap::new(),
349        extra_args: vec!["-p".into()],
350        no_persist_flags: vec!["--no-session".into()],
351        thinking_flags: {
352            let mut tf = BTreeMap::new();
353            tf.insert(0, vec!["--thinking".into(), "off".into()]);
354            tf.insert(1, vec!["--thinking".into(), "low".into()]);
355            tf.insert(2, vec!["--thinking".into(), "medium".into()]);
356            tf.insert(3, vec!["--thinking".into(), "high".into()]);
357            tf.insert(4, vec!["--thinking".into(), "xhigh".into()]);
358            tf
359        },
360        show_thinking_flags: {
361            let mut tf = BTreeMap::new();
362            tf.insert(true, vec!["--thinking".into(), "low".into()]);
363            tf
364        },
365        yolo_flags: vec![],
366        provider_flag: "--provider".into(),
367        model_flag: "--model".into(),
368        agent_flag: String::new(),
369        prompt_flag: String::new(),
370    };
371    let grok = RunnerInfo {
372        binary: "grok".into(),
373        default_thinking: None,
374        fast_flags: BTreeMap::new(),
375        extra_args: vec!["--no-auto-update".into()],
376        no_persist_flags: vec![],
377        thinking_flags: {
378            let mut tf = BTreeMap::new();
379            tf.insert(0, vec!["--reasoning-effort".into(), "minimal".into()]);
380            tf.insert(1, vec!["--reasoning-effort".into(), "low".into()]);
381            tf.insert(2, vec!["--reasoning-effort".into(), "medium".into()]);
382            tf.insert(3, vec!["--reasoning-effort".into(), "high".into()]);
383            tf.insert(4, vec!["--reasoning-effort".into(), "xhigh".into()]);
384            tf
385        },
386        show_thinking_flags: {
387            let mut tf = BTreeMap::new();
388            tf.insert(true, vec!["--reasoning-effort".into(), "low".into()]);
389            tf
390        },
391        yolo_flags: vec!["--always-approve".into()],
392        provider_flag: String::new(),
393        model_flag: "--model".into(),
394        agent_flag: "--agent".into(),
395        prompt_flag: "-p".into(),
396    };
397
398    let claude_clone = claude.clone();
399    let kimi_clone = kimi.clone();
400    let opencode_clone = opencode.clone();
401
402    let codex_clone = codex.clone();
403    let roocode_clone = roocode.clone();
404    let crush_clone = crush.clone();
405    let cursor_clone = cursor.clone();
406    let gemini_clone = gemini.clone();
407    let pi_clone = pi.clone();
408    let grok_clone = grok.clone();
409
410    m.insert("opencode".into(), opencode);
411    m.insert("claude".into(), claude);
412    m.insert("kimi".into(), kimi);
413    m.insert("codex".into(), codex);
414    m.insert("roocode".into(), roocode);
415    m.insert("crush".into(), crush);
416    m.insert("cursor".into(), cursor);
417    m.insert("gemini".into(), gemini);
418    m.insert("pi".into(), pi);
419    m.insert("grok".into(), grok);
420
421    m.insert("oc".into(), opencode_clone);
422    m.insert("cc".into(), claude_clone.clone());
423    m.insert("c".into(), codex_clone.clone());
424    m.insert("cx".into(), codex_clone);
425    m.insert("k".into(), kimi_clone);
426    m.insert("rc".into(), roocode_clone.clone());
427    m.insert("cr".into(), crush_clone);
428    m.insert("cu".into(), cursor_clone);
429    m.insert("g".into(), gemini_clone);
430    m.insert("p".into(), pi_clone);
431    m.insert("gb".into(), grok_clone);
432
433    RwLock::new(m)
434});
435
436static RUNNER_SELECTOR_STRS: &[&str] = &[
437    "oc", "cc", "c", "cx", "k", "rc", "cr", "cu", "g", "p", "pi", "gb", "grok", "codex", "claude",
438    "opencode", "kimi", "roocode", "crush", "cursor", "gemini",
439];
440
441fn is_runner_selector(s: &str) -> bool {
442    RUNNER_SELECTOR_STRS
443        .iter()
444        .any(|&sel| sel.eq_ignore_ascii_case(s))
445}
446
447fn parse_thinking(s: &str) -> Option<i32> {
448    let rest = s.strip_prefix('+')?;
449    match rest.to_ascii_lowercase().as_str() {
450        "0" | "none" => Some(0),
451        "1" | "low" => Some(1),
452        "2" | "med" | "mid" | "medium" => Some(2),
453        "3" | "high" => Some(3),
454        "4" | "xhigh" => Some(4),
455        "5" | "max" => Some(5),
456        _ => None,
457    }
458}
459
460fn parse_provider_model(s: &str) -> Option<(&str, &str)> {
461    let rest = s.strip_prefix(':')?;
462    let parts: Vec<&str> = rest.splitn(2, ':').collect();
463    if parts.len() == 2 {
464        Some((parts[0], parts[1]))
465    } else {
466        None
467    }
468}
469
470fn parse_model_only(s: &str) -> Option<&str> {
471    let rest = s.strip_prefix(':')?;
472    if rest.contains(':') {
473        None
474    } else {
475        Some(rest)
476    }
477}
478
479fn parse_alias(s: &str) -> Option<&str> {
480    let rest = s.strip_prefix('@')?;
481    if rest
482        .chars()
483        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
484    {
485        Some(rest)
486    } else {
487        None
488    }
489}
490
491fn parse_output_mode(s: &str) -> Option<String> {
492    let mode = s.to_ascii_lowercase();
493    if OUTPUT_MODES.iter().any(|known| *known == mode) {
494        Some(mode)
495    } else {
496        None
497    }
498}
499
500fn parse_output_mode_sugar(s: &str) -> Option<String> {
501    match s.to_ascii_lowercase().as_str() {
502        ".text" => Some("text".to_string()),
503        "..text" => Some("stream-text".to_string()),
504        ".json" => Some("json".to_string()),
505        "..json" => Some("stream-json".to_string()),
506        ".fmt" => Some("formatted".to_string()),
507        "..fmt" => Some("stream-formatted".to_string()),
508        ".pt" => Some("pass-text".to_string()),
509        "..pt" => Some("stream-pass-text".to_string()),
510        ".pj" => Some("pass-json".to_string()),
511        "..pj" => Some("stream-pass-json".to_string()),
512        _ => None,
513    }
514}
515
516pub fn parse_args(argv: &[String]) -> ParsedArgs {
517    let mut parsed = ParsedArgs::default();
518    let mut positional: Vec<String> = Vec::new();
519    let mut force_prompt = false;
520    let mut index = 0;
521
522    while index < argv.len() {
523        let token = &argv[index];
524        if force_prompt || !positional.is_empty() {
525            positional.push(token.clone());
526        } else if token == "--" {
527            force_prompt = true;
528            index += 1;
529            continue;
530        } else if is_runner_selector(token) {
531            parsed.runner = Some(token.to_lowercase());
532        } else if let Some(level) = parse_thinking(token) {
533            parsed.thinking = Some(level);
534        } else if token == "--show-thinking" || token == "--no-show-thinking" {
535            parsed.show_thinking = Some(token == "--show-thinking");
536        } else if token == "--print-config" {
537            parsed.print_config = true;
538        } else if token == "--sanitize-osc" || token == "--no-sanitize-osc" {
539            parsed.sanitize_osc = Some(token == "--sanitize-osc");
540        } else if token == "--output-log-path" || token == "--no-output-log-path" {
541            parsed.output_log_path = Some(token == "--output-log-path");
542        } else if token == "--output-mode" || token == "-o" {
543            if index + 1 >= argv.len() {
544                parsed.output_mode = Some(String::new());
545            } else {
546                parsed.output_mode = Some(argv[index + 1].to_ascii_lowercase());
547                index += 1;
548            }
549        } else if token == "--forward-unknown-json" {
550            parsed.forward_unknown_json = true;
551        } else if token == "--save-session" {
552            parsed.save_session = true;
553        } else if token == "--cleanup-session" {
554            parsed.cleanup_session = true;
555        } else if token == "--timeout-secs" {
556            if index + 1 >= argv.len() {
557                parsed.timeout_secs = Some(0);
558            } else {
559                let raw = &argv[index + 1];
560                parsed.timeout_secs = match raw.parse::<u64>() {
561                    Ok(value) if value > 0 => Some(value),
562                    _ => Some(0),
563                };
564                index += 1;
565            }
566        } else if token == "--runner-arg" {
567            if index + 1 >= argv.len() {
568                parsed.runner_args.push(String::new());
569            } else {
570                parsed.runner_args.push(argv[index + 1].clone());
571                index += 1;
572            }
573        } else if let Some(mode) = parse_output_mode_sugar(token) {
574            parsed.output_mode = Some(mode);
575        } else if token == "--fast" || token == "--no-fast" {
576            parsed.fast = Some(token == "--fast");
577        } else if token == "--yolo" || token == "-y" {
578            parsed.yolo = true;
579            parsed.permission_mode = Some("yolo".to_string());
580        } else if token == "--permission-mode" {
581            if index + 1 >= argv.len() {
582                parsed.permission_mode = Some(String::new());
583            } else {
584                let mode = argv[index + 1].to_lowercase();
585                parsed.yolo = mode == "yolo";
586                parsed.permission_mode = Some(mode);
587                index += 1;
588            }
589        } else if let Some((provider, model)) = parse_provider_model(token) {
590            parsed.provider = Some(provider.to_string());
591            parsed.model = Some(model.to_string());
592        } else if let Some(model) = parse_model_only(token) {
593            parsed.model = Some(model.to_string());
594        } else if let Some(alias_name) = parse_alias(token) {
595            parsed.alias = Some(alias_name.to_string());
596        } else {
597            positional.push(token.clone());
598        }
599        index += 1;
600    }
601
602    parsed.prompt = positional.join(" ");
603    parsed.prompt_supplied = !positional.is_empty();
604    parsed
605}
606
607pub fn resolve_command(
608    parsed: &ParsedArgs,
609    config: Option<&CccConfig>,
610) -> Result<(Vec<String>, BTreeMap<String, String>, Vec<String>), String> {
611    let config = config.cloned().unwrap_or_default();
612    let registry = RUNNER_REGISTRY.read().unwrap();
613    let mut warnings = Vec::new();
614
615    let (effective_runner_name, effective_runner, alias_def) =
616        resolve_effective_runner(parsed, &config, &registry)
617            .ok_or_else(|| "no runner found".to_string())?;
618    if parsed.save_session && parsed.cleanup_session {
619        return Err("--save-session and --cleanup-session are mutually exclusive".to_string());
620    }
621    if parsed.timeout_secs == Some(0) {
622        return Err("--timeout-secs must be a positive integer".to_string());
623    }
624    if parsed.runner_args.iter().any(|arg| arg.is_empty()) {
625        return Err("--runner-arg requires a non-empty value".to_string());
626    }
627
628    let mut argv: Vec<String> = vec![effective_runner.binary.clone()];
629    argv.extend(effective_runner.extra_args.iter().cloned());
630    warnings.extend(session_persistence_warnings(
631        parsed,
632        &effective_runner_name,
633        effective_runner,
634    ));
635    let output_plan = resolve_output_plan(parsed, Some(&config)).map_err(str::to_string)?;
636    warnings.extend(output_plan.warnings.clone());
637    argv.extend(output_plan.argv_flags.iter().cloned());
638
639    let effective_thinking = parsed
640        .thinking
641        .or_else(|| alias_def.and_then(|a| a.thinking))
642        .or(effective_runner.default_thinking)
643        .or(config.default_thinking);
644
645    let mut thinking_flags_applied = false;
646    if let Some(level) = effective_thinking {
647        if !effective_runner.thinking_flags.is_empty() {
648            let mut lookup = level;
649            if !effective_runner.thinking_flags.contains_key(&lookup) {
650                if let Some(&top) = effective_runner.thinking_flags.keys().max() {
651                    if lookup > top {
652                        lookup = top;
653                    }
654                }
655            }
656            if let Some(flags) = effective_runner.thinking_flags.get(&lookup) {
657                argv.extend(flags.iter().cloned());
658                thinking_flags_applied = true;
659            }
660        }
661    }
662
663    let effective_show_thinking = resolve_show_thinking(parsed, Some(&config));
664
665    if !thinking_flags_applied && effective_show_thinking {
666        if let Some(flags) = effective_runner.show_thinking_flags.get(&true) {
667            argv.extend(flags.iter().cloned());
668        }
669    }
670
671    let effective_provider: Option<String> = parsed
672        .provider
673        .clone()
674        .or_else(|| alias_def.and_then(|a| a.provider.clone()))
675        .or_else(|| {
676            if config.default_provider.is_empty() {
677                None
678            } else {
679                Some(config.default_provider.clone())
680            }
681        });
682
683    let effective_model: Option<String> = parsed
684        .model
685        .clone()
686        .or_else(|| alias_def.and_then(|a| a.model.clone()))
687        .or_else(|| {
688            if config.default_model.is_empty() {
689                None
690            } else {
691                Some(config.default_model.clone())
692            }
693        });
694
695    let mut env_overrides = BTreeMap::new();
696    if let Some(ref provider) = effective_provider {
697        env_overrides.insert("CCC_PROVIDER".to_string(), provider.clone());
698    }
699    if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
700        env_overrides.insert(
701            "OPENCODE_DISABLE_TERMINAL_TITLE".to_string(),
702            "true".to_string(),
703        );
704    }
705
706    if let Some(ref provider) = effective_provider {
707        if !effective_runner.provider_flag.is_empty() {
708            argv.push(effective_runner.provider_flag.clone());
709            argv.push(provider.clone());
710        }
711    }
712
713    if let Some(ref model) = effective_model {
714        if !effective_runner.model_flag.is_empty() {
715            argv.push(effective_runner.model_flag.clone());
716            if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
717                if let Some(ref provider) = effective_provider {
718                    argv.push(format!("{}/{}", provider, model));
719                } else {
720                    argv.push(model.clone());
721                }
722            } else {
723                argv.push(model.clone());
724            }
725        }
726    }
727
728    let effective_agent = if let Some(alias_def) = alias_def {
729        alias_def.agent.clone()
730    } else if unresolved_alias_runner_name(parsed, &config, &registry).is_some() {
731        None
732    } else {
733        parsed.alias.clone()
734    };
735
736    if let Some(agent) = effective_agent {
737        if effective_runner.agent_flag.is_empty() {
738            warnings.push(format!(
739                "warning: runner \"{}\" does not support agents; ignoring @{}",
740                effective_runner_name, agent
741            ));
742        } else {
743            argv.push(effective_runner.agent_flag.clone());
744            argv.push(agent);
745        }
746    }
747
748    let effective_permission_mode = parsed.permission_mode.clone().or_else(|| {
749        if parsed.yolo {
750            Some("yolo".to_string())
751        } else {
752            None
753        }
754    });
755    if let Some(ref mode) = effective_permission_mode {
756        if mode.is_empty() {
757            return Err("permission mode requires one of: safe, auto, yolo, plan".to_string());
758        }
759        if !PERMISSION_MODES.iter().any(|known| known == mode) {
760            return Err("permission mode must be one of: safe, auto, yolo, plan".to_string());
761        }
762    }
763
764    if matches!(effective_permission_mode.as_deref(), Some("safe")) {
765        if matches!(effective_runner_name.as_str(), "cc" | "claude") {
766            argv.push("--permission-mode".to_string());
767            argv.push("default".to_string());
768        } else if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
769            env_overrides.insert(
770                "OPENCODE_CONFIG_CONTENT".to_string(),
771                "{\"permission\":\"ask\"}".to_string(),
772            );
773        } else if matches!(effective_runner_name.as_str(), "cu" | "cursor") {
774            argv.push("--sandbox".to_string());
775            argv.push("enabled".to_string());
776        } else if matches!(effective_runner_name.as_str(), "g" | "gemini") {
777            argv.push("--approval-mode".to_string());
778            argv.push("default".to_string());
779            argv.push("--sandbox".to_string());
780        } else if matches!(effective_runner_name.as_str(), "gb" | "grok") {
781            argv.push("--permission-mode".to_string());
782            argv.push("default".to_string());
783        } else if matches!(effective_runner_name.as_str(), "rc" | "roocode") {
784            warnings.push(
785                "warning: runner \"roocode\" safe mode is unverified; leaving default permissions unchanged"
786                    .to_string(),
787            );
788        }
789    } else if matches!(effective_permission_mode.as_deref(), Some("auto")) {
790        if matches!(effective_runner_name.as_str(), "cc" | "claude") {
791            argv.push("--permission-mode".to_string());
792            argv.push("auto".to_string());
793        } else if matches!(effective_runner_name.as_str(), "c" | "cx" | "codex") {
794            argv.push("--full-auto".to_string());
795        } else if matches!(effective_runner_name.as_str(), "g" | "gemini") {
796            argv.push("--approval-mode".to_string());
797            argv.push("auto_edit".to_string());
798        } else if matches!(effective_runner_name.as_str(), "gb" | "grok") {
799            argv.push("--permission-mode".to_string());
800            argv.push("auto".to_string());
801        } else {
802            warnings.push(format!(
803                "warning: runner \"{}\" does not support permission mode \"auto\"; ignoring it",
804                effective_runner_name
805            ));
806        }
807    } else if matches!(effective_permission_mode.as_deref(), Some("yolo")) {
808        if matches!(effective_runner_name.as_str(), "g" | "gemini") {
809            argv.push("--approval-mode".to_string());
810            argv.push("yolo".to_string());
811        } else if !effective_runner.yolo_flags.is_empty() {
812            argv.extend(effective_runner.yolo_flags.iter().cloned());
813        } else if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
814            env_overrides.insert(
815                "OPENCODE_CONFIG_CONTENT".to_string(),
816                "{\"permission\":\"allow\"}".to_string(),
817            );
818        } else if matches!(effective_runner_name.as_str(), "cr" | "crush") {
819            warnings.push(
820                "warning: runner \"crush\" does not support yolo mode in non-interactive run mode; ignoring --yolo".to_string(),
821            );
822        } else if matches!(effective_runner_name.as_str(), "rc" | "roocode") {
823            warnings.push(
824                "warning: runner \"roocode\" yolo mode is unverified; ignoring --yolo".to_string(),
825            );
826        }
827    } else if matches!(effective_permission_mode.as_deref(), Some("plan")) {
828        if matches!(effective_runner_name.as_str(), "cc" | "claude") {
829            argv.push("--permission-mode".to_string());
830            argv.push("plan".to_string());
831        } else if matches!(effective_runner_name.as_str(), "k" | "kimi") {
832            argv.push("--plan".to_string());
833        } else if matches!(effective_runner_name.as_str(), "cu" | "cursor") {
834            argv.push("--mode".to_string());
835            argv.push("plan".to_string());
836        } else if matches!(effective_runner_name.as_str(), "g" | "gemini") {
837            argv.push("--approval-mode".to_string());
838            argv.push("plan".to_string());
839        } else if matches!(effective_runner_name.as_str(), "gb" | "grok") {
840            argv.push("--permission-mode".to_string());
841            argv.push("plan".to_string());
842        } else {
843            warnings.push(format!(
844                "warning: runner \"{}\" does not support permission mode \"plan\"; ignoring it",
845                effective_runner_name
846            ));
847        }
848    }
849
850    if let Some(fast) = parsed.fast {
851        if let Some(flags) = effective_runner.fast_flags.get(&fast) {
852            argv.extend(flags.iter().cloned());
853        } else {
854            let display = canonical_runner_name(&effective_runner_name, effective_runner);
855            let flag = if fast { "--fast" } else { "--no-fast" };
856            warnings.push(format!(
857                "warning: runner \"{display}\" does not support fast mode; ignoring {flag}"
858            ));
859        }
860    }
861
862    if !parsed.save_session {
863        argv.extend(effective_runner.no_persist_flags.iter().cloned());
864    }
865    argv.extend(parsed.runner_args.iter().cloned());
866
867    let prompt = resolve_prompt(parsed, alias_def)?;
868    if effective_runner.prompt_flag.is_empty() {
869        argv.push(prompt);
870    } else {
871        argv.push(effective_runner.prompt_flag.clone());
872        argv.push(prompt);
873    }
874
875    Ok((argv, env_overrides, warnings))
876}
877
878pub(crate) fn resolve_command_warnings(
879    parsed: &ParsedArgs,
880    config: Option<&CccConfig>,
881) -> Result<Vec<String>, String> {
882    resolve_command(parsed, config).map(|(_, _, warnings)| warnings)
883}
884
885fn canonical_runner_name(effective_runner_name: &str, info: &RunnerInfo) -> String {
886    match effective_runner_name {
887        "oc" | "opencode" => "opencode".to_string(),
888        "cc" | "claude" => "claude".to_string(),
889        "c" | "cx" | "codex" => "codex".to_string(),
890        "k" | "kimi" => "kimi".to_string(),
891        "cr" | "crush" => "crush".to_string(),
892        "rc" | "roocode" => "roocode".to_string(),
893        "cu" | "cursor" => "cursor".to_string(),
894        "g" | "gemini" => "gemini".to_string(),
895        "p" | "pi" => "pi".to_string(),
896        "gb" | "grok" => "grok".to_string(),
897        _ => info.binary.clone(),
898    }
899}
900
901fn session_persistence_warnings(
902    parsed: &ParsedArgs,
903    effective_runner_name: &str,
904    info: &RunnerInfo,
905) -> Vec<String> {
906    if parsed.save_session || !info.no_persist_flags.is_empty() {
907        return Vec::new();
908    }
909    let display = canonical_runner_name(effective_runner_name, info);
910    if parsed.cleanup_session {
911        if display == "opencode" || display == "kimi" || display == "grok" {
912            return Vec::new();
913        }
914        return vec![format!(
915            "warning: runner \"{display}\" does not support automatic session cleanup; pass --save-session to allow saved sessions explicitly"
916        )];
917    }
918    Vec::new()
919}
920
921fn resolve_prompt(parsed: &ParsedArgs, alias_def: Option<&AliasDef>) -> Result<String, String> {
922    let user_prompt = parsed.prompt.trim();
923    let alias_prompt = alias_def
924        .and_then(|alias| alias.prompt.as_deref())
925        .map(str::trim)
926        .unwrap_or("");
927    let prompt_mode = alias_def
928        .and_then(|alias| alias.prompt_mode.as_deref())
929        .map(str::trim)
930        .filter(|mode| !mode.is_empty())
931        .unwrap_or("default");
932
933    if !PROMPT_MODES.iter().any(|known| *known == prompt_mode) {
934        return Err("prompt_mode must be one of: default, prepend, append".to_string());
935    }
936
937    if prompt_mode == "default" {
938        let prompt = if user_prompt.is_empty() {
939            alias_prompt
940        } else {
941            user_prompt
942        };
943        if prompt.is_empty() {
944            return Err("prompt must not be empty".to_string());
945        }
946        return Ok(prompt.to_string());
947    }
948
949    if !parsed.prompt_supplied {
950        return Err(format!(
951            "prompt_mode {prompt_mode} requires an explicit prompt argument"
952        ));
953    }
954    if alias_prompt.is_empty() {
955        let alias_name = parsed.alias.as_deref().unwrap_or("<alias>");
956        return Err(format!(
957            "prompt_mode {prompt_mode} requires aliases.{alias_name}.prompt"
958        ));
959    }
960
961    let mut parts = vec![alias_prompt, user_prompt];
962    if prompt_mode == "append" {
963        parts.reverse();
964    }
965    Ok(parts
966        .into_iter()
967        .filter(|part| !part.is_empty())
968        .collect::<Vec<_>>()
969        .join("\n"))
970}
971
972fn resolve_alias_def<'a>(parsed: &'a ParsedArgs, config: &'a CccConfig) -> Option<&'a AliasDef> {
973    parsed
974        .alias
975        .as_ref()
976        .and_then(|alias| config.aliases.get(alias))
977}
978
979fn unresolved_alias_runner_name(
980    parsed: &ParsedArgs,
981    config: &CccConfig,
982    registry: &BTreeMap<String, RunnerInfo>,
983) -> Option<String> {
984    if parsed.runner.is_some() {
985        return None;
986    }
987    let alias = parsed.alias.as_ref()?;
988    if config.aliases.contains_key(alias) {
989        return None;
990    }
991    let exact = resolve_runner_name(Some(alias), config);
992    if registry.contains_key(&exact) {
993        return Some(exact);
994    }
995    let lower_alias = alias.to_ascii_lowercase();
996    let lower = resolve_runner_name(Some(&lower_alias), config);
997    registry.contains_key(&lower).then_some(lower)
998}
999
1000fn resolve_runner_name(parsed_runner: Option<&str>, config: &CccConfig) -> String {
1001    let runner_name = parsed_runner.unwrap_or(&config.default_runner);
1002    config
1003        .abbreviations
1004        .get(runner_name)
1005        .cloned()
1006        .unwrap_or_else(|| runner_name.to_string())
1007}
1008
1009fn resolve_effective_runner<'a>(
1010    parsed: &'a ParsedArgs,
1011    config: &'a CccConfig,
1012    registry: &'a BTreeMap<String, RunnerInfo>,
1013) -> Option<(String, &'a RunnerInfo, Option<&'a AliasDef>)> {
1014    let alias_def = resolve_alias_def(parsed, config);
1015    let mut runner_name = resolve_runner_name(parsed.runner.as_deref(), config);
1016    if parsed.runner.is_none() {
1017        if let Some(alias_runner) = alias_def.and_then(|alias| alias.runner.as_deref()) {
1018            runner_name = resolve_runner_name(Some(alias_runner), config);
1019        } else if let Some(alias_runner) = unresolved_alias_runner_name(parsed, config, registry) {
1020            runner_name = alias_runner;
1021        }
1022    }
1023    let info = registry
1024        .get(&runner_name)
1025        .or_else(|| registry.get(&config.default_runner))
1026        .or_else(|| registry.get("opencode"))?;
1027    Some((runner_name, info, alias_def))
1028}
1029
1030fn supported_output_modes(runner_name: &str) -> &'static [&'static str] {
1031    match runner_name {
1032        "cc" | "claude" | "oc" | "opencode" | "c" | "cx" | "codex" | "cu" | "cursor" => {
1033            OUTPUT_MODES
1034        }
1035        "k" | "kimi" => KIMI_OUTPUT_MODES,
1036        "g" | "gemini" | "p" | "pi" | "gb" | "grok" => &[
1037            "text",
1038            "stream-text",
1039            "json",
1040            "stream-json",
1041            "formatted",
1042            "stream-formatted",
1043            "pass-text",
1044            "stream-pass-text",
1045            "pass-json",
1046            "stream-pass-json",
1047        ],
1048        _ => TEXT_OUTPUT_MODES,
1049    }
1050}
1051
1052fn fallback_output_mode(supported: &[&str]) -> &'static str {
1053    if supported.iter().any(|mode| *mode == "text") {
1054        "text"
1055    } else {
1056        "stream-text"
1057    }
1058}
1059
1060pub fn resolve_output_mode(
1061    parsed: &ParsedArgs,
1062    config: Option<&CccConfig>,
1063) -> Result<String, &'static str> {
1064    resolve_output_mode_with_source(parsed, config).map(|(mode, _)| mode)
1065}
1066
1067fn resolve_output_mode_with_source(
1068    parsed: &ParsedArgs,
1069    config: Option<&CccConfig>,
1070) -> Result<(String, &'static str), &'static str> {
1071    let config = config.cloned().unwrap_or_default();
1072    let alias_def = resolve_alias_def(parsed, &config);
1073    let mut mode = parsed.output_mode.clone();
1074    let mut source = "argument";
1075    if mode.is_none() {
1076        mode = alias_def.and_then(|alias| alias.output_mode.clone());
1077        if mode.is_some() {
1078            source = "alias";
1079        }
1080    }
1081    let mode = mode.unwrap_or_else(|| {
1082        source = "configured";
1083        config.default_output_mode.clone()
1084    });
1085    if mode.is_empty() {
1086        return Err(
1087            "output mode requires one of: text, stream-text, json, stream-json, formatted, stream-formatted, pass-text, pt, stream-pass-text, stream-pt, pass-json, pj, stream-pass-json, stream-pj",
1088        );
1089    }
1090    match parse_output_mode(&mode) {
1091        Some(mode) => Ok((mode, source)),
1092        None => Err(
1093            "output mode must be one of: text, stream-text, json, stream-json, formatted, stream-formatted, pass-text, pt, stream-pass-text, stream-pt, pass-json, pj, stream-pass-json, stream-pj",
1094        ),
1095    }
1096}
1097
1098pub fn resolve_show_thinking(parsed: &ParsedArgs, config: Option<&CccConfig>) -> bool {
1099    let config = config.cloned().unwrap_or_default();
1100    parsed
1101        .show_thinking
1102        .or_else(|| resolve_alias_def(parsed, &config).and_then(|alias| alias.show_thinking))
1103        .unwrap_or(config.default_show_thinking)
1104}
1105
1106pub fn resolve_sanitize_osc(parsed: &ParsedArgs, config: Option<&CccConfig>) -> bool {
1107    let config = config.cloned().unwrap_or_default();
1108    parsed
1109        .sanitize_osc
1110        .or_else(|| resolve_alias_def(parsed, &config).and_then(|alias| alias.sanitize_osc))
1111        .or(config.default_sanitize_osc)
1112        .unwrap_or_else(|| {
1113            resolve_output_plan(parsed, Some(&config))
1114                .map(|plan| plan.mode.contains("formatted"))
1115                .unwrap_or(false)
1116        })
1117}
1118
1119pub fn resolve_output_plan(
1120    parsed: &ParsedArgs,
1121    config: Option<&CccConfig>,
1122) -> Result<OutputPlan, &'static str> {
1123    let config = config.cloned().unwrap_or_default();
1124    let registry = RUNNER_REGISTRY.read().unwrap();
1125    let (runner_name, info, _) =
1126        resolve_effective_runner(parsed, &config, &registry).ok_or("no runner found")?;
1127    let (mut mode, mode_source) = resolve_output_mode_with_source(parsed, Some(&config))?;
1128    let supported = supported_output_modes(&runner_name);
1129    let mut warnings = Vec::new();
1130    if !supported.iter().any(|candidate| *candidate == mode) {
1131        if mode_source == "argument" {
1132            return Err("gemini runner does not support requested output mode");
1133        }
1134        let fallback = fallback_output_mode(supported);
1135        warnings.push(format!(
1136            "warning: runner \"{}\" does not support {} output mode \"{}\"; falling back to \"{}\"",
1137            canonical_runner_name(&runner_name, info),
1138            mode_source,
1139            mode,
1140            fallback,
1141        ));
1142        mode = fallback.to_string();
1143    }
1144
1145    if matches!(mode.as_str(), "text" | "stream-text") {
1146        return Ok(OutputPlan {
1147            runner_name,
1148            mode: mode.clone(),
1149            stream: mode.starts_with("stream-"),
1150            formatted: false,
1151            schema: None,
1152            argv_flags: Vec::new(),
1153            warnings,
1154        });
1155    }
1156
1157    if matches!(
1158        mode.as_str(),
1159        "pass-text" | "stream-pass-text" | "pass-json" | "stream-pass-json"
1160    ) {
1161        let argv_flags = if mode == "pass-json" {
1162            if matches!(runner_name.as_str(), "cc" | "claude") {
1163                vec!["--output-format".into(), "json".into()]
1164            } else if matches!(runner_name.as_str(), "cu" | "cursor") {
1165                vec!["--output-format".into(), "json".into()]
1166            } else if matches!(runner_name.as_str(), "g" | "gemini") {
1167                vec!["--output-format".into(), "json".into()]
1168            } else if matches!(runner_name.as_str(), "gb" | "grok") {
1169                vec!["--output-format".into(), "json".into()]
1170            } else if matches!(runner_name.as_str(), "c" | "cx" | "codex") {
1171                vec!["--json".into()]
1172            } else if matches!(runner_name.as_str(), "k" | "kimi") {
1173                vec![
1174                    "--print".into(),
1175                    "--output-format".into(),
1176                    "stream-json".into(),
1177                ]
1178            } else if matches!(runner_name.as_str(), "oc" | "opencode") {
1179                vec!["--format".into(), "json".into()]
1180            } else if matches!(runner_name.as_str(), "p" | "pi") {
1181                vec!["--mode".into(), "json".into()]
1182            } else {
1183                vec![]
1184            }
1185        } else if mode == "stream-pass-json" {
1186            if matches!(runner_name.as_str(), "cc" | "claude") {
1187                vec![
1188                    "--verbose".into(),
1189                    "--output-format".into(),
1190                    "stream-json".into(),
1191                ]
1192            } else if matches!(runner_name.as_str(), "cu" | "cursor") {
1193                vec!["--output-format".into(), "stream-json".into()]
1194            } else if matches!(runner_name.as_str(), "g" | "gemini") {
1195                vec!["--output-format".into(), "stream-json".into()]
1196            } else if matches!(runner_name.as_str(), "gb" | "grok") {
1197                vec!["--output-format".into(), "streaming-json".into()]
1198            } else if matches!(runner_name.as_str(), "c" | "cx" | "codex") {
1199                vec!["--json".into()]
1200            } else if matches!(runner_name.as_str(), "k" | "kimi") {
1201                vec![
1202                    "--print".into(),
1203                    "--output-format".into(),
1204                    "stream-json".into(),
1205                ]
1206            } else if matches!(runner_name.as_str(), "oc" | "opencode") {
1207                vec!["--format".into(), "json".into()]
1208            } else if matches!(runner_name.as_str(), "p" | "pi") {
1209                vec!["--mode".into(), "json".into()]
1210            } else {
1211                vec![]
1212            }
1213        } else {
1214            vec![]
1215        };
1216        return Ok(OutputPlan {
1217            runner_name,
1218            mode: mode.clone(),
1219            stream: mode.starts_with("stream-"),
1220            formatted: false,
1221            schema: None,
1222            argv_flags,
1223            warnings,
1224        });
1225    }
1226
1227    if matches!(runner_name.as_str(), "cc" | "claude") {
1228        let mut argv_flags = if mode == "json" {
1229            vec!["--output-format".into(), "json".into()]
1230        } else if mode == "formatted" {
1231            vec![
1232                "--verbose".into(),
1233                "--output-format".into(),
1234                "stream-json".into(),
1235            ]
1236        } else if mode == "stream-formatted" {
1237            vec![
1238                "--verbose".into(),
1239                "--output-format".into(),
1240                "stream-json".into(),
1241            ]
1242        } else if mode == "stream-json" {
1243            vec![
1244                "--verbose".into(),
1245                "--output-format".into(),
1246                "stream-json".into(),
1247            ]
1248        } else {
1249            vec![]
1250        };
1251        if mode == "stream-formatted" {
1252            argv_flags.push("--include-partial-messages".into());
1253        }
1254        return Ok(OutputPlan {
1255            runner_name,
1256            mode: mode.clone(),
1257            stream: mode.starts_with("stream-"),
1258            formatted: mode.contains("formatted"),
1259            schema: Some("claude-code".into()),
1260            argv_flags,
1261            warnings,
1262        });
1263    }
1264
1265    if matches!(runner_name.as_str(), "k" | "kimi") {
1266        return Ok(OutputPlan {
1267            runner_name,
1268            mode: mode.clone(),
1269            stream: mode.starts_with("stream-"),
1270            formatted: mode.contains("formatted"),
1271            schema: Some("kimi".into()),
1272            argv_flags: vec![
1273                "--print".into(),
1274                "--output-format".into(),
1275                "stream-json".into(),
1276            ],
1277            warnings,
1278        });
1279    }
1280
1281    if matches!(runner_name.as_str(), "oc" | "opencode") {
1282        return Ok(OutputPlan {
1283            runner_name,
1284            mode: mode.clone(),
1285            stream: mode.starts_with("stream-"),
1286            formatted: mode.contains("formatted"),
1287            schema: Some("opencode".into()),
1288            argv_flags: vec!["--format".into(), "json".into()],
1289            warnings,
1290        });
1291    }
1292
1293    if matches!(runner_name.as_str(), "c" | "cx" | "codex") {
1294        return Ok(OutputPlan {
1295            runner_name,
1296            mode: mode.clone(),
1297            stream: mode.starts_with("stream-"),
1298            formatted: mode.contains("formatted"),
1299            schema: Some("codex".into()),
1300            argv_flags: vec!["--json".into()],
1301            warnings,
1302        });
1303    }
1304
1305    if matches!(runner_name.as_str(), "cu" | "cursor") {
1306        let argv_flags = if mode == "json" {
1307            vec!["--output-format".into(), "json".into()]
1308        } else {
1309            vec!["--output-format".into(), "stream-json".into()]
1310        };
1311        return Ok(OutputPlan {
1312            runner_name,
1313            mode: mode.clone(),
1314            stream: mode.starts_with("stream-"),
1315            formatted: mode.contains("formatted"),
1316            schema: Some("cursor-agent".into()),
1317            argv_flags,
1318            warnings,
1319        });
1320    }
1321
1322    if matches!(runner_name.as_str(), "g" | "gemini") {
1323        let argv_flags = if mode == "json" {
1324            vec!["--output-format".into(), "json".into()]
1325        } else {
1326            vec!["--output-format".into(), "stream-json".into()]
1327        };
1328        return Ok(OutputPlan {
1329            runner_name,
1330            mode: mode.clone(),
1331            stream: mode.starts_with("stream-"),
1332            formatted: mode.contains("formatted"),
1333            schema: Some("gemini".into()),
1334            argv_flags,
1335            warnings,
1336        });
1337    }
1338
1339    if matches!(runner_name.as_str(), "p" | "pi") {
1340        return Ok(OutputPlan {
1341            runner_name,
1342            mode: mode.clone(),
1343            stream: mode.starts_with("stream-"),
1344            formatted: mode.contains("formatted"),
1345            schema: Some("pi".into()),
1346            argv_flags: vec!["--mode".into(), "json".into()],
1347            warnings,
1348        });
1349    }
1350
1351    if matches!(runner_name.as_str(), "gb" | "grok") {
1352        let argv_flags = if mode == "json" {
1353            vec!["--output-format".into(), "json".into()]
1354        } else {
1355            vec!["--output-format".into(), "streaming-json".into()]
1356        };
1357        return Ok(OutputPlan {
1358            runner_name,
1359            mode: mode.clone(),
1360            stream: mode.starts_with("stream-"),
1361            formatted: mode.contains("formatted"),
1362            schema: Some("grok".into()),
1363            argv_flags,
1364            warnings,
1365        });
1366    }
1367
1368    Ok(OutputPlan {
1369        runner_name,
1370        mode: mode.clone(),
1371        stream: mode.starts_with("stream-"),
1372        formatted: false,
1373        schema: None,
1374        argv_flags: Vec::new(),
1375        warnings,
1376    })
1377}