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