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 };
1564 let sizing = live.resolve_context_window(&probe_request).await;
1565 if let Some(window) = sizing.model_max.or(sizing.effective) {
1566 context_tokens = Some(window);
1567 context_confidence = "probed";
1568 }
1569 if let Some(output) = sizing.max_output {
1570 output_tokens = Some(output);
1571 output_confidence = "probed";
1572 }
1573 }
1574
1575 for (key, value) in [
1576 ("supports_tools", snapshot.supports_tools.to_string()),
1577 ("supports_vision", snapshot.supports_vision.to_string()),
1578 ("reasoning", snapshot.reasoning.clone()),
1579 ] {
1580 let _ = store.provider_probes().upsert(NewProviderProbe {
1581 provider: provider.clone(),
1582 model_id: snapshot.model.clone(),
1583 capability_key: key.to_string(),
1584 capability_value: value,
1585 confidence: "static".to_string(),
1586 error: None,
1587 });
1588 }
1589 let _ = store.provider_probes().upsert(NewProviderProbe {
1591 provider: provider.clone(),
1592 model_id: snapshot.model.clone(),
1593 capability_key: "max_context_tokens".to_string(),
1594 capability_value: context_tokens
1595 .map(|n| n.to_string())
1596 .unwrap_or_else(|| "unknown".to_string()),
1597 confidence: context_confidence.to_string(),
1598 error: None,
1599 });
1600 println!("Model: {model}");
1601 println!("Provider: {}", snapshot.provider);
1602 println!("Name: {}", snapshot.model);
1603 println!("Supports tools: {}", snapshot.supports_tools);
1604 println!("Supports vision: {}", snapshot.supports_vision);
1605 println!("Reasoning: {}", snapshot.reasoning);
1606 println!(
1607 "Context: {}",
1608 context_tokens
1609 .map(|n| format!("{n} ({context_confidence})"))
1610 .unwrap_or_else(|| "unknown".to_string())
1611 );
1612 println!(
1613 "Output limit: {}",
1614 output_tokens
1615 .map(|n| format!("{n} ({output_confidence})"))
1616 .unwrap_or_else(|| {
1617 "unknown (discovered live from the provider's models endpoint when exposed)"
1618 .to_string()
1619 })
1620 );
1621 if let Some(profile) = lookup_provider(&snapshot.provider) {
1622 record_static_provider_probes(&store, profile, &provider, &snapshot.model);
1623 println!("Token budget field: {:?}", profile.max_tokens_param);
1624 println!(
1625 "Single-tool-call models: {}",
1626 if profile.disable_parallel_tool_calls_for.is_empty() {
1627 "(none)".to_string()
1628 } else {
1629 profile.disable_parallel_tool_calls_for.join(", ")
1630 }
1631 );
1632 }
1633 Ok(())
1634}
1635
1636async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1637 let client = reqwest::Client::builder()
1638 .timeout(std::time::Duration::from_secs(5))
1639 .build()?;
1640 for profile in PROVIDER_REGISTRY {
1641 let user_cfg = config.providers.get(profile.name);
1642 let Some(api_key) = mermaid_model::utils::resolve_provider_key(
1643 profile.name,
1644 profile.api_key_env,
1645 user_cfg.and_then(|c| c.api_key_env.as_deref()),
1646 ) else {
1647 continue;
1648 };
1649 let Some(base_url) = crate::providers::factory::discovery_base_url(
1650 profile,
1651 user_cfg.and_then(|c| c.base_url.clone()),
1652 ) else {
1653 record_provider_probe(
1657 profile.name,
1658 "*",
1659 "models_availability",
1660 "failed",
1661 "failed",
1662 Some("CLOUDFLARE_ACCOUNT_ID not set".to_string()),
1663 );
1664 continue;
1665 };
1666 let url = format!("{}/models", base_url.trim_end_matches('/'));
1667 let mut request = client.get(&url).bearer_auth(api_key);
1668 for (name, value) in profile.extra_headers {
1669 request = request.header(*name, *value);
1670 }
1671 if let Some(user_cfg) = user_cfg {
1672 for (name, value) in &user_cfg.extra_headers {
1673 request = request.header(name, value);
1674 }
1675 }
1676
1677 let result = request.send().await;
1678 match result {
1679 Ok(response) if response.status().is_success() => {
1680 let status = response.status();
1681 let body: serde_json::Value = response.json().await.unwrap_or_default();
1682 let ids = body
1683 .get("data")
1684 .and_then(|v| v.as_array())
1685 .map(|items| {
1686 items
1687 .iter()
1688 .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1689 .map(str::to_string)
1690 .collect::<Vec<_>>()
1691 })
1692 .unwrap_or_default();
1693 record_provider_probe(
1694 profile.name,
1695 "*",
1696 "models_availability",
1697 &format!("available:{}:{}", status.as_u16(), ids.len()),
1698 "probed",
1699 None,
1700 );
1701 for model_id in ids.into_iter().take(200) {
1702 record_provider_probe(
1703 profile.name,
1704 &model_id,
1705 "model_listed",
1706 "true",
1707 "listed",
1708 None,
1709 );
1710 }
1711 },
1712 Ok(response) => {
1713 record_provider_probe(
1714 profile.name,
1715 "*",
1716 "models_availability",
1717 "failed",
1718 "failed",
1719 Some(format!("HTTP {}", response.status().as_u16())),
1720 );
1721 },
1722 Err(error) => {
1723 record_provider_probe(
1724 profile.name,
1725 "*",
1726 "models_availability",
1727 "failed",
1728 "failed",
1729 Some(error.to_string()),
1730 );
1731 },
1732 }
1733 }
1734 probe_meta_models(&client, config).await;
1735 Ok(())
1736}
1737
1738async fn probe_meta_models(client: &reqwest::Client, config: &Config) {
1739 let Some(api_key) = meta_api_key(config) else {
1740 return;
1741 };
1742 let url = format!("{}/models", meta_base_url(config).trim_end_matches('/'));
1743 let mut request = client.get(&url).bearer_auth(api_key);
1744 if let Some(provider) = config.providers.get("meta") {
1745 for (name, value) in &provider.extra_headers {
1746 request = request.header(name, value);
1747 }
1748 for (name, env_var) in &provider.env_headers {
1749 if let Ok(value) = std::env::var(env_var) {
1750 request = request.header(name, value);
1751 }
1752 }
1753 }
1754 match request.send().await {
1755 Ok(response) if response.status().is_success() => {
1756 let status = response.status();
1757 let body: serde_json::Value = response.json().await.unwrap_or_default();
1758 let ids = body
1759 .get("data")
1760 .and_then(serde_json::Value::as_array)
1761 .into_iter()
1762 .flatten()
1763 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1764 .map(str::to_string)
1765 .collect::<Vec<_>>();
1766 record_provider_probe(
1767 "meta",
1768 "*",
1769 "models_availability",
1770 &format!("available:{}:{}", status.as_u16(), ids.len()),
1771 "probed",
1772 None,
1773 );
1774 for model_id in ids.into_iter().take(200) {
1775 record_provider_probe("meta", &model_id, "model_listed", "true", "listed", None);
1776 }
1777 },
1778 Ok(response) => record_provider_probe(
1779 "meta",
1780 "*",
1781 "models_availability",
1782 "failed",
1783 "failed",
1784 Some(format!("HTTP {}", response.status().as_u16())),
1785 ),
1786 Err(error) => record_provider_probe(
1787 "meta",
1788 "*",
1789 "models_availability",
1790 "failed",
1791 "failed",
1792 Some(error.to_string()),
1793 ),
1794 }
1795}
1796
1797fn record_provider_probe(
1798 provider: &str,
1799 model_id: &str,
1800 key: &str,
1801 value: &str,
1802 confidence: &str,
1803 error: Option<String>,
1804) {
1805 if let Ok(store) = RuntimeStore::open_default() {
1806 let _ = store.provider_probes().upsert(NewProviderProbe {
1807 provider: provider.to_string(),
1808 model_id: model_id.to_string(),
1809 capability_key: key.to_string(),
1810 capability_value: value.to_string(),
1811 confidence: confidence.to_string(),
1812 error,
1813 });
1814 }
1815}
1816
1817fn show_approvals() -> Result<()> {
1818 let approvals = RuntimeClient::auto().list_approvals()?.value;
1819 if approvals.is_empty() {
1820 println!("No pending approvals.");
1821 return Ok(());
1822 }
1823 for approval in approvals {
1824 println!(
1825 "{} [{} -> {}] {}",
1826 approval.id,
1827 approval.risk_classification,
1828 approval.policy_decision,
1829 approval.proposed_action
1830 );
1831 if let Some(args) = approval.args_summary {
1832 println!(" args: {args}");
1833 }
1834 if let Some(checkpoint_id) = approval.checkpoint_id {
1835 println!(" checkpoint: {checkpoint_id}");
1836 }
1837 if approval.pending_action_json.is_some() {
1838 println!(" pending action: recorded");
1839 }
1840 }
1841 Ok(())
1842}
1843
1844fn approve(id: &str) -> Result<()> {
1845 let result = RuntimeClient::auto().approve(id)?;
1846 println!("Approved {id}");
1847 if result.replayed {
1848 println!("{}", result.summary);
1849 }
1850 Ok(())
1851}
1852
1853fn deny(id: &str) -> Result<()> {
1854 let _ = RuntimeClient::auto().deny(id)?;
1855 println!("Denied {id}");
1856 Ok(())
1857}
1858
1859fn follow_task(id: &str) -> Result<()> {
1864 let lines = mermaid_runtime::subscribe_daemon_lines(
1865 crate::runtime_client::DaemonRequest::SubscribeTask {
1866 task_id: id.to_string(),
1867 }
1868 .to_wire(),
1869 )
1870 .context("mermaid task --follow needs a running daemon (`mermaid daemon start`)")?;
1871 let mut saw_any = false;
1872 for line in lines {
1873 let line = line?;
1874 if line.trim().is_empty() {
1875 continue;
1876 }
1877 if !saw_any {
1879 saw_any = true;
1880 let ack: serde_json::Value =
1881 serde_json::from_str(line.trim()).context("daemon returned invalid JSON")?;
1882 if ack.get("ok").and_then(|v| v.as_bool()) == Some(false) {
1883 anyhow::bail!(
1884 "{}",
1885 ack.get("error")
1886 .and_then(|v| v.as_str())
1887 .unwrap_or("subscribe failed")
1888 );
1889 }
1890 println!("{}", line.trim());
1891 continue;
1892 }
1893 println!("{}", line.trim());
1894 if serde_json::from_str::<serde_json::Value>(line.trim())
1895 .ok()
1896 .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_string))
1897 .as_deref()
1898 == Some("result")
1899 {
1900 return Ok(());
1901 }
1902 }
1903 if saw_any {
1904 anyhow::bail!("stream ended without a result (daemon restarted mid-run?)");
1905 }
1906 anyhow::bail!("daemon closed the connection without responding");
1907}
1908
1909fn send_to_task(id: &str, text: &str) -> Result<()> {
1915 if text.trim().is_empty() {
1916 anyhow::bail!("nothing to send: --send needs a prompt");
1917 }
1918 mermaid_runtime::request_daemon_json(
1919 crate::runtime_client::DaemonRequest::SendToTask {
1920 id: id.to_string(),
1921 text: text.to_string(),
1922 }
1923 .to_wire(),
1924 )?;
1925 println!("Sent to {id}; it answers after the turn it is on.");
1926 Ok(())
1927}
1928
1929fn cancel_task(id: &str) -> Result<()> {
1934 match mermaid_runtime::request_daemon_json(
1935 crate::runtime_client::DaemonRequest::CancelTask { id: id.to_string() }.to_wire(),
1936 ) {
1937 Ok(response) => {
1938 if response.get("cancelling").and_then(|v| v.as_bool()) == Some(true) {
1939 println!("Cancelling {id} (running; the agent unwinds gracefully)");
1940 } else {
1941 println!("Cancelled {id}");
1942 }
1943 Ok(())
1944 },
1945 Err(daemon_err) => {
1946 let store = mermaid_runtime::RuntimeStore::open_default()?;
1947 match store.tasks().get(id)? {
1948 Some(task) if task.status == mermaid_runtime::TaskStatus::Queued => {
1949 store.tasks().update_status(
1950 id,
1951 mermaid_runtime::TaskStatus::Cancelled,
1952 Some("cancelled before start"),
1953 )?;
1954 println!("Cancelled {id} (was queued; daemon unreachable)");
1955 Ok(())
1956 },
1957 Some(task) => anyhow::bail!(
1958 "task {} is {} and the daemon request failed: {}",
1959 id,
1960 task.status,
1961 daemon_err
1962 ),
1963 None => anyhow::bail!("task not found: {id}"),
1964 }
1965 },
1966 }
1967}
1968
1969fn show_tool_runs(limit: usize) -> Result<()> {
1970 let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1971 runs.truncate(limit);
1972 if runs.is_empty() {
1973 println!("No tool runs recorded yet.");
1974 return Ok(());
1975 }
1976 for run in runs {
1977 println!(
1978 "{} [{}] {} started {}",
1979 run.id, run.status, run.tool_name, run.started_at
1980 );
1981 if let Some(turn_id) = run.turn_id {
1982 println!(" turn: {turn_id}");
1983 }
1984 if let Some(call_id) = run.call_id {
1985 println!(" call: {call_id}");
1986 }
1987 if let Some(finished_at) = run.finished_at {
1988 println!(" finished: {finished_at}");
1989 }
1990 }
1991 Ok(())
1992}
1993
1994fn show_checkpoints(limit: usize) -> Result<()> {
1995 let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1996 checkpoints.truncate(limit);
1997 if checkpoints.is_empty() {
1998 println!("No checkpoints recorded yet.");
1999 return Ok(());
2000 }
2001 for checkpoint in checkpoints {
2002 println!(
2003 "{} {} {}",
2004 checkpoint.id, checkpoint.created_at, checkpoint.project_path
2005 );
2006 println!(" snapshot: {}", checkpoint.snapshot_path);
2007 println!(" files: {}", checkpoint.changed_files_json);
2008 if let Some(approval_id) = checkpoint.approval_id {
2009 println!(" approval: {approval_id}");
2010 }
2011 }
2012 Ok(())
2013}
2014
2015fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
2016 if !mermaid_model::utils::confirm_or_refuse(
2020 &format!("Restore checkpoint {id}? This overwrites the current working tree."),
2021 force,
2022 )? {
2023 println!("Restore cancelled.");
2024 return Ok(());
2025 }
2026 let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
2027 println!("Restored {} ({} files)", manifest.id, manifest.files.len());
2028 if let Some(repo) = manifest.shadow_git_repo {
2029 println!("Shadow repo: {repo}");
2030 }
2031 if let Some(commit) = manifest.shadow_git_commit {
2032 println!("Shadow commit: {commit}");
2033 }
2034 if let Some(action) = manifest.pending_action {
2035 println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
2036 }
2037 Ok(())
2038}
2039
2040fn handle_plugin(command: &PluginCommand) -> Result<()> {
2041 match command {
2042 PluginCommand::Install { path } => {
2043 let preview = mermaid_runtime::plugin_capability_preview(path)?;
2044 print_plugin_capability_preview(&preview);
2045 let record = mermaid_runtime::install_plugin_from_path(path)?;
2046 println!(
2047 "Installed plugin {} ({}) — DISABLED.",
2048 record.name, record.id
2049 );
2050 println!(
2051 "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
2052 record.id
2053 );
2054 },
2055 PluginCommand::List => {
2056 let plugins = RuntimeClient::auto().list_plugins()?.value;
2057 if plugins.is_empty() {
2058 println!("No plugins installed.");
2059 } else {
2060 for plugin in plugins {
2061 println!(
2062 "{} [{}] {} ({})",
2063 plugin.id,
2064 if plugin.enabled {
2065 "enabled"
2066 } else {
2067 "disabled"
2068 },
2069 plugin.name,
2070 plugin.source
2071 );
2072 }
2073 }
2074 },
2075 PluginCommand::Enable { id } => {
2076 let client = RuntimeClient::auto();
2078 if let Some(plugin) = client
2079 .list_plugins()?
2080 .value
2081 .into_iter()
2082 .find(|p| p.id == *id || p.name == *id)
2083 && let Ok(preview) =
2084 mermaid_runtime::plugin_capability_preview(Path::new(&plugin.source))
2085 {
2086 print_plugin_capability_preview(&preview);
2087 }
2088 client.set_plugin_enabled(id, true)?;
2089 println!("Enabled plugin {id} — its hooks will now run.");
2090 },
2091 PluginCommand::Disable { id } => {
2092 RuntimeClient::auto().set_plugin_enabled(id, false)?;
2093 println!("Disabled plugin {id}");
2094 },
2095 PluginCommand::Audit { path } => {
2096 let manifest_path = if path.is_dir() {
2097 path.join("plugin.toml")
2098 } else {
2099 path.clone()
2100 };
2101 let raw = std::fs::read_to_string(&manifest_path)?;
2102 let manifest: mermaid_runtime::PluginManifest = toml::from_str(&raw)?;
2103 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
2104 mermaid_runtime::validate_plugin_manifest(&manifest, root)?;
2105 let preview = mermaid_runtime::plugin_capability_preview(path)?;
2106 println!("Plugin manifest is valid: {}", manifest.name);
2107 print_plugin_capability_preview(&preview);
2108 },
2109 }
2110 Ok(())
2111}
2112
2113fn print_plugin_capability_preview(preview: &mermaid_runtime::PluginCapabilityPreview) {
2114 println!(
2115 "ModelCapabilities declared by plugin {} (advisory, not sandbox-enforced):",
2116 preview.name
2117 );
2118 if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
2119 println!(" capabilities: (none declared)");
2120 } else {
2121 if !preview.declared_capabilities.is_empty() {
2122 println!(" declared: {}", preview.declared_capabilities.join(", "));
2123 }
2124 if let Some(value) = &preview.capabilities_toml {
2125 println!(
2126 " capabilities.toml: {}",
2127 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
2128 );
2129 }
2130 }
2131 if !preview.hooks.is_empty() {
2132 println!(" hooks: {}", preview.hooks.join(", "));
2133 }
2134 if !preview.mcp.is_empty() {
2135 println!(" mcp: {}", preview.mcp.join(", "));
2136 }
2137 if !preview.bin.is_empty() {
2138 println!(" bin: {}", preview.bin.join(", "));
2139 }
2140}
2141
2142fn handle_pair(command: &PairCommand) -> Result<()> {
2143 let store = RuntimeStore::open_default()?;
2144 match command {
2145 PairCommand::Create { label, ttl_days } => {
2146 let ttl = ttl_days.unwrap_or(mermaid_runtime::DEFAULT_PAIRING_TTL_DAYS);
2147 let expires_at = mermaid_runtime::pairing_expiry_from_now(ttl);
2148 let (token, hash) = mermaid_runtime::generate_pairing_token()?;
2149 let record =
2150 store
2151 .pairing_tokens()
2152 .create(&hash, label.as_deref(), expires_at.as_deref())?;
2153 println!("Pairing token id: {}", record.id);
2154 println!("Pairing token: {token}");
2155 println!(
2156 "Expires: {}",
2157 record.expires_at.as_deref().unwrap_or("never")
2158 );
2159 println!(
2160 "Use with daemon JSON by setting {}.",
2161 mermaid_runtime::daemon::DAEMON_TOKEN_ENV
2162 );
2163 println!("Store this now; Mermaid will not print it again.");
2164 },
2165 PairCommand::List => {
2166 let tokens = store.pairing_tokens().list()?;
2167 if tokens.is_empty() {
2168 println!("No pairing tokens.");
2169 } else {
2170 for t in tokens {
2172 println!(
2173 "{} [{}] label={} created={} expires={} last_used={}",
2174 t.id,
2175 if t.enabled { "active" } else { "revoked" },
2176 t.label.as_deref().unwrap_or("-"),
2177 t.created_at,
2178 t.expires_at.as_deref().unwrap_or("never"),
2179 t.last_used_at.as_deref().unwrap_or("never"),
2180 );
2181 }
2182 }
2183 },
2184 PairCommand::Revoke { id } => {
2185 if store.pairing_tokens().revoke(id)? {
2186 println!("Revoked pairing token {id}");
2187 } else {
2188 println!("No active pairing token with id {id}");
2189 }
2190 },
2191 }
2192 Ok(())
2193}
2194
2195fn sanitize_terminal_text(input: &str) -> String {
2204 let mut out = String::with_capacity(input.len());
2205 let mut chars = input.chars();
2206 while let Some(c) = chars.next() {
2207 match c {
2208 '\n' | '\t' => out.push(c),
2209 '\u{1b}' => match chars.next() {
2210 Some('[') => {
2213 for p in chars.by_ref() {
2214 if ('@'..='~').contains(&p) {
2215 break;
2216 }
2217 }
2218 },
2219 Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
2222 while let Some(p) = chars.next() {
2223 if p == '\u{07}' {
2224 break;
2225 }
2226 if p == '\u{1b}' {
2227 let mut peek = chars.clone();
2229 if peek.next() == Some('\\') {
2230 chars = peek;
2231 }
2232 break;
2233 }
2234 }
2235 },
2236 Some(mut b) => {
2239 while ('\u{20}'..='\u{2f}').contains(&b) {
2240 match chars.next() {
2241 Some(next) => b = next,
2242 None => break,
2243 }
2244 }
2245 },
2246 None => {},
2247 },
2248 c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
2250 c => out.push(c),
2251 }
2252 }
2253 out
2254}
2255
2256fn show_logs(id: &str) -> Result<()> {
2257 let content = RuntimeClient::auto().process_log(id, None)?.content;
2258 print!("{}", sanitize_terminal_text(&content));
2259 Ok(())
2260}
2261
2262fn stop_process(id: &str) -> Result<()> {
2263 let process = RuntimeClient::auto().stop_process(id)?.item;
2264 println!("Stopped process {} (pid {})", id, process.pid);
2265 Ok(())
2266}
2267
2268fn restart_process(id: &str) -> Result<()> {
2269 let process = RuntimeClient::auto().restart_process(id)?.item;
2270 println!("Restarted process {} (pid {})", id, process.pid);
2271 Ok(())
2272}
2273
2274fn open_target(target: &str) -> Result<()> {
2275 if RuntimeClient::auto().open_process(target).is_err() {
2276 mermaid_model::utils::open_file(target);
2277 }
2278 Ok(())
2279}
2280
2281fn show_ports() -> Result<()> {
2282 let ports = RuntimeClient::auto().ports()?.ports;
2283 print!("{}", sanitize_terminal_text(&ports));
2284 Ok(())
2285}
2286
2287pub async fn list_models(config: &Config) -> Result<()> {
2302 match observe_models(config).await {
2303 LocalModelListing::Unreachable if is_ollama_installed() => {
2304 println!("Ollama is installed but not running, and its model store could not be read.");
2305 println!("(It starts automatically when you use an Ollama model.)");
2306 },
2307 LocalModelListing::Unreachable => println!("Ollama is not installed; no local models."),
2308 LocalModelListing::Live(models) if models.is_empty() => {
2309 println!("No Ollama models installed locally.");
2310 },
2311 LocalModelListing::Live(models) => {
2312 println!("Ollama models (local/cloud):");
2313 for name in &models {
2314 println!(" - ollama/{name}");
2315 }
2316 },
2317 LocalModelListing::FromDisk(models) => {
2318 println!(
2319 "Ollama models (installed; server not running — starts automatically on use):"
2320 );
2321 for name in &models {
2322 println!(" - ollama/{name}");
2323 }
2324 },
2325 }
2326
2327 println!("\nConfigured remote providers:");
2328 let catalogs = crate::providers::discovery::provider_catalogs(config).await;
2329 if catalogs.is_empty() {
2330 println!(" (none — set a provider API key env var to enable)");
2331 }
2332 for catalog in &catalogs {
2333 println!(
2334 " - {} ({}) {}",
2335 catalog.provider.name,
2336 catalog.provider.source_label(),
2337 catalog.provider.endpoint
2338 );
2339 match &catalog.models {
2340 None => {
2343 println!(" (model list unavailable — the provider's /models did not answer)")
2344 },
2345 Some(models) if models.is_empty() => println!(" (provider lists no models)"),
2346 Some(models) => {
2347 for id in models {
2348 println!(" {}/{}", catalog.provider.name, id);
2349 }
2350 },
2351 }
2352 }
2353
2354 let problems = crate::providers::provider_problems(config);
2358 if !problems.is_empty() {
2359 println!("\nConfigured but not usable:");
2360 for problem in &problems {
2361 println!(" - {}: {}", problem.name, problem.reason);
2362 }
2363 }
2364
2365 println!("\nSwitch models in-session with /model <name>.");
2366 Ok(())
2367}
2368
2369const RELEASE_LATEST_API: &str =
2370 "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
2371const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
2372const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
2373
2374async fn run_update(check: bool, force: bool) -> Result<()> {
2380 let current = env!("CARGO_PKG_VERSION");
2381 println!("Installed: v{current}");
2382
2383 let client = reqwest::Client::builder()
2384 .timeout(std::time::Duration::from_secs(15))
2385 .build()?;
2386 let resp = client
2387 .get(RELEASE_LATEST_API)
2388 .header("User-Agent", "mermaid-cli")
2389 .header("Accept", "application/vnd.github+json")
2390 .send()
2391 .await
2392 .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
2393 if !resp.status().is_success() {
2394 bail!("GitHub Releases API returned HTTP {}", resp.status());
2395 }
2396 let release: serde_json::Value = resp.json().await?;
2397 let tag = release
2398 .get("tag_name")
2399 .and_then(|v| v.as_str())
2400 .ok_or_else(|| anyhow!("release response had no tag_name"))?;
2401 println!("Latest: {tag}");
2402
2403 let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
2404 if check {
2405 if up_to_date {
2406 println!("You're on the latest version.");
2407 } else {
2408 println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
2409 }
2410 return Ok(());
2411 }
2412 if up_to_date && !force {
2413 println!("Already up to date.");
2414 return Ok(());
2415 }
2416
2417 let exe =
2419 std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
2420 let install_dir = exe
2421 .parent()
2422 .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
2423
2424 let script_url = if cfg!(target_os = "windows") {
2429 INSTALL_PS1_URL
2430 } else {
2431 INSTALL_SH_URL
2432 };
2433 if !mermaid_model::utils::confirm_or_refuse(
2434 &format!(
2435 "About to download and run {script_url} to replace {}.",
2436 install_dir.display()
2437 ),
2438 force,
2439 )? {
2440 println!("Update cancelled.");
2441 return Ok(());
2442 }
2443
2444 println!("Updating {} …", install_dir.display());
2445 run_install_script(&client, install_dir).await?;
2446 println!("Updated. New version takes effect on the next run.");
2447 Ok(())
2448}
2449
2450async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
2453 let windows = cfg!(target_os = "windows");
2454 let url = if windows {
2455 INSTALL_PS1_URL
2456 } else {
2457 INSTALL_SH_URL
2458 };
2459 let script = client
2460 .get(url)
2461 .header("User-Agent", "mermaid-cli")
2462 .send()
2463 .await
2464 .map_err(|e| anyhow!("could not fetch install script: {e}"))?
2465 .error_for_status()?
2466 .text()
2467 .await?;
2468
2469 let ext = if windows { "ps1" } else { "sh" };
2470 let dir = mermaid_model::utils::private_temp_dir()
2477 .map_err(|e| anyhow!("could not create private temp dir for install script: {e}"))?;
2478 let nanos = std::time::SystemTime::now()
2479 .duration_since(std::time::UNIX_EPOCH)
2480 .map(|d| d.as_nanos())
2481 .unwrap_or_default();
2482 let script_path = dir.join(format!(
2483 "mermaid-update-{}-{nanos}.{ext}",
2484 std::process::id()
2485 ));
2486 stage_install_script(&script_path, script.as_bytes())
2487 .map_err(|e| anyhow!("could not stage install script: {e}"))?;
2488
2489 let mut cmd = if windows {
2490 let mut c = tokio::process::Command::new("powershell");
2491 c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
2492 c.arg(&script_path);
2493 c
2494 } else {
2495 let mut c = tokio::process::Command::new("sh");
2496 c.arg(&script_path);
2497 c
2498 };
2499 cmd.env("MERMAID_INSTALL_DIR", install_dir)
2500 .env("MERMAID_NO_MODIFY_PATH", "1");
2501
2502 let status = cmd
2503 .status()
2504 .await
2505 .map_err(|e| anyhow!("could not run install script: {e}"))?;
2506 let _ = std::fs::remove_file(&script_path);
2507 if !status.success() {
2508 bail!("install script exited with {:?}", status.code());
2509 }
2510 Ok(())
2511}
2512
2513fn stage_install_script(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2519 use std::io::Write;
2520 #[cfg(unix)]
2521 let mut file = {
2522 use std::os::unix::fs::OpenOptionsExt;
2523 std::fs::OpenOptions::new()
2524 .write(true)
2525 .create_new(true)
2526 .mode(0o600)
2527 .open(path)?
2528 };
2529 #[cfg(not(unix))]
2530 let mut file = std::fs::OpenOptions::new()
2531 .write(true)
2532 .create_new(true)
2533 .open(path)?;
2534 file.write_all(bytes)
2535}
2536
2537fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
2539 let core = s.trim().trim_start_matches('v');
2540 let core = core.split(['-', '+']).next().unwrap_or(core);
2541 let mut parts = core.split('.');
2542 let major = parts.next()?.parse().ok()?;
2543 let minor = parts.next().unwrap_or("0").parse().ok()?;
2544 let patch = parts.next().unwrap_or("0").parse().ok()?;
2545 Some((major, minor, patch))
2546}
2547
2548fn version_at_least(current: &str, latest: &str) -> bool {
2552 match (parse_semver(current), parse_semver(latest)) {
2553 (Some(c), Some(l)) => c >= l,
2554 _ => current == latest,
2555 }
2556}
2557
2558fn show_mcp_servers() {
2560 let config = load_config_or_warn();
2561
2562 if config.mcp_servers.is_empty() {
2563 println!("No MCP servers configured.\n");
2564 println!("Add one with: mermaid add <name>");
2565 println!("Examples:");
2566 println!(" mermaid add context7 # Library documentation");
2567 println!(" mermaid add playwright # Browser automation");
2568 println!(" mermaid add memory # Persistent knowledge graph");
2569 return;
2570 }
2571
2572 println!("Configured MCP servers:\n");
2573 for (name, server_cfg) in &config.mcp_servers {
2574 let package: &str = match &server_cfg.url {
2576 Some(url) => url,
2577 None => server_cfg
2578 .args
2579 .iter()
2580 .find(|a| !a.starts_with('-'))
2581 .map(String::as_str)
2582 .unwrap_or(server_cfg.command.as_str()),
2583 };
2584 let env_keys: Vec<&String> = server_cfg.env.keys().collect();
2585 let env_display = if env_keys.is_empty() {
2586 String::new()
2587 } else {
2588 format!(
2589 " (env: {})",
2590 env_keys
2591 .iter()
2592 .map(|k| k.as_str())
2593 .collect::<Vec<_>>()
2594 .join(", ")
2595 )
2596 };
2597 println!(" {name} — {package}{env_display}");
2598 }
2599 println!("\nManage with: mermaid add <name> / mermaid remove <name>");
2600}
2601
2602async fn show_status(config: &Config) -> Result<()> {
2604 println!("Mermaid Status:");
2605 println!();
2606
2607 let available = configured_remote_providers(config);
2612 if available.is_empty() {
2613 println!(
2614 " [WARNING] Remote providers: none (no API keys in env or keyring; `mermaid login <provider>`)"
2615 );
2616 } else {
2617 println!(" [OK] Remote providers: {} configured", available.len());
2618 for provider in &available {
2619 println!(
2620 " - {} ({}) {}",
2621 provider.name,
2622 provider.source_label(),
2623 provider.endpoint
2624 );
2625 }
2626 }
2627 let problems = crate::providers::provider_problems(config);
2631 if !problems.is_empty() {
2632 println!(
2633 " [WARNING] Providers configured but not usable: {}",
2634 problems.len()
2635 );
2636 for problem in &problems {
2637 println!(" - {}: {}", problem.name, problem.reason);
2638 }
2639 }
2640
2641 print_ollama_status(config, !available.is_empty()).await;
2642
2643 if let Ok(config_dir) = get_config_dir() {
2645 let config_path = config_dir.join("config.toml");
2646 if config_path.exists() {
2647 println!(" [OK] Configuration: {}", config_path.display());
2648 } else {
2649 println!(" [WARNING] Configuration: Not found (using defaults)");
2650 }
2651 }
2652
2653 if config.mcp_servers.is_empty() {
2655 println!(" [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
2656 } else {
2657 println!(
2658 " [OK] MCP Servers: {} configured",
2659 config.mcp_servers.len()
2660 );
2661 for (name, server_cfg) in &config.mcp_servers {
2662 let target: &str = match &server_cfg.url {
2663 Some(url) => url,
2664 None => server_cfg
2665 .args
2666 .get(1)
2667 .map(String::as_str)
2668 .unwrap_or(server_cfg.command.as_str()),
2669 };
2670 println!(" - {name} ({target})");
2671 }
2672 }
2673
2674 print_project_instructions_status();
2675
2676 println!("\n Environment:");
2678 if std::env::var("OLLAMA_API_KEY").is_ok() {
2679 println!(" - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2680 }
2681
2682 println!();
2683 Ok(())
2684}
2685
2686async fn print_ollama_status(config: &Config, has_remote: bool) {
2689 if is_ollama_installed() {
2695 let preview = |models: &[String]| {
2696 for model in models.iter().take(3) {
2697 println!(" - {model}");
2698 }
2699 if models.len() > 3 {
2700 println!(" ... and {} more", models.len() - 3);
2701 }
2702 };
2703 match observe_models(config).await {
2704 LocalModelListing::Unreachable => println!(
2705 " [WARNING] Ollama: Installed but not running (started automatically \
2706 when an Ollama model is used)"
2707 ),
2708 LocalModelListing::Live(models) if models.is_empty() => {
2709 println!(" [WARNING] Ollama: Running (no models installed)");
2710 },
2711 LocalModelListing::Live(models) => {
2712 println!(" [OK] Ollama: Running ({} models installed)", models.len());
2713 preview(&models);
2714 },
2715 LocalModelListing::FromDisk(models) => {
2716 println!(
2717 " [OK] Ollama: Not running ({} models installed on disk; starts \
2718 automatically when used)",
2719 models.len()
2720 );
2721 preview(&models);
2722 },
2723 }
2724 } else if !has_remote {
2725 println!(" [WARNING] Ollama: Not installed (and no remote provider configured)");
2726 } else {
2727 println!(" [INFO] Ollama: Not installed (only needed for local models)");
2730 }
2731}
2732
2733fn print_project_instructions_status() {
2736 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2737 let paths = crate::app::instructions::find_instruction_files(&cwd);
2738 if paths.is_empty() {
2739 println!(" [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
2740 } else {
2741 match crate::app::instructions::load_from_paths(&paths) {
2742 Some(loaded) => {
2743 let files = loaded
2744 .sources
2745 .iter()
2746 .map(|source| {
2747 source
2748 .path
2749 .file_name()
2750 .and_then(|name| name.to_str())
2751 .unwrap_or("instructions")
2752 })
2753 .collect::<Vec<_>>()
2754 .join(", ");
2755 println!(
2756 " [OK] Project instructions: {} at {} ({} bytes{})",
2757 files,
2758 loaded.path.display(),
2759 loaded.byte_len,
2760 if loaded.truncated { ", truncated" } else { "" }
2761 );
2762 },
2763 None => {
2764 println!(
2765 " [WARNING] Project instructions: found but unreadable ({})",
2766 paths
2767 .iter()
2768 .map(|path| path.display().to_string())
2769 .collect::<Vec<_>>()
2770 .join(", ")
2771 );
2772 },
2773 }
2774 }
2775}
2776
2777#[cfg(test)]
2778mod tests {
2779 use super::*;
2780
2781 #[test]
2782 fn session_log_drift_reports_agreement_and_catches_a_diverged_checkpoint() {
2783 let root = unique_temp_dir("mermaid-doctor-drift");
2784 let _ = std::fs::remove_dir_all(&root);
2785 std::fs::create_dir_all(&root).expect("project dir");
2786
2787 let empty = session_log_drift(&root);
2789 assert_eq!(empty.status, "ok", "{}", empty.message);
2790
2791 let manager = ConversationManager::new(&root).expect("manager");
2793 let mut state = mermaid_domain::State::new(
2794 Config::default(),
2795 root.clone(),
2796 "ollama/test".to_string(),
2797 chrono::Local::now(),
2798 std::env::temp_dir(),
2799 );
2800 state
2801 .session
2802 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
2803 let snapshot = state.session.snapshot_conversation();
2804 let events = state.session.drain_events(&snapshot);
2805 manager
2806 .append_session_events(&snapshot, &events)
2807 .expect("append");
2808 manager.save_conversation(&snapshot).expect("checkpoint");
2809
2810 let agreed = session_log_drift(&root);
2811 assert_eq!(agreed.status, "ok", "{}", agreed.message);
2812 assert!(agreed.message.contains('1'), "{}", agreed.message);
2813
2814 let path = manager
2817 .conversations_dir()
2818 .join(format!("{}.json", snapshot.id));
2819 let mut value: serde_json::Value =
2820 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2821 let planted = serde_json::to_value(mermaid_model::models::ChatMessage::user(
2822 "never reached the log",
2823 ))
2824 .unwrap();
2825 value
2826 .get_mut("messages")
2827 .and_then(serde_json::Value::as_array_mut)
2828 .expect("messages array")
2829 .push(planted);
2830 std::fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
2831
2832 let drifted = session_log_drift(&root);
2833 assert_eq!(drifted.status, "warning", "{}", drifted.message);
2834 assert!(
2835 drifted.message.contains(&snapshot.id),
2836 "the warning must name the session: {}",
2837 drifted.message
2838 );
2839 let _ = std::fs::remove_dir_all(&root);
2840 }
2841
2842 #[test]
2843 fn doctor_uses_resolved_keyless_web_capabilities() {
2844 let config = Config {
2845 web: mermaid_domain::WebConfig {
2846 fetch_backend: mermaid_domain::FetchBackend::Native,
2847 search_backend: mermaid_domain::SearchBackend::Searxng,
2848 searxng_url: "http://127.0.0.1:8080".to_string(),
2849 ..mermaid_domain::WebConfig::default()
2850 },
2851 ..Config::default()
2852 };
2853 let (tools, next_steps) = web_doctor_entries(&config);
2854 assert!(
2855 tools
2856 .iter()
2857 .any(|entry| entry.contains("web_fetch (native"))
2858 );
2859 assert!(
2860 tools
2861 .iter()
2862 .any(|entry| entry.contains("web_search (searxng"))
2863 );
2864 assert!(next_steps.is_empty(), "unexpected warnings: {next_steps:?}");
2865 assert!(
2866 tools.iter().all(|entry| !entry.contains("container")),
2867 "doctor must describe the selected capability, not stale container setup"
2868 );
2869 }
2870
2871 #[test]
2872 fn doctor_reports_global_network_deny_instead_of_advertising_web() {
2873 let mut config = Config::default();
2874 config.web.search_backend = mermaid_domain::SearchBackend::Searxng;
2875 config.web.searxng_url = "http://127.0.0.1:8080".to_string();
2876 config.safety.network = mermaid_domain::NetworkPolicy::Deny;
2877
2878 let (tools, next_steps) = web_doctor_entries(&config);
2879 assert!(
2880 tools
2881 .iter()
2882 .all(|entry| !entry.starts_with("web_fetch") && !entry.starts_with("web_search")),
2883 "network-denied tools were advertised: {tools:?}"
2884 );
2885 for name in ["web_fetch", "web_search"] {
2886 assert!(
2887 next_steps.iter().any(|entry| {
2888 entry.contains(name) && entry.contains("safety.network = \"deny\"")
2889 }),
2890 "missing network-deny explanation for {name}: {next_steps:?}"
2891 );
2892 }
2893 }
2894
2895 #[test]
2896 fn sanitize_terminal_text_strips_control_sequences() {
2897 assert_eq!(
2899 sanitize_terminal_text("hello\tworld\nline two"),
2900 "hello\tworld\nline two"
2901 );
2902 assert_eq!(
2904 sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
2905 "RED text"
2906 );
2907 assert_eq!(
2909 sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
2910 "beforeafter"
2911 );
2912 assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
2914 assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
2916 assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
2918 }
2919
2920 #[test]
2921 fn version_compare_handles_update_logic() {
2922 assert!(version_at_least("0.10.2", "0.10.2"));
2924 assert!(version_at_least("0.11.0", "0.10.2"));
2925 assert!(version_at_least("1.0.0", "0.99.99"));
2926 assert!(!version_at_least("0.10.1", "0.10.2"));
2928 assert!(!version_at_least("0.9.0", "0.10.0"));
2929 assert!(!version_at_least("0.10.2", "0.11.0"));
2930 assert!(version_at_least("0.10.2", "v0.10.2"));
2932 assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2933 assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2934 assert!(!version_at_least("0.10.2", "not-a-version"));
2936 }
2937
2938 #[test]
2939 fn qa_compact_smoke_persists_conversation_and_archive() {
2940 let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2941 std::fs::create_dir_all(&dir).unwrap();
2942
2943 let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2944
2945 assert!(report.ok);
2946 assert!(report.archived_messages > 0);
2947 assert!(report.preserved_messages > 0);
2948 assert!(report.replacement_messages >= 3);
2949 assert!(
2950 std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2951 "conversation path should exist"
2952 );
2953 assert!(
2954 std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2955 "archive path should exist"
2956 );
2957
2958 let _ = std::fs::remove_dir_all(dir);
2959 }
2960
2961 #[test]
2962 fn qa_model_id_falls_back_to_deterministic() {
2963 assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2964 }
2965
2966 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2967 let nanos = std::time::SystemTime::now()
2968 .duration_since(std::time::UNIX_EPOCH)
2969 .map(|duration| duration.as_nanos())
2970 .unwrap_or_default();
2971 std::env::temp_dir().join(format!("{name}-{nanos}"))
2972 }
2973}