use anyhow::{Context, Result};
use clap::Parser;
use mermaid_cli::{
app::{
InteractiveOptions, RunOptions, format_result, load_layered_config_or_warn,
persist_last_model, persist_reasoning_for_model, resolve_model_id, run_interactive_with,
run_non_interactive_with,
},
cli::{Cli, Commands, OutputFormat, resolve_run_prompt},
ollama::ensure_model as ensure_ollama_model,
runtime::{NewTask, RuntimeStore, TaskStatus},
session::{ConversationManager, SessionEntry, select_conversation},
utils::init_logger,
};
fn main() -> Result<()> {
mermaid_cli::runtime::hardening::harden_process();
if let Some(code) = mermaid_cli::app::sandbox_exec::maybe_dispatch(std::env::args_os()) {
std::process::exit(code);
}
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("failed to build the async runtime")?
.block_on(async_main())
}
async fn async_main() -> Result<()> {
let cli = Cli::parse();
init_logger(cli.verbose);
if let Some(path) = cli.replay.as_ref() {
let deterministic = mermaid_cli::app::run_replay(path)?;
if !deterministic {
std::process::exit(1);
}
return Ok(());
}
let cwd = cli.path.clone().unwrap_or(std::env::current_dir()?);
let mut config = load_layered_config_or_warn(Some(&cwd), &cli.session_flags());
apply_prompt_flags(&cli, &mut config)?;
if let Some(cmd) = &cli.command
&& mermaid_cli::cli::handle_command(cmd, &config, &cwd, cli.model.as_deref()).await?
{
return Ok(());
}
if let Some(Commands::Run {
prompt,
format,
no_execute,
output_schema,
plan,
plan_autoaccept,
..
}) = &cli.command
{
let output_schema = output_schema
.as_deref()
.map(load_output_schema)
.transpose()?;
return dispatch_non_interactive(
&cli,
config,
prompt.clone(),
*format,
output_schema,
HeadlessFlags {
no_execute: *no_execute,
plan: *plan,
plan_autoaccept: *plan_autoaccept,
},
)
.await;
}
dispatch_interactive(cli, config).await
}
fn apply_prompt_flags(cli: &Cli, config: &mut mermaid_cli::app::Config) -> Result<()> {
if let Some(prompt) = cli.system_prompt.as_ref() {
config.prompt.system_prompt = Some(prompt.clone());
}
if let Some(path) = cli.system_prompt_file.as_ref() {
config.prompt.system_prompt =
Some(std::fs::read_to_string(path).with_context(|| {
format!("failed to read --system-prompt-file {}", path.display())
})?);
}
if let Some(prompt) = cli.append_system_prompt.as_ref() {
config.prompt.append_system_prompt.push(prompt.clone());
}
if let Some(path) = cli.append_system_prompt_file.as_ref() {
config
.prompt
.append_system_prompt
.push(std::fs::read_to_string(path).with_context(|| {
format!(
"failed to read --append-system-prompt-file {}",
path.display()
)
})?);
}
Ok(())
}
async fn dispatch_interactive(cli: Cli, mut config: mermaid_cli::app::Config) -> Result<()> {
let cli_model_provided = cli.model.is_some();
let model_id = resolve_model_id(cli.model.as_deref(), &config).await?;
if is_ollama_model(&model_id) {
ensure_ollama_model(&model_id, &config).await?;
}
if cli_model_provided && let Err(err) = persist_last_model(&model_id) {
tracing::warn!(error = %err, "failed to persist last-used model");
}
if let Some(level) = cli.reasoning {
config.reasoning_per_model.insert(model_id.clone(), level);
if let Err(err) = persist_reasoning_for_model(&model_id, level) {
tracing::warn!(error = %err, "failed to persist reasoning level for model");
}
}
let cwd = cli.path.clone().unwrap_or(std::env::current_dir()?);
let seed_conversation = load_seed_conversation(&cwd, cli.continue_session, &cli.resume, true)?;
let recorder = match cli.record.as_ref() {
Some(path) => Some(mermaid_cli::app::Recorder::open(path.clone())?),
None => None,
};
run_interactive_with(
config,
cwd,
model_id,
InteractiveOptions {
recorder,
seed_conversation,
},
)
.await
}
fn load_seed_conversation(
cwd: &std::path::Path,
continue_session: bool,
resume: &Option<Option<String>>,
interactive: bool,
) -> Result<Option<mermaid_cli::session::ConversationHistory>> {
if continue_session {
return ConversationManager::new(cwd)?.load_last_conversation();
}
match resume {
None => Ok(None),
Some(Some(id)) => {
let manager = ConversationManager::new(cwd)?;
let history = manager.load_conversation(id).map_err(|e| {
anyhow::anyhow!(
"session {id} not found under {}/.mermaid/conversations: {e}",
cwd.display()
)
})?;
Ok(Some(history))
},
Some(None) if !interactive => anyhow::bail!(
"--resume without a session id opens an interactive picker; \
pass --resume <session-id> or use --continue"
),
Some(None) => {
let manager = ConversationManager::new(cwd)?;
let entries: Vec<SessionEntry> = manager
.list_conversations()?
.into_iter()
.map(|history| {
let path = manager
.conversations_dir()
.join(format!("{}.json", history.id));
let size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
SessionEntry {
history,
size_bytes,
}
})
.collect();
select_conversation(entries, &manager, chrono::Local::now())
},
}
}
fn load_output_schema(path: &std::path::Path) -> Result<serde_json::Value> {
const MAX_SCHEMA_BYTES: u64 = 64 * 1024;
let meta = std::fs::metadata(path)
.with_context(|| format!("--output-schema: cannot read {}", path.display()))?;
anyhow::ensure!(
meta.len() <= MAX_SCHEMA_BYTES,
"--output-schema: {} is {} bytes; cap is {} (64 KiB)",
path.display(),
meta.len(),
MAX_SCHEMA_BYTES
);
let raw = std::fs::read_to_string(path)
.with_context(|| format!("--output-schema: cannot read {}", path.display()))?;
let schema: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("--output-schema: {} is not valid JSON", path.display()))?;
anyhow::ensure!(
schema.is_object(),
"--output-schema: {} must contain a JSON object (a JSON Schema)",
path.display()
);
Ok(schema)
}
struct HeadlessFlags {
no_execute: bool,
plan: bool,
plan_autoaccept: bool,
}
async fn dispatch_non_interactive(
cli: &Cli,
mut config: mermaid_cli::app::Config,
prompt: Option<String>,
format: OutputFormat,
output_schema: Option<serde_json::Value>,
flags: HeadlessFlags,
) -> Result<()> {
let prompt = resolve_prompt_from_stdin(prompt)?;
let cli_model_provided = cli.model.is_some();
let model_id = resolve_model_id(cli.model.as_deref(), &config).await?;
if is_ollama_model(&model_id) {
ensure_ollama_model(&model_id, &config).await?;
}
if cli_model_provided && let Err(err) = persist_last_model(&model_id) {
tracing::warn!(error = %err, "failed to persist last-used model");
}
if let Some(level) = cli.reasoning {
config.reasoning_per_model.insert(model_id.clone(), level);
if let Err(err) = persist_reasoning_for_model(&model_id, level) {
tracing::warn!(error = %err, "failed to persist reasoning level for model");
}
}
let cwd = cli.path.clone().unwrap_or(std::env::current_dir()?);
let seed = load_seed_conversation(&cwd, cli.continue_session, &cli.resume, false)?;
if cli.continue_session && seed.is_none() {
anyhow::bail!("--continue: no saved session found for {}", cwd.display());
}
let runtime_task_id = create_run_task(&cwd, &model_id, &prompt, flags.no_execute);
let run_result = run_non_interactive_with(
config,
cwd,
model_id,
prompt,
RunOptions {
no_execute: flags.no_execute,
task_id: runtime_task_id.clone(),
stream_ndjson: matches!(format, OutputFormat::Ndjson),
seed,
output_schema,
plan: flags.plan,
plan_autoaccept: flags.plan_autoaccept,
..RunOptions::default()
},
)
.await;
match &run_result {
Ok(result) if result.errors.is_empty() => {
finish_run_task(
runtime_task_id.as_deref(),
TaskStatus::Completed,
Some(&result.response),
);
},
Ok(result) => {
finish_run_task(
runtime_task_id.as_deref(),
TaskStatus::Failed,
Some(&result.errors.join("\n")),
);
},
Err(err) => {
finish_run_task(
runtime_task_id.as_deref(),
TaskStatus::Failed,
Some(&err.to_string()),
);
},
}
let result = run_result?;
if !matches!(format, OutputFormat::Ndjson) {
println!("{}", format_result(&result, format));
}
if matches!(format, OutputFormat::Text | OutputFormat::Markdown) {
eprintln!("session: {}", result.session_id);
}
if !result.errors.is_empty() {
std::process::exit(1);
}
Ok(())
}
fn resolve_prompt_from_stdin(prompt: Option<String>) -> Result<String> {
use std::io::{IsTerminal, Read};
let stdin_text = if std::io::stdin().is_terminal() {
None
} else {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf).ok();
Some(buf)
};
resolve_run_prompt(prompt.as_deref(), stdin_text).map_err(anyhow::Error::msg)
}
fn create_run_task(
cwd: &std::path::Path,
model_id: &str,
prompt: &str,
no_execute: bool,
) -> Option<String> {
let store = RuntimeStore::open_default().ok()?;
let task = store
.tasks()
.create(NewTask::new(
task_title_from_prompt(prompt),
cwd.display().to_string(),
model_id.to_string(),
))
.ok()?;
let _ = store
.tasks()
.update_status(&task.id, TaskStatus::Running, None);
if no_execute {
let _ = store
.tasks()
.add_event(&task.id, "run_option", "tools disabled by --no-execute");
}
let _ = mermaid_cli::runtime::run_plugin_hooks(
"task_start",
&serde_json::json!({
"id": task.id.clone(),
"title": task.title.clone(),
"project_path": task.project_path.clone(),
"model_id": task.model_id.clone(),
}),
);
Some(task.id)
}
fn finish_run_task(task_id: Option<&str>, status: TaskStatus, final_report: Option<&str>) {
let Some(task_id) = task_id else {
return;
};
if let Ok(store) = RuntimeStore::open_default() {
let _ = store.tasks().update_status(task_id, status, final_report);
let _ = mermaid_cli::runtime::run_plugin_hooks(
"task_stop",
&serde_json::json!({
"id": task_id,
"status": status.as_str(),
"final_report": final_report,
}),
);
}
}
fn task_title_from_prompt(prompt: &str) -> String {
let one_line = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
if one_line.is_empty() {
return "mermaid run".to_string();
}
if one_line.len() <= 80 {
return one_line;
}
let end = one_line.floor_char_boundary(80);
format!("{}...", &one_line[..end])
}
fn is_ollama_model(model_id: &str) -> bool {
match model_id.split_once('/') {
Some((provider, _)) => provider.eq_ignore_ascii_case("ollama"),
None => true,
}
}