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