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_id: Option<String>,
65 pub fork: Option<String>,
68 pub models: Option<Vec<String>>,
72 pub session_dir: Option<PathBuf>,
73 pub no_session: bool,
74 pub name: Option<String>,
75
76 pub tools: Option<Vec<String>>,
77 pub exclude_tools: Option<Vec<String>>,
78 pub no_tools: bool,
79 pub no_builtin_tools: bool,
80
81 pub no_skills: bool,
84 pub no_prompt_templates: bool,
87 pub no_context_files: bool,
90 pub no_extensions: bool,
94 pub extensions_dir: Vec<PathBuf>,
101 pub extension: Vec<PathBuf>,
104 pub skill: Vec<PathBuf>,
106 pub prompt_template: Vec<PathBuf>,
109
110 pub verbose: bool,
111 pub help: bool,
112 pub version: bool,
113
114 pub debug_system_prompt: bool,
121
122 pub messages: Vec<String>,
124 pub file_args: Vec<PathBuf>,
127
128 pub ignored: Vec<String>,
131 pub errors: Vec<String>,
134}
135
136pub const VALID_THINKING_LEVELS: &[&str] =
139 &["off", "minimal", "low", "medium", "high", "xhigh", "max"];
140
141pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
143 Some(match s {
144 "off" => ThinkingLevel::Off,
145 "minimal" => ThinkingLevel::Minimal,
146 "low" => ThinkingLevel::Low,
147 "medium" => ThinkingLevel::Medium,
148 "high" => ThinkingLevel::High,
149 "xhigh" => ThinkingLevel::Xhigh,
150 "max" => ThinkingLevel::Max,
151 _ => return None,
152 })
153}
154
155fn file_arg(arg: &str) -> Option<PathBuf> {
158 if let Some(rest) = arg.strip_prefix('@') {
159 if rest.is_empty() {
161 None
162 } else {
163 Some(PathBuf::from(rest))
164 }
165 } else {
166 None
167 }
168}
169
170pub fn parse_args(args: &[String]) -> Args {
176 let mut result = Args::default();
177 if let Ok(raw) = std::env::var("RPI_EXTENSIONS_DIR") {
181 if !raw.is_empty() {
182 let sep = if cfg!(windows) { ';' } else { ':' };
183 for part in raw.split(sep) {
184 let trimmed = part.trim();
185 if !trimmed.is_empty() {
186 result.extensions_dir.push(PathBuf::from(trimmed));
187 }
188 }
189 }
190 }
191 let mut i = 0;
192 while i < args.len() {
193 let arg = args[i].clone();
194 let (flag_key, inline) = if arg.starts_with("--") {
198 match arg.find('=') {
199 Some(eq) => (arg[..eq].to_string(), Some(arg[eq + 1..].to_string())),
200 None => (arg.clone(), None),
201 }
202 } else {
203 (arg.clone(), None)
204 };
205
206 let mut take_value = |result: &mut Args, _flag: &str| -> Option<String> {
210 if let Some(v) = inline.clone() {
211 return Some(v);
212 }
213 if i + 1 < args.len() {
214 let next = &args[i + 1];
215 if !next.starts_with('-') || next == "-" {
216 i += 1;
217 return Some(args[i].clone());
218 }
219 }
220 result.errors.push(format!("{flag_key} requires a value"));
221 None
222 };
223
224 match flag_key.as_str() {
225 "--help" | "-h" => result.help = true,
226 "--version" | "-v" => result.version = true,
227 "--print" | "-p" => {
228 result.print = true;
229 if i + 1 < args.len() {
234 let next = &args[i + 1];
235 if !next.starts_with('@') && !next.starts_with('-') {
236 i += 1;
237 result.messages.push(args[i].clone());
238 }
239 }
240 }
241 "--mode" => {
242 if let Some(v) = take_value(&mut result, "--mode") {
243 result.mode = match v.as_str() {
244 "text" => Mode::Text,
245 "json" => Mode::Json,
246 "rpc" => Mode::Rpc,
247 other => {
248 result.errors.push(format!(
249 "Invalid --mode \"{other}\". Valid: text, json, rpc"
250 ));
251 Mode::Text
252 }
253 };
254 }
255 }
256 "--continue" | "-c" => result.continue_session = true,
257 "--resume" | "-r" => result.resume = true,
258 "--no-session" => result.no_session = true,
259 "--no-tools" | "-nt" => result.no_tools = true,
260 "--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
261 "--no-skills" | "-ns" => result.no_skills = true,
262 "--no-prompt-templates" | "-np" => result.no_prompt_templates = true,
263 "--no-context-files" | "-nc" => result.no_context_files = true,
264 "--no-extensions" | "-ne" => result.no_extensions = true,
265 "--extensions-dir" | "-ed" => {
266 if let Some(v) = take_value(&mut result, &flag_key) {
267 result.extensions_dir.push(PathBuf::from(v));
268 }
269 }
270 "--verbose" => result.verbose = true,
271 "--debug-system-prompt" => result.debug_system_prompt = true,
272 "--provider" => result.provider = take_value(&mut result, "--provider"),
273 "--model" => result.model = take_value(&mut result, "--model"),
274 "--api-key" => result.api_key = take_value(&mut result, "--api-key"),
275 "--base-url" => result.base_url = take_value(&mut result, "--base-url"),
276 "--system-prompt" => result.system_prompt = take_value(&mut result, "--system-prompt"),
277 "--append-system-prompt" => {
278 if let Some(v) = take_value(&mut result, "--append-system-prompt") {
279 result.append_system_prompt.push(v);
280 }
281 }
282 "--name" | "-n" => result.name = take_value(&mut result, "--name"),
283 "--session" => result.session = take_value(&mut result, "--session"),
284 "--session-id" => result.session_id = take_value(&mut result, "--session-id"),
285 "--fork" => result.fork = take_value(&mut result, "--fork"),
286 "--models" => {
287 if let Some(v) = take_value(&mut result, &flag_key) {
288 result.models = Some(split_csv(&v));
289 }
290 }
291 "--extension" | "-e" => {
292 if let Some(v) = take_value(&mut result, &flag_key) {
293 result.extension.push(PathBuf::from(v));
294 }
295 }
296 "--skill" => {
297 if let Some(v) = take_value(&mut result, &flag_key) {
298 result.skill.push(PathBuf::from(v));
299 }
300 }
301 "--prompt-template" => {
302 if let Some(v) = take_value(&mut result, &flag_key) {
303 result.prompt_template.push(PathBuf::from(v));
304 }
305 }
306 "--session-dir" => {
307 if let Some(v) = take_value(&mut result, "--session-dir") {
308 result.session_dir = Some(PathBuf::from(v));
309 }
310 }
311 "--thinking" => {
312 if let Some(v) = take_value(&mut result, "--thinking") {
313 match parse_thinking_level(&v) {
314 Some(lvl) => result.thinking = Some(lvl),
315 None => result.ignored.push(format!(
316 "Invalid --thinking \"{v}\". Valid: {}",
317 VALID_THINKING_LEVELS.join(", ")
318 )),
319 }
320 }
321 }
322 "--tools" | "-t" => {
323 if let Some(v) = take_value(&mut result, &flag_key) {
324 result.tools = Some(split_csv(&v));
325 }
326 }
327 "--exclude-tools" | "-xt" => {
328 if let Some(v) = take_value(&mut result, &flag_key) {
329 result.exclude_tools = Some(split_csv(&v));
330 }
331 }
332 other
343 if matches!(
344 other,
345 "--models"
346 | "--offline"
347 | "--export"
348 | "--tui-mode"
349 | "--approve"
350 | "-a"
351 | "--no-approve"
352 | "-na"
353 | "--no-themes"
354 ) =>
355 {
356 if inline.is_none()
359 && i + 1 < args.len()
360 && !args[i + 1].starts_with('-')
361 && !args[i + 1].starts_with('@')
362 {
363 i += 1;
364 }
365 result
366 .ignored
367 .push(format!("{other} is not supported in v1 (ignored)"));
368 }
369 "--theme" => {
370 if inline.is_none()
374 && i + 1 < args.len()
375 && !args[i + 1].starts_with('-')
376 && !args[i + 1].starts_with('@')
377 {
378 i += 1;
379 }
380 result
381 .ignored
382 .push("--theme is not supported in v1 (ignored)".to_string());
383 }
384 "--list-models" => {
385 if inline.is_none()
387 && i + 1 < args.len()
388 && !args[i + 1].starts_with('-')
389 && !args[i + 1].starts_with('@')
390 {
391 i += 1;
392 }
393 result
394 .ignored
395 .push("--list-models is not supported in v1 (ignored)".to_string());
396 }
397 other if other.starts_with("--") => {
401 let name = &flag_key;
402 if inline.is_none()
403 && i + 1 < args.len()
404 && !args[i + 1].starts_with('-')
405 && !args[i + 1].starts_with('@')
406 {
407 i += 1;
408 }
409 result
410 .ignored
411 .push(format!("{name} is not a recognized flag (ignored)"));
412 }
413 other if other.starts_with('-') && other.len() > 1 => {
415 result.errors.push(format!("Unknown option: {other}"));
416 }
417 other => {
418 if let Some(path) = file_arg(other) {
419 result.file_args.push(path);
420 } else {
421 result.messages.push(other.to_string());
422 }
423 }
424 }
425 i += 1;
426 }
427
428 result
432}
433
434fn split_csv(v: &str) -> Vec<String> {
436 v.split(',')
437 .map(|s| s.trim().to_string())
438 .filter(|s| !s.is_empty())
439 .collect()
440}
441
442pub fn resolve_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> RunMode {
447 if parsed.mode == Mode::Rpc {
448 return RunMode::Rpc;
449 }
450 if parsed.mode == Mode::Json {
451 return RunMode::Json;
452 }
453 if parsed.print || !stdin_is_tty || !stdout_is_tty {
454 RunMode::Print
455 } else {
456 RunMode::Interactive
457 }
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum RunMode {
466 Interactive,
467 Print,
468 Json,
469 Rpc,
470}
471
472pub fn print_help() {
474 let builtin = "read, bash, edit, write, grep, find, ls";
475 println!(
476 "{name} - AI coding assistant with read, bash, edit, write, grep, find, ls tools
477
478{u}Usage:{r}
479 {name} [options] [@files...] [messages...]
480
481{u}Options:{r}
482 --provider <name> Provider name (anthropic, openai-completions, or models.json id)
483 --model <pattern> Model pattern or ID (supports \"provider/id\" and optional \":<thinking>\")
484 --api-key <key> API key override for the selected provider
485 --base-url <url> Override the selected model endpoint
486 --system-prompt <text> Replace the default system prompt
487 --append-system-prompt <text> Append text to the system prompt (repeatable)
488 --thinking <level> off, minimal, low, medium, high, xhigh, max
489 --mode <mode> Output mode: text (default), json, or rpc
490 --print, -p Non-interactive: process prompt(s) and exit
491 --continue, -c Continue the most recent session
492 --resume, -r Browse and select a session to resume
493 --session <id|path> Use a specific session (partial UUID or file)
494 --session-dir <dir> Directory for session storage
495 --no-session Ephemeral mode (do not persist the session)
496 --name, -n <name> Set the session display name
497 --tools, -t <list> Comma-separated allowlist of tool names to enable
498 --exclude-tools, -xt <list> Comma-separated denylist of tool names to disable
499 --no-tools, -nt Disable all tools
500 --no-builtin-tools, -nbt Disable the built-in tools (read, bash, edit, write, grep, find, ls)
501 --no-skills, -ns Skip skill discovery (no <available_skills> block)
502 --no-prompt-templates, -np Skip prompt-template discovery (/expand templates)
503 --no-context-files, -nc Skip AGENTS.md/CLAUDE.md discovery (no <project_context>)
504 --no-extensions, -ne Skip cdylib plugin/extension loading entirely
505 --extensions-dir, -ed <dir> Extra dir to scan for plugins (.dll/.so/.dylib); repeatable
506 (also via RPI_EXTENSIONS_DIR env: ';' on Windows, ':' on Unix)
507 --debug-system-prompt Print the resolved system-prompt sections to stderr (verification)
508 --verbose Show startup warnings (e.g. ignored flags)
509 --help, -h Show this help
510 --version, -v Show version
511
512{u}Subcommands:{r}
513 auth login|check|logout Manage persisted credentials in ~/.rpi/auth.json
514 (see `rpi auth --help`)
515
516{u}Built-in Tools:{r}
517 {builtin} (enabled by default; grep/find/ls are read-only)
518
519{u}Examples:{r}
520 # Interactive with an initial prompt
521 {name} \"List all .rs files in src/\"
522
523 # Single-shot print mode
524 {name} -p \"Summarize this project\"
525
526 # Include a file in the initial message
527 {name} @README.md \"What does this project do?\"
528
529 # Continue the previous session
530 {name} -c \"What did we discuss?\"
531
532 # Use a specific model + thinking level
533 {name} --model claude-sonnet-5 --thinking high \"Refactor this\"
534
535 # JSON event stream (one JSON object per line on stdout)
536 {name} --mode json -p \"Inspect the code\"
537
538 # Read-only: no file-modifying tools
539 {name} --tools read,bash -p \"Review the code in src/\"
540
541{u}Environment:{r}
542 ANTHROPIC_API_KEY Anthropic API key (x-api-key) — fallback when no stored credential
543 ANTHROPIC_AUTH_TOKEN Bearer token (Authorization: Bearer) for third-party gateways
544 ANTHROPIC_BASE_URL Override the Anthropic endpoint (e.g. a compatible proxy)
545 OPENAI_API_KEY Bearer token for openai-completions
546 RPI_CODING_AGENT_DIR Override the ~/.rpi config directory (auth.json + models.json)
547
548{u}Notes:{r}
549 Supported HTTP protocols are Anthropic Messages and OpenAI Chat Completions.
550 Define custom model catalogs and provider apiKey values in
551 ~/.rpi/agent/models.json. The interactive TUI, extensions, skills, prompt
552 templates, themes, model cycling, session fork/export, and trust commands are
553 available in the current build. OAuth, package manager, and HTML export remain
554 outside the current implementation.
555",
556 name = crate::APP_NAME,
557 builtin = builtin,
558 u = "\x1b[1m",
559 r = "\x1b[0m",
560 );
561}
562
563pub fn print_version() {
565 println!("{} {}", crate::APP_NAME, crate::VERSION);
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 fn s(args: &[&str]) -> Vec<String> {
573 args.iter().map(|a| a.to_string()).collect()
574 }
575
576 #[test]
577 fn parses_basic_prompt() {
578 let a = parse_args(&s(&["hello", "world"]));
579 assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
580 assert!(!a.help);
581 }
582
583 #[test]
584 fn parses_help_and_version() {
585 let a = parse_args(&s(&["--help"]));
586 assert!(a.help);
587 let a = parse_args(&s(&["-v"]));
588 assert!(a.version);
589 }
590
591 #[test]
592 fn print_consumes_following_positional() {
593 let a = parse_args(&s(&["-p", "summarize"]));
594 assert!(a.print);
595 assert_eq!(a.messages, vec!["summarize".to_string()]);
596 }
597
598 #[test]
599 fn print_does_not_consume_file_or_flag() {
600 let a = parse_args(&s(&["-p", "@file.md"]));
601 assert!(a.print);
602 assert!(a.messages.is_empty());
603 assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
604 }
605
606 #[test]
607 fn model_and_thinking() {
608 let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
609 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
610 assert_eq!(a.thinking, Some(ThinkingLevel::High));
611 }
612
613 #[test]
614 fn model_with_thinking_shorthand() {
615 let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
616 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
618 }
619
620 #[test]
621 fn tools_split_csv() {
622 let a = parse_args(&s(&["--tools", "read, bash ,write"]));
623 assert_eq!(
624 a.tools.as_deref(),
625 Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..])
626 );
627 }
628
629 #[test]
630 fn unknown_short_flag_errors() {
631 let a = parse_args(&s(&["-Z"]));
632 assert!(!a.errors.is_empty());
633 }
634
635 #[test]
636 fn unknown_long_flag_warns_not_errors() {
637 let a = parse_args(&s(&["--frobnicate", "value"]));
638 assert!(a.errors.is_empty());
639 assert!(!a.ignored.is_empty());
640 }
641
642 #[test]
643 fn models_flag_parses_csv() {
644 let a = parse_args(&s(&["--models", "a,b,c"]));
645 assert!(a.errors.is_empty());
646 assert!(a.ignored.is_empty(), "--models is implemented");
647 assert_eq!(
648 a.models.as_deref(),
649 Some(&["a".to_string(), "b".to_string(), "c".to_string()][..])
650 );
651 assert!(a.messages.is_empty());
653 }
654
655 #[test]
656 fn session_id_and_fork_flags_parse() {
657 let a = parse_args(&s(&["--session-id", "01abc", "--fork", "xyz"]));
658 assert!(a.errors.is_empty());
659 assert_eq!(a.session_id.as_deref(), Some("01abc"));
660 assert_eq!(a.fork.as_deref(), Some("xyz"));
661 let a = parse_args(&s(&[
662 "-e",
663 "plugin.dll",
664 "--skill",
665 "s",
666 "--prompt-template",
667 "t.md",
668 ]));
669 assert_eq!(a.extension.len(), 1);
670 assert_eq!(a.skill.len(), 1);
671 assert_eq!(a.prompt_template.len(), 1);
672 }
673
674 #[test]
675 fn no_skills_flag_honored() {
676 let a = parse_args(&s(&["-ns"]));
677 assert!(a.errors.is_empty());
678 assert!(a.no_skills);
679 assert!(a.ignored.is_empty());
681 }
682
683 #[test]
684 fn no_prompt_templates_flag_honored() {
685 let a = parse_args(&s(&["--no-prompt-templates"]));
686 assert!(a.no_prompt_templates);
687 assert!(a.ignored.is_empty());
688 }
689
690 #[test]
691 fn no_context_files_flag_honored() {
692 let a = parse_args(&s(&["-nc"]));
693 assert!(a.no_context_files);
694 assert!(a.ignored.is_empty());
695 }
696
697 #[test]
698 fn no_extensions_flag_honored() {
699 let a = parse_args(&s(&["--no-extensions"]));
702 assert!(a.no_extensions);
703 assert!(a.ignored.is_empty());
704 }
705
706 #[test]
707 fn extensions_dir_flag_collects_dirs() {
708 let a = parse_args(&s(&["--extensions-dir", "/a/b", "-ed", "/c/d"]));
709 assert_eq!(
710 a.extensions_dir,
711 vec![PathBuf::from("/a/b"), PathBuf::from("/c/d")]
712 );
713 assert!(a.ignored.is_empty());
714 }
715
716 #[test]
717 fn extensions_dir_inline_equals_form() {
718 let a = parse_args(&s(&["--extensions-dir=/x/y"]));
719 assert_eq!(a.extensions_dir, vec![PathBuf::from("/x/y")]);
720 }
721
722 #[test]
723 fn extensions_dir_env_is_merged() {
724 let a = parse_args(&s(&["--extensions-dir", "/flag/only"]));
730 assert!(a
731 .extensions_dir
732 .iter()
733 .any(|p| p == &PathBuf::from("/flag/only")));
734 }
735
736 #[test]
737 fn file_args_stripped() {
738 let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
739 assert_eq!(
740 a.file_args,
741 vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]
742 );
743 assert_eq!(a.messages, vec!["hi".to_string()]);
744 }
745
746 #[test]
747 fn equals_form_supported() {
748 let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
749 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
750 assert_eq!(a.thinking, Some(ThinkingLevel::Low));
751 }
752
753 #[test]
754 fn resolve_mode_interactive_when_tty() {
755 let a = Args {
756 print: true,
757 ..Args::default()
758 };
759 assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
760 let a = Args::default();
761 assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
762 let a = Args {
763 mode: Mode::Json,
764 ..Args::default()
765 };
766 assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
767 let a = Args {
768 mode: Mode::Rpc,
769 ..Args::default()
770 };
771 assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
772 }
773
774 #[test]
775 fn piped_stdout_forces_print() {
776 let a = Args::default();
777 assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
779 }
780}