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