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