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