1use clap::{Parser, Subcommand, ValueEnum};
6use std::path::PathBuf;
7use std::str::FromStr;
8
9#[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#[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#[derive(Debug, Clone, Parser)]
70pub struct InstallArgs {
71 pub source: String,
73
74 #[arg(short = 'l', long)]
76 pub local: bool,
77
78 #[arg(short = 'g', long)]
80 pub global: bool,
81}
82
83#[derive(Debug, Clone, Parser)]
85pub struct RemoveArgs {
86 pub source: String,
88
89 #[arg(short = 'l', long)]
91 pub local: bool,
92}
93
94#[derive(Debug, Clone, Parser)]
96pub struct UpdateArgs {
97 pub source: Option<String>,
99
100 #[arg(short = 'a', long)]
102 pub all: bool,
103
104 #[arg(short = 'f', long)]
106 pub force: bool,
107}
108
109#[derive(Debug, Clone, Parser)]
111pub struct ListArgs {
112 #[arg(long)]
114 pub extensions: bool,
115
116 #[arg(long)]
118 pub skills: bool,
119
120 #[arg(long)]
122 pub prompts: bool,
123
124 #[arg(long)]
126 pub themes: bool,
127
128 #[arg(long, short = 'a')]
130 pub include_disabled: bool,
131}
132
133#[derive(Debug, Clone, Subcommand)]
135pub enum Commands {
136 Install(InstallArgs),
138 Remove(RemoveArgs),
140 Uninstall(RemoveArgs),
142 Update(UpdateArgs),
144 List(ListArgs),
146 Config,
148}
149
150#[derive(Debug, Clone, Parser)]
152#[command(name = "oxi")]
153#[command(about = "AI coding assistant with read, bash, edit, write tools")]
154pub struct CliArgs {
155 #[arg(short, long)]
157 pub provider: Option<String>,
158
159 #[arg(short, long)]
161 pub model: Option<String>,
162
163 #[arg(long)]
165 pub api_key: Option<String>,
166
167 #[arg(long)]
169 pub system_prompt: Option<String>,
170
171 #[arg(long = "append-system-prompt")]
173 pub append_system_prompt: Vec<String>,
174
175 #[arg(long)]
177 pub thinking: Option<ThinkingLevel>,
178
179 #[arg(short = 'c', long)]
181 pub continue_session: bool,
182
183 #[arg(short = 'r', long)]
185 pub resume: bool,
186
187 #[arg(long)]
189 pub session: Option<String>,
190
191 #[arg(long)]
193 pub fork: Option<String>,
194
195 #[arg(long)]
197 pub session_dir: Option<PathBuf>,
198
199 #[arg(long)]
201 pub no_session: bool,
202
203 #[arg(long)]
205 pub models: Option<String>,
206
207 #[arg(long = "no-tools", short = 't')]
209 pub no_tools: bool,
210
211 #[arg(long = "no-builtin-tools")]
213 pub no_builtin_tools: bool,
214
215 #[arg(short = 'o', long)]
217 pub tools: Option<String>,
218
219 #[arg(long)]
221 pub print: bool,
222
223 #[arg(long)]
225 pub export: Option<PathBuf>,
226
227 #[arg(short = 'e', long)]
229 pub extension: Vec<PathBuf>,
230
231 #[arg(long)]
233 pub no_extensions: bool,
234
235 #[arg(long)]
237 pub skill: Vec<PathBuf>,
238
239 #[arg(long = "no-skills")]
241 pub no_skills: bool,
242
243 #[arg(long = "prompt-template")]
245 pub prompt_template: Vec<PathBuf>,
246
247 #[arg(long = "no-prompt-templates")]
249 pub no_prompt_templates: bool,
250
251 #[arg(long)]
253 pub theme: Vec<PathBuf>,
254
255 #[arg(long)]
257 pub no_themes: bool,
258
259 #[arg(long = "no-context-files")]
261 pub no_context_files: bool,
262
263 #[arg(long)]
265 pub list_models: Option<Option<String>>,
266
267 #[arg(long)]
269 pub verbose: bool,
270
271 #[arg(long)]
273 pub offline: bool,
274
275 #[command(subcommand)]
277 pub command: Option<Commands>,
278
279 pub messages: Vec<String>,
281
282 #[arg(long = "file", value_delimiter = ' ')]
284 pub file_args: Vec<PathBuf>,
285}
286
287pub fn parse_args() -> CliArgs {
289 CliArgs::parse()
290}
291
292pub 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
301pub fn is_stdin_piped() -> bool {
303 #[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
315pub fn detect_print_mode() -> bool {
317 let args: Vec<String> = std::env::args().collect();
319 if args.iter().any(|a| a == "-p" || a == "--print") {
320 return true;
321 }
322
323 is_stdin_piped()
325}
326
327pub fn get_version() -> String {
329 let version = env!("CARGO_PKG_VERSION");
330 format!("{}", version)
331}
332
333pub fn generate_completion(shell: &str) -> String {
335 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}