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