Skip to main content

oxi/
cli.rs

1//! CLI argument parsing with clap
2//!
3//! Provides command-line argument parsing for the oxi CLI.
4
5use clap::{Parser, Subcommand, ValueEnum};
6use std::path::PathBuf;
7use std::str::FromStr;
8
9/// Thinking level options
10#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
11#[clap(rename_all = "lower")]
12pub enum ThinkingLevel {
13    Off,
14    Minimal,
15    Low,
16    Medium,
17    High,
18    XHigh,
19}
20
21impl std::fmt::Display for ThinkingLevel {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            ThinkingLevel::Off => write!(f, "off"),
25            ThinkingLevel::Minimal => write!(f, "minimal"),
26            ThinkingLevel::Low => write!(f, "low"),
27            ThinkingLevel::Medium => write!(f, "medium"),
28            ThinkingLevel::High => write!(f, "high"),
29            ThinkingLevel::XHigh => write!(f, "xhigh"),
30        }
31    }
32}
33
34impl FromStr for ThinkingLevel {
35    type Err = String;
36    fn from_str(s: &str) -> Result<Self, Self::Err> {
37        match s.to_lowercase().as_str() {
38            "off" => Ok(ThinkingLevel::Off),
39            "minimal" => Ok(ThinkingLevel::Minimal),
40            "low" => Ok(ThinkingLevel::Low),
41            "medium" => Ok(ThinkingLevel::Medium),
42            "high" => Ok(ThinkingLevel::High),
43            "xhigh" | "x-high" => Ok(ThinkingLevel::XHigh),
44            _ => Err(format!("Invalid thinking level: {}", s)),
45        }
46    }
47}
48
49/// Output mode
50#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
51pub enum OutputMode {
52    Text,
53    Json,
54    Rpc,
55}
56
57impl std::fmt::Display for OutputMode {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            OutputMode::Text => write!(f, "text"),
61            OutputMode::Json => write!(f, "json"),
62            OutputMode::Rpc => write!(f, "rpc"),
63        }
64    }
65}
66
67/// CLI arguments for the main chat command
68/// CLI arguments for the install command
69#[derive(Debug, Clone, Parser)]
70pub struct InstallArgs {
71    /// Source to install (URL, git repo, or npm package)
72    pub source: String,
73
74    /// Local only (don't add to settings)
75    #[arg(short = 'l', long)]
76    pub local: bool,
77
78    /// Install globally
79    #[arg(short = 'g', long)]
80    pub global: bool,
81}
82
83/// CLI arguments for the remove command
84#[derive(Debug, Clone, Parser)]
85pub struct RemoveArgs {
86    /// Source to remove
87    pub source: String,
88
89    /// Local only
90    #[arg(short = 'l', long)]
91    pub local: bool,
92}
93
94/// CLI arguments for the update command
95#[derive(Debug, Clone, Parser)]
96pub struct UpdateArgs {
97    /// Source to update (or 'self' for oxi, 'pi' for package)
98    pub source: Option<String>,
99
100    /// Update all
101    #[arg(short = 'a', long)]
102    pub all: bool,
103
104    /// Force update
105    #[arg(short = 'f', long)]
106    pub force: bool,
107}
108
109/// CLI arguments for the list command
110#[derive(Debug, Clone, Parser)]
111pub struct ListArgs {
112    /// Show installed extensions
113    #[arg(long)]
114    pub extensions: bool,
115
116    /// Show installed skills
117    #[arg(long)]
118    pub skills: bool,
119
120    /// Show installed prompts
121    #[arg(long)]
122    pub prompts: bool,
123
124    /// Show installed themes
125    #[arg(long)]
126    pub themes: bool,
127
128    /// Include disabled
129    #[arg(long, short = 'a')]
130    pub include_disabled: bool,
131}
132
133/// CLI subcommands
134#[derive(Debug, Clone, Subcommand)]
135pub enum Commands {
136    /// Install extension source
137    Install(InstallArgs),
138    /// Remove extension source
139    Remove(RemoveArgs),
140    /// Uninstall extension source (alias for remove)
141    Uninstall(RemoveArgs),
142    /// Update oxi and extensions
143    Update(UpdateArgs),
144    /// List installed resources
145    List(ListArgs),
146    /// Open config selector TUI
147    Config,
148}
149
150/// Main CLI arguments
151#[derive(Debug, Clone, Parser)]
152#[command(name = "oxi")]
153#[command(about = "AI coding assistant with read, bash, edit, write tools")]
154pub struct CliArgs {
155    /// Provider name
156    #[arg(short, long)]
157    pub provider: Option<String>,
158
159    /// Model pattern
160    #[arg(short, long)]
161    pub model: Option<String>,
162
163    /// API key
164    #[arg(long)]
165    pub api_key: Option<String>,
166
167    /// System prompt
168    #[arg(long)]
169    pub system_prompt: Option<String>,
170
171    /// Append system prompt
172    #[arg(long = "append-system-prompt")]
173    pub append_system_prompt: Vec<String>,
174
175    /// Thinking level
176    #[arg(long)]
177    pub thinking: Option<ThinkingLevel>,
178
179    /// Continue session
180    #[arg(short = 'c', long)]
181    pub continue_session: bool,
182
183    /// Resume session
184    #[arg(short = 'r', long)]
185    pub resume: bool,
186
187    /// Session
188    #[arg(long)]
189    pub session: Option<String>,
190
191    /// Fork session
192    #[arg(long)]
193    pub fork: Option<String>,
194
195    /// Session directory
196    #[arg(long)]
197    pub session_dir: Option<PathBuf>,
198
199    /// No session
200    #[arg(long)]
201    pub no_session: bool,
202
203    /// Models for cycling
204    #[arg(long)]
205    pub models: Option<String>,
206
207    /// No tools
208    #[arg(long = "no-tools", short = 't')]
209    pub no_tools: bool,
210
211    /// No built-in tools
212    #[arg(long = "no-builtin-tools")]
213    pub no_builtin_tools: bool,
214
215    /// Tools allowlist
216    #[arg(short = 'o', long)]
217    pub tools: Option<String>,
218
219    /// Print mode
220    #[arg(long)]
221    pub print: bool,
222
223    /// Export
224    #[arg(long)]
225    pub export: Option<PathBuf>,
226
227    /// Extensions
228    #[arg(short = 'e', long)]
229    pub extension: Vec<PathBuf>,
230
231    /// No extensions
232    #[arg(long)]
233    pub no_extensions: bool,
234
235    /// Skills
236    #[arg(long)]
237    pub skill: Vec<PathBuf>,
238
239    /// No skills
240    #[arg(long = "no-skills")]
241    pub no_skills: bool,
242
243    /// Prompt templates
244    #[arg(long = "prompt-template")]
245    pub prompt_template: Vec<PathBuf>,
246
247    /// No prompt templates
248    #[arg(long = "no-prompt-templates")]
249    pub no_prompt_templates: bool,
250
251    /// Themes
252    #[arg(long)]
253    pub theme: Vec<PathBuf>,
254
255    /// No themes
256    #[arg(long)]
257    pub no_themes: bool,
258
259    /// No context files
260    #[arg(long = "no-context-files")]
261    pub no_context_files: bool,
262
263    /// List models
264    #[arg(long)]
265    pub list_models: Option<Option<String>>,
266
267    /// Verbose
268    #[arg(long)]
269    pub verbose: bool,
270
271    /// Offline
272    #[arg(long)]
273    pub offline: bool,
274
275    /// Command to run
276    #[command(subcommand)]
277    pub command: Option<Commands>,
278
279    /// Messages
280    pub messages: Vec<String>,
281
282    /// File arguments
283    #[arg(long = "file", value_delimiter = ' ')]
284    pub file_args: Vec<PathBuf>,
285}
286
287/// Parse CLI arguments from the command line
288pub fn parse_args() -> CliArgs {
289    CliArgs::parse()
290}
291
292/// Parse CLI arguments from a specific iterator
293pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
294where
295    I: IntoIterator<Item = T>,
296    T: Into<std::ffi::OsString> + Clone,
297{
298    CliArgs::try_parse_from(iter)
299}
300
301/// Check if stdin is piped (for print mode detection)
302pub fn is_stdin_piped() -> bool {
303    // Simple check using is_terminal()
304    #[cfg(unix)]
305    {
306        use std::io::IsTerminal;
307        return !std::io::stdin().is_terminal();
308    }
309    #[cfg(not(unix))]
310    {
311        false
312    }
313}
314
315/// Detect if we're running in print mode (non-interactive)
316pub fn detect_print_mode() -> bool {
317    // Check for print flag
318    let args: Vec<String> = std::env::args().collect();
319    if args.iter().any(|a| a == "-p" || a == "--print") {
320        return true;
321    }
322
323    // Check if stdin is piped
324    is_stdin_piped()
325}
326
327/// Get the version string
328pub fn get_version() -> String {
329    let version = env!("CARGO_PKG_VERSION");
330    format!("{}", version)
331}
332
333/// Generate shell completion script
334pub fn generate_completion(shell: &str) -> String {
335    // Requires clap_complete crate
336    format!("# Shell completion for {} is not yet implemented.\n# Install clap_complete to enable this feature.", shell)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_parse_basic_args() {
345        let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
346        assert_eq!(args.messages, vec!["Hello", "world"]);
347    }
348
349    #[test]
350    fn test_parse_with_provider_and_model() {
351        let args = parse_args_from([
352            "oxi",
353            "--provider",
354            "anthropic",
355            "--model",
356            "claude-sonnet-4-5",
357            "Hello",
358        ])
359        .unwrap();
360        assert_eq!(args.provider, Some("anthropic".to_string()));
361        assert_eq!(args.model, Some("claude-sonnet-4-5".to_string()));
362    }
363
364    #[test]
365    fn test_parse_with_thinking_level() {
366        let args = parse_args_from(["oxi", "--thinking", "high", "Hello"]).unwrap();
367        assert_eq!(args.thinking, Some(ThinkingLevel::High));
368    }
369
370    #[test]
371    fn test_parse_with_tools() {
372        let args = parse_args_from(["oxi", "-o", "read,bash,edit", "Hello"]).unwrap();
373        assert_eq!(args.tools, Some("read,bash,edit".to_string()));
374    }
375
376    #[test]
377    fn test_parse_with_multiple_files() {
378        let args = parse_args_from([
379            "oxi",
380            "--file", "file1.txt",
381            "--file", "file2.txt",
382            "Hello",
383        ])
384        .unwrap();
385        assert_eq!(args.file_args.len(), 2);
386    }
387
388    #[test]
389    fn test_parse_print_mode() {
390        let args = parse_args_from(["oxi", "--print", "Hello"]).unwrap();
391        assert!(args.print);
392    }
393
394    #[test]
395    fn test_parse_resume_flag() {
396        let args = parse_args_from(["oxi", "-r"]).unwrap();
397        assert!(args.resume);
398    }
399
400    #[test]
401    fn test_parse_continue_flag() {
402        let args = parse_args_from(["oxi", "-c"]).unwrap();
403        assert!(args.continue_session);
404    }
405
406    #[test]
407    fn test_parse_subcommand() {
408        let args = parse_args_from(["oxi", "config"]).unwrap();
409        assert!(matches!(args.command, Some(Commands::Config)));
410    }
411
412    #[test]
413    fn test_parse_install_command() {
414        let args = parse_args_from(["oxi", "install", "git:https://github.com/example/ext"])
415            .unwrap();
416        match args.command {
417            Some(Commands::Install(install_args)) => {
418                assert_eq!(install_args.source, "git:https://github.com/example/ext");
419            }
420            _ => panic!("Expected Install command"),
421        }
422    }
423
424    #[test]
425    fn test_parse_remove_command() {
426        let args = parse_args_from(["oxi", "remove", "example-ext"]).unwrap();
427        match args.command {
428            Some(Commands::Remove(remove_args)) => {
429                assert_eq!(remove_args.source, "example-ext");
430            }
431            _ => panic!("Expected Remove command"),
432        }
433    }
434
435    #[test]
436    fn test_parse_update_command() {
437        let args = parse_args_from(["oxi", "update", "self"]).unwrap();
438        match args.command {
439            Some(Commands::Update(update_args)) => {
440                assert_eq!(update_args.source, Some("self".to_string()));
441            }
442            _ => panic!("Expected Update command"),
443        }
444    }
445
446    #[test]
447    fn test_parse_list_command() {
448        let args = parse_args_from(["oxi", "list", "--extensions"]).unwrap();
449        match args.command {
450            Some(Commands::List(list_args)) => {
451                assert!(list_args.extensions);
452            }
453            _ => panic!("Expected List command"),
454        }
455    }
456
457    #[test]
458    fn test_thinking_level_from_str() {
459        assert_eq!("high".parse::<ThinkingLevel>().unwrap(), ThinkingLevel::High);
460        assert_eq!("off".parse::<ThinkingLevel>().unwrap(), ThinkingLevel::Off);
461        assert_eq!("xhigh".parse::<ThinkingLevel>().unwrap(), ThinkingLevel::XHigh);
462        assert!("invalid".parse::<ThinkingLevel>().is_err());
463    }
464
465    #[test]
466    fn test_thinking_level_display() {
467        assert_eq!(ThinkingLevel::High.to_string(), "high");
468        assert_eq!(ThinkingLevel::XHigh.to_string(), "xhigh");
469    }
470}