use clap::builder::styling::{AnsiColor, Styles};
use clap::{Args as ClapArgs, Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const STYLES: Styles = Styles::styled()
.header(AnsiColor::Green.on_default().bold())
.usage(AnsiColor::Green.on_default().bold())
.literal(AnsiColor::Cyan.on_default())
.placeholder(AnsiColor::BrightBlack.on_default());
const AFTER_HELP: &str = "\
Examples:
roba \"explain the borrow checker in 3 bullets\" one-shot question
cat err.log | roba \"what's wrong here?\" pipe stdin
roba --attach 'src/**/*.rs' \"audit error handling\" attach files
roba -c -p \"now add a test for that\" continue the last session
roba --writable \"rename foo to bar in src/\" let claude edit files
Full flag detail, env vars, and roba.toml config: roba --help";
const AFTER_LONG_HELP: &str = "\
Examples:
roba \"explain the borrow checker in 3 bullets\" one-shot question
cat err.log | roba \"what's wrong here?\" pipe stdin
roba --attach 'src/**/*.rs' \"audit error handling\" attach files
roba -c -p \"now add a test for that\" continue the last session
roba --writable \"rename foo to bar in src/\" let claude edit files
Environment variables:
Every long flag has a ROBA_<FLAG> override (uppercased, '-' -> '_'):
--model -> ROBA_MODEL; bool flags take a truthy value (--writable ->
ROBA_WRITABLE=1). Special cases:
ROBA_PROFILE=NAME apply a profile (like --profile NAME)
ROBA_VAR_<KEY>=VALUE set a template var (like --var KEY=VALUE)
ROBA_RATES_FILE=PATH override the footer rates table
NO_COLOR=1 disable color (help and answer rendering)
Precedence: CLI flag > ROBA_* env > active profile > built-in default.
Configuration (roba.toml):
Every flag is also a key under [profile.NAME] or at the top level of a
roba.toml -- discovered by walking up from the cwd, plus
~/.config/roba.toml. Closer-to-cwd files win; a `default` profile
auto-applies. Define [alias.NAME] shortcuts too. See the `roba profile`
and `roba alias` subcommands.";
#[derive(Parser, Debug)]
#[command(
version,
about,
long_about = None,
after_help = AFTER_HELP,
after_long_help = AFTER_LONG_HELP,
styles = STYLES,
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<SubCommand>,
#[command(flatten)]
pub ask: AskArgs,
#[arg(short = 'C', long, value_name = "PATH", global = true)]
pub cwd: Option<PathBuf>,
}
#[derive(Subcommand, Debug)]
pub enum SubCommand {
History(HistoryArgs),
Last(LastArgs),
Profile {
#[command(subcommand)]
action: ProfileAction,
},
Cost(CostArgs),
Doctor,
Alias {
#[command(subcommand)]
action: AliasAction,
},
Completions {
shell: clap_complete::Shell,
},
#[command(external_subcommand)]
External(Vec<String>),
}
#[derive(Subcommand, Debug)]
pub enum AliasAction {
List,
Show {
name: String,
},
Path,
}
#[derive(ClapArgs, Debug)]
pub struct CostArgs {
#[arg(long)]
pub by_project: bool,
#[arg(long, value_name = "SLUG", allow_hyphen_values = true)]
pub project: Option<String>,
#[arg(short = 'n', long, value_name = "N")]
pub limit: Option<usize>,
#[arg(long)]
pub json: bool,
#[arg(long, value_name = "PATH")]
pub rates_file: Option<PathBuf>,
#[arg(long)]
pub no_dollars: bool,
}
#[derive(Subcommand, Debug)]
pub enum ProfileAction {
List,
Show {
name: String,
},
Init {
#[arg(long)]
force: bool,
},
Path,
Active,
}
#[derive(ClapArgs, Debug)]
pub struct LastArgs {
#[arg(short = 'n', long = "number", value_name = "N")]
pub number: Option<usize>,
#[arg(long = "type", value_enum, default_value_t = LastKind::Text)]
pub kind: LastKind,
#[arg(long, value_name = "SLUG", allow_hyphen_values = true)]
pub project: Option<String>,
#[arg(long, conflicts_with = "project")]
pub all_projects: bool,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum LastKind {
Text,
Tools,
All,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EffortLevel {
Low,
Medium,
High,
Xhigh,
Max,
}
impl LastKind {
pub fn label(self) -> &'static str {
match self {
LastKind::Text => "text answers",
LastKind::Tools => "tool calls",
LastKind::All => "items",
}
}
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum PermMode {
#[value(name = "acceptEdits", alias = "accept-edits")]
AcceptEdits,
Auto,
#[value(name = "bypassPermissions", alias = "bypass-permissions")]
BypassPermissions,
Default,
#[value(name = "dontAsk", alias = "dont-ask")]
DontAsk,
Plan,
}
#[derive(ClapArgs, Debug)]
pub struct HistoryArgs {
#[arg(short = 'n', long, value_name = "N")]
pub limit: Option<usize>,
#[arg(long, conflicts_with = "limit")]
pub all: bool,
#[arg(long, value_name = "SLUG", allow_hyphen_values = true)]
pub project: Option<String>,
#[arg(long, conflicts_with = "project")]
pub all_projects: bool,
#[arg(long)]
pub json: bool,
#[arg(long, value_name = "N", num_args = 0..=1, require_equals = false)]
pub paths: Option<Option<usize>>,
}
#[derive(ClapArgs, Debug)]
pub struct AskArgs {
#[arg(conflicts_with_all = ["file", "editor"])]
pub prompt: Option<String>,
#[arg(
short = 'p',
long = "prompt",
value_name = "TEXT",
conflicts_with = "prompt",
help_heading = "Prompt sources"
)]
pub prompt_flag: Option<String>,
#[arg(
short,
long,
value_name = "PATH",
conflicts_with = "editor",
help_heading = "Prompt sources"
)]
pub file: Option<PathBuf>,
#[arg(short = 'e', long = "editor", help_heading = "Prompt sources")]
pub editor: bool,
#[arg(long, value_name = "N", help_heading = "Prompt sources")]
pub editor_history: Option<usize>,
#[arg(long, value_name = "PATH", help_heading = "Composition")]
pub prepend: Vec<PathBuf>,
#[arg(long, value_name = "PATH", help_heading = "Composition")]
pub append: Vec<PathBuf>,
#[arg(long, value_name = "GLOB", help_heading = "Composition")]
pub attach: Vec<String>,
#[arg(long, help_heading = "Composition")]
pub git_diff: bool,
#[arg(
long,
value_name = "N",
num_args(0..=1),
default_missing_value = "5",
help_heading = "Composition"
)]
pub git_log: Option<usize>,
#[arg(long, help_heading = "Composition")]
pub git_status: bool,
#[arg(
long,
value_name = "K=V",
value_parser = parse_kv,
help_heading = "Composition"
)]
pub var: Vec<(String, String)>,
#[arg(short = 'q', long, help_heading = "Output")]
pub quiet: bool,
#[arg(long, help_heading = "Output")]
pub json: bool,
#[arg(
long,
value_name = "LANG",
num_args(0..=1),
default_missing_value = "",
conflicts_with = "json",
help_heading = "Output"
)]
pub code: Option<String>,
#[arg(short = 'o', long, value_name = "PATH", help_heading = "Output")]
pub out: Option<PathBuf>,
#[arg(long, value_name = "PATH", help_heading = "Output")]
pub trace: Option<PathBuf>,
#[arg(
long,
conflicts_with_all = ["json", "code", "out"],
help_heading = "Output"
)]
pub stream: bool,
#[arg(long, help_heading = "Output")]
pub show_thinking: bool,
#[arg(long, help_heading = "Output")]
pub echo: bool,
#[arg(long, help_heading = "Output")]
pub plain: bool,
#[arg(long, value_name = "PATH", help_heading = "Output")]
pub rates_file: Option<PathBuf>,
#[arg(long, help_heading = "Output")]
pub no_dollars: bool,
#[arg(long, help_heading = "Failure modes")]
pub no_retry: bool,
#[arg(long, help_heading = "Mode")]
pub bare: bool,
#[arg(long, value_name = "MODEL", help_heading = "Model")]
pub model: Option<String>,
#[arg(long, value_name = "LEVEL", value_enum, help_heading = "Model")]
pub effort: Option<EffortLevel>,
#[arg(long, value_name = "TEXT", help_heading = "System prompt")]
pub system_prompt: Option<String>,
#[arg(long, value_name = "TEXT", help_heading = "System prompt")]
pub append_system_prompt: Option<String>,
#[arg(
short = 'c',
long = "continue",
num_args = 0..=1,
value_name = "ID",
help_heading = "Sessions"
)]
pub continue_session: Option<Option<String>>,
#[arg(long, requires = "continue_session", help_heading = "Sessions")]
pub fork: bool,
#[arg(long, conflicts_with = "continue_session", help_heading = "Sessions")]
pub pick: bool,
#[arg(
long,
conflicts_with_all = ["continue_session", "pick"],
help_heading = "Sessions"
)]
pub fresh: bool,
#[arg(
short = 'w',
long,
value_name = "NAME",
num_args(0..=1),
help_heading = "Sessions"
)]
pub worktree: Option<Option<String>>,
#[arg(long, value_name = "NAME", help_heading = "Sessions")]
pub agent: Option<String>,
#[arg(long, value_name = "MODE", value_enum, help_heading = "Permissions")]
pub permission_mode: Option<PermMode>,
#[arg(long, conflicts_with = "full_auto", help_heading = "Permissions")]
pub readonly: bool,
#[arg(long, conflicts_with = "full_auto", help_heading = "Permissions")]
pub writable: bool,
#[arg(long, help_heading = "Permissions")]
pub full_auto: bool,
#[arg(long = "allow-tool", value_name = "TOOL", help_heading = "Permissions")]
pub allow_tool: Vec<String>,
#[arg(long = "deny-tool", value_name = "TOOL", help_heading = "Permissions")]
pub deny_tool: Vec<String>,
#[arg(long, help_heading = "Permissions")]
pub show_permissions: bool,
#[arg(long, help_heading = "Permissions")]
pub no_agent_check: bool,
#[arg(long, help_heading = "Dispatch")]
pub dispatch: bool,
#[clap(skip)]
pub readonly_source: Option<String>,
#[clap(skip)]
pub writable_source: Option<String>,
#[clap(skip)]
pub full_auto_source: Option<String>,
#[clap(skip)]
pub allow_tool_sources: Vec<String>,
#[clap(skip)]
pub deny_tool_sources: Vec<String>,
#[clap(skip)]
pub permission_mode_source: Option<String>,
#[arg(long, value_name = "NAME", help_heading = "Profiles")]
pub profile: Option<String>,
#[arg(long, help_heading = "Profiles")]
pub no_default_profile: bool,
}
pub fn parse_kv(s: &str) -> std::result::Result<(String, String), String> {
let (k, v) = s
.split_once('=')
.ok_or_else(|| format!("expected K=V, got `{s}`"))?;
if k.is_empty() {
return Err(format!("empty key in `{s}`"));
}
Ok((k.to_string(), v.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_kv_splits_on_first_equals() {
assert_eq!(
parse_kv("foo=bar"),
Ok(("foo".to_string(), "bar".to_string()))
);
}
#[test]
fn parse_kv_keeps_equals_in_value() {
assert_eq!(
parse_kv("x=a=b=c"),
Ok(("x".to_string(), "a=b=c".to_string()))
);
}
#[test]
fn parse_kv_rejects_no_equals() {
assert!(parse_kv("foo").is_err());
}
#[test]
fn parse_kv_rejects_empty_key() {
assert!(parse_kv("=bar").is_err());
}
#[test]
fn parse_kv_accepts_empty_value() {
assert_eq!(parse_kv("k="), Ok(("k".to_string(), String::new())));
}
#[test]
fn out_and_json_compose() {
use clap::Parser;
let cli =
Cli::try_parse_from(["roba", "ask thing", "--out", "result.txt", "--json"]).unwrap();
assert_eq!(
cli.ask.out.as_deref(),
Some(std::path::Path::new("result.txt"))
);
assert!(cli.ask.json);
}
#[test]
fn worktree_missing_is_none() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "do thing"]).unwrap();
assert!(cli.ask.worktree.is_none());
}
#[test]
fn worktree_short_alone_is_presence() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-w", "-p", "do thing"]).unwrap();
assert_eq!(cli.ask.worktree, Some(None));
assert_eq!(cli.ask.prompt_flag.as_deref(), Some("do thing"));
}
#[test]
fn worktree_long_alone_is_presence() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--worktree", "-p", "do thing"]).unwrap();
assert_eq!(cli.ask.worktree, Some(None));
assert_eq!(cli.ask.prompt_flag.as_deref(), Some("do thing"));
}
#[test]
fn worktree_short_equals_name() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-w=mybranch", "do thing"]).unwrap();
assert_eq!(cli.ask.worktree, Some(Some("mybranch".to_string())));
assert_eq!(cli.ask.prompt.as_deref(), Some("do thing"));
}
#[test]
fn worktree_long_equals_name() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--worktree=mybranch", "do thing"]).unwrap();
assert_eq!(cli.ask.worktree, Some(Some("mybranch".to_string())));
}
#[test]
fn agent_parses_with_name() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--agent", "reviewer", "prompt"]).unwrap();
assert_eq!(cli.ask.agent.as_deref(), Some("reviewer"));
assert_eq!(cli.ask.prompt.as_deref(), Some("prompt"));
}
#[test]
fn agent_omitted_is_none() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "prompt"]).unwrap();
assert!(cli.ask.agent.is_none());
}
#[test]
fn no_retry_parses_alone() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--no-retry", "prompt"]).unwrap();
assert!(cli.ask.no_retry);
assert_eq!(cli.ask.prompt.as_deref(), Some("prompt"));
}
#[test]
fn no_retry_conflicts_with_nothing() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--no-retry", "--quiet", "prompt"]).unwrap();
assert!(cli.ask.no_retry);
assert!(cli.ask.quiet);
let cli = Cli::try_parse_from(["roba", "--no-retry", "--json", "prompt"]).unwrap();
assert!(cli.ask.no_retry);
assert!(cli.ask.json);
}
#[test]
fn permission_mode_parses_all_variants() {
use clap::Parser;
for mode in &[
"acceptEdits",
"auto",
"bypassPermissions",
"default",
"dontAsk",
"plan",
"accept-edits",
"bypass-permissions",
"dont-ask",
] {
let cli = Cli::try_parse_from(["roba", "--permission-mode", mode, "prompt"])
.unwrap_or_else(|e| panic!("--permission-mode {mode} should parse: {e}"));
assert!(
cli.ask.permission_mode.is_some(),
"--permission-mode {mode} should be Some"
);
}
}
#[test]
fn permission_mode_coexists_with_writable() {
use clap::Parser;
let cli =
Cli::try_parse_from(["roba", "--writable", "--permission-mode", "plan", "prompt"])
.unwrap();
assert!(cli.ask.writable);
assert!(cli.ask.permission_mode.is_some());
}
#[test]
fn permission_mode_coexists_with_readonly() {
use clap::Parser;
let cli = Cli::try_parse_from([
"roba",
"--readonly",
"--permission-mode",
"dont-ask",
"prompt",
])
.unwrap();
assert!(cli.ask.readonly);
assert!(cli.ask.permission_mode.is_some());
}
#[test]
fn permission_mode_coexists_with_full_auto() {
use clap::Parser;
let cli =
Cli::try_parse_from(["roba", "--full-auto", "--permission-mode", "plan", "prompt"])
.unwrap();
assert!(cli.ask.full_auto);
assert!(cli.ask.permission_mode.is_some());
}
#[test]
fn bare_parses_alone() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--bare", "prompt"]).unwrap();
assert!(cli.ask.bare);
}
#[test]
fn bare_is_orthogonal() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--bare", "--quiet", "prompt"]).unwrap();
assert!(cli.ask.bare);
assert!(cli.ask.quiet);
let cli = Cli::try_parse_from(["roba", "--bare", "--json", "prompt"]).unwrap();
assert!(cli.ask.bare);
assert!(cli.ask.json);
}
#[test]
fn trace_flag_parses() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--trace", "/tmp/x.jsonl", "prompt"]).unwrap();
assert_eq!(
cli.ask.trace.as_deref(),
Some(std::path::Path::new("/tmp/x.jsonl"))
);
assert_eq!(cli.ask.prompt.as_deref(), Some("prompt"));
}
#[test]
fn trace_omitted_is_none() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "prompt"]).unwrap();
assert!(cli.ask.trace.is_none());
}
#[test]
fn trace_conflicts_with_nothing() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--trace", "/tmp/x", "--json", "prompt"]).unwrap();
assert_eq!(
cli.ask.trace.as_deref(),
Some(std::path::Path::new("/tmp/x"))
);
assert!(cli.ask.json);
let cli = Cli::try_parse_from(["roba", "--trace", "/tmp/x", "--quiet", "prompt"]).unwrap();
assert!(cli.ask.trace.is_some());
assert!(cli.ask.quiet);
let cli =
Cli::try_parse_from(["roba", "--trace", "/tmp/x", "--out", "r.txt", "prompt"]).unwrap();
assert!(cli.ask.trace.is_some());
assert_eq!(cli.ask.out.as_deref(), Some(std::path::Path::new("r.txt")));
let cli = Cli::try_parse_from(["roba", "--trace", "/tmp/x", "--stream", "prompt"]).unwrap();
assert!(cli.ask.trace.is_some());
assert!(cli.ask.stream);
}
#[test]
fn continue_parses_bare() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-c", "-p", "prompt"]).unwrap();
assert_eq!(cli.ask.continue_session, Some(None));
assert_eq!(cli.ask.prompt_flag.as_deref(), Some("prompt"));
}
#[test]
fn continue_parses_with_id() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-c=abc123", "prompt"]).unwrap();
assert_eq!(cli.ask.continue_session, Some(Some("abc123".to_string())));
assert_eq!(cli.ask.prompt.as_deref(), Some("prompt"));
}
#[test]
fn continue_long_parses_with_id() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--continue=abc123", "prompt"]).unwrap();
assert_eq!(cli.ask.continue_session, Some(Some("abc123".to_string())));
}
#[test]
fn continue_without_equals_consumes_next_arg_as_id() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-c", "prompt"]).unwrap();
assert_eq!(cli.ask.continue_session, Some(Some("prompt".to_string())));
assert!(cli.ask.prompt.is_none());
}
#[test]
fn continue_missing_is_none() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "prompt"]).unwrap();
assert!(cli.ask.continue_session.is_none());
}
#[test]
fn fork_requires_continue_at_parse_time() {
use clap::Parser;
let err = Cli::try_parse_from(["roba", "--fork", "prompt"]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("continue") || msg.contains("required"),
"expected a requires error mentioning continue, got: {msg}"
);
}
#[test]
fn fork_with_specific_id_parses() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-c=abc123", "--fork", "prompt"]).unwrap();
assert_eq!(cli.ask.continue_session, Some(Some("abc123".to_string())));
assert!(cli.ask.fork);
}
#[test]
fn pick_conflicts_with_continue() {
use clap::Parser;
assert!(Cli::try_parse_from(["roba", "--pick", "-c", "prompt"]).is_err());
}
#[test]
fn fresh_conflicts_with_continue() {
use clap::Parser;
assert!(Cli::try_parse_from(["roba", "--fresh", "-c", "prompt"]).is_err());
}
#[test]
fn worktree_long_space_name_attaches_value() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--worktree", "mybranch"]).unwrap();
assert_eq!(cli.ask.worktree, Some(Some("mybranch".to_string())));
assert!(cli.ask.prompt.is_none());
}
#[test]
fn external_subcommand_captures_unknown_leading_word() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "review", "42", "--readonly"]).unwrap();
assert_eq!(cli.ask.prompt.as_deref(), Some("review"));
match cli.command {
Some(SubCommand::External(rest)) => {
assert_eq!(rest, vec!["42".to_string(), "--readonly".to_string()]);
}
other => panic!("expected External, got {other:?}"),
}
}
#[test]
fn prompt_flag_parses() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-p", "hello"]).unwrap();
assert_eq!(cli.ask.prompt_flag.as_deref(), Some("hello"));
assert!(cli.ask.prompt.is_none());
}
#[test]
fn prompt_flag_long_form_parses() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--prompt", "hello"]).unwrap();
assert_eq!(cli.ask.prompt_flag.as_deref(), Some("hello"));
assert!(cli.ask.prompt.is_none());
}
#[test]
fn prompt_flag_conflicts_with_positional() {
use clap::Parser;
assert!(Cli::try_parse_from(["roba", "-p", "x", "positional"]).is_err());
}
#[test]
fn continue_with_space_value_consumes_id() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-c", "abc123"]).unwrap();
assert_eq!(cli.ask.continue_session, Some(Some("abc123".to_string())));
assert!(cli.ask.prompt.is_none());
}
#[test]
fn worktree_with_space_value_and_prompt_flag() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-w", "mybranch", "-p", "the prompt"]).unwrap();
assert_eq!(cli.ask.worktree, Some(Some("mybranch".to_string())));
assert_eq!(cli.ask.prompt_flag.as_deref(), Some("the prompt"));
}
#[test]
fn continue_bare_then_p_flag_for_prompt() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "-c", "-p", "follow up"]).unwrap();
assert_eq!(cli.ask.continue_session, Some(None));
assert_eq!(cli.ask.prompt_flag.as_deref(), Some("follow up"));
}
#[test]
fn single_bare_word_is_prompt_not_external() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "commit-msg"]).unwrap();
assert!(cli.command.is_none());
assert_eq!(cli.ask.prompt.as_deref(), Some("commit-msg"));
}
#[test]
fn dispatch_flag_parsed() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--dispatch", "do thing"]).unwrap();
assert!(cli.ask.dispatch);
assert_eq!(cli.ask.prompt.as_deref(), Some("do thing"));
}
#[test]
fn dispatch_no_agent_parses() {
use clap::Parser;
let cli = Cli::try_parse_from(["roba", "--dispatch", "task"]).unwrap();
assert!(cli.ask.dispatch);
assert!(cli.ask.agent.is_none());
}
#[test]
fn dispatch_with_agent_parses() {
use clap::Parser;
let cli =
Cli::try_parse_from(["roba", "--dispatch", "--agent", "worker.md", "task"]).unwrap();
assert!(cli.ask.dispatch);
assert_eq!(cli.ask.agent.as_deref(), Some("worker.md"));
}
}