Skip to main content

oxicode/
cli.rs

1//! CLI argument parsing with clap
2//!
3//! Provides the unified command-line argument types for the oxicode 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 = "oxicode")]
20#[command(about = "CLI coding harness for oxicode")]
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 `oxicode 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 .oxicode/ directory
155        #[arg(long)]
156        include_project: bool,
157    },
158    /// Print home-layout diagnostics (oxi home, canonical oxicode home,
159    /// legacy presence, migration status)
160    Doctor,
161    /// Export a session to HTML
162    Export {
163        /// Session ID or prefix (default: most recent for this project)
164        session_id: Option<String>,
165        /// Output file path (default: oxicode-export-{id}.html in CWD)
166        #[arg(short, long)]
167        output: Option<PathBuf>,
168    },
169    /// Import a session from a JSONL file
170    Import {
171        /// Path to the JSONL session file
172        path: PathBuf,
173    },
174    /// Share a session as a GitHub Gist (requires gh CLI)
175    Share {
176        /// Session ID or prefix (default: most recent for this project)
177        session_id: Option<String>,
178    },
179    /// Generate shell completion scripts (bash, zsh, or fish)
180    Completions {
181        /// Shell type: bash, zsh, or fish
182        shell: String,
183    },
184    /// Install an extension package (alias for `ext install`/`pkg install`)
185    Install {
186        /// Package source: a local directory path or npm:@scope/name
187        source: String,
188    },
189    /// Update oxicode to the latest version
190    Update {
191        /// Check for updates without installing
192        #[arg(long)]
193        check: bool,
194    },
195    /// Generate a commit message and commit staged changes
196    Commit {
197        /// Push after committing
198        #[arg(long)]
199        push: bool,
200        /// Preview without committing
201        #[arg(long)]
202        dry_run: bool,
203        /// Additional context for the model
204        #[arg(long, short)]
205        context: Option<String>,
206    },
207    /// Foundation v1 migration routines
208    Migrate {
209        /// Action
210        #[command(subcommand)]
211        action: MigrationCommands,
212    },
213}
214
215/// `oxicode migrate` subcommands.
216#[derive(Debug, Clone, Subcommand)]
217pub enum MigrationCommands {
218    /// Migrate legacy durable memory to the oxibrain daemon.
219    Brain(MigrateBrainArgs),
220    /// Migrate the legacy `~/.oxicode` home into the unified Oxi home
221    /// (`<oxi_home>/oxicode`). Journaled, resumable, copy-only.
222    Home(MigrateHomeArgs),
223}
224
225#[derive(Debug, Clone, clap::Args)]
226pub struct MigrateHomeArgs {
227    /// Print the migration plan (source, destination, bytes, conflicts,
228    /// required action) without touching the filesystem.
229    #[arg(long)]
230    pub dry_run: bool,
231}
232
233#[derive(Debug, Clone, clap::Args)]
234pub struct MigrateBrainArgs {
235    /// oxibrain socket path. Defaults to `$OXIBRAIN_SOCKET`,
236    /// `$XDG_RUNTIME_DIR/oxibrain.sock`, or `~/.oxi/run/oxibrain.sock`.
237    #[arg(long)]
238    pub socket: Option<PathBuf>,
239    /// Do not write to the brain; just enumerate the legacy store.
240    #[arg(long)]
241    pub dry_run: bool,
242    /// Move the legacy store to `~/.oxicode/archive/memory/<ts>/`
243    /// after a successful migration.
244    #[arg(long)]
245    pub archive_legacy: bool,
246    /// Migration checkpoint file.
247    #[arg(long, default_value = "~/.oxicode/migration/brain.json")]
248    pub checkpoint: PathBuf,
249    /// Items per batch.
250    #[arg(long, default_value = "64")]
251    pub batch_size: usize,
252}
253
254// ── Package subcommands ────────────────────────────────────────────
255
256/// Package management subcommands
257#[derive(Debug, Clone, Subcommand)]
258pub enum PkgCommands {
259    /// Install a package from a local path or npm:@scope/name
260    Install {
261        /// Package source: a local directory path or npm:@scope/name
262        source: String,
263    },
264    /// List installed packages
265    List,
266    /// Uninstall a package by name
267    Uninstall {
268        /// Package name to uninstall
269        name: String,
270    },
271    /// Update a package to the latest version
272    Update {
273        /// Package name to update (updates all if omitted)
274        name: Option<String>,
275    },
276}
277
278// ── Issue subcommands ───────────────────────────────────────────────
279
280/// Local issue management subcommands.
281#[derive(Debug, Clone, Subcommand)]
282pub enum IssueCommands {
283    /// List local issues (default: open only)
284    List {
285        /// Show closed issues too
286        #[arg(long)]
287        all: bool,
288        /// Filter by label
289        #[arg(long)]
290        label: Option<String>,
291        /// Filter by substring of title
292        #[arg(long)]
293        text: Option<String>,
294    },
295    /// Show a single issue (prints content + content_hash for `update`)
296    Show {
297        /// Issue id
298        id: u32,
299    },
300    /// Create a new issue
301    New {
302        /// Issue title
303        title: String,
304        /// Issue body (markdown); pass via stdin or $EDITOR
305        #[arg(long, short)]
306        body: Option<String>,
307        /// Priority: low|medium|high|critical (default: medium)
308        #[arg(long)]
309        priority: Option<String>,
310        /// Comma-separated labels
311        #[arg(long)]
312        labels: Option<String>,
313    },
314    /// Close an issue (releases any assignment; must be owner)
315    Close {
316        /// Issue id
317        id: u32,
318        /// Content hash from `show` (skip to bypass CAS check)
319        #[arg(long)]
320        hash: Option<String>,
321    },
322    /// Reopen a closed issue (anyone may reopen after close)
323    Reopen {
324        /// Issue id
325        id: u32,
326        /// Content hash from `show` (skip to bypass CAS check)
327        #[arg(long)]
328        hash: Option<String>,
329    },
330    /// Reap dead alive-lock files under `.oxicode/issues/.alive/` (best-effort cleanup
331    /// of zombie locks left by crashed/killed processes). Age-gated: only files
332    /// older than 1 hour and not currently held are removed. Prints the count.
333    Reap,
334}
335
336// ── Extension subcommands ──────────────────────────────────────────────
337
338/// Extension management subcommands
339#[derive(Debug, Clone, Subcommand)]
340pub enum ExtCommands {
341    /// Install a WASM extension from a GitHub repo (owner/repo or owner/repo@version)
342    Install {
343        /// Extension source: owner/repo or owner/repo@version
344        source: String,
345        /// Include pre-release versions
346        #[arg(long)]
347        prerelease: bool,
348    },
349    /// List installed extensions
350    List,
351    /// Remove an installed extension
352    Remove {
353        /// Extension source: owner/repo
354        source: String,
355    },
356    /// Update extension(s) to latest version
357    Update {
358        /// Extension source: owner/repo (updates all if omitted)
359        source: Option<String>,
360    },
361    /// Show info about a remote extension (without installing)
362    Info {
363        /// Extension source: owner/repo
364        source: String,
365    },
366}
367
368// ── Config subcommands ─────────────────────────────────────────────
369
370/// Configuration management subcommands
371#[derive(Debug, Clone, Subcommand)]
372pub enum ConfigCommands {
373    /// Show current configuration
374    Show,
375    /// List all enabled resources
376    List {
377        /// Resource type filter (extensions, skills, prompts, themes)
378        resource_type: Option<String>,
379    },
380    /// Enable a resource (extension, skill, prompt, or theme)
381    Enable {
382        /// Resource type: extension, skill, prompt, or theme
383        resource_type: String,
384        /// Resource path or name
385        name: String,
386    },
387    /// Disable a resource
388    Disable {
389        /// Resource type: extension, skill, prompt, or theme
390        resource_type: String,
391        /// Resource path or name
392        name: String,
393    },
394    /// Set a configuration value
395    Set {
396        /// Setting key (e.g. theme, model, thinking_level)
397        key: String,
398        /// Setting value
399        value: String,
400    },
401    /// Get a configuration value
402    Get {
403        /// Setting key
404        key: String,
405    },
406    /// Add a custom OpenAI-compatible provider
407    AddProvider {
408        /// Provider name (e.g. minimax)
409        name: String,
410        /// Base URL (e.g. <https://api.minimax.chat/v1>)
411        base_url: String,
412        /// Environment variable name for API key (e.g. MINIMAX_API_KEY)
413        api_key_env: String,
414        /// API type: openai-completions or openai-responses (default: openai-completions)
415        #[arg(default_value = "openai-completions")]
416        api: String,
417    },
418    /// Remove a custom provider
419    RemoveProvider {
420        /// Provider name to remove
421        name: String,
422    },
423    /// Reset credentials (auth.json) and optionally settings
424    Reset {
425        /// Also reset settings (settings.toml / settings.json)
426        #[arg(long, short)]
427        all: bool,
428    },
429    /// Show the config file path
430    Path,
431}
432
433// ── Parsing helpers ────────────────────────────────────────────────
434
435/// Parse CLI arguments from the command line
436///
437/// # Examples
438///
439/// ```ignore
440/// use oxicode_cli::CliArgs;
441///
442/// fn main() {
443///     let args = CliArgs::parse();
444///     match args.command {
445///         Some(Commands::Sessions) => { /* list sessions */ }
446///         Some(Commands::Tree { session_id }) => { /* show tree */ }
447///         _ => { /* interactive mode */ }
448///     }
449/// }
450/// ```
451pub fn parse_args() -> CliArgs {
452    CliArgs::parse()
453}
454
455/// Parse CLI arguments from a specific iterator
456pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
457where
458    I: IntoIterator<Item = T>,
459    T: Into<std::ffi::OsString> + Clone,
460{
461    CliArgs::try_parse_from(iter)
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_parse_basic_prompt() {
470        let args = parse_args_from(["oxicode", "Hello", "world"]).unwrap();
471        assert_eq!(args.prompt, vec!["Hello", "world"]);
472    }
473
474    #[test]
475    fn test_parse_with_provider_and_model() {
476        let args = parse_args_from([
477            "oxicode",
478            "--provider",
479            "anthropic",
480            "--model",
481            "claude-sonnet-4-20250514",
482            "Hello",
483        ])
484        .unwrap();
485        assert_eq!(args.provider, Some("anthropic".to_string()));
486        assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
487    }
488
489    #[test]
490    fn test_parse_interactive_flag() {
491        let args = parse_args_from(["oxicode", "-i"]).unwrap();
492        assert!(args.interactive);
493    }
494
495    #[test]
496    fn test_parse_extension_paths() {
497        let args =
498            parse_args_from(["oxicode", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
499        assert_eq!(args.extensions.len(), 2);
500    }
501
502    #[test]
503    fn test_parse_sessions_command() {
504        let args = parse_args_from(["oxicode", "sessions"]).unwrap();
505        assert!(matches!(args.command, Some(Commands::Sessions)));
506    }
507
508    #[test]
509    fn test_parse_tree_command() {
510        let args = parse_args_from(["oxicode", "tree", "abc-123"]).unwrap();
511        match args.command {
512            Some(Commands::Tree { session_id }) => {
513                assert_eq!(session_id, "abc-123");
514            }
515            _ => panic!("Expected Tree command"),
516        }
517    }
518
519    #[test]
520    fn test_parse_tree_command_default() {
521        let args = parse_args_from(["oxicode", "tree"]).unwrap();
522        match args.command {
523            Some(Commands::Tree { session_id }) => {
524                assert_eq!(session_id, "");
525            }
526            _ => panic!("Expected Tree command"),
527        }
528    }
529
530    #[test]
531    fn test_parse_fork_command() {
532        let args = parse_args_from(["oxicode", "fork", "parent-id", "entry-id"]).unwrap();
533        match args.command {
534            Some(Commands::Fork {
535                parent_id,
536                entry_id,
537            }) => {
538                assert_eq!(parent_id, "parent-id");
539                assert_eq!(entry_id, "entry-id");
540            }
541            _ => panic!("Expected Fork command"),
542        }
543    }
544
545    #[test]
546    fn test_parse_delete_command() {
547        let args = parse_args_from(["oxicode", "delete", "session-123"]).unwrap();
548        match args.command {
549            Some(Commands::Delete { session_id }) => {
550                assert_eq!(session_id, "session-123");
551            }
552            _ => panic!("Expected Delete command"),
553        }
554    }
555
556    #[test]
557    fn test_parse_pkg_install() {
558        let args = parse_args_from(["oxicode", "pkg", "install", "npm:@scope/name"]).unwrap();
559        match args.command {
560            Some(Commands::Pkg { action }) => match action {
561                PkgCommands::Install { source } => {
562                    assert_eq!(source, "npm:@scope/name");
563                }
564                _ => panic!("Expected Install subcommand"),
565            },
566            _ => panic!("Expected Pkg command"),
567        }
568    }
569
570    #[test]
571    fn test_parse_pkg_list() {
572        let args = parse_args_from(["oxicode", "pkg", "list"]).unwrap();
573        match args.command {
574            Some(Commands::Pkg { action }) => {
575                assert!(matches!(action, PkgCommands::List));
576            }
577            _ => panic!("Expected Pkg command"),
578        }
579    }
580
581    #[test]
582    fn test_parse_pkg_update_all() {
583        let args = parse_args_from(["oxicode", "pkg", "update"]).unwrap();
584        match args.command {
585            Some(Commands::Pkg { action }) => match action {
586                PkgCommands::Update { name } => assert!(name.is_none()),
587                _ => panic!("Expected Update subcommand"),
588            },
589            _ => panic!("Expected Pkg command"),
590        }
591    }
592
593    #[test]
594    fn test_parse_pkg_update_named() {
595        let args = parse_args_from(["oxicode", "pkg", "update", "my-pkg"]).unwrap();
596        match args.command {
597            Some(Commands::Pkg { action }) => match action {
598                PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
599                _ => panic!("Expected Update subcommand"),
600            },
601            _ => panic!("Expected Pkg command"),
602        }
603    }
604
605    #[test]
606    fn test_parse_config_show() {
607        let args = parse_args_from(["oxicode", "config", "show"]).unwrap();
608        assert!(matches!(
609            args.command,
610            Some(Commands::Config {
611                action: ConfigCommands::Show
612            })
613        ));
614    }
615
616    #[test]
617    fn test_parse_config_set() {
618        let args = parse_args_from(["oxicode", "config", "set", "theme", "dracula"]).unwrap();
619        match args.command {
620            Some(Commands::Config { action }) => match action {
621                ConfigCommands::Set { key, value } => {
622                    assert_eq!(key, "theme");
623                    assert_eq!(value, "dracula");
624                }
625                _ => panic!("Expected Set subcommand"),
626            },
627            _ => panic!("Expected Config command"),
628        }
629    }
630
631    #[test]
632    fn test_parse_config_get() {
633        let args = parse_args_from(["oxicode", "config", "get", "theme"]).unwrap();
634        match args.command {
635            Some(Commands::Config { action }) => match action {
636                ConfigCommands::Get { key } => {
637                    assert_eq!(key, "theme");
638                }
639                _ => panic!("Expected Get subcommand"),
640            },
641            _ => panic!("Expected Config command"),
642        }
643    }
644
645    #[test]
646    fn test_parse_config_enable() {
647        let args = parse_args_from(["oxicode", "config", "enable", "extension", "my-ext"]).unwrap();
648        match args.command {
649            Some(Commands::Config { action }) => match action {
650                ConfigCommands::Enable {
651                    resource_type,
652                    name,
653                } => {
654                    assert_eq!(resource_type, "extension");
655                    assert_eq!(name, "my-ext");
656                }
657                _ => panic!("Expected Enable subcommand"),
658            },
659            _ => panic!("Expected Config command"),
660        }
661    }
662
663    #[test]
664    fn test_parse_config_disable() {
665        let args = parse_args_from(["oxicode", "config", "disable", "skill", "my-skill"]).unwrap();
666        match args.command {
667            Some(Commands::Config { action }) => match action {
668                ConfigCommands::Disable {
669                    resource_type,
670                    name,
671                } => {
672                    assert_eq!(resource_type, "skill");
673                    assert_eq!(name, "my-skill");
674                }
675                _ => panic!("Expected Disable subcommand"),
676            },
677            _ => panic!("Expected Config command"),
678        }
679    }
680
681    #[test]
682    fn test_parse_config_list() {
683        let args = parse_args_from(["oxicode", "config", "list"]).unwrap();
684        match args.command {
685            Some(Commands::Config { action }) => match action {
686                ConfigCommands::List { resource_type } => {
687                    assert!(resource_type.is_none());
688                }
689                _ => panic!("Expected List subcommand"),
690            },
691            _ => panic!("Expected Config command"),
692        }
693    }
694
695    #[test]
696    fn test_parse_config_list_filtered() {
697        let args = parse_args_from(["oxicode", "config", "list", "extensions"]).unwrap();
698        match args.command {
699            Some(Commands::Config { action }) => match action {
700                ConfigCommands::List { resource_type } => {
701                    assert_eq!(resource_type, Some("extensions".to_string()));
702                }
703                _ => panic!("Expected List subcommand"),
704            },
705            _ => panic!("Expected Config command"),
706        }
707    }
708
709    #[test]
710    fn test_thinking_level_reexport() {
711        // Verify the re-export from settings works
712        assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
713    }
714
715    #[test]
716    fn test_parse_config_add_provider() {
717        let args = parse_args_from([
718            "oxicode",
719            "config",
720            "add-provider",
721            "minimax",
722            "https://api.minimax.chat/v1",
723            "MINIMAX_API_KEY",
724            "openai-completions",
725        ])
726        .unwrap();
727        match args.command {
728            Some(Commands::Config { action }) => match action {
729                ConfigCommands::AddProvider {
730                    name,
731                    base_url,
732                    api_key_env,
733                    api,
734                } => {
735                    assert_eq!(name, "minimax");
736                    assert_eq!(base_url, "https://api.minimax.chat/v1");
737                    assert_eq!(api_key_env, "MINIMAX_API_KEY");
738                    assert_eq!(api, "openai-completions");
739                }
740                _ => panic!("Expected AddProvider subcommand"),
741            },
742            _ => panic!("Expected Config command"),
743        }
744    }
745
746    #[test]
747    fn test_parse_config_add_provider_default_api() {
748        let args = parse_args_from([
749            "oxicode",
750            "config",
751            "add-provider",
752            "zai",
753            "https://api.z.ai/v1",
754            "ZAI_API_KEY",
755        ])
756        .unwrap();
757        match args.command {
758            Some(Commands::Config { action }) => match action {
759                ConfigCommands::AddProvider {
760                    name,
761                    base_url,
762                    api_key_env,
763                    api,
764                } => {
765                    assert_eq!(name, "zai");
766                    assert_eq!(base_url, "https://api.z.ai/v1");
767                    assert_eq!(api_key_env, "ZAI_API_KEY");
768                    assert_eq!(api, "openai-completions"); // default
769                }
770                _ => panic!("Expected AddProvider subcommand"),
771            },
772            _ => panic!("Expected Config command"),
773        }
774    }
775
776    #[test]
777    fn test_parse_config_remove_provider() {
778        let args = parse_args_from(["oxicode", "config", "remove-provider", "minimax"]).unwrap();
779        match args.command {
780            Some(Commands::Config { action }) => match action {
781                ConfigCommands::RemoveProvider { name } => {
782                    assert_eq!(name, "minimax");
783                }
784                _ => panic!("Expected RemoveProvider subcommand"),
785            },
786            _ => panic!("Expected Config command"),
787        }
788    }
789
790    #[test]
791    fn test_parse_models_command() {
792        let args = parse_args_from(["oxicode", "models"]).unwrap();
793        match args.command {
794            Some(Commands::Models { provider }) => {
795                assert!(provider.is_none());
796            }
797            _ => panic!("Expected Models command"),
798        }
799    }
800
801    #[test]
802    fn test_parse_models_with_provider() {
803        let args = parse_args_from(["oxicode", "models", "--provider", "minimax"]).unwrap();
804        match args.command {
805            Some(Commands::Models { provider }) => {
806                assert_eq!(provider, Some("minimax".to_string()));
807            }
808            _ => panic!("Expected Models command"),
809        }
810    }
811
812    #[test]
813    fn test_parse_setup_command() {
814        let args = parse_args_from(["oxicode", "setup"]).unwrap();
815        match args.command {
816            Some(Commands::Setup { reset }) => {
817                assert!(!reset);
818            }
819            _ => panic!("Expected Setup command"),
820        }
821    }
822
823    #[test]
824    fn test_parse_setup_reset() {
825        let args = parse_args_from(["oxicode", "setup", "--reset"]).unwrap();
826        match args.command {
827            Some(Commands::Setup { reset }) => {
828                assert!(reset);
829            }
830            _ => panic!("Expected Setup command with reset"),
831        }
832    }
833
834    // ── Reset command ────────────────────────────────────────────
835
836    #[test]
837    fn test_parse_reset_command() {
838        let args = parse_args_from(["oxicode", "reset"]).unwrap();
839        match args.command {
840            Some(Commands::Reset {
841                yes,
842                include_project,
843            }) => {
844                assert!(!yes);
845                assert!(!include_project);
846            }
847            _ => panic!("Expected Reset command"),
848        }
849    }
850
851    #[test]
852    fn test_parse_reset_yes_flag() {
853        let args = parse_args_from(["oxicode", "reset", "--yes"]).unwrap();
854        match args.command {
855            Some(Commands::Reset {
856                yes,
857                include_project,
858            }) => {
859                assert!(yes);
860                assert!(!include_project);
861            }
862            _ => panic!("Expected Reset command with --yes"),
863        }
864    }
865
866    #[test]
867    fn test_parse_reset_include_project() {
868        let args = parse_args_from(["oxicode", "reset", "--yes", "--include-project"]).unwrap();
869        match args.command {
870            Some(Commands::Reset {
871                yes,
872                include_project,
873            }) => {
874                assert!(yes);
875                assert!(include_project);
876            }
877            _ => panic!("Expected Reset command with all flags"),
878        }
879    }
880}