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