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
272async fn show_doctor(
273 config: &Config,
274 cwd: &Path,
275 cli_model: Option<&str>,
276 format: OutputFormat,
277) -> Result<()> {
278 let report = build_doctor_report(config, cwd, cli_model).await;
279 print_doctor_report(&report, format)
280}
281
282pub(crate) async fn build_doctor_report(
285 config: &Config,
286 cwd: &Path,
287 cli_model: Option<&str>,
288) -> DoctorReport {
289 let active_model_result = crate::app::resolve_model_id(cli_model, config).await;
290 let (active_model, model_error, model_capabilities) = match active_model_result {
291 Ok(model) => {
292 let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(&model);
293 (
294 Some(model),
295 None,
296 Some(DoctorModelCapabilities {
297 provider: snapshot.provider,
298 name: snapshot.model,
299 supports_tools: snapshot.supports_tools,
300 supports_vision: snapshot.supports_vision,
301 reasoning: snapshot.reasoning,
302 max_context_tokens: snapshot.max_context_tokens,
303 }),
304 )
305 },
306 Err(err) => (None, Some(err.to_string()), None),
307 };
308
309 let ollama_models = if is_ollama_installed() {
314 list_ollama_models(config).await
315 } else {
316 None
317 };
318 let ollama = if !is_ollama_installed() {
319 DoctorCheck {
320 status: "warning",
321 message: "Ollama is not installed; remote providers can still work if configured."
322 .to_string(),
323 }
324 } else {
325 match &ollama_models {
326 None => DoctorCheck {
327 status: "warning",
328 message: "Ollama is installed but not running; mermaid starts it \
329 automatically when an Ollama model is used."
330 .to_string(),
331 },
332 Some(models) if models.is_empty() => DoctorCheck {
333 status: "warning",
334 message: "Ollama is running but no local/cloud models were listed.".to_string(),
335 },
336 Some(models) => DoctorCheck {
337 status: "ok",
338 message: format!("Ollama reachable with {} models.", models.len()),
339 },
340 }
341 };
342
343 let remote_providers = configured_remote_providers(config);
344 let instruction_paths = crate::app::instructions::find_instruction_files(cwd);
345 let project_instructions = if instruction_paths.is_empty() {
346 DoctorCheck {
347 status: "info",
348 message: "No AGENTS.md or MERMAID.md found.".to_string(),
349 }
350 } else if let Some(loaded) = crate::app::instructions::load_from_paths(&instruction_paths) {
351 DoctorCheck {
352 status: "ok",
353 message: format!(
354 "{} bytes loaded from {} source(s){}.",
355 loaded.byte_len,
356 loaded.sources.len(),
357 if loaded.truncated { " (truncated)" } else { "" }
358 ),
359 }
360 } else {
361 DoctorCheck {
362 status: "warning",
363 message: "Instruction files were found but could not be loaded.".to_string(),
364 }
365 };
366
367 let daemon = match RuntimeClient::daemon().health() {
368 Ok(read) => DoctorCheck {
369 status: "ok",
370 message: format!("daemon attached; database {}", read.value.database),
371 },
372 Err(err) => DoctorCheck {
373 status: "info",
374 message: format!("daemon not attached; CLI will use local runtime store ({err})"),
375 },
376 };
377 let local_store = match RuntimeClient::local().health() {
378 Ok(read) => DoctorCheck {
379 status: "ok",
380 message: format!("local runtime store ready at {}", read.value.database),
381 },
382 Err(err) => DoctorCheck {
383 status: "warning",
384 message: format!("local runtime store unavailable: {err}"),
385 },
386 };
387
388 let has_ollama_key = std::env::var("OLLAMA_API_KEY").is_ok();
389 let mut tools = vec![
390 "read/edit/write files".to_string(),
391 "run shell commands".to_string(),
392 "create checkpoints before risky mutations".to_string(),
393 ];
394 match config.web.fetch_backend {
396 crate::app::FetchBackend::Native => tools.push("web_fetch (native, no key)".to_string()),
397 crate::app::FetchBackend::Ollama if has_ollama_key => {
398 tools.push("web_fetch (Ollama Cloud)".to_string())
399 },
400 crate::app::FetchBackend::Ollama => {},
401 }
402 match config.web.search_backend {
405 crate::app::SearchBackend::Auto if has_ollama_key => {
406 tools.push("web_search (auto: Ollama Cloud)".to_string())
407 },
408 crate::app::SearchBackend::Auto => {
409 tools.push("web_search (auto: managed local SearXNG)".to_string())
410 },
411 crate::app::SearchBackend::Searxng => {
412 tools.push(format!("web_search (SearXNG {})", config.web.searxng_url))
413 },
414 crate::app::SearchBackend::Ollama if has_ollama_key => {
415 tools.push("web_search (Ollama Cloud)".to_string())
416 },
417 crate::app::SearchBackend::Ollama => {},
418 }
419 if !config.mcp_servers.is_empty() {
420 tools.push(format!(
421 "{} configured MCP server(s)",
422 config.mcp_servers.len()
423 ));
424 }
425 if let Some(skills) = crate::app::skills::load(cwd) {
426 tools.push(format!(
427 "{} skill(s) discovered (SKILL.md playbooks)",
428 skills.entries.len()
429 ));
430 }
431
432 let mut next_steps = Vec::new();
433 if active_model.is_none() {
434 next_steps.push(
435 "Pick a model with `mermaid --model <provider/model>` or run `mermaid list`."
436 .to_string(),
437 );
438 }
439 if remote_providers.is_empty() && ollama_models.as_deref().unwrap_or_default().is_empty() {
440 next_steps.push(
441 "Install or start Ollama, pull a model, or set a remote provider API key.".to_string(),
442 );
443 }
444 if instruction_paths.is_empty() {
445 next_steps.push("Optional: add MERMAID.md or AGENTS.md with project-specific run commands and conventions.".to_string());
446 }
447 if matches!(config.web.search_backend, crate::app::SearchBackend::Ollama) && !has_ollama_key {
448 next_steps.push(
449 "Web search needs a backend: set OLLAMA_API_KEY (run `mermaid cloud-setup`) or set `[web] search_backend = \"searxng\"` with a running SearXNG.".to_string(),
450 );
451 }
452 if matches!(config.web.search_backend, crate::app::SearchBackend::Auto)
453 && !has_ollama_key
454 && which::which("podman").is_err()
455 && which::which("docker").is_err()
456 {
457 next_steps.push(
458 "Web search (auto) starts a local SearXNG container, but no podman or docker was found. Install one, or set OLLAMA_API_KEY.".to_string(),
459 );
460 }
461 if next_steps.is_empty() {
462 next_steps.push(
463 "Start Mermaid with `mermaid` or run one prompt with `mermaid run \"...\"`."
464 .to_string(),
465 );
466 }
467
468 let ok = active_model.is_some()
469 && local_store.status != "warning"
470 && (ollama.status == "ok" || !remote_providers.is_empty());
471 DoctorReport {
472 ok,
473 cwd: cwd.display().to_string(),
474 active_profile: config.active_profile.clone(),
475 active_model,
476 model_error,
477 model_capabilities,
478 safety_mode: safety_mode_name(config.safety.mode).to_string(),
479 checkpoint_on_mutation: config.safety.checkpoint_on_mutation,
480 prompt_customized: config.prompt.is_customized(),
481 ollama,
482 remote_providers,
483 project_instructions,
484 tools,
485 runtime: DoctorRuntime {
486 daemon,
487 local_store,
488 },
489 next_steps,
490 }
491}
492
493fn print_doctor_report(report: &DoctorReport, format: OutputFormat) -> Result<()> {
494 match format {
495 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
496 OutputFormat::Ndjson => println!("{}", serde_json::to_string(report)?),
497 OutputFormat::Markdown => {
498 println!("# Mermaid Doctor\n");
499 print_doctor_text(report);
500 },
501 OutputFormat::Text => print_doctor_text(report),
502 }
503 Ok(())
504}
505
506fn print_doctor_text(report: &DoctorReport) {
507 println!(
508 "Mermaid Doctor: {}",
509 if report.ok {
510 "ready"
511 } else {
512 "needs attention"
513 }
514 );
515 println!("Project: {}", report.cwd);
516 match (&report.active_model, &report.model_error) {
517 (Some(model), _) => println!(" [OK] Active model: {model}"),
518 (None, Some(error)) => println!(" [WARNING] Active model: {error}"),
519 _ => println!(" [WARNING] Active model: unresolved"),
520 }
521 if let Some(caps) = &report.model_capabilities {
522 println!(
523 " provider={} tools={} vision={} reasoning={} context={}",
524 caps.provider,
525 caps.supports_tools,
526 caps.supports_vision,
527 caps.reasoning,
528 caps.max_context_tokens
529 .map(|n| n.to_string())
530 .unwrap_or_else(|| "unknown".to_string())
531 );
532 }
533 println!(
534 " [{}] Ollama: {}",
535 label(report.ollama.status),
536 report.ollama.message
537 );
538 println!(
539 " [INFO] Remote providers: {}",
540 if report.remote_providers.is_empty() {
541 "none configured".to_string()
542 } else {
543 report.remote_providers.join(", ")
544 }
545 );
546 println!(
547 " [{}] Project instructions: {}",
548 label(report.project_instructions.status),
549 report.project_instructions.message
550 );
551 println!(
552 " [INFO] Safety: mode={}, checkpoint_on_mutation={}",
553 report.safety_mode, report.checkpoint_on_mutation
554 );
555 if let Some(profile) = &report.active_profile {
556 println!(" [INFO] Config profile: {}", profile);
557 }
558 println!(
559 " [INFO] Prompt customization: {}",
560 if report.prompt_customized {
561 "active"
562 } else {
563 "default"
564 }
565 );
566 println!(
567 " [{}] Runtime daemon: {}",
568 label(report.runtime.daemon.status),
569 report.runtime.daemon.message
570 );
571 println!(
572 " [{}] Runtime store: {}",
573 label(report.runtime.local_store.status),
574 report.runtime.local_store.message
575 );
576 println!(" [OK] Tool surface:");
577 for tool in &report.tools {
578 println!(" - {tool}");
579 }
580 println!("\nNext steps:");
581 for step in &report.next_steps {
582 println!(" - {step}");
583 }
584}
585
586#[derive(Debug, serde::Serialize)]
587struct SelfTestReport {
588 ok: bool,
589 workspace: String,
590 checks: Vec<String>,
591 compact_smoke: QaCompactSmokeReport,
592 runtime_store: DoctorCheck,
593 kept_workspace: bool,
594}
595
596fn run_self_test(config: &Config, format: OutputFormat, keep_workspace: bool) -> Result<()> {
597 let workspace = std::env::temp_dir().join(format!("mermaid-self-test-{}", fresh_qa_id()));
598 std::fs::create_dir_all(&workspace)
599 .with_context(|| format!("failed to create {}", workspace.display()))?;
600
601 let compact_smoke = match run_qa_compact_smoke(config, &workspace, 6) {
602 Ok(report) => report,
603 Err(err) => QaCompactSmokeReport::failed(&workspace, 6, err.to_string()),
604 };
605 let runtime_store = match RuntimeClient::local().health() {
606 Ok(read) => DoctorCheck {
607 status: "ok",
608 message: format!("local runtime store ready at {}", read.value.database),
609 },
610 Err(err) => DoctorCheck {
611 status: "warning",
612 message: err.to_string(),
613 },
614 };
615
616 let sandbox_available = crate::runtime::network_killswitch_available();
620 let fs_sandbox_available = crate::runtime::fs_confinement_available();
621 let (network_check, fs_check) = if cfg!(target_os = "linux") {
622 (
623 "network kill-switch (seccomp) builds on this platform",
624 "filesystem confinement (Landlock) ruleset builds on this platform",
625 )
626 } else if cfg!(target_os = "macos") {
627 (
628 "network sandbox (Seatbelt via sandbox-exec) available on this platform",
629 "filesystem confinement (Seatbelt via sandbox-exec) available on this platform",
630 )
631 } else {
632 (
633 "network sandbox backend available on this platform",
634 "filesystem confinement backend available on this platform",
635 )
636 };
637 let checks = vec![
638 "compact smoke exercises reducer compaction path".to_string(),
639 "compact smoke persists conversation and archive artifacts".to_string(),
640 "local runtime store opens without daemon".to_string(),
641 format!(
642 "{network_check}: {}",
643 if sandbox_available { "yes" } else { "no" }
644 ),
645 format!(
646 "{fs_check}: {}",
647 if fs_sandbox_available { "yes" } else { "no" }
648 ),
649 ];
650 let sandbox_expected = cfg!(any(target_os = "linux", target_os = "macos"));
654 let ok = compact_smoke.ok
655 && runtime_store.status == "ok"
656 && (!sandbox_expected || (sandbox_available && fs_sandbox_available));
657 let report = SelfTestReport {
658 ok,
659 workspace: workspace.display().to_string(),
660 checks,
661 compact_smoke,
662 runtime_store,
663 kept_workspace: keep_workspace,
664 };
665
666 print_self_test_report(&report, format)?;
667 if !keep_workspace {
668 let _ = std::fs::remove_dir_all(&workspace);
669 }
670 anyhow::ensure!(report.ok, "mermaid self-test failed");
671 Ok(())
672}
673
674fn print_self_test_report(report: &SelfTestReport, format: OutputFormat) -> Result<()> {
675 match format {
676 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
677 OutputFormat::Ndjson => println!("{}", serde_json::to_string(report)?),
678 OutputFormat::Markdown => {
679 println!("# Mermaid Self-Test\n");
680 print_self_test_text(report);
681 },
682 OutputFormat::Text => print_self_test_text(report),
683 }
684 Ok(())
685}
686
687fn print_self_test_text(report: &SelfTestReport) {
688 println!(
689 "Mermaid self-test: {}",
690 if report.ok { "ok" } else { "failed" }
691 );
692 println!("workspace: {}", report.workspace);
693 println!(
694 "compact smoke: {}",
695 if report.compact_smoke.ok {
696 "ok"
697 } else {
698 "failed"
699 }
700 );
701 println!("runtime store: {}", report.runtime_store.message);
702 println!("checks:");
703 for check in &report.checks {
704 println!(" - {check}");
705 }
706 if !report.ok
707 && let Some(failure) = &report.compact_smoke.failure
708 {
709 println!("failure: {failure}");
710 }
711}
712
713fn label(status: &str) -> &'static str {
714 match status {
715 "ok" => "OK",
716 "warning" => "WARNING",
717 "error" => "ERROR",
718 _ => "INFO",
719 }
720}
721
722fn safety_mode_name(mode: crate::runtime::SafetyMode) -> &'static str {
723 mode.as_str()
724}
725
726fn meta_api_key_env(config: &Config) -> &str {
727 config
728 .providers
729 .get("meta")
730 .and_then(|provider| provider.api_key_env.as_deref())
731 .unwrap_or(crate::providers::model::meta::DEFAULT_API_KEY_ENV)
732}
733
734fn login_providers(config: &Config) -> Vec<(String, String, Option<String>)> {
738 let over = |name: &str| {
739 config
740 .providers
741 .get(name)
742 .and_then(|c| c.api_key_env.clone())
743 };
744 let mut rows: Vec<(String, String, Option<String>)> = vec![
745 (
746 "anthropic".to_string(),
747 "ANTHROPIC_API_KEY".to_string(),
748 over("anthropic"),
749 ),
750 (
751 "gemini".to_string(),
752 "GOOGLE_API_KEY".to_string(),
753 over("gemini"),
754 ),
755 (
756 "meta".to_string(),
757 crate::providers::model::meta::DEFAULT_API_KEY_ENV.to_string(),
758 over("meta"),
759 ),
760 (
761 "ollama".to_string(),
762 "OLLAMA_API_KEY".to_string(),
763 over("ollama"),
764 ),
765 ];
766 for profile in PROVIDER_REGISTRY {
767 rows.push((
768 profile.name.to_string(),
769 profile.api_key_env.to_string(),
770 over(profile.name),
771 ));
772 }
773 for (name, cfg) in &config.providers {
774 if rows.iter().any(|(n, _, _)| n == name) {
775 continue;
776 }
777 if let Some(env) = &cfg.api_key_env {
779 rows.push((name.clone(), env.clone(), None));
780 }
781 }
782 rows.sort_by(|a, b| a.0.cmp(&b.0));
783 rows
784}
785
786fn login(provider: Option<&str>, config: &Config) -> Result<()> {
790 let rows = login_providers(config);
791 let Some(provider) = provider else {
792 println!(
793 "Provider API-key status (env beats keyring; `mermaid login <provider>` stores a key):\n"
794 );
795 for (name, default_env, override_env) in &rows {
796 let source =
797 crate::utils::provider_key_source(name, default_env, override_env.as_deref());
798 let env_name = override_env.as_deref().unwrap_or(default_env);
799 println!(" {:<14} {:<8} (${})", name, source, env_name);
800 }
801 return Ok(());
802 };
803 let provider = provider.to_lowercase();
804 let Some((name, default_env, override_env)) = rows.into_iter().find(|(n, _, _)| n == &provider)
805 else {
806 let names: Vec<String> = login_providers(config)
807 .into_iter()
808 .map(|(n, _, _)| n)
809 .collect();
810 anyhow::bail!(
811 "unknown provider '{}'; known: {}",
812 provider,
813 names.join(", ")
814 );
815 };
816 let key = rpassword::prompt_password(format!("API key for {} (input hidden): ", name))
817 .context("read API key")?;
818 let key = key.trim();
819 anyhow::ensure!(!key.is_empty(), "no key entered; nothing stored");
820 let store = crate::utils::default_store();
821 store
822 .set(&name, key)
823 .with_context(|| format!("store key for {}", name))?;
824 println!(
825 "Stored key for {} in {} (service \"mermaid\").",
826 name,
827 store.label()
828 );
829 if crate::utils::resolve_api_key(&default_env, override_env.as_deref()).is_some() {
831 let env_name = override_env.as_deref().unwrap_or(&default_env);
832 println!(
833 "Note: ${} is currently set and takes precedence over the stored key.",
834 env_name
835 );
836 }
837 Ok(())
838}
839
840fn logout(provider: &str, config: &Config) -> Result<()> {
843 let provider = provider.to_lowercase();
844 let _ = config;
847 let store = crate::utils::default_store();
848 if store
849 .delete(&provider)
850 .with_context(|| format!("delete key for {}", provider))?
851 {
852 println!(
853 "Removed stored key for {} from {}.",
854 provider,
855 store.label()
856 );
857 } else {
858 println!("No stored key for {}.", provider);
859 }
860 Ok(())
861}
862
863fn meta_api_key(config: &Config) -> Option<String> {
864 crate::utils::resolve_provider_key(
865 "meta",
866 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
867 config
868 .providers
869 .get("meta")
870 .and_then(|provider| provider.api_key_env.as_deref()),
871 )
872}
873
874fn meta_base_url(config: &Config) -> String {
875 config
876 .providers
877 .get("meta")
878 .and_then(|provider| provider.base_url.clone())
879 .unwrap_or_else(|| crate::providers::model::meta::DEFAULT_BASE_URL.to_string())
880}
881
882fn configured_remote_providers(config: &Config) -> Vec<String> {
883 let mut names = Vec::new();
884 if meta_api_key(config).is_some() {
885 names.push("meta".to_string());
886 }
887 for profile in PROVIDER_REGISTRY {
888 let override_env = config
889 .providers
890 .get(profile.name)
891 .and_then(|c| c.api_key_env.as_deref());
892 if crate::utils::resolve_provider_key(profile.name, profile.api_key_env, override_env)
893 .is_some()
894 {
895 names.push(profile.name.to_string());
896 }
897 }
898 for (name, provider) in &config.providers {
899 if names.iter().any(|existing| existing == name) {
900 continue;
901 }
902 if let Some(env) = provider.api_key_env.as_deref()
905 && crate::utils::resolve_provider_key(name, env, None).is_some()
906 {
907 names.push(name.clone());
908 }
909 }
910 names.sort();
911 names
912}
913
914#[derive(Debug, serde::Serialize)]
915struct QaCompactSmokeReport {
916 ok: bool,
917 turns: usize,
918 archived_messages: usize,
919 preserved_messages: usize,
920 replacement_messages: usize,
921 conversation_path: Option<String>,
922 archive_path: Option<String>,
923 checks: Vec<String>,
924 failure: Option<String>,
925}
926
927impl QaCompactSmokeReport {
928 fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
929 Self {
930 ok: false,
931 turns,
932 archived_messages: 0,
933 preserved_messages: 0,
934 replacement_messages: 0,
935 conversation_path: Some(
936 cwd.join(".mermaid")
937 .join("conversations")
938 .display()
939 .to_string(),
940 ),
941 archive_path: None,
942 checks: Vec::new(),
943 failure: Some(failure),
944 }
945 }
946}
947
948fn run_qa_compact_smoke(
949 config: &Config,
950 cwd: &Path,
951 requested_turns: usize,
952) -> Result<QaCompactSmokeReport> {
953 let turns = requested_turns.max(3);
954 let mut state = State::new(
955 config.clone(),
956 cwd.to_path_buf(),
957 qa_model_id(config),
958 chrono::Local::now(),
959 );
960 for message in synthetic_compaction_messages(turns) {
961 state.session.append(message, state.now);
962 }
963
964 let (state_after_slash, compact_cmds) = update(
965 state,
966 Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
967 );
968 let turn = state_after_slash
969 .turn
970 .id()
971 .context("manual compaction did not enter a compaction turn")?;
972 let request = compact_cmds
973 .iter()
974 .find_map(|cmd| match cmd {
975 Cmd::CompactConversation { request, .. } => Some(request.clone()),
976 _ => None,
977 })
978 .context("manual compaction did not emit a CompactConversation command")?;
979
980 let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
981 let prepared = prepare_compaction(&request, Some(100_000))
982 .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
983 anyhow::ensure!(
984 !prepared.archived_messages.is_empty(),
985 "compaction archived no messages"
986 );
987 anyhow::ensure!(
988 !prepared.preserved_messages.is_empty(),
989 "compaction preserved no messages"
990 );
991
992 let summary = deterministic_compaction_summary(&prepared, turns);
993 let mut record = CompactionRecord {
994 id: format!("qa_compact_{}", fresh_qa_id()),
995 trigger: CompactionTrigger::Manual,
996 created_at: chrono::Local::now(),
997 before_tokens: before_snapshot.used_tokens,
998 after_tokens: 0,
999 archived_message_count: prepared.archived_messages.len(),
1000 preserved_message_count: prepared.preserved_messages.len(),
1001 preserved_turn_count: prepared
1002 .preserved_messages
1003 .iter()
1004 .filter(|message| message.role == crate::models::MessageRole::User)
1005 .count(),
1006 summary_tokens: summary.len().div_ceil(4),
1007 duration_secs: 0.0,
1008 review_status: crate::domain::CompactionReviewStatus::DraftValidated,
1009 review_error: None,
1010 focus: Some("qa compact smoke".to_string()),
1011 archive_path: None,
1012 };
1013 let mut replacement = build_replacement_messages(&summary, &prepared, &record);
1014 let mut after_chat: ChatRequest = request.chat.clone();
1015 after_chat.messages = replacement.clone();
1016 let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1017 record.after_tokens = after_snapshot.used_tokens;
1018 replacement = build_replacement_messages(&summary, &prepared, &record);
1019 after_chat.messages = replacement.clone();
1020 after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1021
1022 let result = CompactionResult {
1023 record,
1024 replacement_messages: replacement,
1025 archived_messages: prepared.archived_messages,
1026 before_snapshot,
1027 after_snapshot,
1028 usage: None,
1029 source_boundaries: Vec::new(),
1030 };
1031 let (final_state, save_cmds) =
1032 update(state_after_slash, Msg::CompactionFinished { turn, result });
1033
1034 let manager = ConversationManager::new(cwd)?;
1035 let mut conversation_path = None;
1036 let mut archive_path = None;
1037 for cmd in save_cmds {
1038 match cmd {
1039 Cmd::SaveConversation(conversation) => {
1040 manager.save_conversation(&conversation)?;
1041 conversation_path = Some(
1042 manager
1043 .conversations_dir()
1044 .join(format!("{}.json", conversation.id))
1045 .display()
1046 .to_string(),
1047 );
1048 },
1049 Cmd::SaveCompactionArchive {
1050 archive,
1051 conversation,
1052 ..
1053 } => {
1054 archive_path = Some(
1058 manager
1059 .save_compaction_archive(&archive)?
1060 .display()
1061 .to_string(),
1062 );
1063 manager.save_conversation(&conversation)?;
1064 conversation_path = Some(
1065 manager
1066 .conversations_dir()
1067 .join(format!("{}.json", conversation.id))
1068 .display()
1069 .to_string(),
1070 );
1071 },
1072 _ => {},
1073 }
1074 }
1075
1076 let conversation_path = conversation_path.context("compaction did not save conversation")?;
1077 let archive_path = archive_path.context("compaction did not save archive")?;
1078 let messages = final_state.session.messages();
1079 let compactions = &final_state.session.conversation.compactions;
1080
1081 let mut checks = Vec::new();
1082 anyhow::ensure!(
1083 !compactions.is_empty(),
1084 "conversation did not record compaction metadata"
1085 );
1086 checks.push("conversation records compaction metadata".to_string());
1087 anyhow::ensure!(
1088 messages
1089 .first()
1090 .is_some_and(|msg| msg.kind == crate::models::ChatMessageKind::ContextCheckpoint),
1091 "replacement does not start with a context checkpoint"
1092 );
1093 checks.push("replacement starts with context checkpoint".to_string());
1094 anyhow::ensure!(
1095 std::path::Path::new(&conversation_path).exists(),
1096 "conversation file missing after save"
1097 );
1098 checks.push("conversation file saved".to_string());
1099 anyhow::ensure!(
1100 std::path::Path::new(&archive_path).exists(),
1101 "compaction archive file missing after save"
1102 );
1103 checks.push("archive file saved".to_string());
1104 anyhow::ensure!(
1105 compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
1106 "compaction did not archive and preserve messages"
1107 );
1108 checks.push("archived and preserved message counts are non-zero".to_string());
1109
1110 Ok(QaCompactSmokeReport {
1111 ok: true,
1112 turns,
1113 archived_messages: compactions[0].archived_message_count,
1114 preserved_messages: compactions[0].preserved_message_count,
1115 replacement_messages: messages.len(),
1116 conversation_path: Some(conversation_path),
1117 archive_path: Some(archive_path),
1118 checks,
1119 failure: None,
1120 })
1121}
1122
1123fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
1124 match format {
1125 OutputFormat::Json => {
1126 println!("{}", serde_json::to_string_pretty(report)?);
1127 },
1128 OutputFormat::Ndjson => {
1129 println!("{}", serde_json::to_string(report)?);
1130 },
1131 OutputFormat::Text => {
1132 println!(
1133 "qa compact smoke: {}",
1134 if report.ok { "ok" } else { "failed" }
1135 );
1136 println!("turns: {}", report.turns);
1137 println!("archived messages: {}", report.archived_messages);
1138 println!("preserved messages: {}", report.preserved_messages);
1139 println!("replacement messages: {}", report.replacement_messages);
1140 if let Some(path) = &report.conversation_path {
1141 println!("conversation: {path}");
1142 }
1143 if let Some(path) = &report.archive_path {
1144 println!("archive: {path}");
1145 }
1146 if let Some(failure) = &report.failure {
1147 println!("failure: {failure}");
1148 }
1149 },
1150 OutputFormat::Markdown => {
1151 println!(
1152 "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
1153 if report.ok { "ok" } else { "failed" },
1154 report.turns,
1155 report.archived_messages,
1156 report.preserved_messages,
1157 report.replacement_messages
1158 );
1159 if let Some(path) = &report.conversation_path {
1160 println!("- Conversation: `{path}`");
1161 }
1162 if let Some(path) = &report.archive_path {
1163 println!("- Archive: `{path}`");
1164 }
1165 if let Some(failure) = &report.failure {
1166 println!("\nFailure: `{failure}`");
1167 }
1168 },
1169 }
1170 Ok(())
1171}
1172
1173fn qa_model_id(config: &Config) -> String {
1174 if let Some(model) = config
1175 .last_used_model
1176 .as_ref()
1177 .filter(|value| !value.is_empty())
1178 {
1179 return model.clone();
1180 }
1181 if !config.default_model.name.is_empty() {
1182 if config.default_model.provider.is_empty() {
1183 return config.default_model.name.clone();
1184 }
1185 return format!(
1186 "{}/{}",
1187 config.default_model.provider, config.default_model.name
1188 );
1189 }
1190 "qa/deterministic".to_string()
1191}
1192
1193fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
1194 let mut messages = Vec::with_capacity(turns.saturating_mul(2));
1195 for idx in 1..=turns {
1196 messages.push(ChatMessage::user(format!(
1197 "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
1198 )));
1199 messages.push(ChatMessage::assistant(format!(
1200 "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}."
1201 )));
1202 }
1203 messages
1204}
1205
1206fn deterministic_compaction_summary(
1207 prepared: &crate::domain::PreparedCompaction,
1208 turns: usize,
1209) -> String {
1210 format!(
1211 "## 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.",
1212 prepared.archived_messages.len(),
1213 prepared.preserved_messages.len()
1214 )
1215}
1216
1217fn fresh_qa_id() -> u128 {
1218 std::time::SystemTime::now()
1219 .duration_since(std::time::UNIX_EPOCH)
1220 .map(|duration| duration.as_nanos())
1221 .unwrap_or_default()
1222}
1223
1224fn show_tasks(limit: usize) -> Result<()> {
1225 let read = RuntimeClient::auto().list_tasks(limit)?;
1226 let mut tasks = read.value;
1227 tasks.truncate(limit);
1228 println!("Mermaid runtime tasks");
1229 println!("Source: {}", read.source.as_str());
1230 println!();
1231 if tasks.is_empty() {
1232 println!("No tasks recorded yet.");
1233 return Ok(());
1234 }
1235 for task in tasks {
1236 println!(
1237 "{} [{}] {} {} {}",
1238 task.id, task.status, task.priority, task.updated_at, task.title
1239 );
1240 println!(" project: {}", task.project_path);
1241 println!(" model: {}", task.model_id);
1242 }
1243 Ok(())
1244}
1245
1246fn show_task(id: &str) -> Result<()> {
1247 let detail = RuntimeClient::auto().task_detail(id)?.value;
1248 print_task_detail(&detail.task);
1249 let events = detail.events;
1250 if !events.is_empty() {
1251 println!();
1252 println!("Timeline:");
1253 for event in events {
1254 println!(" {} {} {}", event.created_at, event.kind, event.message);
1255 }
1256 }
1257 Ok(())
1258}
1259
1260fn print_task_detail(task: &TaskRecord) {
1261 println!("Task: {}", task.id);
1262 println!("Title: {}", task.title);
1263 println!("Status: {}", task.status);
1264 println!("Priority: {}", task.priority);
1265 println!("Project: {}", task.project_path);
1266 println!("Model: {}", task.model_id);
1267 if let Some(conversation_id) = &task.conversation_id {
1268 println!("Conversation: {}", conversation_id);
1269 }
1270 println!("Created: {}", task.created_at);
1271 println!("Updated: {}", task.updated_at);
1272 if let Some(report) = &task.final_report {
1273 println!();
1274 println!("Final report:");
1275 println!("{}", sanitize_terminal_text(report));
1276 }
1277}
1278
1279fn show_processes(limit: usize) -> Result<()> {
1280 let read = RuntimeClient::auto().list_processes(limit)?;
1281 let mut processes = read.value;
1282 processes.truncate(limit);
1283 println!("Mermaid runtime processes");
1284 println!("Source: {}", read.source.as_str());
1285 println!();
1286 if processes.is_empty() {
1287 println!("No processes recorded yet.");
1288 return Ok(());
1289 }
1290 for process in processes {
1291 println!(
1292 "{} pid={} status={} {}",
1293 process.id,
1294 process.pid,
1295 process.status.as_str(),
1296 process.command
1297 );
1298 if let Some(task_id) = process.task_id {
1299 println!(" task: {}", task_id);
1300 }
1301 if let Some(cwd) = process.cwd {
1302 println!(" cwd: {}", cwd);
1303 }
1304 if let Some(log_path) = process.log_path {
1305 println!(" log: {}", log_path);
1306 }
1307 if let Some(url) = process.detected_url {
1308 println!(" url: {}", url);
1309 }
1310 }
1311 Ok(())
1312}
1313
1314async fn show_models(config: &Config) -> Result<()> {
1315 list_models(config).await?;
1316 probe_configured_provider_models(config).await?;
1317 let store = RuntimeStore::open_default()?;
1318 let probes = store.provider_probes().list(None, None)?;
1319 if !probes.is_empty() {
1320 println!("\nCached capability probes:");
1321 for probe in probes {
1322 println!(
1323 " - {}/{} {}={} ({})",
1324 probe.provider,
1325 probe.model_id,
1326 probe.capability_key,
1327 probe.capability_value,
1328 probe.confidence
1329 );
1330 }
1331 }
1332 Ok(())
1333}
1334
1335async fn show_model_info(model: &str, config: &Config) -> Result<()> {
1336 let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(model);
1337 let store = RuntimeStore::open_default()?;
1338 let provider = snapshot.provider.clone();
1339
1340 let mut context_tokens = snapshot.max_context_tokens;
1347 let mut context_confidence = "static";
1348 let mut output_tokens = snapshot.max_output_tokens;
1349 let mut output_confidence = "static";
1350 let factory = crate::providers::ProviderFactory::new(config.clone());
1351 if let Ok(live) = factory.resolve(model).await {
1352 let probe_request = ChatRequest {
1353 model_id: model.to_string(),
1354 messages: vec![],
1355 system_prompt: String::new(),
1356 instructions: None,
1357 reasoning: crate::models::ReasoningLevel::None,
1358 temperature: 0.0,
1359 max_tokens: 0,
1360 tools: vec![],
1361 ollama_num_ctx: None,
1362 ollama_allow_ram_offload: None,
1363 resolved_context_window: None,
1364 resolved_max_output: None,
1365 output_schema: None,
1366 suppress_auto_compact: false,
1367 };
1368 let sizing = live.resolve_context_window(&probe_request).await;
1369 if let Some(window) = sizing.model_max.or(sizing.effective) {
1370 context_tokens = Some(window);
1371 context_confidence = "probed";
1372 }
1373 if let Some(output) = sizing.max_output {
1374 output_tokens = Some(output);
1375 output_confidence = "probed";
1376 }
1377 }
1378
1379 for (key, value) in [
1380 ("supports_tools", snapshot.supports_tools.to_string()),
1381 ("supports_vision", snapshot.supports_vision.to_string()),
1382 ("reasoning", snapshot.reasoning.clone()),
1383 ] {
1384 let _ = store.provider_probes().upsert(NewProviderProbe {
1385 provider: provider.clone(),
1386 model_id: snapshot.model.clone(),
1387 capability_key: key.to_string(),
1388 capability_value: value,
1389 confidence: "static".to_string(),
1390 error: None,
1391 });
1392 }
1393 let _ = store.provider_probes().upsert(NewProviderProbe {
1395 provider: provider.clone(),
1396 model_id: snapshot.model.clone(),
1397 capability_key: "max_context_tokens".to_string(),
1398 capability_value: context_tokens
1399 .map(|n| n.to_string())
1400 .unwrap_or_else(|| "unknown".to_string()),
1401 confidence: context_confidence.to_string(),
1402 error: None,
1403 });
1404 println!("Model: {}", model);
1405 println!("Provider: {}", snapshot.provider);
1406 println!("Name: {}", snapshot.model);
1407 println!("Supports tools: {}", snapshot.supports_tools);
1408 println!("Supports vision: {}", snapshot.supports_vision);
1409 println!("Reasoning: {}", snapshot.reasoning);
1410 println!(
1411 "Context: {}",
1412 context_tokens
1413 .map(|n| format!("{n} ({context_confidence})"))
1414 .unwrap_or_else(|| "unknown".to_string())
1415 );
1416 println!(
1417 "Output limit: {}",
1418 output_tokens
1419 .map(|n| format!("{n} ({output_confidence})"))
1420 .unwrap_or_else(|| {
1421 "unknown (discovered live from the provider's models endpoint when exposed)"
1422 .to_string()
1423 })
1424 );
1425 if let Some(profile) = lookup_provider(&snapshot.provider) {
1426 for (key, value) in [
1427 (
1428 "max_output_tokens_param",
1429 format!("{:?}", profile.max_tokens_param),
1430 ),
1431 (
1432 "parallel_tool_calls",
1433 (!profile
1434 .disable_parallel_tool_calls_for
1435 .iter()
1436 .any(|disabled| *disabled == snapshot.model))
1437 .to_string(),
1438 ),
1439 (
1440 "reasoning_parameter_shape",
1441 format!("{:?}", profile.reasoning_strategy),
1442 ),
1443 (
1444 "streaming_usage_available",
1445 "provider_dependent".to_string(),
1446 ),
1447 ("token_usage_field_shape", "openai_compatible".to_string()),
1448 ] {
1449 let _ = store.provider_probes().upsert(NewProviderProbe {
1450 provider: provider.clone(),
1451 model_id: snapshot.model.clone(),
1452 capability_key: key.to_string(),
1453 capability_value: value,
1454 confidence: "static".to_string(),
1455 error: None,
1456 });
1457 }
1458 println!("Token budget field: {:?}", profile.max_tokens_param);
1459 println!(
1460 "Single-tool-call models: {}",
1461 if profile.disable_parallel_tool_calls_for.is_empty() {
1462 "(none)".to_string()
1463 } else {
1464 profile.disable_parallel_tool_calls_for.join(", ")
1465 }
1466 );
1467 }
1468 Ok(())
1469}
1470
1471async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1472 let client = reqwest::Client::builder()
1473 .timeout(std::time::Duration::from_secs(5))
1474 .build()?;
1475 for profile in PROVIDER_REGISTRY {
1476 let user_cfg = config.providers.get(profile.name);
1477 let Some(api_key) = crate::utils::resolve_provider_key(
1478 profile.name,
1479 profile.api_key_env,
1480 user_cfg.and_then(|c| c.api_key_env.as_deref()),
1481 ) else {
1482 continue;
1483 };
1484 let Some(base_url) = crate::providers::factory::discovery_base_url(
1485 profile,
1486 user_cfg.and_then(|c| c.base_url.clone()),
1487 ) else {
1488 record_provider_probe(
1492 profile.name,
1493 "*",
1494 "models_availability",
1495 "failed",
1496 "failed",
1497 Some("CLOUDFLARE_ACCOUNT_ID not set".to_string()),
1498 );
1499 continue;
1500 };
1501 let url = format!("{}/models", base_url.trim_end_matches('/'));
1502 let mut request = client.get(&url).bearer_auth(api_key);
1503 for (name, value) in profile.extra_headers {
1504 request = request.header(*name, *value);
1505 }
1506 if let Some(user_cfg) = user_cfg {
1507 for (name, value) in &user_cfg.extra_headers {
1508 request = request.header(name, value);
1509 }
1510 }
1511
1512 let result = request.send().await;
1513 match result {
1514 Ok(response) if response.status().is_success() => {
1515 let status = response.status();
1516 let body: serde_json::Value = response.json().await.unwrap_or_default();
1517 let ids = body
1518 .get("data")
1519 .and_then(|v| v.as_array())
1520 .map(|items| {
1521 items
1522 .iter()
1523 .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1524 .map(str::to_string)
1525 .collect::<Vec<_>>()
1526 })
1527 .unwrap_or_default();
1528 record_provider_probe(
1529 profile.name,
1530 "*",
1531 "models_availability",
1532 &format!("available:{}:{}", status.as_u16(), ids.len()),
1533 "probed",
1534 None,
1535 );
1536 for model_id in ids.into_iter().take(200) {
1537 record_provider_probe(
1538 profile.name,
1539 &model_id,
1540 "model_listed",
1541 "true",
1542 "listed",
1543 None,
1544 );
1545 }
1546 },
1547 Ok(response) => {
1548 record_provider_probe(
1549 profile.name,
1550 "*",
1551 "models_availability",
1552 "failed",
1553 "failed",
1554 Some(format!("HTTP {}", response.status().as_u16())),
1555 );
1556 },
1557 Err(error) => {
1558 record_provider_probe(
1559 profile.name,
1560 "*",
1561 "models_availability",
1562 "failed",
1563 "failed",
1564 Some(error.to_string()),
1565 );
1566 },
1567 }
1568 }
1569 probe_meta_models(&client, config).await;
1570 Ok(())
1571}
1572
1573async fn probe_meta_models(client: &reqwest::Client, config: &Config) {
1574 let Some(api_key) = meta_api_key(config) else {
1575 return;
1576 };
1577 let url = format!("{}/models", meta_base_url(config).trim_end_matches('/'));
1578 let mut request = client.get(&url).bearer_auth(api_key);
1579 if let Some(provider) = config.providers.get("meta") {
1580 for (name, value) in &provider.extra_headers {
1581 request = request.header(name, value);
1582 }
1583 for (name, env_var) in &provider.env_headers {
1584 if let Ok(value) = std::env::var(env_var) {
1585 request = request.header(name, value);
1586 }
1587 }
1588 }
1589 match request.send().await {
1590 Ok(response) if response.status().is_success() => {
1591 let status = response.status();
1592 let body: serde_json::Value = response.json().await.unwrap_or_default();
1593 let ids = body
1594 .get("data")
1595 .and_then(serde_json::Value::as_array)
1596 .into_iter()
1597 .flatten()
1598 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1599 .map(str::to_string)
1600 .collect::<Vec<_>>();
1601 record_provider_probe(
1602 "meta",
1603 "*",
1604 "models_availability",
1605 &format!("available:{}:{}", status.as_u16(), ids.len()),
1606 "probed",
1607 None,
1608 );
1609 for model_id in ids.into_iter().take(200) {
1610 record_provider_probe("meta", &model_id, "model_listed", "true", "listed", None);
1611 }
1612 },
1613 Ok(response) => record_provider_probe(
1614 "meta",
1615 "*",
1616 "models_availability",
1617 "failed",
1618 "failed",
1619 Some(format!("HTTP {}", response.status().as_u16())),
1620 ),
1621 Err(error) => record_provider_probe(
1622 "meta",
1623 "*",
1624 "models_availability",
1625 "failed",
1626 "failed",
1627 Some(error.to_string()),
1628 ),
1629 }
1630}
1631
1632fn record_provider_probe(
1633 provider: &str,
1634 model_id: &str,
1635 key: &str,
1636 value: &str,
1637 confidence: &str,
1638 error: Option<String>,
1639) {
1640 if let Ok(store) = RuntimeStore::open_default() {
1641 let _ = store.provider_probes().upsert(NewProviderProbe {
1642 provider: provider.to_string(),
1643 model_id: model_id.to_string(),
1644 capability_key: key.to_string(),
1645 capability_value: value.to_string(),
1646 confidence: confidence.to_string(),
1647 error,
1648 });
1649 }
1650}
1651
1652fn show_approvals() -> Result<()> {
1653 let approvals = RuntimeClient::auto().list_approvals()?.value;
1654 if approvals.is_empty() {
1655 println!("No pending approvals.");
1656 return Ok(());
1657 }
1658 for approval in approvals {
1659 println!(
1660 "{} [{} -> {}] {}",
1661 approval.id,
1662 approval.risk_classification,
1663 approval.policy_decision,
1664 approval.proposed_action
1665 );
1666 if let Some(args) = approval.args_summary {
1667 println!(" args: {}", args);
1668 }
1669 if let Some(checkpoint_id) = approval.checkpoint_id {
1670 println!(" checkpoint: {}", checkpoint_id);
1671 }
1672 if approval.pending_action_json.is_some() {
1673 println!(" pending action: recorded");
1674 }
1675 }
1676 Ok(())
1677}
1678
1679fn approve(id: &str) -> Result<()> {
1680 let result = RuntimeClient::auto().approve(id)?;
1681 println!("Approved {}", id);
1682 if result.replayed {
1683 println!("{}", result.summary);
1684 }
1685 Ok(())
1686}
1687
1688fn deny(id: &str) -> Result<()> {
1689 let _ = RuntimeClient::auto().deny(id)?;
1690 println!("Denied {}", id);
1691 Ok(())
1692}
1693
1694fn follow_task(id: &str) -> Result<()> {
1699 let lines = crate::runtime::subscribe_daemon_lines(
1700 crate::runtime::DaemonRequest::SubscribeTask {
1701 task_id: id.to_string(),
1702 }
1703 .to_wire(),
1704 )
1705 .context("mermaid task --follow needs a running daemon (`mermaid daemon start`)")?;
1706 let mut saw_any = false;
1707 for line in lines {
1708 let line = line?;
1709 if line.trim().is_empty() {
1710 continue;
1711 }
1712 if !saw_any {
1714 saw_any = true;
1715 let ack: serde_json::Value =
1716 serde_json::from_str(line.trim()).context("daemon returned invalid JSON")?;
1717 if ack.get("ok").and_then(|v| v.as_bool()) == Some(false) {
1718 anyhow::bail!(
1719 "{}",
1720 ack.get("error")
1721 .and_then(|v| v.as_str())
1722 .unwrap_or("subscribe failed")
1723 );
1724 }
1725 println!("{}", line.trim());
1726 continue;
1727 }
1728 println!("{}", line.trim());
1729 if serde_json::from_str::<serde_json::Value>(line.trim())
1730 .ok()
1731 .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_string))
1732 .as_deref()
1733 == Some("result")
1734 {
1735 return Ok(());
1736 }
1737 }
1738 if saw_any {
1739 anyhow::bail!("stream ended without a result (daemon restarted mid-run?)");
1740 }
1741 anyhow::bail!("daemon closed the connection without responding");
1742}
1743
1744fn cancel_task(id: &str) -> Result<()> {
1749 match crate::runtime::request_daemon_json(
1750 crate::runtime::DaemonRequest::CancelTask { id: id.to_string() }.to_wire(),
1751 ) {
1752 Ok(response) => {
1753 if response.get("cancelling").and_then(|v| v.as_bool()) == Some(true) {
1754 println!("Cancelling {} (running; the agent unwinds gracefully)", id);
1755 } else {
1756 println!("Cancelled {}", id);
1757 }
1758 Ok(())
1759 },
1760 Err(daemon_err) => {
1761 let store = crate::runtime::RuntimeStore::open_default()?;
1762 match store.tasks().get(id)? {
1763 Some(task) if task.status == crate::runtime::TaskStatus::Queued => {
1764 store.tasks().update_status(
1765 id,
1766 crate::runtime::TaskStatus::Cancelled,
1767 Some("cancelled before start"),
1768 )?;
1769 println!("Cancelled {} (was queued; daemon unreachable)", id);
1770 Ok(())
1771 },
1772 Some(task) => anyhow::bail!(
1773 "task {} is {} and the daemon request failed: {}",
1774 id,
1775 task.status,
1776 daemon_err
1777 ),
1778 None => anyhow::bail!("task not found: {}", id),
1779 }
1780 },
1781 }
1782}
1783
1784fn show_tool_runs(limit: usize) -> Result<()> {
1785 let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1786 runs.truncate(limit);
1787 if runs.is_empty() {
1788 println!("No tool runs recorded yet.");
1789 return Ok(());
1790 }
1791 for run in runs {
1792 println!(
1793 "{} [{}] {} started {}",
1794 run.id, run.status, run.tool_name, run.started_at
1795 );
1796 if let Some(turn_id) = run.turn_id {
1797 println!(" turn: {}", turn_id);
1798 }
1799 if let Some(call_id) = run.call_id {
1800 println!(" call: {}", call_id);
1801 }
1802 if let Some(finished_at) = run.finished_at {
1803 println!(" finished: {}", finished_at);
1804 }
1805 }
1806 Ok(())
1807}
1808
1809fn show_checkpoints(limit: usize) -> Result<()> {
1810 let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1811 checkpoints.truncate(limit);
1812 if checkpoints.is_empty() {
1813 println!("No checkpoints recorded yet.");
1814 return Ok(());
1815 }
1816 for checkpoint in checkpoints {
1817 println!(
1818 "{} {} {}",
1819 checkpoint.id, checkpoint.created_at, checkpoint.project_path
1820 );
1821 println!(" snapshot: {}", checkpoint.snapshot_path);
1822 println!(" files: {}", checkpoint.changed_files_json);
1823 if let Some(approval_id) = checkpoint.approval_id {
1824 println!(" approval: {}", approval_id);
1825 }
1826 }
1827 Ok(())
1828}
1829
1830fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
1831 if !crate::utils::confirm_or_refuse(
1835 &format!("Restore checkpoint {id}? This overwrites the current working tree."),
1836 force,
1837 )? {
1838 println!("Restore cancelled.");
1839 return Ok(());
1840 }
1841 let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
1842 println!("Restored {} ({} files)", manifest.id, manifest.files.len());
1843 if let Some(repo) = manifest.shadow_git_repo {
1844 println!("Shadow repo: {}", repo);
1845 }
1846 if let Some(commit) = manifest.shadow_git_commit {
1847 println!("Shadow commit: {}", commit);
1848 }
1849 if let Some(action) = manifest.pending_action {
1850 println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
1851 }
1852 Ok(())
1853}
1854
1855fn handle_plugin(command: &PluginCommand) -> Result<()> {
1856 match command {
1857 PluginCommand::Install { path } => {
1858 let preview = crate::runtime::plugin_capability_preview(path)?;
1859 print_plugin_capability_preview(&preview);
1860 let record = crate::runtime::install_plugin_from_path(path)?;
1861 println!(
1862 "Installed plugin {} ({}) — DISABLED.",
1863 record.name, record.id
1864 );
1865 println!(
1866 "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
1867 record.id
1868 );
1869 },
1870 PluginCommand::List => {
1871 let plugins = RuntimeClient::auto().list_plugins()?.value;
1872 if plugins.is_empty() {
1873 println!("No plugins installed.");
1874 } else {
1875 for plugin in plugins {
1876 println!(
1877 "{} [{}] {} ({})",
1878 plugin.id,
1879 if plugin.enabled {
1880 "enabled"
1881 } else {
1882 "disabled"
1883 },
1884 plugin.name,
1885 plugin.source
1886 );
1887 }
1888 }
1889 },
1890 PluginCommand::Enable { id } => {
1891 let client = RuntimeClient::auto();
1893 if let Some(plugin) = client
1894 .list_plugins()?
1895 .value
1896 .into_iter()
1897 .find(|p| p.id == *id || p.name == *id)
1898 && let Ok(preview) =
1899 crate::runtime::plugin_capability_preview(Path::new(&plugin.source))
1900 {
1901 print_plugin_capability_preview(&preview);
1902 }
1903 client.set_plugin_enabled(id, true)?;
1904 println!("Enabled plugin {} — its hooks will now run.", id);
1905 },
1906 PluginCommand::Disable { id } => {
1907 RuntimeClient::auto().set_plugin_enabled(id, false)?;
1908 println!("Disabled plugin {}", id);
1909 },
1910 PluginCommand::Audit { path } => {
1911 let manifest_path = if path.is_dir() {
1912 path.join("plugin.toml")
1913 } else {
1914 path.clone()
1915 };
1916 let raw = std::fs::read_to_string(&manifest_path)?;
1917 let manifest: crate::runtime::PluginManifest = toml::from_str(&raw)?;
1918 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1919 crate::runtime::validate_plugin_manifest(&manifest, root)?;
1920 let preview = crate::runtime::plugin_capability_preview(path)?;
1921 println!("Plugin manifest is valid: {}", manifest.name);
1922 print_plugin_capability_preview(&preview);
1923 },
1924 }
1925 Ok(())
1926}
1927
1928fn print_plugin_capability_preview(preview: &crate::runtime::PluginCapabilityPreview) {
1929 println!(
1930 "Capabilities declared by plugin {} (advisory, not sandbox-enforced):",
1931 preview.name
1932 );
1933 if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
1934 println!(" capabilities: (none declared)");
1935 } else {
1936 if !preview.declared_capabilities.is_empty() {
1937 println!(" declared: {}", preview.declared_capabilities.join(", "));
1938 }
1939 if let Some(value) = &preview.capabilities_toml {
1940 println!(
1941 " capabilities.toml: {}",
1942 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
1943 );
1944 }
1945 }
1946 if !preview.hooks.is_empty() {
1947 println!(" hooks: {}", preview.hooks.join(", "));
1948 }
1949 if !preview.mcp.is_empty() {
1950 println!(" mcp: {}", preview.mcp.join(", "));
1951 }
1952 if !preview.bin.is_empty() {
1953 println!(" bin: {}", preview.bin.join(", "));
1954 }
1955}
1956
1957fn handle_pair(command: &PairCommand) -> Result<()> {
1958 let store = RuntimeStore::open_default()?;
1959 match command {
1960 PairCommand::Create { label, ttl_days } => {
1961 let ttl = ttl_days.unwrap_or(crate::runtime::DEFAULT_PAIRING_TTL_DAYS);
1962 let expires_at = crate::runtime::pairing_expiry_from_now(ttl);
1963 let (token, hash) = crate::runtime::generate_pairing_token()?;
1964 let record =
1965 store
1966 .pairing_tokens()
1967 .create(&hash, label.as_deref(), expires_at.as_deref())?;
1968 println!("Pairing token id: {}", record.id);
1969 println!("Pairing token: {}", token);
1970 println!(
1971 "Expires: {}",
1972 record.expires_at.as_deref().unwrap_or("never")
1973 );
1974 println!(
1975 "Use with daemon JSON by setting {}.",
1976 crate::runtime::daemon::DAEMON_TOKEN_ENV
1977 );
1978 println!("Store this now; Mermaid will not print it again.");
1979 },
1980 PairCommand::List => {
1981 let tokens = store.pairing_tokens().list()?;
1982 if tokens.is_empty() {
1983 println!("No pairing tokens.");
1984 } else {
1985 for t in tokens {
1987 println!(
1988 "{} [{}] label={} created={} expires={} last_used={}",
1989 t.id,
1990 if t.enabled { "active" } else { "revoked" },
1991 t.label.as_deref().unwrap_or("-"),
1992 t.created_at,
1993 t.expires_at.as_deref().unwrap_or("never"),
1994 t.last_used_at.as_deref().unwrap_or("never"),
1995 );
1996 }
1997 }
1998 },
1999 PairCommand::Revoke { id } => {
2000 if store.pairing_tokens().revoke(id)? {
2001 println!("Revoked pairing token {id}");
2002 } else {
2003 println!("No active pairing token with id {id}");
2004 }
2005 },
2006 }
2007 Ok(())
2008}
2009
2010fn sanitize_terminal_text(input: &str) -> String {
2019 let mut out = String::with_capacity(input.len());
2020 let mut chars = input.chars();
2021 while let Some(c) = chars.next() {
2022 match c {
2023 '\n' | '\t' => out.push(c),
2024 '\u{1b}' => match chars.next() {
2025 Some('[') => {
2028 for p in chars.by_ref() {
2029 if ('@'..='~').contains(&p) {
2030 break;
2031 }
2032 }
2033 },
2034 Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
2037 while let Some(p) = chars.next() {
2038 if p == '\u{07}' {
2039 break;
2040 }
2041 if p == '\u{1b}' {
2042 let mut peek = chars.clone();
2044 if peek.next() == Some('\\') {
2045 chars = peek;
2046 }
2047 break;
2048 }
2049 }
2050 },
2051 Some(mut b) => {
2054 while ('\u{20}'..='\u{2f}').contains(&b) {
2055 match chars.next() {
2056 Some(next) => b = next,
2057 None => break,
2058 }
2059 }
2060 },
2061 None => {},
2062 },
2063 c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
2065 c => out.push(c),
2066 }
2067 }
2068 out
2069}
2070
2071fn show_logs(id: &str) -> Result<()> {
2072 let content = RuntimeClient::auto().process_log(id, None)?.content;
2073 print!("{}", sanitize_terminal_text(&content));
2074 Ok(())
2075}
2076
2077fn stop_process(id: &str) -> Result<()> {
2078 let process = RuntimeClient::auto().stop_process(id)?.item;
2079 println!("Stopped process {} (pid {})", id, process.pid);
2080 Ok(())
2081}
2082
2083fn restart_process(id: &str) -> Result<()> {
2084 let process = RuntimeClient::auto().restart_process(id)?.item;
2085 println!("Restarted process {} (pid {})", id, process.pid);
2086 Ok(())
2087}
2088
2089fn open_target(target: &str) -> Result<()> {
2090 if RuntimeClient::auto().open_process(target).is_err() {
2091 crate::utils::open_file(target);
2092 }
2093 Ok(())
2094}
2095
2096fn show_ports() -> Result<()> {
2097 let ports = RuntimeClient::auto().ports()?.ports;
2098 print!("{}", sanitize_terminal_text(&ports));
2099 Ok(())
2100}
2101
2102pub async fn list_models(config: &Config) -> Result<()> {
2107 match list_ollama_models(config).await {
2108 None if is_ollama_installed() => {
2109 println!("Ollama is installed but not running — local models can't be listed.");
2110 println!("(It starts automatically when you use an Ollama model.)");
2111 },
2112 None => println!("Ollama is not installed; no local models."),
2113 Some(models) if models.is_empty() => println!("No Ollama models installed locally."),
2114 Some(models) => {
2115 println!("Ollama models (local/cloud):");
2116 for name in &models {
2117 println!(" - ollama/{}", name);
2118 }
2119 },
2120 }
2121
2122 println!("\nConfigured remote providers:");
2123 let mut any = false;
2124 if meta_api_key(config).is_some() {
2125 any = true;
2126 println!(" - meta (via ${})", meta_api_key_env(config));
2127 }
2128 for profile in PROVIDER_REGISTRY {
2129 let override_env = config
2130 .providers
2131 .get(profile.name)
2132 .and_then(|c| c.api_key_env.as_deref());
2133 match crate::utils::provider_key_source(profile.name, profile.api_key_env, override_env) {
2134 "env" => {
2135 any = true;
2136 let env = override_env.unwrap_or(profile.api_key_env);
2137 println!(" - {} (via ${})", profile.name, env);
2138 },
2139 "keyring" => {
2140 any = true;
2141 println!(" - {} (via keyring)", profile.name);
2142 },
2143 _ => {},
2144 }
2145 }
2146 if !any {
2147 println!(" (none — set a provider API key env var to enable)");
2148 }
2149 println!("\nSwitch models in-session with /model <name>.");
2150 Ok(())
2151}
2152
2153async fn list_ollama_models(config: &Config) -> Option<Vec<String>> {
2163 use crate::models::adapters::ollama::OllamaAdapter;
2164 let backend = BackendConfig {
2165 ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
2166 timeout_secs: 5,
2167 max_idle_per_host: 2,
2168 ollama_autostart: false,
2169 };
2170 match OllamaAdapter::new("__list__", Arc::new(backend)).await {
2171 Ok(adapter) => adapter.list_models().await.ok(),
2172 Err(_) => None,
2173 }
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 !crate::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 = crate::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
2415async fn show_status(config: &Config) -> Result<()> {
2417 println!("Mermaid Status:");
2418 println!();
2419
2420 let mut available: Vec<String> = Vec::new();
2423 if meta_api_key(config).is_some() {
2424 available.push("meta".to_string());
2425 }
2426 for profile in PROVIDER_REGISTRY {
2427 let override_env = config
2428 .providers
2429 .get(profile.name)
2430 .and_then(|c| c.api_key_env.as_deref());
2431 match crate::utils::provider_key_source(profile.name, profile.api_key_env, override_env) {
2432 "env" => available.push(profile.name.to_string()),
2433 "keyring" => available.push(format!("{} (keyring)", profile.name)),
2436 _ => {},
2437 }
2438 }
2439 if available.is_empty() {
2440 println!(
2441 " [WARNING] Remote providers: none (no API keys in env or keyring; `mermaid login <provider>`)"
2442 );
2443 } else {
2444 println!(" [OK] Remote providers: {}", available.join(", "));
2445 }
2446
2447 if is_ollama_installed() {
2452 match list_ollama_models(config).await {
2453 None => println!(
2454 " [WARNING] Ollama: Installed but not running (started automatically \
2455 when an Ollama model is used)"
2456 ),
2457 Some(models) if models.is_empty() => {
2458 println!(" [WARNING] Ollama: Running (no models installed)");
2459 },
2460 Some(models) => {
2461 println!(" [OK] Ollama: Running ({} models installed)", models.len());
2462 for model in models.iter().take(3) {
2463 println!(" - {}", model);
2464 }
2465 if models.len() > 3 {
2466 println!(" ... and {} more", models.len() - 3);
2467 }
2468 },
2469 }
2470 } else {
2471 println!(" [ERROR] Ollama: Not installed");
2472 }
2473
2474 if let Ok(config_dir) = get_config_dir() {
2476 let config_path = config_dir.join("config.toml");
2477 if config_path.exists() {
2478 println!(" [OK] Configuration: {}", config_path.display());
2479 } else {
2480 println!(" [WARNING] Configuration: Not found (using defaults)");
2481 }
2482 }
2483
2484 if config.mcp_servers.is_empty() {
2486 println!(" [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
2487 } else {
2488 println!(
2489 " [OK] MCP Servers: {} configured",
2490 config.mcp_servers.len()
2491 );
2492 for (name, server_cfg) in &config.mcp_servers {
2493 let target: &str = match &server_cfg.url {
2494 Some(url) => url,
2495 None => server_cfg
2496 .args
2497 .get(1)
2498 .map(String::as_str)
2499 .unwrap_or(server_cfg.command.as_str()),
2500 };
2501 println!(" - {} ({})", name, target);
2502 }
2503 }
2504
2505 {
2508 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2509 let paths = crate::app::instructions::find_instruction_files(&cwd);
2510 if paths.is_empty() {
2511 println!(" [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
2512 } else {
2513 match crate::app::instructions::load_from_paths(&paths) {
2514 Some(loaded) => {
2515 let files = loaded
2516 .sources
2517 .iter()
2518 .map(|source| {
2519 source
2520 .path
2521 .file_name()
2522 .and_then(|name| name.to_str())
2523 .unwrap_or("instructions")
2524 })
2525 .collect::<Vec<_>>()
2526 .join(", ");
2527 println!(
2528 " [OK] Project instructions: {} at {} ({} bytes{})",
2529 files,
2530 loaded.path.display(),
2531 loaded.byte_len,
2532 if loaded.truncated { ", truncated" } else { "" }
2533 );
2534 },
2535 None => {
2536 println!(
2537 " [WARNING] Project instructions: found but unreadable ({})",
2538 paths
2539 .iter()
2540 .map(|path| path.display().to_string())
2541 .collect::<Vec<_>>()
2542 .join(", ")
2543 );
2544 },
2545 }
2546 }
2547 }
2548
2549 show_provider_status(config);
2553
2554 println!("\n Environment:");
2556 if std::env::var("OLLAMA_API_KEY").is_ok() {
2557 println!(" - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2558 }
2559
2560 println!();
2561 Ok(())
2562}
2563
2564fn show_provider_status(config: &Config) {
2569 let mut configured: Vec<(String, String)> = Vec::new(); let anth_cfg = config.providers.get("anthropic");
2574 if crate::utils::resolve_provider_key(
2575 "anthropic",
2576 "ANTHROPIC_API_KEY",
2577 anth_cfg.and_then(|c| c.api_key_env.as_deref()),
2578 )
2579 .is_some()
2580 {
2581 let url = anth_cfg
2582 .and_then(|c| c.base_url.clone())
2583 .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
2584 configured.push(("anthropic".to_string(), url));
2585 }
2586
2587 let gem_cfg = config.providers.get("gemini");
2589 if crate::utils::resolve_provider_key_with_fallback(
2590 "gemini",
2591 "GOOGLE_API_KEY",
2592 "GEMINI_API_KEY",
2593 gem_cfg.and_then(|c| c.api_key_env.as_deref()),
2594 )
2595 .is_some()
2596 {
2597 let url = gem_cfg
2598 .and_then(|c| c.base_url.clone())
2599 .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
2600 configured.push(("gemini".to_string(), url));
2601 }
2602
2603 if meta_api_key(config).is_some() {
2604 configured.push(("meta".to_string(), meta_base_url(config)));
2605 }
2606
2607 for profile in PROVIDER_REGISTRY {
2608 let user_cfg = config.providers.get(profile.name);
2609 let api_key_present = crate::utils::resolve_provider_key(
2610 profile.name,
2611 profile.api_key_env,
2612 user_cfg.and_then(|c| c.api_key_env.as_deref()),
2613 )
2614 .is_some();
2615 if api_key_present {
2616 let url = crate::providers::factory::discovery_base_url(
2617 profile,
2618 user_cfg.and_then(|c| c.base_url.clone()),
2619 )
2620 .unwrap_or_else(|| {
2623 "CLOUDFLARE_ACCOUNT_ID not set — set it or [providers.cloudflare].base_url"
2624 .to_string()
2625 });
2626 configured.push((profile.name.to_string(), url));
2627 }
2628 }
2629
2630 for (name, cfg) in &config.providers {
2633 if matches!(name.as_str(), "anthropic" | "gemini" | "meta")
2634 || lookup_provider(name).is_some()
2635 {
2636 continue;
2637 }
2638 if let (Some(url), Some(env)) = (&cfg.base_url, cfg.api_key_env.as_deref())
2639 && crate::utils::resolve_provider_key(name, env, None).is_some()
2640 {
2641 configured.push((name.clone(), url.clone()));
2642 }
2643 }
2644
2645 if configured.is_empty() {
2646 println!(
2647 " [INFO] Remote providers: None configured (set $ANTHROPIC_API_KEY, \
2648 $GOOGLE_API_KEY, $OPENAI_API_KEY, $GROQ_API_KEY, $OPENROUTER_API_KEY, etc., or \
2649 add [providers.<name>] to config.toml)"
2650 );
2651 } else {
2652 println!(" [OK] Remote providers: {} configured", configured.len());
2653 for (name, url) in configured {
2654 println!(" - {} ({})", name, url);
2655 }
2656 }
2657}
2658
2659fn handle_pr(command: &PrCommand) -> Result<()> {
2661 match command {
2662 PrCommand::Create {
2663 title,
2664 body,
2665 summary,
2666 base,
2667 draft,
2668 web,
2669 provider,
2670 } => create_pr(CreatePrArgs {
2671 title: title.as_deref(),
2672 body: body.as_deref(),
2673 summary: summary.as_deref(),
2674 base: base.as_deref(),
2675 draft: *draft,
2676 web: *web,
2677 provider: *provider,
2678 }),
2679 }
2680}
2681
2682struct CreatePrArgs<'a> {
2683 title: Option<&'a str>,
2684 body: Option<&'a str>,
2685 summary: Option<&'a Path>,
2686 base: Option<&'a str>,
2687 draft: bool,
2688 web: bool,
2689 provider: Option<GitHost>,
2690}
2691
2692fn create_pr(args: CreatePrArgs) -> Result<()> {
2697 let body = match args.summary {
2699 Some(path) => Some(
2700 std::fs::read_to_string(path)
2701 .with_context(|| format!("failed to read summary file {}", path.display()))?,
2702 ),
2703 None => args.body.map(str::to_string),
2704 };
2705
2706 let host = match args.provider {
2707 Some(host) => host,
2708 None => detect_git_host()?,
2709 };
2710
2711 let (cli, install_hint) = match host {
2712 GitHost::Github => (
2713 "gh",
2714 "Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.",
2715 ),
2716 GitHost::Gitlab => (
2717 "glab",
2718 "Install the GitLab CLI (https://gitlab.com/gitlab-org/cli) and run `glab auth login`.",
2719 ),
2720 };
2721 if which::which(cli).is_err() {
2722 anyhow::bail!("`{cli}` was not found on your PATH. {install_hint}");
2723 }
2724
2725 let argv = build_pr_argv(
2726 host,
2727 args.title,
2728 body.as_deref(),
2729 args.base,
2730 args.draft,
2731 args.web,
2732 );
2733
2734 println!("Creating pull/merge request via `{cli}`…");
2735 let status = std::process::Command::new(cli)
2736 .args(&argv)
2737 .status()
2738 .with_context(|| format!("failed to run `{cli}`"))?;
2739 anyhow::ensure!(status.success(), "`{cli}` exited unsuccessfully ({status})");
2740 Ok(())
2741}
2742
2743fn detect_git_host() -> Result<GitHost> {
2746 if let Some(host) = git_origin_host() {
2747 return Ok(host);
2748 }
2749 if which::which("gh").is_ok() {
2750 return Ok(GitHost::Github);
2751 }
2752 if which::which("glab").is_ok() {
2753 return Ok(GitHost::Gitlab);
2754 }
2755 anyhow::bail!(
2756 "could not detect a Git host from the `origin` remote. Pass `--provider github|gitlab` and install the matching CLI (`gh`/`glab`)."
2757 )
2758}
2759
2760fn git_origin_host() -> Option<GitHost> {
2761 let output = std::process::Command::new("git")
2762 .args(["config", "--get", "remote.origin.url"])
2763 .output()
2764 .ok()?;
2765 if !output.status.success() {
2766 return None;
2767 }
2768 host_from_remote_url(String::from_utf8_lossy(&output.stdout).trim())
2769}
2770
2771fn host_from_remote_url(url: &str) -> Option<GitHost> {
2772 let lower = url.to_ascii_lowercase();
2773 if lower.contains("github.com") {
2774 Some(GitHost::Github)
2775 } else if lower.contains("gitlab") {
2776 Some(GitHost::Gitlab)
2777 } else {
2778 None
2779 }
2780}
2781
2782fn build_pr_argv(
2784 host: GitHost,
2785 title: Option<&str>,
2786 body: Option<&str>,
2787 base: Option<&str>,
2788 draft: bool,
2789 web: bool,
2790) -> Vec<String> {
2791 let s = |v: &str| v.to_string();
2792 let has_content = title.is_some() || body.is_some();
2793 let mut argv = Vec::new();
2794 match host {
2795 GitHost::Github => {
2796 argv.push(s("pr"));
2797 argv.push(s("create"));
2798 if web {
2799 argv.push(s("--web"));
2800 }
2801 if draft {
2802 argv.push(s("--draft"));
2803 }
2804 if let Some(title) = title {
2805 argv.push(s("--title"));
2806 argv.push(s(title));
2807 }
2808 if let Some(body) = body {
2809 argv.push(s("--body"));
2810 argv.push(s(body));
2811 }
2812 if !has_content && !web {
2816 argv.push(s("--fill"));
2817 }
2818 if let Some(base) = base {
2819 argv.push(s("--base"));
2820 argv.push(s(base));
2821 }
2822 },
2823 GitHost::Gitlab => {
2824 argv.push(s("mr"));
2825 argv.push(s("create"));
2826 if web {
2827 argv.push(s("--web"));
2828 }
2829 if draft {
2830 argv.push(s("--draft"));
2831 }
2832 if let Some(title) = title {
2833 argv.push(s("--title"));
2834 argv.push(s(title));
2835 }
2836 if let Some(body) = body {
2837 argv.push(s("--description"));
2838 argv.push(s(body));
2839 }
2840 if !has_content && !web {
2841 argv.push(s("--fill"));
2842 }
2843 if let Some(base) = base {
2844 argv.push(s("--target-branch"));
2845 argv.push(s(base));
2846 }
2847 },
2848 }
2849 argv
2850}
2851
2852#[cfg(test)]
2853mod tests {
2854 use super::*;
2855
2856 #[test]
2857 fn sanitize_terminal_text_strips_control_sequences() {
2858 assert_eq!(
2860 sanitize_terminal_text("hello\tworld\nline two"),
2861 "hello\tworld\nline two"
2862 );
2863 assert_eq!(
2865 sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
2866 "RED text"
2867 );
2868 assert_eq!(
2870 sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
2871 "beforeafter"
2872 );
2873 assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
2875 assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
2877 assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
2879 }
2880
2881 #[test]
2882 fn version_compare_handles_update_logic() {
2883 assert!(version_at_least("0.10.2", "0.10.2"));
2885 assert!(version_at_least("0.11.0", "0.10.2"));
2886 assert!(version_at_least("1.0.0", "0.99.99"));
2887 assert!(!version_at_least("0.10.1", "0.10.2"));
2889 assert!(!version_at_least("0.9.0", "0.10.0"));
2890 assert!(!version_at_least("0.10.2", "0.11.0"));
2891 assert!(version_at_least("0.10.2", "v0.10.2"));
2893 assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2894 assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2895 assert!(!version_at_least("0.10.2", "not-a-version"));
2897 }
2898
2899 #[test]
2900 fn host_from_remote_url_detects_provider() {
2901 assert_eq!(
2902 host_from_remote_url("https://github.com/foo/bar.git"),
2903 Some(GitHost::Github)
2904 );
2905 assert_eq!(
2906 host_from_remote_url("git@github.com:foo/bar.git"),
2907 Some(GitHost::Github)
2908 );
2909 assert_eq!(
2910 host_from_remote_url("https://gitlab.com/foo/bar.git"),
2911 Some(GitHost::Gitlab)
2912 );
2913 assert_eq!(
2914 host_from_remote_url("git@gitlab.example.com:foo/bar.git"),
2915 Some(GitHost::Gitlab)
2916 );
2917 assert_eq!(host_from_remote_url("https://bitbucket.org/foo/bar"), None);
2918 }
2919
2920 #[test]
2921 fn build_pr_argv_github_with_content() {
2922 let argv = build_pr_argv(
2923 GitHost::Github,
2924 Some("T"),
2925 Some("B"),
2926 Some("main"),
2927 true,
2928 false,
2929 );
2930 assert_eq!(
2931 argv,
2932 vec![
2933 "pr", "create", "--draft", "--title", "T", "--body", "B", "--base", "main"
2934 ]
2935 );
2936 }
2937
2938 #[test]
2939 fn build_pr_argv_github_fills_without_content() {
2940 let argv = build_pr_argv(GitHost::Github, None, None, None, false, false);
2941 assert!(argv.contains(&"--fill".to_string()));
2942 assert!(!argv.contains(&"--title".to_string()));
2943 }
2944
2945 #[test]
2946 fn build_pr_argv_web_skips_fill() {
2947 let argv = build_pr_argv(GitHost::Github, None, None, None, false, true);
2948 assert!(argv.contains(&"--web".to_string()));
2949 assert!(!argv.contains(&"--fill".to_string()));
2950 }
2951
2952 #[test]
2953 fn build_pr_argv_gitlab_uses_mr_and_target_branch() {
2954 let argv = build_pr_argv(GitHost::Gitlab, Some("T"), None, Some("main"), false, false);
2955 assert_eq!(&argv[0..2], &["mr", "create"]);
2956 assert!(argv.windows(2).any(|w| w == ["--target-branch", "main"]));
2957 assert!(argv.contains(&"--title".to_string()));
2958 }
2959
2960 #[test]
2961 fn qa_compact_smoke_persists_conversation_and_archive() {
2962 let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2963 std::fs::create_dir_all(&dir).unwrap();
2964
2965 let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2966
2967 assert!(report.ok);
2968 assert!(report.archived_messages > 0);
2969 assert!(report.preserved_messages > 0);
2970 assert!(report.replacement_messages >= 3);
2971 assert!(
2972 std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2973 "conversation path should exist"
2974 );
2975 assert!(
2976 std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2977 "archive path should exist"
2978 );
2979
2980 let _ = std::fs::remove_dir_all(dir);
2981 }
2982
2983 #[test]
2984 fn qa_model_id_falls_back_to_deterministic() {
2985 assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2986 }
2987
2988 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2989 let nanos = std::time::SystemTime::now()
2990 .duration_since(std::time::UNIX_EPOCH)
2991 .map(|duration| duration.as_nanos())
2992 .unwrap_or_default();
2993 std::env::temp_dir().join(format!("{name}-{nanos}"))
2994 }
2995}