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