1use clap::{Parser, Subcommand};
8use std::path::PathBuf;
9
10pub use crate::store::settings::ThinkingLevel;
13
14#[derive(Debug, Clone, Parser)]
18#[command(name = "oxi")]
19#[command(about = "CLI coding harness for oxi")]
20#[command(version)]
21pub struct CliArgs {
22 #[command(subcommand)]
24 pub command: Option<Commands>,
25
26 #[arg(short, long)]
28 pub provider: Option<String>,
29
30 #[arg(short, long)]
32 pub model: Option<String>,
33
34 #[arg(default_value = "")]
36 pub prompt: Vec<String>,
37
38 #[arg(short, long)]
40 pub interactive: bool,
41
42 #[arg(long)]
44 pub thinking: Option<String>,
45
46 #[arg(short = 'e', long = "extension", value_name = "PATH")]
49 pub extensions: Vec<PathBuf>,
50
51 #[arg(long)]
53 pub mode: Option<String>,
54
55 #[arg(long)]
57 pub tools: Option<String>,
58
59 #[arg(long)]
61 pub append_system_prompt: Option<PathBuf>,
62
63 #[arg(long)]
65 pub print: bool,
66
67 #[arg(long)]
69 pub no_session: bool,
70
71 #[arg(long)]
73 pub timeout: Option<u64>,
74
75 #[arg(short, long)]
77 pub continue_session: bool,
78
79 #[arg(long = "enable-routing")]
82 pub enable_routing: bool,
83
84 #[arg(long = "prefer-cost-efficient")]
86 pub prefer_cost_efficient: bool,
87
88 #[arg(long = "fallback-chain", value_delimiter = ',')]
90 pub fallback_chain: Vec<String>,
91
92 #[arg(long = "disable-fallback")]
94 pub disable_fallback: bool,
95}
96
97#[derive(Debug, Clone, Subcommand)]
101pub enum Commands {
102 Sessions,
104 Tree {
106 #[arg(default_value = "")]
108 session_id: String,
109 },
110 Fork {
112 parent_id: String,
114 entry_id: String,
116 },
117 Delete {
119 session_id: String,
121 },
122 Issue {
124 #[command(subcommand)]
126 action: IssueCommands,
127 },
128 Pkg {
130 #[command(subcommand)]
132 action: PkgCommands,
133 },
134 Config {
136 #[command(subcommand)]
138 action: ConfigCommands,
139 },
140 Ext {
142 #[command(subcommand)]
144 action: ExtCommands,
145 },
146 Models {
148 #[arg(long)]
150 provider: Option<String>,
151 },
152 Setup {
154 #[arg(long)]
156 reset: bool,
157 },
158 Reset {
163 #[arg(long, short)]
165 yes: bool,
166 #[arg(long)]
168 include_project: bool,
169 },
170 Export {
172 session_id: Option<String>,
174 #[arg(short, long)]
176 output: Option<PathBuf>,
177 },
178 Import {
180 path: PathBuf,
182 },
183 Share {
185 session_id: Option<String>,
187 },
188}
189
190#[derive(Debug, Clone, Subcommand)]
194pub enum PkgCommands {
195 Install {
197 source: String,
199 },
200 List,
202 Uninstall {
204 name: String,
206 },
207 Update {
209 name: Option<String>,
211 },
212}
213
214#[derive(Debug, Clone, Subcommand)]
218pub enum IssueCommands {
219 List {
221 #[arg(long)]
223 all: bool,
224 #[arg(long)]
226 label: Option<String>,
227 #[arg(long)]
229 text: Option<String>,
230 },
231 Show {
233 id: u32,
235 },
236 New {
238 title: String,
240 #[arg(long, short)]
242 body: Option<String>,
243 #[arg(long)]
245 priority: Option<String>,
246 #[arg(long)]
248 labels: Option<String>,
249 },
250 Close {
252 id: u32,
254 #[arg(long)]
256 hash: Option<String>,
257 },
258}
259
260#[derive(Debug, Clone, Subcommand)]
264pub enum ExtCommands {
265 Install {
267 source: String,
269 #[arg(long)]
271 prerelease: bool,
272 },
273 List,
275 Remove {
277 source: String,
279 },
280 Update {
282 source: Option<String>,
284 },
285 Info {
287 source: String,
289 },
290}
291
292#[derive(Debug, Clone, Subcommand)]
296pub enum ConfigCommands {
297 Show,
299 List {
301 resource_type: Option<String>,
303 },
304 Enable {
306 resource_type: String,
308 name: String,
310 },
311 Disable {
313 resource_type: String,
315 name: String,
317 },
318 Set {
320 key: String,
322 value: String,
324 },
325 Get {
327 key: String,
329 },
330 AddProvider {
332 name: String,
334 base_url: String,
336 api_key_env: String,
338 #[arg(default_value = "openai-completions")]
340 api: String,
341 },
342 RemoveProvider {
344 name: String,
346 },
347 Reset {
349 #[arg(long, short)]
351 all: bool,
352 },
353}
354
355pub fn parse_args() -> CliArgs {
374 CliArgs::parse()
375}
376
377pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
379where
380 I: IntoIterator<Item = T>,
381 T: Into<std::ffi::OsString> + Clone,
382{
383 CliArgs::try_parse_from(iter)
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn test_parse_basic_prompt() {
392 let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
393 assert_eq!(args.prompt, vec!["Hello", "world"]);
394 }
395
396 #[test]
397 fn test_parse_with_provider_and_model() {
398 let args = parse_args_from([
399 "oxi",
400 "--provider",
401 "anthropic",
402 "--model",
403 "claude-sonnet-4-20250514",
404 "Hello",
405 ])
406 .unwrap();
407 assert_eq!(args.provider, Some("anthropic".to_string()));
408 assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
409 }
410
411 #[test]
412 fn test_parse_interactive_flag() {
413 let args = parse_args_from(["oxi", "-i"]).unwrap();
414 assert!(args.interactive);
415 }
416
417 #[test]
418 fn test_parse_extension_paths() {
419 let args =
420 parse_args_from(["oxi", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
421 assert_eq!(args.extensions.len(), 2);
422 }
423
424 #[test]
425 fn test_parse_sessions_command() {
426 let args = parse_args_from(["oxi", "sessions"]).unwrap();
427 assert!(matches!(args.command, Some(Commands::Sessions)));
428 }
429
430 #[test]
431 fn test_parse_tree_command() {
432 let args = parse_args_from(["oxi", "tree", "abc-123"]).unwrap();
433 match args.command {
434 Some(Commands::Tree { session_id }) => {
435 assert_eq!(session_id, "abc-123");
436 }
437 _ => panic!("Expected Tree command"),
438 }
439 }
440
441 #[test]
442 fn test_parse_tree_command_default() {
443 let args = parse_args_from(["oxi", "tree"]).unwrap();
444 match args.command {
445 Some(Commands::Tree { session_id }) => {
446 assert_eq!(session_id, "");
447 }
448 _ => panic!("Expected Tree command"),
449 }
450 }
451
452 #[test]
453 fn test_parse_fork_command() {
454 let args = parse_args_from(["oxi", "fork", "parent-id", "entry-id"]).unwrap();
455 match args.command {
456 Some(Commands::Fork {
457 parent_id,
458 entry_id,
459 }) => {
460 assert_eq!(parent_id, "parent-id");
461 assert_eq!(entry_id, "entry-id");
462 }
463 _ => panic!("Expected Fork command"),
464 }
465 }
466
467 #[test]
468 fn test_parse_delete_command() {
469 let args = parse_args_from(["oxi", "delete", "session-123"]).unwrap();
470 match args.command {
471 Some(Commands::Delete { session_id }) => {
472 assert_eq!(session_id, "session-123");
473 }
474 _ => panic!("Expected Delete command"),
475 }
476 }
477
478 #[test]
479 fn test_parse_pkg_install() {
480 let args = parse_args_from(["oxi", "pkg", "install", "npm:@scope/name"]).unwrap();
481 match args.command {
482 Some(Commands::Pkg { action }) => match action {
483 PkgCommands::Install { source } => {
484 assert_eq!(source, "npm:@scope/name");
485 }
486 _ => panic!("Expected Install subcommand"),
487 },
488 _ => panic!("Expected Pkg command"),
489 }
490 }
491
492 #[test]
493 fn test_parse_pkg_list() {
494 let args = parse_args_from(["oxi", "pkg", "list"]).unwrap();
495 match args.command {
496 Some(Commands::Pkg { action }) => {
497 assert!(matches!(action, PkgCommands::List));
498 }
499 _ => panic!("Expected Pkg command"),
500 }
501 }
502
503 #[test]
504 fn test_parse_pkg_update_all() {
505 let args = parse_args_from(["oxi", "pkg", "update"]).unwrap();
506 match args.command {
507 Some(Commands::Pkg { action }) => match action {
508 PkgCommands::Update { name } => assert!(name.is_none()),
509 _ => panic!("Expected Update subcommand"),
510 },
511 _ => panic!("Expected Pkg command"),
512 }
513 }
514
515 #[test]
516 fn test_parse_pkg_update_named() {
517 let args = parse_args_from(["oxi", "pkg", "update", "my-pkg"]).unwrap();
518 match args.command {
519 Some(Commands::Pkg { action }) => match action {
520 PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
521 _ => panic!("Expected Update subcommand"),
522 },
523 _ => panic!("Expected Pkg command"),
524 }
525 }
526
527 #[test]
528 fn test_parse_config_show() {
529 let args = parse_args_from(["oxi", "config", "show"]).unwrap();
530 assert!(matches!(
531 args.command,
532 Some(Commands::Config {
533 action: ConfigCommands::Show
534 })
535 ));
536 }
537
538 #[test]
539 fn test_parse_config_set() {
540 let args = parse_args_from(["oxi", "config", "set", "theme", "dracula"]).unwrap();
541 match args.command {
542 Some(Commands::Config { action }) => match action {
543 ConfigCommands::Set { key, value } => {
544 assert_eq!(key, "theme");
545 assert_eq!(value, "dracula");
546 }
547 _ => panic!("Expected Set subcommand"),
548 },
549 _ => panic!("Expected Config command"),
550 }
551 }
552
553 #[test]
554 fn test_parse_config_get() {
555 let args = parse_args_from(["oxi", "config", "get", "theme"]).unwrap();
556 match args.command {
557 Some(Commands::Config { action }) => match action {
558 ConfigCommands::Get { key } => {
559 assert_eq!(key, "theme");
560 }
561 _ => panic!("Expected Get subcommand"),
562 },
563 _ => panic!("Expected Config command"),
564 }
565 }
566
567 #[test]
568 fn test_parse_config_enable() {
569 let args = parse_args_from(["oxi", "config", "enable", "extension", "my-ext"]).unwrap();
570 match args.command {
571 Some(Commands::Config { action }) => match action {
572 ConfigCommands::Enable {
573 resource_type,
574 name,
575 } => {
576 assert_eq!(resource_type, "extension");
577 assert_eq!(name, "my-ext");
578 }
579 _ => panic!("Expected Enable subcommand"),
580 },
581 _ => panic!("Expected Config command"),
582 }
583 }
584
585 #[test]
586 fn test_parse_config_disable() {
587 let args = parse_args_from(["oxi", "config", "disable", "skill", "my-skill"]).unwrap();
588 match args.command {
589 Some(Commands::Config { action }) => match action {
590 ConfigCommands::Disable {
591 resource_type,
592 name,
593 } => {
594 assert_eq!(resource_type, "skill");
595 assert_eq!(name, "my-skill");
596 }
597 _ => panic!("Expected Disable subcommand"),
598 },
599 _ => panic!("Expected Config command"),
600 }
601 }
602
603 #[test]
604 fn test_parse_config_list() {
605 let args = parse_args_from(["oxi", "config", "list"]).unwrap();
606 match args.command {
607 Some(Commands::Config { action }) => match action {
608 ConfigCommands::List { resource_type } => {
609 assert!(resource_type.is_none());
610 }
611 _ => panic!("Expected List subcommand"),
612 },
613 _ => panic!("Expected Config command"),
614 }
615 }
616
617 #[test]
618 fn test_parse_config_list_filtered() {
619 let args = parse_args_from(["oxi", "config", "list", "extensions"]).unwrap();
620 match args.command {
621 Some(Commands::Config { action }) => match action {
622 ConfigCommands::List { resource_type } => {
623 assert_eq!(resource_type, Some("extensions".to_string()));
624 }
625 _ => panic!("Expected List subcommand"),
626 },
627 _ => panic!("Expected Config command"),
628 }
629 }
630
631 #[test]
632 fn test_thinking_level_reexport() {
633 assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
635 }
636
637 #[test]
638 fn test_parse_config_add_provider() {
639 let args = parse_args_from([
640 "oxi",
641 "config",
642 "add-provider",
643 "minimax",
644 "https://api.minimax.chat/v1",
645 "MINIMAX_API_KEY",
646 "openai-completions",
647 ])
648 .unwrap();
649 match args.command {
650 Some(Commands::Config { action }) => match action {
651 ConfigCommands::AddProvider {
652 name,
653 base_url,
654 api_key_env,
655 api,
656 } => {
657 assert_eq!(name, "minimax");
658 assert_eq!(base_url, "https://api.minimax.chat/v1");
659 assert_eq!(api_key_env, "MINIMAX_API_KEY");
660 assert_eq!(api, "openai-completions");
661 }
662 _ => panic!("Expected AddProvider subcommand"),
663 },
664 _ => panic!("Expected Config command"),
665 }
666 }
667
668 #[test]
669 fn test_parse_config_add_provider_default_api() {
670 let args = parse_args_from([
671 "oxi",
672 "config",
673 "add-provider",
674 "zai",
675 "https://api.z.ai/v1",
676 "ZAI_API_KEY",
677 ])
678 .unwrap();
679 match args.command {
680 Some(Commands::Config { action }) => match action {
681 ConfigCommands::AddProvider {
682 name,
683 base_url,
684 api_key_env,
685 api,
686 } => {
687 assert_eq!(name, "zai");
688 assert_eq!(base_url, "https://api.z.ai/v1");
689 assert_eq!(api_key_env, "ZAI_API_KEY");
690 assert_eq!(api, "openai-completions"); }
692 _ => panic!("Expected AddProvider subcommand"),
693 },
694 _ => panic!("Expected Config command"),
695 }
696 }
697
698 #[test]
699 fn test_parse_config_remove_provider() {
700 let args = parse_args_from(["oxi", "config", "remove-provider", "minimax"]).unwrap();
701 match args.command {
702 Some(Commands::Config { action }) => match action {
703 ConfigCommands::RemoveProvider { name } => {
704 assert_eq!(name, "minimax");
705 }
706 _ => panic!("Expected RemoveProvider subcommand"),
707 },
708 _ => panic!("Expected Config command"),
709 }
710 }
711
712 #[test]
713 fn test_parse_models_command() {
714 let args = parse_args_from(["oxi", "models"]).unwrap();
715 match args.command {
716 Some(Commands::Models { provider }) => {
717 assert!(provider.is_none());
718 }
719 _ => panic!("Expected Models command"),
720 }
721 }
722
723 #[test]
724 fn test_parse_models_with_provider() {
725 let args = parse_args_from(["oxi", "models", "--provider", "minimax"]).unwrap();
726 match args.command {
727 Some(Commands::Models { provider }) => {
728 assert_eq!(provider, Some("minimax".to_string()));
729 }
730 _ => panic!("Expected Models command"),
731 }
732 }
733
734 #[test]
735 fn test_parse_setup_command() {
736 let args = parse_args_from(["oxi", "setup"]).unwrap();
737 match args.command {
738 Some(Commands::Setup { reset }) => {
739 assert!(!reset);
740 }
741 _ => panic!("Expected Setup command"),
742 }
743 }
744
745 #[test]
746 fn test_parse_setup_reset() {
747 let args = parse_args_from(["oxi", "setup", "--reset"]).unwrap();
748 match args.command {
749 Some(Commands::Setup { reset }) => {
750 assert!(reset);
751 }
752 _ => panic!("Expected Setup command with reset"),
753 }
754 }
755
756 #[test]
759 fn test_parse_enable_routing_flag() {
760 let args = parse_args_from(["oxi", "--enable-routing", "Hello"]).unwrap();
761 assert!(args.enable_routing);
762 assert!(!args.prefer_cost_efficient);
763 assert!(args.fallback_chain.is_empty());
764 assert!(!args.disable_fallback);
765 }
766
767 #[test]
768 fn test_parse_prefer_cost_efficient_flag() {
769 let args = parse_args_from(["oxi", "--prefer-cost-efficient", "Hello"]).unwrap();
770 assert!(!args.enable_routing); assert!(args.prefer_cost_efficient);
773 assert!(args.fallback_chain.is_empty());
774 assert!(!args.disable_fallback);
775 }
776
777 #[test]
778 fn test_parse_fallback_chain_single() {
779 let args = parse_args_from(["oxi", "--fallback-chain", "openai/gpt-4o", "Hello"]).unwrap();
780 assert_eq!(args.fallback_chain, vec!["openai/gpt-4o"]);
781 }
782
783 #[test]
784 fn test_parse_fallback_chain_comma_separated() {
785 let args = parse_args_from([
786 "oxi",
787 "--fallback-chain",
788 "openai/gpt-4o,anthropic/claude-3",
789 "Hello",
790 ])
791 .unwrap();
792 assert_eq!(
793 args.fallback_chain,
794 vec!["openai/gpt-4o", "anthropic/claude-3"]
795 );
796 }
797
798 #[test]
799 fn test_parse_fallback_chain_multiple_args() {
800 let args = parse_args_from([
801 "oxi",
802 "--fallback-chain",
803 "openai/gpt-4o",
804 "--fallback-chain",
805 "anthropic/claude-3",
806 "Hello",
807 ])
808 .unwrap();
809 assert_eq!(
810 args.fallback_chain,
811 vec!["openai/gpt-4o", "anthropic/claude-3"]
812 );
813 }
814
815 #[test]
816 fn test_parse_fallback_chain_empty() {
817 let args = parse_args_from(["oxi", "Hello"]).unwrap();
818 assert!(args.fallback_chain.is_empty());
819 }
820
821 #[test]
822 fn test_parse_disable_fallback_flag() {
823 let args = parse_args_from(["oxi", "--disable-fallback", "Hello"]).unwrap();
824 assert!(args.disable_fallback);
825 }
826
827 #[test]
828 fn test_parse_routing_all_flags() {
829 let args = parse_args_from([
830 "oxi",
831 "--enable-routing",
832 "--prefer-cost-efficient",
833 "--fallback-chain",
834 "openai/gpt-4o,anthropic/claude-3",
835 "--disable-fallback",
836 "Hello",
837 ])
838 .unwrap();
839 assert!(args.enable_routing);
840 assert!(args.prefer_cost_efficient);
841 assert_eq!(
842 args.fallback_chain,
843 vec!["openai/gpt-4o", "anthropic/claude-3"]
844 );
845 assert!(args.disable_fallback);
846 }
847
848 #[test]
851 fn test_parse_reset_command() {
852 let args = parse_args_from(["oxi", "reset"]).unwrap();
853 match args.command {
854 Some(Commands::Reset {
855 yes,
856 include_project,
857 }) => {
858 assert!(!yes);
859 assert!(!include_project);
860 }
861 _ => panic!("Expected Reset command"),
862 }
863 }
864
865 #[test]
866 fn test_parse_reset_yes_flag() {
867 let args = parse_args_from(["oxi", "reset", "--yes"]).unwrap();
868 match args.command {
869 Some(Commands::Reset {
870 yes,
871 include_project,
872 }) => {
873 assert!(yes);
874 assert!(!include_project);
875 }
876 _ => panic!("Expected Reset command with --yes"),
877 }
878 }
879
880 #[test]
881 fn test_parse_reset_include_project() {
882 let args = parse_args_from(["oxi", "reset", "--yes", "--include-project"]).unwrap();
883 match args.command {
884 Some(Commands::Reset {
885 yes,
886 include_project,
887 }) => {
888 assert!(yes);
889 assert!(include_project);
890 }
891 _ => panic!("Expected Reset command with all flags"),
892 }
893 }
894}