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