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}