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