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