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