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
80// ── Subcommands ────────────────────────────────────────────────────
81
82/// CLI subcommands
83#[derive(Debug, Clone, Subcommand)]
84pub enum Commands {
85    /// List all sessions.
86    Sessions,
87    /// Show session tree structure.
88    Tree {
89        /// Session ID to show tree for (default: current/last session)
90        #[arg(default_value = "")]
91        session_id: String,
92    },
93    /// Fork a new session from a specific entry
94    Fork {
95        /// Parent session ID
96        parent_id: String,
97        /// Entry ID to branch from
98        entry_id: String,
99    },
100    /// Delete a session
101    Delete {
102        /// Session ID to delete
103        session_id: String,
104    },
105    /// Package management
106    Pkg {
107        /// action.
108        #[command(subcommand)]
109        action: PkgCommands,
110    },
111    /// Configuration management
112    Config {
113        /// action.
114        #[command(subcommand)]
115        action: ConfigCommands,
116    },
117    /// Extension management — install, update, remove WASM extensions
118    Ext {
119        /// action.
120        #[command(subcommand)]
121        action: ExtCommands,
122    },
123    /// List available models
124    Models {
125        /// Filter by provider name (e.g., openai, anthropic, minimax)
126        #[arg(long)]
127        provider: Option<String>,
128    },
129    /// Run the interactive setup wizard
130    Setup {
131        /// Reset all settings to defaults
132        #[arg(long)]
133        reset: bool,
134    },
135}
136
137// ── Package subcommands ────────────────────────────────────────────
138
139/// Package management subcommands
140#[derive(Debug, Clone, Subcommand)]
141pub enum PkgCommands {
142    /// Install a package from a local path or npm:@scope/name
143    Install {
144        /// Package source: a local directory path or npm:@scope/name
145        source: String,
146    },
147    /// List installed packages
148    List,
149    /// Uninstall a package by name
150    Uninstall {
151        /// Package name to uninstall
152        name: String,
153    },
154    /// Update a package to the latest version
155    Update {
156        /// Package name to update (updates all if omitted)
157        name: Option<String>,
158    },
159}
160
161// ── Extension subcommands ──────────────────────────────────────────────
162
163/// Extension management subcommands
164#[derive(Debug, Clone, Subcommand)]
165pub enum ExtCommands {
166    /// Install a WASM extension from a GitHub repo (owner/repo or owner/repo@version)
167    Install {
168        /// Extension source: owner/repo or owner/repo@version
169        source: String,
170        /// Include pre-release versions
171        #[arg(long)]
172        prerelease: bool,
173    },
174    /// List installed extensions
175    List,
176    /// Remove an installed extension
177    Remove {
178        /// Extension name to remove
179        name: String,
180    },
181    /// Update extension(s) to latest version
182    Update {
183        /// Extension name to update (updates all if omitted)
184        name: Option<String>,
185    },
186    /// Show info about a remote extension (without installing)
187    Info {
188        /// Extension source: owner/repo
189        source: String,
190    },
191}
192
193// ── Config subcommands ─────────────────────────────────────────────
194
195/// Configuration management subcommands
196#[derive(Debug, Clone, Subcommand)]
197pub enum ConfigCommands {
198    /// Show current configuration
199    Show,
200    /// List all enabled resources
201    List {
202        /// Resource type filter (extensions, skills, prompts, themes)
203        resource_type: Option<String>,
204    },
205    /// Enable a resource (extension, skill, prompt, or theme)
206    Enable {
207        /// Resource type: extension, skill, prompt, or theme
208        resource_type: String,
209        /// Resource path or name
210        name: String,
211    },
212    /// Disable a resource
213    Disable {
214        /// Resource type: extension, skill, prompt, or theme
215        resource_type: String,
216        /// Resource path or name
217        name: String,
218    },
219    /// Set a configuration value
220    Set {
221        /// Setting key (e.g. theme, default_model, thinking_level)
222        key: String,
223        /// Setting value
224        value: String,
225    },
226    /// Get a configuration value
227    Get {
228        /// Setting key
229        key: String,
230    },
231    /// Add a custom OpenAI-compatible provider
232    AddProvider {
233        /// Provider name (e.g. minimax)
234        name: String,
235        /// Base URL (e.g. https://api.minimax.chat/v1)
236        base_url: String,
237        /// Environment variable name for API key (e.g. MINIMAX_API_KEY)
238        api_key_env: String,
239        /// API type: openai-completions or openai-responses (default: openai-completions)
240        #[arg(default_value = "openai-completions")]
241        api: String,
242    },
243    /// Remove a custom provider
244    RemoveProvider {
245        /// Provider name to remove
246        name: String,
247    },
248    /// Reset credentials (auth.json) and optionally settings
249    Reset {
250        /// Also reset settings (settings.toml / settings.json)
251        #[arg(long, short)]
252        all: bool,
253    },
254}
255
256// ── Parsing helpers ────────────────────────────────────────────────
257
258/// Parse CLI arguments from the command line
259///
260/// # Examples
261///
262/// ```ignore
263/// use oxi_cli::CliArgs;
264///
265/// fn main() {
266///     let args = CliArgs::parse();
267///     match args.command {
268///         Some(Commands::Sessions) => { /* list sessions */ }
269///         Some(Commands::Tree { session_id }) => { /* show tree */ }
270///         _ => { /* interactive mode */ }
271///     }
272/// }
273/// ```
274pub fn parse_args() -> CliArgs {
275    CliArgs::parse()
276}
277
278/// Parse CLI arguments from a specific iterator
279pub 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        // Verify the re-export from settings works
535        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"); // default
592                }
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}