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