1use indexmap::IndexMap;
6use oino_ai::{JsonValue, ThinkingLevel};
7
8pub const VALID_THINKING_LEVELS: [&str; 7] =
10 ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum OutputMode {
15 Text,
17 Json,
19 Rpc,
21 Acp,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Severity {
28 Error,
30 Warning,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct Diagnostic {
37 pub severity: Severity,
39 pub message: String,
41}
42
43impl Diagnostic {
44 #[must_use]
46 pub fn error(message: impl Into<String>) -> Self {
47 Self {
48 severity: Severity::Error,
49 message: message.into(),
50 }
51 }
52
53 #[must_use]
55 pub fn warning(message: impl Into<String>) -> Self {
56 Self {
57 severity: Severity::Warning,
58 message: message.into(),
59 }
60 }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum ListModels {
66 All,
68 Search(String),
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub enum TuiMode {
75 Regular,
77 Fullscreen,
79}
80
81#[allow(
83 clippy::struct_excessive_bools,
84 reason = "one field per CLI switch, as in Args"
85)]
86#[derive(Clone, Debug, Default, PartialEq)]
87pub struct Args {
88 pub provider: Option<String>,
90 pub model: Option<String>,
92 pub api_key: Option<String>,
94 pub system_prompt: Option<String>,
96 pub append_system_prompt: Option<Vec<String>>,
98 pub thinking: Option<ThinkingLevel>,
100 pub continue_session: bool,
102 pub resume: bool,
104 pub help: bool,
106 pub version: bool,
108 pub mode: Option<OutputMode>,
110 pub name: Option<String>,
112 pub no_session: bool,
114 pub session: Option<String>,
116 pub session_id: Option<String>,
118 pub fork: Option<String>,
120 pub session_dir: Option<String>,
122 pub models: Option<Vec<String>>,
124 pub tools: Option<Vec<String>>,
126 pub exclude_tools: Option<Vec<String>>,
128 pub no_tools: bool,
130 pub no_builtin_tools: bool,
132 pub extensions: Option<Vec<String>>,
134 pub exclude_extensions: Option<Vec<String>>,
136 pub no_extensions: bool,
138 pub no_mcp: bool,
140 pub print: bool,
142 pub export: Option<String>,
144 pub skills: Option<Vec<String>>,
146 pub prompt_templates: Option<Vec<String>>,
148 pub themes: Option<Vec<String>>,
150 pub use_theme: Option<String>,
152 pub no_skills: bool,
154 pub no_prompt_templates: bool,
156 pub no_themes: bool,
158 pub no_context_files: bool,
160 pub list_models: Option<ListModels>,
162 pub tui_mode: Option<TuiMode>,
164 pub verbose: bool,
166 pub project_trust_override: Option<bool>,
168 pub offline: bool,
170 pub remote: Option<String>,
172 pub agent_dir: Option<String>,
174 pub messages: Vec<String>,
176 pub file_args: Vec<String>,
178 pub unknown_flags: IndexMap<String, JsonValue>,
180 pub diagnostics: Vec<Diagnostic>,
182}
183
184fn split_list(value: &str) -> Vec<String> {
185 value
186 .split(',')
187 .map(str::trim)
188 .filter(|s| !s.is_empty())
189 .map(str::to_owned)
190 .collect()
191}
192
193fn push(list: &mut Option<Vec<String>>, value: String) {
194 list.get_or_insert_with(Vec::new).push(value);
195}
196
197fn push_ids(list: &mut Option<Vec<String>>, value: &str) {
199 list.get_or_insert_with(Vec::new).extend(split_list(value));
200}
201
202#[must_use]
204pub fn normalize_session_name(value: &str) -> Option<String> {
205 let name = value.trim();
206 (!name.is_empty()).then(|| name.to_owned())
207}
208
209#[allow(
211 clippy::too_many_lines,
212 reason = "one branch per flag, in flag order, so the port stays reviewable"
213)]
214#[must_use]
215pub fn parse_args(args: &[String]) -> Args {
216 let mut result = Args::default();
217 let mut i = 0;
218 let value_at = |i: usize| args.get(i).cloned();
219 while i < args.len() {
220 let arg = args[i].as_str();
221 let has_next = i + 1 < args.len();
222 match arg {
223 "--" => {
224 for positional in &args[i + 1..] {
225 match positional.strip_prefix('@') {
226 Some(file) => result.file_args.push(file.to_owned()),
227 None => result.messages.push(positional.clone()),
228 }
229 }
230 break;
231 }
232 "--help" | "-h" => result.help = true,
233 "--version" | "-v" => result.version = true,
234 "--mode" => match value_at(i + 1) {
235 Some(mode) if !mode.starts_with('-') => {
236 i += 1;
237 result.mode = match mode.as_str() {
238 "text" => Some(OutputMode::Text),
239 "json" => Some(OutputMode::Json),
240 "rpc" => Some(OutputMode::Rpc),
241 "acp" => Some(OutputMode::Acp),
242 _ => {
243 result.diagnostics.push(Diagnostic::error(format!(
244 "Invalid mode \"{mode}\". Valid values: text, json, rpc, acp"
245 )));
246 result.mode
247 }
248 };
249 }
250 _ => result
251 .diagnostics
252 .push(Diagnostic::error("--mode requires text, json, rpc, or acp")),
253 },
254 "--continue" | "-c" => result.continue_session = true,
255 "--resume" | "-r" => result.resume = true,
256 "--provider" if has_next => {
257 i += 1;
258 result.provider = value_at(i);
259 }
260 "--model" if has_next => {
261 i += 1;
262 result.model = value_at(i);
263 }
264 "--api-key" if has_next => {
265 i += 1;
266 result.api_key = value_at(i);
267 }
268 "--system-prompt" if has_next => {
269 i += 1;
270 result.system_prompt = value_at(i);
271 }
272 "--append-system-prompt" if has_next => {
273 i += 1;
274 push(&mut result.append_system_prompt, args[i].clone());
275 }
276 "--name" | "-n" => {
277 if has_next {
278 i += 1;
279 result.name = value_at(i);
280 } else {
281 result
282 .diagnostics
283 .push(Diagnostic::error("--name requires a value"));
284 }
285 }
286 "--no-session" => result.no_session = true,
287 "--session" if has_next => {
288 i += 1;
289 result.session = value_at(i);
290 }
291 "--session-id" if has_next => {
292 i += 1;
293 result.session_id = value_at(i);
294 }
295 "--fork" if has_next => {
296 i += 1;
297 result.fork = value_at(i);
298 }
299 "--session-dir" if has_next => {
300 i += 1;
301 result.session_dir = value_at(i);
302 }
303 "--models" if has_next => {
304 i += 1;
305 result.models = Some(split_list(&args[i]));
306 }
307 "--no-tools" | "-nt" => result.no_tools = true,
308 "--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
309 "--tools" | "-t" if has_next => {
310 i += 1;
311 let tools = split_list(&args[i]);
312 match oino_settings::tools::tool_list_error(&tools) {
313 Some(error) => result
314 .diagnostics
315 .push(Diagnostic::error(format!("{arg}: {error}"))),
316 None => result.tools = Some(tools),
317 }
318 }
319 "--exclude-tools" | "-xt" if has_next => {
320 i += 1;
321 result.exclude_tools = Some(split_list(&args[i]));
322 }
323 "--thinking" if has_next => {
324 i += 1;
325 let level = &args[i];
326 match level.parse::<ThinkingLevel>() {
327 Ok(parsed) if VALID_THINKING_LEVELS.contains(&level.as_str()) => {
328 result.thinking = Some(parsed);
329 }
330 _ => result.diagnostics.push(Diagnostic::warning(format!(
331 "Invalid thinking level \"{level}\". Valid values: {}",
332 VALID_THINKING_LEVELS.join(", ")
333 ))),
334 }
335 }
336 "--print" | "-p" => {
337 result.print = true;
338 if let Some(next) = args.get(i + 1)
339 && !next.starts_with('@')
340 && (!next.starts_with('-') || next.starts_with("---"))
341 {
342 result.messages.push(next.clone());
343 i += 1;
344 }
345 }
346 "--export" if has_next => {
347 i += 1;
348 result.export = value_at(i);
349 }
350 "--extension" | "-e" if has_next => {
351 i += 1;
352 push(&mut result.extensions, args[i].clone());
353 }
354 "--exclude-extension" if has_next => {
355 i += 1;
356 push_ids(&mut result.exclude_extensions, &args[i]);
357 }
358 "--no-extensions" | "-ne" => result.no_extensions = true,
359 "--no-mcp" => result.no_mcp = true,
360 "--skill" if has_next => {
361 i += 1;
362 push(&mut result.skills, args[i].clone());
363 }
364 "--prompt-template" if has_next => {
365 i += 1;
366 push(&mut result.prompt_templates, args[i].clone());
367 }
368 "--theme" if has_next => {
369 i += 1;
370 push(&mut result.themes, args[i].clone());
371 }
372 "--use-theme" => match args.get(i + 1) {
373 Some(name) if !name.starts_with('-') => {
374 result.use_theme = Some(name.clone());
375 i += 1;
376 }
377 _ => result
378 .diagnostics
379 .push(Diagnostic::error("--use-theme requires a theme name")),
380 },
381 "--no-skills" | "-ns" => result.no_skills = true,
382 "--no-prompt-templates" | "-np" => result.no_prompt_templates = true,
383 "--no-themes" => result.no_themes = true,
384 "--no-context-files" | "-nc" => result.no_context_files = true,
385 "--list-models" => match args.get(i + 1) {
386 Some(search) if !search.starts_with('-') && !search.starts_with('@') => {
387 result.list_models = Some(ListModels::Search(search.clone()));
388 i += 1;
389 }
390 _ => result.list_models = Some(ListModels::All),
391 },
392 "--tui-mode" => match args.get(i + 1).map(String::as_str) {
393 Some("regular") => {
394 result.tui_mode = Some(TuiMode::Regular);
395 i += 1;
396 }
397 Some("fullscreen") => {
398 result.tui_mode = Some(TuiMode::Fullscreen);
399 i += 1;
400 }
401 Some(mode) if !mode.starts_with('-') => {
402 i += 1;
403 result.diagnostics.push(Diagnostic::error(format!(
404 "Invalid TUI mode \"{mode}\". Valid values: regular, fullscreen"
405 )));
406 }
407 _ => result.diagnostics.push(Diagnostic::error(
408 "--tui-mode requires regular or fullscreen",
409 )),
410 },
411 "--verbose" => result.verbose = true,
412 "--approve" | "-a" => result.project_trust_override = Some(true),
413 "--no-approve" | "-na" => result.project_trust_override = Some(false),
414 "--offline" => result.offline = true,
415 "--remote" if has_next => {
416 i += 1;
417 result.remote = value_at(i);
418 }
419 "--agent-dir" if has_next => {
420 i += 1;
421 result.agent_dir = value_at(i);
422 }
423 _ => {
424 if let Some(file) = arg.strip_prefix('@') {
425 result.file_args.push(file.to_owned());
426 } else if let Some(flag) = arg.strip_prefix("--") {
427 if let Some((name, value)) = flag.split_once('=') {
428 result
429 .unknown_flags
430 .insert(name.to_owned(), JsonValue::from(value));
431 } else {
432 match args.get(i + 1) {
433 Some(next) if !next.starts_with('-') && !next.starts_with('@') => {
434 result
435 .unknown_flags
436 .insert(flag.to_owned(), JsonValue::from(next.as_str()));
437 i += 1;
438 }
439 _ => {
440 result
441 .unknown_flags
442 .insert(flag.to_owned(), JsonValue::Bool(true));
443 }
444 }
445 }
446 } else if arg.starts_with('-') {
447 result
448 .diagnostics
449 .push(Diagnostic::error(format!("Unknown option: {arg}")));
450 } else {
451 result.messages.push(arg.to_owned());
452 }
453 }
454 }
455 i += 1;
456 }
457 result
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 fn parse(args: &[&str]) -> Args {
465 parse_args(&args.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>())
466 }
467
468 #[test]
469 fn print_takes_an_optional_message() {
470 let args = parse(&["-p", "hello", "world"]);
471 assert!(args.print);
472 assert_eq!(args.messages, vec!["hello", "world"]);
473 let args = parse(&["-p", "@a.md", "x"]);
474 assert_eq!(args.messages, vec!["x"]);
475 assert_eq!(args.file_args, vec!["a.md"]);
476 let args = parse(&["-p", "--", "- point"]);
477 assert_eq!(args.messages, vec!["- point"]);
478 let args = parse(&["-p", "---x"]);
479 assert_eq!(args.messages, vec!["---x"]);
480 }
481
482 #[test]
483 fn short_multi_letter_flags_and_lists() {
484 let args = parse(&[
485 "-nbt",
486 "-nc",
487 "-t",
488 "read, grep ,",
489 "-xt",
490 "bash",
491 "-ne",
492 "-na",
493 ]);
494 assert!(args.no_builtin_tools && args.no_context_files && args.no_extensions);
495 assert_eq!(args.tools, Some(vec!["read".into(), "grep".into()]));
496 assert_eq!(args.exclude_tools, Some(vec!["bash".into()]));
497 assert_eq!(args.project_trust_override, Some(false));
498 }
499
500 #[test]
501 fn modes_and_diagnostics() {
502 assert_eq!(parse(&["--mode", "rpc"]).mode, Some(OutputMode::Rpc));
503 assert_eq!(
504 parse(&["--mode", "x"]).diagnostics,
505 vec![Diagnostic::error(
506 "Invalid mode \"x\". Valid values: text, json, rpc, acp"
507 )]
508 );
509 assert_eq!(
510 parse(&["--mode"]).diagnostics[0].message,
511 "--mode requires text, json, rpc, or acp"
512 );
513 assert_eq!(
514 parse(&["-z"]).diagnostics,
515 vec![Diagnostic::error("Unknown option: -z")]
516 );
517 let thinking = parse(&["--thinking", "huge"]);
518 assert_eq!(thinking.diagnostics[0].severity, Severity::Warning);
519 assert_eq!(
520 thinking.diagnostics[0].message,
521 "Invalid thinking level \"huge\". Valid values: off, minimal, low, medium, high, xhigh, max"
522 );
523 assert_eq!(
524 parse(&["--thinking", "high"]).thinking,
525 Some(ThinkingLevel::High)
526 );
527 }
528
529 #[test]
530 fn unknown_long_flags_go_to_extensions() {
531 let args = parse(&["--plan", "--depth=3", "--level", "5", "msg"]);
532 assert_eq!(args.unknown_flags["plan"], JsonValue::Bool(true));
533 assert_eq!(args.unknown_flags["depth"], JsonValue::from("3"));
534 assert_eq!(args.unknown_flags["level"], JsonValue::from("5"));
535 assert_eq!(args.messages, vec!["msg"]);
536 }
537
538 #[test]
539 fn list_models_and_repeatables() {
540 assert_eq!(parse(&["--list-models"]).list_models, Some(ListModels::All));
541 assert_eq!(
542 parse(&["--list-models", "son"]).list_models,
543 Some(ListModels::Search("son".into()))
544 );
545 let args = parse(&["-e", "a", "--extension", "b", "--append-system-prompt", "x"]);
546 assert_eq!(args.extensions, Some(vec!["a".into(), "b".into()]));
547 assert_eq!(args.append_system_prompt, Some(vec!["x".into()]));
548 assert_eq!(
549 parse(&["--name"]).diagnostics[0].message,
550 "--name requires a value"
551 );
552 }
553
554 #[test]
555 fn exclude_extension_is_repeatable_and_takes_comma_lists() {
556 let args = parse(&[
557 "--exclude-extension",
558 "acme.kanboard, oino.anthropic ,",
559 "--exclude-extension",
560 "acme.notify",
561 "-ne",
562 ]);
563 assert_eq!(
564 args.exclude_extensions,
565 Some(vec![
566 "acme.kanboard".to_owned(),
567 "oino.anthropic".to_owned(),
568 "acme.notify".to_owned()
569 ])
570 );
571 assert!(args.no_extensions, "-ne is unchanged");
572 assert_eq!(parse(&["--exclude-extension"]).exclude_extensions, None);
573 }
574}