Skip to main content

mermaid_cli/cli/
args.rs

1use clap::{Parser, Subcommand, ValueEnum};
2use std::path::PathBuf;
3
4use mermaid_model::models::ReasoningLevel;
5
6#[derive(Parser, Debug)]
7#[command(name = "mermaid")]
8#[command(version)]
9#[command(about = "An open-source, model-agnostic AI pair programmer", long_about = None)]
10#[command(after_help = TOP_LEVEL_HELP_AFTER)]
11pub struct Cli {
12    /// Model to use (e.g., qwen3-coder:30b, ollama/llama3)
13    #[arg(short, long)]
14    pub model: Option<String>,
15
16    /// Reasoning depth (none, minimal, low, medium, high, max).
17    /// Overrides the persisted default for this session; the slash
18    /// command `/reasoning <level>` and Alt+T can change it at runtime.
19    #[arg(long)]
20    pub reasoning: Option<ReasoningLevel>,
21
22    /// Project directory (defaults to current directory)
23    #[arg(short, long)]
24    pub path: Option<PathBuf>,
25
26    /// Verbose output
27    #[arg(short, long)]
28    pub verbose: bool,
29
30    /// Resume a past conversation. Bare `--resume` opens a searchable picker
31    /// (interactive only); `--resume <SESSION_ID>` loads that session directly
32    /// and also works headless with `mermaid run`. Like `claude --resume`.
33    /// `None` = flag absent; `Some(None)` = bare (picker);
34    /// `Some(Some(id))` = direct load.
35    #[arg(
36        long,
37        value_name = "SESSION_ID",
38        num_args = 0..=1,
39        conflicts_with = "continue_session"
40    )]
41    pub resume: Option<Option<String>>,
42
43    /// Resume the most recent conversation in this directory. Like
44    /// `claude --continue`.
45    #[arg(long = "continue", conflicts_with = "resume")]
46    pub continue_session: bool,
47
48    /// Append every reducer `Msg` to a JSONL file at this path for
49    /// debugging / post-mortem replay. Interactive mode only.
50    #[arg(long, value_name = "FILE")]
51    pub record: Option<PathBuf>,
52
53    /// Replay a `--record` log through the pure reducer: print the
54    /// reconstructed session and a determinism verdict. Headless — no model
55    /// calls, no tool execution, no config reads (the log is self-contained).
56    #[arg(long, value_name = "FILE", conflicts_with = "record")]
57    pub replay: Option<PathBuf>,
58
59    /// Replace Mermaid's default system prompt for this invocation
60    #[arg(long, global = true, conflicts_with = "system_prompt_file")]
61    pub system_prompt: Option<String>,
62
63    /// Replace Mermaid's default system prompt with the contents of a file
64    #[arg(
65        long,
66        value_name = "FILE",
67        global = true,
68        conflicts_with = "system_prompt"
69    )]
70    pub system_prompt_file: Option<PathBuf>,
71
72    /// Append extra instructions after Mermaid's system prompt for this invocation
73    #[arg(long, global = true)]
74    pub append_system_prompt: Option<String>,
75
76    /// Append extra instructions from a file after Mermaid's system prompt
77    #[arg(long, value_name = "FILE", global = true)]
78    pub append_system_prompt_file: Option<PathBuf>,
79
80    /// Override a config value: repeatable `-c key.path=value` applied on top
81    /// of the config file (value parsed as TOML, so `true`/`3`/`"x"` keep their
82    /// types; a bare word is a string). Example:
83    /// `-c default_model.max_tokens=8192 -c safety.mode=full_access`.
84    #[arg(short = 'c', long = "config", value_name = "KEY=VALUE", global = true)]
85    pub config_overrides: Vec<String>,
86
87    /// Deny web-tool egress on every platform and network access for model-run
88    /// shell commands where an OS sandbox is available. Equivalent to
89    /// `-c safety.network=deny`.
90    #[arg(long, global = true)]
91    pub no_network: bool,
92
93    /// Confine model-run shell commands' writes to the project directory (plus
94    /// the system temp directory and `/dev`) this session. Linux Landlock,
95    /// best-effort; a no-op on other platforms and pre-5.13 kernels. Equivalent
96    /// to `-c safety.filesystem=project`.
97    #[arg(long, global = true)]
98    pub confine_fs: bool,
99
100    /// Full OS sandbox for model-run shell commands: shorthand for
101    /// `--no-network --confine-fs`.
102    #[arg(long, global = true)]
103    pub sandbox: bool,
104
105    /// Apply a named config overlay from `[profiles.<name>]` in your user
106    /// config file for this invocation. Profile values beat the user file but
107    /// lose to a repo's project config and to `-c` overrides.
108    #[arg(long, value_name = "NAME", global = true)]
109    pub profile: Option<String>,
110
111    #[command(subcommand)]
112    pub command: Option<Commands>,
113}
114
115impl Cli {
116    /// Collect this invocation's config-shaped flags into the `Session`
117    /// config layer's inputs: the repeatable `-c` overrides plus the dedicated
118    /// flags (`--no-network`/`--confine-fs`/`--sandbox`, and `run`'s
119    /// `--max-tokens`/`--allow-untrusted-tools`). Prompt flags and
120    /// `--reasoning` stay outside the layer merge — see `apply_prompt_flags`.
121    #[must_use]
122    pub fn session_flags(&self) -> mermaid_domain::SessionFlags {
123        let (max_tokens, allow_untrusted_tools) = match &self.command {
124            Some(Commands::Run {
125                max_tokens,
126                allow_untrusted_tools,
127                ..
128            }) => (*max_tokens, *allow_untrusted_tools),
129            _ => (None, false),
130        };
131        mermaid_domain::SessionFlags {
132            overrides: self.config_overrides.clone(),
133            deny_network: self.no_network || self.sandbox,
134            confine_fs: self.confine_fs || self.sandbox,
135            max_tokens,
136            allow_untrusted_tools,
137            profile: self.profile.clone(),
138        }
139    }
140}
141
142const TOP_LEVEL_HELP_AFTER: &str = "\
143Common first run:
144  mermaid doctor                         Check model, tools, safety, and project readiness
145  mermaid                                Start the full-screen terminal coding agent
146  mermaid run \"inspect this repo\"        Run one prompt headlessly
147  mermaid self-test                      Run fast deterministic Mermaid self-tests
148
149Command groups:
150  Everyday: chat, run, doctor, status, list, self-test
151  Model/context: models, model-info, --model, --reasoning, --system-prompt*
152  Safety/recovery: approvals, approve, deny, checkpoints, restore
153  Integrations: add, remove, mcp, cloud-setup, plugin, pr
154  Advanced runtime: daemon, tasks, task, processes, logs, stop, restart, ports, pair";
155
156#[derive(Subcommand, Debug)]
157pub enum Commands {
158    /// Initialize configuration
159    Init,
160    /// List available models
161    List,
162    /// List model/provider capability records
163    Models,
164    /// Show static and cached capability info for a model id
165    ModelInfo {
166        /// Model id, e.g. `<provider>/<model>`
167        model: String,
168    },
169    /// Start a chat session (default)
170    Chat,
171    /// Show version information
172    Version,
173    /// Update Mermaid to the latest release
174    Update {
175        /// Only report whether an update is available; don't install it
176        #[arg(long)]
177        check: bool,
178        /// Reinstall even if already on the latest version
179        #[arg(long)]
180        force: bool,
181    },
182    /// Check status of dependencies and backends
183    Status,
184    /// Check first-run readiness and explain what Mermaid can do now
185    Doctor {
186        /// Output format (text, json, markdown)
187        #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
188        format: OutputFormat,
189    },
190    /// Write a local diagnostic bundle: doctor report, config summary
191    /// (names and booleans only), recent trace events, and the log tail.
192    /// Nothing is uploaded — the file stays on this machine.
193    Feedback {
194        /// Print to stdout instead of writing `mermaid-feedback-<ts>` in the
195        /// current directory
196        #[arg(long)]
197        stdout: bool,
198        /// Output format (markdown or json)
199        #[arg(short, long, value_enum, default_value_t = OutputFormat::Markdown)]
200        format: OutputFormat,
201    },
202    /// Run fast deterministic Mermaid self-tests
203    SelfTest {
204        /// Output format (text, json, markdown)
205        #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
206        format: OutputFormat,
207        /// Keep the temporary self-test workspace after the run
208        #[arg(long)]
209        keep_workspace: bool,
210    },
211    /// List durable runtime tasks
212    Tasks {
213        /// Maximum number of tasks to show
214        #[arg(short, long, default_value_t = 20)]
215        limit: usize,
216    },
217    /// Show one durable runtime task and its timeline
218    Task {
219        /// Task id
220        id: String,
221        /// Attach to the task's live event stream (daemon required): prints
222        /// NDJSON `RunEvent` lines to stdout and exits after the terminal
223        /// `result`. Works on queued tasks too (waits for the run to start).
224        #[arg(long)]
225        follow: bool,
226        /// Send a prompt to a RUNNING task (daemon required): the text lands
227        /// in the running agent's queue and it answers in a turn of its own,
228        /// as if you had typed it. Finished tasks take `mermaid run --resume`
229        /// instead.
230        #[arg(long, value_name = "TEXT")]
231        send: Option<String>,
232    },
233    /// List Mermaid-managed background processes
234    Processes {
235        /// Maximum number of processes to show
236        #[arg(short, long, default_value_t = 20)]
237        limit: usize,
238    },
239    /// Print a managed process log
240    Logs {
241        /// Process id from `mermaid processes`
242        id: String,
243    },
244    /// Stop a managed process
245    Stop {
246        /// Process id from `mermaid processes`
247        id: String,
248    },
249    /// Restart a managed process
250    Restart {
251        /// Process id from `mermaid processes`
252        id: String,
253    },
254    /// Open a URL, file, or managed process URL
255    Open {
256        /// URL, path, or process id
257        target: String,
258    },
259    /// Show listening TCP ports
260    Ports,
261    /// List pending approvals
262    Approvals,
263    /// Approve a pending approval record
264    Approve {
265        /// Approval id
266        id: String,
267    },
268    /// Deny a pending approval record
269    Deny {
270        /// Approval id
271        id: String,
272    },
273    /// Cancel a queued or running daemon task
274    Cancel {
275        /// Task id from `mermaid tasks`
276        id: String,
277    },
278    /// List recent persisted tool runs
279    ToolRuns {
280        /// Maximum number of tool runs to show
281        #[arg(short, long, default_value_t = 20)]
282        limit: usize,
283    },
284    /// List checkpoints
285    Checkpoints {
286        /// Maximum number of checkpoints to show
287        #[arg(short, long, default_value_t = 20)]
288        limit: usize,
289    },
290    /// Restore a checkpoint by id
291    Restore {
292        /// Checkpoint id
293        id: String,
294        /// Skip the confirmation prompt (required for non-interactive use)
295        #[arg(short, long)]
296        force: bool,
297    },
298    /// Manage Mermaid plugin bundles
299    Plugin {
300        #[command(subcommand)]
301        command: PluginCommand,
302    },
303    /// Manage Mermaid's Linux background service
304    Daemon {
305        #[command(subcommand)]
306        command: DaemonCommand,
307    },
308    /// Manage remote pairing tokens
309    Pair {
310        #[command(subcommand)]
311        command: PairCommand,
312    },
313    /// Internal self-QA commands. Hidden from normal help output.
314    #[command(hide = true)]
315    Qa {
316        #[command(subcommand)]
317        command: QaCommand,
318    },
319    /// Add an MCP server (e.g., mermaid add context7)
320    Add {
321        /// MCP server name (registry key, or a label when using --command)
322        name: String,
323        /// Skip the confirmation prompt before fetching and running a package
324        /// that is not in the built-in registry (for scripted/CI use). Without
325        /// this, adding an unknown package fails closed when there is no TTY.
326        #[arg(long)]
327        yes: bool,
328        /// Register a raw command server instead of resolving from the registry:
329        /// the executable to run (e.g. `uvx`, `node`, `/path/to/mcp-server`).
330        #[arg(long)]
331        command: Option<String>,
332        /// Argument for --command, repeatable and order-preserving
333        /// (e.g. `--arg mcp-server-git --arg --repository --arg .`).
334        #[arg(long = "arg")]
335        arg: Vec<String>,
336        /// Environment variable for --command: repeatable `KEY=VALUE`.
337        #[arg(long = "env")]
338        env: Vec<String>,
339        /// Register a remote Streamable HTTP server instead: the MCP endpoint
340        /// URL (`https://...`, or `http://` to localhost only).
341        #[arg(long, conflicts_with_all = ["command", "arg", "env"])]
342        url: Option<String>,
343        /// Literal HTTP header for --url: repeatable `'Name: Value'`
344        /// (e.g. `--header 'Authorization: Bearer TOKEN'`).
345        #[arg(long = "header", requires = "url")]
346        header: Vec<String>,
347        /// HTTP header for --url whose value is read from an environment
348        /// variable at request time: repeatable `Header=ENV_VAR`, so the
349        /// secret never lands in config.toml.
350        #[arg(long = "env-header", requires = "url")]
351        env_header: Vec<String>,
352    },
353    /// Remove a configured MCP server
354    Remove {
355        /// MCP server name to remove
356        name: String,
357    },
358    /// List configured MCP servers
359    Mcp,
360    /// Create a pull/merge request from the current branch via the host CLI
361    /// (`gh` for GitHub, `glab` for GitLab)
362    Pr {
363        #[command(subcommand)]
364        command: PrCommand,
365    },
366    /// Configure Ollama Cloud API key (interactive prompt). Run this
367    /// from your shell before starting mermaid — it reads stdin and
368    /// doesn't work from inside the TUI.
369    CloudSetup,
370    /// Store a provider API key in the OS keyring, or list key status
371    Login {
372        /// Provider name (e.g. groq, anthropic, ollama). Omit to list every
373        /// provider's key status.
374        provider: Option<String>,
375    },
376    /// Remove a provider API key from the OS keyring
377    Logout {
378        /// Provider name whose stored key to remove
379        provider: String,
380    },
381    /// Run a single prompt non-interactively
382    Run {
383        /// Prompt to execute. Omit or pass `-` to read it from piped stdin;
384        /// piped stdin alongside a prompt is appended as a fenced block.
385        prompt: Option<String>,
386
387        /// Output format (text, json, markdown, ndjson)
388        #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
389        format: OutputFormat,
390
391        /// Maximum tokens to generate
392        #[arg(long)]
393        max_tokens: Option<usize>,
394
395        /// Don't execute agent actions (dry run)
396        #[arg(long)]
397        no_execute: bool,
398
399        /// Allow non-replayable tools (web/mcp/subagent/computer-use) to run on
400        /// an `Ask` decision in this headless run. Off by default — `ask` mode
401        /// otherwise refuses them when there's no approval UI.
402        #[arg(long)]
403        allow_untrusted_tools: bool,
404
405        /// JSON Schema file the final answer must conform to. The agentic
406        /// loop runs normally; one extra formatting turn (no tools, native
407        /// constrained output where the provider supports it) reshapes the
408        /// final answer, validated client-side. Failures are reported in the
409        /// run's errors; the text answer is still returned.
410        #[arg(long, value_name = "FILE")]
411        output_schema: Option<PathBuf>,
412
413        /// Run in plan mode: the agent explores read-only and produces a plan
414        /// file (`.mermaid/plans/`) instead of making changes. With no
415        /// approval UI the plan is accepted as the run's deliverable but
416        /// implementation does NOT start.
417        #[arg(long)]
418        plan: bool,
419
420        /// With --plan: the moment the plan is presented, accept it and
421        /// continue straight into implementation in the same run.
422        #[arg(long, requires = "plan")]
423        plan_autoaccept: bool,
424    },
425}
426
427#[derive(Subcommand, Debug)]
428pub enum PluginCommand {
429    /// Install a plugin from a local path
430    Install {
431        /// Path containing plugin.toml
432        path: PathBuf,
433    },
434    /// List installed plugins
435    List,
436    /// Enable an installed plugin
437    Enable {
438        /// Plugin id or name
439        id: String,
440    },
441    /// Disable an installed plugin
442    Disable {
443        /// Plugin id or name
444        id: String,
445    },
446    /// Validate a plugin manifest without installing
447    Audit {
448        /// Path containing plugin.toml
449        path: PathBuf,
450    },
451}
452
453#[derive(Subcommand, Debug)]
454pub enum PairCommand {
455    /// Create a pairing token (the secret is printed once)
456    Create {
457        /// Human label for the remote client
458        #[arg(long)]
459        label: Option<String>,
460        /// Days until the token expires (0 = never expires; default 30)
461        #[arg(long)]
462        ttl_days: Option<i64>,
463    },
464    /// List pairing tokens (id, label, created, expiry, status)
465    List,
466    /// Revoke a pairing token by id
467    Revoke {
468        /// Pairing token id
469        id: String,
470    },
471}
472
473/// Which Git hosting provider's CLI to drive.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
475pub enum GitHost {
476    /// GitHub, via the `gh` CLI.
477    Github,
478    /// GitLab, via the `glab` CLI.
479    Gitlab,
480}
481
482#[derive(Subcommand, Debug)]
483pub enum PrCommand {
484    /// Create a PR/MR from the current branch. Wraps `gh pr create` /
485    /// `glab mr create`, reusing their existing authentication.
486    Create {
487        /// PR/MR title. Omitted → filled from the branch's commits.
488        #[arg(short, long)]
489        title: Option<String>,
490        /// PR/MR body text.
491        #[arg(short, long)]
492        body: Option<String>,
493        /// Read the body from a file (e.g. a saved review summary).
494        #[arg(long, value_name = "FILE", conflicts_with = "body")]
495        summary: Option<PathBuf>,
496        /// Base branch to merge into (defaults to the host's default branch).
497        #[arg(long)]
498        base: Option<String>,
499        /// Open as a draft.
500        #[arg(long)]
501        draft: bool,
502        /// Open the creation page in a browser instead of creating directly.
503        #[arg(long)]
504        web: bool,
505        /// Force a provider instead of auto-detecting from the `origin` remote.
506        #[arg(long, value_enum)]
507        provider: Option<GitHost>,
508    },
509}
510
511#[derive(Subcommand, Debug)]
512pub enum DaemonCommand {
513    /// Install the systemd user service for this user
514    Install {
515        /// Start and enable the service after writing the unit
516        #[arg(long)]
517        start: bool,
518        /// Overwrite an existing Mermaid service unit
519        #[arg(long)]
520        force: bool,
521    },
522    /// Remove the systemd user service for this user
523    Uninstall,
524    /// Start the background user service
525    Start,
526    /// Stop the background user service
527    Stop,
528    /// Restart the background user service
529    Restart,
530    /// Show background service status
531    Status,
532    /// Show background service logs
533    Logs {
534        /// Follow log output
535        #[arg(short, long)]
536        follow: bool,
537        /// Number of log lines to show before following/exiting
538        #[arg(short = 'n', long, default_value_t = 100)]
539        lines: usize,
540    },
541    /// Print the generated service unit without installing it
542    PrintUnit,
543}
544
545#[derive(Subcommand, Debug)]
546pub enum QaCommand {
547    /// Deterministically exercise context compaction without a real model.
548    CompactSmoke {
549        /// Number of synthetic user/assistant turns to seed
550        #[arg(long, default_value_t = 6)]
551        turns: usize,
552        /// Output format
553        #[arg(short, long, value_enum, default_value_t = OutputFormat::Json)]
554        format: OutputFormat,
555    },
556}
557
558#[derive(Debug, Clone, Copy, ValueEnum)]
559pub enum OutputFormat {
560    /// Plain text output
561    Text,
562    /// JSON structured output (a single object)
563    Json,
564    /// Markdown formatted output
565    Markdown,
566    /// Streaming newline-delimited JSON (one `RunEvent` per line) — the
567    /// scripting / SDK surface for `mermaid run`.
568    Ndjson,
569}
570
571/// Reject an empty or whitespace-only `run` prompt at parse time, so
572/// Resolve the effective `run` prompt from the CLI arg and any piped stdin.
573/// `stdin` is `Some(text)` when stdin was piped (non-TTY), else `None`. Pure so
574/// it can be unit-tested; the caller does the terminal check + stdin read.
575///
576/// - No prompt (or `-`): use stdin; error if nothing was piped.
577/// - A prompt plus piped stdin: append the stdin as a fenced block.
578/// - A prompt alone: use it. Empty results are rejected with a usage error.
579///
580/// # Errors
581///
582/// The usage message to print: no prompt (or `-`) with nothing piped, and an
583/// explicitly empty or whitespace-only prompt. Whitespace-only piped stdin
584/// counts as nothing piped.
585pub fn resolve_run_prompt(prompt: Option<&str>, stdin: Option<String>) -> Result<String, String> {
586    let piped = stdin
587        .map(|s| s.trim().to_string())
588        .filter(|s| !s.is_empty());
589    match prompt.map(str::trim) {
590        None | Some("-") => {
591            piped.ok_or_else(|| "no prompt given: pass a prompt or pipe text on stdin".to_string())
592        },
593        Some("") => Err("prompt must not be empty".to_string()),
594        Some(text) => Ok(match piped {
595            Some(extra) => format!("{text}\n\n```\n{extra}\n```"),
596            None => text.to_string(),
597        }),
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use clap::Parser;
605
606    #[test]
607    fn resolve_run_prompt_reads_stdin_when_dash_or_missing() {
608        assert_eq!(
609            resolve_run_prompt(None, Some("piped work".to_string())).unwrap(),
610            "piped work"
611        );
612        assert_eq!(
613            resolve_run_prompt(Some("-"), Some("  piped  ".to_string())).unwrap(),
614            "piped"
615        );
616    }
617
618    #[test]
619    fn resolve_run_prompt_errors_without_prompt_or_stdin() {
620        assert!(resolve_run_prompt(None, None).is_err());
621        assert!(resolve_run_prompt(Some("-"), None).is_err());
622        assert!(resolve_run_prompt(Some(""), None).is_err());
623        assert!(resolve_run_prompt(None, Some("   ".to_string())).is_err());
624    }
625
626    #[test]
627    fn resolve_run_prompt_appends_piped_stdin_to_explicit_prompt() {
628        let out = resolve_run_prompt(Some("summarize"), Some("file body".to_string())).unwrap();
629        assert!(out.starts_with("summarize"));
630        assert!(out.contains("file body"));
631    }
632
633    #[test]
634    fn cli_run_allows_missing_prompt_and_normal_prompt() {
635        // The prompt is optional now (stdin fallback); parsing succeeds with no
636        // positional, and emptiness is enforced later by `resolve_run_prompt`.
637        assert!(Cli::try_parse_from(["mermaid", "run"]).is_ok());
638        assert!(Cli::try_parse_from(["mermaid", "run", "do a thing"]).is_ok());
639    }
640
641    #[test]
642    fn cli_config_overrides_are_repeatable() {
643        let cli = Cli::try_parse_from(["mermaid", "-c", "a.b=1", "-c", "c=true", "run", "x"])
644            .expect("repeatable -c parses");
645        assert_eq!(cli.config_overrides, vec!["a.b=1", "c=true"]);
646    }
647
648    #[test]
649    fn cli_config_override_after_subcommand_is_global() {
650        let cli = Cli::try_parse_from(["mermaid", "run", "x", "-c", "c=true"])
651            .expect("global -c parses after the subcommand");
652        assert_eq!(cli.config_overrides, vec!["c=true"]);
653    }
654
655    #[test]
656    fn parses_login_and_logout() {
657        let cli = Cli::parse_from(["mermaid", "login"]);
658        assert!(matches!(
659            cli.command,
660            Some(Commands::Login { provider: None })
661        ));
662        let cli = Cli::parse_from(["mermaid", "login", "groq"]);
663        assert!(matches!(cli.command, Some(Commands::Login { provider: Some(p) }) if p == "groq"));
664        let cli = Cli::parse_from(["mermaid", "logout", "groq"]);
665        assert!(matches!(cli.command, Some(Commands::Logout { provider }) if provider == "groq"));
666    }
667
668    #[test]
669    fn parses_task_follow() {
670        let cli = Cli::parse_from(["mermaid", "task", "t1", "--follow"]);
671        assert!(matches!(cli.command, Some(Commands::Task { id, follow: true, .. }) if id == "t1"));
672        let cli = Cli::parse_from(["mermaid", "task", "t1"]);
673        assert!(
674            matches!(cli.command, Some(Commands::Task { id, follow: false, .. }) if id == "t1")
675        );
676    }
677
678    #[test]
679    fn session_flags_collect_sandbox_and_run_flags() {
680        let cli = Cli::try_parse_from([
681            "mermaid",
682            "--sandbox",
683            "-c",
684            "a=1",
685            "run",
686            "x",
687            "--max-tokens",
688            "512",
689            "--allow-untrusted-tools",
690        ])
691        .expect("parses");
692        let flags = cli.session_flags();
693        assert!(flags.deny_network && flags.confine_fs);
694        assert_eq!(flags.max_tokens, Some(512));
695        assert!(flags.allow_untrusted_tools);
696        assert_eq!(flags.overrides, vec!["a=1"]);
697
698        // Without `run`, the run-scoped flags stay unset.
699        let cli = Cli::try_parse_from(["mermaid", "--no-network"]).expect("parses");
700        let flags = cli.session_flags();
701        assert!(flags.deny_network && !flags.confine_fs);
702        assert_eq!(flags.max_tokens, None);
703        assert!(!flags.allow_untrusted_tools);
704    }
705
706    #[test]
707    fn add_url_conflicts_with_command_and_requires_url_for_headers() {
708        // Remote registration parses with its header flags...
709        let cli = Cli::try_parse_from([
710            "mermaid",
711            "add",
712            "gh",
713            "--url",
714            "https://example.com/mcp",
715            "--header",
716            "X-Token: abc",
717            "--env-header",
718            "Authorization=TOKEN_VAR",
719        ])
720        .expect("parses");
721        match cli.command {
722            Some(Commands::Add {
723                url,
724                header,
725                env_header,
726                ..
727            }) => {
728                assert_eq!(url.as_deref(), Some("https://example.com/mcp"));
729                assert_eq!(header, vec!["X-Token: abc".to_string()]);
730                assert_eq!(env_header, vec!["Authorization=TOKEN_VAR".to_string()]);
731            },
732            other => panic!("expected Add, got {other:?}"),
733        }
734        // ...but --url and --command are mutually exclusive registration paths,
735        assert!(
736            Cli::try_parse_from([
737                "mermaid",
738                "add",
739                "gh",
740                "--url",
741                "https://example.com/mcp",
742                "--command",
743                "npx"
744            ])
745            .is_err(),
746            "--url must conflict with --command"
747        );
748        // and the header flags only make sense with --url.
749        assert!(
750            Cli::try_parse_from(["mermaid", "add", "gh", "--header", "X: y"]).is_err(),
751            "--header must require --url"
752        );
753    }
754
755    #[test]
756    fn resume_and_continue_flags_parse_and_conflict() {
757        // Claude Code parity: `--resume` (picker), `--resume <id>` (direct),
758        // and `--continue` (last) all exist; resume/continue are mutually
759        // exclusive. The old `--sessions` is gone.
760        let resume = Cli::try_parse_from(["mermaid", "--resume"]).expect("--resume parses");
761        assert_eq!(resume.resume, Some(None));
762        assert!(!resume.continue_session);
763        let direct = Cli::try_parse_from(["mermaid", "--resume", "20260709_120000_000"])
764            .expect("--resume <id> parses");
765        assert_eq!(direct.resume, Some(Some("20260709_120000_000".to_string())));
766        let absent = Cli::try_parse_from(["mermaid"]).expect("no flag parses");
767        assert_eq!(absent.resume, None);
768        let cont = Cli::try_parse_from(["mermaid", "--continue"]).expect("--continue parses");
769        assert!(cont.continue_session && cont.resume.is_none());
770        assert!(
771            Cli::try_parse_from(["mermaid", "--resume", "--continue"]).is_err(),
772            "--resume and --continue must conflict"
773        );
774        assert!(
775            Cli::try_parse_from(["mermaid", "--sessions"]).is_err(),
776            "the old --sessions flag is renamed to --resume"
777        );
778    }
779}