Skip to main content

mermaid_cli/cli/
args.rs

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