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 = "oxicode")]
20#[command(about = "CLI coding harness for oxicode")]
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 Migrate {
206 #[command(subcommand)]
208 action: MigrationCommands,
209 },
210}
211
212#[derive(Debug, Clone, Subcommand)]
214pub enum MigrationCommands {
215 Brain(MigrateBrainArgs),
217}
218
219#[derive(Debug, Clone, clap::Args)]
220pub struct MigrateBrainArgs {
221 #[arg(long)]
224 pub socket: Option<PathBuf>,
225 #[arg(long)]
227 pub dry_run: bool,
228 #[arg(long)]
231 pub archive_legacy: bool,
232 #[arg(long, default_value = "~/.oxicode/migration/brain.json")]
234 pub checkpoint: PathBuf,
235 #[arg(long, default_value = "64")]
237 pub batch_size: usize,
238}
239
240#[derive(Debug, Clone, Subcommand)]
244pub enum PkgCommands {
245 Install {
247 source: String,
249 },
250 List,
252 Uninstall {
254 name: String,
256 },
257 Update {
259 name: Option<String>,
261 },
262}
263
264#[derive(Debug, Clone, Subcommand)]
268pub enum IssueCommands {
269 List {
271 #[arg(long)]
273 all: bool,
274 #[arg(long)]
276 label: Option<String>,
277 #[arg(long)]
279 text: Option<String>,
280 },
281 Show {
283 id: u32,
285 },
286 New {
288 title: String,
290 #[arg(long, short)]
292 body: Option<String>,
293 #[arg(long)]
295 priority: Option<String>,
296 #[arg(long)]
298 labels: Option<String>,
299 },
300 Close {
302 id: u32,
304 #[arg(long)]
306 hash: Option<String>,
307 },
308 Reopen {
310 id: u32,
312 #[arg(long)]
314 hash: Option<String>,
315 },
316 Reap,
320}
321
322#[derive(Debug, Clone, Subcommand)]
326pub enum ExtCommands {
327 Install {
329 source: String,
331 #[arg(long)]
333 prerelease: bool,
334 },
335 List,
337 Remove {
339 source: String,
341 },
342 Update {
344 source: Option<String>,
346 },
347 Info {
349 source: String,
351 },
352}
353
354#[derive(Debug, Clone, Subcommand)]
358pub enum ConfigCommands {
359 Show,
361 List {
363 resource_type: Option<String>,
365 },
366 Enable {
368 resource_type: String,
370 name: String,
372 },
373 Disable {
375 resource_type: String,
377 name: String,
379 },
380 Set {
382 key: String,
384 value: String,
386 },
387 Get {
389 key: String,
391 },
392 AddProvider {
394 name: String,
396 base_url: String,
398 api_key_env: String,
400 #[arg(default_value = "openai-completions")]
402 api: String,
403 },
404 RemoveProvider {
406 name: String,
408 },
409 Reset {
411 #[arg(long, short)]
413 all: bool,
414 },
415 Path,
417}
418
419pub fn parse_args() -> CliArgs {
438 CliArgs::parse()
439}
440
441pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
443where
444 I: IntoIterator<Item = T>,
445 T: Into<std::ffi::OsString> + Clone,
446{
447 CliArgs::try_parse_from(iter)
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn test_parse_basic_prompt() {
456 let args = parse_args_from(["oxicode", "Hello", "world"]).unwrap();
457 assert_eq!(args.prompt, vec!["Hello", "world"]);
458 }
459
460 #[test]
461 fn test_parse_with_provider_and_model() {
462 let args = parse_args_from([
463 "oxicode",
464 "--provider",
465 "anthropic",
466 "--model",
467 "claude-sonnet-4-20250514",
468 "Hello",
469 ])
470 .unwrap();
471 assert_eq!(args.provider, Some("anthropic".to_string()));
472 assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
473 }
474
475 #[test]
476 fn test_parse_interactive_flag() {
477 let args = parse_args_from(["oxicode", "-i"]).unwrap();
478 assert!(args.interactive);
479 }
480
481 #[test]
482 fn test_parse_extension_paths() {
483 let args =
484 parse_args_from(["oxicode", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
485 assert_eq!(args.extensions.len(), 2);
486 }
487
488 #[test]
489 fn test_parse_sessions_command() {
490 let args = parse_args_from(["oxicode", "sessions"]).unwrap();
491 assert!(matches!(args.command, Some(Commands::Sessions)));
492 }
493
494 #[test]
495 fn test_parse_tree_command() {
496 let args = parse_args_from(["oxicode", "tree", "abc-123"]).unwrap();
497 match args.command {
498 Some(Commands::Tree { session_id }) => {
499 assert_eq!(session_id, "abc-123");
500 }
501 _ => panic!("Expected Tree command"),
502 }
503 }
504
505 #[test]
506 fn test_parse_tree_command_default() {
507 let args = parse_args_from(["oxicode", "tree"]).unwrap();
508 match args.command {
509 Some(Commands::Tree { session_id }) => {
510 assert_eq!(session_id, "");
511 }
512 _ => panic!("Expected Tree command"),
513 }
514 }
515
516 #[test]
517 fn test_parse_fork_command() {
518 let args = parse_args_from(["oxicode", "fork", "parent-id", "entry-id"]).unwrap();
519 match args.command {
520 Some(Commands::Fork {
521 parent_id,
522 entry_id,
523 }) => {
524 assert_eq!(parent_id, "parent-id");
525 assert_eq!(entry_id, "entry-id");
526 }
527 _ => panic!("Expected Fork command"),
528 }
529 }
530
531 #[test]
532 fn test_parse_delete_command() {
533 let args = parse_args_from(["oxicode", "delete", "session-123"]).unwrap();
534 match args.command {
535 Some(Commands::Delete { session_id }) => {
536 assert_eq!(session_id, "session-123");
537 }
538 _ => panic!("Expected Delete command"),
539 }
540 }
541
542 #[test]
543 fn test_parse_pkg_install() {
544 let args = parse_args_from(["oxicode", "pkg", "install", "npm:@scope/name"]).unwrap();
545 match args.command {
546 Some(Commands::Pkg { action }) => match action {
547 PkgCommands::Install { source } => {
548 assert_eq!(source, "npm:@scope/name");
549 }
550 _ => panic!("Expected Install subcommand"),
551 },
552 _ => panic!("Expected Pkg command"),
553 }
554 }
555
556 #[test]
557 fn test_parse_pkg_list() {
558 let args = parse_args_from(["oxicode", "pkg", "list"]).unwrap();
559 match args.command {
560 Some(Commands::Pkg { action }) => {
561 assert!(matches!(action, PkgCommands::List));
562 }
563 _ => panic!("Expected Pkg command"),
564 }
565 }
566
567 #[test]
568 fn test_parse_pkg_update_all() {
569 let args = parse_args_from(["oxicode", "pkg", "update"]).unwrap();
570 match args.command {
571 Some(Commands::Pkg { action }) => match action {
572 PkgCommands::Update { name } => assert!(name.is_none()),
573 _ => panic!("Expected Update subcommand"),
574 },
575 _ => panic!("Expected Pkg command"),
576 }
577 }
578
579 #[test]
580 fn test_parse_pkg_update_named() {
581 let args = parse_args_from(["oxicode", "pkg", "update", "my-pkg"]).unwrap();
582 match args.command {
583 Some(Commands::Pkg { action }) => match action {
584 PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
585 _ => panic!("Expected Update subcommand"),
586 },
587 _ => panic!("Expected Pkg command"),
588 }
589 }
590
591 #[test]
592 fn test_parse_config_show() {
593 let args = parse_args_from(["oxicode", "config", "show"]).unwrap();
594 assert!(matches!(
595 args.command,
596 Some(Commands::Config {
597 action: ConfigCommands::Show
598 })
599 ));
600 }
601
602 #[test]
603 fn test_parse_config_set() {
604 let args = parse_args_from(["oxicode", "config", "set", "theme", "dracula"]).unwrap();
605 match args.command {
606 Some(Commands::Config { action }) => match action {
607 ConfigCommands::Set { key, value } => {
608 assert_eq!(key, "theme");
609 assert_eq!(value, "dracula");
610 }
611 _ => panic!("Expected Set subcommand"),
612 },
613 _ => panic!("Expected Config command"),
614 }
615 }
616
617 #[test]
618 fn test_parse_config_get() {
619 let args = parse_args_from(["oxicode", "config", "get", "theme"]).unwrap();
620 match args.command {
621 Some(Commands::Config { action }) => match action {
622 ConfigCommands::Get { key } => {
623 assert_eq!(key, "theme");
624 }
625 _ => panic!("Expected Get subcommand"),
626 },
627 _ => panic!("Expected Config command"),
628 }
629 }
630
631 #[test]
632 fn test_parse_config_enable() {
633 let args = parse_args_from(["oxicode", "config", "enable", "extension", "my-ext"]).unwrap();
634 match args.command {
635 Some(Commands::Config { action }) => match action {
636 ConfigCommands::Enable {
637 resource_type,
638 name,
639 } => {
640 assert_eq!(resource_type, "extension");
641 assert_eq!(name, "my-ext");
642 }
643 _ => panic!("Expected Enable subcommand"),
644 },
645 _ => panic!("Expected Config command"),
646 }
647 }
648
649 #[test]
650 fn test_parse_config_disable() {
651 let args = parse_args_from(["oxicode", "config", "disable", "skill", "my-skill"]).unwrap();
652 match args.command {
653 Some(Commands::Config { action }) => match action {
654 ConfigCommands::Disable {
655 resource_type,
656 name,
657 } => {
658 assert_eq!(resource_type, "skill");
659 assert_eq!(name, "my-skill");
660 }
661 _ => panic!("Expected Disable subcommand"),
662 },
663 _ => panic!("Expected Config command"),
664 }
665 }
666
667 #[test]
668 fn test_parse_config_list() {
669 let args = parse_args_from(["oxicode", "config", "list"]).unwrap();
670 match args.command {
671 Some(Commands::Config { action }) => match action {
672 ConfigCommands::List { resource_type } => {
673 assert!(resource_type.is_none());
674 }
675 _ => panic!("Expected List subcommand"),
676 },
677 _ => panic!("Expected Config command"),
678 }
679 }
680
681 #[test]
682 fn test_parse_config_list_filtered() {
683 let args = parse_args_from(["oxicode", "config", "list", "extensions"]).unwrap();
684 match args.command {
685 Some(Commands::Config { action }) => match action {
686 ConfigCommands::List { resource_type } => {
687 assert_eq!(resource_type, Some("extensions".to_string()));
688 }
689 _ => panic!("Expected List subcommand"),
690 },
691 _ => panic!("Expected Config command"),
692 }
693 }
694
695 #[test]
696 fn test_thinking_level_reexport() {
697 assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
699 }
700
701 #[test]
702 fn test_parse_config_add_provider() {
703 let args = parse_args_from([
704 "oxicode",
705 "config",
706 "add-provider",
707 "minimax",
708 "https://api.minimax.chat/v1",
709 "MINIMAX_API_KEY",
710 "openai-completions",
711 ])
712 .unwrap();
713 match args.command {
714 Some(Commands::Config { action }) => match action {
715 ConfigCommands::AddProvider {
716 name,
717 base_url,
718 api_key_env,
719 api,
720 } => {
721 assert_eq!(name, "minimax");
722 assert_eq!(base_url, "https://api.minimax.chat/v1");
723 assert_eq!(api_key_env, "MINIMAX_API_KEY");
724 assert_eq!(api, "openai-completions");
725 }
726 _ => panic!("Expected AddProvider subcommand"),
727 },
728 _ => panic!("Expected Config command"),
729 }
730 }
731
732 #[test]
733 fn test_parse_config_add_provider_default_api() {
734 let args = parse_args_from([
735 "oxicode",
736 "config",
737 "add-provider",
738 "zai",
739 "https://api.z.ai/v1",
740 "ZAI_API_KEY",
741 ])
742 .unwrap();
743 match args.command {
744 Some(Commands::Config { action }) => match action {
745 ConfigCommands::AddProvider {
746 name,
747 base_url,
748 api_key_env,
749 api,
750 } => {
751 assert_eq!(name, "zai");
752 assert_eq!(base_url, "https://api.z.ai/v1");
753 assert_eq!(api_key_env, "ZAI_API_KEY");
754 assert_eq!(api, "openai-completions"); }
756 _ => panic!("Expected AddProvider subcommand"),
757 },
758 _ => panic!("Expected Config command"),
759 }
760 }
761
762 #[test]
763 fn test_parse_config_remove_provider() {
764 let args = parse_args_from(["oxicode", "config", "remove-provider", "minimax"]).unwrap();
765 match args.command {
766 Some(Commands::Config { action }) => match action {
767 ConfigCommands::RemoveProvider { name } => {
768 assert_eq!(name, "minimax");
769 }
770 _ => panic!("Expected RemoveProvider subcommand"),
771 },
772 _ => panic!("Expected Config command"),
773 }
774 }
775
776 #[test]
777 fn test_parse_models_command() {
778 let args = parse_args_from(["oxicode", "models"]).unwrap();
779 match args.command {
780 Some(Commands::Models { provider }) => {
781 assert!(provider.is_none());
782 }
783 _ => panic!("Expected Models command"),
784 }
785 }
786
787 #[test]
788 fn test_parse_models_with_provider() {
789 let args = parse_args_from(["oxicode", "models", "--provider", "minimax"]).unwrap();
790 match args.command {
791 Some(Commands::Models { provider }) => {
792 assert_eq!(provider, Some("minimax".to_string()));
793 }
794 _ => panic!("Expected Models command"),
795 }
796 }
797
798 #[test]
799 fn test_parse_setup_command() {
800 let args = parse_args_from(["oxicode", "setup"]).unwrap();
801 match args.command {
802 Some(Commands::Setup { reset }) => {
803 assert!(!reset);
804 }
805 _ => panic!("Expected Setup command"),
806 }
807 }
808
809 #[test]
810 fn test_parse_setup_reset() {
811 let args = parse_args_from(["oxicode", "setup", "--reset"]).unwrap();
812 match args.command {
813 Some(Commands::Setup { reset }) => {
814 assert!(reset);
815 }
816 _ => panic!("Expected Setup command with reset"),
817 }
818 }
819
820 #[test]
823 fn test_parse_reset_command() {
824 let args = parse_args_from(["oxicode", "reset"]).unwrap();
825 match args.command {
826 Some(Commands::Reset {
827 yes,
828 include_project,
829 }) => {
830 assert!(!yes);
831 assert!(!include_project);
832 }
833 _ => panic!("Expected Reset command"),
834 }
835 }
836
837 #[test]
838 fn test_parse_reset_yes_flag() {
839 let args = parse_args_from(["oxicode", "reset", "--yes"]).unwrap();
840 match args.command {
841 Some(Commands::Reset {
842 yes,
843 include_project,
844 }) => {
845 assert!(yes);
846 assert!(!include_project);
847 }
848 _ => panic!("Expected Reset command with --yes"),
849 }
850 }
851
852 #[test]
853 fn test_parse_reset_include_project() {
854 let args = parse_args_from(["oxicode", "reset", "--yes", "--include-project"]).unwrap();
855 match args.command {
856 Some(Commands::Reset {
857 yes,
858 include_project,
859 }) => {
860 assert!(yes);
861 assert!(include_project);
862 }
863 _ => panic!("Expected Reset command with all flags"),
864 }
865 }
866}