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,
value_name = "SESSION_ID",
num_args = 0..=1,
conflicts_with = "continue_session"
)]
pub resume: Option<Option<String>>,
#[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>,
#[arg(short = 'c', long = "config", value_name = "KEY=VALUE", global = true)]
pub config_overrides: Vec<String>,
#[arg(long, global = true)]
pub no_network: bool,
#[arg(long, global = true)]
pub confine_fs: bool,
#[arg(long, global = true)]
pub sandbox: bool,
#[arg(long, value_name = "NAME", global = true)]
pub profile: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
}
impl Cli {
pub fn session_flags(&self) -> crate::app::SessionFlags {
let (max_tokens, allow_untrusted_tools) = match &self.command {
Some(Commands::Run {
max_tokens,
allow_untrusted_tools,
..
}) => (*max_tokens, *allow_untrusted_tools),
_ => (None, false),
};
crate::app::SessionFlags {
overrides: self.config_overrides.clone(),
deny_network: self.no_network || self.sandbox,
confine_fs: self.confine_fs || self.sandbox,
max_tokens,
allow_untrusted_tools,
profile: self.profile.clone(),
}
}
}
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,
},
Feedback {
#[arg(long)]
stdout: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Markdown)]
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,
#[arg(long)]
follow: bool,
},
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,
},
Cancel {
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,
#[arg(long)]
command: Option<String>,
#[arg(long = "arg")]
arg: Vec<String>,
#[arg(long = "env")]
env: Vec<String>,
#[arg(long, conflicts_with_all = ["command", "arg", "env"])]
url: Option<String>,
#[arg(long = "header", requires = "url")]
header: Vec<String>,
#[arg(long = "env-header", requires = "url")]
env_header: Vec<String>,
},
Remove {
name: String,
},
Mcp,
Pr {
#[command(subcommand)]
command: PrCommand,
},
CloudSetup,
Login {
provider: Option<String>,
},
Logout {
provider: String,
},
Run {
prompt: Option<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,
#[arg(long, value_name = "FILE")]
output_schema: Option<PathBuf>,
#[arg(long)]
plan: bool,
#[arg(long, requires = "plan")]
plan_autoaccept: 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,
Ndjson,
}
pub fn resolve_run_prompt(prompt: Option<&str>, stdin: Option<String>) -> Result<String, String> {
let piped = stdin
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
match prompt.map(str::trim) {
None | Some("-") => {
piped.ok_or_else(|| "no prompt given: pass a prompt or pipe text on stdin".to_string())
},
Some("") => Err("prompt must not be empty".to_string()),
Some(text) => Ok(match piped {
Some(extra) => format!("{text}\n\n```\n{extra}\n```"),
None => text.to_string(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn resolve_run_prompt_reads_stdin_when_dash_or_missing() {
assert_eq!(
resolve_run_prompt(None, Some("piped work".to_string())).unwrap(),
"piped work"
);
assert_eq!(
resolve_run_prompt(Some("-"), Some(" piped ".to_string())).unwrap(),
"piped"
);
}
#[test]
fn resolve_run_prompt_errors_without_prompt_or_stdin() {
assert!(resolve_run_prompt(None, None).is_err());
assert!(resolve_run_prompt(Some("-"), None).is_err());
assert!(resolve_run_prompt(Some(""), None).is_err());
assert!(resolve_run_prompt(None, Some(" ".to_string())).is_err());
}
#[test]
fn resolve_run_prompt_appends_piped_stdin_to_explicit_prompt() {
let out = resolve_run_prompt(Some("summarize"), Some("file body".to_string())).unwrap();
assert!(out.starts_with("summarize"));
assert!(out.contains("file body"));
}
#[test]
fn cli_run_allows_missing_prompt_and_normal_prompt() {
assert!(Cli::try_parse_from(["mermaid", "run"]).is_ok());
assert!(Cli::try_parse_from(["mermaid", "run", "do a thing"]).is_ok());
}
#[test]
fn cli_config_overrides_are_repeatable() {
let cli = Cli::try_parse_from(["mermaid", "-c", "a.b=1", "-c", "c=true", "run", "x"])
.expect("repeatable -c parses");
assert_eq!(cli.config_overrides, vec!["a.b=1", "c=true"]);
}
#[test]
fn cli_config_override_after_subcommand_is_global() {
let cli = Cli::try_parse_from(["mermaid", "run", "x", "-c", "c=true"])
.expect("global -c parses after the subcommand");
assert_eq!(cli.config_overrides, vec!["c=true"]);
}
#[test]
fn parses_login_and_logout() {
let cli = Cli::parse_from(["mermaid", "login"]);
assert!(matches!(
cli.command,
Some(Commands::Login { provider: None })
));
let cli = Cli::parse_from(["mermaid", "login", "groq"]);
assert!(matches!(cli.command, Some(Commands::Login { provider: Some(p) }) if p == "groq"));
let cli = Cli::parse_from(["mermaid", "logout", "groq"]);
assert!(matches!(cli.command, Some(Commands::Logout { provider }) if provider == "groq"));
}
#[test]
fn parses_task_follow() {
let cli = Cli::parse_from(["mermaid", "task", "t1", "--follow"]);
assert!(matches!(cli.command, Some(Commands::Task { id, follow: true }) if id == "t1"));
let cli = Cli::parse_from(["mermaid", "task", "t1"]);
assert!(matches!(cli.command, Some(Commands::Task { id, follow: false }) if id == "t1"));
}
#[test]
fn session_flags_collect_sandbox_and_run_flags() {
let cli = Cli::try_parse_from([
"mermaid",
"--sandbox",
"-c",
"a=1",
"run",
"x",
"--max-tokens",
"512",
"--allow-untrusted-tools",
])
.expect("parses");
let flags = cli.session_flags();
assert!(flags.deny_network && flags.confine_fs);
assert_eq!(flags.max_tokens, Some(512));
assert!(flags.allow_untrusted_tools);
assert_eq!(flags.overrides, vec!["a=1"]);
let cli = Cli::try_parse_from(["mermaid", "--no-network"]).expect("parses");
let flags = cli.session_flags();
assert!(flags.deny_network && !flags.confine_fs);
assert_eq!(flags.max_tokens, None);
assert!(!flags.allow_untrusted_tools);
}
#[test]
fn add_url_conflicts_with_command_and_requires_url_for_headers() {
let cli = Cli::try_parse_from([
"mermaid",
"add",
"gh",
"--url",
"https://example.com/mcp",
"--header",
"X-Token: abc",
"--env-header",
"Authorization=TOKEN_VAR",
])
.expect("parses");
match cli.command {
Some(Commands::Add {
url,
header,
env_header,
..
}) => {
assert_eq!(url.as_deref(), Some("https://example.com/mcp"));
assert_eq!(header, vec!["X-Token: abc".to_string()]);
assert_eq!(env_header, vec!["Authorization=TOKEN_VAR".to_string()]);
},
other => panic!("expected Add, got {other:?}"),
}
assert!(
Cli::try_parse_from([
"mermaid",
"add",
"gh",
"--url",
"https://example.com/mcp",
"--command",
"npx"
])
.is_err(),
"--url must conflict with --command"
);
assert!(
Cli::try_parse_from(["mermaid", "add", "gh", "--header", "X: y"]).is_err(),
"--header must require --url"
);
}
#[test]
fn resume_and_continue_flags_parse_and_conflict() {
let resume = Cli::try_parse_from(["mermaid", "--resume"]).expect("--resume parses");
assert_eq!(resume.resume, Some(None));
assert!(!resume.continue_session);
let direct = Cli::try_parse_from(["mermaid", "--resume", "20260709_120000_000"])
.expect("--resume <id> parses");
assert_eq!(direct.resume, Some(Some("20260709_120000_000".to_string())));
let absent = Cli::try_parse_from(["mermaid"]).expect("no flag parses");
assert_eq!(absent.resume, None);
let cont = Cli::try_parse_from(["mermaid", "--continue"]).expect("--continue parses");
assert!(cont.continue_session && cont.resume.is_none());
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"
);
}
}