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 base_url: Option<String>,
51 pub system_prompt: Option<String>,
52 pub append_system_prompt: Vec<String>,
53 pub thinking: Option<ThinkingLevel>,
54
55 pub print: bool,
56 pub mode: Mode,
57
58 pub continue_session: bool,
59 pub resume: bool,
60 pub session: Option<String>,
61 pub session_dir: Option<PathBuf>,
62 pub no_session: bool,
63 pub name: Option<String>,
64
65 pub tools: Option<Vec<String>>,
66 pub exclude_tools: Option<Vec<String>>,
67 pub no_tools: bool,
68 pub no_builtin_tools: bool,
69
70 pub no_skills: bool,
73 pub no_prompt_templates: bool,
76 pub no_context_files: bool,
79 pub no_extensions: bool,
83 pub extensions_dir: Vec<PathBuf>,
90
91 pub verbose: bool,
92 pub help: bool,
93 pub version: bool,
94
95 pub debug_system_prompt: bool,
102
103 pub messages: Vec<String>,
105 pub file_args: Vec<PathBuf>,
108
109 pub ignored: Vec<String>,
112 pub errors: Vec<String>,
115}
116
117pub const VALID_THINKING_LEVELS: &[&str] =
120 &["off", "minimal", "low", "medium", "high", "xhigh", "max"];
121
122pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
124 Some(match s {
125 "off" => ThinkingLevel::Off,
126 "minimal" => ThinkingLevel::Minimal,
127 "low" => ThinkingLevel::Low,
128 "medium" => ThinkingLevel::Medium,
129 "high" => ThinkingLevel::High,
130 "xhigh" => ThinkingLevel::Xhigh,
131 "max" => ThinkingLevel::Max,
132 _ => return None,
133 })
134}
135
136fn file_arg(arg: &str) -> Option<PathBuf> {
139 if let Some(rest) = arg.strip_prefix('@') {
140 if rest.is_empty() {
142 None
143 } else {
144 Some(PathBuf::from(rest))
145 }
146 } else {
147 None
148 }
149}
150
151pub fn parse_args(args: &[String]) -> Args {
157 let mut result = Args::default();
158 if let Ok(raw) = std::env::var("RPI_EXTENSIONS_DIR") {
162 if !raw.is_empty() {
163 let sep = if cfg!(windows) { ';' } else { ':' };
164 for part in raw.split(sep) {
165 let trimmed = part.trim();
166 if !trimmed.is_empty() {
167 result.extensions_dir.push(PathBuf::from(trimmed));
168 }
169 }
170 }
171 }
172 let mut i = 0;
173 while i < args.len() {
174 let arg = args[i].clone();
175 let (flag_key, inline) = if arg.starts_with("--") {
179 match arg.find('=') {
180 Some(eq) => (arg[..eq].to_string(), Some(arg[eq + 1..].to_string())),
181 None => (arg.clone(), None),
182 }
183 } else {
184 (arg.clone(), None)
185 };
186
187 let mut take_value = |result: &mut Args, _flag: &str| -> Option<String> {
191 if let Some(v) = inline.clone() {
192 return Some(v);
193 }
194 if i + 1 < args.len() {
195 let next = &args[i + 1];
196 if !next.starts_with('-') || next == "-" {
197 i += 1;
198 return Some(args[i].clone());
199 }
200 }
201 result.errors.push(format!("{flag_key} requires a value"));
202 None
203 };
204
205 match flag_key.as_str() {
206 "--help" | "-h" => result.help = true,
207 "--version" | "-v" => result.version = true,
208 "--print" | "-p" => {
209 result.print = true;
210 if i + 1 < args.len() {
215 let next = &args[i + 1];
216 if !next.starts_with('@') && !next.starts_with('-') {
217 i += 1;
218 result.messages.push(args[i].clone());
219 }
220 }
221 }
222 "--mode" => {
223 if let Some(v) = take_value(&mut result, "--mode") {
224 result.mode = match v.as_str() {
225 "text" => Mode::Text,
226 "json" => Mode::Json,
227 "rpc" => Mode::Rpc,
228 other => {
229 result
230 .errors
231 .push(format!("Invalid --mode \"{other}\". Valid: text, json, rpc"));
232 Mode::Text
233 }
234 };
235 }
236 }
237 "--continue" | "-c" => result.continue_session = true,
238 "--resume" | "-r" => result.resume = true,
239 "--no-session" => result.no_session = true,
240 "--no-tools" | "-nt" => result.no_tools = true,
241 "--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
242 "--no-skills" | "-ns" => result.no_skills = true,
243 "--no-prompt-templates" | "-np" => result.no_prompt_templates = true,
244 "--no-context-files" | "-nc" => result.no_context_files = true,
245 "--no-extensions" | "-ne" => result.no_extensions = true,
246 "--extensions-dir" | "-ed" => {
247 if let Some(v) = take_value(&mut result, &flag_key) {
248 result.extensions_dir.push(PathBuf::from(v));
249 }
250 }
251 "--verbose" => result.verbose = true,
252 "--debug-system-prompt" => result.debug_system_prompt = true,
253 "--provider" => result.provider = take_value(&mut result, "--provider"),
254 "--model" => result.model = take_value(&mut result, "--model"),
255 "--api-key" => result.api_key = take_value(&mut result, "--api-key"),
256 "--base-url" => result.base_url = take_value(&mut result, "--base-url"),
257 "--system-prompt" => result.system_prompt = take_value(&mut result, "--system-prompt"),
258 "--append-system-prompt" => {
259 if let Some(v) = take_value(&mut result, "--append-system-prompt") {
260 result.append_system_prompt.push(v);
261 }
262 }
263 "--name" | "-n" => result.name = take_value(&mut result, "--name"),
264 "--session" => result.session = take_value(&mut result, "--session"),
265 "--session-dir" => {
266 if let Some(v) = take_value(&mut result, "--session-dir") {
267 result.session_dir = Some(PathBuf::from(v));
268 }
269 }
270 "--thinking" => {
271 if let Some(v) = take_value(&mut result, "--thinking") {
272 match parse_thinking_level(&v) {
273 Some(lvl) => result.thinking = Some(lvl),
274 None => result.ignored.push(format!(
275 "Invalid --thinking \"{v}\". Valid: {}",
276 VALID_THINKING_LEVELS.join(", ")
277 )),
278 }
279 }
280 }
281 "--tools" | "-t" => {
282 if let Some(v) = take_value(&mut result, &flag_key) {
283 result.tools = Some(split_csv(&v));
284 }
285 }
286 "--exclude-tools" | "-xt" => {
287 if let Some(v) = take_value(&mut result, &flag_key) {
288 result.exclude_tools = Some(split_csv(&v));
289 }
290 }
291 other
302 if matches!(
303 other,
304 "--models"
305 | "--offline"
306 | "--export"
307 | "--tui-mode"
308 | "--approve" | "-a"
309 | "--no-approve" | "-na"
310 | "--no-themes"
311 ) =>
312 {
313 if inline.is_none()
316 && i + 1 < args.len()
317 && !args[i + 1].starts_with('-')
318 && !args[i + 1].starts_with('@')
319 {
320 i += 1;
321 }
322 result.ignored.push(format!("{other} is not supported in v1 (ignored)"));
323 }
324 flag @ ("--extension" | "-e" | "--skill" | "--prompt-template" | "--theme") => {
325 if inline.is_none()
329 && i + 1 < args.len()
330 && !args[i + 1].starts_with('-')
331 && !args[i + 1].starts_with('@')
332 {
333 i += 1;
334 }
335 result.ignored.push(format!("{flag} is not supported in v1 (ignored)"));
336 }
337 "--list-models" => {
338 if inline.is_none()
340 && i + 1 < args.len()
341 && !args[i + 1].starts_with('-')
342 && !args[i + 1].starts_with('@')
343 {
344 i += 1;
345 }
346 result.ignored.push("--list-models is not supported in v1 (ignored)".to_string());
347 }
348 "--fork" => {
349 result.ignored.push("--fork is not supported in v1 (ignored)".to_string());
350 if inline.is_none() && i + 1 < args.len() && !args[i + 1].starts_with('-') {
351 i += 1;
352 }
353 }
354 other if other.starts_with("--") => {
358 let name = &flag_key;
359 if inline.is_none()
360 && i + 1 < args.len()
361 && !args[i + 1].starts_with('-')
362 && !args[i + 1].starts_with('@')
363 {
364 i += 1;
365 }
366 result.ignored.push(format!("{name} is not a recognized flag (ignored)"));
367 }
368 other if other.starts_with('-') && other.len() > 1 => {
370 result
371 .errors
372 .push(format!("Unknown option: {other}"));
373 }
374 other if let Some(path) = file_arg(other) => {
376 result.file_args.push(path);
377 }
378 other => {
380 result.messages.push(other.to_string());
381 }
382 }
383 i += 1;
384 }
385
386 result
390}
391
392fn split_csv(v: &str) -> Vec<String> {
394 v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
395}
396
397pub fn resolve_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> RunMode {
402 if parsed.mode == Mode::Rpc {
403 return RunMode::Rpc;
404 }
405 if parsed.mode == Mode::Json {
406 return RunMode::Json;
407 }
408 if parsed.print || !stdin_is_tty || !stdout_is_tty {
409 RunMode::Print
410 } else {
411 RunMode::Interactive
412 }
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum RunMode {
421 Interactive,
422 Print,
423 Json,
424 Rpc,
425}
426
427pub fn print_help() {
429 let builtin = "read, bash, edit, write, grep, find, ls";
430 println!(
431 "{name} - AI coding assistant with read, bash, edit, write, grep, find, ls tools
432
433{u}Usage:{r}
434 {name} [options] [@files...] [messages...]
435
436{u}Options:{r}
437 --provider <name> Provider name (v1: anthropic)
438 --model <pattern> Model pattern or ID (supports \"provider/id\" and optional \":<thinking>\")
439 --api-key <key> API key (x-api-key; defaults to ~/.rpi/auth.json, then ANTHROPIC_API_KEY)
440 --base-url <url> Override the Anthropic endpoint (defaults to ANTHROPIC_BASE_URL)
441 --system-prompt <text> Replace the default system prompt
442 --append-system-prompt <text> Append text to the system prompt (repeatable)
443 --thinking <level> off, minimal, low, medium, high, xhigh, max
444 --mode <mode> Output mode: text (default), json, or rpc
445 --print, -p Non-interactive: process prompt(s) and exit
446 --continue, -c Continue the most recent session
447 --resume, -r Browse and select a session to resume
448 --session <id|path> Use a specific session (partial UUID or file)
449 --session-dir <dir> Directory for session storage
450 --no-session Ephemeral mode (do not persist the session)
451 --name, -n <name> Set the session display name
452 --tools, -t <list> Comma-separated allowlist of tool names to enable
453 --exclude-tools, -xt <list> Comma-separated denylist of tool names to disable
454 --no-tools, -nt Disable all tools
455 --no-builtin-tools, -nbt Disable the built-in tools (read, bash, edit, write, grep, find, ls)
456 --no-skills, -ns Skip skill discovery (no <available_skills> block)
457 --no-prompt-templates, -np Skip prompt-template discovery (/expand templates)
458 --no-context-files, -nc Skip AGENTS.md/CLAUDE.md discovery (no <project_context>)
459 --no-extensions, -ne Skip cdylib plugin/extension loading entirely
460 --extensions-dir, -ed <dir> Extra dir to scan for plugins (.dll/.so/.dylib); repeatable
461 (also via RPI_EXTENSIONS_DIR env: ';' on Windows, ':' on Unix)
462 --debug-system-prompt Print the resolved system-prompt sections to stderr (verification)
463 --verbose Show startup warnings (e.g. ignored flags)
464 --help, -h Show this help
465 --version, -v Show version
466
467{u}Subcommands:{r}
468 auth login|check|logout Manage persisted credentials in ~/.rpi/auth.json
469 (see `rpi auth --help`)
470
471{u}Built-in Tools:{r}
472 {builtin} (enabled by default; grep/find/ls are read-only)
473
474{u}Examples:{r}
475 # Interactive with an initial prompt
476 {name} \"List all .rs files in src/\"
477
478 # Single-shot print mode
479 {name} -p \"Summarize this project\"
480
481 # Include a file in the initial message
482 {name} @README.md \"What does this project do?\"
483
484 # Continue the previous session
485 {name} -c \"What did we discuss?\"
486
487 # Use a specific model + thinking level
488 {name} --model claude-sonnet-5 --thinking high \"Refactor this\"
489
490 # JSON event stream (one JSON object per line on stdout)
491 {name} --mode json -p \"Inspect the code\"
492
493 # Read-only: no file-modifying tools
494 {name} --tools read,bash -p \"Review the code in src/\"
495
496{u}Environment:{r}
497 ANTHROPIC_API_KEY Anthropic API key (x-api-key) — fallback when no stored credential
498 ANTHROPIC_AUTH_TOKEN Bearer token (Authorization: Bearer) for third-party gateways
499 ANTHROPIC_BASE_URL Override the Anthropic endpoint (e.g. a compatible proxy)
500 RPI_CODING_AGENT_DIR Override the ~/.rpi config directory (auth.json + models.json)
501
502{u}Notes:{r}
503 v1 speaks the Anthropic Messages protocol only. Auth is resolved in order:
504 --api-key → ~/.rpi/auth.json (via `rpi auth login`) → ANTHROPIC_AUTH_TOKEN
505 (Bearer) → ANTHROPIC_API_KEY (x-api-key). Define custom model catalogs in
506 ~/.rpi/models.json. TUI, extensions, skills, prompt templates, themes, model
507 cycling, package manager, HTML export, --fork, --list-models, --export, and
508 OAuth are recognized but not implemented yet.
509",
510 name = crate::APP_NAME,
511 builtin = builtin,
512 u = "\x1b[1m",
513 r = "\x1b[0m",
514 );
515}
516
517pub fn print_version() {
519 println!("{} {}", crate::APP_NAME, crate::VERSION);
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 fn s(args: &[&str]) -> Vec<String> {
527 args.iter().map(|a| a.to_string()).collect()
528 }
529
530 #[test]
531 fn parses_basic_prompt() {
532 let a = parse_args(&s(&["hello", "world"]));
533 assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
534 assert!(!a.help);
535 }
536
537 #[test]
538 fn parses_help_and_version() {
539 let a = parse_args(&s(&["--help"]));
540 assert!(a.help);
541 let a = parse_args(&s(&["-v"]));
542 assert!(a.version);
543 }
544
545 #[test]
546 fn print_consumes_following_positional() {
547 let a = parse_args(&s(&["-p", "summarize"]));
548 assert!(a.print);
549 assert_eq!(a.messages, vec!["summarize".to_string()]);
550 }
551
552 #[test]
553 fn print_does_not_consume_file_or_flag() {
554 let a = parse_args(&s(&["-p", "@file.md"]));
555 assert!(a.print);
556 assert!(a.messages.is_empty());
557 assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
558 }
559
560 #[test]
561 fn model_and_thinking() {
562 let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
563 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
564 assert_eq!(a.thinking, Some(ThinkingLevel::High));
565 }
566
567 #[test]
568 fn model_with_thinking_shorthand() {
569 let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
570 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
572 }
573
574 #[test]
575 fn tools_split_csv() {
576 let a = parse_args(&s(&["--tools", "read, bash ,write"]));
577 assert_eq!(a.tools.as_deref(), Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..]));
578 }
579
580 #[test]
581 fn unknown_short_flag_errors() {
582 let a = parse_args(&s(&["-Z"]));
583 assert!(!a.errors.is_empty());
584 }
585
586 #[test]
587 fn unknown_long_flag_warns_not_errors() {
588 let a = parse_args(&s(&["--frobnicate", "value"]));
589 assert!(a.errors.is_empty());
590 assert!(!a.ignored.is_empty());
591 }
592
593 #[test]
594 fn ignored_scope_cuts_warn() {
595 let a = parse_args(&s(&["--models", "sonnet"]));
596 assert!(a.errors.is_empty());
597 assert!(!a.ignored.is_empty());
598 assert!(a.messages.is_empty());
600 }
601
602 #[test]
603 fn no_skills_flag_honored() {
604 let a = parse_args(&s(&["-ns"]));
605 assert!(a.errors.is_empty());
606 assert!(a.no_skills);
607 assert!(a.ignored.is_empty());
609 }
610
611 #[test]
612 fn no_prompt_templates_flag_honored() {
613 let a = parse_args(&s(&["--no-prompt-templates"]));
614 assert!(a.no_prompt_templates);
615 assert!(a.ignored.is_empty());
616 }
617
618 #[test]
619 fn no_context_files_flag_honored() {
620 let a = parse_args(&s(&["-nc"]));
621 assert!(a.no_context_files);
622 assert!(a.ignored.is_empty());
623 }
624
625 #[test]
626 fn no_extensions_flag_honored() {
627 let a = parse_args(&s(&["--no-extensions"]));
630 assert!(a.no_extensions);
631 assert!(a.ignored.is_empty());
632 }
633
634 #[test]
635 fn extensions_dir_flag_collects_dirs() {
636 let a = parse_args(&s(&["--extensions-dir", "/a/b", "-ed", "/c/d"]));
637 assert_eq!(a.extensions_dir, vec![PathBuf::from("/a/b"), PathBuf::from("/c/d")]);
638 assert!(a.ignored.is_empty());
639 }
640
641 #[test]
642 fn extensions_dir_inline_equals_form() {
643 let a = parse_args(&s(&["--extensions-dir=/x/y"]));
644 assert_eq!(a.extensions_dir, vec![PathBuf::from("/x/y")]);
645 }
646
647 #[test]
648 fn extensions_dir_env_is_merged() {
649 let a = parse_args(&s(&["--extensions-dir", "/flag/only"]));
655 assert!(a.extensions_dir.iter().any(|p| p == &PathBuf::from("/flag/only")));
656 }
657
658 #[test]
659 fn file_args_stripped() {
660 let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
661 assert_eq!(a.file_args, vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]);
662 assert_eq!(a.messages, vec!["hi".to_string()]);
663 }
664
665 #[test]
666 fn equals_form_supported() {
667 let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
668 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
669 assert_eq!(a.thinking, Some(ThinkingLevel::Low));
670 }
671
672 #[test]
673 fn resolve_mode_interactive_when_tty() {
674 let a = Args { print: true, ..Args::default() };
675 assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
676 let a = Args::default();
677 assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
678 let a = Args { mode: Mode::Json, ..Args::default() };
679 assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
680 let a = Args { mode: Mode::Rpc, ..Args::default() };
681 assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
682 }
683
684 #[test]
685 fn piped_stdout_forces_print() {
686 let a = Args::default();
687 assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
689 }
690}