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