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 crate::store::settings::ThinkingLevel;
13pub mod commands;
14
15// ── Main CLI arguments ─────────────────────────────────────────────
16
17/// CLI arguments
18#[derive(Debug, Clone, Parser)]
19#[command(name = "oxi")]
20#[command(about = "CLI coding harness for oxi")]
21#[command(version)]
22pub struct CliArgs {
23    /// pub.
24    #[command(subcommand)]
25    pub command: Option<Commands>,
26
27    /// Provider to use (e.g., anthropic, openai, google, deepseek)
28    #[arg(short, long)]
29    pub provider: Option<String>,
30
31    /// Model to use (e.g., claude-sonnet-4-20250514, gpt-4o)
32    #[arg(short, long)]
33    pub model: Option<String>,
34
35    /// Initial prompt (non-interactive mode)
36    #[arg(default_value = "")]
37    pub prompt: Vec<String>,
38
39    /// Interactive mode (default when no prompt is given)
40    #[arg(short, long)]
41    pub interactive: bool,
42
43    /// Thinking level (none, minimal, standard, thorough)
44    #[arg(long)]
45    pub thinking: Option<String>,
46
47    /// Load an extension from a shared library (.so / .dll / .dylib).
48    /// Can be specified multiple times.
49    #[arg(short = 'e', long = "extension", value_name = "PATH")]
50    pub extensions: Vec<PathBuf>,
51
52    /// Output mode: text or json (newline-delimited JSON events)
53    #[arg(long)]
54    pub mode: Option<String>,
55
56    /// Comma-separated list of tools to enable. Default: all builtins.
57    #[arg(long)]
58    pub tools: Option<String>,
59
60    /// Append system prompt from a file
61    #[arg(long)]
62    pub append_system_prompt: Option<PathBuf>,
63
64    /// Single-shot print mode (non-interactive)
65    #[arg(long)]
66    pub print: bool,
67
68    /// Disable session persistence
69    #[arg(long)]
70    pub no_session: bool,
71
72    /// Timeout in seconds for print mode
73    #[arg(long)]
74    pub timeout: Option<u64>,
75
76    /// Resume the most recent session for this project
77    #[arg(short, long)]
78    pub continue_session: bool,
79}
80
81// ── Subcommands ────────────────────────────────────────────────────
82
83/// CLI subcommands
84#[derive(Debug, Clone, Subcommand)]
85pub enum Commands {
86    /// List all sessions for this project
87    Sessions,
88    /// Show session entry tree structure
89    Tree {
90        /// Session ID or prefix (default: current/last session for this project)
91        #[arg(default_value = "")]
92        session_id: String,
93    },
94    /// Fork a new session from a specific entry
95    Fork {
96        /// Parent session ID or prefix
97        parent_id: String,
98        /// Entry ID to branch from
99        entry_id: String,
100    },
101    /// Delete a session by ID (prefix match supported)
102    Delete {
103        /// Session ID or prefix (from `oxi sessions`)
104        session_id: String,
105    },
106    /// Local issue management
107    Issue {
108        /// Action
109        #[command(subcommand)]
110        action: IssueCommands,
111    },
112    /// Package management
113    Pkg {
114        /// action.
115        #[command(subcommand)]
116        action: PkgCommands,
117    },
118    /// Configuration management
119    Config {
120        /// action.
121        #[command(subcommand)]
122        action: ConfigCommands,
123    },
124    /// Extension management — install, update, remove WASM extensions
125    Ext {
126        /// action.
127        #[command(subcommand)]
128        action: ExtCommands,
129    },
130    /// List available models
131    Models {
132        /// Filter by provider name (e.g., openai, anthropic, minimax)
133        #[arg(long)]
134        provider: Option<String>,
135    },
136    /// Refresh the model catalog from models.dev
137    ///
138    /// Performs a conditional GET (ETag). Updates take effect on next start.
139    Refresh {},
140    /// Run the interactive setup wizard
141    Setup {
142        /// Reset all settings to defaults
143        #[arg(long)]
144        reset: bool,
145    },
146    /// Reset all settings and data to factory defaults
147    ///
148    /// Use when configuration has become tangled and you want a clean start.
149    /// An interactive confirmation prompt will be shown.
150    Reset {
151        /// Skip the confirmation prompt
152        #[arg(long, short)]
153        yes: bool,
154        /// Also delete the project-local .oxi/ directory
155        #[arg(long)]
156        include_project: bool,
157    },
158    /// Export a session to HTML
159    Export {
160        /// Session ID or prefix (default: most recent for this project)
161        session_id: Option<String>,
162        /// Output file path (default: oxi-export-{id}.html in CWD)
163        #[arg(short, long)]
164        output: Option<PathBuf>,
165    },
166    /// Import a session from a JSONL file
167    Import {
168        /// Path to the JSONL session file
169        path: PathBuf,
170    },
171    /// Share a session as a GitHub Gist (requires gh CLI)
172    Share {
173        /// Session ID or prefix (default: most recent for this project)
174        session_id: Option<String>,
175    },
176    /// Generate shell completion scripts (bash, zsh, or fish)
177    Completions {
178        /// Shell type: bash, zsh, or fish
179        shell: String,
180    },
181    /// Install an extension package (alias for `ext install`/`pkg install`)
182    Install {
183        /// Package source: a local directory path or npm:@scope/name
184        source: String,
185    },
186    /// Update oxi to the latest version
187    Update {
188        /// Check for updates without installing
189        #[arg(long)]
190        check: bool,
191    },
192    /// Generate a commit message and commit staged changes
193    Commit {
194        /// Push after committing
195        #[arg(long)]
196        push: bool,
197        /// Preview without committing
198        #[arg(long)]
199        dry_run: bool,
200        /// Additional context for the model
201        #[arg(long, short)]
202        context: Option<String>,
203    },
204}
205
206// ── Package subcommands ────────────────────────────────────────────
207
208/// Package management subcommands
209#[derive(Debug, Clone, Subcommand)]
210pub enum PkgCommands {
211    /// Install a package from a local path or npm:@scope/name
212    Install {
213        /// Package source: a local directory path or npm:@scope/name
214        source: String,
215    },
216    /// List installed packages
217    List,
218    /// Uninstall a package by name
219    Uninstall {
220        /// Package name to uninstall
221        name: String,
222    },
223    /// Update a package to the latest version
224    Update {
225        /// Package name to update (updates all if omitted)
226        name: Option<String>,
227    },
228}
229
230// ── Issue subcommands ───────────────────────────────────────────────
231
232/// Local issue management subcommands.
233#[derive(Debug, Clone, Subcommand)]
234pub enum IssueCommands {
235    /// List local issues (default: open only)
236    List {
237        /// Show closed issues too
238        #[arg(long)]
239        all: bool,
240        /// Filter by label
241        #[arg(long)]
242        label: Option<String>,
243        /// Filter by substring of title
244        #[arg(long)]
245        text: Option<String>,
246    },
247    /// Show a single issue (prints content + content_hash for `update`)
248    Show {
249        /// Issue id
250        id: u32,
251    },
252    /// Create a new issue
253    New {
254        /// Issue title
255        title: String,
256        /// Issue body (markdown); pass via stdin or $EDITOR
257        #[arg(long, short)]
258        body: Option<String>,
259        /// Priority: low|medium|high|critical (default: medium)
260        #[arg(long)]
261        priority: Option<String>,
262        /// Comma-separated labels
263        #[arg(long)]
264        labels: Option<String>,
265    },
266    /// Close an issue (releases any assignment; must be owner)
267    Close {
268        /// Issue id
269        id: u32,
270        /// Content hash from `show` (skip to bypass CAS check)
271        #[arg(long)]
272        hash: Option<String>,
273    },
274    /// Reopen a closed issue (anyone may reopen after close)
275    Reopen {
276        /// Issue id
277        id: u32,
278        /// Content hash from `show` (skip to bypass CAS check)
279        #[arg(long)]
280        hash: Option<String>,
281    },
282    /// Reap dead alive-lock files under `.oxi/issues/.alive/` (best-effort cleanup
283    /// of zombie locks left by crashed/killed processes). Age-gated: only files
284    /// older than 1 hour and not currently held are removed. Prints the count.
285    Reap,
286}
287
288// ── Extension subcommands ──────────────────────────────────────────────
289
290/// Extension management subcommands
291#[derive(Debug, Clone, Subcommand)]
292pub enum ExtCommands {
293    /// Install a WASM extension from a GitHub repo (owner/repo or owner/repo@version)
294    Install {
295        /// Extension source: owner/repo or owner/repo@version
296        source: String,
297        /// Include pre-release versions
298        #[arg(long)]
299        prerelease: bool,
300    },
301    /// List installed extensions
302    List,
303    /// Remove an installed extension
304    Remove {
305        /// Extension source: owner/repo
306        source: String,
307    },
308    /// Update extension(s) to latest version
309    Update {
310        /// Extension source: owner/repo (updates all if omitted)
311        source: Option<String>,
312    },
313    /// Show info about a remote extension (without installing)
314    Info {
315        /// Extension source: owner/repo
316        source: String,
317    },
318}
319
320// ── Config subcommands ─────────────────────────────────────────────
321
322/// Configuration management subcommands
323#[derive(Debug, Clone, Subcommand)]
324pub enum ConfigCommands {
325    /// Show current configuration
326    Show,
327    /// List all enabled resources
328    List {
329        /// Resource type filter (extensions, skills, prompts, themes)
330        resource_type: Option<String>,
331    },
332    /// Enable a resource (extension, skill, prompt, or theme)
333    Enable {
334        /// Resource type: extension, skill, prompt, or theme
335        resource_type: String,
336        /// Resource path or name
337        name: String,
338    },
339    /// Disable a resource
340    Disable {
341        /// Resource type: extension, skill, prompt, or theme
342        resource_type: String,
343        /// Resource path or name
344        name: String,
345    },
346    /// Set a configuration value
347    Set {
348        /// Setting key (e.g. theme, model, thinking_level)
349        key: String,
350        /// Setting value
351        value: String,
352    },
353    /// Get a configuration value
354    Get {
355        /// Setting key
356        key: String,
357    },
358    /// Add a custom OpenAI-compatible provider
359    AddProvider {
360        /// Provider name (e.g. minimax)
361        name: String,
362        /// Base URL (e.g. <https://api.minimax.chat/v1>)
363        base_url: String,
364        /// Environment variable name for API key (e.g. MINIMAX_API_KEY)
365        api_key_env: String,
366        /// API type: openai-completions or openai-responses (default: openai-completions)
367        #[arg(default_value = "openai-completions")]
368        api: String,
369    },
370    /// Remove a custom provider
371    RemoveProvider {
372        /// Provider name to remove
373        name: String,
374    },
375    /// Reset credentials (auth.json) and optionally settings
376    Reset {
377        /// Also reset settings (settings.toml / settings.json)
378        #[arg(long, short)]
379        all: bool,
380    },
381    /// Show the config file path
382    Path,
383}
384
385// ── Parsing helpers ────────────────────────────────────────────────
386
387/// Parse CLI arguments from the command line
388///
389/// # Examples
390///
391/// ```ignore
392/// use oxi_cli::CliArgs;
393///
394/// fn main() {
395///     let args = CliArgs::parse();
396///     match args.command {
397///         Some(Commands::Sessions) => { /* list sessions */ }
398///         Some(Commands::Tree { session_id }) => { /* show tree */ }
399///         _ => { /* interactive mode */ }
400///     }
401/// }
402/// ```
403pub fn parse_args() -> CliArgs {
404    CliArgs::parse()
405}
406
407/// Parse CLI arguments from a specific iterator
408pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
409where
410    I: IntoIterator<Item = T>,
411    T: Into<std::ffi::OsString> + Clone,
412{
413    CliArgs::try_parse_from(iter)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn test_parse_basic_prompt() {
422        let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
423        assert_eq!(args.prompt, vec!["Hello", "world"]);
424    }
425
426    #[test]
427    fn test_parse_with_provider_and_model() {
428        let args = parse_args_from([
429            "oxi",
430            "--provider",
431            "anthropic",
432            "--model",
433            "claude-sonnet-4-20250514",
434            "Hello",
435        ])
436        .unwrap();
437        assert_eq!(args.provider, Some("anthropic".to_string()));
438        assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
439    }
440
441    #[test]
442    fn test_parse_interactive_flag() {
443        let args = parse_args_from(["oxi", "-i"]).unwrap();
444        assert!(args.interactive);
445    }
446
447    #[test]
448    fn test_parse_extension_paths() {
449        let args =
450            parse_args_from(["oxi", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
451        assert_eq!(args.extensions.len(), 2);
452    }
453
454    #[test]
455    fn test_parse_sessions_command() {
456        let args = parse_args_from(["oxi", "sessions"]).unwrap();
457        assert!(matches!(args.command, Some(Commands::Sessions)));
458    }
459
460    #[test]
461    fn test_parse_tree_command() {
462        let args = parse_args_from(["oxi", "tree", "abc-123"]).unwrap();
463        match args.command {
464            Some(Commands::Tree { session_id }) => {
465                assert_eq!(session_id, "abc-123");
466            }
467            _ => panic!("Expected Tree command"),
468        }
469    }
470
471    #[test]
472    fn test_parse_tree_command_default() {
473        let args = parse_args_from(["oxi", "tree"]).unwrap();
474        match args.command {
475            Some(Commands::Tree { session_id }) => {
476                assert_eq!(session_id, "");
477            }
478            _ => panic!("Expected Tree command"),
479        }
480    }
481
482    #[test]
483    fn test_parse_fork_command() {
484        let args = parse_args_from(["oxi", "fork", "parent-id", "entry-id"]).unwrap();
485        match args.command {
486            Some(Commands::Fork {
487                parent_id,
488                entry_id,
489            }) => {
490                assert_eq!(parent_id, "parent-id");
491                assert_eq!(entry_id, "entry-id");
492            }
493            _ => panic!("Expected Fork command"),
494        }
495    }
496
497    #[test]
498    fn test_parse_delete_command() {
499        let args = parse_args_from(["oxi", "delete", "session-123"]).unwrap();
500        match args.command {
501            Some(Commands::Delete { session_id }) => {
502                assert_eq!(session_id, "session-123");
503            }
504            _ => panic!("Expected Delete command"),
505        }
506    }
507
508    #[test]
509    fn test_parse_pkg_install() {
510        let args = parse_args_from(["oxi", "pkg", "install", "npm:@scope/name"]).unwrap();
511        match args.command {
512            Some(Commands::Pkg { action }) => match action {
513                PkgCommands::Install { source } => {
514                    assert_eq!(source, "npm:@scope/name");
515                }
516                _ => panic!("Expected Install subcommand"),
517            },
518            _ => panic!("Expected Pkg command"),
519        }
520    }
521
522    #[test]
523    fn test_parse_pkg_list() {
524        let args = parse_args_from(["oxi", "pkg", "list"]).unwrap();
525        match args.command {
526            Some(Commands::Pkg { action }) => {
527                assert!(matches!(action, PkgCommands::List));
528            }
529            _ => panic!("Expected Pkg command"),
530        }
531    }
532
533    #[test]
534    fn test_parse_pkg_update_all() {
535        let args = parse_args_from(["oxi", "pkg", "update"]).unwrap();
536        match args.command {
537            Some(Commands::Pkg { action }) => match action {
538                PkgCommands::Update { name } => assert!(name.is_none()),
539                _ => panic!("Expected Update subcommand"),
540            },
541            _ => panic!("Expected Pkg command"),
542        }
543    }
544
545    #[test]
546    fn test_parse_pkg_update_named() {
547        let args = parse_args_from(["oxi", "pkg", "update", "my-pkg"]).unwrap();
548        match args.command {
549            Some(Commands::Pkg { action }) => match action {
550                PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
551                _ => panic!("Expected Update subcommand"),
552            },
553            _ => panic!("Expected Pkg command"),
554        }
555    }
556
557    #[test]
558    fn test_parse_config_show() {
559        let args = parse_args_from(["oxi", "config", "show"]).unwrap();
560        assert!(matches!(
561            args.command,
562            Some(Commands::Config {
563                action: ConfigCommands::Show
564            })
565        ));
566    }
567
568    #[test]
569    fn test_parse_config_set() {
570        let args = parse_args_from(["oxi", "config", "set", "theme", "dracula"]).unwrap();
571        match args.command {
572            Some(Commands::Config { action }) => match action {
573                ConfigCommands::Set { key, value } => {
574                    assert_eq!(key, "theme");
575                    assert_eq!(value, "dracula");
576                }
577                _ => panic!("Expected Set subcommand"),
578            },
579            _ => panic!("Expected Config command"),
580        }
581    }
582
583    #[test]
584    fn test_parse_config_get() {
585        let args = parse_args_from(["oxi", "config", "get", "theme"]).unwrap();
586        match args.command {
587            Some(Commands::Config { action }) => match action {
588                ConfigCommands::Get { key } => {
589                    assert_eq!(key, "theme");
590                }
591                _ => panic!("Expected Get subcommand"),
592            },
593            _ => panic!("Expected Config command"),
594        }
595    }
596
597    #[test]
598    fn test_parse_config_enable() {
599        let args = parse_args_from(["oxi", "config", "enable", "extension", "my-ext"]).unwrap();
600        match args.command {
601            Some(Commands::Config { action }) => match action {
602                ConfigCommands::Enable {
603                    resource_type,
604                    name,
605                } => {
606                    assert_eq!(resource_type, "extension");
607                    assert_eq!(name, "my-ext");
608                }
609                _ => panic!("Expected Enable subcommand"),
610            },
611            _ => panic!("Expected Config command"),
612        }
613    }
614
615    #[test]
616    fn test_parse_config_disable() {
617        let args = parse_args_from(["oxi", "config", "disable", "skill", "my-skill"]).unwrap();
618        match args.command {
619            Some(Commands::Config { action }) => match action {
620                ConfigCommands::Disable {
621                    resource_type,
622                    name,
623                } => {
624                    assert_eq!(resource_type, "skill");
625                    assert_eq!(name, "my-skill");
626                }
627                _ => panic!("Expected Disable subcommand"),
628            },
629            _ => panic!("Expected Config command"),
630        }
631    }
632
633    #[test]
634    fn test_parse_config_list() {
635        let args = parse_args_from(["oxi", "config", "list"]).unwrap();
636        match args.command {
637            Some(Commands::Config { action }) => match action {
638                ConfigCommands::List { resource_type } => {
639                    assert!(resource_type.is_none());
640                }
641                _ => panic!("Expected List subcommand"),
642            },
643            _ => panic!("Expected Config command"),
644        }
645    }
646
647    #[test]
648    fn test_parse_config_list_filtered() {
649        let args = parse_args_from(["oxi", "config", "list", "extensions"]).unwrap();
650        match args.command {
651            Some(Commands::Config { action }) => match action {
652                ConfigCommands::List { resource_type } => {
653                    assert_eq!(resource_type, Some("extensions".to_string()));
654                }
655                _ => panic!("Expected List subcommand"),
656            },
657            _ => panic!("Expected Config command"),
658        }
659    }
660
661    #[test]
662    fn test_thinking_level_reexport() {
663        // Verify the re-export from settings works
664        assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
665    }
666
667    #[test]
668    fn test_parse_config_add_provider() {
669        let args = parse_args_from([
670            "oxi",
671            "config",
672            "add-provider",
673            "minimax",
674            "https://api.minimax.chat/v1",
675            "MINIMAX_API_KEY",
676            "openai-completions",
677        ])
678        .unwrap();
679        match args.command {
680            Some(Commands::Config { action }) => match action {
681                ConfigCommands::AddProvider {
682                    name,
683                    base_url,
684                    api_key_env,
685                    api,
686                } => {
687                    assert_eq!(name, "minimax");
688                    assert_eq!(base_url, "https://api.minimax.chat/v1");
689                    assert_eq!(api_key_env, "MINIMAX_API_KEY");
690                    assert_eq!(api, "openai-completions");
691                }
692                _ => panic!("Expected AddProvider subcommand"),
693            },
694            _ => panic!("Expected Config command"),
695        }
696    }
697
698    #[test]
699    fn test_parse_config_add_provider_default_api() {
700        let args = parse_args_from([
701            "oxi",
702            "config",
703            "add-provider",
704            "zai",
705            "https://api.z.ai/v1",
706            "ZAI_API_KEY",
707        ])
708        .unwrap();
709        match args.command {
710            Some(Commands::Config { action }) => match action {
711                ConfigCommands::AddProvider {
712                    name,
713                    base_url,
714                    api_key_env,
715                    api,
716                } => {
717                    assert_eq!(name, "zai");
718                    assert_eq!(base_url, "https://api.z.ai/v1");
719                    assert_eq!(api_key_env, "ZAI_API_KEY");
720                    assert_eq!(api, "openai-completions"); // default
721                }
722                _ => panic!("Expected AddProvider subcommand"),
723            },
724            _ => panic!("Expected Config command"),
725        }
726    }
727
728    #[test]
729    fn test_parse_config_remove_provider() {
730        let args = parse_args_from(["oxi", "config", "remove-provider", "minimax"]).unwrap();
731        match args.command {
732            Some(Commands::Config { action }) => match action {
733                ConfigCommands::RemoveProvider { name } => {
734                    assert_eq!(name, "minimax");
735                }
736                _ => panic!("Expected RemoveProvider subcommand"),
737            },
738            _ => panic!("Expected Config command"),
739        }
740    }
741
742    #[test]
743    fn test_parse_models_command() {
744        let args = parse_args_from(["oxi", "models"]).unwrap();
745        match args.command {
746            Some(Commands::Models { provider }) => {
747                assert!(provider.is_none());
748            }
749            _ => panic!("Expected Models command"),
750        }
751    }
752
753    #[test]
754    fn test_parse_models_with_provider() {
755        let args = parse_args_from(["oxi", "models", "--provider", "minimax"]).unwrap();
756        match args.command {
757            Some(Commands::Models { provider }) => {
758                assert_eq!(provider, Some("minimax".to_string()));
759            }
760            _ => panic!("Expected Models command"),
761        }
762    }
763
764    #[test]
765    fn test_parse_setup_command() {
766        let args = parse_args_from(["oxi", "setup"]).unwrap();
767        match args.command {
768            Some(Commands::Setup { reset }) => {
769                assert!(!reset);
770            }
771            _ => panic!("Expected Setup command"),
772        }
773    }
774
775    #[test]
776    fn test_parse_setup_reset() {
777        let args = parse_args_from(["oxi", "setup", "--reset"]).unwrap();
778        match args.command {
779            Some(Commands::Setup { reset }) => {
780                assert!(reset);
781            }
782            _ => panic!("Expected Setup command with reset"),
783        }
784    }
785
786    // ── Reset command ────────────────────────────────────────────
787
788    #[test]
789    fn test_parse_reset_command() {
790        let args = parse_args_from(["oxi", "reset"]).unwrap();
791        match args.command {
792            Some(Commands::Reset {
793                yes,
794                include_project,
795            }) => {
796                assert!(!yes);
797                assert!(!include_project);
798            }
799            _ => panic!("Expected Reset command"),
800        }
801    }
802
803    #[test]
804    fn test_parse_reset_yes_flag() {
805        let args = parse_args_from(["oxi", "reset", "--yes"]).unwrap();
806        match args.command {
807            Some(Commands::Reset {
808                yes,
809                include_project,
810            }) => {
811                assert!(yes);
812                assert!(!include_project);
813            }
814            _ => panic!("Expected Reset command with --yes"),
815        }
816    }
817
818    #[test]
819    fn test_parse_reset_include_project() {
820        let args = parse_args_from(["oxi", "reset", "--yes", "--include-project"]).unwrap();
821        match args.command {
822            Some(Commands::Reset {
823                yes,
824                include_project,
825            }) => {
826                assert!(yes);
827                assert!(include_project);
828            }
829            _ => panic!("Expected Reset command with all flags"),
830        }
831    }
832}