1use clap::{Parser, Subcommand};
8use std::path::PathBuf;
9
10pub use oxi_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 Pkg {
124 #[command(subcommand)]
126 action: PkgCommands,
127 },
128 Config {
130 #[command(subcommand)]
132 action: ConfigCommands,
133 },
134 Ext {
136 #[command(subcommand)]
138 action: ExtCommands,
139 },
140 Models {
142 #[arg(long)]
144 provider: Option<String>,
145 },
146 Setup {
148 #[arg(long)]
150 reset: bool,
151 },
152 Reset {
157 #[arg(long, short)]
159 yes: bool,
160 #[arg(long)]
162 include_project: bool,
163 },
164 Export {
166 session_id: Option<String>,
168 #[arg(short, long)]
170 output: Option<PathBuf>,
171 },
172 Import {
174 path: PathBuf,
176 },
177 Share {
179 session_id: Option<String>,
181 },
182}
183
184#[derive(Debug, Clone, Subcommand)]
188pub enum PkgCommands {
189 Install {
191 source: String,
193 },
194 List,
196 Uninstall {
198 name: String,
200 },
201 Update {
203 name: Option<String>,
205 },
206}
207
208#[derive(Debug, Clone, Subcommand)]
212pub enum ExtCommands {
213 Install {
215 source: String,
217 #[arg(long)]
219 prerelease: bool,
220 },
221 List,
223 Remove {
225 name: String,
227 },
228 Update {
230 name: Option<String>,
232 },
233 Info {
235 source: String,
237 },
238}
239
240#[derive(Debug, Clone, Subcommand)]
244pub enum ConfigCommands {
245 Show,
247 List {
249 resource_type: Option<String>,
251 },
252 Enable {
254 resource_type: String,
256 name: String,
258 },
259 Disable {
261 resource_type: String,
263 name: String,
265 },
266 Set {
268 key: String,
270 value: String,
272 },
273 Get {
275 key: String,
277 },
278 AddProvider {
280 name: String,
282 base_url: String,
284 api_key_env: String,
286 #[arg(default_value = "openai-completions")]
288 api: String,
289 },
290 RemoveProvider {
292 name: String,
294 },
295 Reset {
297 #[arg(long, short)]
299 all: bool,
300 },
301}
302
303pub fn parse_args() -> CliArgs {
322 CliArgs::parse()
323}
324
325pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
327where
328 I: IntoIterator<Item = T>,
329 T: Into<std::ffi::OsString> + Clone,
330{
331 CliArgs::try_parse_from(iter)
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_parse_basic_prompt() {
340 let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
341 assert_eq!(args.prompt, vec!["Hello", "world"]);
342 }
343
344 #[test]
345 fn test_parse_with_provider_and_model() {
346 let args = parse_args_from([
347 "oxi",
348 "--provider",
349 "anthropic",
350 "--model",
351 "claude-sonnet-4-20250514",
352 "Hello",
353 ])
354 .unwrap();
355 assert_eq!(args.provider, Some("anthropic".to_string()));
356 assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
357 }
358
359 #[test]
360 fn test_parse_interactive_flag() {
361 let args = parse_args_from(["oxi", "-i"]).unwrap();
362 assert!(args.interactive);
363 }
364
365 #[test]
366 fn test_parse_extension_paths() {
367 let args =
368 parse_args_from(["oxi", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
369 assert_eq!(args.extensions.len(), 2);
370 }
371
372 #[test]
373 fn test_parse_sessions_command() {
374 let args = parse_args_from(["oxi", "sessions"]).unwrap();
375 assert!(matches!(args.command, Some(Commands::Sessions)));
376 }
377
378 #[test]
379 fn test_parse_tree_command() {
380 let args = parse_args_from(["oxi", "tree", "abc-123"]).unwrap();
381 match args.command {
382 Some(Commands::Tree { session_id }) => {
383 assert_eq!(session_id, "abc-123");
384 }
385 _ => panic!("Expected Tree command"),
386 }
387 }
388
389 #[test]
390 fn test_parse_tree_command_default() {
391 let args = parse_args_from(["oxi", "tree"]).unwrap();
392 match args.command {
393 Some(Commands::Tree { session_id }) => {
394 assert_eq!(session_id, "");
395 }
396 _ => panic!("Expected Tree command"),
397 }
398 }
399
400 #[test]
401 fn test_parse_fork_command() {
402 let args = parse_args_from(["oxi", "fork", "parent-id", "entry-id"]).unwrap();
403 match args.command {
404 Some(Commands::Fork {
405 parent_id,
406 entry_id,
407 }) => {
408 assert_eq!(parent_id, "parent-id");
409 assert_eq!(entry_id, "entry-id");
410 }
411 _ => panic!("Expected Fork command"),
412 }
413 }
414
415 #[test]
416 fn test_parse_delete_command() {
417 let args = parse_args_from(["oxi", "delete", "session-123"]).unwrap();
418 match args.command {
419 Some(Commands::Delete { session_id }) => {
420 assert_eq!(session_id, "session-123");
421 }
422 _ => panic!("Expected Delete command"),
423 }
424 }
425
426 #[test]
427 fn test_parse_pkg_install() {
428 let args = parse_args_from(["oxi", "pkg", "install", "npm:@scope/name"]).unwrap();
429 match args.command {
430 Some(Commands::Pkg { action }) => match action {
431 PkgCommands::Install { source } => {
432 assert_eq!(source, "npm:@scope/name");
433 }
434 _ => panic!("Expected Install subcommand"),
435 },
436 _ => panic!("Expected Pkg command"),
437 }
438 }
439
440 #[test]
441 fn test_parse_pkg_list() {
442 let args = parse_args_from(["oxi", "pkg", "list"]).unwrap();
443 match args.command {
444 Some(Commands::Pkg { action }) => {
445 assert!(matches!(action, PkgCommands::List));
446 }
447 _ => panic!("Expected Pkg command"),
448 }
449 }
450
451 #[test]
452 fn test_parse_pkg_update_all() {
453 let args = parse_args_from(["oxi", "pkg", "update"]).unwrap();
454 match args.command {
455 Some(Commands::Pkg { action }) => match action {
456 PkgCommands::Update { name } => assert!(name.is_none()),
457 _ => panic!("Expected Update subcommand"),
458 },
459 _ => panic!("Expected Pkg command"),
460 }
461 }
462
463 #[test]
464 fn test_parse_pkg_update_named() {
465 let args = parse_args_from(["oxi", "pkg", "update", "my-pkg"]).unwrap();
466 match args.command {
467 Some(Commands::Pkg { action }) => match action {
468 PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
469 _ => panic!("Expected Update subcommand"),
470 },
471 _ => panic!("Expected Pkg command"),
472 }
473 }
474
475 #[test]
476 fn test_parse_config_show() {
477 let args = parse_args_from(["oxi", "config", "show"]).unwrap();
478 assert!(matches!(
479 args.command,
480 Some(Commands::Config {
481 action: ConfigCommands::Show
482 })
483 ));
484 }
485
486 #[test]
487 fn test_parse_config_set() {
488 let args = parse_args_from(["oxi", "config", "set", "theme", "dracula"]).unwrap();
489 match args.command {
490 Some(Commands::Config { action }) => match action {
491 ConfigCommands::Set { key, value } => {
492 assert_eq!(key, "theme");
493 assert_eq!(value, "dracula");
494 }
495 _ => panic!("Expected Set subcommand"),
496 },
497 _ => panic!("Expected Config command"),
498 }
499 }
500
501 #[test]
502 fn test_parse_config_get() {
503 let args = parse_args_from(["oxi", "config", "get", "theme"]).unwrap();
504 match args.command {
505 Some(Commands::Config { action }) => match action {
506 ConfigCommands::Get { key } => {
507 assert_eq!(key, "theme");
508 }
509 _ => panic!("Expected Get subcommand"),
510 },
511 _ => panic!("Expected Config command"),
512 }
513 }
514
515 #[test]
516 fn test_parse_config_enable() {
517 let args = parse_args_from(["oxi", "config", "enable", "extension", "my-ext"]).unwrap();
518 match args.command {
519 Some(Commands::Config { action }) => match action {
520 ConfigCommands::Enable {
521 resource_type,
522 name,
523 } => {
524 assert_eq!(resource_type, "extension");
525 assert_eq!(name, "my-ext");
526 }
527 _ => panic!("Expected Enable subcommand"),
528 },
529 _ => panic!("Expected Config command"),
530 }
531 }
532
533 #[test]
534 fn test_parse_config_disable() {
535 let args = parse_args_from(["oxi", "config", "disable", "skill", "my-skill"]).unwrap();
536 match args.command {
537 Some(Commands::Config { action }) => match action {
538 ConfigCommands::Disable {
539 resource_type,
540 name,
541 } => {
542 assert_eq!(resource_type, "skill");
543 assert_eq!(name, "my-skill");
544 }
545 _ => panic!("Expected Disable subcommand"),
546 },
547 _ => panic!("Expected Config command"),
548 }
549 }
550
551 #[test]
552 fn test_parse_config_list() {
553 let args = parse_args_from(["oxi", "config", "list"]).unwrap();
554 match args.command {
555 Some(Commands::Config { action }) => match action {
556 ConfigCommands::List { resource_type } => {
557 assert!(resource_type.is_none());
558 }
559 _ => panic!("Expected List subcommand"),
560 },
561 _ => panic!("Expected Config command"),
562 }
563 }
564
565 #[test]
566 fn test_parse_config_list_filtered() {
567 let args = parse_args_from(["oxi", "config", "list", "extensions"]).unwrap();
568 match args.command {
569 Some(Commands::Config { action }) => match action {
570 ConfigCommands::List { resource_type } => {
571 assert_eq!(resource_type, Some("extensions".to_string()));
572 }
573 _ => panic!("Expected List subcommand"),
574 },
575 _ => panic!("Expected Config command"),
576 }
577 }
578
579 #[test]
580 fn test_thinking_level_reexport() {
581 assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
583 }
584
585 #[test]
586 fn test_parse_config_add_provider() {
587 let args = parse_args_from([
588 "oxi",
589 "config",
590 "add-provider",
591 "minimax",
592 "https://api.minimax.chat/v1",
593 "MINIMAX_API_KEY",
594 "openai-completions",
595 ])
596 .unwrap();
597 match args.command {
598 Some(Commands::Config { action }) => match action {
599 ConfigCommands::AddProvider {
600 name,
601 base_url,
602 api_key_env,
603 api,
604 } => {
605 assert_eq!(name, "minimax");
606 assert_eq!(base_url, "https://api.minimax.chat/v1");
607 assert_eq!(api_key_env, "MINIMAX_API_KEY");
608 assert_eq!(api, "openai-completions");
609 }
610 _ => panic!("Expected AddProvider subcommand"),
611 },
612 _ => panic!("Expected Config command"),
613 }
614 }
615
616 #[test]
617 fn test_parse_config_add_provider_default_api() {
618 let args = parse_args_from([
619 "oxi",
620 "config",
621 "add-provider",
622 "zai",
623 "https://api.z.ai/v1",
624 "ZAI_API_KEY",
625 ])
626 .unwrap();
627 match args.command {
628 Some(Commands::Config { action }) => match action {
629 ConfigCommands::AddProvider {
630 name,
631 base_url,
632 api_key_env,
633 api,
634 } => {
635 assert_eq!(name, "zai");
636 assert_eq!(base_url, "https://api.z.ai/v1");
637 assert_eq!(api_key_env, "ZAI_API_KEY");
638 assert_eq!(api, "openai-completions"); }
640 _ => panic!("Expected AddProvider subcommand"),
641 },
642 _ => panic!("Expected Config command"),
643 }
644 }
645
646 #[test]
647 fn test_parse_config_remove_provider() {
648 let args = parse_args_from(["oxi", "config", "remove-provider", "minimax"]).unwrap();
649 match args.command {
650 Some(Commands::Config { action }) => match action {
651 ConfigCommands::RemoveProvider { name } => {
652 assert_eq!(name, "minimax");
653 }
654 _ => panic!("Expected RemoveProvider subcommand"),
655 },
656 _ => panic!("Expected Config command"),
657 }
658 }
659
660 #[test]
661 fn test_parse_models_command() {
662 let args = parse_args_from(["oxi", "models"]).unwrap();
663 match args.command {
664 Some(Commands::Models { provider }) => {
665 assert!(provider.is_none());
666 }
667 _ => panic!("Expected Models command"),
668 }
669 }
670
671 #[test]
672 fn test_parse_models_with_provider() {
673 let args = parse_args_from(["oxi", "models", "--provider", "minimax"]).unwrap();
674 match args.command {
675 Some(Commands::Models { provider }) => {
676 assert_eq!(provider, Some("minimax".to_string()));
677 }
678 _ => panic!("Expected Models command"),
679 }
680 }
681
682 #[test]
683 fn test_parse_setup_command() {
684 let args = parse_args_from(["oxi", "setup"]).unwrap();
685 match args.command {
686 Some(Commands::Setup { reset }) => {
687 assert!(!reset);
688 }
689 _ => panic!("Expected Setup command"),
690 }
691 }
692
693 #[test]
694 fn test_parse_setup_reset() {
695 let args = parse_args_from(["oxi", "setup", "--reset"]).unwrap();
696 match args.command {
697 Some(Commands::Setup { reset }) => {
698 assert!(reset);
699 }
700 _ => panic!("Expected Setup command with reset"),
701 }
702 }
703
704 #[test]
707 fn test_parse_enable_routing_flag() {
708 let args = parse_args_from(["oxi", "--enable-routing", "Hello"]).unwrap();
709 assert!(args.enable_routing);
710 assert!(!args.prefer_cost_efficient);
711 assert!(args.fallback_chain.is_empty());
712 assert!(!args.disable_fallback);
713 }
714
715 #[test]
716 fn test_parse_prefer_cost_efficient_flag() {
717 let args = parse_args_from(["oxi", "--prefer-cost-efficient", "Hello"]).unwrap();
718 assert!(!args.enable_routing); assert!(args.prefer_cost_efficient);
721 assert!(args.fallback_chain.is_empty());
722 assert!(!args.disable_fallback);
723 }
724
725 #[test]
726 fn test_parse_fallback_chain_single() {
727 let args = parse_args_from(["oxi", "--fallback-chain", "openai/gpt-4o", "Hello"]).unwrap();
728 assert_eq!(args.fallback_chain, vec!["openai/gpt-4o"]);
729 }
730
731 #[test]
732 fn test_parse_fallback_chain_comma_separated() {
733 let args = parse_args_from([
734 "oxi",
735 "--fallback-chain",
736 "openai/gpt-4o,anthropic/claude-3",
737 "Hello",
738 ])
739 .unwrap();
740 assert_eq!(
741 args.fallback_chain,
742 vec!["openai/gpt-4o", "anthropic/claude-3"]
743 );
744 }
745
746 #[test]
747 fn test_parse_fallback_chain_multiple_args() {
748 let args = parse_args_from([
749 "oxi",
750 "--fallback-chain",
751 "openai/gpt-4o",
752 "--fallback-chain",
753 "anthropic/claude-3",
754 "Hello",
755 ])
756 .unwrap();
757 assert_eq!(
758 args.fallback_chain,
759 vec!["openai/gpt-4o", "anthropic/claude-3"]
760 );
761 }
762
763 #[test]
764 fn test_parse_fallback_chain_empty() {
765 let args = parse_args_from(["oxi", "Hello"]).unwrap();
766 assert!(args.fallback_chain.is_empty());
767 }
768
769 #[test]
770 fn test_parse_disable_fallback_flag() {
771 let args = parse_args_from(["oxi", "--disable-fallback", "Hello"]).unwrap();
772 assert!(args.disable_fallback);
773 }
774
775 #[test]
776 fn test_parse_routing_all_flags() {
777 let args = parse_args_from([
778 "oxi",
779 "--enable-routing",
780 "--prefer-cost-efficient",
781 "--fallback-chain",
782 "openai/gpt-4o,anthropic/claude-3",
783 "--disable-fallback",
784 "Hello",
785 ])
786 .unwrap();
787 assert!(args.enable_routing);
788 assert!(args.prefer_cost_efficient);
789 assert_eq!(
790 args.fallback_chain,
791 vec!["openai/gpt-4o", "anthropic/claude-3"]
792 );
793 assert!(args.disable_fallback);
794 }
795
796 #[test]
799 fn test_parse_reset_command() {
800 let args = parse_args_from(["oxi", "reset"]).unwrap();
801 match args.command {
802 Some(Commands::Reset {
803 yes,
804 include_project,
805 }) => {
806 assert!(!yes);
807 assert!(!include_project);
808 }
809 _ => panic!("Expected Reset command"),
810 }
811 }
812
813 #[test]
814 fn test_parse_reset_yes_flag() {
815 let args = parse_args_from(["oxi", "reset", "--yes"]).unwrap();
816 match args.command {
817 Some(Commands::Reset {
818 yes,
819 include_project,
820 }) => {
821 assert!(yes);
822 assert!(!include_project);
823 }
824 _ => panic!("Expected Reset command with --yes"),
825 }
826 }
827
828 #[test]
829 fn test_parse_reset_include_project() {
830 let args = parse_args_from(["oxi", "reset", "--yes", "--include-project"]).unwrap();
831 match args.command {
832 Some(Commands::Reset {
833 yes,
834 include_project,
835 }) => {
836 assert!(yes);
837 assert!(include_project);
838 }
839 _ => panic!("Expected Reset command with all flags"),
840 }
841 }
842}