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}