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