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