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}
153
154#[derive(Debug, Clone, Subcommand)]
158pub enum PkgCommands {
159 Install {
161 source: String,
163 },
164 List,
166 Uninstall {
168 name: String,
170 },
171 Update {
173 name: Option<String>,
175 },
176}
177
178#[derive(Debug, Clone, Subcommand)]
182pub enum ExtCommands {
183 Install {
185 source: String,
187 #[arg(long)]
189 prerelease: bool,
190 },
191 List,
193 Remove {
195 name: String,
197 },
198 Update {
200 name: Option<String>,
202 },
203 Info {
205 source: String,
207 },
208}
209
210#[derive(Debug, Clone, Subcommand)]
214pub enum ConfigCommands {
215 Show,
217 List {
219 resource_type: Option<String>,
221 },
222 Enable {
224 resource_type: String,
226 name: String,
228 },
229 Disable {
231 resource_type: String,
233 name: String,
235 },
236 Set {
238 key: String,
240 value: String,
242 },
243 Get {
245 key: String,
247 },
248 AddProvider {
250 name: String,
252 base_url: String,
254 api_key_env: String,
256 #[arg(default_value = "openai-completions")]
258 api: String,
259 },
260 RemoveProvider {
262 name: String,
264 },
265 Reset {
267 #[arg(long, short)]
269 all: bool,
270 },
271}
272
273pub fn parse_args() -> CliArgs {
292 CliArgs::parse()
293}
294
295pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
297where
298 I: IntoIterator<Item = T>,
299 T: Into<std::ffi::OsString> + Clone,
300{
301 CliArgs::try_parse_from(iter)
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn test_parse_basic_prompt() {
310 let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
311 assert_eq!(args.prompt, vec!["Hello", "world"]);
312 }
313
314 #[test]
315 fn test_parse_with_provider_and_model() {
316 let args = parse_args_from([
317 "oxi",
318 "--provider",
319 "anthropic",
320 "--model",
321 "claude-sonnet-4-20250514",
322 "Hello",
323 ])
324 .unwrap();
325 assert_eq!(args.provider, Some("anthropic".to_string()));
326 assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
327 }
328
329 #[test]
330 fn test_parse_interactive_flag() {
331 let args = parse_args_from(["oxi", "-i"]).unwrap();
332 assert!(args.interactive);
333 }
334
335 #[test]
336 fn test_parse_extension_paths() {
337 let args =
338 parse_args_from(["oxi", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
339 assert_eq!(args.extensions.len(), 2);
340 }
341
342 #[test]
343 fn test_parse_sessions_command() {
344 let args = parse_args_from(["oxi", "sessions"]).unwrap();
345 assert!(matches!(args.command, Some(Commands::Sessions)));
346 }
347
348 #[test]
349 fn test_parse_tree_command() {
350 let args = parse_args_from(["oxi", "tree", "abc-123"]).unwrap();
351 match args.command {
352 Some(Commands::Tree { session_id }) => {
353 assert_eq!(session_id, "abc-123");
354 }
355 _ => panic!("Expected Tree command"),
356 }
357 }
358
359 #[test]
360 fn test_parse_tree_command_default() {
361 let args = parse_args_from(["oxi", "tree"]).unwrap();
362 match args.command {
363 Some(Commands::Tree { session_id }) => {
364 assert_eq!(session_id, "");
365 }
366 _ => panic!("Expected Tree command"),
367 }
368 }
369
370 #[test]
371 fn test_parse_fork_command() {
372 let args = parse_args_from(["oxi", "fork", "parent-id", "entry-id"]).unwrap();
373 match args.command {
374 Some(Commands::Fork {
375 parent_id,
376 entry_id,
377 }) => {
378 assert_eq!(parent_id, "parent-id");
379 assert_eq!(entry_id, "entry-id");
380 }
381 _ => panic!("Expected Fork command"),
382 }
383 }
384
385 #[test]
386 fn test_parse_delete_command() {
387 let args = parse_args_from(["oxi", "delete", "session-123"]).unwrap();
388 match args.command {
389 Some(Commands::Delete { session_id }) => {
390 assert_eq!(session_id, "session-123");
391 }
392 _ => panic!("Expected Delete command"),
393 }
394 }
395
396 #[test]
397 fn test_parse_pkg_install() {
398 let args = parse_args_from(["oxi", "pkg", "install", "npm:@scope/name"]).unwrap();
399 match args.command {
400 Some(Commands::Pkg { action }) => match action {
401 PkgCommands::Install { source } => {
402 assert_eq!(source, "npm:@scope/name");
403 }
404 _ => panic!("Expected Install subcommand"),
405 },
406 _ => panic!("Expected Pkg command"),
407 }
408 }
409
410 #[test]
411 fn test_parse_pkg_list() {
412 let args = parse_args_from(["oxi", "pkg", "list"]).unwrap();
413 match args.command {
414 Some(Commands::Pkg { action }) => {
415 assert!(matches!(action, PkgCommands::List));
416 }
417 _ => panic!("Expected Pkg command"),
418 }
419 }
420
421 #[test]
422 fn test_parse_pkg_update_all() {
423 let args = parse_args_from(["oxi", "pkg", "update"]).unwrap();
424 match args.command {
425 Some(Commands::Pkg { action }) => match action {
426 PkgCommands::Update { name } => assert!(name.is_none()),
427 _ => panic!("Expected Update subcommand"),
428 },
429 _ => panic!("Expected Pkg command"),
430 }
431 }
432
433 #[test]
434 fn test_parse_pkg_update_named() {
435 let args = parse_args_from(["oxi", "pkg", "update", "my-pkg"]).unwrap();
436 match args.command {
437 Some(Commands::Pkg { action }) => match action {
438 PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
439 _ => panic!("Expected Update subcommand"),
440 },
441 _ => panic!("Expected Pkg command"),
442 }
443 }
444
445 #[test]
446 fn test_parse_config_show() {
447 let args = parse_args_from(["oxi", "config", "show"]).unwrap();
448 assert!(matches!(
449 args.command,
450 Some(Commands::Config {
451 action: ConfigCommands::Show
452 })
453 ));
454 }
455
456 #[test]
457 fn test_parse_config_set() {
458 let args = parse_args_from(["oxi", "config", "set", "theme", "dracula"]).unwrap();
459 match args.command {
460 Some(Commands::Config { action }) => match action {
461 ConfigCommands::Set { key, value } => {
462 assert_eq!(key, "theme");
463 assert_eq!(value, "dracula");
464 }
465 _ => panic!("Expected Set subcommand"),
466 },
467 _ => panic!("Expected Config command"),
468 }
469 }
470
471 #[test]
472 fn test_parse_config_get() {
473 let args = parse_args_from(["oxi", "config", "get", "theme"]).unwrap();
474 match args.command {
475 Some(Commands::Config { action }) => match action {
476 ConfigCommands::Get { key } => {
477 assert_eq!(key, "theme");
478 }
479 _ => panic!("Expected Get subcommand"),
480 },
481 _ => panic!("Expected Config command"),
482 }
483 }
484
485 #[test]
486 fn test_parse_config_enable() {
487 let args = parse_args_from(["oxi", "config", "enable", "extension", "my-ext"]).unwrap();
488 match args.command {
489 Some(Commands::Config { action }) => match action {
490 ConfigCommands::Enable {
491 resource_type,
492 name,
493 } => {
494 assert_eq!(resource_type, "extension");
495 assert_eq!(name, "my-ext");
496 }
497 _ => panic!("Expected Enable subcommand"),
498 },
499 _ => panic!("Expected Config command"),
500 }
501 }
502
503 #[test]
504 fn test_parse_config_disable() {
505 let args = parse_args_from(["oxi", "config", "disable", "skill", "my-skill"]).unwrap();
506 match args.command {
507 Some(Commands::Config { action }) => match action {
508 ConfigCommands::Disable {
509 resource_type,
510 name,
511 } => {
512 assert_eq!(resource_type, "skill");
513 assert_eq!(name, "my-skill");
514 }
515 _ => panic!("Expected Disable subcommand"),
516 },
517 _ => panic!("Expected Config command"),
518 }
519 }
520
521 #[test]
522 fn test_parse_config_list() {
523 let args = parse_args_from(["oxi", "config", "list"]).unwrap();
524 match args.command {
525 Some(Commands::Config { action }) => match action {
526 ConfigCommands::List { resource_type } => {
527 assert!(resource_type.is_none());
528 }
529 _ => panic!("Expected List subcommand"),
530 },
531 _ => panic!("Expected Config command"),
532 }
533 }
534
535 #[test]
536 fn test_parse_config_list_filtered() {
537 let args = parse_args_from(["oxi", "config", "list", "extensions"]).unwrap();
538 match args.command {
539 Some(Commands::Config { action }) => match action {
540 ConfigCommands::List { resource_type } => {
541 assert_eq!(resource_type, Some("extensions".to_string()));
542 }
543 _ => panic!("Expected List subcommand"),
544 },
545 _ => panic!("Expected Config command"),
546 }
547 }
548
549 #[test]
550 fn test_thinking_level_reexport() {
551 assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
553 }
554
555 #[test]
556 fn test_parse_config_add_provider() {
557 let args = parse_args_from([
558 "oxi",
559 "config",
560 "add-provider",
561 "minimax",
562 "https://api.minimax.chat/v1",
563 "MINIMAX_API_KEY",
564 "openai-completions",
565 ])
566 .unwrap();
567 match args.command {
568 Some(Commands::Config { action }) => match action {
569 ConfigCommands::AddProvider {
570 name,
571 base_url,
572 api_key_env,
573 api,
574 } => {
575 assert_eq!(name, "minimax");
576 assert_eq!(base_url, "https://api.minimax.chat/v1");
577 assert_eq!(api_key_env, "MINIMAX_API_KEY");
578 assert_eq!(api, "openai-completions");
579 }
580 _ => panic!("Expected AddProvider subcommand"),
581 },
582 _ => panic!("Expected Config command"),
583 }
584 }
585
586 #[test]
587 fn test_parse_config_add_provider_default_api() {
588 let args = parse_args_from([
589 "oxi",
590 "config",
591 "add-provider",
592 "zai",
593 "https://api.z.ai/v1",
594 "ZAI_API_KEY",
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, "zai");
606 assert_eq!(base_url, "https://api.z.ai/v1");
607 assert_eq!(api_key_env, "ZAI_API_KEY");
608 assert_eq!(api, "openai-completions"); }
610 _ => panic!("Expected AddProvider subcommand"),
611 },
612 _ => panic!("Expected Config command"),
613 }
614 }
615
616 #[test]
617 fn test_parse_config_remove_provider() {
618 let args = parse_args_from(["oxi", "config", "remove-provider", "minimax"]).unwrap();
619 match args.command {
620 Some(Commands::Config { action }) => match action {
621 ConfigCommands::RemoveProvider { name } => {
622 assert_eq!(name, "minimax");
623 }
624 _ => panic!("Expected RemoveProvider subcommand"),
625 },
626 _ => panic!("Expected Config command"),
627 }
628 }
629
630 #[test]
631 fn test_parse_models_command() {
632 let args = parse_args_from(["oxi", "models"]).unwrap();
633 match args.command {
634 Some(Commands::Models { provider }) => {
635 assert!(provider.is_none());
636 }
637 _ => panic!("Expected Models command"),
638 }
639 }
640
641 #[test]
642 fn test_parse_models_with_provider() {
643 let args = parse_args_from(["oxi", "models", "--provider", "minimax"]).unwrap();
644 match args.command {
645 Some(Commands::Models { provider }) => {
646 assert_eq!(provider, Some("minimax".to_string()));
647 }
648 _ => panic!("Expected Models command"),
649 }
650 }
651
652 #[test]
653 fn test_parse_setup_command() {
654 let args = parse_args_from(["oxi", "setup"]).unwrap();
655 match args.command {
656 Some(Commands::Setup { reset }) => {
657 assert!(!reset);
658 }
659 _ => panic!("Expected Setup command"),
660 }
661 }
662
663 #[test]
664 fn test_parse_setup_reset() {
665 let args = parse_args_from(["oxi", "setup", "--reset"]).unwrap();
666 match args.command {
667 Some(Commands::Setup { reset }) => {
668 assert!(reset);
669 }
670 _ => panic!("Expected Setup command with reset"),
671 }
672 }
673
674 #[test]
677 fn test_parse_enable_routing_flag() {
678 let args = parse_args_from(["oxi", "--enable-routing", "Hello"]).unwrap();
679 assert!(args.enable_routing);
680 assert!(!args.prefer_cost_efficient);
681 assert!(args.fallback_chain.is_empty());
682 assert!(!args.disable_fallback);
683 }
684
685 #[test]
686 fn test_parse_prefer_cost_efficient_flag() {
687 let args = parse_args_from(["oxi", "--prefer-cost-efficient", "Hello"]).unwrap();
688 assert!(!args.enable_routing); assert!(args.prefer_cost_efficient);
691 assert!(args.fallback_chain.is_empty());
692 assert!(!args.disable_fallback);
693 }
694
695 #[test]
696 fn test_parse_fallback_chain_single() {
697 let args = parse_args_from(["oxi", "--fallback-chain", "openai/gpt-4o", "Hello"]).unwrap();
698 assert_eq!(args.fallback_chain, vec!["openai/gpt-4o"]);
699 }
700
701 #[test]
702 fn test_parse_fallback_chain_comma_separated() {
703 let args = parse_args_from([
704 "oxi",
705 "--fallback-chain",
706 "openai/gpt-4o,anthropic/claude-3",
707 "Hello",
708 ])
709 .unwrap();
710 assert_eq!(
711 args.fallback_chain,
712 vec!["openai/gpt-4o", "anthropic/claude-3"]
713 );
714 }
715
716 #[test]
717 fn test_parse_fallback_chain_multiple_args() {
718 let args = parse_args_from([
719 "oxi",
720 "--fallback-chain",
721 "openai/gpt-4o",
722 "--fallback-chain",
723 "anthropic/claude-3",
724 "Hello",
725 ])
726 .unwrap();
727 assert_eq!(
728 args.fallback_chain,
729 vec!["openai/gpt-4o", "anthropic/claude-3"]
730 );
731 }
732
733 #[test]
734 fn test_parse_fallback_chain_empty() {
735 let args = parse_args_from(["oxi", "Hello"]).unwrap();
736 assert!(args.fallback_chain.is_empty());
737 }
738
739 #[test]
740 fn test_parse_disable_fallback_flag() {
741 let args = parse_args_from(["oxi", "--disable-fallback", "Hello"]).unwrap();
742 assert!(args.disable_fallback);
743 }
744
745 #[test]
746 fn test_parse_routing_all_flags() {
747 let args = parse_args_from([
748 "oxi",
749 "--enable-routing",
750 "--prefer-cost-efficient",
751 "--fallback-chain",
752 "openai/gpt-4o,anthropic/claude-3",
753 "--disable-fallback",
754 "Hello",
755 ])
756 .unwrap();
757 assert!(args.enable_routing);
758 assert!(args.prefer_cost_efficient);
759 assert_eq!(
760 args.fallback_chain,
761 vec!["openai/gpt-4o", "anthropic/claude-3"]
762 );
763 assert!(args.disable_fallback);
764 }
765}