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
80#[derive(Debug, Clone, Subcommand)]
84pub enum Commands {
85 Sessions,
87 Tree {
89 #[arg(default_value = "")]
91 session_id: String,
92 },
93 Fork {
95 parent_id: String,
97 entry_id: String,
99 },
100 Delete {
102 session_id: String,
104 },
105 Pkg {
107 #[command(subcommand)]
109 action: PkgCommands,
110 },
111 Config {
113 #[command(subcommand)]
115 action: ConfigCommands,
116 },
117 Ext {
119 #[command(subcommand)]
121 action: ExtCommands,
122 },
123 Models {
125 #[arg(long)]
127 provider: Option<String>,
128 },
129 Setup {
131 #[arg(long)]
133 reset: bool,
134 },
135}
136
137#[derive(Debug, Clone, Subcommand)]
141pub enum PkgCommands {
142 Install {
144 source: String,
146 },
147 List,
149 Uninstall {
151 name: String,
153 },
154 Update {
156 name: Option<String>,
158 },
159}
160
161#[derive(Debug, Clone, Subcommand)]
165pub enum ExtCommands {
166 Install {
168 source: String,
170 #[arg(long)]
172 prerelease: bool,
173 },
174 List,
176 Remove {
178 name: String,
180 },
181 Update {
183 name: Option<String>,
185 },
186 Info {
188 source: String,
190 },
191}
192
193#[derive(Debug, Clone, Subcommand)]
197pub enum ConfigCommands {
198 Show,
200 List {
202 resource_type: Option<String>,
204 },
205 Enable {
207 resource_type: String,
209 name: String,
211 },
212 Disable {
214 resource_type: String,
216 name: String,
218 },
219 Set {
221 key: String,
223 value: String,
225 },
226 Get {
228 key: String,
230 },
231 AddProvider {
233 name: String,
235 base_url: String,
237 api_key_env: String,
239 #[arg(default_value = "openai-completions")]
241 api: String,
242 },
243 RemoveProvider {
245 name: String,
247 },
248 Reset {
250 #[arg(long, short)]
252 all: bool,
253 },
254}
255
256pub fn parse_args() -> CliArgs {
275 CliArgs::parse()
276}
277
278pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
280where
281 I: IntoIterator<Item = T>,
282 T: Into<std::ffi::OsString> + Clone,
283{
284 CliArgs::try_parse_from(iter)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn test_parse_basic_prompt() {
293 let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
294 assert_eq!(args.prompt, vec!["Hello", "world"]);
295 }
296
297 #[test]
298 fn test_parse_with_provider_and_model() {
299 let args = parse_args_from([
300 "oxi",
301 "--provider",
302 "anthropic",
303 "--model",
304 "claude-sonnet-4-20250514",
305 "Hello",
306 ])
307 .unwrap();
308 assert_eq!(args.provider, Some("anthropic".to_string()));
309 assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
310 }
311
312 #[test]
313 fn test_parse_interactive_flag() {
314 let args = parse_args_from(["oxi", "-i"]).unwrap();
315 assert!(args.interactive);
316 }
317
318 #[test]
319 fn test_parse_extension_paths() {
320 let args =
321 parse_args_from(["oxi", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
322 assert_eq!(args.extensions.len(), 2);
323 }
324
325 #[test]
326 fn test_parse_sessions_command() {
327 let args = parse_args_from(["oxi", "sessions"]).unwrap();
328 assert!(matches!(args.command, Some(Commands::Sessions)));
329 }
330
331 #[test]
332 fn test_parse_tree_command() {
333 let args = parse_args_from(["oxi", "tree", "abc-123"]).unwrap();
334 match args.command {
335 Some(Commands::Tree { session_id }) => {
336 assert_eq!(session_id, "abc-123");
337 }
338 _ => panic!("Expected Tree command"),
339 }
340 }
341
342 #[test]
343 fn test_parse_tree_command_default() {
344 let args = parse_args_from(["oxi", "tree"]).unwrap();
345 match args.command {
346 Some(Commands::Tree { session_id }) => {
347 assert_eq!(session_id, "");
348 }
349 _ => panic!("Expected Tree command"),
350 }
351 }
352
353 #[test]
354 fn test_parse_fork_command() {
355 let args = parse_args_from(["oxi", "fork", "parent-id", "entry-id"]).unwrap();
356 match args.command {
357 Some(Commands::Fork {
358 parent_id,
359 entry_id,
360 }) => {
361 assert_eq!(parent_id, "parent-id");
362 assert_eq!(entry_id, "entry-id");
363 }
364 _ => panic!("Expected Fork command"),
365 }
366 }
367
368 #[test]
369 fn test_parse_delete_command() {
370 let args = parse_args_from(["oxi", "delete", "session-123"]).unwrap();
371 match args.command {
372 Some(Commands::Delete { session_id }) => {
373 assert_eq!(session_id, "session-123");
374 }
375 _ => panic!("Expected Delete command"),
376 }
377 }
378
379 #[test]
380 fn test_parse_pkg_install() {
381 let args = parse_args_from(["oxi", "pkg", "install", "npm:@scope/name"]).unwrap();
382 match args.command {
383 Some(Commands::Pkg { action }) => match action {
384 PkgCommands::Install { source } => {
385 assert_eq!(source, "npm:@scope/name");
386 }
387 _ => panic!("Expected Install subcommand"),
388 },
389 _ => panic!("Expected Pkg command"),
390 }
391 }
392
393 #[test]
394 fn test_parse_pkg_list() {
395 let args = parse_args_from(["oxi", "pkg", "list"]).unwrap();
396 match args.command {
397 Some(Commands::Pkg { action }) => {
398 assert!(matches!(action, PkgCommands::List));
399 }
400 _ => panic!("Expected Pkg command"),
401 }
402 }
403
404 #[test]
405 fn test_parse_pkg_update_all() {
406 let args = parse_args_from(["oxi", "pkg", "update"]).unwrap();
407 match args.command {
408 Some(Commands::Pkg { action }) => match action {
409 PkgCommands::Update { name } => assert!(name.is_none()),
410 _ => panic!("Expected Update subcommand"),
411 },
412 _ => panic!("Expected Pkg command"),
413 }
414 }
415
416 #[test]
417 fn test_parse_pkg_update_named() {
418 let args = parse_args_from(["oxi", "pkg", "update", "my-pkg"]).unwrap();
419 match args.command {
420 Some(Commands::Pkg { action }) => match action {
421 PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
422 _ => panic!("Expected Update subcommand"),
423 },
424 _ => panic!("Expected Pkg command"),
425 }
426 }
427
428 #[test]
429 fn test_parse_config_show() {
430 let args = parse_args_from(["oxi", "config", "show"]).unwrap();
431 assert!(matches!(
432 args.command,
433 Some(Commands::Config {
434 action: ConfigCommands::Show
435 })
436 ));
437 }
438
439 #[test]
440 fn test_parse_config_set() {
441 let args = parse_args_from(["oxi", "config", "set", "theme", "dracula"]).unwrap();
442 match args.command {
443 Some(Commands::Config { action }) => match action {
444 ConfigCommands::Set { key, value } => {
445 assert_eq!(key, "theme");
446 assert_eq!(value, "dracula");
447 }
448 _ => panic!("Expected Set subcommand"),
449 },
450 _ => panic!("Expected Config command"),
451 }
452 }
453
454 #[test]
455 fn test_parse_config_get() {
456 let args = parse_args_from(["oxi", "config", "get", "theme"]).unwrap();
457 match args.command {
458 Some(Commands::Config { action }) => match action {
459 ConfigCommands::Get { key } => {
460 assert_eq!(key, "theme");
461 }
462 _ => panic!("Expected Get subcommand"),
463 },
464 _ => panic!("Expected Config command"),
465 }
466 }
467
468 #[test]
469 fn test_parse_config_enable() {
470 let args = parse_args_from(["oxi", "config", "enable", "extension", "my-ext"]).unwrap();
471 match args.command {
472 Some(Commands::Config { action }) => match action {
473 ConfigCommands::Enable {
474 resource_type,
475 name,
476 } => {
477 assert_eq!(resource_type, "extension");
478 assert_eq!(name, "my-ext");
479 }
480 _ => panic!("Expected Enable subcommand"),
481 },
482 _ => panic!("Expected Config command"),
483 }
484 }
485
486 #[test]
487 fn test_parse_config_disable() {
488 let args = parse_args_from(["oxi", "config", "disable", "skill", "my-skill"]).unwrap();
489 match args.command {
490 Some(Commands::Config { action }) => match action {
491 ConfigCommands::Disable {
492 resource_type,
493 name,
494 } => {
495 assert_eq!(resource_type, "skill");
496 assert_eq!(name, "my-skill");
497 }
498 _ => panic!("Expected Disable subcommand"),
499 },
500 _ => panic!("Expected Config command"),
501 }
502 }
503
504 #[test]
505 fn test_parse_config_list() {
506 let args = parse_args_from(["oxi", "config", "list"]).unwrap();
507 match args.command {
508 Some(Commands::Config { action }) => match action {
509 ConfigCommands::List { resource_type } => {
510 assert!(resource_type.is_none());
511 }
512 _ => panic!("Expected List subcommand"),
513 },
514 _ => panic!("Expected Config command"),
515 }
516 }
517
518 #[test]
519 fn test_parse_config_list_filtered() {
520 let args = parse_args_from(["oxi", "config", "list", "extensions"]).unwrap();
521 match args.command {
522 Some(Commands::Config { action }) => match action {
523 ConfigCommands::List { resource_type } => {
524 assert_eq!(resource_type, Some("extensions".to_string()));
525 }
526 _ => panic!("Expected List subcommand"),
527 },
528 _ => panic!("Expected Config command"),
529 }
530 }
531
532 #[test]
533 fn test_thinking_level_reexport() {
534 assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
536 }
537
538 #[test]
539 fn test_parse_config_add_provider() {
540 let args = parse_args_from([
541 "oxi",
542 "config",
543 "add-provider",
544 "minimax",
545 "https://api.minimax.chat/v1",
546 "MINIMAX_API_KEY",
547 "openai-completions",
548 ])
549 .unwrap();
550 match args.command {
551 Some(Commands::Config { action }) => match action {
552 ConfigCommands::AddProvider {
553 name,
554 base_url,
555 api_key_env,
556 api,
557 } => {
558 assert_eq!(name, "minimax");
559 assert_eq!(base_url, "https://api.minimax.chat/v1");
560 assert_eq!(api_key_env, "MINIMAX_API_KEY");
561 assert_eq!(api, "openai-completions");
562 }
563 _ => panic!("Expected AddProvider subcommand"),
564 },
565 _ => panic!("Expected Config command"),
566 }
567 }
568
569 #[test]
570 fn test_parse_config_add_provider_default_api() {
571 let args = parse_args_from([
572 "oxi",
573 "config",
574 "add-provider",
575 "zai",
576 "https://api.z.ai/v1",
577 "ZAI_API_KEY",
578 ])
579 .unwrap();
580 match args.command {
581 Some(Commands::Config { action }) => match action {
582 ConfigCommands::AddProvider {
583 name,
584 base_url,
585 api_key_env,
586 api,
587 } => {
588 assert_eq!(name, "zai");
589 assert_eq!(base_url, "https://api.z.ai/v1");
590 assert_eq!(api_key_env, "ZAI_API_KEY");
591 assert_eq!(api, "openai-completions"); }
593 _ => panic!("Expected AddProvider subcommand"),
594 },
595 _ => panic!("Expected Config command"),
596 }
597 }
598
599 #[test]
600 fn test_parse_config_remove_provider() {
601 let args = parse_args_from(["oxi", "config", "remove-provider", "minimax"]).unwrap();
602 match args.command {
603 Some(Commands::Config { action }) => match action {
604 ConfigCommands::RemoveProvider { name } => {
605 assert_eq!(name, "minimax");
606 }
607 _ => panic!("Expected RemoveProvider subcommand"),
608 },
609 _ => panic!("Expected Config command"),
610 }
611 }
612
613 #[test]
614 fn test_parse_models_command() {
615 let args = parse_args_from(["oxi", "models"]).unwrap();
616 match args.command {
617 Some(Commands::Models { provider }) => {
618 assert!(provider.is_none());
619 }
620 _ => panic!("Expected Models command"),
621 }
622 }
623
624 #[test]
625 fn test_parse_models_with_provider() {
626 let args = parse_args_from(["oxi", "models", "--provider", "minimax"]).unwrap();
627 match args.command {
628 Some(Commands::Models { provider }) => {
629 assert_eq!(provider, Some("minimax".to_string()));
630 }
631 _ => panic!("Expected Models command"),
632 }
633 }
634
635 #[test]
636 fn test_parse_setup_command() {
637 let args = parse_args_from(["oxi", "setup"]).unwrap();
638 match args.command {
639 Some(Commands::Setup { reset }) => {
640 assert!(!reset);
641 }
642 _ => panic!("Expected Setup command"),
643 }
644 }
645
646 #[test]
647 fn test_parse_setup_reset() {
648 let args = parse_args_from(["oxi", "setup", "--reset"]).unwrap();
649 match args.command {
650 Some(Commands::Setup { reset }) => {
651 assert!(reset);
652 }
653 _ => panic!("Expected Setup command with reset"),
654 }
655 }
656}