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 model) = effective_model {
623        if !effective_runner.model_flag.is_empty() {
624            argv.push(effective_runner.model_flag.clone());
625            if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
626                if let Some(ref provider) = effective_provider {
627                    argv.push(format!("{}/{}", provider, model));
628                } else {
629                    argv.push(model.clone());
630                }
631            } else {
632                argv.push(model.clone());
633            }
634        }
635    }
636
637    let effective_agent = if let Some(alias_def) = alias_def {
638        alias_def.agent.clone()
639    } else if unresolved_alias_runner_name(parsed, &config, &registry).is_some() {
640        None
641    } else {
642        parsed.alias.clone()
643    };
644
645    if let Some(agent) = effective_agent {
646        if effective_runner.agent_flag.is_empty() {
647            warnings.push(format!(
648                "warning: runner \"{}\" does not support agents; ignoring @{}",
649                effective_runner_name, agent
650            ));
651        } else {
652            argv.push(effective_runner.agent_flag.clone());
653            argv.push(agent);
654        }
655    }
656
657    let effective_permission_mode = parsed.permission_mode.clone().or_else(|| {
658        if parsed.yolo {
659            Some("yolo".to_string())
660        } else {
661            None
662        }
663    });
664    if let Some(ref mode) = effective_permission_mode {
665        if mode.is_empty() {
666            return Err("permission mode requires one of: safe, auto, yolo, plan".to_string());
667        }
668        if !PERMISSION_MODES.iter().any(|known| known == mode) {
669            return Err("permission mode must be one of: safe, auto, yolo, plan".to_string());
670        }
671    }
672
673    if matches!(effective_permission_mode.as_deref(), Some("safe")) {
674        if matches!(effective_runner_name.as_str(), "cc" | "claude") {
675            argv.push("--permission-mode".to_string());
676            argv.push("default".to_string());
677        } else if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
678            env_overrides.insert(
679                "OPENCODE_CONFIG_CONTENT".to_string(),
680                "{\"permission\":\"ask\"}".to_string(),
681            );
682        } else if matches!(effective_runner_name.as_str(), "cu" | "cursor") {
683            argv.push("--sandbox".to_string());
684            argv.push("enabled".to_string());
685        } else if matches!(effective_runner_name.as_str(), "g" | "gemini") {
686            argv.push("--approval-mode".to_string());
687            argv.push("default".to_string());
688            argv.push("--sandbox".to_string());
689        } else if matches!(effective_runner_name.as_str(), "rc" | "roocode") {
690            warnings.push(
691                "warning: runner \"roocode\" safe mode is unverified; leaving default permissions unchanged"
692                    .to_string(),
693            );
694        }
695    } else if matches!(effective_permission_mode.as_deref(), Some("auto")) {
696        if matches!(effective_runner_name.as_str(), "cc" | "claude") {
697            argv.push("--permission-mode".to_string());
698            argv.push("auto".to_string());
699        } else if matches!(effective_runner_name.as_str(), "c" | "cx" | "codex") {
700            argv.push("--full-auto".to_string());
701        } else if matches!(effective_runner_name.as_str(), "g" | "gemini") {
702            argv.push("--approval-mode".to_string());
703            argv.push("auto_edit".to_string());
704        } else {
705            warnings.push(format!(
706                "warning: runner \"{}\" does not support permission mode \"auto\"; ignoring it",
707                effective_runner_name
708            ));
709        }
710    } else if matches!(effective_permission_mode.as_deref(), Some("yolo")) {
711        if matches!(effective_runner_name.as_str(), "g" | "gemini") {
712            argv.push("--approval-mode".to_string());
713            argv.push("yolo".to_string());
714        } else if !effective_runner.yolo_flags.is_empty() {
715            argv.extend(effective_runner.yolo_flags.iter().cloned());
716        } else if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
717            env_overrides.insert(
718                "OPENCODE_CONFIG_CONTENT".to_string(),
719                "{\"permission\":\"allow\"}".to_string(),
720            );
721        } else if matches!(effective_runner_name.as_str(), "cr" | "crush") {
722            warnings.push(
723                "warning: runner \"crush\" does not support yolo mode in non-interactive run mode; ignoring --yolo".to_string(),
724            );
725        } else if matches!(effective_runner_name.as_str(), "rc" | "roocode") {
726            warnings.push(
727                "warning: runner \"roocode\" yolo mode is unverified; ignoring --yolo".to_string(),
728            );
729        }
730    } else if matches!(effective_permission_mode.as_deref(), Some("plan")) {
731        if matches!(effective_runner_name.as_str(), "cc" | "claude") {
732            argv.push("--permission-mode".to_string());
733            argv.push("plan".to_string());
734        } else if matches!(effective_runner_name.as_str(), "k" | "kimi") {
735            argv.push("--plan".to_string());
736        } else if matches!(effective_runner_name.as_str(), "cu" | "cursor") {
737            argv.push("--mode".to_string());
738            argv.push("plan".to_string());
739        } else if matches!(effective_runner_name.as_str(), "g" | "gemini") {
740            argv.push("--approval-mode".to_string());
741            argv.push("plan".to_string());
742        } else {
743            warnings.push(format!(
744                "warning: runner \"{}\" does not support permission mode \"plan\"; ignoring it",
745                effective_runner_name
746            ));
747        }
748    }
749
750    if !parsed.save_session {
751        argv.extend(effective_runner.no_persist_flags.iter().cloned());
752    }
753    argv.extend(parsed.runner_args.iter().cloned());
754
755    let prompt = resolve_prompt(parsed, alias_def)?;
756    if effective_runner.prompt_flag.is_empty() {
757        argv.push(prompt);
758    } else {
759        argv.push(effective_runner.prompt_flag.clone());
760        argv.push(prompt);
761    }
762
763    Ok((argv, env_overrides, warnings))
764}
765
766pub(crate) fn resolve_command_warnings(
767    parsed: &ParsedArgs,
768    config: Option<&CccConfig>,
769) -> Result<Vec<String>, String> {
770    resolve_command(parsed, config).map(|(_, _, warnings)| warnings)
771}
772
773fn canonical_runner_name(effective_runner_name: &str, info: &RunnerInfo) -> String {
774    match effective_runner_name {
775        "oc" | "opencode" => "opencode".to_string(),
776        "cc" | "claude" => "claude".to_string(),
777        "c" | "cx" | "codex" => "codex".to_string(),
778        "k" | "kimi" => "kimi".to_string(),
779        "cr" | "crush" => "crush".to_string(),
780        "rc" | "roocode" => "roocode".to_string(),
781        "cu" | "cursor" => "cursor".to_string(),
782        "g" | "gemini" => "gemini".to_string(),
783        "p" | "pi" => "pi".to_string(),
784        _ => info.binary.clone(),
785    }
786}
787
788fn session_persistence_warnings(
789    parsed: &ParsedArgs,
790    effective_runner_name: &str,
791    info: &RunnerInfo,
792) -> Vec<String> {
793    if parsed.save_session || !info.no_persist_flags.is_empty() {
794        return Vec::new();
795    }
796    let display = canonical_runner_name(effective_runner_name, info);
797    if parsed.cleanup_session {
798        if display == "opencode" || display == "kimi" {
799            return Vec::new();
800        }
801        return vec![format!(
802            "warning: runner \"{display}\" does not support automatic session cleanup; pass --save-session to allow saved sessions explicitly"
803        )];
804    }
805    Vec::new()
806}
807
808fn resolve_prompt(parsed: &ParsedArgs, alias_def: Option<&AliasDef>) -> Result<String, String> {
809    let user_prompt = parsed.prompt.trim();
810    let alias_prompt = alias_def
811        .and_then(|alias| alias.prompt.as_deref())
812        .map(str::trim)
813        .unwrap_or("");
814    let prompt_mode = alias_def
815        .and_then(|alias| alias.prompt_mode.as_deref())
816        .map(str::trim)
817        .filter(|mode| !mode.is_empty())
818        .unwrap_or("default");
819
820    if !PROMPT_MODES.iter().any(|known| *known == prompt_mode) {
821        return Err("prompt_mode must be one of: default, prepend, append".to_string());
822    }
823
824    if prompt_mode == "default" {
825        let prompt = if user_prompt.is_empty() {
826            alias_prompt
827        } else {
828            user_prompt
829        };
830        if prompt.is_empty() {
831            return Err("prompt must not be empty".to_string());
832        }
833        return Ok(prompt.to_string());
834    }
835
836    if !parsed.prompt_supplied {
837        return Err(format!(
838            "prompt_mode {prompt_mode} requires an explicit prompt argument"
839        ));
840    }
841    if alias_prompt.is_empty() {
842        let alias_name = parsed.alias.as_deref().unwrap_or("<alias>");
843        return Err(format!(
844            "prompt_mode {prompt_mode} requires aliases.{alias_name}.prompt"
845        ));
846    }
847
848    let mut parts = vec![alias_prompt, user_prompt];
849    if prompt_mode == "append" {
850        parts.reverse();
851    }
852    Ok(parts
853        .into_iter()
854        .filter(|part| !part.is_empty())
855        .collect::<Vec<_>>()
856        .join("\n"))
857}
858
859fn resolve_alias_def<'a>(parsed: &'a ParsedArgs, config: &'a CccConfig) -> Option<&'a AliasDef> {
860    parsed
861        .alias
862        .as_ref()
863        .and_then(|alias| config.aliases.get(alias))
864}
865
866fn unresolved_alias_runner_name(
867    parsed: &ParsedArgs,
868    config: &CccConfig,
869    registry: &BTreeMap<String, RunnerInfo>,
870) -> Option<String> {
871    if parsed.runner.is_some() {
872        return None;
873    }
874    let alias = parsed.alias.as_ref()?;
875    if config.aliases.contains_key(alias) {
876        return None;
877    }
878    let exact = resolve_runner_name(Some(alias), config);
879    if registry.contains_key(&exact) {
880        return Some(exact);
881    }
882    let lower_alias = alias.to_ascii_lowercase();
883    let lower = resolve_runner_name(Some(&lower_alias), config);
884    registry.contains_key(&lower).then_some(lower)
885}
886
887fn resolve_runner_name(parsed_runner: Option<&str>, config: &CccConfig) -> String {
888    let runner_name = parsed_runner.unwrap_or(&config.default_runner);
889    config
890        .abbreviations
891        .get(runner_name)
892        .cloned()
893        .unwrap_or_else(|| runner_name.to_string())
894}
895
896fn resolve_effective_runner<'a>(
897    parsed: &'a ParsedArgs,
898    config: &'a CccConfig,
899    registry: &'a BTreeMap<String, RunnerInfo>,
900) -> Option<(String, &'a RunnerInfo, Option<&'a AliasDef>)> {
901    let alias_def = resolve_alias_def(parsed, config);
902    let mut runner_name = resolve_runner_name(parsed.runner.as_deref(), config);
903    if parsed.runner.is_none() {
904        if let Some(alias_runner) = alias_def.and_then(|alias| alias.runner.as_deref()) {
905            runner_name = resolve_runner_name(Some(alias_runner), config);
906        } else if let Some(alias_runner) = unresolved_alias_runner_name(parsed, config, registry) {
907            runner_name = alias_runner;
908        }
909    }
910    let info = registry
911        .get(&runner_name)
912        .or_else(|| registry.get(&config.default_runner))
913        .or_else(|| registry.get("opencode"))?;
914    Some((runner_name, info, alias_def))
915}
916
917fn supported_output_modes(runner_name: &str) -> &'static [&'static str] {
918    match runner_name {
919        "cc" | "claude" | "oc" | "opencode" | "c" | "cx" | "codex" | "cu" | "cursor" => {
920            OUTPUT_MODES
921        }
922        "k" | "kimi" => KIMI_OUTPUT_MODES,
923        "g" | "gemini" | "p" | "pi" => &[
924            "text",
925            "stream-text",
926            "json",
927            "stream-json",
928            "formatted",
929            "stream-formatted",
930            "pass-text",
931            "stream-pass-text",
932            "pass-json",
933            "stream-pass-json",
934        ],
935        _ => TEXT_OUTPUT_MODES,
936    }
937}
938
939fn fallback_output_mode(supported: &[&str]) -> &'static str {
940    if supported.iter().any(|mode| *mode == "text") {
941        "text"
942    } else {
943        "stream-text"
944    }
945}
946
947pub fn resolve_output_mode(
948    parsed: &ParsedArgs,
949    config: Option<&CccConfig>,
950) -> Result<String, &'static str> {
951    resolve_output_mode_with_source(parsed, config).map(|(mode, _)| mode)
952}
953
954fn resolve_output_mode_with_source(
955    parsed: &ParsedArgs,
956    config: Option<&CccConfig>,
957) -> Result<(String, &'static str), &'static str> {
958    let config = config.cloned().unwrap_or_default();
959    let alias_def = resolve_alias_def(parsed, &config);
960    let mut mode = parsed.output_mode.clone();
961    let mut source = "argument";
962    if mode.is_none() {
963        mode = alias_def.and_then(|alias| alias.output_mode.clone());
964        if mode.is_some() {
965            source = "alias";
966        }
967    }
968    let mode = mode.unwrap_or_else(|| {
969        source = "configured";
970        config.default_output_mode.clone()
971    });
972    if mode.is_empty() {
973        return Err(
974            "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",
975        );
976    }
977    match parse_output_mode(&mode) {
978        Some(mode) => Ok((mode, source)),
979        None => Err(
980            "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",
981        ),
982    }
983}
984
985pub fn resolve_show_thinking(parsed: &ParsedArgs, config: Option<&CccConfig>) -> bool {
986    let config = config.cloned().unwrap_or_default();
987    parsed
988        .show_thinking
989        .or_else(|| resolve_alias_def(parsed, &config).and_then(|alias| alias.show_thinking))
990        .unwrap_or(config.default_show_thinking)
991}
992
993pub fn resolve_sanitize_osc(parsed: &ParsedArgs, config: Option<&CccConfig>) -> bool {
994    let config = config.cloned().unwrap_or_default();
995    parsed
996        .sanitize_osc
997        .or_else(|| resolve_alias_def(parsed, &config).and_then(|alias| alias.sanitize_osc))
998        .or(config.default_sanitize_osc)
999        .unwrap_or_else(|| {
1000            resolve_output_plan(parsed, Some(&config))
1001                .map(|plan| plan.mode.contains("formatted"))
1002                .unwrap_or(false)
1003        })
1004}
1005
1006pub fn resolve_output_plan(
1007    parsed: &ParsedArgs,
1008    config: Option<&CccConfig>,
1009) -> Result<OutputPlan, &'static str> {
1010    let config = config.cloned().unwrap_or_default();
1011    let registry = RUNNER_REGISTRY.read().unwrap();
1012    let (runner_name, info, _) =
1013        resolve_effective_runner(parsed, &config, &registry).ok_or("no runner found")?;
1014    let (mut mode, mode_source) = resolve_output_mode_with_source(parsed, Some(&config))?;
1015    let supported = supported_output_modes(&runner_name);
1016    let mut warnings = Vec::new();
1017    if !supported.iter().any(|candidate| *candidate == mode) {
1018        if mode_source == "argument" {
1019            return Err("gemini runner does not support requested output mode");
1020        }
1021        let fallback = fallback_output_mode(supported);
1022        warnings.push(format!(
1023            "warning: runner \"{}\" does not support {} output mode \"{}\"; falling back to \"{}\"",
1024            canonical_runner_name(&runner_name, info),
1025            mode_source,
1026            mode,
1027            fallback,
1028        ));
1029        mode = fallback.to_string();
1030    }
1031
1032    if matches!(mode.as_str(), "text" | "stream-text") {
1033        return Ok(OutputPlan {
1034            runner_name,
1035            mode: mode.clone(),
1036            stream: mode.starts_with("stream-"),
1037            formatted: false,
1038            schema: None,
1039            argv_flags: Vec::new(),
1040            warnings,
1041        });
1042    }
1043
1044    if matches!(
1045        mode.as_str(),
1046        "pass-text" | "stream-pass-text" | "pass-json" | "stream-pass-json"
1047    ) {
1048        let argv_flags = if mode == "pass-json" {
1049            if matches!(runner_name.as_str(), "cc" | "claude") {
1050                vec!["--output-format".into(), "json".into()]
1051            } else if matches!(runner_name.as_str(), "cu" | "cursor") {
1052                vec!["--output-format".into(), "json".into()]
1053            } else if matches!(runner_name.as_str(), "g" | "gemini") {
1054                vec!["--output-format".into(), "json".into()]
1055            } else if matches!(runner_name.as_str(), "c" | "cx" | "codex") {
1056                vec!["--json".into()]
1057            } else if matches!(runner_name.as_str(), "k" | "kimi") {
1058                vec![
1059                    "--print".into(),
1060                    "--output-format".into(),
1061                    "stream-json".into(),
1062                ]
1063            } else if matches!(runner_name.as_str(), "oc" | "opencode") {
1064                vec!["--format".into(), "json".into()]
1065            } else if matches!(runner_name.as_str(), "p" | "pi") {
1066                vec!["--mode".into(), "json".into()]
1067            } else {
1068                vec![]
1069            }
1070        } else if mode == "stream-pass-json" {
1071            if matches!(runner_name.as_str(), "cc" | "claude") {
1072                vec![
1073                    "--verbose".into(),
1074                    "--output-format".into(),
1075                    "stream-json".into(),
1076                ]
1077            } else if matches!(runner_name.as_str(), "cu" | "cursor") {
1078                vec!["--output-format".into(), "stream-json".into()]
1079            } else if matches!(runner_name.as_str(), "g" | "gemini") {
1080                vec!["--output-format".into(), "stream-json".into()]
1081            } else if matches!(runner_name.as_str(), "c" | "cx" | "codex") {
1082                vec!["--json".into()]
1083            } else if matches!(runner_name.as_str(), "k" | "kimi") {
1084                vec![
1085                    "--print".into(),
1086                    "--output-format".into(),
1087                    "stream-json".into(),
1088                ]
1089            } else if matches!(runner_name.as_str(), "oc" | "opencode") {
1090                vec!["--format".into(), "json".into()]
1091            } else if matches!(runner_name.as_str(), "p" | "pi") {
1092                vec!["--mode".into(), "json".into()]
1093            } else {
1094                vec![]
1095            }
1096        } else {
1097            vec![]
1098        };
1099        return Ok(OutputPlan {
1100            runner_name,
1101            mode: mode.clone(),
1102            stream: mode.starts_with("stream-"),
1103            formatted: false,
1104            schema: None,
1105            argv_flags,
1106            warnings,
1107        });
1108    }
1109
1110    if matches!(runner_name.as_str(), "cc" | "claude") {
1111        let mut argv_flags = if mode == "json" {
1112            vec!["--output-format".into(), "json".into()]
1113        } else if mode == "formatted" {
1114            vec![
1115                "--verbose".into(),
1116                "--output-format".into(),
1117                "stream-json".into(),
1118            ]
1119        } else if mode == "stream-formatted" {
1120            vec![
1121                "--verbose".into(),
1122                "--output-format".into(),
1123                "stream-json".into(),
1124            ]
1125        } else if mode == "stream-json" {
1126            vec![
1127                "--verbose".into(),
1128                "--output-format".into(),
1129                "stream-json".into(),
1130            ]
1131        } else {
1132            vec![]
1133        };
1134        if mode == "stream-formatted" {
1135            argv_flags.push("--include-partial-messages".into());
1136        }
1137        return Ok(OutputPlan {
1138            runner_name,
1139            mode: mode.clone(),
1140            stream: mode.starts_with("stream-"),
1141            formatted: mode.contains("formatted"),
1142            schema: Some("claude-code".into()),
1143            argv_flags,
1144            warnings,
1145        });
1146    }
1147
1148    if matches!(runner_name.as_str(), "k" | "kimi") {
1149        return Ok(OutputPlan {
1150            runner_name,
1151            mode: mode.clone(),
1152            stream: mode.starts_with("stream-"),
1153            formatted: mode.contains("formatted"),
1154            schema: Some("kimi".into()),
1155            argv_flags: vec![
1156                "--print".into(),
1157                "--output-format".into(),
1158                "stream-json".into(),
1159            ],
1160            warnings,
1161        });
1162    }
1163
1164    if matches!(runner_name.as_str(), "oc" | "opencode") {
1165        return Ok(OutputPlan {
1166            runner_name,
1167            mode: mode.clone(),
1168            stream: mode.starts_with("stream-"),
1169            formatted: mode.contains("formatted"),
1170            schema: Some("opencode".into()),
1171            argv_flags: vec!["--format".into(), "json".into()],
1172            warnings,
1173        });
1174    }
1175
1176    if matches!(runner_name.as_str(), "c" | "cx" | "codex") {
1177        return Ok(OutputPlan {
1178            runner_name,
1179            mode: mode.clone(),
1180            stream: mode.starts_with("stream-"),
1181            formatted: mode.contains("formatted"),
1182            schema: Some("codex".into()),
1183            argv_flags: vec!["--json".into()],
1184            warnings,
1185        });
1186    }
1187
1188    if matches!(runner_name.as_str(), "cu" | "cursor") {
1189        let argv_flags = if mode == "json" {
1190            vec!["--output-format".into(), "json".into()]
1191        } else {
1192            vec!["--output-format".into(), "stream-json".into()]
1193        };
1194        return Ok(OutputPlan {
1195            runner_name,
1196            mode: mode.clone(),
1197            stream: mode.starts_with("stream-"),
1198            formatted: mode.contains("formatted"),
1199            schema: Some("cursor-agent".into()),
1200            argv_flags,
1201            warnings,
1202        });
1203    }
1204
1205    if matches!(runner_name.as_str(), "g" | "gemini") {
1206        let argv_flags = if mode == "json" {
1207            vec!["--output-format".into(), "json".into()]
1208        } else {
1209            vec!["--output-format".into(), "stream-json".into()]
1210        };
1211        return Ok(OutputPlan {
1212            runner_name,
1213            mode: mode.clone(),
1214            stream: mode.starts_with("stream-"),
1215            formatted: mode.contains("formatted"),
1216            schema: Some("gemini".into()),
1217            argv_flags,
1218            warnings,
1219        });
1220    }
1221
1222    if matches!(runner_name.as_str(), "p" | "pi") {
1223        return Ok(OutputPlan {
1224            runner_name,
1225            mode: mode.clone(),
1226            stream: mode.starts_with("stream-"),
1227            formatted: mode.contains("formatted"),
1228            schema: Some("pi".into()),
1229            argv_flags: vec!["--mode".into(), "json".into()],
1230            warnings,
1231        });
1232    }
1233
1234    Ok(OutputPlan {
1235        runner_name,
1236        mode: mode.clone(),
1237        stream: mode.starts_with("stream-"),
1238        formatted: false,
1239        schema: None,
1240        argv_flags: Vec::new(),
1241        warnings,
1242    })
1243}