Skip to main content

oxi/
cli.rs

1//! CLI argument parsing with clap
2//!
3//! Provides the unified command-line argument types for the oxi CLI.
4//! This is the single source of truth for all CLI parsing — main.rs
5//! imports from here rather than defining its own types.
6
7use clap::{Parser, Subcommand};
8use std::path::PathBuf;
9
10// ── Re-exports ─────────────────────────────────────────────────────
11// Use the canonical ThinkingLevel from settings (None/Minimal/Standard/Thorough).
12pub use oxi_store::settings::ThinkingLevel;
13
14// ── Main CLI arguments ─────────────────────────────────────────────
15
16/// CLI arguments
17#[derive(Debug, Clone, Parser)]
18#[command(name = "oxi")]
19#[command(about = "CLI coding harness for oxi")]
20#[command(version)]
21pub struct CliArgs {
22    /// pub.
23    #[command(subcommand)]
24    pub command: Option<Commands>,
25
26    /// Provider to use (e.g., anthropic, openai, google, deepseek)
27    #[arg(short, long)]
28    pub provider: Option<String>,
29
30    /// Model to use (e.g., claude-sonnet-4-20250514, gpt-4o)
31    #[arg(short, long)]
32    pub model: Option<String>,
33
34    /// Initial prompt (non-interactive mode)
35    #[arg(default_value = "")]
36    pub prompt: Vec<String>,
37
38    /// Interactive mode (default when no prompt is given)
39    #[arg(short, long)]
40    pub interactive: bool,
41
42    /// Thinking level (none, minimal, standard, thorough)
43    #[arg(long)]
44    pub thinking: Option<String>,
45
46    /// Load an extension from a shared library (.so / .dll / .dylib).
47    /// Can be specified multiple times.
48    #[arg(short = 'e', long = "extension", value_name = "PATH")]
49    pub extensions: Vec<PathBuf>,
50
51    /// Output mode: text or json (newline-delimited JSON events)
52    #[arg(long)]
53    pub mode: Option<String>,
54
55    /// Comma-separated list of tools to enable. Default: all builtins.
56    #[arg(long)]
57    pub tools: Option<String>,
58
59    /// Append system prompt from a file
60    #[arg(long)]
61    pub append_system_prompt: Option<PathBuf>,
62
63    /// Single-shot print mode (non-interactive)
64    #[arg(long)]
65    pub print: bool,
66
67    /// Disable session persistence
68    #[arg(long)]
69    pub no_session: bool,
70
71    /// Timeout in seconds for print mode
72    #[arg(long)]
73    pub timeout: Option<u64>,
74
75    /// Resume the most recent session for this project
76    #[arg(short, long)]
77    pub continue_session: bool,
78
79    // ── Routing configuration ─────────────────────────────────────────
80    /// Enable automatic model routing (falls back to cost-efficient models on errors)
81    #[arg(long = "enable-routing")]
82    pub enable_routing: bool,
83
84    /// Prefer cost-efficient models when routing is enabled
85    #[arg(long = "prefer-cost-efficient")]
86    pub prefer_cost_efficient: bool,
87
88    /// Fallback chain: comma-separated list of provider/model IDs (can be specified multiple times)
89    #[arg(long = "fallback-chain", value_delimiter = ',')]
90    pub fallback_chain: Vec<String>,
91
92    /// Disable automatic fallback (fail fast on errors instead of trying alternatives)
93    #[arg(long = "disable-fallback")]
94    pub disable_fallback: bool,
95}
96
97// ── Subcommands ────────────────────────────────────────────────────
98
99/// CLI subcommands
100#[derive(Debug, Clone, Subcommand)]
101pub enum Commands {
102    /// List all sessions.
103    Sessions,
104    /// Show session tree structure.
105    Tree {
106        /// Session ID to show tree for (default: current/last session)
107        #[arg(default_value = "")]
108        session_id: String,
109    },
110    /// Fork a new session from a specific entry
111    Fork {
112        /// Parent session ID
113        parent_id: String,
114        /// Entry ID to branch from
115        entry_id: String,
116    },
117    /// Delete a session
118    Delete {
119        /// Session ID to delete
120        session_id: String,
121    },
122    /// Package management
123    Pkg {
124        /// action.
125        #[command(subcommand)]
126        action: PkgCommands,
127    },
128    /// Configuration management
129    Config {
130        /// action.
131        #[command(subcommand)]
132        action: ConfigCommands,
133    },
134    /// Extension management — install, update, remove WASM extensions
135    Ext {
136        /// action.
137        #[command(subcommand)]
138        action: ExtCommands,
139    },
140    /// List available models
141    Models {
142        /// Filter by provider name (e.g., openai, anthropic, minimax)
143        #[arg(long)]
144        provider: Option<String>,
145    },
146    /// Run the interactive setup wizard
147    Setup {
148        /// Reset all settings to defaults
149        #[arg(long)]
150        reset: bool,
151    },
152}
153
154// ── Package subcommands ────────────────────────────────────────────
155
156/// Package management subcommands
157#[derive(Debug, Clone, Subcommand)]
158pub enum PkgCommands {
159    /// Install a package from a local path or npm:@scope/name
160    Install {
161        /// Package source: a local directory path or npm:@scope/name
162        source: String,
163    },
164    /// List installed packages
165    List,
166    /// Uninstall a package by name
167    Uninstall {
168        /// Package name to uninstall
169        name: String,
170    },
171    /// Update a package to the latest version
172    Update {
173        /// Package name to update (updates all if omitted)
174        name: Option<String>,
175    },
176}
177
178// ── Extension subcommands ──────────────────────────────────────────────
179
180/// Extension management subcommands
181#[derive(Debug, Clone, Subcommand)]
182pub enum ExtCommands {
183    /// Install a WASM extension from a GitHub repo (owner/repo or owner/repo@version)
184    Install {
185        /// Extension source: owner/repo or owner/repo@version
186        source: String,
187        /// Include pre-release versions
188        #[arg(long)]
189        prerelease: bool,
190    },
191    /// List installed extensions
192    List,
193    /// Remove an installed extension
194    Remove {
195        /// Extension name to remove
196        name: String,
197    },
198    /// Update extension(s) to latest version
199    Update {
200        /// Extension name to update (updates all if omitted)
201        name: Option<String>,
202    },
203    /// Show info about a remote extension (without installing)
204    Info {
205        /// Extension source: owner/repo
206        source: String,
207    },
208}
209
210// ── Config subcommands ─────────────────────────────────────────────
211
212/// Configuration management subcommands
213#[derive(Debug, Clone, Subcommand)]
214pub enum ConfigCommands {
215    /// Show current configuration
216    Show,
217    /// List all enabled resources
218    List {
219        /// Resource type filter (extensions, skills, prompts, themes)
220        resource_type: Option<String>,
221    },
222    /// Enable a resource (extension, skill, prompt, or theme)
223    Enable {
224        /// Resource type: extension, skill, prompt, or theme
225        resource_type: String,
226        /// Resource path or name
227        name: String,
228    },
229    /// Disable a resource
230    Disable {
231        /// Resource type: extension, skill, prompt, or theme
232        resource_type: String,
233        /// Resource path or name
234        name: String,
235    },
236    /// Set a configuration value
237    Set {
238        /// Setting key (e.g. theme, default_model, thinking_level)
239        key: String,
240        /// Setting value
241        value: String,
242    },
243    /// Get a configuration value
244    Get {
245        /// Setting key
246        key: String,
247    },
248    /// Add a custom OpenAI-compatible provider
249    AddProvider {
250        /// Provider name (e.g. minimax)
251        name: String,
252        /// Base URL (e.g. https://api.minimax.chat/v1)
253        base_url: String,
254        /// Environment variable name for API key (e.g. MINIMAX_API_KEY)
255        api_key_env: String,
256        /// API type: openai-completions or openai-responses (default: openai-completions)
257        #[arg(default_value = "openai-completions")]
258        api: String,
259    },
260    /// Remove a custom provider
261    RemoveProvider {
262        /// Provider name to remove
263        name: String,
264    },
265    /// Reset credentials (auth.json) and optionally settings
266    Reset {
267        /// Also reset settings (settings.toml / settings.json)
268        #[arg(long, short)]
269        all: bool,
270    },
271}
272
273// ── Parsing helpers ────────────────────────────────────────────────
274
275/// Parse CLI arguments from the command line
276///
277/// # Examples
278///
279/// ```ignore
280/// use oxi_cli::CliArgs;
281///
282/// fn main() {
283///     let args = CliArgs::parse();
284///     match args.command {
285///         Some(Commands::Sessions) => { /* list sessions */ }
286///         Some(Commands::Tree { session_id }) => { /* show tree */ }
287///         _ => { /* interactive mode */ }
288///     }
289/// }
290/// ```
291pub fn parse_args() -> CliArgs {
292    CliArgs::parse()
293}
294
295/// Parse CLI arguments from a specific iterator
296pub 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        // Verify the re-export from settings works
552        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"); // default
609                }
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    // ── Routing flags ──────────────────────────────────────────────
675
676    #[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        // prefer_cost_efficient alone should NOT set enable_routing
689        assert!(!args.enable_routing); // enable_routing is a separate flag
690        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}