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    /// 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}