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