1use anyhow::{Context, Result, anyhow, bail};
2use std::path::{Path, PathBuf};
3
4use mermaid_runtime::{NewProviderProbe, RuntimeStore, TaskRecord};
5
6use mermaid_model::models::{ChatMessage, PROVIDER_REGISTRY, lookup_provider};
7
8use mermaid_domain::Config;
9
10use mermaid_domain::{
11 ChatRequest, Cmd, CompactionEvent, CompactionResult, CompactionTrigger, Msg, SlashCmd, State,
12 build_replacement_messages, estimate_context_usage_for_request, prepare_compaction, update,
13};
14
15use crate::{
16 app::{get_config_dir, init_config, load_config_or_warn},
17 ollama::{LocalModelListing, is_installed as is_ollama_installed, observe_models},
18 providers::discovery::{configured_remote_provider_names, configured_remote_providers},
19 runtime_client::{RuntimeClient, record_static_provider_probes},
20 session::ConversationManager,
21};
22
23use super::{Commands, OutputFormat, PairCommand, PluginCommand, QaCommand};
24
25#[expect(
37 clippy::too_many_lines,
38 reason = "the clap dispatch: one arm per subcommand, nearly all a call plus Ok(true); a \
39 helper per arm adds nothing and the table is how a reader finds which function a verb runs"
40)]
41pub async fn handle_command(
42 command: &Commands,
43 config: &Config,
44 cwd: &Path,
45 cli_model: Option<&str>,
46) -> Result<bool> {
47 match command {
48 Commands::Init => {
49 println!("Initializing Mermaid configuration...");
50 init_config()?;
51 println!("Configuration initialized successfully!");
52 Ok(true)
53 },
54 Commands::List => {
55 list_models(config).await?;
56 Ok(true)
57 },
58 Commands::Models => {
59 show_models(config).await?;
60 Ok(true)
61 },
62 Commands::ModelInfo { model } => {
63 show_model_info(model, config).await?;
64 Ok(true)
65 },
66 Commands::Update { check, force } => {
67 run_update(*check, *force).await?;
68 Ok(true)
69 },
70 Commands::Status => {
71 show_status(config).await?;
72 Ok(true)
73 },
74 Commands::Doctor { format } => {
75 show_doctor(config, cwd, cli_model, *format).await?;
76 Ok(true)
77 },
78 Commands::Feedback { stdout, format } => {
79 super::feedback::run_feedback(config, cwd, cli_model, *stdout, *format).await?;
80 Ok(true)
81 },
82 Commands::SelfTest {
83 format,
84 keep_workspace,
85 } => {
86 run_self_test(config, *format, *keep_workspace)?;
87 Ok(true)
88 },
89 Commands::Tasks { limit } => {
90 show_tasks(*limit)?;
91 Ok(true)
92 },
93 Commands::Task { id, follow, send } => {
94 if let Some(text) = send {
95 send_to_task(id, text)?;
96 } else if *follow {
97 follow_task(id)?;
98 } else {
99 show_task(id)?;
100 }
101 Ok(true)
102 },
103 Commands::Processes { limit } => {
104 show_processes(*limit)?;
105 Ok(true)
106 },
107 Commands::Logs { id } => {
108 show_logs(id)?;
109 Ok(true)
110 },
111 Commands::Stop { id } => {
112 stop_process(id)?;
113 Ok(true)
114 },
115 Commands::Restart { id } => {
116 restart_process(id)?;
117 Ok(true)
118 },
119 Commands::Open { target } => {
120 open_target(target)?;
121 Ok(true)
122 },
123 Commands::Ports => {
124 show_ports()?;
125 Ok(true)
126 },
127 Commands::Approvals => {
128 show_approvals()?;
129 Ok(true)
130 },
131 Commands::Approve { id } => {
132 approve(id)?;
133 Ok(true)
134 },
135 Commands::Deny { id } => {
136 deny(id)?;
137 Ok(true)
138 },
139 Commands::Cancel { id } => {
140 cancel_task(id)?;
141 Ok(true)
142 },
143 Commands::ToolRuns { limit } => {
144 show_tool_runs(*limit)?;
145 Ok(true)
146 },
147 Commands::Checkpoints { limit } => {
148 show_checkpoints(*limit)?;
149 Ok(true)
150 },
151 Commands::Restore { id, force } => {
152 restore_checkpoint(id, *force)?;
153 Ok(true)
154 },
155 Commands::Plugin { command } => {
156 handle_plugin(command)?;
157 Ok(true)
158 },
159 Commands::Daemon { command } => {
160 super::daemon::handle_daemon_command(command)?;
161 Ok(true)
162 },
163 Commands::Pair { command } => {
164 handle_pair(command)?;
165 Ok(true)
166 },
167 Commands::Qa { command } => {
168 handle_qa(command, config, cwd)?;
169 Ok(true)
170 },
171 Commands::Add {
172 name,
173 yes,
174 command,
175 arg,
176 env,
177 url,
178 header,
179 env_header,
180 } => {
181 match url {
184 Some(url) => {
185 crate::mcp::add_http_server(
186 name,
187 url.clone(),
188 header.clone(),
189 env_header.clone(),
190 )
191 .await?;
192 },
193 None => {
194 crate::mcp::add_server(name, *yes, command.clone(), arg.clone(), env.clone())
195 .await?;
196 },
197 }
198 Ok(true)
199 },
200 Commands::Remove { name } => {
201 crate::mcp::remove_server(name).await?;
202 Ok(true)
203 },
204 Commands::Mcp => {
205 show_mcp_servers();
206 Ok(true)
207 },
208 Commands::Login { provider } => {
209 login(provider.as_deref(), config)?;
210 Ok(true)
211 },
212 Commands::Logout { provider } => {
213 logout(provider, config)?;
214 Ok(true)
215 },
216 Commands::CloudSetup => {
217 let _ = crate::ollama::setup_cloud_interactive();
221 Ok(true)
222 },
223 Commands::Run { .. } => Ok(false), }
225}
226
227fn handle_qa(command: &QaCommand, config: &Config, cwd: &Path) -> Result<()> {
228 match command {
229 QaCommand::CompactSmoke { turns, format } => {
230 let report = match run_qa_compact_smoke(config, cwd, *turns) {
231 Ok(report) => report,
232 Err(err) => QaCompactSmokeReport::failed(cwd, *turns, err.to_string()),
233 };
234 print_qa_compact_report(&report, *format)?;
235 anyhow::ensure!(report.ok, "qa compact smoke failed");
236 Ok(())
237 },
238 }
239}
240
241#[derive(Debug, serde::Serialize)]
242pub(crate) struct DoctorReport {
243 pub(crate) ok: bool,
244 pub(crate) cwd: String,
245 pub(crate) active_profile: Option<String>,
247 pub(crate) active_model: Option<String>,
248 pub(crate) model_error: Option<String>,
249 pub(crate) model_capabilities: Option<DoctorCapabilities>,
250 pub(crate) safety_mode: String,
251 pub(crate) checkpoint_on_mutation: bool,
252 pub(crate) prompt_customized: bool,
253 pub(crate) output_style: String,
254 pub(crate) output_style_source: String,
255 pub(crate) ollama: DoctorCheck,
256 pub(crate) remote_providers: Vec<String>,
257 pub(crate) provider_problems: Vec<DoctorProviderProblem>,
260 pub(crate) project_instructions: DoctorCheck,
261 pub(crate) tools: Vec<String>,
262 pub(crate) runtime: DoctorRuntime,
263 pub(crate) session_logs: DoctorCheck,
265 pub(crate) next_steps: Vec<String>,
266}
267
268#[derive(Debug, serde::Serialize)]
269pub(crate) struct DoctorCapabilities {
270 pub(crate) provider: String,
271 pub(crate) name: String,
272 pub(crate) supports_tools: bool,
273 pub(crate) supports_vision: bool,
274 pub(crate) reasoning: String,
275 pub(crate) max_context_tokens: Option<usize>,
276}
277
278#[derive(Debug, serde::Serialize)]
279pub(crate) struct DoctorCheck {
280 pub(crate) status: &'static str,
281 pub(crate) message: String,
282}
283
284#[derive(Debug, serde::Serialize)]
287pub(crate) struct DoctorProviderProblem {
288 pub(crate) name: String,
289 pub(crate) reason: String,
290}
291
292#[derive(Debug, serde::Serialize)]
293pub(crate) struct DoctorRuntime {
294 pub(crate) daemon: DoctorCheck,
295 pub(crate) local_store: DoctorCheck,
296}
297
298fn web_doctor_entries(config: &Config) -> (Vec<String>, Vec<String>) {
299 let capabilities = crate::providers::tool::web::WebCapabilities::resolve(&config.web);
300 let mut tools = Vec::new();
301 let mut next_steps = Vec::new();
302 for (name, status) in [
303 ("web_fetch", capabilities.fetch),
304 ("web_search", capabilities.search),
305 ] {
306 if config.safety.network == mermaid_domain::NetworkPolicy::Deny {
307 next_steps.push(format!(
308 "{name} is disabled by safety.network = \"deny\" (selected backend '{}'; {}).",
309 status.backend, status.trust_destination
310 ));
311 } else if status.available {
312 tools.push(format!(
313 "{name} ({}; {})",
314 status.backend, status.trust_destination
315 ));
316 } else {
317 next_steps.push(format!(
318 "{name} is unavailable with backend '{}': {}.",
319 status.backend,
320 status
321 .reason
322 .as_deref()
323 .unwrap_or("the selected backend could not be initialized")
324 ));
325 }
326 }
327 (tools, next_steps)
328}
329
330async fn show_doctor(
331 config: &Config,
332 cwd: &Path,
333 cli_model: Option<&str>,
334 format: OutputFormat,
335) -> Result<()> {
336 let report = build_doctor_report(config, cwd, cli_model).await;
337 print_doctor_report(&report, format)
338}
339
340pub(crate) async fn build_doctor_report(
343 config: &Config,
344 cwd: &Path,
345 cli_model: Option<&str>,
346) -> DoctorReport {
347 let active_model_result = crate::app::resolve_model_id(cli_model, config).await;
348 let (active_model, model_error, model_capabilities) = match active_model_result {
349 Ok(model) => {
350 let snapshot = mermaid_domain::ProviderCapabilitySnapshot::from_model_id(&model);
351 (
352 Some(model),
353 None,
354 Some(DoctorCapabilities {
355 provider: snapshot.provider,
356 name: snapshot.model,
357 supports_tools: snapshot.supports_tools,
358 supports_vision: snapshot.supports_vision,
359 reasoning: snapshot.reasoning,
360 max_context_tokens: snapshot.max_context_tokens,
361 }),
362 )
363 },
364 Err(err) => (None, Some(err.to_string()), None),
365 };
366
367 let ollama_models = if is_ollama_installed() {
372 observe_models(config).await
373 } else {
374 LocalModelListing::Unreachable
375 };
376 let ollama = ollama_check(&ollama_models);
377
378 let remote_providers = configured_remote_provider_names(config);
379 let provider_problems = crate::providers::provider_problems(config)
380 .into_iter()
381 .map(|problem| DoctorProviderProblem {
382 name: problem.name,
383 reason: problem.reason,
384 })
385 .collect::<Vec<_>>();
386 let instruction_paths = crate::app::instructions::find_instruction_files(cwd);
387 let project_instructions = project_instructions_check(&instruction_paths);
388
389 let runtime = runtime_checks();
390
391 let (tools, web_next_steps) = doctor_tools(config, cwd);
392
393 let mut next_steps = web_next_steps;
394 if active_model.is_none() {
395 next_steps.push(
396 "Pick a model with `mermaid --model <provider/model>` or run `mermaid list`."
397 .to_string(),
398 );
399 }
400 if remote_providers.is_empty() && ollama_models.models().unwrap_or_default().is_empty() {
401 next_steps.push(
402 "Install or start Ollama, pull a model, or set a remote provider API key.".to_string(),
403 );
404 }
405 if instruction_paths.is_empty() {
406 next_steps.push("Optional: add MERMAID.md or AGENTS.md with project-specific run commands and conventions.".to_string());
407 }
408 if next_steps.is_empty() {
409 next_steps.push(
410 "Start Mermaid with `mermaid` or run one prompt with `mermaid run \"...\"`."
411 .to_string(),
412 );
413 }
414
415 let session_logs = session_log_drift(cwd);
416
417 let ok = active_model.is_some()
418 && runtime.local_store.status != "warning"
419 && (ollama.status == "ok" || !remote_providers.is_empty());
420 DoctorReport {
421 ok,
422 cwd: cwd.display().to_string(),
423 active_profile: config.active_profile.clone(),
424 active_model,
425 model_error,
426 model_capabilities,
427 safety_mode: safety_mode_name(config.safety.mode).to_string(),
428 checkpoint_on_mutation: config.safety.checkpoint_on_mutation,
429 prompt_customized: config.prompt.is_customized(),
430 output_style: config.output.style.clone(),
431 output_style_source: config.active_style.source.clone(),
432 ollama,
433 remote_providers,
434 provider_problems,
435 project_instructions,
436 tools,
437 runtime,
438 session_logs,
439 next_steps,
440 }
441}
442
443fn ollama_check(ollama_models: &LocalModelListing) -> DoctorCheck {
445 if !is_ollama_installed() {
446 DoctorCheck {
447 status: "warning",
448 message: "Ollama is not installed; remote providers can still work if configured."
449 .to_string(),
450 }
451 } else {
452 match ollama_models {
453 LocalModelListing::Unreachable => DoctorCheck {
454 status: "warning",
455 message: "Ollama is installed but not running; mermaid starts it \
456 automatically when an Ollama model is used."
457 .to_string(),
458 },
459 LocalModelListing::Live(models) if models.is_empty() => DoctorCheck {
460 status: "warning",
461 message: "Ollama is running but no local/cloud models were listed.".to_string(),
462 },
463 LocalModelListing::Live(models) => DoctorCheck {
464 status: "ok",
465 message: format!("Ollama reachable with {} models.", models.len()),
466 },
467 LocalModelListing::FromDisk(models) => DoctorCheck {
471 status: "ok",
472 message: format!(
473 "Ollama installed, not running — {} model(s) on disk; starts \
474 automatically when used.",
475 models.len()
476 ),
477 },
478 }
479 }
480}
481
482fn project_instructions_check(instruction_paths: &[PathBuf]) -> DoctorCheck {
484 if instruction_paths.is_empty() {
485 DoctorCheck {
486 status: "info",
487 message: "No AGENTS.md or MERMAID.md found.".to_string(),
488 }
489 } else if let Some(loaded) = crate::app::instructions::load_from_paths(instruction_paths) {
490 DoctorCheck {
491 status: "ok",
492 message: format!(
493 "{} bytes loaded from {} source(s){}.",
494 loaded.byte_len,
495 loaded.sources.len(),
496 if loaded.truncated { " (truncated)" } else { "" }
497 ),
498 }
499 } else {
500 DoctorCheck {
501 status: "warning",
502 message: "Instruction files were found but could not be loaded.".to_string(),
503 }
504 }
505}
506
507fn runtime_checks() -> DoctorRuntime {
509 let daemon = match RuntimeClient::daemon().health() {
510 Ok(read) => DoctorCheck {
511 status: "ok",
512 message: format!("daemon attached; database {}", read.value.database),
513 },
514 Err(err) => DoctorCheck {
515 status: "info",
516 message: format!("daemon not attached; CLI will use local runtime store ({err})"),
517 },
518 };
519 let local_store = match RuntimeClient::local().health() {
520 Ok(read) => DoctorCheck {
521 status: "ok",
522 message: format!("local runtime store ready at {}", read.value.database),
523 },
524 Err(err) => DoctorCheck {
525 status: "warning",
526 message: format!("local runtime store unavailable: {err}"),
527 },
528 };
529 DoctorRuntime {
530 daemon,
531 local_store,
532 }
533}
534
535fn doctor_tools(config: &Config, cwd: &Path) -> (Vec<String>, Vec<String>) {
537 let mut tools = vec![
538 "read/edit/write files".to_string(),
539 "run shell commands".to_string(),
540 "create checkpoints before risky mutations".to_string(),
541 ];
542 let (web_tools, web_next_steps) = web_doctor_entries(config);
543 tools.extend(web_tools);
544 if !config.mcp_servers.is_empty() {
545 tools.push(format!(
546 "{} configured MCP server(s)",
547 config.mcp_servers.len()
548 ));
549 }
550 if let Some(skills) = crate::app::skills::load(cwd) {
551 tools.push(format!(
552 "{} skill(s) discovered (SKILL.md playbooks)",
553 skills.entries.len()
554 ));
555 }
556 (tools, web_next_steps)
557}
558
559fn session_log_drift(cwd: &Path) -> DoctorCheck {
573 let Ok(manager) = ConversationManager::new(cwd) else {
574 return DoctorCheck {
575 status: "ok",
576 message: "no .mermaid directory in this project".to_string(),
577 };
578 };
579 let Ok(metas) = manager.list_conversation_metas() else {
580 return DoctorCheck {
581 status: "warning",
582 message: "could not list this project's sessions".to_string(),
583 };
584 };
585
586 let mut checked = 0usize;
587 let mut drifted = Vec::new();
588 for meta in &metas {
589 let Ok(Some(folded)) = manager.fold_conversation_from_log(&meta.id) else {
590 continue;
593 };
594 let Ok(loaded) = manager.load_conversation(&meta.id) else {
595 drifted.push(format!("{} (would not load)", meta.id));
596 continue;
597 };
598 checked += 1;
599 let same = folded.messages().len() == loaded.messages().len()
603 && folded
604 .messages()
605 .iter()
606 .zip(loaded.messages())
607 .all(|(a, b)| a.role == b.role && a.content == b.content);
608 if !same {
609 drifted.push(format!(
610 "{} (log folds to {} messages, resume loads {})",
611 meta.id,
612 folded.messages().len(),
613 loaded.messages().len()
614 ));
615 }
616 }
617
618 if drifted.is_empty() {
619 return DoctorCheck {
620 status: "ok",
621 message: match checked {
622 0 => "no session event logs in this project yet".to_string(),
623 1 => "1 session: its log and checkpoint agree".to_string(),
624 n => format!("{n} sessions: every log and checkpoint agree"),
625 },
626 };
627 }
628 DoctorCheck {
629 status: "warning",
630 message: format!(
631 "{} of {checked} sessions disagree with their log: {}",
632 drifted.len(),
633 drifted.join(", ")
634 ),
635 }
636}
637
638fn print_doctor_report(report: &DoctorReport, format: OutputFormat) -> Result<()> {
639 match format {
640 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
641 OutputFormat::Ndjson => println!("{}", serde_json::to_string(report)?),
642 OutputFormat::Markdown => {
643 println!("# Mermaid Doctor\n");
644 print_doctor_text(report);
645 },
646 OutputFormat::Text => print_doctor_text(report),
647 }
648 Ok(())
649}
650
651fn print_doctor_text(report: &DoctorReport) {
652 println!(
653 "Mermaid Doctor: {}",
654 if report.ok {
655 "ready"
656 } else {
657 "needs attention"
658 }
659 );
660 println!("Project: {}", report.cwd);
661 match (&report.active_model, &report.model_error) {
662 (Some(model), _) => println!(" [OK] Active model: {model}"),
663 (None, Some(error)) => println!(" [WARNING] Active model: {error}"),
664 _ => println!(" [WARNING] Active model: unresolved"),
665 }
666 if let Some(caps) = &report.model_capabilities {
667 println!(
668 " provider={} tools={} vision={} reasoning={} context={}",
669 caps.provider,
670 caps.supports_tools,
671 caps.supports_vision,
672 caps.reasoning,
673 caps.max_context_tokens
674 .map(|n| n.to_string())
675 .unwrap_or_else(|| "unknown".to_string())
676 );
677 }
678 println!(
679 " [{}] Ollama: {}",
680 label(report.ollama.status),
681 report.ollama.message
682 );
683 println!(
684 " [INFO] Remote providers: {}",
685 if report.remote_providers.is_empty() {
686 "none configured".to_string()
687 } else {
688 report.remote_providers.join(", ")
689 }
690 );
691 for problem in &report.provider_problems {
692 println!(
693 " [WARNING] Provider {} is configured but unusable: {}",
694 problem.name, problem.reason
695 );
696 }
697 println!(
698 " [{}] Project instructions: {}",
699 label(report.project_instructions.status),
700 report.project_instructions.message
701 );
702 println!(
703 " [INFO] Safety: mode={}, checkpoint_on_mutation={}",
704 report.safety_mode, report.checkpoint_on_mutation
705 );
706 if let Some(profile) = &report.active_profile {
707 println!(" [INFO] Config profile: {profile}");
708 }
709 println!(
710 " [INFO] Prompt customization: {}",
711 if report.prompt_customized {
712 "active"
713 } else {
714 "default"
715 }
716 );
717 println!(
718 " [INFO] Output style: {} (from {})",
719 report.output_style, report.output_style_source
720 );
721 println!(
722 " [{}] Runtime daemon: {}",
723 label(report.runtime.daemon.status),
724 report.runtime.daemon.message
725 );
726 println!(
727 " [{}] Runtime store: {}",
728 label(report.runtime.local_store.status),
729 report.runtime.local_store.message
730 );
731 println!(
732 " [{}] Session logs: {}",
733 label(report.session_logs.status),
734 report.session_logs.message
735 );
736 println!(" [OK] Tool surface:");
737 for tool in &report.tools {
738 println!(" - {tool}");
739 }
740 println!("\nNext steps:");
741 for step in &report.next_steps {
742 println!(" - {step}");
743 }
744}
745
746#[derive(Debug, serde::Serialize)]
747struct SelfTestReport {
748 ok: bool,
749 workspace: String,
750 checks: Vec<String>,
751 compact_smoke: QaCompactSmokeReport,
752 runtime_store: DoctorCheck,
753 kept_workspace: bool,
754}
755
756fn run_self_test(config: &Config, format: OutputFormat, keep_workspace: bool) -> Result<()> {
757 let workspace = std::env::temp_dir().join(format!("mermaid-self-test-{}", fresh_qa_id()));
758 std::fs::create_dir_all(&workspace)
759 .with_context(|| format!("failed to create {}", workspace.display()))?;
760
761 let compact_smoke = match run_qa_compact_smoke(config, &workspace, 6) {
762 Ok(report) => report,
763 Err(err) => QaCompactSmokeReport::failed(&workspace, 6, err.to_string()),
764 };
765 let runtime_store = match RuntimeClient::local().health() {
766 Ok(read) => DoctorCheck {
767 status: "ok",
768 message: format!("local runtime store ready at {}", read.value.database),
769 },
770 Err(err) => DoctorCheck {
771 status: "warning",
772 message: format!("{err:#}"),
779 },
780 };
781
782 let sandbox_available = mermaid_runtime::network_killswitch_available();
786 let fs_sandbox_available = mermaid_runtime::fs_confinement_available();
787 let (network_check, fs_check) = if cfg!(target_os = "linux") {
788 (
789 "network kill-switch (seccomp) builds on this platform",
790 "filesystem confinement (Landlock) ruleset builds on this platform",
791 )
792 } else if cfg!(target_os = "macos") {
793 (
794 "network sandbox (Seatbelt via sandbox-exec) available on this platform",
795 "filesystem confinement (Seatbelt via sandbox-exec) available on this platform",
796 )
797 } else if cfg!(target_os = "windows") {
798 (
799 "network sandbox (AppContainer) available on this platform",
800 "filesystem confinement (AppContainer) available on this platform",
801 )
802 } else {
803 (
804 "network sandbox backend available on this platform",
805 "filesystem confinement backend available on this platform",
806 )
807 };
808 let checks = vec![
809 "compact smoke exercises reducer compaction path".to_string(),
810 "compact smoke persists conversation and archive artifacts".to_string(),
811 "local runtime store opens without daemon".to_string(),
812 format!(
813 "{network_check}: {}",
814 if sandbox_available { "yes" } else { "no" }
815 ),
816 format!(
817 "{fs_check}: {}",
818 if fs_sandbox_available { "yes" } else { "no" }
819 ),
820 format!(
823 "read_only mode runs commands in the OS sandbox: {}",
824 if mermaid_runtime::read_only_containment_available() {
825 "yes"
826 } else {
827 "no (shell allowlists decide)"
828 }
829 ),
830 ];
831 let sandbox_expected = cfg!(any(
834 target_os = "linux",
835 target_os = "macos",
836 target_os = "windows"
837 ));
838 let ok = compact_smoke.ok
839 && runtime_store.status == "ok"
840 && (!sandbox_expected || (sandbox_available && fs_sandbox_available));
841 let report = SelfTestReport {
842 ok,
843 workspace: workspace.display().to_string(),
844 checks,
845 compact_smoke,
846 runtime_store,
847 kept_workspace: keep_workspace,
848 };
849
850 print_self_test_report(&report, format)?;
851 if !keep_workspace {
852 let _ = std::fs::remove_dir_all(&workspace);
853 }
854 anyhow::ensure!(report.ok, "mermaid self-test failed");
855 Ok(())
856}
857
858fn print_self_test_report(report: &SelfTestReport, format: OutputFormat) -> Result<()> {
859 match format {
860 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
861 OutputFormat::Ndjson => println!("{}", serde_json::to_string(report)?),
862 OutputFormat::Markdown => {
863 println!("# Mermaid Self-Test\n");
864 print_self_test_text(report);
865 },
866 OutputFormat::Text => print_self_test_text(report),
867 }
868 Ok(())
869}
870
871fn print_self_test_text(report: &SelfTestReport) {
872 println!(
873 "Mermaid self-test: {}",
874 if report.ok { "ok" } else { "failed" }
875 );
876 println!("workspace: {}", report.workspace);
877 println!(
878 "compact smoke: {}",
879 if report.compact_smoke.ok {
880 "ok"
881 } else {
882 "failed"
883 }
884 );
885 println!("runtime store: {}", report.runtime_store.message);
886 println!("checks:");
887 for check in &report.checks {
888 println!(" - {check}");
889 }
890 if !report.ok
891 && let Some(failure) = &report.compact_smoke.failure
892 {
893 println!("failure: {failure}");
894 }
895}
896
897fn label(status: &str) -> &'static str {
898 match status {
899 "ok" => "OK",
900 "warning" => "WARNING",
901 "error" => "ERROR",
902 _ => "INFO",
903 }
904}
905
906fn safety_mode_name(mode: mermaid_runtime::SafetyMode) -> &'static str {
907 mode.as_str()
908}
909
910fn login_providers(config: &Config) -> Vec<(String, String, Option<String>)> {
914 let over = |name: &str| {
915 config
916 .providers
917 .get(name)
918 .and_then(|c| c.api_key_env.clone())
919 };
920 let mut rows: Vec<(String, String, Option<String>)> = vec![
921 (
922 "anthropic".to_string(),
923 "ANTHROPIC_API_KEY".to_string(),
924 over("anthropic"),
925 ),
926 (
927 "gemini".to_string(),
928 "GOOGLE_API_KEY".to_string(),
929 over("gemini"),
930 ),
931 (
932 "meta".to_string(),
933 crate::providers::model::meta::DEFAULT_API_KEY_ENV.to_string(),
934 over("meta"),
935 ),
936 (
937 "ollama".to_string(),
938 "OLLAMA_API_KEY".to_string(),
939 over("ollama"),
940 ),
941 ];
942 for profile in PROVIDER_REGISTRY {
943 rows.push((
944 profile.name.to_string(),
945 profile.api_key_env.to_string(),
946 over(profile.name),
947 ));
948 }
949 for (name, cfg) in &config.providers {
950 if rows.iter().any(|(n, _, _)| n == name) {
951 continue;
952 }
953 if let Some(env) = &cfg.api_key_env {
955 rows.push((name.clone(), env.clone(), None));
956 }
957 }
958 rows.sort_by(|a, b| a.0.cmp(&b.0));
959 rows
960}
961
962fn login(provider: Option<&str>, config: &Config) -> Result<()> {
966 let rows = login_providers(config);
967 let Some(provider) = provider else {
968 println!(
969 "Provider API-key status (env beats keyring; `mermaid login <provider>` stores a key):\n"
970 );
971 for (name, default_env, override_env) in &rows {
972 let source = mermaid_model::utils::provider_key_source(
973 name,
974 default_env,
975 override_env.as_deref(),
976 );
977 let env_name = override_env.as_deref().unwrap_or(default_env);
978 println!(" {name:<14} {source:<8} (${env_name})");
979 }
980 return Ok(());
981 };
982 let provider = provider.to_lowercase();
983 let Some((name, default_env, override_env)) = rows.into_iter().find(|(n, _, _)| n == &provider)
984 else {
985 let names: Vec<String> = login_providers(config)
986 .into_iter()
987 .map(|(n, _, _)| n)
988 .collect();
989 anyhow::bail!(
990 "unknown provider '{}'; known: {}",
991 provider,
992 names.join(", ")
993 );
994 };
995 let key = rpassword::prompt_password(format!("API key for {name} (input hidden): "))
996 .context("read API key")?;
997 let key = key.trim();
998 anyhow::ensure!(!key.is_empty(), "no key entered; nothing stored");
999 let store = mermaid_model::utils::default_store();
1000 store
1001 .set(&name, key)
1002 .with_context(|| format!("store key for {name}"))?;
1003 println!(
1004 "Stored key for {} in {} (service \"mermaid\").",
1005 name,
1006 store.label()
1007 );
1008 if mermaid_model::utils::resolve_api_key(&default_env, override_env.as_deref()).is_some() {
1010 let env_name = override_env.as_deref().unwrap_or(&default_env);
1011 println!("Note: ${env_name} is currently set and takes precedence over the stored key.");
1012 }
1013 Ok(())
1014}
1015
1016fn logout(provider: &str, config: &Config) -> Result<()> {
1019 let provider = provider.to_lowercase();
1020 let _ = config;
1023 let store = mermaid_model::utils::default_store();
1024 if store
1025 .delete(&provider)
1026 .with_context(|| format!("delete key for {provider}"))?
1027 {
1028 println!(
1029 "Removed stored key for {} from {}.",
1030 provider,
1031 store.label()
1032 );
1033 } else {
1034 println!("No stored key for {provider}.");
1035 }
1036 Ok(())
1037}
1038
1039fn meta_api_key(config: &Config) -> Option<String> {
1040 mermaid_model::utils::resolve_provider_key(
1041 "meta",
1042 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1043 config
1044 .providers
1045 .get("meta")
1046 .and_then(|provider| provider.api_key_env.as_deref()),
1047 )
1048}
1049
1050fn meta_base_url(config: &Config) -> String {
1051 config
1052 .providers
1053 .get("meta")
1054 .and_then(|provider| provider.base_url.clone())
1055 .unwrap_or_else(|| crate::providers::model::meta::DEFAULT_BASE_URL.to_string())
1056}
1057
1058#[derive(Debug, serde::Serialize)]
1059struct QaCompactSmokeReport {
1060 ok: bool,
1061 turns: usize,
1062 archived_messages: usize,
1063 preserved_messages: usize,
1064 replacement_messages: usize,
1065 conversation_path: Option<String>,
1066 archive_path: Option<String>,
1067 checks: Vec<String>,
1068 failure: Option<String>,
1069}
1070
1071impl QaCompactSmokeReport {
1072 fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
1073 Self {
1074 ok: false,
1075 turns,
1076 archived_messages: 0,
1077 preserved_messages: 0,
1078 replacement_messages: 0,
1079 conversation_path: Some(
1080 cwd.join(".mermaid")
1081 .join("conversations")
1082 .display()
1083 .to_string(),
1084 ),
1085 archive_path: None,
1086 checks: Vec::new(),
1087 failure: Some(failure),
1088 }
1089 }
1090}
1091
1092#[expect(
1093 clippy::too_many_lines,
1094 reason = "an in-binary smoke test with a test's shape: build a synthetic session, drive a \
1095 manual compaction through the reducer, replay the save commands, then assert on the files \
1096 and record each check for the report; the arrange/act/assert steps read as one scenario and \
1097 would lose their sequence split across helpers"
1098)]
1099fn run_qa_compact_smoke(
1100 config: &Config,
1101 cwd: &Path,
1102 requested_turns: usize,
1103) -> Result<QaCompactSmokeReport> {
1104 let turns = requested_turns.max(3);
1105 let mut state = State::new(
1106 config.clone(),
1107 cwd.to_path_buf(),
1108 qa_model_id(config),
1109 chrono::Local::now(),
1110 std::env::temp_dir(),
1111 );
1112 for message in synthetic_compaction_messages(turns) {
1113 state.session.append(message, state.now);
1114 }
1115 let pre_manager = ConversationManager::new(cwd)?;
1121 let pre_snapshot = state.session.snapshot_conversation();
1122 let pre_events = state.session.drain_events(&pre_snapshot);
1123 pre_manager.append_session_events(&pre_snapshot, &pre_events)?;
1124 pre_manager.save_conversation(&pre_snapshot)?;
1125 let dropped_probe = state
1126 .session
1127 .messages()
1128 .first()
1129 .map(|message| message.content.clone())
1130 .context("synthetic history is empty")?;
1131
1132 let (state_after_slash, compact_cmds) = update(
1133 state,
1134 Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
1135 );
1136 let turn = state_after_slash
1137 .turn
1138 .id()
1139 .context("manual compaction did not enter a compaction turn")?;
1140 let request = compact_cmds
1141 .iter()
1142 .find_map(|cmd| match cmd {
1143 Cmd::CompactConversation { request, .. } => Some(request.clone()),
1144 _ => None,
1145 })
1146 .context("manual compaction did not emit a CompactConversation command")?;
1147
1148 let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
1149 let prepared = prepare_compaction(&request, Some(100_000))
1150 .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
1151 anyhow::ensure!(
1152 !prepared.archived_messages.is_empty(),
1153 "compaction archived no messages"
1154 );
1155 anyhow::ensure!(
1156 !prepared.preserved_messages.is_empty(),
1157 "compaction preserved no messages"
1158 );
1159
1160 let summary = deterministic_compaction_summary(&prepared, turns);
1161 let mut record = CompactionEvent {
1162 id: format!("qa_compact_{}", fresh_qa_id()),
1163 trigger: CompactionTrigger::Manual,
1164 created_at: chrono::Local::now(),
1165 before_tokens: before_snapshot.used_tokens,
1166 after_tokens: 0,
1167 archived_message_count: prepared.archived_messages.len(),
1168 preserved_message_count: prepared.preserved_messages.len(),
1169 preserved_turn_count: prepared
1170 .preserved_messages
1171 .iter()
1172 .filter(|message| message.role == mermaid_model::models::MessageRole::User)
1173 .count(),
1174 summary_tokens: summary.len().div_ceil(4),
1175 duration_secs: 0.0,
1176 focus: Some("qa compact smoke".to_string()),
1177 archive_path: None,
1178 };
1179 let mut replacement = build_replacement_messages(&summary, &prepared, &record);
1180 let mut after_chat: ChatRequest = request.chat.clone();
1181 after_chat.messages = replacement.clone();
1182 let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1183 record.after_tokens = after_snapshot.used_tokens;
1184 replacement = build_replacement_messages(&summary, &prepared, &record);
1185 after_chat.messages = replacement.clone();
1186 after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1187
1188 let result = CompactionResult {
1189 record,
1190 replacement_messages: replacement,
1191 archived_messages: prepared.archived_messages,
1192 before_snapshot,
1193 after_snapshot,
1194 usage: None,
1195 source_boundaries: Vec::new(),
1196 };
1197 let (final_state, save_cmds) =
1198 update(state_after_slash, Msg::CompactionFinished { turn, result });
1199
1200 let manager = ConversationManager::new(cwd)?;
1201 let mut conversation_path = None;
1202 let mut archive_path = None;
1203 for cmd in save_cmds {
1204 match cmd {
1205 Cmd::SaveConversation {
1206 snapshot: conversation,
1207 ..
1208 } => {
1209 manager.save_conversation(&conversation)?;
1210 conversation_path = Some(
1211 manager
1212 .conversations_dir()
1213 .join(format!("{}.json", conversation.id))
1214 .display()
1215 .to_string(),
1216 );
1217 },
1218 Cmd::SaveCompaction {
1219 conversation,
1220 events,
1221 ..
1222 } => {
1223 manager.append_session_events(&conversation, &events)?;
1228 archive_path = Some(
1229 manager
1230 .event_log_path(&conversation.id)
1231 .display()
1232 .to_string(),
1233 );
1234 manager.save_conversation(&conversation)?;
1235 conversation_path = Some(
1236 manager
1237 .conversations_dir()
1238 .join(format!("{}.json", conversation.id))
1239 .display()
1240 .to_string(),
1241 );
1242 },
1243 _ => {},
1244 }
1245 }
1246
1247 let conversation_path = conversation_path.context("compaction did not save conversation")?;
1248 let archive_path = archive_path.context("compaction did not save archive")?;
1249 let messages = final_state.session.messages();
1250 let compactions = &final_state.session.conversation.compactions;
1251
1252 let mut checks = Vec::new();
1253 anyhow::ensure!(
1254 !compactions.is_empty(),
1255 "conversation did not record compaction metadata"
1256 );
1257 checks.push("conversation records compaction metadata".to_string());
1258 anyhow::ensure!(
1259 messages.first().is_some_and(
1260 |msg| msg.kind == mermaid_model::models::ChatMessageKind::ContextCheckpoint
1261 ),
1262 "replacement does not start with a context checkpoint"
1263 );
1264 checks.push("replacement starts with context checkpoint".to_string());
1265 anyhow::ensure!(
1266 std::path::Path::new(&conversation_path).exists(),
1267 "conversation file missing after save"
1268 );
1269 checks.push("conversation file saved".to_string());
1270 anyhow::ensure!(
1271 std::path::Path::new(&archive_path).exists(),
1272 "session event log missing after compaction save"
1273 );
1274 checks.push("session event log saved".to_string());
1275 let log = std::fs::read_to_string(&archive_path).context("read the session event log")?;
1280 anyhow::ensure!(
1281 log.contains("\"type\":\"compaction\""),
1282 "event log has no compaction boundary"
1283 );
1284 anyhow::ensure!(
1285 !messages
1286 .iter()
1287 .any(|message| message.content == dropped_probe),
1288 "the probe message was not actually dropped by the compaction"
1289 );
1290 anyhow::ensure!(
1291 log.contains(dropped_probe.trim()),
1292 "event log lost a message the compaction dropped"
1293 );
1294 checks.push("dropped messages survive in the event log".to_string());
1295 anyhow::ensure!(
1296 compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
1297 "compaction did not archive and preserve messages"
1298 );
1299 checks.push("archived and preserved message counts are non-zero".to_string());
1300
1301 Ok(QaCompactSmokeReport {
1302 ok: true,
1303 turns,
1304 archived_messages: compactions[0].archived_message_count,
1305 preserved_messages: compactions[0].preserved_message_count,
1306 replacement_messages: messages.len(),
1307 conversation_path: Some(conversation_path),
1308 archive_path: Some(archive_path),
1309 checks,
1310 failure: None,
1311 })
1312}
1313
1314fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
1315 match format {
1316 OutputFormat::Json => {
1317 println!("{}", serde_json::to_string_pretty(report)?);
1318 },
1319 OutputFormat::Ndjson => {
1320 println!("{}", serde_json::to_string(report)?);
1321 },
1322 OutputFormat::Text => {
1323 println!(
1324 "qa compact smoke: {}",
1325 if report.ok { "ok" } else { "failed" }
1326 );
1327 println!("turns: {}", report.turns);
1328 println!("archived messages: {}", report.archived_messages);
1329 println!("preserved messages: {}", report.preserved_messages);
1330 println!("replacement messages: {}", report.replacement_messages);
1331 if let Some(path) = &report.conversation_path {
1332 println!("conversation: {path}");
1333 }
1334 if let Some(path) = &report.archive_path {
1335 println!("archive: {path}");
1336 }
1337 if let Some(failure) = &report.failure {
1338 println!("failure: {failure}");
1339 }
1340 },
1341 OutputFormat::Markdown => {
1342 println!(
1343 "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
1344 if report.ok { "ok" } else { "failed" },
1345 report.turns,
1346 report.archived_messages,
1347 report.preserved_messages,
1348 report.replacement_messages
1349 );
1350 if let Some(path) = &report.conversation_path {
1351 println!("- Conversation: `{path}`");
1352 }
1353 if let Some(path) = &report.archive_path {
1354 println!("- Archive: `{path}`");
1355 }
1356 if let Some(failure) = &report.failure {
1357 println!("\nFailure: `{failure}`");
1358 }
1359 },
1360 }
1361 Ok(())
1362}
1363
1364fn qa_model_id(config: &Config) -> String {
1365 if let Some(model) = config
1366 .last_used_model
1367 .as_ref()
1368 .filter(|value| !value.is_empty())
1369 {
1370 return model.clone();
1371 }
1372 if !config.default_model.name.is_empty() {
1373 if config.default_model.provider.is_empty() {
1374 return config.default_model.name.clone();
1375 }
1376 return format!(
1377 "{}/{}",
1378 config.default_model.provider, config.default_model.name
1379 );
1380 }
1381 "qa/deterministic".to_string()
1382}
1383
1384fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
1385 let mut messages = Vec::with_capacity(turns.saturating_mul(2));
1386 for idx in 1..=turns {
1387 messages.push(ChatMessage::user(format!(
1388 "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
1389 )));
1390 messages.push(ChatMessage::assistant(format!(
1391 "Assistant turn {idx}: inspected src/domain/compaction.rs, tests/reducer_flows.rs, and scripts/qa_mermaid.py; noted command `cargo test --all-targets` result placeholder {idx}."
1392 )));
1393 }
1394 messages
1395}
1396
1397fn deterministic_compaction_summary(
1398 prepared: &mermaid_domain::PreparedCompaction,
1399 turns: usize,
1400) -> String {
1401 format!(
1402 "QA compact smoke: a synthetic conversation of {turns} user/assistant turns, compacted \
1403 through the reducer path with a deterministic summary so fast QA needs no model. \
1404 Archived {} messages and preserved {}. Next: keep using the real-model QA tier \
1405 for end-to-end checks.",
1406 prepared.archived_messages.len(),
1407 prepared.preserved_messages.len()
1408 )
1409}
1410
1411fn fresh_qa_id() -> u128 {
1412 std::time::SystemTime::now()
1413 .duration_since(std::time::UNIX_EPOCH)
1414 .map(|duration| duration.as_nanos())
1415 .unwrap_or_default()
1416}
1417
1418fn show_tasks(limit: usize) -> Result<()> {
1419 let read = RuntimeClient::auto().list_tasks(limit)?;
1420 let mut tasks = read.value;
1421 tasks.truncate(limit);
1422 println!("Mermaid runtime tasks");
1423 println!("Source: {}", read.source.as_str());
1424 println!();
1425 if tasks.is_empty() {
1426 println!("No tasks recorded yet.");
1427 return Ok(());
1428 }
1429 for task in tasks {
1430 println!(
1431 "{} [{}] {} {} {}",
1432 task.id, task.status, task.priority, task.updated_at, task.title
1433 );
1434 println!(" project: {}", task.project_path);
1435 println!(" model: {}", task.model_id);
1436 }
1437 Ok(())
1438}
1439
1440fn show_task(id: &str) -> Result<()> {
1441 let detail = RuntimeClient::auto().task_detail(id)?.value;
1442 print_task_detail(&detail.task);
1443 let events = detail.events;
1444 if !events.is_empty() {
1445 println!();
1446 println!("Timeline:");
1447 for event in events {
1448 println!(" {} {} {}", event.created_at, event.kind, event.message);
1449 }
1450 }
1451 Ok(())
1452}
1453
1454fn print_task_detail(task: &TaskRecord) {
1455 println!("Task: {}", task.id);
1456 println!("Title: {}", task.title);
1457 println!("Status: {}", task.status);
1458 println!("Priority: {}", task.priority);
1459 println!("Project: {}", task.project_path);
1460 println!("Model: {}", task.model_id);
1461 if let Some(conversation_id) = &task.conversation_id {
1462 println!("Conversation: {conversation_id}");
1463 }
1464 println!("Created: {}", task.created_at);
1465 println!("Updated: {}", task.updated_at);
1466 if let Some(report) = &task.final_report {
1467 println!();
1468 println!("Final report:");
1469 println!("{}", sanitize_terminal_text(report));
1470 }
1471}
1472
1473fn show_processes(limit: usize) -> Result<()> {
1474 let read = RuntimeClient::auto().list_processes(limit)?;
1475 let mut processes = read.value;
1476 processes.truncate(limit);
1477 println!("Mermaid runtime processes");
1478 println!("Source: {}", read.source.as_str());
1479 println!();
1480 if processes.is_empty() {
1481 println!("No processes recorded yet.");
1482 return Ok(());
1483 }
1484 for process in processes {
1485 println!(
1486 "{} pid={} status={} {}",
1487 process.id,
1488 process.pid,
1489 process.status.as_str(),
1490 process.command
1491 );
1492 if let Some(task_id) = process.task_id {
1493 println!(" task: {task_id}");
1494 }
1495 if let Some(cwd) = process.cwd {
1496 println!(" cwd: {cwd}");
1497 }
1498 if let Some(log_path) = process.log_path {
1499 println!(" log: {log_path}");
1500 }
1501 if let Some(url) = process.detected_url {
1502 println!(" url: {url}");
1503 }
1504 }
1505 Ok(())
1506}
1507
1508async fn show_models(config: &Config) -> Result<()> {
1509 list_models(config).await?;
1510 probe_configured_provider_models(config).await?;
1511 let store = RuntimeStore::open_default()?;
1512 let probes = store.provider_probes().list(None, None)?;
1513 if !probes.is_empty() {
1514 println!("\nCached capability probes:");
1515 for probe in probes {
1516 println!(
1517 " - {}/{} {}={} ({})",
1518 probe.provider,
1519 probe.model_id,
1520 probe.capability_key,
1521 probe.capability_value,
1522 probe.confidence
1523 );
1524 }
1525 }
1526 Ok(())
1527}
1528
1529async fn show_model_info(model: &str, config: &Config) -> Result<()> {
1530 let snapshot = mermaid_domain::ProviderCapabilitySnapshot::from_model_id(model);
1531 let store = RuntimeStore::open_default()?;
1532 let provider = snapshot.provider.clone();
1533
1534 let mut context_tokens = snapshot.max_context_tokens;
1541 let mut context_confidence = "static";
1542 let mut output_tokens = snapshot.max_output_tokens;
1543 let mut output_confidence = "static";
1544 let factory = crate::providers::ProviderFactory::new(config.clone());
1545 if let Ok(live) = factory.resolve(model).await {
1546 let probe_request = ChatRequest {
1547 model_id: model.to_string(),
1548 messages: vec![],
1549 system_prompt: String::new(),
1550 instructions: None,
1551 reasoning: mermaid_model::models::ReasoningLevel::None,
1552 temperature: 0.0,
1553 max_tokens: 0,
1554 tools: vec![],
1555 ollama_num_ctx: None,
1556 ollama_allow_ram_offload: None,
1557 resolved_context_window: None,
1558 resolved_max_output: None,
1559 output_schema: None,
1560 suppress_auto_compact: false,
1561 requested_compaction: None,
1562 native_compaction: None,
1563 native_tools: mermaid_model::models::NativeTools::default(),
1564 };
1565 let sizing = live.resolve_context_window(&probe_request).await;
1566 if let Some(window) = sizing.model_max.or(sizing.effective) {
1567 context_tokens = Some(window);
1568 context_confidence = "probed";
1569 }
1570 if let Some(output) = sizing.max_output {
1571 output_tokens = Some(output);
1572 output_confidence = "probed";
1573 }
1574 }
1575
1576 for (key, value) in [
1577 ("supports_tools", snapshot.supports_tools.to_string()),
1578 ("supports_vision", snapshot.supports_vision.to_string()),
1579 ("reasoning", snapshot.reasoning.clone()),
1580 ] {
1581 let _ = store.provider_probes().upsert(NewProviderProbe {
1582 provider: provider.clone(),
1583 model_id: snapshot.model.clone(),
1584 capability_key: key.to_string(),
1585 capability_value: value,
1586 confidence: "static".to_string(),
1587 error: None,
1588 });
1589 }
1590 let _ = store.provider_probes().upsert(NewProviderProbe {
1592 provider: provider.clone(),
1593 model_id: snapshot.model.clone(),
1594 capability_key: "max_context_tokens".to_string(),
1595 capability_value: context_tokens
1596 .map(|n| n.to_string())
1597 .unwrap_or_else(|| "unknown".to_string()),
1598 confidence: context_confidence.to_string(),
1599 error: None,
1600 });
1601 println!("Model: {model}");
1602 println!("Provider: {}", snapshot.provider);
1603 println!("Name: {}", snapshot.model);
1604 println!("Supports tools: {}", snapshot.supports_tools);
1605 println!("Supports vision: {}", snapshot.supports_vision);
1606 println!("Reasoning: {}", snapshot.reasoning);
1607 println!(
1608 "Context: {}",
1609 context_tokens
1610 .map(|n| format!("{n} ({context_confidence})"))
1611 .unwrap_or_else(|| "unknown".to_string())
1612 );
1613 println!(
1614 "Output limit: {}",
1615 output_tokens
1616 .map(|n| format!("{n} ({output_confidence})"))
1617 .unwrap_or_else(|| {
1618 "unknown (discovered live from the provider's models endpoint when exposed)"
1619 .to_string()
1620 })
1621 );
1622 if let Some(profile) = lookup_provider(&snapshot.provider) {
1623 record_static_provider_probes(&store, profile, &provider, &snapshot.model);
1624 println!("Token budget field: {:?}", profile.max_tokens_param);
1625 println!(
1626 "Single-tool-call models: {}",
1627 if profile.disable_parallel_tool_calls_for.is_empty() {
1628 "(none)".to_string()
1629 } else {
1630 profile.disable_parallel_tool_calls_for.join(", ")
1631 }
1632 );
1633 }
1634 Ok(())
1635}
1636
1637async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1638 let client = reqwest::Client::builder()
1639 .timeout(std::time::Duration::from_secs(5))
1640 .build()?;
1641 for profile in PROVIDER_REGISTRY {
1642 let user_cfg = config.providers.get(profile.name);
1643 let Some(api_key) = mermaid_model::utils::resolve_provider_key(
1644 profile.name,
1645 profile.api_key_env,
1646 user_cfg.and_then(|c| c.api_key_env.as_deref()),
1647 ) else {
1648 continue;
1649 };
1650 let Some(base_url) = crate::providers::factory::discovery_base_url(
1651 profile,
1652 user_cfg.and_then(|c| c.base_url.clone()),
1653 ) else {
1654 record_provider_probe(
1658 profile.name,
1659 "*",
1660 "models_availability",
1661 "failed",
1662 "failed",
1663 Some("CLOUDFLARE_ACCOUNT_ID not set".to_string()),
1664 );
1665 continue;
1666 };
1667 let url = format!("{}/models", base_url.trim_end_matches('/'));
1668 let mut request = client.get(&url).bearer_auth(api_key);
1669 for (name, value) in profile.extra_headers {
1670 request = request.header(*name, *value);
1671 }
1672 if let Some(user_cfg) = user_cfg {
1673 for (name, value) in &user_cfg.extra_headers {
1674 request = request.header(name, value);
1675 }
1676 }
1677
1678 let result = request.send().await;
1679 match result {
1680 Ok(response) if response.status().is_success() => {
1681 let status = response.status();
1682 let body: serde_json::Value = response.json().await.unwrap_or_default();
1683 let ids = body
1684 .get("data")
1685 .and_then(|v| v.as_array())
1686 .map(|items| {
1687 items
1688 .iter()
1689 .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1690 .map(str::to_string)
1691 .collect::<Vec<_>>()
1692 })
1693 .unwrap_or_default();
1694 record_provider_probe(
1695 profile.name,
1696 "*",
1697 "models_availability",
1698 &format!("available:{}:{}", status.as_u16(), ids.len()),
1699 "probed",
1700 None,
1701 );
1702 for model_id in ids.into_iter().take(200) {
1703 record_provider_probe(
1704 profile.name,
1705 &model_id,
1706 "model_listed",
1707 "true",
1708 "listed",
1709 None,
1710 );
1711 }
1712 },
1713 Ok(response) => {
1714 record_provider_probe(
1715 profile.name,
1716 "*",
1717 "models_availability",
1718 "failed",
1719 "failed",
1720 Some(format!("HTTP {}", response.status().as_u16())),
1721 );
1722 },
1723 Err(error) => {
1724 record_provider_probe(
1725 profile.name,
1726 "*",
1727 "models_availability",
1728 "failed",
1729 "failed",
1730 Some(error.to_string()),
1731 );
1732 },
1733 }
1734 }
1735 probe_meta_models(&client, config).await;
1736 Ok(())
1737}
1738
1739async fn probe_meta_models(client: &reqwest::Client, config: &Config) {
1740 let Some(api_key) = meta_api_key(config) else {
1741 return;
1742 };
1743 let url = format!("{}/models", meta_base_url(config).trim_end_matches('/'));
1744 let mut request = client.get(&url).bearer_auth(api_key);
1745 if let Some(provider) = config.providers.get("meta") {
1746 for (name, value) in &provider.extra_headers {
1747 request = request.header(name, value);
1748 }
1749 for (name, env_var) in &provider.env_headers {
1750 if let Ok(value) = std::env::var(env_var) {
1751 request = request.header(name, value);
1752 }
1753 }
1754 }
1755 match request.send().await {
1756 Ok(response) if response.status().is_success() => {
1757 let status = response.status();
1758 let body: serde_json::Value = response.json().await.unwrap_or_default();
1759 let ids = body
1760 .get("data")
1761 .and_then(serde_json::Value::as_array)
1762 .into_iter()
1763 .flatten()
1764 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1765 .map(str::to_string)
1766 .collect::<Vec<_>>();
1767 record_provider_probe(
1768 "meta",
1769 "*",
1770 "models_availability",
1771 &format!("available:{}:{}", status.as_u16(), ids.len()),
1772 "probed",
1773 None,
1774 );
1775 for model_id in ids.into_iter().take(200) {
1776 record_provider_probe("meta", &model_id, "model_listed", "true", "listed", None);
1777 }
1778 },
1779 Ok(response) => record_provider_probe(
1780 "meta",
1781 "*",
1782 "models_availability",
1783 "failed",
1784 "failed",
1785 Some(format!("HTTP {}", response.status().as_u16())),
1786 ),
1787 Err(error) => record_provider_probe(
1788 "meta",
1789 "*",
1790 "models_availability",
1791 "failed",
1792 "failed",
1793 Some(error.to_string()),
1794 ),
1795 }
1796}
1797
1798fn record_provider_probe(
1799 provider: &str,
1800 model_id: &str,
1801 key: &str,
1802 value: &str,
1803 confidence: &str,
1804 error: Option<String>,
1805) {
1806 if let Ok(store) = RuntimeStore::open_default() {
1807 let _ = store.provider_probes().upsert(NewProviderProbe {
1808 provider: provider.to_string(),
1809 model_id: model_id.to_string(),
1810 capability_key: key.to_string(),
1811 capability_value: value.to_string(),
1812 confidence: confidence.to_string(),
1813 error,
1814 });
1815 }
1816}
1817
1818fn show_approvals() -> Result<()> {
1819 let approvals = RuntimeClient::auto().list_approvals()?.value;
1820 if approvals.is_empty() {
1821 println!("No pending approvals.");
1822 return Ok(());
1823 }
1824 for approval in approvals {
1825 println!(
1826 "{} [{} -> {}] {}",
1827 approval.id,
1828 approval.risk_classification,
1829 approval.policy_decision,
1830 approval.proposed_action
1831 );
1832 if let Some(args) = approval.args_summary {
1833 println!(" args: {args}");
1834 }
1835 if let Some(checkpoint_id) = approval.checkpoint_id {
1836 println!(" checkpoint: {checkpoint_id}");
1837 }
1838 if approval.pending_action_json.is_some() {
1839 println!(" pending action: recorded");
1840 }
1841 }
1842 Ok(())
1843}
1844
1845fn approve(id: &str) -> Result<()> {
1846 let result = RuntimeClient::auto().approve(id)?;
1847 println!("Approved {id}");
1848 if result.replayed {
1849 println!("{}", result.summary);
1850 }
1851 Ok(())
1852}
1853
1854fn deny(id: &str) -> Result<()> {
1855 let _ = RuntimeClient::auto().deny(id)?;
1856 println!("Denied {id}");
1857 Ok(())
1858}
1859
1860fn follow_task(id: &str) -> Result<()> {
1865 let lines = mermaid_runtime::subscribe_daemon_lines(
1866 crate::runtime_client::DaemonRequest::SubscribeTask {
1867 task_id: id.to_string(),
1868 }
1869 .to_wire(),
1870 )
1871 .context("mermaid task --follow needs a running daemon (`mermaid daemon start`)")?;
1872 let mut saw_any = false;
1873 for line in lines {
1874 let line = line?;
1875 if line.trim().is_empty() {
1876 continue;
1877 }
1878 if !saw_any {
1880 saw_any = true;
1881 let ack: serde_json::Value =
1882 serde_json::from_str(line.trim()).context("daemon returned invalid JSON")?;
1883 if ack.get("ok").and_then(|v| v.as_bool()) == Some(false) {
1884 anyhow::bail!(
1885 "{}",
1886 ack.get("error")
1887 .and_then(|v| v.as_str())
1888 .unwrap_or("subscribe failed")
1889 );
1890 }
1891 println!("{}", line.trim());
1892 continue;
1893 }
1894 println!("{}", line.trim());
1895 if serde_json::from_str::<serde_json::Value>(line.trim())
1896 .ok()
1897 .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_string))
1898 .as_deref()
1899 == Some("result")
1900 {
1901 return Ok(());
1902 }
1903 }
1904 if saw_any {
1905 anyhow::bail!("stream ended without a result (daemon restarted mid-run?)");
1906 }
1907 anyhow::bail!("daemon closed the connection without responding");
1908}
1909
1910fn send_to_task(id: &str, text: &str) -> Result<()> {
1916 if text.trim().is_empty() {
1917 anyhow::bail!("nothing to send: --send needs a prompt");
1918 }
1919 mermaid_runtime::request_daemon_json(
1920 crate::runtime_client::DaemonRequest::SendToTask {
1921 id: id.to_string(),
1922 text: text.to_string(),
1923 }
1924 .to_wire(),
1925 )?;
1926 println!("Sent to {id}; it answers after the turn it is on.");
1927 Ok(())
1928}
1929
1930fn cancel_task(id: &str) -> Result<()> {
1935 match mermaid_runtime::request_daemon_json(
1936 crate::runtime_client::DaemonRequest::CancelTask { id: id.to_string() }.to_wire(),
1937 ) {
1938 Ok(response) => {
1939 if response.get("cancelling").and_then(|v| v.as_bool()) == Some(true) {
1940 println!("Cancelling {id} (running; the agent unwinds gracefully)");
1941 } else {
1942 println!("Cancelled {id}");
1943 }
1944 Ok(())
1945 },
1946 Err(daemon_err) => {
1947 let store = mermaid_runtime::RuntimeStore::open_default()?;
1948 match store.tasks().get(id)? {
1949 Some(task) if task.status == mermaid_runtime::TaskStatus::Queued => {
1950 store.tasks().update_status(
1951 id,
1952 mermaid_runtime::TaskStatus::Cancelled,
1953 Some("cancelled before start"),
1954 )?;
1955 println!("Cancelled {id} (was queued; daemon unreachable)");
1956 Ok(())
1957 },
1958 Some(task) => anyhow::bail!(
1959 "task {} is {} and the daemon request failed: {}",
1960 id,
1961 task.status,
1962 daemon_err
1963 ),
1964 None => anyhow::bail!("task not found: {id}"),
1965 }
1966 },
1967 }
1968}
1969
1970fn show_tool_runs(limit: usize) -> Result<()> {
1971 let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1972 runs.truncate(limit);
1973 if runs.is_empty() {
1974 println!("No tool runs recorded yet.");
1975 return Ok(());
1976 }
1977 for run in runs {
1978 println!(
1979 "{} [{}] {} started {}",
1980 run.id, run.status, run.tool_name, run.started_at
1981 );
1982 if let Some(turn_id) = run.turn_id {
1983 println!(" turn: {turn_id}");
1984 }
1985 if let Some(call_id) = run.call_id {
1986 println!(" call: {call_id}");
1987 }
1988 if let Some(finished_at) = run.finished_at {
1989 println!(" finished: {finished_at}");
1990 }
1991 }
1992 Ok(())
1993}
1994
1995fn show_checkpoints(limit: usize) -> Result<()> {
1996 let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1997 checkpoints.truncate(limit);
1998 if checkpoints.is_empty() {
1999 println!("No checkpoints recorded yet.");
2000 return Ok(());
2001 }
2002 for checkpoint in checkpoints {
2003 println!(
2004 "{} {} {}",
2005 checkpoint.id, checkpoint.created_at, checkpoint.project_path
2006 );
2007 println!(" snapshot: {}", checkpoint.snapshot_path);
2008 println!(" files: {}", checkpoint.changed_files_json);
2009 if let Some(approval_id) = checkpoint.approval_id {
2010 println!(" approval: {approval_id}");
2011 }
2012 }
2013 Ok(())
2014}
2015
2016fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
2017 if !mermaid_model::utils::confirm_or_refuse(
2021 &format!("Restore checkpoint {id}? This overwrites the current working tree."),
2022 force,
2023 )? {
2024 println!("Restore cancelled.");
2025 return Ok(());
2026 }
2027 let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
2028 println!("Restored {} ({} files)", manifest.id, manifest.files.len());
2029 if let Some(repo) = manifest.shadow_git_repo {
2030 println!("Shadow repo: {repo}");
2031 }
2032 if let Some(commit) = manifest.shadow_git_commit {
2033 println!("Shadow commit: {commit}");
2034 }
2035 if let Some(action) = manifest.pending_action {
2036 println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
2037 }
2038 Ok(())
2039}
2040
2041fn handle_plugin(command: &PluginCommand) -> Result<()> {
2042 match command {
2043 PluginCommand::Install { path } => {
2044 let preview = mermaid_runtime::plugin_capability_preview(path)?;
2045 print_plugin_capability_preview(&preview);
2046 let record = mermaid_runtime::install_plugin_from_path(path)?;
2047 println!(
2048 "Installed plugin {} ({}) — DISABLED.",
2049 record.name, record.id
2050 );
2051 println!(
2052 "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
2053 record.id
2054 );
2055 },
2056 PluginCommand::List => {
2057 let plugins = RuntimeClient::auto().list_plugins()?.value;
2058 if plugins.is_empty() {
2059 println!("No plugins installed.");
2060 } else {
2061 for plugin in plugins {
2062 println!(
2063 "{} [{}] {} ({})",
2064 plugin.id,
2065 if plugin.enabled {
2066 "enabled"
2067 } else {
2068 "disabled"
2069 },
2070 plugin.name,
2071 plugin.source
2072 );
2073 }
2074 }
2075 },
2076 PluginCommand::Enable { id } => {
2077 let client = RuntimeClient::auto();
2079 if let Some(plugin) = client
2080 .list_plugins()?
2081 .value
2082 .into_iter()
2083 .find(|p| p.id == *id || p.name == *id)
2084 && let Ok(preview) =
2085 mermaid_runtime::plugin_capability_preview(Path::new(&plugin.source))
2086 {
2087 print_plugin_capability_preview(&preview);
2088 }
2089 client.set_plugin_enabled(id, true)?;
2090 println!("Enabled plugin {id} — its hooks will now run.");
2091 },
2092 PluginCommand::Disable { id } => {
2093 RuntimeClient::auto().set_plugin_enabled(id, false)?;
2094 println!("Disabled plugin {id}");
2095 },
2096 PluginCommand::Audit { path } => {
2097 let manifest_path = if path.is_dir() {
2098 path.join("plugin.toml")
2099 } else {
2100 path.clone()
2101 };
2102 let raw = std::fs::read_to_string(&manifest_path)?;
2103 let manifest: mermaid_runtime::PluginManifest = toml::from_str(&raw)?;
2104 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
2105 mermaid_runtime::validate_plugin_manifest(&manifest, root)?;
2106 let preview = mermaid_runtime::plugin_capability_preview(path)?;
2107 println!("Plugin manifest is valid: {}", manifest.name);
2108 print_plugin_capability_preview(&preview);
2109 },
2110 }
2111 Ok(())
2112}
2113
2114fn print_plugin_capability_preview(preview: &mermaid_runtime::PluginCapabilityPreview) {
2115 println!(
2116 "ModelCapabilities declared by plugin {} (advisory, not sandbox-enforced):",
2117 preview.name
2118 );
2119 if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
2120 println!(" capabilities: (none declared)");
2121 } else {
2122 if !preview.declared_capabilities.is_empty() {
2123 println!(" declared: {}", preview.declared_capabilities.join(", "));
2124 }
2125 if let Some(value) = &preview.capabilities_toml {
2126 println!(
2127 " capabilities.toml: {}",
2128 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
2129 );
2130 }
2131 }
2132 if !preview.hooks.is_empty() {
2133 println!(" hooks: {}", preview.hooks.join(", "));
2134 }
2135 if !preview.mcp.is_empty() {
2136 println!(" mcp: {}", preview.mcp.join(", "));
2137 }
2138 if !preview.bin.is_empty() {
2139 println!(" bin: {}", preview.bin.join(", "));
2140 }
2141}
2142
2143fn handle_pair(command: &PairCommand) -> Result<()> {
2144 let store = RuntimeStore::open_default()?;
2145 match command {
2146 PairCommand::Create { label, ttl_days } => {
2147 let ttl = ttl_days.unwrap_or(mermaid_runtime::DEFAULT_PAIRING_TTL_DAYS);
2148 let expires_at = mermaid_runtime::pairing_expiry_from_now(ttl);
2149 let (token, hash) = mermaid_runtime::generate_pairing_token()?;
2150 let record =
2151 store
2152 .pairing_tokens()
2153 .create(&hash, label.as_deref(), expires_at.as_deref())?;
2154 println!("Pairing token id: {}", record.id);
2155 println!("Pairing token: {token}");
2156 println!(
2157 "Expires: {}",
2158 record.expires_at.as_deref().unwrap_or("never")
2159 );
2160 println!(
2161 "Use with daemon JSON by setting {}.",
2162 mermaid_runtime::daemon::DAEMON_TOKEN_ENV
2163 );
2164 println!("Store this now; Mermaid will not print it again.");
2165 },
2166 PairCommand::List => {
2167 let tokens = store.pairing_tokens().list()?;
2168 if tokens.is_empty() {
2169 println!("No pairing tokens.");
2170 } else {
2171 for t in tokens {
2173 println!(
2174 "{} [{}] label={} created={} expires={} last_used={}",
2175 t.id,
2176 if t.enabled { "active" } else { "revoked" },
2177 t.label.as_deref().unwrap_or("-"),
2178 t.created_at,
2179 t.expires_at.as_deref().unwrap_or("never"),
2180 t.last_used_at.as_deref().unwrap_or("never"),
2181 );
2182 }
2183 }
2184 },
2185 PairCommand::Revoke { id } => {
2186 if store.pairing_tokens().revoke(id)? {
2187 println!("Revoked pairing token {id}");
2188 } else {
2189 println!("No active pairing token with id {id}");
2190 }
2191 },
2192 }
2193 Ok(())
2194}
2195
2196fn sanitize_terminal_text(input: &str) -> String {
2205 let mut out = String::with_capacity(input.len());
2206 let mut chars = input.chars();
2207 while let Some(c) = chars.next() {
2208 match c {
2209 '\n' | '\t' => out.push(c),
2210 '\u{1b}' => match chars.next() {
2211 Some('[') => {
2214 for p in chars.by_ref() {
2215 if ('@'..='~').contains(&p) {
2216 break;
2217 }
2218 }
2219 },
2220 Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
2223 while let Some(p) = chars.next() {
2224 if p == '\u{07}' {
2225 break;
2226 }
2227 if p == '\u{1b}' {
2228 let mut peek = chars.clone();
2230 if peek.next() == Some('\\') {
2231 chars = peek;
2232 }
2233 break;
2234 }
2235 }
2236 },
2237 Some(mut b) => {
2240 while ('\u{20}'..='\u{2f}').contains(&b) {
2241 match chars.next() {
2242 Some(next) => b = next,
2243 None => break,
2244 }
2245 }
2246 },
2247 None => {},
2248 },
2249 c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
2251 c => out.push(c),
2252 }
2253 }
2254 out
2255}
2256
2257fn show_logs(id: &str) -> Result<()> {
2258 let content = RuntimeClient::auto().process_log(id, None)?.content;
2259 print!("{}", sanitize_terminal_text(&content));
2260 Ok(())
2261}
2262
2263fn stop_process(id: &str) -> Result<()> {
2264 let process = RuntimeClient::auto().stop_process(id)?.item;
2265 println!("Stopped process {} (pid {})", id, process.pid);
2266 Ok(())
2267}
2268
2269fn restart_process(id: &str) -> Result<()> {
2270 let process = RuntimeClient::auto().restart_process(id)?.item;
2271 println!("Restarted process {} (pid {})", id, process.pid);
2272 Ok(())
2273}
2274
2275fn open_target(target: &str) -> Result<()> {
2276 if RuntimeClient::auto().open_process(target).is_err() {
2277 mermaid_model::utils::open_file(target);
2278 }
2279 Ok(())
2280}
2281
2282fn show_ports() -> Result<()> {
2283 let ports = RuntimeClient::auto().ports()?.ports;
2284 print!("{}", sanitize_terminal_text(&ports));
2285 Ok(())
2286}
2287
2288pub async fn list_models(config: &Config) -> Result<()> {
2303 match observe_models(config).await {
2304 LocalModelListing::Unreachable if is_ollama_installed() => {
2305 println!("Ollama is installed but not running, and its model store could not be read.");
2306 println!("(It starts automatically when you use an Ollama model.)");
2307 },
2308 LocalModelListing::Unreachable => println!("Ollama is not installed; no local models."),
2309 LocalModelListing::Live(models) if models.is_empty() => {
2310 println!("No Ollama models installed locally.");
2311 },
2312 LocalModelListing::Live(models) => {
2313 println!("Ollama models (local/cloud):");
2314 for name in &models {
2315 println!(" - ollama/{name}");
2316 }
2317 },
2318 LocalModelListing::FromDisk(models) => {
2319 println!(
2320 "Ollama models (installed; server not running — starts automatically on use):"
2321 );
2322 for name in &models {
2323 println!(" - ollama/{name}");
2324 }
2325 },
2326 }
2327
2328 println!("\nConfigured remote providers:");
2329 let catalogs = crate::providers::discovery::provider_catalogs(config).await;
2330 if catalogs.is_empty() {
2331 println!(" (none — set a provider API key env var to enable)");
2332 }
2333 for catalog in &catalogs {
2334 println!(
2335 " - {} ({}) {}",
2336 catalog.provider.name,
2337 catalog.provider.source_label(),
2338 catalog.provider.endpoint
2339 );
2340 match &catalog.models {
2341 None => {
2344 println!(" (model list unavailable — the provider's /models did not answer)")
2345 },
2346 Some(models) if models.is_empty() => println!(" (provider lists no models)"),
2347 Some(models) => {
2348 for id in models {
2349 println!(" {}/{}", catalog.provider.name, id);
2350 }
2351 },
2352 }
2353 }
2354
2355 let problems = crate::providers::provider_problems(config);
2359 if !problems.is_empty() {
2360 println!("\nConfigured but not usable:");
2361 for problem in &problems {
2362 println!(" - {}: {}", problem.name, problem.reason);
2363 }
2364 }
2365
2366 println!("\nSwitch models in-session with /model <name>.");
2367 Ok(())
2368}
2369
2370const RELEASE_LATEST_API: &str =
2371 "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
2372const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
2373const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
2374
2375async fn run_update(check: bool, force: bool) -> Result<()> {
2381 let current = env!("CARGO_PKG_VERSION");
2382 println!("Installed: v{current}");
2383
2384 let client = reqwest::Client::builder()
2385 .timeout(std::time::Duration::from_secs(15))
2386 .build()?;
2387 let resp = client
2388 .get(RELEASE_LATEST_API)
2389 .header("User-Agent", "mermaid-cli")
2390 .header("Accept", "application/vnd.github+json")
2391 .send()
2392 .await
2393 .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
2394 if !resp.status().is_success() {
2395 bail!("GitHub Releases API returned HTTP {}", resp.status());
2396 }
2397 let release: serde_json::Value = resp.json().await?;
2398 let tag = release
2399 .get("tag_name")
2400 .and_then(|v| v.as_str())
2401 .ok_or_else(|| anyhow!("release response had no tag_name"))?;
2402 println!("Latest: {tag}");
2403
2404 let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
2405 if check {
2406 if up_to_date {
2407 println!("You're on the latest version.");
2408 } else {
2409 println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
2410 }
2411 return Ok(());
2412 }
2413 if up_to_date && !force {
2414 println!("Already up to date.");
2415 return Ok(());
2416 }
2417
2418 let exe =
2420 std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
2421 let install_dir = exe
2422 .parent()
2423 .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
2424
2425 let script_url = if cfg!(target_os = "windows") {
2430 INSTALL_PS1_URL
2431 } else {
2432 INSTALL_SH_URL
2433 };
2434 if !mermaid_model::utils::confirm_or_refuse(
2435 &format!(
2436 "About to download and run {script_url} to replace {}.",
2437 install_dir.display()
2438 ),
2439 force,
2440 )? {
2441 println!("Update cancelled.");
2442 return Ok(());
2443 }
2444
2445 println!("Updating {} …", install_dir.display());
2446 run_install_script(&client, install_dir).await?;
2447 println!("Updated. New version takes effect on the next run.");
2448 Ok(())
2449}
2450
2451async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
2454 let windows = cfg!(target_os = "windows");
2455 let url = if windows {
2456 INSTALL_PS1_URL
2457 } else {
2458 INSTALL_SH_URL
2459 };
2460 let script = client
2461 .get(url)
2462 .header("User-Agent", "mermaid-cli")
2463 .send()
2464 .await
2465 .map_err(|e| anyhow!("could not fetch install script: {e}"))?
2466 .error_for_status()?
2467 .text()
2468 .await?;
2469
2470 let ext = if windows { "ps1" } else { "sh" };
2471 let dir = mermaid_model::utils::private_temp_dir()
2478 .map_err(|e| anyhow!("could not create private temp dir for install script: {e}"))?;
2479 let nanos = std::time::SystemTime::now()
2480 .duration_since(std::time::UNIX_EPOCH)
2481 .map(|d| d.as_nanos())
2482 .unwrap_or_default();
2483 let script_path = dir.join(format!(
2484 "mermaid-update-{}-{nanos}.{ext}",
2485 std::process::id()
2486 ));
2487 stage_install_script(&script_path, script.as_bytes())
2488 .map_err(|e| anyhow!("could not stage install script: {e}"))?;
2489
2490 let mut cmd = if windows {
2491 let mut c = tokio::process::Command::new("powershell");
2492 c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
2493 c.arg(&script_path);
2494 c
2495 } else {
2496 let mut c = tokio::process::Command::new("sh");
2497 c.arg(&script_path);
2498 c
2499 };
2500 cmd.env("MERMAID_INSTALL_DIR", install_dir)
2501 .env("MERMAID_NO_MODIFY_PATH", "1");
2502
2503 let status = cmd
2504 .status()
2505 .await
2506 .map_err(|e| anyhow!("could not run install script: {e}"))?;
2507 let _ = std::fs::remove_file(&script_path);
2508 if !status.success() {
2509 bail!("install script exited with {:?}", status.code());
2510 }
2511 Ok(())
2512}
2513
2514fn stage_install_script(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2520 use std::io::Write;
2521 #[cfg(unix)]
2522 let mut file = {
2523 use std::os::unix::fs::OpenOptionsExt;
2524 std::fs::OpenOptions::new()
2525 .write(true)
2526 .create_new(true)
2527 .mode(0o600)
2528 .open(path)?
2529 };
2530 #[cfg(not(unix))]
2531 let mut file = std::fs::OpenOptions::new()
2532 .write(true)
2533 .create_new(true)
2534 .open(path)?;
2535 file.write_all(bytes)
2536}
2537
2538fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
2540 let core = s.trim().trim_start_matches('v');
2541 let core = core.split(['-', '+']).next().unwrap_or(core);
2542 let mut parts = core.split('.');
2543 let major = parts.next()?.parse().ok()?;
2544 let minor = parts.next().unwrap_or("0").parse().ok()?;
2545 let patch = parts.next().unwrap_or("0").parse().ok()?;
2546 Some((major, minor, patch))
2547}
2548
2549fn version_at_least(current: &str, latest: &str) -> bool {
2553 match (parse_semver(current), parse_semver(latest)) {
2554 (Some(c), Some(l)) => c >= l,
2555 _ => current == latest,
2556 }
2557}
2558
2559fn show_mcp_servers() {
2561 let config = load_config_or_warn();
2562
2563 if config.mcp_servers.is_empty() {
2564 println!("No MCP servers configured.\n");
2565 println!("Add one with: mermaid add <name>");
2566 println!("Examples:");
2567 println!(" mermaid add context7 # Library documentation");
2568 println!(" mermaid add playwright # Browser automation");
2569 println!(" mermaid add memory # Persistent knowledge graph");
2570 return;
2571 }
2572
2573 println!("Configured MCP servers:\n");
2574 for (name, server_cfg) in &config.mcp_servers {
2575 let package: &str = match &server_cfg.url {
2577 Some(url) => url,
2578 None => server_cfg
2579 .args
2580 .iter()
2581 .find(|a| !a.starts_with('-'))
2582 .map(String::as_str)
2583 .unwrap_or(server_cfg.command.as_str()),
2584 };
2585 let env_keys: Vec<&String> = server_cfg.env.keys().collect();
2586 let env_display = if env_keys.is_empty() {
2587 String::new()
2588 } else {
2589 format!(
2590 " (env: {})",
2591 env_keys
2592 .iter()
2593 .map(|k| k.as_str())
2594 .collect::<Vec<_>>()
2595 .join(", ")
2596 )
2597 };
2598 println!(" {name} — {package}{env_display}");
2599 }
2600 println!("\nManage with: mermaid add <name> / mermaid remove <name>");
2601}
2602
2603async fn show_status(config: &Config) -> Result<()> {
2605 println!("Mermaid Status:");
2606 println!();
2607
2608 let available = configured_remote_providers(config);
2613 if available.is_empty() {
2614 println!(
2615 " [WARNING] Remote providers: none (no API keys in env or keyring; `mermaid login <provider>`)"
2616 );
2617 } else {
2618 println!(" [OK] Remote providers: {} configured", available.len());
2619 for provider in &available {
2620 println!(
2621 " - {} ({}) {}",
2622 provider.name,
2623 provider.source_label(),
2624 provider.endpoint
2625 );
2626 }
2627 }
2628 let problems = crate::providers::provider_problems(config);
2632 if !problems.is_empty() {
2633 println!(
2634 " [WARNING] Providers configured but not usable: {}",
2635 problems.len()
2636 );
2637 for problem in &problems {
2638 println!(" - {}: {}", problem.name, problem.reason);
2639 }
2640 }
2641
2642 print_ollama_status(config, !available.is_empty()).await;
2643
2644 if let Ok(config_dir) = get_config_dir() {
2646 let config_path = config_dir.join("config.toml");
2647 if config_path.exists() {
2648 println!(" [OK] Configuration: {}", config_path.display());
2649 } else {
2650 println!(" [WARNING] Configuration: Not found (using defaults)");
2651 }
2652 }
2653
2654 if config.mcp_servers.is_empty() {
2656 println!(" [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
2657 } else {
2658 println!(
2659 " [OK] MCP Servers: {} configured",
2660 config.mcp_servers.len()
2661 );
2662 for (name, server_cfg) in &config.mcp_servers {
2663 let target: &str = match &server_cfg.url {
2664 Some(url) => url,
2665 None => server_cfg
2666 .args
2667 .get(1)
2668 .map(String::as_str)
2669 .unwrap_or(server_cfg.command.as_str()),
2670 };
2671 println!(" - {name} ({target})");
2672 }
2673 }
2674
2675 print_project_instructions_status();
2676
2677 println!("\n Environment:");
2679 if std::env::var("OLLAMA_API_KEY").is_ok() {
2680 println!(" - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2681 }
2682
2683 println!();
2684 Ok(())
2685}
2686
2687async fn print_ollama_status(config: &Config, has_remote: bool) {
2690 if is_ollama_installed() {
2696 let preview = |models: &[String]| {
2697 for model in models.iter().take(3) {
2698 println!(" - {model}");
2699 }
2700 if models.len() > 3 {
2701 println!(" ... and {} more", models.len() - 3);
2702 }
2703 };
2704 match observe_models(config).await {
2705 LocalModelListing::Unreachable => println!(
2706 " [WARNING] Ollama: Installed but not running (started automatically \
2707 when an Ollama model is used)"
2708 ),
2709 LocalModelListing::Live(models) if models.is_empty() => {
2710 println!(" [WARNING] Ollama: Running (no models installed)");
2711 },
2712 LocalModelListing::Live(models) => {
2713 println!(" [OK] Ollama: Running ({} models installed)", models.len());
2714 preview(&models);
2715 },
2716 LocalModelListing::FromDisk(models) => {
2717 println!(
2718 " [OK] Ollama: Not running ({} models installed on disk; starts \
2719 automatically when used)",
2720 models.len()
2721 );
2722 preview(&models);
2723 },
2724 }
2725 } else if !has_remote {
2726 println!(" [WARNING] Ollama: Not installed (and no remote provider configured)");
2727 } else {
2728 println!(" [INFO] Ollama: Not installed (only needed for local models)");
2731 }
2732}
2733
2734fn print_project_instructions_status() {
2737 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2738 let paths = crate::app::instructions::find_instruction_files(&cwd);
2739 if paths.is_empty() {
2740 println!(" [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
2741 } else {
2742 match crate::app::instructions::load_from_paths(&paths) {
2743 Some(loaded) => {
2744 let files = loaded
2745 .sources
2746 .iter()
2747 .map(|source| {
2748 source
2749 .path
2750 .file_name()
2751 .and_then(|name| name.to_str())
2752 .unwrap_or("instructions")
2753 })
2754 .collect::<Vec<_>>()
2755 .join(", ");
2756 println!(
2757 " [OK] Project instructions: {} at {} ({} bytes{})",
2758 files,
2759 loaded.path.display(),
2760 loaded.byte_len,
2761 if loaded.truncated { ", truncated" } else { "" }
2762 );
2763 },
2764 None => {
2765 println!(
2766 " [WARNING] Project instructions: found but unreadable ({})",
2767 paths
2768 .iter()
2769 .map(|path| path.display().to_string())
2770 .collect::<Vec<_>>()
2771 .join(", ")
2772 );
2773 },
2774 }
2775 }
2776}
2777
2778#[cfg(test)]
2779mod tests {
2780 use super::*;
2781
2782 #[test]
2783 fn session_log_drift_reports_agreement_and_catches_a_diverged_checkpoint() {
2784 let root = unique_temp_dir("mermaid-doctor-drift");
2785 let _ = std::fs::remove_dir_all(&root);
2786 std::fs::create_dir_all(&root).expect("project dir");
2787
2788 let empty = session_log_drift(&root);
2790 assert_eq!(empty.status, "ok", "{}", empty.message);
2791
2792 let manager = ConversationManager::new(&root).expect("manager");
2794 let mut state = mermaid_domain::State::new(
2795 Config::default(),
2796 root.clone(),
2797 "ollama/test".to_string(),
2798 chrono::Local::now(),
2799 std::env::temp_dir(),
2800 );
2801 state
2802 .session
2803 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
2804 let snapshot = state.session.snapshot_conversation();
2805 let events = state.session.drain_events(&snapshot);
2806 manager
2807 .append_session_events(&snapshot, &events)
2808 .expect("append");
2809 manager.save_conversation(&snapshot).expect("checkpoint");
2810
2811 let agreed = session_log_drift(&root);
2812 assert_eq!(agreed.status, "ok", "{}", agreed.message);
2813 assert!(agreed.message.contains('1'), "{}", agreed.message);
2814
2815 let path = manager
2818 .conversations_dir()
2819 .join(format!("{}.json", snapshot.id));
2820 let mut value: serde_json::Value =
2821 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2822 let planted = serde_json::to_value(mermaid_model::models::ChatMessage::user(
2823 "never reached the log",
2824 ))
2825 .unwrap();
2826 value
2827 .get_mut("messages")
2828 .and_then(serde_json::Value::as_array_mut)
2829 .expect("messages array")
2830 .push(planted);
2831 std::fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
2832
2833 let drifted = session_log_drift(&root);
2834 assert_eq!(drifted.status, "warning", "{}", drifted.message);
2835 assert!(
2836 drifted.message.contains(&snapshot.id),
2837 "the warning must name the session: {}",
2838 drifted.message
2839 );
2840 let _ = std::fs::remove_dir_all(&root);
2841 }
2842
2843 #[test]
2844 fn doctor_uses_resolved_keyless_web_capabilities() {
2845 let config = Config {
2846 web: mermaid_domain::WebConfig {
2847 fetch_backend: mermaid_domain::FetchBackend::Native,
2848 search_backend: mermaid_domain::SearchBackend::Searxng,
2849 searxng_url: "http://127.0.0.1:8080".to_string(),
2850 ..mermaid_domain::WebConfig::default()
2851 },
2852 ..Config::default()
2853 };
2854 let (tools, next_steps) = web_doctor_entries(&config);
2855 assert!(
2856 tools
2857 .iter()
2858 .any(|entry| entry.contains("web_fetch (native"))
2859 );
2860 assert!(
2861 tools
2862 .iter()
2863 .any(|entry| entry.contains("web_search (searxng"))
2864 );
2865 assert!(next_steps.is_empty(), "unexpected warnings: {next_steps:?}");
2866 assert!(
2867 tools.iter().all(|entry| !entry.contains("container")),
2868 "doctor must describe the selected capability, not stale container setup"
2869 );
2870 }
2871
2872 #[test]
2873 fn doctor_reports_global_network_deny_instead_of_advertising_web() {
2874 let mut config = Config::default();
2875 config.web.search_backend = mermaid_domain::SearchBackend::Searxng;
2876 config.web.searxng_url = "http://127.0.0.1:8080".to_string();
2877 config.safety.network = mermaid_domain::NetworkPolicy::Deny;
2878
2879 let (tools, next_steps) = web_doctor_entries(&config);
2880 assert!(
2881 tools
2882 .iter()
2883 .all(|entry| !entry.starts_with("web_fetch") && !entry.starts_with("web_search")),
2884 "network-denied tools were advertised: {tools:?}"
2885 );
2886 for name in ["web_fetch", "web_search"] {
2887 assert!(
2888 next_steps.iter().any(|entry| {
2889 entry.contains(name) && entry.contains("safety.network = \"deny\"")
2890 }),
2891 "missing network-deny explanation for {name}: {next_steps:?}"
2892 );
2893 }
2894 }
2895
2896 #[test]
2897 fn sanitize_terminal_text_strips_control_sequences() {
2898 assert_eq!(
2900 sanitize_terminal_text("hello\tworld\nline two"),
2901 "hello\tworld\nline two"
2902 );
2903 assert_eq!(
2905 sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
2906 "RED text"
2907 );
2908 assert_eq!(
2910 sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
2911 "beforeafter"
2912 );
2913 assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
2915 assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
2917 assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
2919 }
2920
2921 #[test]
2922 fn version_compare_handles_update_logic() {
2923 assert!(version_at_least("0.10.2", "0.10.2"));
2925 assert!(version_at_least("0.11.0", "0.10.2"));
2926 assert!(version_at_least("1.0.0", "0.99.99"));
2927 assert!(!version_at_least("0.10.1", "0.10.2"));
2929 assert!(!version_at_least("0.9.0", "0.10.0"));
2930 assert!(!version_at_least("0.10.2", "0.11.0"));
2931 assert!(version_at_least("0.10.2", "v0.10.2"));
2933 assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2934 assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2935 assert!(!version_at_least("0.10.2", "not-a-version"));
2937 }
2938
2939 #[test]
2940 fn qa_compact_smoke_persists_conversation_and_archive() {
2941 let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2942 std::fs::create_dir_all(&dir).unwrap();
2943
2944 let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2945
2946 assert!(report.ok);
2947 assert!(report.archived_messages > 0);
2948 assert!(report.preserved_messages > 0);
2949 assert!(report.replacement_messages >= 3);
2950 assert!(
2951 std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2952 "conversation path should exist"
2953 );
2954 assert!(
2955 std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2956 "archive path should exist"
2957 );
2958
2959 let _ = std::fs::remove_dir_all(dir);
2960 }
2961
2962 #[test]
2963 fn qa_model_id_falls_back_to_deterministic() {
2964 assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2965 }
2966
2967 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2968 let nanos = std::time::SystemTime::now()
2969 .duration_since(std::time::UNIX_EPOCH)
2970 .map(|duration| duration.as_nanos())
2971 .unwrap_or_default();
2972 std::env::temp_dir().join(format!("{name}-{nanos}"))
2973 }
2974}