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 /// Pick a past conversation to resume from a searchable list (this
31 /// directory's sessions). Like `claude --resume`.
32 #[arg(long, conflicts_with = "continue_session")]
33 pub resume: bool,
34
35 /// Resume the most recent conversation in this directory. Like
36 /// `claude --continue`.
37 #[arg(long = "continue", conflicts_with = "resume")]
38 pub continue_session: bool,
39
40 /// Append every reducer `Msg` to a JSONL file at this path for
41 /// debugging / post-mortem replay. Interactive mode only.
42 #[arg(long, value_name = "FILE")]
43 pub record: Option<PathBuf>,
44
45 /// Replay a `--record` log through the pure reducer: print the
46 /// reconstructed session and a determinism verdict. Headless — no model
47 /// calls, no tool execution, no config reads (the log is self-contained).
48 #[arg(long, value_name = "FILE", conflicts_with = "record")]
49 pub replay: Option<PathBuf>,
50
51 /// Replace Mermaid's default system prompt for this invocation
52 #[arg(long, global = true, conflicts_with = "system_prompt_file")]
53 pub system_prompt: Option<String>,
54
55 /// Replace Mermaid's default system prompt with the contents of a file
56 #[arg(
57 long,
58 value_name = "FILE",
59 global = true,
60 conflicts_with = "system_prompt"
61 )]
62 pub system_prompt_file: Option<PathBuf>,
63
64 /// Append extra instructions after Mermaid's system prompt for this invocation
65 #[arg(long, global = true)]
66 pub append_system_prompt: Option<String>,
67
68 /// Append extra instructions from a file after Mermaid's system prompt
69 #[arg(long, value_name = "FILE", global = true)]
70 pub append_system_prompt_file: Option<PathBuf>,
71
72 #[command(subcommand)]
73 pub command: Option<Commands>,
74}
75
76const TOP_LEVEL_HELP_AFTER: &str = "\
77Common first run:
78 mermaid doctor Check model, tools, safety, and project readiness
79 mermaid Start the full-screen terminal coding agent
80 mermaid run \"inspect this repo\" Run one prompt headlessly
81 mermaid self-test Run fast deterministic Mermaid self-tests
82
83Command groups:
84 Everyday: chat, run, doctor, status, list, self-test
85 Model/context: models, model-info, --model, --reasoning, --system-prompt*
86 Safety/recovery: approvals, approve, deny, checkpoints, restore
87 Integrations: add, remove, mcp, cloud-setup, plugin, pr
88 Advanced runtime: daemon, tasks, task, processes, logs, stop, restart, ports, pair";
89
90#[derive(Subcommand, Debug)]
91pub enum Commands {
92 /// Initialize configuration
93 Init,
94 /// List available models
95 List,
96 /// List model/provider capability records
97 Models,
98 /// Show static and cached capability info for a model id
99 ModelInfo {
100 /// Model id, e.g. <provider>/<model>
101 model: String,
102 },
103 /// Start a chat session (default)
104 Chat,
105 /// Show version information
106 Version,
107 /// Update Mermaid to the latest release
108 Update {
109 /// Only report whether an update is available; don't install it
110 #[arg(long)]
111 check: bool,
112 /// Reinstall even if already on the latest version
113 #[arg(long)]
114 force: bool,
115 },
116 /// Check status of dependencies and backends
117 Status,
118 /// Check first-run readiness and explain what Mermaid can do now
119 Doctor {
120 /// Output format (text, json, markdown)
121 #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
122 format: OutputFormat,
123 },
124 /// Run fast deterministic Mermaid self-tests
125 SelfTest {
126 /// Output format (text, json, markdown)
127 #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
128 format: OutputFormat,
129 /// Keep the temporary self-test workspace after the run
130 #[arg(long)]
131 keep_workspace: bool,
132 },
133 /// List durable runtime tasks
134 Tasks {
135 /// Maximum number of tasks to show
136 #[arg(short, long, default_value_t = 20)]
137 limit: usize,
138 },
139 /// Show one durable runtime task and its timeline
140 Task {
141 /// Task id
142 id: String,
143 },
144 /// List Mermaid-managed background processes
145 Processes {
146 /// Maximum number of processes to show
147 #[arg(short, long, default_value_t = 20)]
148 limit: usize,
149 },
150 /// Print a managed process log
151 Logs {
152 /// Process id from `mermaid processes`
153 id: String,
154 },
155 /// Stop a managed process
156 Stop {
157 /// Process id from `mermaid processes`
158 id: String,
159 },
160 /// Restart a managed process
161 Restart {
162 /// Process id from `mermaid processes`
163 id: String,
164 },
165 /// Open a URL, file, or managed process URL
166 Open {
167 /// URL, path, or process id
168 target: String,
169 },
170 /// Show listening TCP ports
171 Ports,
172 /// List pending approvals
173 Approvals,
174 /// Approve a pending approval record
175 Approve {
176 /// Approval id
177 id: String,
178 },
179 /// Deny a pending approval record
180 Deny {
181 /// Approval id
182 id: String,
183 },
184 /// List recent persisted tool runs
185 ToolRuns {
186 /// Maximum number of tool runs to show
187 #[arg(short, long, default_value_t = 20)]
188 limit: usize,
189 },
190 /// List checkpoints
191 Checkpoints {
192 /// Maximum number of checkpoints to show
193 #[arg(short, long, default_value_t = 20)]
194 limit: usize,
195 },
196 /// Restore a checkpoint by id
197 Restore {
198 /// Checkpoint id
199 id: String,
200 /// Skip the confirmation prompt (required for non-interactive use)
201 #[arg(short, long)]
202 force: bool,
203 },
204 /// Manage Mermaid plugin bundles
205 Plugin {
206 #[command(subcommand)]
207 command: PluginCommand,
208 },
209 /// Manage Mermaid's Linux background service
210 Daemon {
211 #[command(subcommand)]
212 command: DaemonCommand,
213 },
214 /// Manage remote pairing tokens
215 Pair {
216 #[command(subcommand)]
217 command: PairCommand,
218 },
219 /// Internal self-QA commands. Hidden from normal help output.
220 #[command(hide = true)]
221 Qa {
222 #[command(subcommand)]
223 command: QaCommand,
224 },
225 /// Add an MCP server (e.g., mermaid add context7)
226 Add {
227 /// MCP server name (e.g., context7, git, filesystem)
228 name: String,
229 /// Skip the confirmation prompt before fetching and running a package
230 /// that is not in the built-in registry (for scripted/CI use). Without
231 /// this, adding an unknown package fails closed when there is no TTY.
232 #[arg(long)]
233 yes: bool,
234 },
235 /// Remove a configured MCP server
236 Remove {
237 /// MCP server name to remove
238 name: String,
239 },
240 /// List configured MCP servers
241 Mcp,
242 /// Create a pull/merge request from the current branch via the host CLI
243 /// (`gh` for GitHub, `glab` for GitLab)
244 Pr {
245 #[command(subcommand)]
246 command: PrCommand,
247 },
248 /// Configure Ollama Cloud API key (interactive prompt). Run this
249 /// from your shell before starting mermaid — it reads stdin and
250 /// doesn't work from inside the TUI.
251 CloudSetup,
252 /// Run a single prompt non-interactively
253 Run {
254 /// The prompt to execute
255 #[arg(value_parser = non_empty_prompt)]
256 prompt: String,
257
258 /// Output format (text, json, markdown)
259 #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
260 format: OutputFormat,
261
262 /// Maximum tokens to generate
263 #[arg(long)]
264 max_tokens: Option<usize>,
265
266 /// Don't execute agent actions (dry run)
267 #[arg(long)]
268 no_execute: bool,
269
270 /// Allow non-replayable tools (web/mcp/subagent/computer-use) to run on
271 /// an `Ask` decision in this headless run. Off by default — `ask` mode
272 /// otherwise refuses them when there's no approval UI.
273 #[arg(long)]
274 allow_untrusted_tools: bool,
275 },
276}
277
278#[derive(Subcommand, Debug)]
279pub enum PluginCommand {
280 /// Install a plugin from a local path
281 Install {
282 /// Path containing plugin.toml
283 path: PathBuf,
284 },
285 /// List installed plugins
286 List,
287 /// Enable an installed plugin
288 Enable {
289 /// Plugin id or name
290 id: String,
291 },
292 /// Disable an installed plugin
293 Disable {
294 /// Plugin id or name
295 id: String,
296 },
297 /// Validate a plugin manifest without installing
298 Audit {
299 /// Path containing plugin.toml
300 path: PathBuf,
301 },
302}
303
304#[derive(Subcommand, Debug)]
305pub enum PairCommand {
306 /// Create a pairing token (the secret is printed once)
307 Create {
308 /// Human label for the remote client
309 #[arg(long)]
310 label: Option<String>,
311 /// Days until the token expires (0 = never expires; default 30)
312 #[arg(long)]
313 ttl_days: Option<i64>,
314 },
315 /// List pairing tokens (id, label, created, expiry, status)
316 List,
317 /// Revoke a pairing token by id
318 Revoke {
319 /// Pairing token id
320 id: String,
321 },
322}
323
324/// Which Git hosting provider's CLI to drive.
325#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
326pub enum GitHost {
327 /// GitHub, via the `gh` CLI.
328 Github,
329 /// GitLab, via the `glab` CLI.
330 Gitlab,
331}
332
333#[derive(Subcommand, Debug)]
334pub enum PrCommand {
335 /// Create a PR/MR from the current branch. Wraps `gh pr create` /
336 /// `glab mr create`, reusing their existing authentication.
337 Create {
338 /// PR/MR title. Omitted → filled from the branch's commits.
339 #[arg(short, long)]
340 title: Option<String>,
341 /// PR/MR body text.
342 #[arg(short, long)]
343 body: Option<String>,
344 /// Read the body from a file (e.g. a saved review summary).
345 #[arg(long, value_name = "FILE", conflicts_with = "body")]
346 summary: Option<PathBuf>,
347 /// Base branch to merge into (defaults to the host's default branch).
348 #[arg(long)]
349 base: Option<String>,
350 /// Open as a draft.
351 #[arg(long)]
352 draft: bool,
353 /// Open the creation page in a browser instead of creating directly.
354 #[arg(long)]
355 web: bool,
356 /// Force a provider instead of auto-detecting from the `origin` remote.
357 #[arg(long, value_enum)]
358 provider: Option<GitHost>,
359 },
360}
361
362#[derive(Subcommand, Debug)]
363pub enum DaemonCommand {
364 /// Install the systemd user service for this user
365 Install {
366 /// Start and enable the service after writing the unit
367 #[arg(long)]
368 start: bool,
369 /// Overwrite an existing Mermaid service unit
370 #[arg(long)]
371 force: bool,
372 },
373 /// Remove the systemd user service for this user
374 Uninstall,
375 /// Start the background user service
376 Start,
377 /// Stop the background user service
378 Stop,
379 /// Restart the background user service
380 Restart,
381 /// Show background service status
382 Status,
383 /// Show background service logs
384 Logs {
385 /// Follow log output
386 #[arg(short, long)]
387 follow: bool,
388 /// Number of log lines to show before following/exiting
389 #[arg(short = 'n', long, default_value_t = 100)]
390 lines: usize,
391 },
392 /// Print the generated service unit without installing it
393 PrintUnit,
394}
395
396#[derive(Subcommand, Debug)]
397pub enum QaCommand {
398 /// Deterministically exercise context compaction without a real model.
399 CompactSmoke {
400 /// Number of synthetic user/assistant turns to seed
401 #[arg(long, default_value_t = 6)]
402 turns: usize,
403 /// Output format
404 #[arg(short, long, value_enum, default_value_t = OutputFormat::Json)]
405 format: OutputFormat,
406 },
407}
408
409#[derive(Debug, Clone, Copy, ValueEnum)]
410pub enum OutputFormat {
411 /// Plain text output
412 Text,
413 /// JSON structured output
414 Json,
415 /// Markdown formatted output
416 Markdown,
417}
418
419/// Reject an empty or whitespace-only `run` prompt at parse time, so
420/// `mermaid run ""` fails with a clear usage error instead of silently
421/// producing nothing. Only the emptiness check trims — the original string
422/// is preserved on success, since leading/trailing whitespace can be
423/// meaningful prompt content.
424fn non_empty_prompt(s: &str) -> Result<String, String> {
425 if s.trim().is_empty() {
426 Err("prompt cannot be empty".to_string())
427 } else {
428 Ok(s.to_string())
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use clap::Parser;
436
437 #[test]
438 fn non_empty_prompt_rejects_blank() {
439 assert!(non_empty_prompt("").is_err());
440 assert!(non_empty_prompt(" ").is_err());
441 assert!(non_empty_prompt("\t\n ").is_err());
442 }
443
444 #[test]
445 fn non_empty_prompt_preserves_content_including_surrounding_space() {
446 assert_eq!(non_empty_prompt("hello").unwrap(), "hello");
447 assert_eq!(non_empty_prompt(" hi ").unwrap(), " hi ");
448 }
449
450 #[test]
451 fn cli_run_rejects_empty_prompt() {
452 let err = Cli::try_parse_from(["mermaid", "run", ""])
453 .expect_err("empty prompt must be rejected at parse time");
454 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
455 }
456
457 #[test]
458 fn cli_run_accepts_normal_prompt() {
459 assert!(Cli::try_parse_from(["mermaid", "run", "do a thing"]).is_ok());
460 }
461
462 #[test]
463 fn resume_and_continue_flags_parse_and_conflict() {
464 // Claude Code parity: `--resume` (picker) and `--continue` (last) both
465 // exist and are mutually exclusive. The old `--sessions` is gone.
466 let resume = Cli::try_parse_from(["mermaid", "--resume"]).expect("--resume parses");
467 assert!(resume.resume && !resume.continue_session);
468 let cont = Cli::try_parse_from(["mermaid", "--continue"]).expect("--continue parses");
469 assert!(cont.continue_session && !cont.resume);
470 assert!(
471 Cli::try_parse_from(["mermaid", "--resume", "--continue"]).is_err(),
472 "--resume and --continue must conflict"
473 );
474 assert!(
475 Cli::try_parse_from(["mermaid", "--sessions"]).is_err(),
476 "the old --sessions flag is renamed to --resume"
477 );
478 }
479}