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