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 Refresh {},
156 Setup {
158 #[arg(long)]
160 reset: bool,
161 },
162 Reset {
167 #[arg(long, short)]
169 yes: bool,
170 #[arg(long)]
172 include_project: bool,
173 },
174 Export {
176 session_id: Option<String>,
178 #[arg(short, long)]
180 output: Option<PathBuf>,
181 },
182 Import {
184 path: PathBuf,
186 },
187 Share {
189 session_id: Option<String>,
191 },
192}
193
194#[derive(Debug, Clone, Subcommand)]
198pub enum PkgCommands {
199 Install {
201 source: String,
203 },
204 List,
206 Uninstall {
208 name: String,
210 },
211 Update {
213 name: Option<String>,
215 },
216}
217
218#[derive(Debug, Clone, Subcommand)]
222pub enum IssueCommands {
223 List {
225 #[arg(long)]
227 all: bool,
228 #[arg(long)]
230 label: Option<String>,
231 #[arg(long)]
233 text: Option<String>,
234 },
235 Show {
237 id: u32,
239 },
240 New {
242 title: String,
244 #[arg(long, short)]
246 body: Option<String>,
247 #[arg(long)]
249 priority: Option<String>,
250 #[arg(long)]
252 labels: Option<String>,
253 },
254 Close {
256 id: u32,
258 #[arg(long)]
260 hash: Option<String>,
261 },
262 Reopen {
264 id: u32,
266 #[arg(long)]
268 hash: Option<String>,
269 },
270 Reap,
274}
275
276#[derive(Debug, Clone, Subcommand)]
280pub enum ExtCommands {
281 Install {
283 source: String,
285 #[arg(long)]
287 prerelease: bool,
288 },
289 List,
291 Remove {
293 source: String,
295 },
296 Update {
298 source: Option<String>,
300 },
301 Info {
303 source: String,
305 },
306}
307
308#[derive(Debug, Clone, Subcommand)]
312pub enum ConfigCommands {
313 Show,
315 List {
317 resource_type: Option<String>,
319 },
320 Enable {
322 resource_type: String,
324 name: String,
326 },
327 Disable {
329 resource_type: String,
331 name: String,
333 },
334 Set {
336 key: String,
338 value: String,
340 },
341 Get {
343 key: String,
345 },
346 AddProvider {
348 name: String,
350 base_url: String,
352 api_key_env: String,
354 #[arg(default_value = "openai-completions")]
356 api: String,
357 },
358 RemoveProvider {
360 name: String,
362 },
363 Reset {
365 #[arg(long, short)]
367 all: bool,
368 },
369}
370
371pub fn parse_args() -> CliArgs {
390 CliArgs::parse()
391}
392
393pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
395where
396 I: IntoIterator<Item = T>,
397 T: Into<std::ffi::OsString> + Clone,
398{
399 CliArgs::try_parse_from(iter)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn test_parse_basic_prompt() {
408 let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
409 assert_eq!(args.prompt, vec!["Hello", "world"]);
410 }
411
412 #[test]
413 fn test_parse_with_provider_and_model() {
414 let args = parse_args_from([
415 "oxi",
416 "--provider",
417 "anthropic",
418 "--model",
419 "claude-sonnet-4-20250514",
420 "Hello",
421 ])
422 .unwrap();
423 assert_eq!(args.provider, Some("anthropic".to_string()));
424 assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
425 }
426
427 #[test]
428 fn test_parse_interactive_flag() {
429 let args = parse_args_from(["oxi", "-i"]).unwrap();
430 assert!(args.interactive);
431 }
432
433 #[test]
434 fn test_parse_extension_paths() {
435 let args =
436 parse_args_from(["oxi", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
437 assert_eq!(args.extensions.len(), 2);
438 }
439
440 #[test]
441 fn test_parse_sessions_command() {
442 let args = parse_args_from(["oxi", "sessions"]).unwrap();
443 assert!(matches!(args.command, Some(Commands::Sessions)));
444 }
445
446 #[test]
447 fn test_parse_tree_command() {
448 let args = parse_args_from(["oxi", "tree", "abc-123"]).unwrap();
449 match args.command {
450 Some(Commands::Tree { session_id }) => {
451 assert_eq!(session_id, "abc-123");
452 }
453 _ => panic!("Expected Tree command"),
454 }
455 }
456
457 #[test]
458 fn test_parse_tree_command_default() {
459 let args = parse_args_from(["oxi", "tree"]).unwrap();
460 match args.command {
461 Some(Commands::Tree { session_id }) => {
462 assert_eq!(session_id, "");
463 }
464 _ => panic!("Expected Tree command"),
465 }
466 }
467
468 #[test]
469 fn test_parse_fork_command() {
470 let args = parse_args_from(["oxi", "fork", "parent-id", "entry-id"]).unwrap();
471 match args.command {
472 Some(Commands::Fork {
473 parent_id,
474 entry_id,
475 }) => {
476 assert_eq!(parent_id, "parent-id");
477 assert_eq!(entry_id, "entry-id");
478 }
479 _ => panic!("Expected Fork command"),
480 }
481 }
482
483 #[test]
484 fn test_parse_delete_command() {
485 let args = parse_args_from(["oxi", "delete", "session-123"]).unwrap();
486 match args.command {
487 Some(Commands::Delete { session_id }) => {
488 assert_eq!(session_id, "session-123");
489 }
490 _ => panic!("Expected Delete command"),
491 }
492 }
493
494 #[test]
495 fn test_parse_pkg_install() {
496 let args = parse_args_from(["oxi", "pkg", "install", "npm:@scope/name"]).unwrap();
497 match args.command {
498 Some(Commands::Pkg { action }) => match action {
499 PkgCommands::Install { source } => {
500 assert_eq!(source, "npm:@scope/name");
501 }
502 _ => panic!("Expected Install subcommand"),
503 },
504 _ => panic!("Expected Pkg command"),
505 }
506 }
507
508 #[test]
509 fn test_parse_pkg_list() {
510 let args = parse_args_from(["oxi", "pkg", "list"]).unwrap();
511 match args.command {
512 Some(Commands::Pkg { action }) => {
513 assert!(matches!(action, PkgCommands::List));
514 }
515 _ => panic!("Expected Pkg command"),
516 }
517 }
518
519 #[test]
520 fn test_parse_pkg_update_all() {
521 let args = parse_args_from(["oxi", "pkg", "update"]).unwrap();
522 match args.command {
523 Some(Commands::Pkg { action }) => match action {
524 PkgCommands::Update { name } => assert!(name.is_none()),
525 _ => panic!("Expected Update subcommand"),
526 },
527 _ => panic!("Expected Pkg command"),
528 }
529 }
530
531 #[test]
532 fn test_parse_pkg_update_named() {
533 let args = parse_args_from(["oxi", "pkg", "update", "my-pkg"]).unwrap();
534 match args.command {
535 Some(Commands::Pkg { action }) => match action {
536 PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
537 _ => panic!("Expected Update subcommand"),
538 },
539 _ => panic!("Expected Pkg command"),
540 }
541 }
542
543 #[test]
544 fn test_parse_config_show() {
545 let args = parse_args_from(["oxi", "config", "show"]).unwrap();
546 assert!(matches!(
547 args.command,
548 Some(Commands::Config {
549 action: ConfigCommands::Show
550 })
551 ));
552 }
553
554 #[test]
555 fn test_parse_config_set() {
556 let args = parse_args_from(["oxi", "config", "set", "theme", "dracula"]).unwrap();
557 match args.command {
558 Some(Commands::Config { action }) => match action {
559 ConfigCommands::Set { key, value } => {
560 assert_eq!(key, "theme");
561 assert_eq!(value, "dracula");
562 }
563 _ => panic!("Expected Set subcommand"),
564 },
565 _ => panic!("Expected Config command"),
566 }
567 }
568
569 #[test]
570 fn test_parse_config_get() {
571 let args = parse_args_from(["oxi", "config", "get", "theme"]).unwrap();
572 match args.command {
573 Some(Commands::Config { action }) => match action {
574 ConfigCommands::Get { key } => {
575 assert_eq!(key, "theme");
576 }
577 _ => panic!("Expected Get subcommand"),
578 },
579 _ => panic!("Expected Config command"),
580 }
581 }
582
583 #[test]
584 fn test_parse_config_enable() {
585 let args = parse_args_from(["oxi", "config", "enable", "extension", "my-ext"]).unwrap();
586 match args.command {
587 Some(Commands::Config { action }) => match action {
588 ConfigCommands::Enable {
589 resource_type,
590 name,
591 } => {
592 assert_eq!(resource_type, "extension");
593 assert_eq!(name, "my-ext");
594 }
595 _ => panic!("Expected Enable subcommand"),
596 },
597 _ => panic!("Expected Config command"),
598 }
599 }
600
601 #[test]
602 fn test_parse_config_disable() {
603 let args = parse_args_from(["oxi", "config", "disable", "skill", "my-skill"]).unwrap();
604 match args.command {
605 Some(Commands::Config { action }) => match action {
606 ConfigCommands::Disable {
607 resource_type,
608 name,
609 } => {
610 assert_eq!(resource_type, "skill");
611 assert_eq!(name, "my-skill");
612 }
613 _ => panic!("Expected Disable subcommand"),
614 },
615 _ => panic!("Expected Config command"),
616 }
617 }
618
619 #[test]
620 fn test_parse_config_list() {
621 let args = parse_args_from(["oxi", "config", "list"]).unwrap();
622 match args.command {
623 Some(Commands::Config { action }) => match action {
624 ConfigCommands::List { resource_type } => {
625 assert!(resource_type.is_none());
626 }
627 _ => panic!("Expected List subcommand"),
628 },
629 _ => panic!("Expected Config command"),
630 }
631 }
632
633 #[test]
634 fn test_parse_config_list_filtered() {
635 let args = parse_args_from(["oxi", "config", "list", "extensions"]).unwrap();
636 match args.command {
637 Some(Commands::Config { action }) => match action {
638 ConfigCommands::List { resource_type } => {
639 assert_eq!(resource_type, Some("extensions".to_string()));
640 }
641 _ => panic!("Expected List subcommand"),
642 },
643 _ => panic!("Expected Config command"),
644 }
645 }
646
647 #[test]
648 fn test_thinking_level_reexport() {
649 assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
651 }
652
653 #[test]
654 fn test_parse_config_add_provider() {
655 let args = parse_args_from([
656 "oxi",
657 "config",
658 "add-provider",
659 "minimax",
660 "https://api.minimax.chat/v1",
661 "MINIMAX_API_KEY",
662 "openai-completions",
663 ])
664 .unwrap();
665 match args.command {
666 Some(Commands::Config { action }) => match action {
667 ConfigCommands::AddProvider {
668 name,
669 base_url,
670 api_key_env,
671 api,
672 } => {
673 assert_eq!(name, "minimax");
674 assert_eq!(base_url, "https://api.minimax.chat/v1");
675 assert_eq!(api_key_env, "MINIMAX_API_KEY");
676 assert_eq!(api, "openai-completions");
677 }
678 _ => panic!("Expected AddProvider subcommand"),
679 },
680 _ => panic!("Expected Config command"),
681 }
682 }
683
684 #[test]
685 fn test_parse_config_add_provider_default_api() {
686 let args = parse_args_from([
687 "oxi",
688 "config",
689 "add-provider",
690 "zai",
691 "https://api.z.ai/v1",
692 "ZAI_API_KEY",
693 ])
694 .unwrap();
695 match args.command {
696 Some(Commands::Config { action }) => match action {
697 ConfigCommands::AddProvider {
698 name,
699 base_url,
700 api_key_env,
701 api,
702 } => {
703 assert_eq!(name, "zai");
704 assert_eq!(base_url, "https://api.z.ai/v1");
705 assert_eq!(api_key_env, "ZAI_API_KEY");
706 assert_eq!(api, "openai-completions"); }
708 _ => panic!("Expected AddProvider subcommand"),
709 },
710 _ => panic!("Expected Config command"),
711 }
712 }
713
714 #[test]
715 fn test_parse_config_remove_provider() {
716 let args = parse_args_from(["oxi", "config", "remove-provider", "minimax"]).unwrap();
717 match args.command {
718 Some(Commands::Config { action }) => match action {
719 ConfigCommands::RemoveProvider { name } => {
720 assert_eq!(name, "minimax");
721 }
722 _ => panic!("Expected RemoveProvider subcommand"),
723 },
724 _ => panic!("Expected Config command"),
725 }
726 }
727
728 #[test]
729 fn test_parse_models_command() {
730 let args = parse_args_from(["oxi", "models"]).unwrap();
731 match args.command {
732 Some(Commands::Models { provider }) => {
733 assert!(provider.is_none());
734 }
735 _ => panic!("Expected Models command"),
736 }
737 }
738
739 #[test]
740 fn test_parse_models_with_provider() {
741 let args = parse_args_from(["oxi", "models", "--provider", "minimax"]).unwrap();
742 match args.command {
743 Some(Commands::Models { provider }) => {
744 assert_eq!(provider, Some("minimax".to_string()));
745 }
746 _ => panic!("Expected Models command"),
747 }
748 }
749
750 #[test]
751 fn test_parse_setup_command() {
752 let args = parse_args_from(["oxi", "setup"]).unwrap();
753 match args.command {
754 Some(Commands::Setup { reset }) => {
755 assert!(!reset);
756 }
757 _ => panic!("Expected Setup command"),
758 }
759 }
760
761 #[test]
762 fn test_parse_setup_reset() {
763 let args = parse_args_from(["oxi", "setup", "--reset"]).unwrap();
764 match args.command {
765 Some(Commands::Setup { reset }) => {
766 assert!(reset);
767 }
768 _ => panic!("Expected Setup command with reset"),
769 }
770 }
771
772 #[test]
775 fn test_parse_enable_routing_flag() {
776 let args = parse_args_from(["oxi", "--enable-routing", "Hello"]).unwrap();
777 assert!(args.enable_routing);
778 assert!(!args.prefer_cost_efficient);
779 assert!(args.fallback_chain.is_empty());
780 assert!(!args.disable_fallback);
781 }
782
783 #[test]
784 fn test_parse_prefer_cost_efficient_flag() {
785 let args = parse_args_from(["oxi", "--prefer-cost-efficient", "Hello"]).unwrap();
786 assert!(!args.enable_routing); assert!(args.prefer_cost_efficient);
789 assert!(args.fallback_chain.is_empty());
790 assert!(!args.disable_fallback);
791 }
792
793 #[test]
794 fn test_parse_fallback_chain_single() {
795 let args = parse_args_from(["oxi", "--fallback-chain", "openai/gpt-4o", "Hello"]).unwrap();
796 assert_eq!(args.fallback_chain, vec!["openai/gpt-4o"]);
797 }
798
799 #[test]
800 fn test_parse_fallback_chain_comma_separated() {
801 let args = parse_args_from([
802 "oxi",
803 "--fallback-chain",
804 "openai/gpt-4o,anthropic/claude-3",
805 "Hello",
806 ])
807 .unwrap();
808 assert_eq!(
809 args.fallback_chain,
810 vec!["openai/gpt-4o", "anthropic/claude-3"]
811 );
812 }
813
814 #[test]
815 fn test_parse_fallback_chain_multiple_args() {
816 let args = parse_args_from([
817 "oxi",
818 "--fallback-chain",
819 "openai/gpt-4o",
820 "--fallback-chain",
821 "anthropic/claude-3",
822 "Hello",
823 ])
824 .unwrap();
825 assert_eq!(
826 args.fallback_chain,
827 vec!["openai/gpt-4o", "anthropic/claude-3"]
828 );
829 }
830
831 #[test]
832 fn test_parse_fallback_chain_empty() {
833 let args = parse_args_from(["oxi", "Hello"]).unwrap();
834 assert!(args.fallback_chain.is_empty());
835 }
836
837 #[test]
838 fn test_parse_disable_fallback_flag() {
839 let args = parse_args_from(["oxi", "--disable-fallback", "Hello"]).unwrap();
840 assert!(args.disable_fallback);
841 }
842
843 #[test]
844 fn test_parse_routing_all_flags() {
845 let args = parse_args_from([
846 "oxi",
847 "--enable-routing",
848 "--prefer-cost-efficient",
849 "--fallback-chain",
850 "openai/gpt-4o,anthropic/claude-3",
851 "--disable-fallback",
852 "Hello",
853 ])
854 .unwrap();
855 assert!(args.enable_routing);
856 assert!(args.prefer_cost_efficient);
857 assert_eq!(
858 args.fallback_chain,
859 vec!["openai/gpt-4o", "anthropic/claude-3"]
860 );
861 assert!(args.disable_fallback);
862 }
863
864 #[test]
867 fn test_parse_reset_command() {
868 let args = parse_args_from(["oxi", "reset"]).unwrap();
869 match args.command {
870 Some(Commands::Reset {
871 yes,
872 include_project,
873 }) => {
874 assert!(!yes);
875 assert!(!include_project);
876 }
877 _ => panic!("Expected Reset command"),
878 }
879 }
880
881 #[test]
882 fn test_parse_reset_yes_flag() {
883 let args = parse_args_from(["oxi", "reset", "--yes"]).unwrap();
884 match args.command {
885 Some(Commands::Reset {
886 yes,
887 include_project,
888 }) => {
889 assert!(yes);
890 assert!(!include_project);
891 }
892 _ => panic!("Expected Reset command with --yes"),
893 }
894 }
895
896 #[test]
897 fn test_parse_reset_include_project() {
898 let args = parse_args_from(["oxi", "reset", "--yes", "--include-project"]).unwrap();
899 match args.command {
900 Some(Commands::Reset {
901 yes,
902 include_project,
903 }) => {
904 assert!(yes);
905 assert!(include_project);
906 }
907 _ => panic!("Expected Reset command with all flags"),
908 }
909 }
910}