Skip to main content

oino/cli/
args.rs

1//! `parseArgs`: the hand-written parser, ported rule for rule (multi-letter
2//! short flags such as `-nbt`, `-p` taking an optional message, `@file` arguments, unknown
3//! long flags kept for extensions).
4
5use indexmap::IndexMap;
6use oino_ai::{JsonValue, ThinkingLevel};
7
8/// The valid thinking levels constant.
9pub const VALID_THINKING_LEVELS: [&str; 7] =
10    ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
11
12/// `--mode` values (Oino adds `acp`).
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum OutputMode {
15    /// Print mode text output.
16    Text,
17    /// JSON event stream.
18    Json,
19    /// RPC over stdio.
20    Rpc,
21    /// ACP agent over stdio.
22    Acp,
23}
24
25/// Severity of a parse diagnostic.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Severity {
28    /// Exit 1 after reporting.
29    Error,
30    /// Reported only.
31    Warning,
32}
33
34/// A parse or startup diagnostic.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct Diagnostic {
37    /// Severity.
38    pub severity: Severity,
39    /// Fixed text.
40    pub message: String,
41}
42
43impl Diagnostic {
44    /// An error.
45    #[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    /// A warning.
54    #[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/// `--list-models [search]`.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum ListModels {
66    /// Every available model.
67    All,
68    /// Fuzzy filtered.
69    Search(String),
70}
71
72/// `--tui-mode`.
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub enum TuiMode {
75    /// Inline.
76    Regular,
77    /// Alternate screen.
78    Fullscreen,
79}
80
81/// The args record.
82#[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    /// `--provider`.
89    pub provider: Option<String>,
90    /// `--model`.
91    pub model: Option<String>,
92    /// `--api-key`.
93    pub api_key: Option<String>,
94    /// `--system-prompt`.
95    pub system_prompt: Option<String>,
96    /// `--append-system-prompt` (repeatable).
97    pub append_system_prompt: Option<Vec<String>>,
98    /// `--thinking`.
99    pub thinking: Option<ThinkingLevel>,
100    /// `-c`.
101    pub continue_session: bool,
102    /// `-r`.
103    pub resume: bool,
104    /// `-h`.
105    pub help: bool,
106    /// `-v`.
107    pub version: bool,
108    /// `--mode`.
109    pub mode: Option<OutputMode>,
110    /// `-n/--name`.
111    pub name: Option<String>,
112    /// `--no-session`.
113    pub no_session: bool,
114    /// `--session`.
115    pub session: Option<String>,
116    /// `--session-id`.
117    pub session_id: Option<String>,
118    /// `--fork`.
119    pub fork: Option<String>,
120    /// `--session-dir`.
121    pub session_dir: Option<String>,
122    /// `--models`.
123    pub models: Option<Vec<String>>,
124    /// `-t/--tools`.
125    pub tools: Option<Vec<String>>,
126    /// `-xt/--exclude-tools`.
127    pub exclude_tools: Option<Vec<String>>,
128    /// `-nt`.
129    pub no_tools: bool,
130    /// `-nbt`.
131    pub no_builtin_tools: bool,
132    /// `-e` (repeatable).
133    pub extensions: Option<Vec<String>>,
134    /// `--exclude-extension` (repeatable, comma lists; WP-X2).
135    pub exclude_extensions: Option<Vec<String>>,
136    /// `-ne`.
137    pub no_extensions: bool,
138    /// `--no-mcp` (accepted, no core MCP).
139    pub no_mcp: bool,
140    /// `-p`.
141    pub print: bool,
142    /// `--export <in>`.
143    pub export: Option<String>,
144    /// `--skill` (repeatable).
145    pub skills: Option<Vec<String>>,
146    /// `--prompt-template` (repeatable).
147    pub prompt_templates: Option<Vec<String>>,
148    /// `--theme` (repeatable).
149    pub themes: Option<Vec<String>>,
150    /// `--use-theme`.
151    pub use_theme: Option<String>,
152    /// `-ns`.
153    pub no_skills: bool,
154    /// `-np`.
155    pub no_prompt_templates: bool,
156    /// `--no-themes`.
157    pub no_themes: bool,
158    /// `-nc`.
159    pub no_context_files: bool,
160    /// `--list-models`.
161    pub list_models: Option<ListModels>,
162    /// `--tui-mode`.
163    pub tui_mode: Option<TuiMode>,
164    /// `--verbose`.
165    pub verbose: bool,
166    /// `-a` / `-na`.
167    pub project_trust_override: Option<bool>,
168    /// `--offline`.
169    pub offline: bool,
170    /// Oino `--remote <url>`.
171    pub remote: Option<String>,
172    /// Oino `--agent-dir <dir>`.
173    pub agent_dir: Option<String>,
174    /// Positional messages.
175    pub messages: Vec<String>,
176    /// `@file` arguments (without `@`).
177    pub file_args: Vec<String>,
178    /// Unknown long flags for extensions (`true` or a string value).
179    pub unknown_flags: IndexMap<String, JsonValue>,
180    /// Parse diagnostics.
181    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
197/// Appends every id of one (possibly comma-separated) `--exclude-extension` value.
198fn push_ids(list: &mut Option<Vec<String>>, value: &str) {
199    list.get_or_insert_with(Vec::new).extend(split_list(value));
200}
201
202/// N o r m a l i z e   S e s s i o n   N a m e.
203#[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/// P a r s e   A r g s.
210#[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}