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