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, ®istry)
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, ®istry).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}