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 /// Run in plan mode: the agent explores read-only and produces a plan
411 /// file (`.mermaid/plans/`) instead of making changes. With no
412 /// approval UI the plan is accepted as the run's deliverable but
413 /// implementation does NOT start.
414 #[arg(long)]
415 plan: bool,
416
417 /// With --plan: the moment the plan is presented, accept it and
418 /// continue straight into implementation in the same run.
419 #[arg(long, requires = "plan")]
420 plan_autoaccept: bool,
421 },
422}
423
424#[derive(Subcommand, Debug)]
425pub enum PluginCommand {
426 /// Install a plugin from a local path
427 Install {
428 /// Path containing plugin.toml
429 path: PathBuf,
430 },
431 /// List installed plugins
432 List,
433 /// Enable an installed plugin
434 Enable {
435 /// Plugin id or name
436 id: String,
437 },
438 /// Disable an installed plugin
439 Disable {
440 /// Plugin id or name
441 id: String,
442 },
443 /// Validate a plugin manifest without installing
444 Audit {
445 /// Path containing plugin.toml
446 path: PathBuf,
447 },
448}
449
450#[derive(Subcommand, Debug)]
451pub enum PairCommand {
452 /// Create a pairing token (the secret is printed once)
453 Create {
454 /// Human label for the remote client
455 #[arg(long)]
456 label: Option<String>,
457 /// Days until the token expires (0 = never expires; default 30)
458 #[arg(long)]
459 ttl_days: Option<i64>,
460 },
461 /// List pairing tokens (id, label, created, expiry, status)
462 List,
463 /// Revoke a pairing token by id
464 Revoke {
465 /// Pairing token id
466 id: String,
467 },
468}
469
470#[derive(Subcommand, Debug)]
471pub enum DaemonCommand {
472 /// Install the systemd user service for this user
473 Install {
474 /// Start and enable the service after writing the unit
475 #[arg(long)]
476 start: bool,
477 /// Overwrite an existing Mermaid service unit
478 #[arg(long)]
479 force: bool,
480 },
481 /// Remove the systemd user service for this user
482 Uninstall,
483 /// Start the background user service
484 Start,
485 /// Stop the background user service
486 Stop,
487 /// Restart the background user service
488 Restart,
489 /// Show background service status
490 Status,
491 /// Show background service logs
492 Logs {
493 /// Follow log output
494 #[arg(short, long)]
495 follow: bool,
496 /// Number of log lines to show before following/exiting
497 #[arg(short = 'n', long, default_value_t = 100)]
498 lines: usize,
499 },
500 /// Print the generated service unit without installing it
501 PrintUnit,
502}
503
504#[derive(Subcommand, Debug)]
505pub enum QaCommand {
506 /// Deterministically exercise context compaction without a real model.
507 CompactSmoke {
508 /// Number of synthetic user/assistant turns to seed
509 #[arg(long, default_value_t = 6)]
510 turns: usize,
511 /// Output format
512 #[arg(short, long, value_enum, default_value_t = OutputFormat::Json)]
513 format: OutputFormat,
514 },
515}
516
517#[derive(Debug, Clone, Copy, ValueEnum)]
518pub enum OutputFormat {
519 /// Plain text output
520 Text,
521 /// JSON structured output (a single object)
522 Json,
523 /// Markdown formatted output
524 Markdown,
525 /// Streaming newline-delimited JSON (one `RunEvent` per line) — the
526 /// scripting / SDK surface for `mermaid run`.
527 Ndjson,
528}
529
530/// Reject an empty or whitespace-only `run` prompt at parse time, so
531/// Resolve the effective `run` prompt from the CLI arg and any piped stdin.
532/// `stdin` is `Some(text)` when stdin was piped (non-TTY), else `None`. Pure so
533/// it can be unit-tested; the caller does the terminal check + stdin read.
534///
535/// - No prompt (or `-`): use stdin; error if nothing was piped.
536/// - A prompt plus piped stdin: append the stdin as a fenced block.
537/// - A prompt alone: use it. Empty results are rejected with a usage error.
538///
539/// # Errors
540///
541/// The usage message to print: no prompt (or `-`) with nothing piped, and an
542/// explicitly empty or whitespace-only prompt. Whitespace-only piped stdin
543/// counts as nothing piped.
544pub fn resolve_run_prompt(prompt: Option<&str>, stdin: Option<String>) -> Result<String, String> {
545 let piped = stdin
546 .map(|s| s.trim().to_string())
547 .filter(|s| !s.is_empty());
548 match prompt.map(str::trim) {
549 None | Some("-") => {
550 piped.ok_or_else(|| "no prompt given: pass a prompt or pipe text on stdin".to_string())
551 },
552 Some("") => Err("prompt must not be empty".to_string()),
553 Some(text) => Ok(match piped {
554 Some(extra) => format!("{text}\n\n```\n{extra}\n```"),
555 None => text.to_string(),
556 }),
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use clap::Parser;
564
565 #[test]
566 fn resolve_run_prompt_reads_stdin_when_dash_or_missing() {
567 assert_eq!(
568 resolve_run_prompt(None, Some("piped work".to_string())).unwrap(),
569 "piped work"
570 );
571 assert_eq!(
572 resolve_run_prompt(Some("-"), Some(" piped ".to_string())).unwrap(),
573 "piped"
574 );
575 }
576
577 #[test]
578 fn resolve_run_prompt_errors_without_prompt_or_stdin() {
579 assert!(resolve_run_prompt(None, None).is_err());
580 assert!(resolve_run_prompt(Some("-"), None).is_err());
581 assert!(resolve_run_prompt(Some(""), None).is_err());
582 assert!(resolve_run_prompt(None, Some(" ".to_string())).is_err());
583 }
584
585 #[test]
586 fn resolve_run_prompt_appends_piped_stdin_to_explicit_prompt() {
587 let out = resolve_run_prompt(Some("summarize"), Some("file body".to_string())).unwrap();
588 assert!(out.starts_with("summarize"));
589 assert!(out.contains("file body"));
590 }
591
592 #[test]
593 fn cli_run_allows_missing_prompt_and_normal_prompt() {
594 // The prompt is optional now (stdin fallback); parsing succeeds with no
595 // positional, and emptiness is enforced later by `resolve_run_prompt`.
596 assert!(Cli::try_parse_from(["mermaid", "run"]).is_ok());
597 assert!(Cli::try_parse_from(["mermaid", "run", "do a thing"]).is_ok());
598 }
599
600 #[test]
601 fn cli_config_overrides_are_repeatable() {
602 let cli = Cli::try_parse_from(["mermaid", "-c", "a.b=1", "-c", "c=true", "run", "x"])
603 .expect("repeatable -c parses");
604 assert_eq!(cli.config_overrides, vec!["a.b=1", "c=true"]);
605 }
606
607 #[test]
608 fn cli_config_override_after_subcommand_is_global() {
609 let cli = Cli::try_parse_from(["mermaid", "run", "x", "-c", "c=true"])
610 .expect("global -c parses after the subcommand");
611 assert_eq!(cli.config_overrides, vec!["c=true"]);
612 }
613
614 #[test]
615 fn parses_login_and_logout() {
616 let cli = Cli::parse_from(["mermaid", "login"]);
617 assert!(matches!(
618 cli.command,
619 Some(Commands::Login { provider: None })
620 ));
621 let cli = Cli::parse_from(["mermaid", "login", "groq"]);
622 assert!(matches!(cli.command, Some(Commands::Login { provider: Some(p) }) if p == "groq"));
623 let cli = Cli::parse_from(["mermaid", "logout", "groq"]);
624 assert!(matches!(cli.command, Some(Commands::Logout { provider }) if provider == "groq"));
625 }
626
627 #[test]
628 fn parses_task_follow() {
629 let cli = Cli::parse_from(["mermaid", "task", "t1", "--follow"]);
630 assert!(matches!(cli.command, Some(Commands::Task { id, follow: true, .. }) if id == "t1"));
631 let cli = Cli::parse_from(["mermaid", "task", "t1"]);
632 assert!(
633 matches!(cli.command, Some(Commands::Task { id, follow: false, .. }) if id == "t1")
634 );
635 }
636
637 #[test]
638 fn session_flags_collect_sandbox_and_run_flags() {
639 let cli = Cli::try_parse_from([
640 "mermaid",
641 "--sandbox",
642 "-c",
643 "a=1",
644 "run",
645 "x",
646 "--max-tokens",
647 "512",
648 "--allow-untrusted-tools",
649 ])
650 .expect("parses");
651 let flags = cli.session_flags();
652 assert!(flags.deny_network && flags.confine_fs);
653 assert_eq!(flags.max_tokens, Some(512));
654 assert!(flags.allow_untrusted_tools);
655 assert_eq!(flags.overrides, vec!["a=1"]);
656
657 // Without `run`, the run-scoped flags stay unset.
658 let cli = Cli::try_parse_from(["mermaid", "--no-network"]).expect("parses");
659 let flags = cli.session_flags();
660 assert!(flags.deny_network && !flags.confine_fs);
661 assert_eq!(flags.max_tokens, None);
662 assert!(!flags.allow_untrusted_tools);
663 }
664
665 #[test]
666 fn output_style_flag_reaches_the_session_layer() {
667 let cli = Cli::try_parse_from(["mermaid", "--output-style", "concise"]).expect("parses");
668 assert_eq!(cli.output_style.as_deref(), Some("concise"));
669 assert_eq!(cli.session_flags().output_style.as_deref(), Some("concise"));
670 let cli = Cli::try_parse_from(["mermaid"]).expect("parses");
671 assert_eq!(cli.session_flags().output_style, None);
672 }
673
674 #[test]
675 fn add_url_conflicts_with_command_and_requires_url_for_headers() {
676 // Remote registration parses with its header flags...
677 let cli = Cli::try_parse_from([
678 "mermaid",
679 "add",
680 "gh",
681 "--url",
682 "https://example.com/mcp",
683 "--header",
684 "X-Token: abc",
685 "--env-header",
686 "Authorization=TOKEN_VAR",
687 ])
688 .expect("parses");
689 match cli.command {
690 Some(Commands::Add {
691 url,
692 header,
693 env_header,
694 ..
695 }) => {
696 assert_eq!(url.as_deref(), Some("https://example.com/mcp"));
697 assert_eq!(header, vec!["X-Token: abc".to_string()]);
698 assert_eq!(env_header, vec!["Authorization=TOKEN_VAR".to_string()]);
699 },
700 other => panic!("expected Add, got {other:?}"),
701 }
702 // ...but --url and --command are mutually exclusive registration paths,
703 assert!(
704 Cli::try_parse_from([
705 "mermaid",
706 "add",
707 "gh",
708 "--url",
709 "https://example.com/mcp",
710 "--command",
711 "npx"
712 ])
713 .is_err(),
714 "--url must conflict with --command"
715 );
716 // and the header flags only make sense with --url.
717 assert!(
718 Cli::try_parse_from(["mermaid", "add", "gh", "--header", "X: y"]).is_err(),
719 "--header must require --url"
720 );
721 }
722
723 #[test]
724 fn resume_and_continue_flags_parse_and_conflict() {
725 // Claude Code parity: `--resume` (picker), `--resume <id>` (direct),
726 // and `--continue` (last) all exist; resume/continue are mutually
727 // exclusive. The old `--sessions` is gone.
728 let resume = Cli::try_parse_from(["mermaid", "--resume"]).expect("--resume parses");
729 assert_eq!(resume.resume, Some(None));
730 assert!(!resume.continue_session);
731 let direct = Cli::try_parse_from(["mermaid", "--resume", "20260709_120000_000"])
732 .expect("--resume <id> parses");
733 assert_eq!(direct.resume, Some(Some("20260709_120000_000".to_string())));
734 let absent = Cli::try_parse_from(["mermaid"]).expect("no flag parses");
735 assert_eq!(absent.resume, None);
736 let cont = Cli::try_parse_from(["mermaid", "--continue"]).expect("--continue parses");
737 assert!(cont.continue_session && cont.resume.is_none());
738 assert!(
739 Cli::try_parse_from(["mermaid", "--resume", "--continue"]).is_err(),
740 "--resume and --continue must conflict"
741 );
742 assert!(
743 Cli::try_parse_from(["mermaid", "--sessions"]).is_err(),
744 "the old --sessions flag is renamed to --resume"
745 );
746 }
747}