Skip to main content

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}