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