use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
use crate::models::ReasoningLevel;
#[derive(Parser, Debug)]
#[command(name = "mermaid")]
#[command(version)]
#[command(about = "An open-source, model-agnostic AI pair programmer", long_about = None)]
#[command(after_help = TOP_LEVEL_HELP_AFTER)]
pub struct Cli {
#[arg(short, long)]
pub model: Option<String>,
#[arg(long)]
pub reasoning: Option<ReasoningLevel>,
#[arg(short, long)]
pub path: Option<PathBuf>,
#[arg(short, long)]
pub verbose: bool,
#[arg(long, conflicts_with = "continue_session")]
pub resume: bool,
#[arg(long = "continue", conflicts_with = "resume")]
pub continue_session: bool,
#[arg(long, value_name = "FILE")]
pub record: Option<PathBuf>,
#[arg(long, value_name = "FILE", conflicts_with = "record")]
pub replay: Option<PathBuf>,
#[arg(long, global = true, conflicts_with = "system_prompt_file")]
pub system_prompt: Option<String>,
#[arg(
long,
value_name = "FILE",
global = true,
conflicts_with = "system_prompt"
)]
pub system_prompt_file: Option<PathBuf>,
#[arg(long, global = true)]
pub append_system_prompt: Option<String>,
#[arg(long, value_name = "FILE", global = true)]
pub append_system_prompt_file: Option<PathBuf>,
#[command(subcommand)]
pub command: Option<Commands>,
}
const TOP_LEVEL_HELP_AFTER: &str = "\
Common first run:
mermaid doctor Check model, tools, safety, and project readiness
mermaid Start the full-screen terminal coding agent
mermaid run \"inspect this repo\" Run one prompt headlessly
mermaid self-test Run fast deterministic Mermaid self-tests
Command groups:
Everyday: chat, run, doctor, status, list, self-test
Model/context: models, model-info, --model, --reasoning, --system-prompt*
Safety/recovery: approvals, approve, deny, checkpoints, restore
Integrations: add, remove, mcp, cloud-setup, plugin, pr
Advanced runtime: daemon, tasks, task, processes, logs, stop, restart, ports, pair";
#[derive(Subcommand, Debug)]
pub enum Commands {
Init,
List,
Models,
ModelInfo {
model: String,
},
Chat,
Version,
Update {
#[arg(long)]
check: bool,
#[arg(long)]
force: bool,
},
Status,
Doctor {
#[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
SelfTest {
#[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
#[arg(long)]
keep_workspace: bool,
},
Tasks {
#[arg(short, long, default_value_t = 20)]
limit: usize,
},
Task {
id: String,
},
Processes {
#[arg(short, long, default_value_t = 20)]
limit: usize,
},
Logs {
id: String,
},
Stop {
id: String,
},
Restart {
id: String,
},
Open {
target: String,
},
Ports,
Approvals,
Approve {
id: String,
},
Deny {
id: String,
},
ToolRuns {
#[arg(short, long, default_value_t = 20)]
limit: usize,
},
Checkpoints {
#[arg(short, long, default_value_t = 20)]
limit: usize,
},
Restore {
id: String,
#[arg(short, long)]
force: bool,
},
Plugin {
#[command(subcommand)]
command: PluginCommand,
},
Daemon {
#[command(subcommand)]
command: DaemonCommand,
},
Pair {
#[command(subcommand)]
command: PairCommand,
},
#[command(hide = true)]
Qa {
#[command(subcommand)]
command: QaCommand,
},
Add {
name: String,
#[arg(long)]
yes: bool,
},
Remove {
name: String,
},
Mcp,
Pr {
#[command(subcommand)]
command: PrCommand,
},
CloudSetup,
Run {
#[arg(value_parser = non_empty_prompt)]
prompt: String,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
#[arg(long)]
max_tokens: Option<usize>,
#[arg(long)]
no_execute: bool,
#[arg(long)]
allow_untrusted_tools: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum PluginCommand {
Install {
path: PathBuf,
},
List,
Enable {
id: String,
},
Disable {
id: String,
},
Audit {
path: PathBuf,
},
}
#[derive(Subcommand, Debug)]
pub enum PairCommand {
Create {
#[arg(long)]
label: Option<String>,
#[arg(long)]
ttl_days: Option<i64>,
},
List,
Revoke {
id: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GitHost {
Github,
Gitlab,
}
#[derive(Subcommand, Debug)]
pub enum PrCommand {
Create {
#[arg(short, long)]
title: Option<String>,
#[arg(short, long)]
body: Option<String>,
#[arg(long, value_name = "FILE", conflicts_with = "body")]
summary: Option<PathBuf>,
#[arg(long)]
base: Option<String>,
#[arg(long)]
draft: bool,
#[arg(long)]
web: bool,
#[arg(long, value_enum)]
provider: Option<GitHost>,
},
}
#[derive(Subcommand, Debug)]
pub enum DaemonCommand {
Install {
#[arg(long)]
start: bool,
#[arg(long)]
force: bool,
},
Uninstall,
Start,
Stop,
Restart,
Status,
Logs {
#[arg(short, long)]
follow: bool,
#[arg(short = 'n', long, default_value_t = 100)]
lines: usize,
},
PrintUnit,
}
#[derive(Subcommand, Debug)]
pub enum QaCommand {
CompactSmoke {
#[arg(long, default_value_t = 6)]
turns: usize,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Json)]
format: OutputFormat,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum OutputFormat {
Text,
Json,
Markdown,
}
fn non_empty_prompt(s: &str) -> Result<String, String> {
if s.trim().is_empty() {
Err("prompt cannot be empty".to_string())
} else {
Ok(s.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn non_empty_prompt_rejects_blank() {
assert!(non_empty_prompt("").is_err());
assert!(non_empty_prompt(" ").is_err());
assert!(non_empty_prompt("\t\n ").is_err());
}
#[test]
fn non_empty_prompt_preserves_content_including_surrounding_space() {
assert_eq!(non_empty_prompt("hello").unwrap(), "hello");
assert_eq!(non_empty_prompt(" hi ").unwrap(), " hi ");
}
#[test]
fn cli_run_rejects_empty_prompt() {
let err = Cli::try_parse_from(["mermaid", "run", ""])
.expect_err("empty prompt must be rejected at parse time");
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn cli_run_accepts_normal_prompt() {
assert!(Cli::try_parse_from(["mermaid", "run", "do a thing"]).is_ok());
}
#[test]
fn resume_and_continue_flags_parse_and_conflict() {
let resume = Cli::try_parse_from(["mermaid", "--resume"]).expect("--resume parses");
assert!(resume.resume && !resume.continue_session);
let cont = Cli::try_parse_from(["mermaid", "--continue"]).expect("--continue parses");
assert!(cont.continue_session && !cont.resume);
assert!(
Cli::try_parse_from(["mermaid", "--resume", "--continue"]).is_err(),
"--resume and --continue must conflict"
);
assert!(
Cli::try_parse_from(["mermaid", "--sessions"]).is_err(),
"the old --sessions flag is renamed to --resume"
);
}
}