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];
15
16#[derive(Clone, Debug)]
17pub struct RunnerInfo {
18    pub binary: String,
19    pub extra_args: Vec<String>,
20    pub thinking_flags: BTreeMap<i32, Vec<String>>,
21    pub show_thinking_flags: BTreeMap<bool, Vec<String>>,
22    pub yolo_flags: Vec<String>,
23    pub provider_flag: String,
24    pub model_flag: String,
25    pub agent_flag: String,
26    pub prompt_flag: String,
27}
28
29#[derive(Clone, Debug, Default)]
30pub struct ParsedArgs {
31    pub runner: Option<String>,
32    pub thinking: Option<i32>,
33    pub show_thinking: Option<bool>,
34    pub print_config: bool,
35    pub sanitize_osc: Option<bool>,
36    pub output_mode: Option<String>,
37    pub forward_unknown_json: bool,
38    pub yolo: bool,
39    pub permission_mode: Option<String>,
40    pub provider: Option<String>,
41    pub model: Option<String>,
42    pub alias: Option<String>,
43    pub prompt: String,
44    pub prompt_supplied: bool,
45}
46
47#[derive(Clone, Debug)]
48pub struct AliasDef {
49    pub runner: Option<String>,
50    pub thinking: Option<i32>,
51    pub show_thinking: Option<bool>,
52    pub sanitize_osc: Option<bool>,
53    pub output_mode: Option<String>,
54    pub provider: Option<String>,
55    pub model: Option<String>,
56    pub agent: Option<String>,
57    pub prompt: Option<String>,
58    pub prompt_mode: Option<String>,
59}
60
61impl Default for AliasDef {
62    fn default() -> Self {
63        Self {
64            runner: None,
65            thinking: None,
66            show_thinking: None,
67            sanitize_osc: None,
68            output_mode: None,
69            provider: None,
70            model: None,
71            agent: None,
72            prompt: None,
73            prompt_mode: None,
74        }
75    }
76}
77
78#[derive(Clone, Debug)]
79pub struct CccConfig {
80    pub default_runner: String,
81    pub default_provider: String,
82    pub default_model: String,
83    pub default_thinking: Option<i32>,
84    pub default_show_thinking: bool,
85    pub default_sanitize_osc: Option<bool>,
86    pub default_output_mode: String,
87    pub aliases: BTreeMap<String, AliasDef>,
88    pub abbreviations: BTreeMap<String, String>,
89}
90
91impl Default for CccConfig {
92    fn default() -> Self {
93        Self {
94            default_runner: "oc".to_string(),
95            default_provider: String::new(),
96            default_model: String::new(),
97            default_thinking: None,
98            default_show_thinking: false,
99            default_sanitize_osc: None,
100            default_output_mode: "text".to_string(),
101            aliases: BTreeMap::new(),
102            abbreviations: BTreeMap::new(),
103        }
104    }
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct OutputPlan {
109    pub runner_name: String,
110    pub mode: String,
111    pub stream: bool,
112    pub formatted: bool,
113    pub schema: Option<String>,
114    pub argv_flags: Vec<String>,
115}
116
117pub static RUNNER_REGISTRY: LazyLock<RwLock<BTreeMap<String, RunnerInfo>>> = LazyLock::new(|| {
118    let mut m = BTreeMap::new();
119    let opencode = RunnerInfo {
120        binary: "opencode".into(),
121        extra_args: vec!["run".into()],
122        thinking_flags: BTreeMap::new(),
123        show_thinking_flags: {
124            let mut tf = BTreeMap::new();
125            tf.insert(true, vec!["--thinking".into()]);
126            tf
127        },
128        yolo_flags: vec![],
129        provider_flag: String::new(),
130        model_flag: String::new(),
131        agent_flag: "--agent".into(),
132        prompt_flag: String::new(),
133    };
134    let claude = RunnerInfo {
135        binary: "claude".into(),
136        extra_args: vec!["-p".into()],
137        thinking_flags: {
138            let mut tf = BTreeMap::new();
139            tf.insert(0, vec!["--thinking".into(), "disabled".into()]);
140            tf.insert(
141                1,
142                vec![
143                    "--thinking".into(),
144                    "enabled".into(),
145                    "--effort".into(),
146                    "low".into(),
147                ],
148            );
149            tf.insert(
150                2,
151                vec![
152                    "--thinking".into(),
153                    "enabled".into(),
154                    "--effort".into(),
155                    "medium".into(),
156                ],
157            );
158            tf.insert(
159                3,
160                vec![
161                    "--thinking".into(),
162                    "enabled".into(),
163                    "--effort".into(),
164                    "high".into(),
165                ],
166            );
167            tf.insert(
168                4,
169                vec![
170                    "--thinking".into(),
171                    "enabled".into(),
172                    "--effort".into(),
173                    "max".into(),
174                ],
175            );
176            tf
177        },
178        show_thinking_flags: {
179            let mut tf = BTreeMap::new();
180            tf.insert(
181                true,
182                vec![
183                    "--thinking".into(),
184                    "enabled".into(),
185                    "--effort".into(),
186                    "low".into(),
187                ],
188            );
189            tf
190        },
191        yolo_flags: vec!["--dangerously-skip-permissions".into()],
192        provider_flag: String::new(),
193        model_flag: "--model".into(),
194        agent_flag: "--agent".into(),
195        prompt_flag: String::new(),
196    };
197    let kimi = RunnerInfo {
198        binary: "kimi".into(),
199        extra_args: vec![],
200        thinking_flags: {
201            let mut tf = BTreeMap::new();
202            tf.insert(0, vec!["--no-thinking".into()]);
203            tf.insert(1, vec!["--thinking".into()]);
204            tf.insert(2, vec!["--thinking".into()]);
205            tf.insert(3, vec!["--thinking".into()]);
206            tf.insert(4, vec!["--thinking".into()]);
207            tf
208        },
209        show_thinking_flags: {
210            let mut tf = BTreeMap::new();
211            tf.insert(true, vec!["--thinking".into()]);
212            tf
213        },
214        yolo_flags: vec!["--yolo".into()],
215        provider_flag: String::new(),
216        model_flag: "--model".into(),
217        agent_flag: "--agent".into(),
218        prompt_flag: "--prompt".into(),
219    };
220    let codex = RunnerInfo {
221        binary: "codex".into(),
222        extra_args: vec!["exec".into()],
223        thinking_flags: BTreeMap::new(),
224        show_thinking_flags: BTreeMap::new(),
225        yolo_flags: vec!["--dangerously-bypass-approvals-and-sandbox".into()],
226        provider_flag: String::new(),
227        model_flag: "--model".into(),
228        agent_flag: String::new(),
229        prompt_flag: String::new(),
230    };
231    let roocode = RunnerInfo {
232        binary: "roocode".into(),
233        extra_args: vec![],
234        thinking_flags: BTreeMap::new(),
235        show_thinking_flags: BTreeMap::new(),
236        yolo_flags: vec![],
237        provider_flag: String::new(),
238        model_flag: String::new(),
239        agent_flag: String::new(),
240        prompt_flag: String::new(),
241    };
242    let crush = RunnerInfo {
243        binary: "crush".into(),
244        extra_args: vec!["run".into()],
245        thinking_flags: BTreeMap::new(),
246        show_thinking_flags: BTreeMap::new(),
247        yolo_flags: vec![],
248        provider_flag: String::new(),
249        model_flag: String::new(),
250        agent_flag: String::new(),
251        prompt_flag: String::new(),
252    };
253
254    let claude_clone = claude.clone();
255    let kimi_clone = kimi.clone();
256    let opencode_clone = opencode.clone();
257
258    let codex_clone = codex.clone();
259    let roocode_clone = roocode.clone();
260    let crush_clone = crush.clone();
261
262    m.insert("opencode".into(), opencode);
263    m.insert("claude".into(), claude);
264    m.insert("kimi".into(), kimi);
265    m.insert("codex".into(), codex);
266    m.insert("roocode".into(), roocode);
267    m.insert("crush".into(), crush);
268
269    m.insert("oc".into(), opencode_clone);
270    m.insert("cc".into(), claude_clone.clone());
271    m.insert("c".into(), codex_clone.clone());
272    m.insert("cx".into(), codex_clone);
273    m.insert("k".into(), kimi_clone);
274    m.insert("rc".into(), roocode_clone.clone());
275    m.insert("cr".into(), crush_clone);
276
277    RwLock::new(m)
278});
279
280static RUNNER_SELECTOR_STRS: &[&str] = &[
281    "oc", "cc", "c", "cx", "k", "rc", "cr", "codex", "claude", "opencode", "kimi", "roocode",
282    "crush", "pi",
283];
284
285fn is_runner_selector(s: &str) -> bool {
286    RUNNER_SELECTOR_STRS
287        .iter()
288        .any(|&sel| sel.eq_ignore_ascii_case(s))
289}
290
291fn parse_thinking(s: &str) -> Option<i32> {
292    let rest = s.strip_prefix('+')?;
293    match rest.to_ascii_lowercase().as_str() {
294        "0" | "none" => Some(0),
295        "1" | "low" => Some(1),
296        "2" | "med" | "mid" | "medium" => Some(2),
297        "3" | "high" => Some(3),
298        "4" | "max" | "xhigh" => Some(4),
299        _ => None,
300    }
301}
302
303fn parse_provider_model(s: &str) -> Option<(&str, &str)> {
304    let rest = s.strip_prefix(':')?;
305    let parts: Vec<&str> = rest.splitn(2, ':').collect();
306    if parts.len() == 2 {
307        Some((parts[0], parts[1]))
308    } else {
309        None
310    }
311}
312
313fn parse_model_only(s: &str) -> Option<&str> {
314    let rest = s.strip_prefix(':')?;
315    if rest.contains(':') {
316        None
317    } else {
318        Some(rest)
319    }
320}
321
322fn parse_alias(s: &str) -> Option<&str> {
323    let rest = s.strip_prefix('@')?;
324    if rest
325        .chars()
326        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
327    {
328        Some(rest)
329    } else {
330        None
331    }
332}
333
334fn parse_output_mode(s: &str) -> Option<String> {
335    let mode = s.to_ascii_lowercase();
336    if OUTPUT_MODES.iter().any(|known| *known == mode) {
337        Some(mode)
338    } else {
339        None
340    }
341}
342
343fn parse_output_mode_sugar(s: &str) -> Option<String> {
344    match s.to_ascii_lowercase().as_str() {
345        ".text" => Some("text".to_string()),
346        "..text" => Some("stream-text".to_string()),
347        ".json" => Some("json".to_string()),
348        "..json" => Some("stream-json".to_string()),
349        ".fmt" => Some("formatted".to_string()),
350        "..fmt" => Some("stream-formatted".to_string()),
351        _ => None,
352    }
353}
354
355pub fn parse_args(argv: &[String]) -> ParsedArgs {
356    let mut parsed = ParsedArgs::default();
357    let mut positional: Vec<String> = Vec::new();
358    let mut force_prompt = false;
359    let mut index = 0;
360
361    while index < argv.len() {
362        let token = &argv[index];
363        if force_prompt || !positional.is_empty() {
364            positional.push(token.clone());
365        } else if token == "--" {
366            force_prompt = true;
367            index += 1;
368            continue;
369        } else if is_runner_selector(token) {
370            parsed.runner = Some(token.to_lowercase());
371        } else if let Some(level) = parse_thinking(token) {
372            parsed.thinking = Some(level);
373        } else if token == "--show-thinking" || token == "--no-show-thinking" {
374            parsed.show_thinking = Some(token == "--show-thinking");
375        } else if token == "--print-config" {
376            parsed.print_config = true;
377        } else if token == "--sanitize-osc" || token == "--no-sanitize-osc" {
378            parsed.sanitize_osc = Some(token == "--sanitize-osc");
379        } else if token == "--output-mode" || token == "-o" {
380            if index + 1 >= argv.len() {
381                parsed.output_mode = Some(String::new());
382            } else {
383                parsed.output_mode = Some(argv[index + 1].to_ascii_lowercase());
384                index += 1;
385            }
386        } else if token == "--forward-unknown-json" {
387            parsed.forward_unknown_json = true;
388        } else if let Some(mode) = parse_output_mode_sugar(token) {
389            parsed.output_mode = Some(mode);
390        } else if token == "--yolo" || token == "-y" {
391            parsed.yolo = true;
392            parsed.permission_mode = Some("yolo".to_string());
393        } else if token == "--permission-mode" {
394            if index + 1 >= argv.len() {
395                parsed.permission_mode = Some(String::new());
396            } else {
397                let mode = argv[index + 1].to_lowercase();
398                parsed.yolo = mode == "yolo";
399                parsed.permission_mode = Some(mode);
400                index += 1;
401            }
402        } else if let Some((provider, model)) = parse_provider_model(token) {
403            parsed.provider = Some(provider.to_string());
404            parsed.model = Some(model.to_string());
405        } else if let Some(model) = parse_model_only(token) {
406            parsed.model = Some(model.to_string());
407        } else if let Some(alias_name) = parse_alias(token) {
408            parsed.alias = Some(alias_name.to_string());
409        } else {
410            positional.push(token.clone());
411        }
412        index += 1;
413    }
414
415    parsed.prompt = positional.join(" ");
416    parsed.prompt_supplied = !positional.is_empty();
417    parsed
418}
419
420pub fn resolve_command(
421    parsed: &ParsedArgs,
422    config: Option<&CccConfig>,
423) -> Result<(Vec<String>, BTreeMap<String, String>, Vec<String>), String> {
424    let config = config.cloned().unwrap_or_default();
425    let registry = RUNNER_REGISTRY.read().unwrap();
426    let mut warnings = Vec::new();
427
428    let (effective_runner_name, effective_runner, alias_def) =
429        resolve_effective_runner(parsed, &config, &registry)
430            .ok_or_else(|| "no runner found".to_string())?;
431
432    let mut argv: Vec<String> = vec![effective_runner.binary.clone()];
433    argv.extend(effective_runner.extra_args.iter().cloned());
434    let output_plan = resolve_output_plan(parsed, Some(&config)).map_err(str::to_string)?;
435    argv.extend(output_plan.argv_flags.iter().cloned());
436
437    let effective_thinking = parsed
438        .thinking
439        .or_else(|| alias_def.and_then(|a| a.thinking))
440        .or(config.default_thinking);
441
442    if let Some(level) = effective_thinking {
443        if let Some(flags) = effective_runner.thinking_flags.get(&level) {
444            argv.extend(flags.iter().cloned());
445        }
446    }
447
448    let effective_show_thinking = resolve_show_thinking(parsed, Some(&config));
449
450    if effective_thinking.is_none() && effective_show_thinking {
451        if let Some(flags) = effective_runner.show_thinking_flags.get(&true) {
452            argv.extend(flags.iter().cloned());
453        }
454    }
455
456    let effective_provider: Option<String> = parsed
457        .provider
458        .clone()
459        .or_else(|| alias_def.and_then(|a| a.provider.clone()))
460        .or_else(|| {
461            if config.default_provider.is_empty() {
462                None
463            } else {
464                Some(config.default_provider.clone())
465            }
466        });
467
468    let effective_model: Option<String> = parsed
469        .model
470        .clone()
471        .or_else(|| alias_def.and_then(|a| a.model.clone()))
472        .or_else(|| {
473            if config.default_model.is_empty() {
474                None
475            } else {
476                Some(config.default_model.clone())
477            }
478        });
479
480    let mut env_overrides = BTreeMap::new();
481    if let Some(ref provider) = effective_provider {
482        env_overrides.insert("CCC_PROVIDER".to_string(), provider.clone());
483    }
484    if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
485        env_overrides.insert(
486            "OPENCODE_DISABLE_TERMINAL_TITLE".to_string(),
487            "true".to_string(),
488        );
489    }
490
491    if let Some(ref model) = effective_model {
492        if !effective_runner.model_flag.is_empty() {
493            argv.push(effective_runner.model_flag.clone());
494            argv.push(model.clone());
495        }
496    }
497
498    let effective_agent = if let Some(alias_def) = alias_def {
499        alias_def.agent.clone()
500    } else {
501        parsed.alias.clone()
502    };
503
504    if let Some(agent) = effective_agent {
505        if effective_runner.agent_flag.is_empty() {
506            warnings.push(format!(
507                "warning: runner \"{}\" does not support agents; ignoring @{}",
508                effective_runner_name, agent
509            ));
510        } else {
511            argv.push(effective_runner.agent_flag.clone());
512            argv.push(agent);
513        }
514    }
515
516    let effective_permission_mode = parsed.permission_mode.clone().or_else(|| {
517        if parsed.yolo {
518            Some("yolo".to_string())
519        } else {
520            None
521        }
522    });
523    if let Some(ref mode) = effective_permission_mode {
524        if mode.is_empty() {
525            return Err("permission mode requires one of: safe, auto, yolo, plan".to_string());
526        }
527        if !PERMISSION_MODES.iter().any(|known| known == mode) {
528            return Err("permission mode must be one of: safe, auto, yolo, plan".to_string());
529        }
530    }
531
532    if matches!(effective_permission_mode.as_deref(), Some("safe")) {
533        if matches!(effective_runner_name.as_str(), "cc" | "claude") {
534            argv.push("--permission-mode".to_string());
535            argv.push("default".to_string());
536        } else if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
537            env_overrides.insert(
538                "OPENCODE_CONFIG_CONTENT".to_string(),
539                "{\"permission\":\"ask\"}".to_string(),
540            );
541        } else if matches!(effective_runner_name.as_str(), "rc" | "roocode") {
542            warnings.push(
543                "warning: runner \"roocode\" safe mode is unverified; leaving default permissions unchanged"
544                    .to_string(),
545            );
546        }
547    } else if matches!(effective_permission_mode.as_deref(), Some("auto")) {
548        if matches!(effective_runner_name.as_str(), "cc" | "claude") {
549            argv.push("--permission-mode".to_string());
550            argv.push("auto".to_string());
551        } else if matches!(effective_runner_name.as_str(), "c" | "cx" | "codex") {
552            argv.push("--full-auto".to_string());
553        } else {
554            warnings.push(format!(
555                "warning: runner \"{}\" does not support permission mode \"auto\"; ignoring it",
556                effective_runner_name
557            ));
558        }
559    } else if matches!(effective_permission_mode.as_deref(), Some("yolo")) {
560        if !effective_runner.yolo_flags.is_empty() {
561            argv.extend(effective_runner.yolo_flags.iter().cloned());
562        } else if matches!(effective_runner_name.as_str(), "oc" | "opencode") {
563            env_overrides.insert(
564                "OPENCODE_CONFIG_CONTENT".to_string(),
565                "{\"permission\":\"allow\"}".to_string(),
566            );
567        } else if matches!(effective_runner_name.as_str(), "cr" | "crush") {
568            warnings.push(
569                "warning: runner \"crush\" does not support yolo mode in non-interactive run mode; ignoring --yolo".to_string(),
570            );
571        } else if matches!(effective_runner_name.as_str(), "rc" | "roocode") {
572            warnings.push("warning: runner \"roocode\" yolo mode is unverified; ignoring --yolo".to_string());
573        }
574    } else if matches!(effective_permission_mode.as_deref(), Some("plan")) {
575        if matches!(effective_runner_name.as_str(), "cc" | "claude") {
576            argv.push("--permission-mode".to_string());
577            argv.push("plan".to_string());
578        } else if matches!(effective_runner_name.as_str(), "k" | "kimi") {
579            argv.push("--plan".to_string());
580        } else {
581            warnings.push(format!(
582                "warning: runner \"{}\" does not support permission mode \"plan\"; ignoring it",
583                effective_runner_name
584            ));
585        }
586    }
587
588    let prompt = resolve_prompt(parsed, alias_def)?;
589    if effective_runner.prompt_flag.is_empty() {
590        argv.push(prompt);
591    } else {
592        argv.push(effective_runner.prompt_flag.clone());
593        argv.push(prompt);
594    }
595
596    Ok((argv, env_overrides, warnings))
597}
598
599fn resolve_prompt(parsed: &ParsedArgs, alias_def: Option<&AliasDef>) -> Result<String, String> {
600    let user_prompt = parsed.prompt.trim();
601    let alias_prompt = alias_def
602        .and_then(|alias| alias.prompt.as_deref())
603        .map(str::trim)
604        .unwrap_or("");
605    let prompt_mode = alias_def
606        .and_then(|alias| alias.prompt_mode.as_deref())
607        .map(str::trim)
608        .filter(|mode| !mode.is_empty())
609        .unwrap_or("default");
610
611    if !PROMPT_MODES.iter().any(|known| *known == prompt_mode) {
612        return Err("prompt_mode must be one of: default, prepend, append".to_string());
613    }
614
615    if prompt_mode == "default" {
616        let prompt = if user_prompt.is_empty() {
617            alias_prompt
618        } else {
619            user_prompt
620        };
621        if prompt.is_empty() {
622            return Err("prompt must not be empty".to_string());
623        }
624        return Ok(prompt.to_string());
625    }
626
627    if !parsed.prompt_supplied {
628        return Err(format!(
629            "prompt_mode {prompt_mode} requires an explicit prompt argument"
630        ));
631    }
632    if alias_prompt.is_empty() {
633        let alias_name = parsed.alias.as_deref().unwrap_or("<alias>");
634        return Err(format!(
635            "prompt_mode {prompt_mode} requires aliases.{alias_name}.prompt"
636        ));
637    }
638
639    let mut parts = vec![alias_prompt, user_prompt];
640    if prompt_mode == "append" {
641        parts.reverse();
642    }
643    Ok(parts
644        .into_iter()
645        .filter(|part| !part.is_empty())
646        .collect::<Vec<_>>()
647        .join("\n"))
648}
649
650fn resolve_alias_def<'a>(parsed: &'a ParsedArgs, config: &'a CccConfig) -> Option<&'a AliasDef> {
651    parsed.alias.as_ref().and_then(|alias| config.aliases.get(alias))
652}
653
654fn resolve_runner_name(parsed_runner: Option<&str>, config: &CccConfig) -> String {
655    let runner_name = parsed_runner.unwrap_or(&config.default_runner);
656    config
657        .abbreviations
658        .get(runner_name)
659        .cloned()
660        .unwrap_or_else(|| runner_name.to_string())
661}
662
663fn resolve_effective_runner<'a>(
664    parsed: &'a ParsedArgs,
665    config: &'a CccConfig,
666    registry: &'a BTreeMap<String, RunnerInfo>,
667) -> Option<(String, &'a RunnerInfo, Option<&'a AliasDef>)> {
668    let alias_def = resolve_alias_def(parsed, config);
669    let mut runner_name = resolve_runner_name(parsed.runner.as_deref(), config);
670    if parsed.runner.is_none() {
671        if let Some(alias_runner) = alias_def.and_then(|alias| alias.runner.as_deref()) {
672            runner_name = resolve_runner_name(Some(alias_runner), config);
673        }
674    }
675    let info = registry
676        .get(&runner_name)
677        .or_else(|| registry.get(&config.default_runner))
678        .or_else(|| registry.get("opencode"))?;
679    Some((runner_name, info, alias_def))
680}
681
682fn supported_output_modes(runner_name: &str) -> &'static [&'static str] {
683    match runner_name {
684        "cc" | "claude" => &[
685            "text",
686            "stream-text",
687            "json",
688            "stream-json",
689            "formatted",
690            "stream-formatted",
691        ],
692        "k" | "kimi" => &[
693            "text",
694            "stream-text",
695            "stream-json",
696            "formatted",
697            "stream-formatted",
698        ],
699        "oc" | "opencode" => &[
700            "text",
701            "stream-text",
702            "json",
703            "stream-json",
704            "formatted",
705            "stream-formatted",
706        ],
707        _ => &["text", "stream-text"],
708    }
709}
710
711pub fn resolve_output_mode(
712    parsed: &ParsedArgs,
713    config: Option<&CccConfig>,
714) -> Result<String, &'static str> {
715    let config = config.cloned().unwrap_or_default();
716    let alias_def = resolve_alias_def(parsed, &config);
717    let mut mode = parsed.output_mode.clone();
718    if mode.is_none() {
719        mode = alias_def.and_then(|alias| alias.output_mode.clone());
720    }
721    let mode = mode.unwrap_or_else(|| config.default_output_mode.clone());
722    if mode.is_empty() {
723        return Err(
724            "output mode requires one of: text, stream-text, json, stream-json, formatted, stream-formatted",
725        );
726    }
727    if parse_output_mode(&mode).is_none() {
728        return Err(
729            "output mode must be one of: text, stream-text, json, stream-json, formatted, stream-formatted",
730        );
731    }
732    Ok(mode)
733}
734
735pub fn resolve_show_thinking(parsed: &ParsedArgs, config: Option<&CccConfig>) -> bool {
736    let config = config.cloned().unwrap_or_default();
737    parsed
738        .show_thinking
739        .or_else(|| resolve_alias_def(parsed, &config).and_then(|alias| alias.show_thinking))
740        .unwrap_or(config.default_show_thinking)
741}
742
743pub fn resolve_sanitize_osc(parsed: &ParsedArgs, config: Option<&CccConfig>) -> bool {
744    let config = config.cloned().unwrap_or_default();
745    parsed
746        .sanitize_osc
747        .or_else(|| resolve_alias_def(parsed, &config).and_then(|alias| alias.sanitize_osc))
748        .or(config.default_sanitize_osc)
749        .unwrap_or_else(|| {
750            resolve_output_mode(parsed, Some(&config))
751                .map(|mode| mode.contains("formatted"))
752                .unwrap_or(false)
753        })
754}
755
756pub fn resolve_output_plan(
757    parsed: &ParsedArgs,
758    config: Option<&CccConfig>,
759) -> Result<OutputPlan, &'static str> {
760    let config = config.cloned().unwrap_or_default();
761    let registry = RUNNER_REGISTRY.read().unwrap();
762    let (runner_name, _, _) =
763        resolve_effective_runner(parsed, &config, &registry).ok_or("no runner found")?;
764    let mode = resolve_output_mode(parsed, Some(&config))?;
765    if !supported_output_modes(&runner_name)
766        .iter()
767        .any(|candidate| *candidate == mode)
768    {
769        return Err("runner does not support requested output mode");
770    }
771
772    if matches!(mode.as_str(), "text" | "stream-text") {
773        return Ok(OutputPlan {
774            runner_name,
775            mode: mode.clone(),
776            stream: mode.starts_with("stream-"),
777            formatted: false,
778            schema: None,
779            argv_flags: Vec::new(),
780        });
781    }
782
783    if matches!(runner_name.as_str(), "cc" | "claude") {
784        let mut argv_flags = if mode == "json" {
785            vec!["--output-format".into(), "json".into()]
786        } else {
787            vec!["--verbose".into(), "--output-format".into(), "stream-json".into()]
788        };
789        if mode == "stream-formatted" {
790            argv_flags.push("--include-partial-messages".into());
791        }
792        return Ok(OutputPlan {
793            runner_name,
794            mode: mode.clone(),
795            stream: mode.starts_with("stream-"),
796            formatted: mode.contains("formatted"),
797            schema: Some("claude-code".into()),
798            argv_flags,
799        });
800    }
801
802    if matches!(runner_name.as_str(), "k" | "kimi") {
803        return Ok(OutputPlan {
804            runner_name,
805            mode: mode.clone(),
806            stream: mode.starts_with("stream-"),
807            formatted: mode.contains("formatted"),
808            schema: Some("kimi".into()),
809            argv_flags: vec!["--print".into(), "--output-format".into(), "stream-json".into()],
810        });
811    }
812
813    if matches!(runner_name.as_str(), "oc" | "opencode") {
814        return Ok(OutputPlan {
815            runner_name,
816            mode: mode.clone(),
817            stream: mode.starts_with("stream-"),
818            formatted: mode.contains("formatted"),
819            schema: Some("opencode".into()),
820            argv_flags: vec!["--format".into(), "json".into()],
821        });
822    }
823
824    Ok(OutputPlan {
825        runner_name,
826        mode: mode.clone(),
827        stream: mode.starts_with("stream-"),
828        formatted: false,
829        schema: None,
830        argv_flags: Vec::new(),
831    })
832}