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 install <crate> Build and install a Rust cdylib extension
516 (see `rpi install --help`)
517
518{u}Built-in Tools:{r}
519 {builtin} (enabled by default; grep/find/ls are read-only)
520
521{u}Examples:{r}
522 # Interactive with an initial prompt
523 {name} \"List all .rs files in src/\"
524
525 # Single-shot print mode
526 {name} -p \"Summarize this project\"
527
528 # Include a file in the initial message
529 {name} @README.md \"What does this project do?\"
530
531 # Continue the previous session
532 {name} -c \"What did we discuss?\"
533
534 # Use a specific model + thinking level
535 {name} --model claude-sonnet-5 --thinking high \"Refactor this\"
536
537 # JSON event stream (one JSON object per line on stdout)
538 {name} --mode json -p \"Inspect the code\"
539
540 # Read-only: no file-modifying tools
541 {name} --tools read,bash -p \"Review the code in src/\"
542
543{u}Environment:{r}
544 ANTHROPIC_API_KEY Anthropic API key (x-api-key) — fallback when no stored credential
545 ANTHROPIC_AUTH_TOKEN Bearer token (Authorization: Bearer) for third-party gateways
546 ANTHROPIC_BASE_URL Override the Anthropic endpoint (e.g. a compatible proxy)
547 OPENAI_API_KEY Bearer token for openai-completions
548 RPI_CODING_AGENT_DIR Override the ~/.rpi config directory (auth.json + models.json)
549
550{u}Notes:{r}
551 Supported HTTP protocols are Anthropic Messages and OpenAI Chat Completions.
552 Define custom model catalogs and provider apiKey values in
553 ~/.rpi/agent/models.json. The interactive TUI, extensions, skills, prompt
554 templates, themes, model cycling, session fork/export, and trust commands are
555 available in the current build. OAuth and HTML export remain
556 outside the current implementation.
557",
558 name = crate::APP_NAME,
559 builtin = builtin,
560 u = "\x1b[1m",
561 r = "\x1b[0m",
562 );
563}
564
565pub fn print_version() {
567 println!("{} {}", crate::APP_NAME, crate::VERSION);
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 fn s(args: &[&str]) -> Vec<String> {
575 args.iter().map(|a| a.to_string()).collect()
576 }
577
578 #[test]
579 fn parses_basic_prompt() {
580 let a = parse_args(&s(&["hello", "world"]));
581 assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
582 assert!(!a.help);
583 }
584
585 #[test]
586 fn parses_help_and_version() {
587 let a = parse_args(&s(&["--help"]));
588 assert!(a.help);
589 let a = parse_args(&s(&["-v"]));
590 assert!(a.version);
591 }
592
593 #[test]
594 fn print_consumes_following_positional() {
595 let a = parse_args(&s(&["-p", "summarize"]));
596 assert!(a.print);
597 assert_eq!(a.messages, vec!["summarize".to_string()]);
598 }
599
600 #[test]
601 fn print_does_not_consume_file_or_flag() {
602 let a = parse_args(&s(&["-p", "@file.md"]));
603 assert!(a.print);
604 assert!(a.messages.is_empty());
605 assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
606 }
607
608 #[test]
609 fn model_and_thinking() {
610 let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
611 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
612 assert_eq!(a.thinking, Some(ThinkingLevel::High));
613 }
614
615 #[test]
616 fn model_with_thinking_shorthand() {
617 let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
618 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
620 }
621
622 #[test]
623 fn tools_split_csv() {
624 let a = parse_args(&s(&["--tools", "read, bash ,write"]));
625 assert_eq!(
626 a.tools.as_deref(),
627 Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..])
628 );
629 }
630
631 #[test]
632 fn unknown_short_flag_errors() {
633 let a = parse_args(&s(&["-Z"]));
634 assert!(!a.errors.is_empty());
635 }
636
637 #[test]
638 fn unknown_long_flag_warns_not_errors() {
639 let a = parse_args(&s(&["--frobnicate", "value"]));
640 assert!(a.errors.is_empty());
641 assert!(!a.ignored.is_empty());
642 }
643
644 #[test]
645 fn models_flag_parses_csv() {
646 let a = parse_args(&s(&["--models", "a,b,c"]));
647 assert!(a.errors.is_empty());
648 assert!(a.ignored.is_empty(), "--models is implemented");
649 assert_eq!(
650 a.models.as_deref(),
651 Some(&["a".to_string(), "b".to_string(), "c".to_string()][..])
652 );
653 assert!(a.messages.is_empty());
655 }
656
657 #[test]
658 fn session_id_and_fork_flags_parse() {
659 let a = parse_args(&s(&["--session-id", "01abc", "--fork", "xyz"]));
660 assert!(a.errors.is_empty());
661 assert_eq!(a.session_id.as_deref(), Some("01abc"));
662 assert_eq!(a.fork.as_deref(), Some("xyz"));
663 let a = parse_args(&s(&[
664 "-e",
665 "plugin.dll",
666 "--skill",
667 "s",
668 "--prompt-template",
669 "t.md",
670 ]));
671 assert_eq!(a.extension.len(), 1);
672 assert_eq!(a.skill.len(), 1);
673 assert_eq!(a.prompt_template.len(), 1);
674 }
675
676 #[test]
677 fn no_skills_flag_honored() {
678 let a = parse_args(&s(&["-ns"]));
679 assert!(a.errors.is_empty());
680 assert!(a.no_skills);
681 assert!(a.ignored.is_empty());
683 }
684
685 #[test]
686 fn no_prompt_templates_flag_honored() {
687 let a = parse_args(&s(&["--no-prompt-templates"]));
688 assert!(a.no_prompt_templates);
689 assert!(a.ignored.is_empty());
690 }
691
692 #[test]
693 fn no_context_files_flag_honored() {
694 let a = parse_args(&s(&["-nc"]));
695 assert!(a.no_context_files);
696 assert!(a.ignored.is_empty());
697 }
698
699 #[test]
700 fn no_extensions_flag_honored() {
701 let a = parse_args(&s(&["--no-extensions"]));
704 assert!(a.no_extensions);
705 assert!(a.ignored.is_empty());
706 }
707
708 #[test]
709 fn extensions_dir_flag_collects_dirs() {
710 let a = parse_args(&s(&["--extensions-dir", "/a/b", "-ed", "/c/d"]));
711 assert_eq!(
712 a.extensions_dir,
713 vec![PathBuf::from("/a/b"), PathBuf::from("/c/d")]
714 );
715 assert!(a.ignored.is_empty());
716 }
717
718 #[test]
719 fn extensions_dir_inline_equals_form() {
720 let a = parse_args(&s(&["--extensions-dir=/x/y"]));
721 assert_eq!(a.extensions_dir, vec![PathBuf::from("/x/y")]);
722 }
723
724 #[test]
725 fn extensions_dir_env_is_merged() {
726 let a = parse_args(&s(&["--extensions-dir", "/flag/only"]));
732 assert!(a
733 .extensions_dir
734 .iter()
735 .any(|p| p == &PathBuf::from("/flag/only")));
736 }
737
738 #[test]
739 fn file_args_stripped() {
740 let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
741 assert_eq!(
742 a.file_args,
743 vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]
744 );
745 assert_eq!(a.messages, vec!["hi".to_string()]);
746 }
747
748 #[test]
749 fn equals_form_supported() {
750 let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
751 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
752 assert_eq!(a.thinking, Some(ThinkingLevel::Low));
753 }
754
755 #[test]
756 fn resolve_mode_interactive_when_tty() {
757 let a = Args {
758 print: true,
759 ..Args::default()
760 };
761 assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
762 let a = Args::default();
763 assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
764 let a = Args {
765 mode: Mode::Json,
766 ..Args::default()
767 };
768 assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
769 let a = Args {
770 mode: Mode::Rpc,
771 ..Args::default()
772 };
773 assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
774 }
775
776 #[test]
777 fn piped_stdout_forces_print() {
778 let a = Args::default();
779 assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
781 }
782}