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