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 ];
821 let sandbox_expected = cfg!(any(
824 target_os = "linux",
825 target_os = "macos",
826 target_os = "windows"
827 ));
828 let ok = compact_smoke.ok
829 && runtime_store.status == "ok"
830 && (!sandbox_expected || (sandbox_available && fs_sandbox_available));
831 let report = SelfTestReport {
832 ok,
833 workspace: workspace.display().to_string(),
834 checks,
835 compact_smoke,
836 runtime_store,
837 kept_workspace: keep_workspace,
838 };
839
840 print_self_test_report(&report, format)?;
841 if !keep_workspace {
842 let _ = std::fs::remove_dir_all(&workspace);
843 }
844 anyhow::ensure!(report.ok, "mermaid self-test failed");
845 Ok(())
846}
847
848fn print_self_test_report(report: &SelfTestReport, format: OutputFormat) -> Result<()> {
849 match format {
850 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
851 OutputFormat::Ndjson => println!("{}", serde_json::to_string(report)?),
852 OutputFormat::Markdown => {
853 println!("# Mermaid Self-Test\n");
854 print_self_test_text(report);
855 },
856 OutputFormat::Text => print_self_test_text(report),
857 }
858 Ok(())
859}
860
861fn print_self_test_text(report: &SelfTestReport) {
862 println!(
863 "Mermaid self-test: {}",
864 if report.ok { "ok" } else { "failed" }
865 );
866 println!("workspace: {}", report.workspace);
867 println!(
868 "compact smoke: {}",
869 if report.compact_smoke.ok {
870 "ok"
871 } else {
872 "failed"
873 }
874 );
875 println!("runtime store: {}", report.runtime_store.message);
876 println!("checks:");
877 for check in &report.checks {
878 println!(" - {check}");
879 }
880 if !report.ok
881 && let Some(failure) = &report.compact_smoke.failure
882 {
883 println!("failure: {failure}");
884 }
885}
886
887fn label(status: &str) -> &'static str {
888 match status {
889 "ok" => "OK",
890 "warning" => "WARNING",
891 "error" => "ERROR",
892 _ => "INFO",
893 }
894}
895
896fn safety_mode_name(mode: mermaid_runtime::SafetyMode) -> &'static str {
897 mode.as_str()
898}
899
900fn login_providers(config: &Config) -> Vec<(String, String, Option<String>)> {
904 let over = |name: &str| {
905 config
906 .providers
907 .get(name)
908 .and_then(|c| c.api_key_env.clone())
909 };
910 let mut rows: Vec<(String, String, Option<String>)> = vec![
911 (
912 "anthropic".to_string(),
913 "ANTHROPIC_API_KEY".to_string(),
914 over("anthropic"),
915 ),
916 (
917 "gemini".to_string(),
918 "GOOGLE_API_KEY".to_string(),
919 over("gemini"),
920 ),
921 (
922 "meta".to_string(),
923 crate::providers::model::meta::DEFAULT_API_KEY_ENV.to_string(),
924 over("meta"),
925 ),
926 (
927 "ollama".to_string(),
928 "OLLAMA_API_KEY".to_string(),
929 over("ollama"),
930 ),
931 ];
932 for profile in PROVIDER_REGISTRY {
933 rows.push((
934 profile.name.to_string(),
935 profile.api_key_env.to_string(),
936 over(profile.name),
937 ));
938 }
939 for (name, cfg) in &config.providers {
940 if rows.iter().any(|(n, _, _)| n == name) {
941 continue;
942 }
943 if let Some(env) = &cfg.api_key_env {
945 rows.push((name.clone(), env.clone(), None));
946 }
947 }
948 rows.sort_by(|a, b| a.0.cmp(&b.0));
949 rows
950}
951
952fn login(provider: Option<&str>, config: &Config) -> Result<()> {
956 let rows = login_providers(config);
957 let Some(provider) = provider else {
958 println!(
959 "Provider API-key status (env beats keyring; `mermaid login <provider>` stores a key):\n"
960 );
961 for (name, default_env, override_env) in &rows {
962 let source = mermaid_model::utils::provider_key_source(
963 name,
964 default_env,
965 override_env.as_deref(),
966 );
967 let env_name = override_env.as_deref().unwrap_or(default_env);
968 println!(" {name:<14} {source:<8} (${env_name})");
969 }
970 return Ok(());
971 };
972 let provider = provider.to_lowercase();
973 let Some((name, default_env, override_env)) = rows.into_iter().find(|(n, _, _)| n == &provider)
974 else {
975 let names: Vec<String> = login_providers(config)
976 .into_iter()
977 .map(|(n, _, _)| n)
978 .collect();
979 anyhow::bail!(
980 "unknown provider '{}'; known: {}",
981 provider,
982 names.join(", ")
983 );
984 };
985 let key = rpassword::prompt_password(format!("API key for {name} (input hidden): "))
986 .context("read API key")?;
987 let key = key.trim();
988 anyhow::ensure!(!key.is_empty(), "no key entered; nothing stored");
989 let store = mermaid_model::utils::default_store();
990 store
991 .set(&name, key)
992 .with_context(|| format!("store key for {name}"))?;
993 println!(
994 "Stored key for {} in {} (service \"mermaid\").",
995 name,
996 store.label()
997 );
998 if mermaid_model::utils::resolve_api_key(&default_env, override_env.as_deref()).is_some() {
1000 let env_name = override_env.as_deref().unwrap_or(&default_env);
1001 println!("Note: ${env_name} is currently set and takes precedence over the stored key.");
1002 }
1003 Ok(())
1004}
1005
1006fn logout(provider: &str, config: &Config) -> Result<()> {
1009 let provider = provider.to_lowercase();
1010 let _ = config;
1013 let store = mermaid_model::utils::default_store();
1014 if store
1015 .delete(&provider)
1016 .with_context(|| format!("delete key for {provider}"))?
1017 {
1018 println!(
1019 "Removed stored key for {} from {}.",
1020 provider,
1021 store.label()
1022 );
1023 } else {
1024 println!("No stored key for {provider}.");
1025 }
1026 Ok(())
1027}
1028
1029fn meta_api_key(config: &Config) -> Option<String> {
1030 mermaid_model::utils::resolve_provider_key(
1031 "meta",
1032 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1033 config
1034 .providers
1035 .get("meta")
1036 .and_then(|provider| provider.api_key_env.as_deref()),
1037 )
1038}
1039
1040fn meta_base_url(config: &Config) -> String {
1041 config
1042 .providers
1043 .get("meta")
1044 .and_then(|provider| provider.base_url.clone())
1045 .unwrap_or_else(|| crate::providers::model::meta::DEFAULT_BASE_URL.to_string())
1046}
1047
1048#[derive(Debug, serde::Serialize)]
1049struct QaCompactSmokeReport {
1050 ok: bool,
1051 turns: usize,
1052 archived_messages: usize,
1053 preserved_messages: usize,
1054 replacement_messages: usize,
1055 conversation_path: Option<String>,
1056 archive_path: Option<String>,
1057 checks: Vec<String>,
1058 failure: Option<String>,
1059}
1060
1061impl QaCompactSmokeReport {
1062 fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
1063 Self {
1064 ok: false,
1065 turns,
1066 archived_messages: 0,
1067 preserved_messages: 0,
1068 replacement_messages: 0,
1069 conversation_path: Some(
1070 cwd.join(".mermaid")
1071 .join("conversations")
1072 .display()
1073 .to_string(),
1074 ),
1075 archive_path: None,
1076 checks: Vec::new(),
1077 failure: Some(failure),
1078 }
1079 }
1080}
1081
1082#[expect(
1083 clippy::too_many_lines,
1084 reason = "an in-binary smoke test with a test's shape: build a synthetic session, drive a \
1085 manual compaction through the reducer, replay the save commands, then assert on the files \
1086 and record each check for the report; the arrange/act/assert steps read as one scenario and \
1087 would lose their sequence split across helpers"
1088)]
1089fn run_qa_compact_smoke(
1090 config: &Config,
1091 cwd: &Path,
1092 requested_turns: usize,
1093) -> Result<QaCompactSmokeReport> {
1094 let turns = requested_turns.max(3);
1095 let mut state = State::new(
1096 config.clone(),
1097 cwd.to_path_buf(),
1098 qa_model_id(config),
1099 chrono::Local::now(),
1100 std::env::temp_dir(),
1101 );
1102 for message in synthetic_compaction_messages(turns) {
1103 state.session.append(message, state.now);
1104 }
1105 let pre_manager = ConversationManager::new(cwd)?;
1111 let pre_snapshot = state.session.snapshot_conversation();
1112 let pre_events = state.session.drain_events(&pre_snapshot);
1113 pre_manager.append_session_events(&pre_snapshot, &pre_events)?;
1114 pre_manager.save_conversation(&pre_snapshot)?;
1115 let dropped_probe = state
1116 .session
1117 .messages()
1118 .first()
1119 .map(|message| message.content.clone())
1120 .context("synthetic history is empty")?;
1121
1122 let (state_after_slash, compact_cmds) = update(
1123 state,
1124 Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
1125 );
1126 let turn = state_after_slash
1127 .turn
1128 .id()
1129 .context("manual compaction did not enter a compaction turn")?;
1130 let request = compact_cmds
1131 .iter()
1132 .find_map(|cmd| match cmd {
1133 Cmd::CompactConversation { request, .. } => Some(request.clone()),
1134 _ => None,
1135 })
1136 .context("manual compaction did not emit a CompactConversation command")?;
1137
1138 let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
1139 let prepared = prepare_compaction(&request, Some(100_000))
1140 .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
1141 anyhow::ensure!(
1142 !prepared.archived_messages.is_empty(),
1143 "compaction archived no messages"
1144 );
1145 anyhow::ensure!(
1146 !prepared.preserved_messages.is_empty(),
1147 "compaction preserved no messages"
1148 );
1149
1150 let summary = deterministic_compaction_summary(&prepared, turns);
1151 let mut record = CompactionEvent {
1152 id: format!("qa_compact_{}", fresh_qa_id()),
1153 trigger: CompactionTrigger::Manual,
1154 created_at: chrono::Local::now(),
1155 before_tokens: before_snapshot.used_tokens,
1156 after_tokens: 0,
1157 archived_message_count: prepared.archived_messages.len(),
1158 preserved_message_count: prepared.preserved_messages.len(),
1159 preserved_turn_count: prepared
1160 .preserved_messages
1161 .iter()
1162 .filter(|message| message.role == mermaid_model::models::MessageRole::User)
1163 .count(),
1164 summary_tokens: summary.len().div_ceil(4),
1165 duration_secs: 0.0,
1166 review_status: mermaid_domain::CompactionReviewStatus::DraftValidated,
1167 review_error: None,
1168 focus: Some("qa compact smoke".to_string()),
1169 archive_path: None,
1170 };
1171 let mut replacement = build_replacement_messages(&summary, &prepared, &record);
1172 let mut after_chat: ChatRequest = request.chat.clone();
1173 after_chat.messages = replacement.clone();
1174 let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1175 record.after_tokens = after_snapshot.used_tokens;
1176 replacement = build_replacement_messages(&summary, &prepared, &record);
1177 after_chat.messages = replacement.clone();
1178 after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1179
1180 let result = CompactionResult {
1181 record,
1182 replacement_messages: replacement,
1183 archived_messages: prepared.archived_messages,
1184 before_snapshot,
1185 after_snapshot,
1186 usage: None,
1187 source_boundaries: Vec::new(),
1188 };
1189 let (final_state, save_cmds) =
1190 update(state_after_slash, Msg::CompactionFinished { turn, result });
1191
1192 let manager = ConversationManager::new(cwd)?;
1193 let mut conversation_path = None;
1194 let mut archive_path = None;
1195 for cmd in save_cmds {
1196 match cmd {
1197 Cmd::SaveConversation {
1198 snapshot: conversation,
1199 ..
1200 } => {
1201 manager.save_conversation(&conversation)?;
1202 conversation_path = Some(
1203 manager
1204 .conversations_dir()
1205 .join(format!("{}.json", conversation.id))
1206 .display()
1207 .to_string(),
1208 );
1209 },
1210 Cmd::SaveCompaction {
1211 conversation,
1212 events,
1213 ..
1214 } => {
1215 manager.append_session_events(&conversation, &events)?;
1220 archive_path = Some(
1221 manager
1222 .event_log_path(&conversation.id)
1223 .display()
1224 .to_string(),
1225 );
1226 manager.save_conversation(&conversation)?;
1227 conversation_path = Some(
1228 manager
1229 .conversations_dir()
1230 .join(format!("{}.json", conversation.id))
1231 .display()
1232 .to_string(),
1233 );
1234 },
1235 _ => {},
1236 }
1237 }
1238
1239 let conversation_path = conversation_path.context("compaction did not save conversation")?;
1240 let archive_path = archive_path.context("compaction did not save archive")?;
1241 let messages = final_state.session.messages();
1242 let compactions = &final_state.session.conversation.compactions;
1243
1244 let mut checks = Vec::new();
1245 anyhow::ensure!(
1246 !compactions.is_empty(),
1247 "conversation did not record compaction metadata"
1248 );
1249 checks.push("conversation records compaction metadata".to_string());
1250 anyhow::ensure!(
1251 messages.first().is_some_and(
1252 |msg| msg.kind == mermaid_model::models::ChatMessageKind::ContextCheckpoint
1253 ),
1254 "replacement does not start with a context checkpoint"
1255 );
1256 checks.push("replacement starts with context checkpoint".to_string());
1257 anyhow::ensure!(
1258 std::path::Path::new(&conversation_path).exists(),
1259 "conversation file missing after save"
1260 );
1261 checks.push("conversation file saved".to_string());
1262 anyhow::ensure!(
1263 std::path::Path::new(&archive_path).exists(),
1264 "session event log missing after compaction save"
1265 );
1266 checks.push("session event log saved".to_string());
1267 let log = std::fs::read_to_string(&archive_path).context("read the session event log")?;
1272 anyhow::ensure!(
1273 log.contains("\"type\":\"compaction\""),
1274 "event log has no compaction boundary"
1275 );
1276 anyhow::ensure!(
1277 !messages
1278 .iter()
1279 .any(|message| message.content == dropped_probe),
1280 "the probe message was not actually dropped by the compaction"
1281 );
1282 anyhow::ensure!(
1283 log.contains(dropped_probe.trim()),
1284 "event log lost a message the compaction dropped"
1285 );
1286 checks.push("dropped messages survive in the event log".to_string());
1287 anyhow::ensure!(
1288 compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
1289 "compaction did not archive and preserve messages"
1290 );
1291 checks.push("archived and preserved message counts are non-zero".to_string());
1292
1293 Ok(QaCompactSmokeReport {
1294 ok: true,
1295 turns,
1296 archived_messages: compactions[0].archived_message_count,
1297 preserved_messages: compactions[0].preserved_message_count,
1298 replacement_messages: messages.len(),
1299 conversation_path: Some(conversation_path),
1300 archive_path: Some(archive_path),
1301 checks,
1302 failure: None,
1303 })
1304}
1305
1306fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
1307 match format {
1308 OutputFormat::Json => {
1309 println!("{}", serde_json::to_string_pretty(report)?);
1310 },
1311 OutputFormat::Ndjson => {
1312 println!("{}", serde_json::to_string(report)?);
1313 },
1314 OutputFormat::Text => {
1315 println!(
1316 "qa compact smoke: {}",
1317 if report.ok { "ok" } else { "failed" }
1318 );
1319 println!("turns: {}", report.turns);
1320 println!("archived messages: {}", report.archived_messages);
1321 println!("preserved messages: {}", report.preserved_messages);
1322 println!("replacement messages: {}", report.replacement_messages);
1323 if let Some(path) = &report.conversation_path {
1324 println!("conversation: {path}");
1325 }
1326 if let Some(path) = &report.archive_path {
1327 println!("archive: {path}");
1328 }
1329 if let Some(failure) = &report.failure {
1330 println!("failure: {failure}");
1331 }
1332 },
1333 OutputFormat::Markdown => {
1334 println!(
1335 "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
1336 if report.ok { "ok" } else { "failed" },
1337 report.turns,
1338 report.archived_messages,
1339 report.preserved_messages,
1340 report.replacement_messages
1341 );
1342 if let Some(path) = &report.conversation_path {
1343 println!("- Conversation: `{path}`");
1344 }
1345 if let Some(path) = &report.archive_path {
1346 println!("- Archive: `{path}`");
1347 }
1348 if let Some(failure) = &report.failure {
1349 println!("\nFailure: `{failure}`");
1350 }
1351 },
1352 }
1353 Ok(())
1354}
1355
1356fn qa_model_id(config: &Config) -> String {
1357 if let Some(model) = config
1358 .last_used_model
1359 .as_ref()
1360 .filter(|value| !value.is_empty())
1361 {
1362 return model.clone();
1363 }
1364 if !config.default_model.name.is_empty() {
1365 if config.default_model.provider.is_empty() {
1366 return config.default_model.name.clone();
1367 }
1368 return format!(
1369 "{}/{}",
1370 config.default_model.provider, config.default_model.name
1371 );
1372 }
1373 "qa/deterministic".to_string()
1374}
1375
1376fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
1377 let mut messages = Vec::with_capacity(turns.saturating_mul(2));
1378 for idx in 1..=turns {
1379 messages.push(ChatMessage::user(format!(
1380 "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
1381 )));
1382 messages.push(ChatMessage::assistant(format!(
1383 "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}."
1384 )));
1385 }
1386 messages
1387}
1388
1389fn deterministic_compaction_summary(
1390 prepared: &mermaid_domain::PreparedCompaction,
1391 turns: usize,
1392) -> String {
1393 format!(
1394 "## 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.",
1395 prepared.archived_messages.len(),
1396 prepared.preserved_messages.len()
1397 )
1398}
1399
1400fn fresh_qa_id() -> u128 {
1401 std::time::SystemTime::now()
1402 .duration_since(std::time::UNIX_EPOCH)
1403 .map(|duration| duration.as_nanos())
1404 .unwrap_or_default()
1405}
1406
1407fn show_tasks(limit: usize) -> Result<()> {
1408 let read = RuntimeClient::auto().list_tasks(limit)?;
1409 let mut tasks = read.value;
1410 tasks.truncate(limit);
1411 println!("Mermaid runtime tasks");
1412 println!("Source: {}", read.source.as_str());
1413 println!();
1414 if tasks.is_empty() {
1415 println!("No tasks recorded yet.");
1416 return Ok(());
1417 }
1418 for task in tasks {
1419 println!(
1420 "{} [{}] {} {} {}",
1421 task.id, task.status, task.priority, task.updated_at, task.title
1422 );
1423 println!(" project: {}", task.project_path);
1424 println!(" model: {}", task.model_id);
1425 }
1426 Ok(())
1427}
1428
1429fn show_task(id: &str) -> Result<()> {
1430 let detail = RuntimeClient::auto().task_detail(id)?.value;
1431 print_task_detail(&detail.task);
1432 let events = detail.events;
1433 if !events.is_empty() {
1434 println!();
1435 println!("Timeline:");
1436 for event in events {
1437 println!(" {} {} {}", event.created_at, event.kind, event.message);
1438 }
1439 }
1440 Ok(())
1441}
1442
1443fn print_task_detail(task: &TaskRecord) {
1444 println!("Task: {}", task.id);
1445 println!("Title: {}", task.title);
1446 println!("Status: {}", task.status);
1447 println!("Priority: {}", task.priority);
1448 println!("Project: {}", task.project_path);
1449 println!("Model: {}", task.model_id);
1450 if let Some(conversation_id) = &task.conversation_id {
1451 println!("Conversation: {conversation_id}");
1452 }
1453 println!("Created: {}", task.created_at);
1454 println!("Updated: {}", task.updated_at);
1455 if let Some(report) = &task.final_report {
1456 println!();
1457 println!("Final report:");
1458 println!("{}", sanitize_terminal_text(report));
1459 }
1460}
1461
1462fn show_processes(limit: usize) -> Result<()> {
1463 let read = RuntimeClient::auto().list_processes(limit)?;
1464 let mut processes = read.value;
1465 processes.truncate(limit);
1466 println!("Mermaid runtime processes");
1467 println!("Source: {}", read.source.as_str());
1468 println!();
1469 if processes.is_empty() {
1470 println!("No processes recorded yet.");
1471 return Ok(());
1472 }
1473 for process in processes {
1474 println!(
1475 "{} pid={} status={} {}",
1476 process.id,
1477 process.pid,
1478 process.status.as_str(),
1479 process.command
1480 );
1481 if let Some(task_id) = process.task_id {
1482 println!(" task: {task_id}");
1483 }
1484 if let Some(cwd) = process.cwd {
1485 println!(" cwd: {cwd}");
1486 }
1487 if let Some(log_path) = process.log_path {
1488 println!(" log: {log_path}");
1489 }
1490 if let Some(url) = process.detected_url {
1491 println!(" url: {url}");
1492 }
1493 }
1494 Ok(())
1495}
1496
1497async fn show_models(config: &Config) -> Result<()> {
1498 list_models(config).await?;
1499 probe_configured_provider_models(config).await?;
1500 let store = RuntimeStore::open_default()?;
1501 let probes = store.provider_probes().list(None, None)?;
1502 if !probes.is_empty() {
1503 println!("\nCached capability probes:");
1504 for probe in probes {
1505 println!(
1506 " - {}/{} {}={} ({})",
1507 probe.provider,
1508 probe.model_id,
1509 probe.capability_key,
1510 probe.capability_value,
1511 probe.confidence
1512 );
1513 }
1514 }
1515 Ok(())
1516}
1517
1518async fn show_model_info(model: &str, config: &Config) -> Result<()> {
1519 let snapshot = mermaid_domain::ProviderCapabilitySnapshot::from_model_id(model);
1520 let store = RuntimeStore::open_default()?;
1521 let provider = snapshot.provider.clone();
1522
1523 let mut context_tokens = snapshot.max_context_tokens;
1530 let mut context_confidence = "static";
1531 let mut output_tokens = snapshot.max_output_tokens;
1532 let mut output_confidence = "static";
1533 let factory = crate::providers::ProviderFactory::new(config.clone());
1534 if let Ok(live) = factory.resolve(model).await {
1535 let probe_request = ChatRequest {
1536 model_id: model.to_string(),
1537 messages: vec![],
1538 system_prompt: String::new(),
1539 instructions: None,
1540 reasoning: mermaid_model::models::ReasoningLevel::None,
1541 temperature: 0.0,
1542 max_tokens: 0,
1543 tools: vec![],
1544 ollama_num_ctx: None,
1545 ollama_allow_ram_offload: None,
1546 resolved_context_window: None,
1547 resolved_max_output: None,
1548 output_schema: None,
1549 suppress_auto_compact: false,
1550 suppressed_builtin_tools: Vec::new(),
1551 };
1552 let sizing = live.resolve_context_window(&probe_request).await;
1553 if let Some(window) = sizing.model_max.or(sizing.effective) {
1554 context_tokens = Some(window);
1555 context_confidence = "probed";
1556 }
1557 if let Some(output) = sizing.max_output {
1558 output_tokens = Some(output);
1559 output_confidence = "probed";
1560 }
1561 }
1562
1563 for (key, value) in [
1564 ("supports_tools", snapshot.supports_tools.to_string()),
1565 ("supports_vision", snapshot.supports_vision.to_string()),
1566 ("reasoning", snapshot.reasoning.clone()),
1567 ] {
1568 let _ = store.provider_probes().upsert(NewProviderProbe {
1569 provider: provider.clone(),
1570 model_id: snapshot.model.clone(),
1571 capability_key: key.to_string(),
1572 capability_value: value,
1573 confidence: "static".to_string(),
1574 error: None,
1575 });
1576 }
1577 let _ = store.provider_probes().upsert(NewProviderProbe {
1579 provider: provider.clone(),
1580 model_id: snapshot.model.clone(),
1581 capability_key: "max_context_tokens".to_string(),
1582 capability_value: context_tokens
1583 .map(|n| n.to_string())
1584 .unwrap_or_else(|| "unknown".to_string()),
1585 confidence: context_confidence.to_string(),
1586 error: None,
1587 });
1588 println!("Model: {model}");
1589 println!("Provider: {}", snapshot.provider);
1590 println!("Name: {}", snapshot.model);
1591 println!("Supports tools: {}", snapshot.supports_tools);
1592 println!("Supports vision: {}", snapshot.supports_vision);
1593 println!("Reasoning: {}", snapshot.reasoning);
1594 println!(
1595 "Context: {}",
1596 context_tokens
1597 .map(|n| format!("{n} ({context_confidence})"))
1598 .unwrap_or_else(|| "unknown".to_string())
1599 );
1600 println!(
1601 "Output limit: {}",
1602 output_tokens
1603 .map(|n| format!("{n} ({output_confidence})"))
1604 .unwrap_or_else(|| {
1605 "unknown (discovered live from the provider's models endpoint when exposed)"
1606 .to_string()
1607 })
1608 );
1609 if let Some(profile) = lookup_provider(&snapshot.provider) {
1610 record_static_provider_probes(&store, profile, &provider, &snapshot.model);
1611 println!("Token budget field: {:?}", profile.max_tokens_param);
1612 println!(
1613 "Single-tool-call models: {}",
1614 if profile.disable_parallel_tool_calls_for.is_empty() {
1615 "(none)".to_string()
1616 } else {
1617 profile.disable_parallel_tool_calls_for.join(", ")
1618 }
1619 );
1620 }
1621 Ok(())
1622}
1623
1624async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1625 let client = reqwest::Client::builder()
1626 .timeout(std::time::Duration::from_secs(5))
1627 .build()?;
1628 for profile in PROVIDER_REGISTRY {
1629 let user_cfg = config.providers.get(profile.name);
1630 let Some(api_key) = mermaid_model::utils::resolve_provider_key(
1631 profile.name,
1632 profile.api_key_env,
1633 user_cfg.and_then(|c| c.api_key_env.as_deref()),
1634 ) else {
1635 continue;
1636 };
1637 let Some(base_url) = crate::providers::factory::discovery_base_url(
1638 profile,
1639 user_cfg.and_then(|c| c.base_url.clone()),
1640 ) else {
1641 record_provider_probe(
1645 profile.name,
1646 "*",
1647 "models_availability",
1648 "failed",
1649 "failed",
1650 Some("CLOUDFLARE_ACCOUNT_ID not set".to_string()),
1651 );
1652 continue;
1653 };
1654 let url = format!("{}/models", base_url.trim_end_matches('/'));
1655 let mut request = client.get(&url).bearer_auth(api_key);
1656 for (name, value) in profile.extra_headers {
1657 request = request.header(*name, *value);
1658 }
1659 if let Some(user_cfg) = user_cfg {
1660 for (name, value) in &user_cfg.extra_headers {
1661 request = request.header(name, value);
1662 }
1663 }
1664
1665 let result = request.send().await;
1666 match result {
1667 Ok(response) if response.status().is_success() => {
1668 let status = response.status();
1669 let body: serde_json::Value = response.json().await.unwrap_or_default();
1670 let ids = body
1671 .get("data")
1672 .and_then(|v| v.as_array())
1673 .map(|items| {
1674 items
1675 .iter()
1676 .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1677 .map(str::to_string)
1678 .collect::<Vec<_>>()
1679 })
1680 .unwrap_or_default();
1681 record_provider_probe(
1682 profile.name,
1683 "*",
1684 "models_availability",
1685 &format!("available:{}:{}", status.as_u16(), ids.len()),
1686 "probed",
1687 None,
1688 );
1689 for model_id in ids.into_iter().take(200) {
1690 record_provider_probe(
1691 profile.name,
1692 &model_id,
1693 "model_listed",
1694 "true",
1695 "listed",
1696 None,
1697 );
1698 }
1699 },
1700 Ok(response) => {
1701 record_provider_probe(
1702 profile.name,
1703 "*",
1704 "models_availability",
1705 "failed",
1706 "failed",
1707 Some(format!("HTTP {}", response.status().as_u16())),
1708 );
1709 },
1710 Err(error) => {
1711 record_provider_probe(
1712 profile.name,
1713 "*",
1714 "models_availability",
1715 "failed",
1716 "failed",
1717 Some(error.to_string()),
1718 );
1719 },
1720 }
1721 }
1722 probe_meta_models(&client, config).await;
1723 Ok(())
1724}
1725
1726async fn probe_meta_models(client: &reqwest::Client, config: &Config) {
1727 let Some(api_key) = meta_api_key(config) else {
1728 return;
1729 };
1730 let url = format!("{}/models", meta_base_url(config).trim_end_matches('/'));
1731 let mut request = client.get(&url).bearer_auth(api_key);
1732 if let Some(provider) = config.providers.get("meta") {
1733 for (name, value) in &provider.extra_headers {
1734 request = request.header(name, value);
1735 }
1736 for (name, env_var) in &provider.env_headers {
1737 if let Ok(value) = std::env::var(env_var) {
1738 request = request.header(name, value);
1739 }
1740 }
1741 }
1742 match request.send().await {
1743 Ok(response) if response.status().is_success() => {
1744 let status = response.status();
1745 let body: serde_json::Value = response.json().await.unwrap_or_default();
1746 let ids = body
1747 .get("data")
1748 .and_then(serde_json::Value::as_array)
1749 .into_iter()
1750 .flatten()
1751 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1752 .map(str::to_string)
1753 .collect::<Vec<_>>();
1754 record_provider_probe(
1755 "meta",
1756 "*",
1757 "models_availability",
1758 &format!("available:{}:{}", status.as_u16(), ids.len()),
1759 "probed",
1760 None,
1761 );
1762 for model_id in ids.into_iter().take(200) {
1763 record_provider_probe("meta", &model_id, "model_listed", "true", "listed", None);
1764 }
1765 },
1766 Ok(response) => record_provider_probe(
1767 "meta",
1768 "*",
1769 "models_availability",
1770 "failed",
1771 "failed",
1772 Some(format!("HTTP {}", response.status().as_u16())),
1773 ),
1774 Err(error) => record_provider_probe(
1775 "meta",
1776 "*",
1777 "models_availability",
1778 "failed",
1779 "failed",
1780 Some(error.to_string()),
1781 ),
1782 }
1783}
1784
1785fn record_provider_probe(
1786 provider: &str,
1787 model_id: &str,
1788 key: &str,
1789 value: &str,
1790 confidence: &str,
1791 error: Option<String>,
1792) {
1793 if let Ok(store) = RuntimeStore::open_default() {
1794 let _ = store.provider_probes().upsert(NewProviderProbe {
1795 provider: provider.to_string(),
1796 model_id: model_id.to_string(),
1797 capability_key: key.to_string(),
1798 capability_value: value.to_string(),
1799 confidence: confidence.to_string(),
1800 error,
1801 });
1802 }
1803}
1804
1805fn show_approvals() -> Result<()> {
1806 let approvals = RuntimeClient::auto().list_approvals()?.value;
1807 if approvals.is_empty() {
1808 println!("No pending approvals.");
1809 return Ok(());
1810 }
1811 for approval in approvals {
1812 println!(
1813 "{} [{} -> {}] {}",
1814 approval.id,
1815 approval.risk_classification,
1816 approval.policy_decision,
1817 approval.proposed_action
1818 );
1819 if let Some(args) = approval.args_summary {
1820 println!(" args: {args}");
1821 }
1822 if let Some(checkpoint_id) = approval.checkpoint_id {
1823 println!(" checkpoint: {checkpoint_id}");
1824 }
1825 if approval.pending_action_json.is_some() {
1826 println!(" pending action: recorded");
1827 }
1828 }
1829 Ok(())
1830}
1831
1832fn approve(id: &str) -> Result<()> {
1833 let result = RuntimeClient::auto().approve(id)?;
1834 println!("Approved {id}");
1835 if result.replayed {
1836 println!("{}", result.summary);
1837 }
1838 Ok(())
1839}
1840
1841fn deny(id: &str) -> Result<()> {
1842 let _ = RuntimeClient::auto().deny(id)?;
1843 println!("Denied {id}");
1844 Ok(())
1845}
1846
1847fn follow_task(id: &str) -> Result<()> {
1852 let lines = mermaid_runtime::subscribe_daemon_lines(
1853 crate::runtime_client::DaemonRequest::SubscribeTask {
1854 task_id: id.to_string(),
1855 }
1856 .to_wire(),
1857 )
1858 .context("mermaid task --follow needs a running daemon (`mermaid daemon start`)")?;
1859 let mut saw_any = false;
1860 for line in lines {
1861 let line = line?;
1862 if line.trim().is_empty() {
1863 continue;
1864 }
1865 if !saw_any {
1867 saw_any = true;
1868 let ack: serde_json::Value =
1869 serde_json::from_str(line.trim()).context("daemon returned invalid JSON")?;
1870 if ack.get("ok").and_then(|v| v.as_bool()) == Some(false) {
1871 anyhow::bail!(
1872 "{}",
1873 ack.get("error")
1874 .and_then(|v| v.as_str())
1875 .unwrap_or("subscribe failed")
1876 );
1877 }
1878 println!("{}", line.trim());
1879 continue;
1880 }
1881 println!("{}", line.trim());
1882 if serde_json::from_str::<serde_json::Value>(line.trim())
1883 .ok()
1884 .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_string))
1885 .as_deref()
1886 == Some("result")
1887 {
1888 return Ok(());
1889 }
1890 }
1891 if saw_any {
1892 anyhow::bail!("stream ended without a result (daemon restarted mid-run?)");
1893 }
1894 anyhow::bail!("daemon closed the connection without responding");
1895}
1896
1897fn send_to_task(id: &str, text: &str) -> Result<()> {
1903 if text.trim().is_empty() {
1904 anyhow::bail!("nothing to send: --send needs a prompt");
1905 }
1906 mermaid_runtime::request_daemon_json(
1907 crate::runtime_client::DaemonRequest::SendToTask {
1908 id: id.to_string(),
1909 text: text.to_string(),
1910 }
1911 .to_wire(),
1912 )?;
1913 println!("Sent to {id}; it answers after the turn it is on.");
1914 Ok(())
1915}
1916
1917fn cancel_task(id: &str) -> Result<()> {
1922 match mermaid_runtime::request_daemon_json(
1923 crate::runtime_client::DaemonRequest::CancelTask { id: id.to_string() }.to_wire(),
1924 ) {
1925 Ok(response) => {
1926 if response.get("cancelling").and_then(|v| v.as_bool()) == Some(true) {
1927 println!("Cancelling {id} (running; the agent unwinds gracefully)");
1928 } else {
1929 println!("Cancelled {id}");
1930 }
1931 Ok(())
1932 },
1933 Err(daemon_err) => {
1934 let store = mermaid_runtime::RuntimeStore::open_default()?;
1935 match store.tasks().get(id)? {
1936 Some(task) if task.status == mermaid_runtime::TaskStatus::Queued => {
1937 store.tasks().update_status(
1938 id,
1939 mermaid_runtime::TaskStatus::Cancelled,
1940 Some("cancelled before start"),
1941 )?;
1942 println!("Cancelled {id} (was queued; daemon unreachable)");
1943 Ok(())
1944 },
1945 Some(task) => anyhow::bail!(
1946 "task {} is {} and the daemon request failed: {}",
1947 id,
1948 task.status,
1949 daemon_err
1950 ),
1951 None => anyhow::bail!("task not found: {id}"),
1952 }
1953 },
1954 }
1955}
1956
1957fn show_tool_runs(limit: usize) -> Result<()> {
1958 let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1959 runs.truncate(limit);
1960 if runs.is_empty() {
1961 println!("No tool runs recorded yet.");
1962 return Ok(());
1963 }
1964 for run in runs {
1965 println!(
1966 "{} [{}] {} started {}",
1967 run.id, run.status, run.tool_name, run.started_at
1968 );
1969 if let Some(turn_id) = run.turn_id {
1970 println!(" turn: {turn_id}");
1971 }
1972 if let Some(call_id) = run.call_id {
1973 println!(" call: {call_id}");
1974 }
1975 if let Some(finished_at) = run.finished_at {
1976 println!(" finished: {finished_at}");
1977 }
1978 }
1979 Ok(())
1980}
1981
1982fn show_checkpoints(limit: usize) -> Result<()> {
1983 let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1984 checkpoints.truncate(limit);
1985 if checkpoints.is_empty() {
1986 println!("No checkpoints recorded yet.");
1987 return Ok(());
1988 }
1989 for checkpoint in checkpoints {
1990 println!(
1991 "{} {} {}",
1992 checkpoint.id, checkpoint.created_at, checkpoint.project_path
1993 );
1994 println!(" snapshot: {}", checkpoint.snapshot_path);
1995 println!(" files: {}", checkpoint.changed_files_json);
1996 if let Some(approval_id) = checkpoint.approval_id {
1997 println!(" approval: {approval_id}");
1998 }
1999 }
2000 Ok(())
2001}
2002
2003fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
2004 if !mermaid_model::utils::confirm_or_refuse(
2008 &format!("Restore checkpoint {id}? This overwrites the current working tree."),
2009 force,
2010 )? {
2011 println!("Restore cancelled.");
2012 return Ok(());
2013 }
2014 let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
2015 println!("Restored {} ({} files)", manifest.id, manifest.files.len());
2016 if let Some(repo) = manifest.shadow_git_repo {
2017 println!("Shadow repo: {repo}");
2018 }
2019 if let Some(commit) = manifest.shadow_git_commit {
2020 println!("Shadow commit: {commit}");
2021 }
2022 if let Some(action) = manifest.pending_action {
2023 println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
2024 }
2025 Ok(())
2026}
2027
2028fn handle_plugin(command: &PluginCommand) -> Result<()> {
2029 match command {
2030 PluginCommand::Install { path } => {
2031 let preview = mermaid_runtime::plugin_capability_preview(path)?;
2032 print_plugin_capability_preview(&preview);
2033 let record = mermaid_runtime::install_plugin_from_path(path)?;
2034 println!(
2035 "Installed plugin {} ({}) — DISABLED.",
2036 record.name, record.id
2037 );
2038 println!(
2039 "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
2040 record.id
2041 );
2042 },
2043 PluginCommand::List => {
2044 let plugins = RuntimeClient::auto().list_plugins()?.value;
2045 if plugins.is_empty() {
2046 println!("No plugins installed.");
2047 } else {
2048 for plugin in plugins {
2049 println!(
2050 "{} [{}] {} ({})",
2051 plugin.id,
2052 if plugin.enabled {
2053 "enabled"
2054 } else {
2055 "disabled"
2056 },
2057 plugin.name,
2058 plugin.source
2059 );
2060 }
2061 }
2062 },
2063 PluginCommand::Enable { id } => {
2064 let client = RuntimeClient::auto();
2066 if let Some(plugin) = client
2067 .list_plugins()?
2068 .value
2069 .into_iter()
2070 .find(|p| p.id == *id || p.name == *id)
2071 && let Ok(preview) =
2072 mermaid_runtime::plugin_capability_preview(Path::new(&plugin.source))
2073 {
2074 print_plugin_capability_preview(&preview);
2075 }
2076 client.set_plugin_enabled(id, true)?;
2077 println!("Enabled plugin {id} — its hooks will now run.");
2078 },
2079 PluginCommand::Disable { id } => {
2080 RuntimeClient::auto().set_plugin_enabled(id, false)?;
2081 println!("Disabled plugin {id}");
2082 },
2083 PluginCommand::Audit { path } => {
2084 let manifest_path = if path.is_dir() {
2085 path.join("plugin.toml")
2086 } else {
2087 path.clone()
2088 };
2089 let raw = std::fs::read_to_string(&manifest_path)?;
2090 let manifest: mermaid_runtime::PluginManifest = toml::from_str(&raw)?;
2091 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
2092 mermaid_runtime::validate_plugin_manifest(&manifest, root)?;
2093 let preview = mermaid_runtime::plugin_capability_preview(path)?;
2094 println!("Plugin manifest is valid: {}", manifest.name);
2095 print_plugin_capability_preview(&preview);
2096 },
2097 }
2098 Ok(())
2099}
2100
2101fn print_plugin_capability_preview(preview: &mermaid_runtime::PluginCapabilityPreview) {
2102 println!(
2103 "ModelCapabilities declared by plugin {} (advisory, not sandbox-enforced):",
2104 preview.name
2105 );
2106 if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
2107 println!(" capabilities: (none declared)");
2108 } else {
2109 if !preview.declared_capabilities.is_empty() {
2110 println!(" declared: {}", preview.declared_capabilities.join(", "));
2111 }
2112 if let Some(value) = &preview.capabilities_toml {
2113 println!(
2114 " capabilities.toml: {}",
2115 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
2116 );
2117 }
2118 }
2119 if !preview.hooks.is_empty() {
2120 println!(" hooks: {}", preview.hooks.join(", "));
2121 }
2122 if !preview.mcp.is_empty() {
2123 println!(" mcp: {}", preview.mcp.join(", "));
2124 }
2125 if !preview.bin.is_empty() {
2126 println!(" bin: {}", preview.bin.join(", "));
2127 }
2128}
2129
2130fn handle_pair(command: &PairCommand) -> Result<()> {
2131 let store = RuntimeStore::open_default()?;
2132 match command {
2133 PairCommand::Create { label, ttl_days } => {
2134 let ttl = ttl_days.unwrap_or(mermaid_runtime::DEFAULT_PAIRING_TTL_DAYS);
2135 let expires_at = mermaid_runtime::pairing_expiry_from_now(ttl);
2136 let (token, hash) = mermaid_runtime::generate_pairing_token()?;
2137 let record =
2138 store
2139 .pairing_tokens()
2140 .create(&hash, label.as_deref(), expires_at.as_deref())?;
2141 println!("Pairing token id: {}", record.id);
2142 println!("Pairing token: {token}");
2143 println!(
2144 "Expires: {}",
2145 record.expires_at.as_deref().unwrap_or("never")
2146 );
2147 println!(
2148 "Use with daemon JSON by setting {}.",
2149 mermaid_runtime::daemon::DAEMON_TOKEN_ENV
2150 );
2151 println!("Store this now; Mermaid will not print it again.");
2152 },
2153 PairCommand::List => {
2154 let tokens = store.pairing_tokens().list()?;
2155 if tokens.is_empty() {
2156 println!("No pairing tokens.");
2157 } else {
2158 for t in tokens {
2160 println!(
2161 "{} [{}] label={} created={} expires={} last_used={}",
2162 t.id,
2163 if t.enabled { "active" } else { "revoked" },
2164 t.label.as_deref().unwrap_or("-"),
2165 t.created_at,
2166 t.expires_at.as_deref().unwrap_or("never"),
2167 t.last_used_at.as_deref().unwrap_or("never"),
2168 );
2169 }
2170 }
2171 },
2172 PairCommand::Revoke { id } => {
2173 if store.pairing_tokens().revoke(id)? {
2174 println!("Revoked pairing token {id}");
2175 } else {
2176 println!("No active pairing token with id {id}");
2177 }
2178 },
2179 }
2180 Ok(())
2181}
2182
2183fn sanitize_terminal_text(input: &str) -> String {
2192 let mut out = String::with_capacity(input.len());
2193 let mut chars = input.chars();
2194 while let Some(c) = chars.next() {
2195 match c {
2196 '\n' | '\t' => out.push(c),
2197 '\u{1b}' => match chars.next() {
2198 Some('[') => {
2201 for p in chars.by_ref() {
2202 if ('@'..='~').contains(&p) {
2203 break;
2204 }
2205 }
2206 },
2207 Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
2210 while let Some(p) = chars.next() {
2211 if p == '\u{07}' {
2212 break;
2213 }
2214 if p == '\u{1b}' {
2215 let mut peek = chars.clone();
2217 if peek.next() == Some('\\') {
2218 chars = peek;
2219 }
2220 break;
2221 }
2222 }
2223 },
2224 Some(mut b) => {
2227 while ('\u{20}'..='\u{2f}').contains(&b) {
2228 match chars.next() {
2229 Some(next) => b = next,
2230 None => break,
2231 }
2232 }
2233 },
2234 None => {},
2235 },
2236 c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
2238 c => out.push(c),
2239 }
2240 }
2241 out
2242}
2243
2244fn show_logs(id: &str) -> Result<()> {
2245 let content = RuntimeClient::auto().process_log(id, None)?.content;
2246 print!("{}", sanitize_terminal_text(&content));
2247 Ok(())
2248}
2249
2250fn stop_process(id: &str) -> Result<()> {
2251 let process = RuntimeClient::auto().stop_process(id)?.item;
2252 println!("Stopped process {} (pid {})", id, process.pid);
2253 Ok(())
2254}
2255
2256fn restart_process(id: &str) -> Result<()> {
2257 let process = RuntimeClient::auto().restart_process(id)?.item;
2258 println!("Restarted process {} (pid {})", id, process.pid);
2259 Ok(())
2260}
2261
2262fn open_target(target: &str) -> Result<()> {
2263 if RuntimeClient::auto().open_process(target).is_err() {
2264 mermaid_model::utils::open_file(target);
2265 }
2266 Ok(())
2267}
2268
2269fn show_ports() -> Result<()> {
2270 let ports = RuntimeClient::auto().ports()?.ports;
2271 print!("{}", sanitize_terminal_text(&ports));
2272 Ok(())
2273}
2274
2275pub async fn list_models(config: &Config) -> Result<()> {
2290 match observe_models(config).await {
2291 LocalModelListing::Unreachable if is_ollama_installed() => {
2292 println!("Ollama is installed but not running, and its model store could not be read.");
2293 println!("(It starts automatically when you use an Ollama model.)");
2294 },
2295 LocalModelListing::Unreachable => println!("Ollama is not installed; no local models."),
2296 LocalModelListing::Live(models) if models.is_empty() => {
2297 println!("No Ollama models installed locally.");
2298 },
2299 LocalModelListing::Live(models) => {
2300 println!("Ollama models (local/cloud):");
2301 for name in &models {
2302 println!(" - ollama/{name}");
2303 }
2304 },
2305 LocalModelListing::FromDisk(models) => {
2306 println!(
2307 "Ollama models (installed; server not running — starts automatically on use):"
2308 );
2309 for name in &models {
2310 println!(" - ollama/{name}");
2311 }
2312 },
2313 }
2314
2315 println!("\nConfigured remote providers:");
2316 let catalogs = crate::providers::discovery::provider_catalogs(config).await;
2317 if catalogs.is_empty() {
2318 println!(" (none — set a provider API key env var to enable)");
2319 }
2320 for catalog in &catalogs {
2321 println!(
2322 " - {} ({}) {}",
2323 catalog.provider.name,
2324 catalog.provider.source_label(),
2325 catalog.provider.endpoint
2326 );
2327 match &catalog.models {
2328 None => {
2331 println!(" (model list unavailable — the provider's /models did not answer)")
2332 },
2333 Some(models) if models.is_empty() => println!(" (provider lists no models)"),
2334 Some(models) => {
2335 for id in models {
2336 println!(" {}/{}", catalog.provider.name, id);
2337 }
2338 },
2339 }
2340 }
2341
2342 let problems = crate::providers::provider_problems(config);
2346 if !problems.is_empty() {
2347 println!("\nConfigured but not usable:");
2348 for problem in &problems {
2349 println!(" - {}: {}", problem.name, problem.reason);
2350 }
2351 }
2352
2353 println!("\nSwitch models in-session with /model <name>.");
2354 Ok(())
2355}
2356
2357const RELEASE_LATEST_API: &str =
2358 "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
2359const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
2360const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
2361
2362async fn run_update(check: bool, force: bool) -> Result<()> {
2368 let current = env!("CARGO_PKG_VERSION");
2369 println!("Installed: v{current}");
2370
2371 let client = reqwest::Client::builder()
2372 .timeout(std::time::Duration::from_secs(15))
2373 .build()?;
2374 let resp = client
2375 .get(RELEASE_LATEST_API)
2376 .header("User-Agent", "mermaid-cli")
2377 .header("Accept", "application/vnd.github+json")
2378 .send()
2379 .await
2380 .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
2381 if !resp.status().is_success() {
2382 bail!("GitHub Releases API returned HTTP {}", resp.status());
2383 }
2384 let release: serde_json::Value = resp.json().await?;
2385 let tag = release
2386 .get("tag_name")
2387 .and_then(|v| v.as_str())
2388 .ok_or_else(|| anyhow!("release response had no tag_name"))?;
2389 println!("Latest: {tag}");
2390
2391 let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
2392 if check {
2393 if up_to_date {
2394 println!("You're on the latest version.");
2395 } else {
2396 println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
2397 }
2398 return Ok(());
2399 }
2400 if up_to_date && !force {
2401 println!("Already up to date.");
2402 return Ok(());
2403 }
2404
2405 let exe =
2407 std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
2408 let install_dir = exe
2409 .parent()
2410 .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
2411
2412 let script_url = if cfg!(target_os = "windows") {
2417 INSTALL_PS1_URL
2418 } else {
2419 INSTALL_SH_URL
2420 };
2421 if !mermaid_model::utils::confirm_or_refuse(
2422 &format!(
2423 "About to download and run {script_url} to replace {}.",
2424 install_dir.display()
2425 ),
2426 force,
2427 )? {
2428 println!("Update cancelled.");
2429 return Ok(());
2430 }
2431
2432 println!("Updating {} …", install_dir.display());
2433 run_install_script(&client, install_dir).await?;
2434 println!("Updated. New version takes effect on the next run.");
2435 Ok(())
2436}
2437
2438async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
2441 let windows = cfg!(target_os = "windows");
2442 let url = if windows {
2443 INSTALL_PS1_URL
2444 } else {
2445 INSTALL_SH_URL
2446 };
2447 let script = client
2448 .get(url)
2449 .header("User-Agent", "mermaid-cli")
2450 .send()
2451 .await
2452 .map_err(|e| anyhow!("could not fetch install script: {e}"))?
2453 .error_for_status()?
2454 .text()
2455 .await?;
2456
2457 let ext = if windows { "ps1" } else { "sh" };
2458 let dir = mermaid_model::utils::private_temp_dir()
2465 .map_err(|e| anyhow!("could not create private temp dir for install script: {e}"))?;
2466 let nanos = std::time::SystemTime::now()
2467 .duration_since(std::time::UNIX_EPOCH)
2468 .map(|d| d.as_nanos())
2469 .unwrap_or_default();
2470 let script_path = dir.join(format!(
2471 "mermaid-update-{}-{nanos}.{ext}",
2472 std::process::id()
2473 ));
2474 stage_install_script(&script_path, script.as_bytes())
2475 .map_err(|e| anyhow!("could not stage install script: {e}"))?;
2476
2477 let mut cmd = if windows {
2478 let mut c = tokio::process::Command::new("powershell");
2479 c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
2480 c.arg(&script_path);
2481 c
2482 } else {
2483 let mut c = tokio::process::Command::new("sh");
2484 c.arg(&script_path);
2485 c
2486 };
2487 cmd.env("MERMAID_INSTALL_DIR", install_dir)
2488 .env("MERMAID_NO_MODIFY_PATH", "1");
2489
2490 let status = cmd
2491 .status()
2492 .await
2493 .map_err(|e| anyhow!("could not run install script: {e}"))?;
2494 let _ = std::fs::remove_file(&script_path);
2495 if !status.success() {
2496 bail!("install script exited with {:?}", status.code());
2497 }
2498 Ok(())
2499}
2500
2501fn stage_install_script(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2507 use std::io::Write;
2508 #[cfg(unix)]
2509 let mut file = {
2510 use std::os::unix::fs::OpenOptionsExt;
2511 std::fs::OpenOptions::new()
2512 .write(true)
2513 .create_new(true)
2514 .mode(0o600)
2515 .open(path)?
2516 };
2517 #[cfg(not(unix))]
2518 let mut file = std::fs::OpenOptions::new()
2519 .write(true)
2520 .create_new(true)
2521 .open(path)?;
2522 file.write_all(bytes)
2523}
2524
2525fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
2527 let core = s.trim().trim_start_matches('v');
2528 let core = core.split(['-', '+']).next().unwrap_or(core);
2529 let mut parts = core.split('.');
2530 let major = parts.next()?.parse().ok()?;
2531 let minor = parts.next().unwrap_or("0").parse().ok()?;
2532 let patch = parts.next().unwrap_or("0").parse().ok()?;
2533 Some((major, minor, patch))
2534}
2535
2536fn version_at_least(current: &str, latest: &str) -> bool {
2540 match (parse_semver(current), parse_semver(latest)) {
2541 (Some(c), Some(l)) => c >= l,
2542 _ => current == latest,
2543 }
2544}
2545
2546fn show_mcp_servers() {
2548 let config = load_config_or_warn();
2549
2550 if config.mcp_servers.is_empty() {
2551 println!("No MCP servers configured.\n");
2552 println!("Add one with: mermaid add <name>");
2553 println!("Examples:");
2554 println!(" mermaid add context7 # Library documentation");
2555 println!(" mermaid add playwright # Browser automation");
2556 println!(" mermaid add memory # Persistent knowledge graph");
2557 return;
2558 }
2559
2560 println!("Configured MCP servers:\n");
2561 for (name, server_cfg) in &config.mcp_servers {
2562 let package: &str = match &server_cfg.url {
2564 Some(url) => url,
2565 None => server_cfg
2566 .args
2567 .iter()
2568 .find(|a| !a.starts_with('-'))
2569 .map(String::as_str)
2570 .unwrap_or(server_cfg.command.as_str()),
2571 };
2572 let env_keys: Vec<&String> = server_cfg.env.keys().collect();
2573 let env_display = if env_keys.is_empty() {
2574 String::new()
2575 } else {
2576 format!(
2577 " (env: {})",
2578 env_keys
2579 .iter()
2580 .map(|k| k.as_str())
2581 .collect::<Vec<_>>()
2582 .join(", ")
2583 )
2584 };
2585 println!(" {name} — {package}{env_display}");
2586 }
2587 println!("\nManage with: mermaid add <name> / mermaid remove <name>");
2588}
2589
2590async fn show_status(config: &Config) -> Result<()> {
2592 println!("Mermaid Status:");
2593 println!();
2594
2595 let available = configured_remote_providers(config);
2600 if available.is_empty() {
2601 println!(
2602 " [WARNING] Remote providers: none (no API keys in env or keyring; `mermaid login <provider>`)"
2603 );
2604 } else {
2605 println!(" [OK] Remote providers: {} configured", available.len());
2606 for provider in &available {
2607 println!(
2608 " - {} ({}) {}",
2609 provider.name,
2610 provider.source_label(),
2611 provider.endpoint
2612 );
2613 }
2614 }
2615 let problems = crate::providers::provider_problems(config);
2619 if !problems.is_empty() {
2620 println!(
2621 " [WARNING] Providers configured but not usable: {}",
2622 problems.len()
2623 );
2624 for problem in &problems {
2625 println!(" - {}: {}", problem.name, problem.reason);
2626 }
2627 }
2628
2629 print_ollama_status(config, !available.is_empty()).await;
2630
2631 if let Ok(config_dir) = get_config_dir() {
2633 let config_path = config_dir.join("config.toml");
2634 if config_path.exists() {
2635 println!(" [OK] Configuration: {}", config_path.display());
2636 } else {
2637 println!(" [WARNING] Configuration: Not found (using defaults)");
2638 }
2639 }
2640
2641 if config.mcp_servers.is_empty() {
2643 println!(" [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
2644 } else {
2645 println!(
2646 " [OK] MCP Servers: {} configured",
2647 config.mcp_servers.len()
2648 );
2649 for (name, server_cfg) in &config.mcp_servers {
2650 let target: &str = match &server_cfg.url {
2651 Some(url) => url,
2652 None => server_cfg
2653 .args
2654 .get(1)
2655 .map(String::as_str)
2656 .unwrap_or(server_cfg.command.as_str()),
2657 };
2658 println!(" - {name} ({target})");
2659 }
2660 }
2661
2662 print_project_instructions_status();
2663
2664 println!("\n Environment:");
2666 if std::env::var("OLLAMA_API_KEY").is_ok() {
2667 println!(" - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2668 }
2669
2670 println!();
2671 Ok(())
2672}
2673
2674async fn print_ollama_status(config: &Config, has_remote: bool) {
2677 if is_ollama_installed() {
2683 let preview = |models: &[String]| {
2684 for model in models.iter().take(3) {
2685 println!(" - {model}");
2686 }
2687 if models.len() > 3 {
2688 println!(" ... and {} more", models.len() - 3);
2689 }
2690 };
2691 match observe_models(config).await {
2692 LocalModelListing::Unreachable => println!(
2693 " [WARNING] Ollama: Installed but not running (started automatically \
2694 when an Ollama model is used)"
2695 ),
2696 LocalModelListing::Live(models) if models.is_empty() => {
2697 println!(" [WARNING] Ollama: Running (no models installed)");
2698 },
2699 LocalModelListing::Live(models) => {
2700 println!(" [OK] Ollama: Running ({} models installed)", models.len());
2701 preview(&models);
2702 },
2703 LocalModelListing::FromDisk(models) => {
2704 println!(
2705 " [OK] Ollama: Not running ({} models installed on disk; starts \
2706 automatically when used)",
2707 models.len()
2708 );
2709 preview(&models);
2710 },
2711 }
2712 } else if !has_remote {
2713 println!(" [WARNING] Ollama: Not installed (and no remote provider configured)");
2714 } else {
2715 println!(" [INFO] Ollama: Not installed (only needed for local models)");
2718 }
2719}
2720
2721fn print_project_instructions_status() {
2724 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2725 let paths = crate::app::instructions::find_instruction_files(&cwd);
2726 if paths.is_empty() {
2727 println!(" [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
2728 } else {
2729 match crate::app::instructions::load_from_paths(&paths) {
2730 Some(loaded) => {
2731 let files = loaded
2732 .sources
2733 .iter()
2734 .map(|source| {
2735 source
2736 .path
2737 .file_name()
2738 .and_then(|name| name.to_str())
2739 .unwrap_or("instructions")
2740 })
2741 .collect::<Vec<_>>()
2742 .join(", ");
2743 println!(
2744 " [OK] Project instructions: {} at {} ({} bytes{})",
2745 files,
2746 loaded.path.display(),
2747 loaded.byte_len,
2748 if loaded.truncated { ", truncated" } else { "" }
2749 );
2750 },
2751 None => {
2752 println!(
2753 " [WARNING] Project instructions: found but unreadable ({})",
2754 paths
2755 .iter()
2756 .map(|path| path.display().to_string())
2757 .collect::<Vec<_>>()
2758 .join(", ")
2759 );
2760 },
2761 }
2762 }
2763}
2764
2765#[cfg(test)]
2766mod tests {
2767 use super::*;
2768
2769 #[test]
2770 fn session_log_drift_reports_agreement_and_catches_a_diverged_checkpoint() {
2771 let root = unique_temp_dir("mermaid-doctor-drift");
2772 let _ = std::fs::remove_dir_all(&root);
2773 std::fs::create_dir_all(&root).expect("project dir");
2774
2775 let empty = session_log_drift(&root);
2777 assert_eq!(empty.status, "ok", "{}", empty.message);
2778
2779 let manager = ConversationManager::new(&root).expect("manager");
2781 let mut state = mermaid_domain::State::new(
2782 Config::default(),
2783 root.clone(),
2784 "ollama/test".to_string(),
2785 chrono::Local::now(),
2786 std::env::temp_dir(),
2787 );
2788 state
2789 .session
2790 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
2791 let snapshot = state.session.snapshot_conversation();
2792 let events = state.session.drain_events(&snapshot);
2793 manager
2794 .append_session_events(&snapshot, &events)
2795 .expect("append");
2796 manager.save_conversation(&snapshot).expect("checkpoint");
2797
2798 let agreed = session_log_drift(&root);
2799 assert_eq!(agreed.status, "ok", "{}", agreed.message);
2800 assert!(agreed.message.contains('1'), "{}", agreed.message);
2801
2802 let path = manager
2805 .conversations_dir()
2806 .join(format!("{}.json", snapshot.id));
2807 let mut value: serde_json::Value =
2808 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2809 let planted = serde_json::to_value(mermaid_model::models::ChatMessage::user(
2810 "never reached the log",
2811 ))
2812 .unwrap();
2813 value
2814 .get_mut("messages")
2815 .and_then(serde_json::Value::as_array_mut)
2816 .expect("messages array")
2817 .push(planted);
2818 std::fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
2819
2820 let drifted = session_log_drift(&root);
2821 assert_eq!(drifted.status, "warning", "{}", drifted.message);
2822 assert!(
2823 drifted.message.contains(&snapshot.id),
2824 "the warning must name the session: {}",
2825 drifted.message
2826 );
2827 let _ = std::fs::remove_dir_all(&root);
2828 }
2829
2830 #[test]
2831 fn doctor_uses_resolved_keyless_web_capabilities() {
2832 let config = Config {
2833 web: mermaid_domain::WebConfig {
2834 fetch_backend: mermaid_domain::FetchBackend::Native,
2835 search_backend: mermaid_domain::SearchBackend::Searxng,
2836 searxng_url: "http://127.0.0.1:8080".to_string(),
2837 ..mermaid_domain::WebConfig::default()
2838 },
2839 ..Config::default()
2840 };
2841 let (tools, next_steps) = web_doctor_entries(&config);
2842 assert!(
2843 tools
2844 .iter()
2845 .any(|entry| entry.contains("web_fetch (native"))
2846 );
2847 assert!(
2848 tools
2849 .iter()
2850 .any(|entry| entry.contains("web_search (searxng"))
2851 );
2852 assert!(next_steps.is_empty(), "unexpected warnings: {next_steps:?}");
2853 assert!(
2854 tools.iter().all(|entry| !entry.contains("container")),
2855 "doctor must describe the selected capability, not stale container setup"
2856 );
2857 }
2858
2859 #[test]
2860 fn doctor_reports_global_network_deny_instead_of_advertising_web() {
2861 let mut config = Config::default();
2862 config.web.search_backend = mermaid_domain::SearchBackend::Searxng;
2863 config.web.searxng_url = "http://127.0.0.1:8080".to_string();
2864 config.safety.network = mermaid_domain::NetworkPolicy::Deny;
2865
2866 let (tools, next_steps) = web_doctor_entries(&config);
2867 assert!(
2868 tools
2869 .iter()
2870 .all(|entry| !entry.starts_with("web_fetch") && !entry.starts_with("web_search")),
2871 "network-denied tools were advertised: {tools:?}"
2872 );
2873 for name in ["web_fetch", "web_search"] {
2874 assert!(
2875 next_steps.iter().any(|entry| {
2876 entry.contains(name) && entry.contains("safety.network = \"deny\"")
2877 }),
2878 "missing network-deny explanation for {name}: {next_steps:?}"
2879 );
2880 }
2881 }
2882
2883 #[test]
2884 fn sanitize_terminal_text_strips_control_sequences() {
2885 assert_eq!(
2887 sanitize_terminal_text("hello\tworld\nline two"),
2888 "hello\tworld\nline two"
2889 );
2890 assert_eq!(
2892 sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
2893 "RED text"
2894 );
2895 assert_eq!(
2897 sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
2898 "beforeafter"
2899 );
2900 assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
2902 assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
2904 assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
2906 }
2907
2908 #[test]
2909 fn version_compare_handles_update_logic() {
2910 assert!(version_at_least("0.10.2", "0.10.2"));
2912 assert!(version_at_least("0.11.0", "0.10.2"));
2913 assert!(version_at_least("1.0.0", "0.99.99"));
2914 assert!(!version_at_least("0.10.1", "0.10.2"));
2916 assert!(!version_at_least("0.9.0", "0.10.0"));
2917 assert!(!version_at_least("0.10.2", "0.11.0"));
2918 assert!(version_at_least("0.10.2", "v0.10.2"));
2920 assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2921 assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2922 assert!(!version_at_least("0.10.2", "not-a-version"));
2924 }
2925
2926 #[test]
2927 fn qa_compact_smoke_persists_conversation_and_archive() {
2928 let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2929 std::fs::create_dir_all(&dir).unwrap();
2930
2931 let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2932
2933 assert!(report.ok);
2934 assert!(report.archived_messages > 0);
2935 assert!(report.preserved_messages > 0);
2936 assert!(report.replacement_messages >= 3);
2937 assert!(
2938 std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2939 "conversation path should exist"
2940 );
2941 assert!(
2942 std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2943 "archive path should exist"
2944 );
2945
2946 let _ = std::fs::remove_dir_all(dir);
2947 }
2948
2949 #[test]
2950 fn qa_model_id_falls_back_to_deterministic() {
2951 assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2952 }
2953
2954 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2955 let nanos = std::time::SystemTime::now()
2956 .duration_since(std::time::UNIX_EPOCH)
2957 .map(|duration| duration.as_nanos())
2958 .unwrap_or_default();
2959 std::env::temp_dir().join(format!("{name}-{nanos}"))
2960 }
2961}