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 verbose: bool,
71 pub help: bool,
72 pub version: bool,
73
74 pub messages: Vec<String>,
76 pub file_args: Vec<PathBuf>,
79
80 pub ignored: Vec<String>,
83 pub errors: Vec<String>,
86}
87
88pub const VALID_THINKING_LEVELS: &[&str] =
91 &["off", "minimal", "low", "medium", "high", "xhigh", "max"];
92
93pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
95 Some(match s {
96 "off" => ThinkingLevel::Off,
97 "minimal" => ThinkingLevel::Minimal,
98 "low" => ThinkingLevel::Low,
99 "medium" => ThinkingLevel::Medium,
100 "high" => ThinkingLevel::High,
101 "xhigh" => ThinkingLevel::Xhigh,
102 "max" => ThinkingLevel::Max,
103 _ => return None,
104 })
105}
106
107fn file_arg(arg: &str) -> Option<PathBuf> {
110 if let Some(rest) = arg.strip_prefix('@') {
111 if rest.is_empty() {
113 None
114 } else {
115 Some(PathBuf::from(rest))
116 }
117 } else {
118 None
119 }
120}
121
122pub fn parse_args(args: &[String]) -> Args {
128 let mut result = Args::default();
129 let mut i = 0;
130 while i < args.len() {
131 let arg = args[i].clone();
132 let (flag_key, inline) = if arg.starts_with("--") {
136 match arg.find('=') {
137 Some(eq) => (arg[..eq].to_string(), Some(arg[eq + 1..].to_string())),
138 None => (arg.clone(), None),
139 }
140 } else {
141 (arg.clone(), None)
142 };
143
144 let mut take_value = |result: &mut Args, _flag: &str| -> Option<String> {
148 if let Some(v) = inline.clone() {
149 return Some(v);
150 }
151 if i + 1 < args.len() {
152 let next = &args[i + 1];
153 if !next.starts_with('-') || next == "-" {
154 i += 1;
155 return Some(args[i].clone());
156 }
157 }
158 result.errors.push(format!("{flag_key} requires a value"));
159 None
160 };
161
162 match flag_key.as_str() {
163 "--help" | "-h" => result.help = true,
164 "--version" | "-v" => result.version = true,
165 "--print" | "-p" => {
166 result.print = true;
167 if i + 1 < args.len() {
172 let next = &args[i + 1];
173 if !next.starts_with('@') && !next.starts_with('-') {
174 i += 1;
175 result.messages.push(args[i].clone());
176 }
177 }
178 }
179 "--mode" => {
180 if let Some(v) = take_value(&mut result, "--mode") {
181 result.mode = match v.as_str() {
182 "text" => Mode::Text,
183 "json" => Mode::Json,
184 "rpc" => Mode::Rpc,
185 other => {
186 result
187 .errors
188 .push(format!("Invalid --mode \"{other}\". Valid: text, json, rpc"));
189 Mode::Text
190 }
191 };
192 }
193 }
194 "--continue" | "-c" => result.continue_session = true,
195 "--resume" | "-r" => result.resume = true,
196 "--no-session" => result.no_session = true,
197 "--no-tools" | "-nt" => result.no_tools = true,
198 "--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
199 "--verbose" => result.verbose = true,
200 "--provider" => result.provider = take_value(&mut result, "--provider"),
201 "--model" => result.model = take_value(&mut result, "--model"),
202 "--api-key" => result.api_key = take_value(&mut result, "--api-key"),
203 "--base-url" => result.base_url = take_value(&mut result, "--base-url"),
204 "--system-prompt" => result.system_prompt = take_value(&mut result, "--system-prompt"),
205 "--append-system-prompt" => {
206 if let Some(v) = take_value(&mut result, "--append-system-prompt") {
207 result.append_system_prompt.push(v);
208 }
209 }
210 "--name" | "-n" => result.name = take_value(&mut result, "--name"),
211 "--session" => result.session = take_value(&mut result, "--session"),
212 "--session-dir" => {
213 if let Some(v) = take_value(&mut result, "--session-dir") {
214 result.session_dir = Some(PathBuf::from(v));
215 }
216 }
217 "--thinking" => {
218 if let Some(v) = take_value(&mut result, "--thinking") {
219 match parse_thinking_level(&v) {
220 Some(lvl) => result.thinking = Some(lvl),
221 None => result.ignored.push(format!(
222 "Invalid --thinking \"{v}\". Valid: {}",
223 VALID_THINKING_LEVELS.join(", ")
224 )),
225 }
226 }
227 }
228 "--tools" | "-t" => {
229 if let Some(v) = take_value(&mut result, &flag_key) {
230 result.tools = Some(split_csv(&v));
231 }
232 }
233 "--exclude-tools" | "-xt" => {
234 if let Some(v) = take_value(&mut result, &flag_key) {
235 result.exclude_tools = Some(split_csv(&v));
236 }
237 }
238 other
242 if matches!(
243 other,
244 "--models"
245 | "--offline"
246 | "--export"
247 | "--tui-mode"
248 | "--approve" | "-a"
249 | "--no-approve" | "-na"
250 | "--no-extensions" | "-ne"
251 | "--no-skills" | "-ns"
252 | "--no-prompt-templates" | "-np"
253 | "--no-themes"
254 | "--no-context-files" | "-nc"
255 ) =>
256 {
257 if inline.is_none()
260 && i + 1 < args.len()
261 && !args[i + 1].starts_with('-')
262 && !args[i + 1].starts_with('@')
263 {
264 i += 1;
265 }
266 result.ignored.push(format!("{other} is not supported in v1 (ignored)"));
267 }
268 flag @ ("--extension" | "-e" | "--skill" | "--prompt-template" | "--theme") => {
269 if inline.is_none()
273 && i + 1 < args.len()
274 && !args[i + 1].starts_with('-')
275 && !args[i + 1].starts_with('@')
276 {
277 i += 1;
278 }
279 result.ignored.push(format!("{flag} is not supported in v1 (ignored)"));
280 }
281 "--list-models" => {
282 if inline.is_none()
284 && i + 1 < args.len()
285 && !args[i + 1].starts_with('-')
286 && !args[i + 1].starts_with('@')
287 {
288 i += 1;
289 }
290 result.ignored.push("--list-models is not supported in v1 (ignored)".to_string());
291 }
292 "--fork" => {
293 result.ignored.push("--fork is not supported in v1 (ignored)".to_string());
294 if inline.is_none() && i + 1 < args.len() && !args[i + 1].starts_with('-') {
295 i += 1;
296 }
297 }
298 other if other.starts_with("--") => {
302 let name = &flag_key;
303 if inline.is_none()
304 && i + 1 < args.len()
305 && !args[i + 1].starts_with('-')
306 && !args[i + 1].starts_with('@')
307 {
308 i += 1;
309 }
310 result.ignored.push(format!("{name} is not a recognized flag (ignored)"));
311 }
312 other if other.starts_with('-') && other.len() > 1 => {
314 result
315 .errors
316 .push(format!("Unknown option: {other}"));
317 }
318 other if let Some(path) = file_arg(other) => {
320 result.file_args.push(path);
321 }
322 other => {
324 result.messages.push(other.to_string());
325 }
326 }
327 i += 1;
328 }
329
330 result
334}
335
336fn split_csv(v: &str) -> Vec<String> {
338 v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
339}
340
341pub fn resolve_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> RunMode {
346 if parsed.mode == Mode::Rpc {
347 return RunMode::Rpc;
348 }
349 if parsed.mode == Mode::Json {
350 return RunMode::Json;
351 }
352 if parsed.print || !stdin_is_tty || !stdout_is_tty {
353 RunMode::Print
354 } else {
355 RunMode::Interactive
356 }
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub enum RunMode {
365 Interactive,
366 Print,
367 Json,
368 Rpc,
369}
370
371pub fn print_help() {
373 let builtin = "read, bash, edit, write, grep, find, ls";
374 println!(
375 "{name} - AI coding assistant with read, bash, edit, write, grep, find, ls tools
376
377{u}Usage:{r}
378 {name} [options] [@files...] [messages...]
379
380{u}Options:{r}
381 --provider <name> Provider name (v1: anthropic)
382 --model <pattern> Model pattern or ID (supports \"provider/id\" and optional \":<thinking>\")
383 --api-key <key> API key (x-api-key; defaults to ~/.rpi/auth.json, then ANTHROPIC_API_KEY)
384 --base-url <url> Override the Anthropic endpoint (defaults to ANTHROPIC_BASE_URL)
385 --system-prompt <text> Replace the default system prompt
386 --append-system-prompt <text> Append text to the system prompt (repeatable)
387 --thinking <level> off, minimal, low, medium, high, xhigh, max
388 --mode <mode> Output mode: text (default), json, or rpc
389 --print, -p Non-interactive: process prompt(s) and exit
390 --continue, -c Continue the most recent session
391 --resume, -r Browse and select a session to resume
392 --session <id|path> Use a specific session (partial UUID or file)
393 --session-dir <dir> Directory for session storage
394 --no-session Ephemeral mode (do not persist the session)
395 --name, -n <name> Set the session display name
396 --tools, -t <list> Comma-separated allowlist of tool names to enable
397 --exclude-tools, -xt <list> Comma-separated denylist of tool names to disable
398 --no-tools, -nt Disable all tools
399 --no-builtin-tools, -nbt Disable the built-in tools (read, bash, edit, write, grep, find, ls)
400 --verbose Show startup warnings (e.g. ignored flags)
401 --help, -h Show this help
402 --version, -v Show version
403
404{u}Subcommands:{r}
405 auth login|check|logout Manage persisted credentials in ~/.rpi/auth.json
406 (see `rpi auth --help`)
407
408{u}Built-in Tools:{r}
409 {builtin} (enabled by default; grep/find/ls are read-only)
410
411{u}Examples:{r}
412 # Interactive with an initial prompt
413 {name} \"List all .rs files in src/\"
414
415 # Single-shot print mode
416 {name} -p \"Summarize this project\"
417
418 # Include a file in the initial message
419 {name} @README.md \"What does this project do?\"
420
421 # Continue the previous session
422 {name} -c \"What did we discuss?\"
423
424 # Use a specific model + thinking level
425 {name} --model claude-sonnet-5 --thinking high \"Refactor this\"
426
427 # JSON event stream (one JSON object per line on stdout)
428 {name} --mode json -p \"Inspect the code\"
429
430 # Read-only: no file-modifying tools
431 {name} --tools read,bash -p \"Review the code in src/\"
432
433{u}Environment:{r}
434 ANTHROPIC_API_KEY Anthropic API key (x-api-key) — fallback when no stored credential
435 ANTHROPIC_AUTH_TOKEN Bearer token (Authorization: Bearer) for third-party gateways
436 ANTHROPIC_BASE_URL Override the Anthropic endpoint (e.g. a compatible proxy)
437 RPI_CODING_AGENT_DIR Override the ~/.rpi config directory (auth.json + models.json)
438
439{u}Notes:{r}
440 v1 speaks the Anthropic Messages protocol only. Auth is resolved in order:
441 --api-key → ~/.rpi/auth.json (via `rpi auth login`) → ANTHROPIC_AUTH_TOKEN
442 (Bearer) → ANTHROPIC_API_KEY (x-api-key). Define custom model catalogs in
443 ~/.rpi/models.json. TUI, extensions, skills, prompt templates, themes, model
444 cycling, package manager, HTML export, --fork, --list-models, --export, and
445 OAuth are recognized but not implemented yet.
446",
447 name = crate::APP_NAME,
448 builtin = builtin,
449 u = "\x1b[1m",
450 r = "\x1b[0m",
451 );
452}
453
454pub fn print_version() {
456 println!("{} {}", crate::APP_NAME, crate::VERSION);
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn s(args: &[&str]) -> Vec<String> {
464 args.iter().map(|a| a.to_string()).collect()
465 }
466
467 #[test]
468 fn parses_basic_prompt() {
469 let a = parse_args(&s(&["hello", "world"]));
470 assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
471 assert!(!a.help);
472 }
473
474 #[test]
475 fn parses_help_and_version() {
476 let a = parse_args(&s(&["--help"]));
477 assert!(a.help);
478 let a = parse_args(&s(&["-v"]));
479 assert!(a.version);
480 }
481
482 #[test]
483 fn print_consumes_following_positional() {
484 let a = parse_args(&s(&["-p", "summarize"]));
485 assert!(a.print);
486 assert_eq!(a.messages, vec!["summarize".to_string()]);
487 }
488
489 #[test]
490 fn print_does_not_consume_file_or_flag() {
491 let a = parse_args(&s(&["-p", "@file.md"]));
492 assert!(a.print);
493 assert!(a.messages.is_empty());
494 assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
495 }
496
497 #[test]
498 fn model_and_thinking() {
499 let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
500 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
501 assert_eq!(a.thinking, Some(ThinkingLevel::High));
502 }
503
504 #[test]
505 fn model_with_thinking_shorthand() {
506 let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
507 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
509 }
510
511 #[test]
512 fn tools_split_csv() {
513 let a = parse_args(&s(&["--tools", "read, bash ,write"]));
514 assert_eq!(a.tools.as_deref(), Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..]));
515 }
516
517 #[test]
518 fn unknown_short_flag_errors() {
519 let a = parse_args(&s(&["-Z"]));
520 assert!(!a.errors.is_empty());
521 }
522
523 #[test]
524 fn unknown_long_flag_warns_not_errors() {
525 let a = parse_args(&s(&["--frobnicate", "value"]));
526 assert!(a.errors.is_empty());
527 assert!(!a.ignored.is_empty());
528 }
529
530 #[test]
531 fn ignored_scope_cuts_warn() {
532 let a = parse_args(&s(&["--models", "sonnet"]));
533 assert!(a.errors.is_empty());
534 assert!(!a.ignored.is_empty());
535 assert!(a.messages.is_empty());
537 }
538
539 #[test]
540 fn file_args_stripped() {
541 let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
542 assert_eq!(a.file_args, vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]);
543 assert_eq!(a.messages, vec!["hi".to_string()]);
544 }
545
546 #[test]
547 fn equals_form_supported() {
548 let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
549 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
550 assert_eq!(a.thinking, Some(ThinkingLevel::Low));
551 }
552
553 #[test]
554 fn resolve_mode_interactive_when_tty() {
555 let a = Args { print: true, ..Args::default() };
556 assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
557 let a = Args::default();
558 assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
559 let a = Args { mode: Mode::Json, ..Args::default() };
560 assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
561 let a = Args { mode: Mode::Rpc, ..Args::default() };
562 assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
563 }
564
565 #[test]
566 fn piped_stdout_forces_print() {
567 let a = Args::default();
568 assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
570 }
571}