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