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