1use std::path::PathBuf;
26
27use rpi_ai::ThinkingLevel;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum Mode {
34 #[default]
35 Text,
36 Json,
37 Rpc,
38}
39
40#[derive(Debug, Clone, Default)]
44pub struct Args {
45 pub provider: Option<String>,
46 pub model: Option<String>,
47 pub api_key: Option<String>,
48 pub system_prompt: Option<String>,
49 pub append_system_prompt: Vec<String>,
50 pub thinking: Option<ThinkingLevel>,
51
52 pub print: bool,
53 pub mode: Mode,
54
55 pub continue_session: bool,
56 pub resume: bool,
57 pub session: Option<String>,
58 pub session_dir: Option<PathBuf>,
59 pub no_session: bool,
60 pub name: Option<String>,
61
62 pub tools: Option<Vec<String>>,
63 pub exclude_tools: Option<Vec<String>>,
64 pub no_tools: bool,
65 pub no_builtin_tools: bool,
66
67 pub verbose: bool,
68 pub help: bool,
69 pub version: bool,
70
71 pub messages: Vec<String>,
73 pub file_args: Vec<PathBuf>,
76
77 pub ignored: Vec<String>,
80 pub errors: Vec<String>,
83}
84
85pub const VALID_THINKING_LEVELS: &[&str] =
88 &["off", "minimal", "low", "medium", "high", "xhigh", "max"];
89
90pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
92 Some(match s {
93 "off" => ThinkingLevel::Off,
94 "minimal" => ThinkingLevel::Minimal,
95 "low" => ThinkingLevel::Low,
96 "medium" => ThinkingLevel::Medium,
97 "high" => ThinkingLevel::High,
98 "xhigh" => ThinkingLevel::Xhigh,
99 "max" => ThinkingLevel::Max,
100 _ => return None,
101 })
102}
103
104fn file_arg(arg: &str) -> Option<PathBuf> {
107 if let Some(rest) = arg.strip_prefix('@') {
108 if rest.is_empty() {
110 None
111 } else {
112 Some(PathBuf::from(rest))
113 }
114 } else {
115 None
116 }
117}
118
119pub fn parse_args(args: &[String]) -> Args {
125 let mut result = Args::default();
126 let mut i = 0;
127 while i < args.len() {
128 let arg = args[i].clone();
129 let (flag_key, inline) = if arg.starts_with("--") {
133 match arg.find('=') {
134 Some(eq) => (arg[..eq].to_string(), Some(arg[eq + 1..].to_string())),
135 None => (arg.clone(), None),
136 }
137 } else {
138 (arg.clone(), None)
139 };
140
141 let mut take_value = |result: &mut Args, _flag: &str| -> Option<String> {
145 if let Some(v) = inline.clone() {
146 return Some(v);
147 }
148 if i + 1 < args.len() {
149 let next = &args[i + 1];
150 if !next.starts_with('-') || next == "-" {
151 i += 1;
152 return Some(args[i].clone());
153 }
154 }
155 result.errors.push(format!("{flag_key} requires a value"));
156 None
157 };
158
159 match flag_key.as_str() {
160 "--help" | "-h" => result.help = true,
161 "--version" | "-v" => result.version = true,
162 "--print" | "-p" => {
163 result.print = true;
164 if i + 1 < args.len() {
169 let next = &args[i + 1];
170 if !next.starts_with('@') && !next.starts_with('-') {
171 i += 1;
172 result.messages.push(args[i].clone());
173 }
174 }
175 }
176 "--mode" => {
177 if let Some(v) = take_value(&mut result, "--mode") {
178 result.mode = match v.as_str() {
179 "text" => Mode::Text,
180 "json" => Mode::Json,
181 "rpc" => Mode::Rpc,
182 other => {
183 result
184 .errors
185 .push(format!("Invalid --mode \"{other}\". Valid: text, json, rpc"));
186 Mode::Text
187 }
188 };
189 }
190 }
191 "--continue" | "-c" => result.continue_session = true,
192 "--resume" | "-r" => result.resume = true,
193 "--no-session" => result.no_session = true,
194 "--no-tools" | "-nt" => result.no_tools = true,
195 "--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
196 "--verbose" => result.verbose = true,
197 "--provider" => result.provider = take_value(&mut result, "--provider"),
198 "--model" => result.model = take_value(&mut result, "--model"),
199 "--api-key" => result.api_key = take_value(&mut result, "--api-key"),
200 "--system-prompt" => result.system_prompt = take_value(&mut result, "--system-prompt"),
201 "--append-system-prompt" => {
202 if let Some(v) = take_value(&mut result, "--append-system-prompt") {
203 result.append_system_prompt.push(v);
204 }
205 }
206 "--name" | "-n" => result.name = take_value(&mut result, "--name"),
207 "--session" => result.session = take_value(&mut result, "--session"),
208 "--session-dir" => {
209 if let Some(v) = take_value(&mut result, "--session-dir") {
210 result.session_dir = Some(PathBuf::from(v));
211 }
212 }
213 "--thinking" => {
214 if let Some(v) = take_value(&mut result, "--thinking") {
215 match parse_thinking_level(&v) {
216 Some(lvl) => result.thinking = Some(lvl),
217 None => result.ignored.push(format!(
218 "Invalid --thinking \"{v}\". Valid: {}",
219 VALID_THINKING_LEVELS.join(", ")
220 )),
221 }
222 }
223 }
224 "--tools" | "-t" => {
225 if let Some(v) = take_value(&mut result, &flag_key) {
226 result.tools = Some(split_csv(&v));
227 }
228 }
229 "--exclude-tools" | "-xt" => {
230 if let Some(v) = take_value(&mut result, &flag_key) {
231 result.exclude_tools = Some(split_csv(&v));
232 }
233 }
234 other
238 if matches!(
239 other,
240 "--models"
241 | "--offline"
242 | "--export"
243 | "--tui-mode"
244 | "--approve" | "-a"
245 | "--no-approve" | "-na"
246 | "--no-extensions" | "-ne"
247 | "--no-skills" | "-ns"
248 | "--no-prompt-templates" | "-np"
249 | "--no-themes"
250 | "--no-context-files" | "-nc"
251 ) =>
252 {
253 if inline.is_none()
256 && i + 1 < args.len()
257 && !args[i + 1].starts_with('-')
258 && !args[i + 1].starts_with('@')
259 {
260 i += 1;
261 }
262 result.ignored.push(format!("{other} is not supported in v1 (ignored)"));
263 }
264 flag @ ("--extension" | "-e" | "--skill" | "--prompt-template" | "--theme") => {
265 if inline.is_none()
269 && i + 1 < args.len()
270 && !args[i + 1].starts_with('-')
271 && !args[i + 1].starts_with('@')
272 {
273 i += 1;
274 }
275 result.ignored.push(format!("{flag} is not supported in v1 (ignored)"));
276 }
277 "--list-models" => {
278 if inline.is_none()
280 && i + 1 < args.len()
281 && !args[i + 1].starts_with('-')
282 && !args[i + 1].starts_with('@')
283 {
284 i += 1;
285 }
286 result.ignored.push("--list-models is not supported in v1 (ignored)".to_string());
287 }
288 "--fork" => {
289 result.ignored.push("--fork is not supported in v1 (ignored)".to_string());
290 if inline.is_none() && i + 1 < args.len() && !args[i + 1].starts_with('-') {
291 i += 1;
292 }
293 }
294 other if other.starts_with("--") => {
298 let name = &flag_key;
299 if inline.is_none()
300 && i + 1 < args.len()
301 && !args[i + 1].starts_with('-')
302 && !args[i + 1].starts_with('@')
303 {
304 i += 1;
305 }
306 result.ignored.push(format!("{name} is not a recognized flag (ignored)"));
307 }
308 other if other.starts_with('-') && other.len() > 1 => {
310 result
311 .errors
312 .push(format!("Unknown option: {other}"));
313 }
314 other if let Some(path) = file_arg(other) => {
316 result.file_args.push(path);
317 }
318 other => {
320 result.messages.push(other.to_string());
321 }
322 }
323 i += 1;
324 }
325
326 result
330}
331
332fn split_csv(v: &str) -> Vec<String> {
334 v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
335}
336
337pub fn resolve_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> RunMode {
342 if parsed.mode == Mode::Rpc {
343 return RunMode::Rpc;
344 }
345 if parsed.mode == Mode::Json {
346 return RunMode::Json;
347 }
348 if parsed.print || !stdin_is_tty || !stdout_is_tty {
349 RunMode::Print
350 } else {
351 RunMode::Interactive
352 }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum RunMode {
361 Interactive,
362 Print,
363 Json,
364 Rpc,
365}
366
367pub fn print_help() {
369 let builtin = "read, bash, edit, write, grep, find, ls";
370 println!(
371 "{name} - AI coding assistant with read, bash, edit, write, grep, find, ls tools
372
373{u}Usage:{r}
374 {name} [options] [@files...] [messages...]
375
376{u}Options:{r}
377 --provider <name> Provider name (v1: anthropic)
378 --model <pattern> Model pattern or ID (supports \"provider/id\" and optional \":<thinking>\")
379 --api-key <key> API key (defaults to ANTHROPIC_API_KEY)
380 --system-prompt <text> Replace the default system prompt
381 --append-system-prompt <text> Append text to the system prompt (repeatable)
382 --thinking <level> off, minimal, low, medium, high, xhigh, max
383 --mode <mode> Output mode: text (default), json, or rpc
384 --print, -p Non-interactive: process prompt(s) and exit
385 --continue, -c Continue the most recent session
386 --resume, -r Browse and select a session to resume
387 --session <id|path> Use a specific session (partial UUID or file)
388 --session-dir <dir> Directory for session storage
389 --no-session Ephemeral mode (do not persist the session)
390 --name, -n <name> Set the session display name
391 --tools, -t <list> Comma-separated allowlist of tool names to enable
392 --exclude-tools, -xt <list> Comma-separated denylist of tool names to disable
393 --no-tools, -nt Disable all tools
394 --no-builtin-tools, -nbt Disable the built-in tools (read, bash, edit, write, grep, find, ls)
395 --verbose Show startup warnings (e.g. ignored flags)
396 --help, -h Show this help
397 --version, -v Show version
398
399{u}Built-in Tools:{r}
400 {builtin} (enabled by default; grep/find/ls are read-only)
401
402{u}Examples:{r}
403 # Interactive with an initial prompt
404 {name} \"List all .rs files in src/\"
405
406 # Single-shot print mode
407 {name} -p \"Summarize this project\"
408
409 # Include a file in the initial message
410 {name} @README.md \"What does this project do?\"
411
412 # Continue the previous session
413 {name} -c \"What did we discuss?\"
414
415 # Use a specific model + thinking level
416 {name} --model claude-sonnet-5 --thinking high \"Refactor this\"
417
418 # JSON event stream (one JSON object per line on stdout)
419 {name} --mode json -p \"Inspect the code\"
420
421 # Read-only: no file-modifying tools
422 {name} --tools read,bash -p \"Review the code in src/\"
423
424{u}Environment:{r}
425 ANTHROPIC_API_KEY Anthropic API key (required for real runs)
426
427{u}Notes:{r}
428 v1 is Anthropic-only (API key). TUI, extensions, skills, prompt templates,
429 themes, model cycling, package manager, HTML export, --fork, --list-models,
430 --export, and OAuth are recognized but not implemented yet.
431",
432 name = crate::APP_NAME,
433 builtin = builtin,
434 u = "\x1b[1m",
435 r = "\x1b[0m",
436 );
437}
438
439pub fn print_version() {
441 println!("{} {}", crate::APP_NAME, crate::VERSION);
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 fn s(args: &[&str]) -> Vec<String> {
449 args.iter().map(|a| a.to_string()).collect()
450 }
451
452 #[test]
453 fn parses_basic_prompt() {
454 let a = parse_args(&s(&["hello", "world"]));
455 assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
456 assert!(!a.help);
457 }
458
459 #[test]
460 fn parses_help_and_version() {
461 let a = parse_args(&s(&["--help"]));
462 assert!(a.help);
463 let a = parse_args(&s(&["-v"]));
464 assert!(a.version);
465 }
466
467 #[test]
468 fn print_consumes_following_positional() {
469 let a = parse_args(&s(&["-p", "summarize"]));
470 assert!(a.print);
471 assert_eq!(a.messages, vec!["summarize".to_string()]);
472 }
473
474 #[test]
475 fn print_does_not_consume_file_or_flag() {
476 let a = parse_args(&s(&["-p", "@file.md"]));
477 assert!(a.print);
478 assert!(a.messages.is_empty());
479 assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
480 }
481
482 #[test]
483 fn model_and_thinking() {
484 let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
485 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
486 assert_eq!(a.thinking, Some(ThinkingLevel::High));
487 }
488
489 #[test]
490 fn model_with_thinking_shorthand() {
491 let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
492 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
494 }
495
496 #[test]
497 fn tools_split_csv() {
498 let a = parse_args(&s(&["--tools", "read, bash ,write"]));
499 assert_eq!(a.tools.as_deref(), Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..]));
500 }
501
502 #[test]
503 fn unknown_short_flag_errors() {
504 let a = parse_args(&s(&["-Z"]));
505 assert!(!a.errors.is_empty());
506 }
507
508 #[test]
509 fn unknown_long_flag_warns_not_errors() {
510 let a = parse_args(&s(&["--frobnicate", "value"]));
511 assert!(a.errors.is_empty());
512 assert!(!a.ignored.is_empty());
513 }
514
515 #[test]
516 fn ignored_scope_cuts_warn() {
517 let a = parse_args(&s(&["--models", "sonnet"]));
518 assert!(a.errors.is_empty());
519 assert!(!a.ignored.is_empty());
520 assert!(a.messages.is_empty());
522 }
523
524 #[test]
525 fn file_args_stripped() {
526 let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
527 assert_eq!(a.file_args, vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]);
528 assert_eq!(a.messages, vec!["hi".to_string()]);
529 }
530
531 #[test]
532 fn equals_form_supported() {
533 let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
534 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
535 assert_eq!(a.thinking, Some(ThinkingLevel::Low));
536 }
537
538 #[test]
539 fn resolve_mode_interactive_when_tty() {
540 let a = Args { print: true, ..Args::default() };
541 assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
542 let a = Args::default();
543 assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
544 let a = Args { mode: Mode::Json, ..Args::default() };
545 assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
546 let a = Args { mode: Mode::Rpc, ..Args::default() };
547 assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
548 }
549
550 #[test]
551 fn piped_stdout_forces_print() {
552 let a = Args::default();
553 assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
555 }
556}