1use clap::{Parser, Subcommand};
8use std::path::PathBuf;
9
10pub use crate::store::settings::ThinkingLevel;
13pub mod commands;
14
15#[derive(Debug, Clone, Parser)]
19#[command(name = "oxi")]
20#[command(about = "CLI coding harness for oxi")]
21#[command(version)]
22pub struct CliArgs {
23 #[command(subcommand)]
25 pub command: Option<Commands>,
26
27 #[arg(short, long)]
29 pub provider: Option<String>,
30
31 #[arg(short, long)]
33 pub model: Option<String>,
34
35 #[arg(default_value = "")]
37 pub prompt: Vec<String>,
38
39 #[arg(short, long)]
41 pub interactive: bool,
42
43 #[arg(long)]
45 pub thinking: Option<String>,
46
47 #[arg(short = 'e', long = "extension", value_name = "PATH")]
50 pub extensions: Vec<PathBuf>,
51
52 #[arg(long)]
54 pub mode: Option<String>,
55
56 #[arg(long)]
58 pub tools: Option<String>,
59
60 #[arg(long)]
62 pub append_system_prompt: Option<PathBuf>,
63
64 #[arg(long)]
66 pub print: bool,
67
68 #[arg(long)]
70 pub no_session: bool,
71
72 #[arg(long)]
74 pub timeout: Option<u64>,
75
76 #[arg(short, long)]
78 pub continue_session: bool,
79}
80
81#[derive(Debug, Clone, Subcommand)]
85pub enum Commands {
86 Sessions,
88 Tree {
90 #[arg(default_value = "")]
92 session_id: String,
93 },
94 Fork {
96 parent_id: String,
98 entry_id: String,
100 },
101 Delete {
103 session_id: String,
105 },
106 Issue {
108 #[command(subcommand)]
110 action: IssueCommands,
111 },
112 Pkg {
114 #[command(subcommand)]
116 action: PkgCommands,
117 },
118 Config {
120 #[command(subcommand)]
122 action: ConfigCommands,
123 },
124 Ext {
126 #[command(subcommand)]
128 action: ExtCommands,
129 },
130 Models {
132 #[arg(long)]
134 provider: Option<String>,
135 },
136 Refresh {},
140 Setup {
142 #[arg(long)]
144 reset: bool,
145 },
146 Reset {
151 #[arg(long, short)]
153 yes: bool,
154 #[arg(long)]
156 include_project: bool,
157 },
158 Export {
160 session_id: Option<String>,
162 #[arg(short, long)]
164 output: Option<PathBuf>,
165 },
166 Import {
168 path: PathBuf,
170 },
171 Share {
173 session_id: Option<String>,
175 },
176 Completions {
178 shell: String,
180 },
181 Install {
183 source: String,
185 },
186 Update {
188 #[arg(long)]
190 check: bool,
191 },
192 Commit {
194 #[arg(long)]
196 push: bool,
197 #[arg(long)]
199 dry_run: bool,
200 #[arg(long, short)]
202 context: Option<String>,
203 },
204}
205
206#[derive(Debug, Clone, Subcommand)]
210pub enum PkgCommands {
211 Install {
213 source: String,
215 },
216 List,
218 Uninstall {
220 name: String,
222 },
223 Update {
225 name: Option<String>,
227 },
228}
229
230#[derive(Debug, Clone, Subcommand)]
234pub enum IssueCommands {
235 List {
237 #[arg(long)]
239 all: bool,
240 #[arg(long)]
242 label: Option<String>,
243 #[arg(long)]
245 text: Option<String>,
246 },
247 Show {
249 id: u32,
251 },
252 New {
254 title: String,
256 #[arg(long, short)]
258 body: Option<String>,
259 #[arg(long)]
261 priority: Option<String>,
262 #[arg(long)]
264 labels: Option<String>,
265 },
266 Close {
268 id: u32,
270 #[arg(long)]
272 hash: Option<String>,
273 },
274 Reopen {
276 id: u32,
278 #[arg(long)]
280 hash: Option<String>,
281 },
282 Reap,
286}
287
288#[derive(Debug, Clone, Subcommand)]
292pub enum ExtCommands {
293 Install {
295 source: String,
297 #[arg(long)]
299 prerelease: bool,
300 },
301 List,
303 Remove {
305 source: String,
307 },
308 Update {
310 source: Option<String>,
312 },
313 Info {
315 source: String,
317 },
318}
319
320#[derive(Debug, Clone, Subcommand)]
324pub enum ConfigCommands {
325 Show,
327 List {
329 resource_type: Option<String>,
331 },
332 Enable {
334 resource_type: String,
336 name: String,
338 },
339 Disable {
341 resource_type: String,
343 name: String,
345 },
346 Set {
348 key: String,
350 value: String,
352 },
353 Get {
355 key: String,
357 },
358 AddProvider {
360 name: String,
362 base_url: String,
364 api_key_env: String,
366 #[arg(default_value = "openai-completions")]
368 api: String,
369 },
370 RemoveProvider {
372 name: String,
374 },
375 Reset {
377 #[arg(long, short)]
379 all: bool,
380 },
381 Path,
383}
384
385pub fn parse_args() -> CliArgs {
404 CliArgs::parse()
405}
406
407pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
409where
410 I: IntoIterator<Item = T>,
411 T: Into<std::ffi::OsString> + Clone,
412{
413 CliArgs::try_parse_from(iter)
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn test_parse_basic_prompt() {
422 let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
423 assert_eq!(args.prompt, vec!["Hello", "world"]);
424 }
425
426 #[test]
427 fn test_parse_with_provider_and_model() {
428 let args = parse_args_from([
429 "oxi",
430 "--provider",
431 "anthropic",
432 "--model",
433 "claude-sonnet-4-20250514",
434 "Hello",
435 ])
436 .unwrap();
437 assert_eq!(args.provider, Some("anthropic".to_string()));
438 assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
439 }
440
441 #[test]
442 fn test_parse_interactive_flag() {
443 let args = parse_args_from(["oxi", "-i"]).unwrap();
444 assert!(args.interactive);
445 }
446
447 #[test]
448 fn test_parse_extension_paths() {
449 let args =
450 parse_args_from(["oxi", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
451 assert_eq!(args.extensions.len(), 2);
452 }
453
454 #[test]
455 fn test_parse_sessions_command() {
456 let args = parse_args_from(["oxi", "sessions"]).unwrap();
457 assert!(matches!(args.command, Some(Commands::Sessions)));
458 }
459
460 #[test]
461 fn test_parse_tree_command() {
462 let args = parse_args_from(["oxi", "tree", "abc-123"]).unwrap();
463 match args.command {
464 Some(Commands::Tree { session_id }) => {
465 assert_eq!(session_id, "abc-123");
466 }
467 _ => panic!("Expected Tree command"),
468 }
469 }
470
471 #[test]
472 fn test_parse_tree_command_default() {
473 let args = parse_args_from(["oxi", "tree"]).unwrap();
474 match args.command {
475 Some(Commands::Tree { session_id }) => {
476 assert_eq!(session_id, "");
477 }
478 _ => panic!("Expected Tree command"),
479 }
480 }
481
482 #[test]
483 fn test_parse_fork_command() {
484 let args = parse_args_from(["oxi", "fork", "parent-id", "entry-id"]).unwrap();
485 match args.command {
486 Some(Commands::Fork {
487 parent_id,
488 entry_id,
489 }) => {
490 assert_eq!(parent_id, "parent-id");
491 assert_eq!(entry_id, "entry-id");
492 }
493 _ => panic!("Expected Fork command"),
494 }
495 }
496
497 #[test]
498 fn test_parse_delete_command() {
499 let args = parse_args_from(["oxi", "delete", "session-123"]).unwrap();
500 match args.command {
501 Some(Commands::Delete { session_id }) => {
502 assert_eq!(session_id, "session-123");
503 }
504 _ => panic!("Expected Delete command"),
505 }
506 }
507
508 #[test]
509 fn test_parse_pkg_install() {
510 let args = parse_args_from(["oxi", "pkg", "install", "npm:@scope/name"]).unwrap();
511 match args.command {
512 Some(Commands::Pkg { action }) => match action {
513 PkgCommands::Install { source } => {
514 assert_eq!(source, "npm:@scope/name");
515 }
516 _ => panic!("Expected Install subcommand"),
517 },
518 _ => panic!("Expected Pkg command"),
519 }
520 }
521
522 #[test]
523 fn test_parse_pkg_list() {
524 let args = parse_args_from(["oxi", "pkg", "list"]).unwrap();
525 match args.command {
526 Some(Commands::Pkg { action }) => {
527 assert!(matches!(action, PkgCommands::List));
528 }
529 _ => panic!("Expected Pkg command"),
530 }
531 }
532
533 #[test]
534 fn test_parse_pkg_update_all() {
535 let args = parse_args_from(["oxi", "pkg", "update"]).unwrap();
536 match args.command {
537 Some(Commands::Pkg { action }) => match action {
538 PkgCommands::Update { name } => assert!(name.is_none()),
539 _ => panic!("Expected Update subcommand"),
540 },
541 _ => panic!("Expected Pkg command"),
542 }
543 }
544
545 #[test]
546 fn test_parse_pkg_update_named() {
547 let args = parse_args_from(["oxi", "pkg", "update", "my-pkg"]).unwrap();
548 match args.command {
549 Some(Commands::Pkg { action }) => match action {
550 PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
551 _ => panic!("Expected Update subcommand"),
552 },
553 _ => panic!("Expected Pkg command"),
554 }
555 }
556
557 #[test]
558 fn test_parse_config_show() {
559 let args = parse_args_from(["oxi", "config", "show"]).unwrap();
560 assert!(matches!(
561 args.command,
562 Some(Commands::Config {
563 action: ConfigCommands::Show
564 })
565 ));
566 }
567
568 #[test]
569 fn test_parse_config_set() {
570 let args = parse_args_from(["oxi", "config", "set", "theme", "dracula"]).unwrap();
571 match args.command {
572 Some(Commands::Config { action }) => match action {
573 ConfigCommands::Set { key, value } => {
574 assert_eq!(key, "theme");
575 assert_eq!(value, "dracula");
576 }
577 _ => panic!("Expected Set subcommand"),
578 },
579 _ => panic!("Expected Config command"),
580 }
581 }
582
583 #[test]
584 fn test_parse_config_get() {
585 let args = parse_args_from(["oxi", "config", "get", "theme"]).unwrap();
586 match args.command {
587 Some(Commands::Config { action }) => match action {
588 ConfigCommands::Get { key } => {
589 assert_eq!(key, "theme");
590 }
591 _ => panic!("Expected Get subcommand"),
592 },
593 _ => panic!("Expected Config command"),
594 }
595 }
596
597 #[test]
598 fn test_parse_config_enable() {
599 let args = parse_args_from(["oxi", "config", "enable", "extension", "my-ext"]).unwrap();
600 match args.command {
601 Some(Commands::Config { action }) => match action {
602 ConfigCommands::Enable {
603 resource_type,
604 name,
605 } => {
606 assert_eq!(resource_type, "extension");
607 assert_eq!(name, "my-ext");
608 }
609 _ => panic!("Expected Enable subcommand"),
610 },
611 _ => panic!("Expected Config command"),
612 }
613 }
614
615 #[test]
616 fn test_parse_config_disable() {
617 let args = parse_args_from(["oxi", "config", "disable", "skill", "my-skill"]).unwrap();
618 match args.command {
619 Some(Commands::Config { action }) => match action {
620 ConfigCommands::Disable {
621 resource_type,
622 name,
623 } => {
624 assert_eq!(resource_type, "skill");
625 assert_eq!(name, "my-skill");
626 }
627 _ => panic!("Expected Disable subcommand"),
628 },
629 _ => panic!("Expected Config command"),
630 }
631 }
632
633 #[test]
634 fn test_parse_config_list() {
635 let args = parse_args_from(["oxi", "config", "list"]).unwrap();
636 match args.command {
637 Some(Commands::Config { action }) => match action {
638 ConfigCommands::List { resource_type } => {
639 assert!(resource_type.is_none());
640 }
641 _ => panic!("Expected List subcommand"),
642 },
643 _ => panic!("Expected Config command"),
644 }
645 }
646
647 #[test]
648 fn test_parse_config_list_filtered() {
649 let args = parse_args_from(["oxi", "config", "list", "extensions"]).unwrap();
650 match args.command {
651 Some(Commands::Config { action }) => match action {
652 ConfigCommands::List { resource_type } => {
653 assert_eq!(resource_type, Some("extensions".to_string()));
654 }
655 _ => panic!("Expected List subcommand"),
656 },
657 _ => panic!("Expected Config command"),
658 }
659 }
660
661 #[test]
662 fn test_thinking_level_reexport() {
663 assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
665 }
666
667 #[test]
668 fn test_parse_config_add_provider() {
669 let args = parse_args_from([
670 "oxi",
671 "config",
672 "add-provider",
673 "minimax",
674 "https://api.minimax.chat/v1",
675 "MINIMAX_API_KEY",
676 "openai-completions",
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, "minimax");
688 assert_eq!(base_url, "https://api.minimax.chat/v1");
689 assert_eq!(api_key_env, "MINIMAX_API_KEY");
690 assert_eq!(api, "openai-completions");
691 }
692 _ => panic!("Expected AddProvider subcommand"),
693 },
694 _ => panic!("Expected Config command"),
695 }
696 }
697
698 #[test]
699 fn test_parse_config_add_provider_default_api() {
700 let args = parse_args_from([
701 "oxi",
702 "config",
703 "add-provider",
704 "zai",
705 "https://api.z.ai/v1",
706 "ZAI_API_KEY",
707 ])
708 .unwrap();
709 match args.command {
710 Some(Commands::Config { action }) => match action {
711 ConfigCommands::AddProvider {
712 name,
713 base_url,
714 api_key_env,
715 api,
716 } => {
717 assert_eq!(name, "zai");
718 assert_eq!(base_url, "https://api.z.ai/v1");
719 assert_eq!(api_key_env, "ZAI_API_KEY");
720 assert_eq!(api, "openai-completions"); }
722 _ => panic!("Expected AddProvider subcommand"),
723 },
724 _ => panic!("Expected Config command"),
725 }
726 }
727
728 #[test]
729 fn test_parse_config_remove_provider() {
730 let args = parse_args_from(["oxi", "config", "remove-provider", "minimax"]).unwrap();
731 match args.command {
732 Some(Commands::Config { action }) => match action {
733 ConfigCommands::RemoveProvider { name } => {
734 assert_eq!(name, "minimax");
735 }
736 _ => panic!("Expected RemoveProvider subcommand"),
737 },
738 _ => panic!("Expected Config command"),
739 }
740 }
741
742 #[test]
743 fn test_parse_models_command() {
744 let args = parse_args_from(["oxi", "models"]).unwrap();
745 match args.command {
746 Some(Commands::Models { provider }) => {
747 assert!(provider.is_none());
748 }
749 _ => panic!("Expected Models command"),
750 }
751 }
752
753 #[test]
754 fn test_parse_models_with_provider() {
755 let args = parse_args_from(["oxi", "models", "--provider", "minimax"]).unwrap();
756 match args.command {
757 Some(Commands::Models { provider }) => {
758 assert_eq!(provider, Some("minimax".to_string()));
759 }
760 _ => panic!("Expected Models command"),
761 }
762 }
763
764 #[test]
765 fn test_parse_setup_command() {
766 let args = parse_args_from(["oxi", "setup"]).unwrap();
767 match args.command {
768 Some(Commands::Setup { reset }) => {
769 assert!(!reset);
770 }
771 _ => panic!("Expected Setup command"),
772 }
773 }
774
775 #[test]
776 fn test_parse_setup_reset() {
777 let args = parse_args_from(["oxi", "setup", "--reset"]).unwrap();
778 match args.command {
779 Some(Commands::Setup { reset }) => {
780 assert!(reset);
781 }
782 _ => panic!("Expected Setup command with reset"),
783 }
784 }
785
786 #[test]
789 fn test_parse_reset_command() {
790 let args = parse_args_from(["oxi", "reset"]).unwrap();
791 match args.command {
792 Some(Commands::Reset {
793 yes,
794 include_project,
795 }) => {
796 assert!(!yes);
797 assert!(!include_project);
798 }
799 _ => panic!("Expected Reset command"),
800 }
801 }
802
803 #[test]
804 fn test_parse_reset_yes_flag() {
805 let args = parse_args_from(["oxi", "reset", "--yes"]).unwrap();
806 match args.command {
807 Some(Commands::Reset {
808 yes,
809 include_project,
810 }) => {
811 assert!(yes);
812 assert!(!include_project);
813 }
814 _ => panic!("Expected Reset command with --yes"),
815 }
816 }
817
818 #[test]
819 fn test_parse_reset_include_project() {
820 let args = parse_args_from(["oxi", "reset", "--yes", "--include-project"]).unwrap();
821 match args.command {
822 Some(Commands::Reset {
823 yes,
824 include_project,
825 }) => {
826 assert!(yes);
827 assert!(include_project);
828 }
829 _ => panic!("Expected Reset command with all flags"),
830 }
831 }
832}