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 },
227 /// List Mermaid-managed background processes
228 Processes {
229 /// Maximum number of processes to show
230 #[arg(short, long, default_value_t = 20)]
231 limit: usize,
232 },
233 /// Print a managed process log
234 Logs {
235 /// Process id from `mermaid processes`
236 id: String,
237 },
238 /// Stop a managed process
239 Stop {
240 /// Process id from `mermaid processes`
241 id: String,
242 },
243 /// Restart a managed process
244 Restart {
245 /// Process id from `mermaid processes`
246 id: String,
247 },
248 /// Open a URL, file, or managed process URL
249 Open {
250 /// URL, path, or process id
251 target: String,
252 },
253 /// Show listening TCP ports
254 Ports,
255 /// List pending approvals
256 Approvals,
257 /// Approve a pending approval record
258 Approve {
259 /// Approval id
260 id: String,
261 },
262 /// Deny a pending approval record
263 Deny {
264 /// Approval id
265 id: String,
266 },
267 /// Cancel a queued or running daemon task
268 Cancel {
269 /// Task id from `mermaid tasks`
270 id: String,
271 },
272 /// List recent persisted tool runs
273 ToolRuns {
274 /// Maximum number of tool runs to show
275 #[arg(short, long, default_value_t = 20)]
276 limit: usize,
277 },
278 /// List checkpoints
279 Checkpoints {
280 /// Maximum number of checkpoints to show
281 #[arg(short, long, default_value_t = 20)]
282 limit: usize,
283 },
284 /// Restore a checkpoint by id
285 Restore {
286 /// Checkpoint id
287 id: String,
288 /// Skip the confirmation prompt (required for non-interactive use)
289 #[arg(short, long)]
290 force: bool,
291 },
292 /// Manage Mermaid plugin bundles
293 Plugin {
294 #[command(subcommand)]
295 command: PluginCommand,
296 },
297 /// Manage Mermaid's Linux background service
298 Daemon {
299 #[command(subcommand)]
300 command: DaemonCommand,
301 },
302 /// Manage remote pairing tokens
303 Pair {
304 #[command(subcommand)]
305 command: PairCommand,
306 },
307 /// Internal self-QA commands. Hidden from normal help output.
308 #[command(hide = true)]
309 Qa {
310 #[command(subcommand)]
311 command: QaCommand,
312 },
313 /// Add an MCP server (e.g., mermaid add context7)
314 Add {
315 /// MCP server name (registry key, or a label when using --command)
316 name: String,
317 /// Skip the confirmation prompt before fetching and running a package
318 /// that is not in the built-in registry (for scripted/CI use). Without
319 /// this, adding an unknown package fails closed when there is no TTY.
320 #[arg(long)]
321 yes: bool,
322 /// Register a raw command server instead of resolving from the registry:
323 /// the executable to run (e.g. `uvx`, `node`, `/path/to/mcp-server`).
324 #[arg(long)]
325 command: Option<String>,
326 /// Argument for --command, repeatable and order-preserving
327 /// (e.g. `--arg mcp-server-git --arg --repository --arg .`).
328 #[arg(long = "arg")]
329 arg: Vec<String>,
330 /// Environment variable for --command: repeatable `KEY=VALUE`.
331 #[arg(long = "env")]
332 env: Vec<String>,
333 /// Register a remote Streamable HTTP server instead: the MCP endpoint
334 /// URL (`https://...`, or `http://` to localhost only).
335 #[arg(long, conflicts_with_all = ["command", "arg", "env"])]
336 url: Option<String>,
337 /// Literal HTTP header for --url: repeatable `'Name: Value'`
338 /// (e.g. `--header 'Authorization: Bearer TOKEN'`).
339 #[arg(long = "header", requires = "url")]
340 header: Vec<String>,
341 /// HTTP header for --url whose value is read from an environment
342 /// variable at request time: repeatable `Header=ENV_VAR`, so the
343 /// secret never lands in config.toml.
344 #[arg(long = "env-header", requires = "url")]
345 env_header: Vec<String>,
346 },
347 /// Remove a configured MCP server
348 Remove {
349 /// MCP server name to remove
350 name: String,
351 },
352 /// List configured MCP servers
353 Mcp,
354 /// Create a pull/merge request from the current branch via the host CLI
355 /// (`gh` for GitHub, `glab` for GitLab)
356 Pr {
357 #[command(subcommand)]
358 command: PrCommand,
359 },
360 /// Configure Ollama Cloud API key (interactive prompt). Run this
361 /// from your shell before starting mermaid — it reads stdin and
362 /// doesn't work from inside the TUI.
363 CloudSetup,
364 /// Store a provider API key in the OS keyring, or list key status
365 Login {
366 /// Provider name (e.g. groq, anthropic, ollama). Omit to list every
367 /// provider's key status.
368 provider: Option<String>,
369 },
370 /// Remove a provider API key from the OS keyring
371 Logout {
372 /// Provider name whose stored key to remove
373 provider: String,
374 },
375 /// Run a single prompt non-interactively
376 Run {
377 /// Prompt to execute. Omit or pass `-` to read it from piped stdin;
378 /// piped stdin alongside a prompt is appended as a fenced block.
379 prompt: Option<String>,
380
381 /// Output format (text, json, markdown, ndjson)
382 #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
383 format: OutputFormat,
384
385 /// Maximum tokens to generate
386 #[arg(long)]
387 max_tokens: Option<usize>,
388
389 /// Don't execute agent actions (dry run)
390 #[arg(long)]
391 no_execute: bool,
392
393 /// Allow non-replayable tools (web/mcp/subagent/computer-use) to run on
394 /// an `Ask` decision in this headless run. Off by default — `ask` mode
395 /// otherwise refuses them when there's no approval UI.
396 #[arg(long)]
397 allow_untrusted_tools: bool,
398
399 /// JSON Schema file the final answer must conform to. The agentic
400 /// loop runs normally; one extra formatting turn (no tools, native
401 /// constrained output where the provider supports it) reshapes the
402 /// final answer, validated client-side. Failures are reported in the
403 /// run's errors; the text answer is still returned.
404 #[arg(long, value_name = "FILE")]
405 output_schema: Option<PathBuf>,
406
407 /// Run in plan mode: the agent explores read-only and produces a plan
408 /// file (`.mermaid/plans/`) instead of making changes. With no
409 /// approval UI the plan is accepted as the run's deliverable but
410 /// implementation does NOT start.
411 #[arg(long)]
412 plan: bool,
413
414 /// With --plan: the moment the plan is presented, accept it and
415 /// continue straight into implementation in the same run.
416 #[arg(long, requires = "plan")]
417 plan_autoaccept: bool,
418 },
419}
420
421#[derive(Subcommand, Debug)]
422pub enum PluginCommand {
423 /// Install a plugin from a local path
424 Install {
425 /// Path containing plugin.toml
426 path: PathBuf,
427 },
428 /// List installed plugins
429 List,
430 /// Enable an installed plugin
431 Enable {
432 /// Plugin id or name
433 id: String,
434 },
435 /// Disable an installed plugin
436 Disable {
437 /// Plugin id or name
438 id: String,
439 },
440 /// Validate a plugin manifest without installing
441 Audit {
442 /// Path containing plugin.toml
443 path: PathBuf,
444 },
445}
446
447#[derive(Subcommand, Debug)]
448pub enum PairCommand {
449 /// Create a pairing token (the secret is printed once)
450 Create {
451 /// Human label for the remote client
452 #[arg(long)]
453 label: Option<String>,
454 /// Days until the token expires (0 = never expires; default 30)
455 #[arg(long)]
456 ttl_days: Option<i64>,
457 },
458 /// List pairing tokens (id, label, created, expiry, status)
459 List,
460 /// Revoke a pairing token by id
461 Revoke {
462 /// Pairing token id
463 id: String,
464 },
465}
466
467/// Which Git hosting provider's CLI to drive.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
469pub enum GitHost {
470 /// GitHub, via the `gh` CLI.
471 Github,
472 /// GitLab, via the `glab` CLI.
473 Gitlab,
474}
475
476#[derive(Subcommand, Debug)]
477pub enum PrCommand {
478 /// Create a PR/MR from the current branch. Wraps `gh pr create` /
479 /// `glab mr create`, reusing their existing authentication.
480 Create {
481 /// PR/MR title. Omitted → filled from the branch's commits.
482 #[arg(short, long)]
483 title: Option<String>,
484 /// PR/MR body text.
485 #[arg(short, long)]
486 body: Option<String>,
487 /// Read the body from a file (e.g. a saved review summary).
488 #[arg(long, value_name = "FILE", conflicts_with = "body")]
489 summary: Option<PathBuf>,
490 /// Base branch to merge into (defaults to the host's default branch).
491 #[arg(long)]
492 base: Option<String>,
493 /// Open as a draft.
494 #[arg(long)]
495 draft: bool,
496 /// Open the creation page in a browser instead of creating directly.
497 #[arg(long)]
498 web: bool,
499 /// Force a provider instead of auto-detecting from the `origin` remote.
500 #[arg(long, value_enum)]
501 provider: Option<GitHost>,
502 },
503}
504
505#[derive(Subcommand, Debug)]
506pub enum DaemonCommand {
507 /// Install the systemd user service for this user
508 Install {
509 /// Start and enable the service after writing the unit
510 #[arg(long)]
511 start: bool,
512 /// Overwrite an existing Mermaid service unit
513 #[arg(long)]
514 force: bool,
515 },
516 /// Remove the systemd user service for this user
517 Uninstall,
518 /// Start the background user service
519 Start,
520 /// Stop the background user service
521 Stop,
522 /// Restart the background user service
523 Restart,
524 /// Show background service status
525 Status,
526 /// Show background service logs
527 Logs {
528 /// Follow log output
529 #[arg(short, long)]
530 follow: bool,
531 /// Number of log lines to show before following/exiting
532 #[arg(short = 'n', long, default_value_t = 100)]
533 lines: usize,
534 },
535 /// Print the generated service unit without installing it
536 PrintUnit,
537}
538
539#[derive(Subcommand, Debug)]
540pub enum QaCommand {
541 /// Deterministically exercise context compaction without a real model.
542 CompactSmoke {
543 /// Number of synthetic user/assistant turns to seed
544 #[arg(long, default_value_t = 6)]
545 turns: usize,
546 /// Output format
547 #[arg(short, long, value_enum, default_value_t = OutputFormat::Json)]
548 format: OutputFormat,
549 },
550}
551
552#[derive(Debug, Clone, Copy, ValueEnum)]
553pub enum OutputFormat {
554 /// Plain text output
555 Text,
556 /// JSON structured output (a single object)
557 Json,
558 /// Markdown formatted output
559 Markdown,
560 /// Streaming newline-delimited JSON (one `RunEvent` per line) — the
561 /// scripting / SDK surface for `mermaid run`.
562 Ndjson,
563}
564
565/// Reject an empty or whitespace-only `run` prompt at parse time, so
566/// Resolve the effective `run` prompt from the CLI arg and any piped stdin.
567/// `stdin` is `Some(text)` when stdin was piped (non-TTY), else `None`. Pure so
568/// it can be unit-tested; the caller does the terminal check + stdin read.
569///
570/// - No prompt (or `-`): use stdin; error if nothing was piped.
571/// - A prompt plus piped stdin: append the stdin as a fenced block.
572/// - A prompt alone: use it. Empty results are rejected with a usage error.
573///
574/// # Errors
575///
576/// The usage message to print: no prompt (or `-`) with nothing piped, and an
577/// explicitly empty or whitespace-only prompt. Whitespace-only piped stdin
578/// counts as nothing piped.
579pub fn resolve_run_prompt(prompt: Option<&str>, stdin: Option<String>) -> Result<String, String> {
580 let piped = stdin
581 .map(|s| s.trim().to_string())
582 .filter(|s| !s.is_empty());
583 match prompt.map(str::trim) {
584 None | Some("-") => {
585 piped.ok_or_else(|| "no prompt given: pass a prompt or pipe text on stdin".to_string())
586 },
587 Some("") => Err("prompt must not be empty".to_string()),
588 Some(text) => Ok(match piped {
589 Some(extra) => format!("{text}\n\n```\n{extra}\n```"),
590 None => text.to_string(),
591 }),
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use clap::Parser;
599
600 #[test]
601 fn resolve_run_prompt_reads_stdin_when_dash_or_missing() {
602 assert_eq!(
603 resolve_run_prompt(None, Some("piped work".to_string())).unwrap(),
604 "piped work"
605 );
606 assert_eq!(
607 resolve_run_prompt(Some("-"), Some(" piped ".to_string())).unwrap(),
608 "piped"
609 );
610 }
611
612 #[test]
613 fn resolve_run_prompt_errors_without_prompt_or_stdin() {
614 assert!(resolve_run_prompt(None, None).is_err());
615 assert!(resolve_run_prompt(Some("-"), None).is_err());
616 assert!(resolve_run_prompt(Some(""), None).is_err());
617 assert!(resolve_run_prompt(None, Some(" ".to_string())).is_err());
618 }
619
620 #[test]
621 fn resolve_run_prompt_appends_piped_stdin_to_explicit_prompt() {
622 let out = resolve_run_prompt(Some("summarize"), Some("file body".to_string())).unwrap();
623 assert!(out.starts_with("summarize"));
624 assert!(out.contains("file body"));
625 }
626
627 #[test]
628 fn cli_run_allows_missing_prompt_and_normal_prompt() {
629 // The prompt is optional now (stdin fallback); parsing succeeds with no
630 // positional, and emptiness is enforced later by `resolve_run_prompt`.
631 assert!(Cli::try_parse_from(["mermaid", "run"]).is_ok());
632 assert!(Cli::try_parse_from(["mermaid", "run", "do a thing"]).is_ok());
633 }
634
635 #[test]
636 fn cli_config_overrides_are_repeatable() {
637 let cli = Cli::try_parse_from(["mermaid", "-c", "a.b=1", "-c", "c=true", "run", "x"])
638 .expect("repeatable -c parses");
639 assert_eq!(cli.config_overrides, vec!["a.b=1", "c=true"]);
640 }
641
642 #[test]
643 fn cli_config_override_after_subcommand_is_global() {
644 let cli = Cli::try_parse_from(["mermaid", "run", "x", "-c", "c=true"])
645 .expect("global -c parses after the subcommand");
646 assert_eq!(cli.config_overrides, vec!["c=true"]);
647 }
648
649 #[test]
650 fn parses_login_and_logout() {
651 let cli = Cli::parse_from(["mermaid", "login"]);
652 assert!(matches!(
653 cli.command,
654 Some(Commands::Login { provider: None })
655 ));
656 let cli = Cli::parse_from(["mermaid", "login", "groq"]);
657 assert!(matches!(cli.command, Some(Commands::Login { provider: Some(p) }) if p == "groq"));
658 let cli = Cli::parse_from(["mermaid", "logout", "groq"]);
659 assert!(matches!(cli.command, Some(Commands::Logout { provider }) if provider == "groq"));
660 }
661
662 #[test]
663 fn parses_task_follow() {
664 let cli = Cli::parse_from(["mermaid", "task", "t1", "--follow"]);
665 assert!(matches!(cli.command, Some(Commands::Task { id, follow: true }) if id == "t1"));
666 let cli = Cli::parse_from(["mermaid", "task", "t1"]);
667 assert!(matches!(cli.command, Some(Commands::Task { id, follow: false }) if id == "t1"));
668 }
669
670 #[test]
671 fn session_flags_collect_sandbox_and_run_flags() {
672 let cli = Cli::try_parse_from([
673 "mermaid",
674 "--sandbox",
675 "-c",
676 "a=1",
677 "run",
678 "x",
679 "--max-tokens",
680 "512",
681 "--allow-untrusted-tools",
682 ])
683 .expect("parses");
684 let flags = cli.session_flags();
685 assert!(flags.deny_network && flags.confine_fs);
686 assert_eq!(flags.max_tokens, Some(512));
687 assert!(flags.allow_untrusted_tools);
688 assert_eq!(flags.overrides, vec!["a=1"]);
689
690 // Without `run`, the run-scoped flags stay unset.
691 let cli = Cli::try_parse_from(["mermaid", "--no-network"]).expect("parses");
692 let flags = cli.session_flags();
693 assert!(flags.deny_network && !flags.confine_fs);
694 assert_eq!(flags.max_tokens, None);
695 assert!(!flags.allow_untrusted_tools);
696 }
697
698 #[test]
699 fn add_url_conflicts_with_command_and_requires_url_for_headers() {
700 // Remote registration parses with its header flags...
701 let cli = Cli::try_parse_from([
702 "mermaid",
703 "add",
704 "gh",
705 "--url",
706 "https://example.com/mcp",
707 "--header",
708 "X-Token: abc",
709 "--env-header",
710 "Authorization=TOKEN_VAR",
711 ])
712 .expect("parses");
713 match cli.command {
714 Some(Commands::Add {
715 url,
716 header,
717 env_header,
718 ..
719 }) => {
720 assert_eq!(url.as_deref(), Some("https://example.com/mcp"));
721 assert_eq!(header, vec!["X-Token: abc".to_string()]);
722 assert_eq!(env_header, vec!["Authorization=TOKEN_VAR".to_string()]);
723 },
724 other => panic!("expected Add, got {other:?}"),
725 }
726 // ...but --url and --command are mutually exclusive registration paths,
727 assert!(
728 Cli::try_parse_from([
729 "mermaid",
730 "add",
731 "gh",
732 "--url",
733 "https://example.com/mcp",
734 "--command",
735 "npx"
736 ])
737 .is_err(),
738 "--url must conflict with --command"
739 );
740 // and the header flags only make sense with --url.
741 assert!(
742 Cli::try_parse_from(["mermaid", "add", "gh", "--header", "X: y"]).is_err(),
743 "--header must require --url"
744 );
745 }
746
747 #[test]
748 fn resume_and_continue_flags_parse_and_conflict() {
749 // Claude Code parity: `--resume` (picker), `--resume <id>` (direct),
750 // and `--continue` (last) all exist; resume/continue are mutually
751 // exclusive. The old `--sessions` is gone.
752 let resume = Cli::try_parse_from(["mermaid", "--resume"]).expect("--resume parses");
753 assert_eq!(resume.resume, Some(None));
754 assert!(!resume.continue_session);
755 let direct = Cli::try_parse_from(["mermaid", "--resume", "20260709_120000_000"])
756 .expect("--resume <id> parses");
757 assert_eq!(direct.resume, Some(Some("20260709_120000_000".to_string())));
758 let absent = Cli::try_parse_from(["mermaid"]).expect("no flag parses");
759 assert_eq!(absent.resume, None);
760 let cont = Cli::try_parse_from(["mermaid", "--continue"]).expect("--continue parses");
761 assert!(cont.continue_session && cont.resume.is_none());
762 assert!(
763 Cli::try_parse_from(["mermaid", "--resume", "--continue"]).is_err(),
764 "--resume and --continue must conflict"
765 );
766 assert!(
767 Cli::try_parse_from(["mermaid", "--sessions"]).is_err(),
768 "the old --sessions flag is renamed to --resume"
769 );
770 }
771}