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},
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 utils::{resolve_api_key, resolve_api_key_with_fallback},
17};
18
19use super::{Commands, GitHost, OutputFormat, PluginCommand, PrCommand, QaCommand};
20
21pub async fn handle_command(
25 command: &Commands,
26 config: &Config,
27 cwd: &Path,
28 cli_model: Option<&str>,
29) -> Result<bool> {
30 match command {
31 Commands::Init => {
32 println!("Initializing Mermaid configuration...");
33 init_config()?;
34 println!("Configuration initialized successfully!");
35 Ok(true)
36 },
37 Commands::List => {
38 list_models(config).await?;
39 Ok(true)
40 },
41 Commands::Models => {
42 show_models(config).await?;
43 Ok(true)
44 },
45 Commands::ModelInfo { model } => {
46 show_model_info(model)?;
47 Ok(true)
48 },
49 Commands::Version => {
50 show_version();
51 Ok(true)
52 },
53 Commands::Update { check, force } => {
54 run_update(*check, *force).await?;
55 Ok(true)
56 },
57 Commands::Status => {
58 show_status(config).await?;
59 Ok(true)
60 },
61 Commands::Doctor { format } => {
62 show_doctor(config, cwd, cli_model, *format).await?;
63 Ok(true)
64 },
65 Commands::SelfTest {
66 format,
67 keep_workspace,
68 } => {
69 run_self_test(config, *format, *keep_workspace)?;
70 Ok(true)
71 },
72 Commands::Tasks { limit } => {
73 show_tasks(*limit)?;
74 Ok(true)
75 },
76 Commands::Task { id } => {
77 show_task(id)?;
78 Ok(true)
79 },
80 Commands::Processes { limit } => {
81 show_processes(*limit)?;
82 Ok(true)
83 },
84 Commands::Logs { id } => {
85 show_logs(id)?;
86 Ok(true)
87 },
88 Commands::Stop { id } => {
89 stop_process(id)?;
90 Ok(true)
91 },
92 Commands::Restart { id } => {
93 restart_process(id)?;
94 Ok(true)
95 },
96 Commands::Open { target } => {
97 open_target(target)?;
98 Ok(true)
99 },
100 Commands::Ports => {
101 show_ports()?;
102 Ok(true)
103 },
104 Commands::Approvals => {
105 show_approvals()?;
106 Ok(true)
107 },
108 Commands::Approve { id } => {
109 approve(id)?;
110 Ok(true)
111 },
112 Commands::Deny { id } => {
113 deny(id)?;
114 Ok(true)
115 },
116 Commands::ToolRuns { limit } => {
117 show_tool_runs(*limit)?;
118 Ok(true)
119 },
120 Commands::Checkpoints { limit } => {
121 show_checkpoints(*limit)?;
122 Ok(true)
123 },
124 Commands::Restore { id } => {
125 restore_checkpoint(id)?;
126 Ok(true)
127 },
128 Commands::Plugin { command } => {
129 handle_plugin(command)?;
130 Ok(true)
131 },
132 Commands::Daemon { command } => {
133 super::daemon::handle_daemon_command(command)?;
134 Ok(true)
135 },
136 Commands::Pair { label } => {
137 pair(label.as_deref())?;
138 Ok(true)
139 },
140 Commands::Qa { command } => {
141 handle_qa(command, config, cwd)?;
142 Ok(true)
143 },
144 Commands::Add { name } => {
145 crate::mcp::add_server(name).await?;
146 Ok(true)
147 },
148 Commands::Remove { name } => {
149 crate::mcp::remove_server(name).await?;
150 Ok(true)
151 },
152 Commands::Pr { command } => {
153 handle_pr(command)?;
154 Ok(true)
155 },
156 Commands::Mcp => {
157 show_mcp_servers();
158 Ok(true)
159 },
160 Commands::CloudSetup => {
161 let _ = crate::ollama::setup_cloud_interactive();
165 Ok(true)
166 },
167 Commands::Chat => Ok(false), Commands::Run { .. } => Ok(false), }
170}
171
172fn handle_qa(command: &QaCommand, config: &Config, cwd: &Path) -> Result<()> {
173 match command {
174 QaCommand::CompactSmoke { turns, format } => {
175 let report = match run_qa_compact_smoke(config, cwd, *turns) {
176 Ok(report) => report,
177 Err(err) => QaCompactSmokeReport::failed(cwd, *turns, err.to_string()),
178 };
179 print_qa_compact_report(&report, *format)?;
180 anyhow::ensure!(report.ok, "qa compact smoke failed");
181 Ok(())
182 },
183 }
184}
185
186#[derive(Debug, serde::Serialize)]
187struct DoctorReport {
188 ok: bool,
189 cwd: String,
190 active_model: Option<String>,
191 model_error: Option<String>,
192 model_capabilities: Option<DoctorModelCapabilities>,
193 safety_mode: String,
194 checkpoint_on_mutation: bool,
195 prompt_customized: bool,
196 ollama: DoctorCheck,
197 remote_providers: Vec<String>,
198 project_instructions: DoctorCheck,
199 tools: Vec<String>,
200 runtime: DoctorRuntime,
201 next_steps: Vec<String>,
202}
203
204#[derive(Debug, serde::Serialize)]
205struct DoctorModelCapabilities {
206 provider: String,
207 name: String,
208 supports_tools: bool,
209 supports_vision: bool,
210 reasoning: String,
211 max_context_tokens: Option<usize>,
212}
213
214#[derive(Debug, serde::Serialize)]
215struct DoctorCheck {
216 status: &'static str,
217 message: String,
218}
219
220#[derive(Debug, serde::Serialize)]
221struct DoctorRuntime {
222 daemon: DoctorCheck,
223 local_store: DoctorCheck,
224}
225
226async fn show_doctor(
227 config: &Config,
228 cwd: &Path,
229 cli_model: Option<&str>,
230 format: OutputFormat,
231) -> Result<()> {
232 let active_model_result = crate::app::resolve_model_id(cli_model, config).await;
233 let (active_model, model_error, model_capabilities) = match active_model_result {
234 Ok(model) => {
235 let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(&model);
236 (
237 Some(model),
238 None,
239 Some(DoctorModelCapabilities {
240 provider: snapshot.provider,
241 name: snapshot.model,
242 supports_tools: snapshot.supports_tools,
243 supports_vision: snapshot.supports_vision,
244 reasoning: snapshot.reasoning,
245 max_context_tokens: snapshot.max_context_tokens,
246 }),
247 )
248 },
249 Err(err) => (None, Some(err.to_string()), None),
250 };
251
252 let ollama_models = if is_ollama_installed() {
253 list_ollama_models(config).await
254 } else {
255 Vec::new()
256 };
257 let ollama = if !is_ollama_installed() {
258 DoctorCheck {
259 status: "warning",
260 message: "Ollama is not installed; remote providers can still work if configured."
261 .to_string(),
262 }
263 } else if ollama_models.is_empty() {
264 DoctorCheck {
265 status: "warning",
266 message: "Ollama is installed but no local/cloud models were listed.".to_string(),
267 }
268 } else {
269 DoctorCheck {
270 status: "ok",
271 message: format!("Ollama reachable with {} models.", ollama_models.len()),
272 }
273 };
274
275 let remote_providers = configured_remote_providers(config);
276 let instruction_paths = crate::app::instructions::find_instruction_files(cwd);
277 let project_instructions = if instruction_paths.is_empty() {
278 DoctorCheck {
279 status: "info",
280 message: "No AGENTS.md or MERMAID.md found.".to_string(),
281 }
282 } else if let Some(loaded) = crate::app::instructions::load_from_paths(&instruction_paths) {
283 DoctorCheck {
284 status: "ok",
285 message: format!(
286 "{} bytes loaded from {} source(s){}.",
287 loaded.byte_len,
288 loaded.sources.len(),
289 if loaded.truncated { " (truncated)" } else { "" }
290 ),
291 }
292 } else {
293 DoctorCheck {
294 status: "warning",
295 message: "Instruction files were found but could not be loaded.".to_string(),
296 }
297 };
298
299 let daemon = match RuntimeClient::daemon().health() {
300 Ok(read) => DoctorCheck {
301 status: "ok",
302 message: format!("daemon attached; database {}", read.value.database),
303 },
304 Err(err) => DoctorCheck {
305 status: "info",
306 message: format!("daemon not attached; CLI will use local runtime store ({err})"),
307 },
308 };
309 let local_store = match RuntimeClient::local().health() {
310 Ok(read) => DoctorCheck {
311 status: "ok",
312 message: format!("local runtime store ready at {}", read.value.database),
313 },
314 Err(err) => DoctorCheck {
315 status: "warning",
316 message: format!("local runtime store unavailable: {err}"),
317 },
318 };
319
320 let mut tools = vec![
321 "read/edit/write files".to_string(),
322 "run shell commands".to_string(),
323 "create checkpoints before risky mutations".to_string(),
324 ];
325 if std::env::var("OLLAMA_API_KEY").is_ok() {
326 tools.push("web search/fetch tools gated by OLLAMA_API_KEY".to_string());
327 }
328 if !config.mcp_servers.is_empty() {
329 tools.push(format!(
330 "{} configured MCP server(s)",
331 config.mcp_servers.len()
332 ));
333 }
334
335 let mut next_steps = Vec::new();
336 if active_model.is_none() {
337 next_steps.push(
338 "Pick a model with `mermaid --model <provider/model>` or run `mermaid list`."
339 .to_string(),
340 );
341 }
342 if remote_providers.is_empty() && ollama_models.is_empty() {
343 next_steps.push(
344 "Install or start Ollama, pull a model, or set a remote provider API key.".to_string(),
345 );
346 }
347 if instruction_paths.is_empty() {
348 next_steps.push("Optional: add MERMAID.md or AGENTS.md with project-specific run commands and conventions.".to_string());
349 }
350 if next_steps.is_empty() {
351 next_steps.push(
352 "Start Mermaid with `mermaid` or run one prompt with `mermaid run \"...\"`."
353 .to_string(),
354 );
355 }
356
357 let ok = active_model.is_some()
358 && local_store.status != "warning"
359 && (ollama.status == "ok" || !remote_providers.is_empty());
360 let report = DoctorReport {
361 ok,
362 cwd: cwd.display().to_string(),
363 active_model,
364 model_error,
365 model_capabilities,
366 safety_mode: safety_mode_name(config.safety.mode).to_string(),
367 checkpoint_on_mutation: config.safety.checkpoint_on_mutation,
368 prompt_customized: config.prompt.is_customized(),
369 ollama,
370 remote_providers,
371 project_instructions,
372 tools,
373 runtime: DoctorRuntime {
374 daemon,
375 local_store,
376 },
377 next_steps,
378 };
379 print_doctor_report(&report, format)
380}
381
382fn print_doctor_report(report: &DoctorReport, format: OutputFormat) -> Result<()> {
383 match format {
384 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
385 OutputFormat::Markdown => {
386 println!("# Mermaid Doctor\n");
387 print_doctor_text(report);
388 },
389 OutputFormat::Text => print_doctor_text(report),
390 }
391 Ok(())
392}
393
394fn print_doctor_text(report: &DoctorReport) {
395 println!(
396 "Mermaid Doctor: {}",
397 if report.ok {
398 "ready"
399 } else {
400 "needs attention"
401 }
402 );
403 println!("Project: {}", report.cwd);
404 match (&report.active_model, &report.model_error) {
405 (Some(model), _) => println!(" [OK] Active model: {model}"),
406 (None, Some(error)) => println!(" [WARNING] Active model: {error}"),
407 _ => println!(" [WARNING] Active model: unresolved"),
408 }
409 if let Some(caps) = &report.model_capabilities {
410 println!(
411 " provider={} tools={} vision={} reasoning={} context={}",
412 caps.provider,
413 caps.supports_tools,
414 caps.supports_vision,
415 caps.reasoning,
416 caps.max_context_tokens
417 .map(|n| n.to_string())
418 .unwrap_or_else(|| "unknown".to_string())
419 );
420 }
421 println!(
422 " [{}] Ollama: {}",
423 label(report.ollama.status),
424 report.ollama.message
425 );
426 println!(
427 " [INFO] Remote providers: {}",
428 if report.remote_providers.is_empty() {
429 "none configured".to_string()
430 } else {
431 report.remote_providers.join(", ")
432 }
433 );
434 println!(
435 " [{}] Project instructions: {}",
436 label(report.project_instructions.status),
437 report.project_instructions.message
438 );
439 println!(
440 " [INFO] Safety: mode={}, checkpoint_on_mutation={}",
441 report.safety_mode, report.checkpoint_on_mutation
442 );
443 println!(
444 " [INFO] Prompt customization: {}",
445 if report.prompt_customized {
446 "active"
447 } else {
448 "default"
449 }
450 );
451 println!(
452 " [{}] Runtime daemon: {}",
453 label(report.runtime.daemon.status),
454 report.runtime.daemon.message
455 );
456 println!(
457 " [{}] Runtime store: {}",
458 label(report.runtime.local_store.status),
459 report.runtime.local_store.message
460 );
461 println!(" [OK] Tool surface:");
462 for tool in &report.tools {
463 println!(" - {tool}");
464 }
465 println!("\nNext steps:");
466 for step in &report.next_steps {
467 println!(" - {step}");
468 }
469}
470
471#[derive(Debug, serde::Serialize)]
472struct SelfTestReport {
473 ok: bool,
474 workspace: String,
475 checks: Vec<String>,
476 compact_smoke: QaCompactSmokeReport,
477 runtime_store: DoctorCheck,
478 kept_workspace: bool,
479}
480
481fn run_self_test(config: &Config, format: OutputFormat, keep_workspace: bool) -> Result<()> {
482 let workspace = std::env::temp_dir().join(format!("mermaid-self-test-{}", fresh_qa_id()));
483 std::fs::create_dir_all(&workspace)
484 .with_context(|| format!("failed to create {}", workspace.display()))?;
485
486 let compact_smoke = match run_qa_compact_smoke(config, &workspace, 6) {
487 Ok(report) => report,
488 Err(err) => QaCompactSmokeReport::failed(&workspace, 6, err.to_string()),
489 };
490 let runtime_store = match RuntimeClient::local().health() {
491 Ok(read) => DoctorCheck {
492 status: "ok",
493 message: format!("local runtime store ready at {}", read.value.database),
494 },
495 Err(err) => DoctorCheck {
496 status: "warning",
497 message: err.to_string(),
498 },
499 };
500
501 let checks = vec![
502 "compact smoke exercises reducer compaction path".to_string(),
503 "compact smoke persists conversation and archive artifacts".to_string(),
504 "local runtime store opens without daemon".to_string(),
505 ];
506 let ok = compact_smoke.ok && runtime_store.status == "ok";
507 let report = SelfTestReport {
508 ok,
509 workspace: workspace.display().to_string(),
510 checks,
511 compact_smoke,
512 runtime_store,
513 kept_workspace: keep_workspace,
514 };
515
516 print_self_test_report(&report, format)?;
517 if !keep_workspace {
518 let _ = std::fs::remove_dir_all(&workspace);
519 }
520 anyhow::ensure!(report.ok, "mermaid self-test failed");
521 Ok(())
522}
523
524fn print_self_test_report(report: &SelfTestReport, format: OutputFormat) -> Result<()> {
525 match format {
526 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
527 OutputFormat::Markdown => {
528 println!("# Mermaid Self-Test\n");
529 print_self_test_text(report);
530 },
531 OutputFormat::Text => print_self_test_text(report),
532 }
533 Ok(())
534}
535
536fn print_self_test_text(report: &SelfTestReport) {
537 println!(
538 "Mermaid self-test: {}",
539 if report.ok { "ok" } else { "failed" }
540 );
541 println!("workspace: {}", report.workspace);
542 println!(
543 "compact smoke: {}",
544 if report.compact_smoke.ok {
545 "ok"
546 } else {
547 "failed"
548 }
549 );
550 println!("runtime store: {}", report.runtime_store.message);
551 println!("checks:");
552 for check in &report.checks {
553 println!(" - {check}");
554 }
555 if !report.ok
556 && let Some(failure) = &report.compact_smoke.failure
557 {
558 println!("failure: {failure}");
559 }
560}
561
562fn label(status: &str) -> &'static str {
563 match status {
564 "ok" => "OK",
565 "warning" => "WARNING",
566 "error" => "ERROR",
567 _ => "INFO",
568 }
569}
570
571fn safety_mode_name(mode: crate::runtime::SafetyMode) -> &'static str {
572 mode.as_str()
573}
574
575fn configured_remote_providers(config: &Config) -> Vec<String> {
576 let mut names = Vec::new();
577 for profile in PROVIDER_REGISTRY {
578 let env = config
579 .providers
580 .get(profile.name)
581 .and_then(|c| c.api_key_env.as_deref())
582 .unwrap_or(profile.api_key_env);
583 if resolve_api_key(env, None).is_some() {
584 names.push(profile.name.to_string());
585 }
586 }
587 for (name, provider) in &config.providers {
588 if names.iter().any(|existing| existing == name) {
589 continue;
590 }
591 if let Some(env) = provider.api_key_env.as_deref()
592 && resolve_api_key(env, None).is_some()
593 {
594 names.push(name.clone());
595 }
596 }
597 names.sort();
598 names
599}
600
601#[derive(Debug, serde::Serialize)]
602struct QaCompactSmokeReport {
603 ok: bool,
604 turns: usize,
605 archived_messages: usize,
606 preserved_messages: usize,
607 replacement_messages: usize,
608 conversation_path: Option<String>,
609 archive_path: Option<String>,
610 checks: Vec<String>,
611 failure: Option<String>,
612}
613
614impl QaCompactSmokeReport {
615 fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
616 Self {
617 ok: false,
618 turns,
619 archived_messages: 0,
620 preserved_messages: 0,
621 replacement_messages: 0,
622 conversation_path: Some(
623 cwd.join(".mermaid")
624 .join("conversations")
625 .display()
626 .to_string(),
627 ),
628 archive_path: None,
629 checks: Vec::new(),
630 failure: Some(failure),
631 }
632 }
633}
634
635fn run_qa_compact_smoke(
636 config: &Config,
637 cwd: &Path,
638 requested_turns: usize,
639) -> Result<QaCompactSmokeReport> {
640 let turns = requested_turns.max(3);
641 let mut state = State::new(config.clone(), cwd.to_path_buf(), qa_model_id(config));
642 for message in synthetic_compaction_messages(turns) {
643 state.session.append(message);
644 }
645
646 let (state_after_slash, compact_cmds) = update(
647 state,
648 Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
649 );
650 let turn = state_after_slash
651 .turn
652 .id()
653 .context("manual compaction did not enter a compaction turn")?;
654 let request = compact_cmds
655 .iter()
656 .find_map(|cmd| match cmd {
657 Cmd::CompactConversation { request, .. } => Some(request.clone()),
658 _ => None,
659 })
660 .context("manual compaction did not emit a CompactConversation command")?;
661
662 let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
663 let prepared = prepare_compaction(&request, Some(100_000))
664 .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
665 anyhow::ensure!(
666 !prepared.archived_messages.is_empty(),
667 "compaction archived no messages"
668 );
669 anyhow::ensure!(
670 !prepared.preserved_messages.is_empty(),
671 "compaction preserved no messages"
672 );
673
674 let summary = deterministic_compaction_summary(&prepared, turns);
675 let mut record = CompactionRecord {
676 id: format!("qa_compact_{}", fresh_qa_id()),
677 trigger: CompactionTrigger::Manual,
678 created_at: chrono::Local::now(),
679 before_tokens: before_snapshot.used_tokens,
680 after_tokens: 0,
681 archived_message_count: prepared.archived_messages.len(),
682 preserved_message_count: prepared.preserved_messages.len(),
683 summary_tokens: summary.len().div_ceil(4),
684 duration_secs: 0.0,
685 verified: true,
686 verification_error: None,
687 focus: Some("qa compact smoke".to_string()),
688 archive_path: None,
689 };
690 let mut replacement = build_replacement_messages(&summary, &prepared, &record);
691 let mut after_chat: ChatRequest = request.chat.clone();
692 after_chat.messages = replacement.clone();
693 let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
694 record.after_tokens = after_snapshot.used_tokens;
695 replacement = build_replacement_messages(&summary, &prepared, &record);
696 after_chat.messages = replacement.clone();
697 after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
698
699 let result = CompactionResult {
700 record,
701 replacement_messages: replacement,
702 archived_messages: prepared.archived_messages,
703 before_snapshot,
704 after_snapshot,
705 usage: None,
706 };
707 let (final_state, save_cmds) =
708 update(state_after_slash, Msg::CompactionFinished { turn, result });
709
710 let manager = ConversationManager::new(cwd)?;
711 let mut conversation_path = None;
712 let mut archive_path = None;
713 for cmd in save_cmds {
714 match cmd {
715 Cmd::SaveConversation(conversation) => {
716 manager.save_conversation(&conversation)?;
717 conversation_path = Some(
718 manager
719 .conversations_dir()
720 .join(format!("{}.json", conversation.id))
721 .display()
722 .to_string(),
723 );
724 },
725 Cmd::SaveCompactionArchive {
726 archive,
727 conversation,
728 ..
729 } => {
730 archive_path = Some(
734 manager
735 .save_compaction_archive(&archive)?
736 .display()
737 .to_string(),
738 );
739 manager.save_conversation(&conversation)?;
740 conversation_path = Some(
741 manager
742 .conversations_dir()
743 .join(format!("{}.json", conversation.id))
744 .display()
745 .to_string(),
746 );
747 },
748 _ => {},
749 }
750 }
751
752 let conversation_path = conversation_path.context("compaction did not save conversation")?;
753 let archive_path = archive_path.context("compaction did not save archive")?;
754 let messages = final_state.session.messages();
755 let compactions = &final_state.session.conversation.compactions;
756
757 let mut checks = Vec::new();
758 anyhow::ensure!(
759 !compactions.is_empty(),
760 "conversation did not record compaction metadata"
761 );
762 checks.push("conversation records compaction metadata".to_string());
763 anyhow::ensure!(
764 messages
765 .first()
766 .is_some_and(|msg| msg.kind == crate::models::ChatMessageKind::ContextCheckpoint),
767 "replacement does not start with a context checkpoint"
768 );
769 checks.push("replacement starts with context checkpoint".to_string());
770 anyhow::ensure!(
771 std::path::Path::new(&conversation_path).exists(),
772 "conversation file missing after save"
773 );
774 checks.push("conversation file saved".to_string());
775 anyhow::ensure!(
776 std::path::Path::new(&archive_path).exists(),
777 "compaction archive file missing after save"
778 );
779 checks.push("archive file saved".to_string());
780 anyhow::ensure!(
781 compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
782 "compaction did not archive and preserve messages"
783 );
784 checks.push("archived and preserved message counts are non-zero".to_string());
785
786 Ok(QaCompactSmokeReport {
787 ok: true,
788 turns,
789 archived_messages: compactions[0].archived_message_count,
790 preserved_messages: compactions[0].preserved_message_count,
791 replacement_messages: messages.len(),
792 conversation_path: Some(conversation_path),
793 archive_path: Some(archive_path),
794 checks,
795 failure: None,
796 })
797}
798
799fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
800 match format {
801 OutputFormat::Json => {
802 println!("{}", serde_json::to_string_pretty(report)?);
803 },
804 OutputFormat::Text => {
805 println!(
806 "qa compact smoke: {}",
807 if report.ok { "ok" } else { "failed" }
808 );
809 println!("turns: {}", report.turns);
810 println!("archived messages: {}", report.archived_messages);
811 println!("preserved messages: {}", report.preserved_messages);
812 println!("replacement messages: {}", report.replacement_messages);
813 if let Some(path) = &report.conversation_path {
814 println!("conversation: {path}");
815 }
816 if let Some(path) = &report.archive_path {
817 println!("archive: {path}");
818 }
819 if let Some(failure) = &report.failure {
820 println!("failure: {failure}");
821 }
822 },
823 OutputFormat::Markdown => {
824 println!(
825 "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
826 if report.ok { "ok" } else { "failed" },
827 report.turns,
828 report.archived_messages,
829 report.preserved_messages,
830 report.replacement_messages
831 );
832 if let Some(path) = &report.conversation_path {
833 println!("- Conversation: `{path}`");
834 }
835 if let Some(path) = &report.archive_path {
836 println!("- Archive: `{path}`");
837 }
838 if let Some(failure) = &report.failure {
839 println!("\nFailure: `{failure}`");
840 }
841 },
842 }
843 Ok(())
844}
845
846fn qa_model_id(config: &Config) -> String {
847 if let Some(model) = config
848 .last_used_model
849 .as_ref()
850 .filter(|value| !value.is_empty())
851 {
852 return model.clone();
853 }
854 if !config.default_model.name.is_empty() {
855 if config.default_model.provider.is_empty() {
856 return config.default_model.name.clone();
857 }
858 return format!(
859 "{}/{}",
860 config.default_model.provider, config.default_model.name
861 );
862 }
863 "qa/deterministic".to_string()
864}
865
866fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
867 let mut messages = Vec::with_capacity(turns.saturating_mul(2));
868 for idx in 1..=turns {
869 messages.push(ChatMessage::user(format!(
870 "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
871 )));
872 messages.push(ChatMessage::assistant(format!(
873 "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}."
874 )));
875 }
876 messages
877}
878
879fn deterministic_compaction_summary(
880 prepared: &crate::domain::PreparedCompaction,
881 turns: usize,
882) -> String {
883 format!(
884 "## 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.",
885 prepared.archived_messages.len(),
886 prepared.preserved_messages.len()
887 )
888}
889
890fn fresh_qa_id() -> u128 {
891 std::time::SystemTime::now()
892 .duration_since(std::time::UNIX_EPOCH)
893 .map(|duration| duration.as_nanos())
894 .unwrap_or_default()
895}
896
897fn show_tasks(limit: usize) -> Result<()> {
898 let read = RuntimeClient::auto().list_tasks(limit)?;
899 let mut tasks = read.value;
900 tasks.truncate(limit);
901 println!("Mermaid runtime tasks");
902 println!("Source: {}", read.source.as_str());
903 println!();
904 if tasks.is_empty() {
905 println!("No tasks recorded yet.");
906 return Ok(());
907 }
908 for task in tasks {
909 println!(
910 "{} [{}] {} {} {}",
911 task.id, task.status, task.priority, task.updated_at, task.title
912 );
913 println!(" project: {}", task.project_path);
914 println!(" model: {}", task.model_id);
915 }
916 Ok(())
917}
918
919fn show_task(id: &str) -> Result<()> {
920 let detail = RuntimeClient::auto().task_detail(id)?.value;
921 print_task_detail(&detail.task);
922 let events = detail.events;
923 if !events.is_empty() {
924 println!();
925 println!("Timeline:");
926 for event in events {
927 println!(" {} {} {}", event.created_at, event.kind, event.message);
928 }
929 }
930 Ok(())
931}
932
933fn print_task_detail(task: &TaskRecord) {
934 println!("Task: {}", task.id);
935 println!("Title: {}", task.title);
936 println!("Status: {}", task.status);
937 println!("Priority: {}", task.priority);
938 println!("Project: {}", task.project_path);
939 println!("Model: {}", task.model_id);
940 if let Some(conversation_id) = &task.conversation_id {
941 println!("Conversation: {}", conversation_id);
942 }
943 println!("Created: {}", task.created_at);
944 println!("Updated: {}", task.updated_at);
945 if let Some(report) = &task.final_report {
946 println!();
947 println!("Final report:");
948 println!("{}", report);
949 }
950}
951
952fn show_processes(limit: usize) -> Result<()> {
953 let read = RuntimeClient::auto().list_processes(limit)?;
954 let mut processes = read.value;
955 processes.truncate(limit);
956 println!("Mermaid runtime processes");
957 println!("Source: {}", read.source.as_str());
958 println!();
959 if processes.is_empty() {
960 println!("No processes recorded yet.");
961 return Ok(());
962 }
963 for process in processes {
964 println!(
965 "{} pid={} status={} {}",
966 process.id,
967 process.pid,
968 process.status.as_str(),
969 process.command
970 );
971 if let Some(task_id) = process.task_id {
972 println!(" task: {}", task_id);
973 }
974 if let Some(cwd) = process.cwd {
975 println!(" cwd: {}", cwd);
976 }
977 if let Some(log_path) = process.log_path {
978 println!(" log: {}", log_path);
979 }
980 if let Some(url) = process.detected_url {
981 println!(" url: {}", url);
982 }
983 }
984 Ok(())
985}
986
987async fn show_models(config: &Config) -> Result<()> {
988 list_models(config).await?;
989 probe_configured_provider_models(config).await?;
990 let store = RuntimeStore::open_default()?;
991 let probes = store.provider_probes().list(None, None)?;
992 if !probes.is_empty() {
993 println!("\nCached capability probes:");
994 for probe in probes {
995 println!(
996 " - {}/{} {}={} ({})",
997 probe.provider,
998 probe.model_id,
999 probe.capability_key,
1000 probe.capability_value,
1001 probe.confidence
1002 );
1003 }
1004 }
1005 Ok(())
1006}
1007
1008fn show_model_info(model: &str) -> Result<()> {
1009 let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(model);
1010 let store = RuntimeStore::open_default()?;
1011 let provider = snapshot.provider.clone();
1012 for (key, value) in [
1013 ("supports_tools", snapshot.supports_tools.to_string()),
1014 ("supports_vision", snapshot.supports_vision.to_string()),
1015 ("reasoning", snapshot.reasoning.clone()),
1016 (
1017 "max_context_tokens",
1018 snapshot
1019 .max_context_tokens
1020 .map(|n| n.to_string())
1021 .unwrap_or_else(|| "unknown".to_string()),
1022 ),
1023 ] {
1024 let _ = store.provider_probes().upsert(NewProviderProbe {
1025 provider: provider.clone(),
1026 model_id: snapshot.model.clone(),
1027 capability_key: key.to_string(),
1028 capability_value: value,
1029 confidence: "static".to_string(),
1030 error: None,
1031 });
1032 }
1033 println!("Model: {}", model);
1034 println!("Provider: {}", snapshot.provider);
1035 println!("Name: {}", snapshot.model);
1036 println!("Supports tools: {}", snapshot.supports_tools);
1037 println!("Supports vision: {}", snapshot.supports_vision);
1038 println!("Reasoning: {}", snapshot.reasoning);
1039 println!(
1040 "Context: {}",
1041 snapshot
1042 .max_context_tokens
1043 .map(|n| n.to_string())
1044 .unwrap_or_else(|| "unknown".to_string())
1045 );
1046 if let Some(profile) = lookup_provider(&snapshot.provider) {
1047 for (key, value) in [
1048 (
1049 "max_output_tokens_param",
1050 format!("{:?}", profile.max_tokens_param),
1051 ),
1052 (
1053 "parallel_tool_calls",
1054 (!profile
1055 .disable_parallel_tool_calls_for
1056 .iter()
1057 .any(|disabled| *disabled == snapshot.model))
1058 .to_string(),
1059 ),
1060 (
1061 "reasoning_parameter_shape",
1062 format!("{:?}", profile.reasoning_strategy),
1063 ),
1064 (
1065 "streaming_usage_available",
1066 "provider_dependent".to_string(),
1067 ),
1068 ("token_usage_field_shape", "openai_compatible".to_string()),
1069 ] {
1070 let _ = store.provider_probes().upsert(NewProviderProbe {
1071 provider: provider.clone(),
1072 model_id: snapshot.model.clone(),
1073 capability_key: key.to_string(),
1074 capability_value: value,
1075 confidence: "static".to_string(),
1076 error: None,
1077 });
1078 }
1079 println!("Token budget field: {:?}", profile.max_tokens_param);
1080 println!(
1081 "Single-tool-call models: {}",
1082 if profile.disable_parallel_tool_calls_for.is_empty() {
1083 "(none)".to_string()
1084 } else {
1085 profile.disable_parallel_tool_calls_for.join(", ")
1086 }
1087 );
1088 }
1089 Ok(())
1090}
1091
1092async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1093 let client = reqwest::Client::builder()
1094 .timeout(std::time::Duration::from_secs(5))
1095 .build()?;
1096 for profile in PROVIDER_REGISTRY {
1097 let user_cfg = config.providers.get(profile.name);
1098 let env = user_cfg
1099 .and_then(|c| c.api_key_env.as_deref())
1100 .unwrap_or(profile.api_key_env);
1101 let Some(api_key) = resolve_api_key(env, None) else {
1102 continue;
1103 };
1104 let base_url = user_cfg
1105 .and_then(|c| c.base_url.clone())
1106 .unwrap_or_else(|| profile.base_url.to_string());
1107 let url = format!("{}/models", base_url.trim_end_matches('/'));
1108 let mut request = client.get(&url).bearer_auth(api_key);
1109 for (name, value) in profile.extra_headers {
1110 request = request.header(*name, *value);
1111 }
1112 if let Some(user_cfg) = user_cfg {
1113 for (name, value) in &user_cfg.extra_headers {
1114 request = request.header(name, value);
1115 }
1116 }
1117
1118 let result = request.send().await;
1119 match result {
1120 Ok(response) if response.status().is_success() => {
1121 let status = response.status();
1122 let body: serde_json::Value = response.json().await.unwrap_or_default();
1123 let ids = body
1124 .get("data")
1125 .and_then(|v| v.as_array())
1126 .map(|items| {
1127 items
1128 .iter()
1129 .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1130 .map(str::to_string)
1131 .collect::<Vec<_>>()
1132 })
1133 .unwrap_or_default();
1134 record_provider_probe(
1135 profile.name,
1136 "*",
1137 "models_availability",
1138 &format!("available:{}:{}", status.as_u16(), ids.len()),
1139 "probed",
1140 None,
1141 );
1142 for model_id in ids.into_iter().take(200) {
1143 record_provider_probe(
1144 profile.name,
1145 &model_id,
1146 "model_listed",
1147 "true",
1148 "listed",
1149 None,
1150 );
1151 }
1152 },
1153 Ok(response) => {
1154 record_provider_probe(
1155 profile.name,
1156 "*",
1157 "models_availability",
1158 "failed",
1159 "failed",
1160 Some(format!("HTTP {}", response.status().as_u16())),
1161 );
1162 },
1163 Err(error) => {
1164 record_provider_probe(
1165 profile.name,
1166 "*",
1167 "models_availability",
1168 "failed",
1169 "failed",
1170 Some(error.to_string()),
1171 );
1172 },
1173 }
1174 }
1175 Ok(())
1176}
1177
1178fn record_provider_probe(
1179 provider: &str,
1180 model_id: &str,
1181 key: &str,
1182 value: &str,
1183 confidence: &str,
1184 error: Option<String>,
1185) {
1186 if let Ok(store) = RuntimeStore::open_default() {
1187 let _ = store.provider_probes().upsert(NewProviderProbe {
1188 provider: provider.to_string(),
1189 model_id: model_id.to_string(),
1190 capability_key: key.to_string(),
1191 capability_value: value.to_string(),
1192 confidence: confidence.to_string(),
1193 error,
1194 });
1195 }
1196}
1197
1198fn show_approvals() -> Result<()> {
1199 let approvals = RuntimeClient::auto().list_approvals()?.value;
1200 if approvals.is_empty() {
1201 println!("No pending approvals.");
1202 return Ok(());
1203 }
1204 for approval in approvals {
1205 println!(
1206 "{} [{} -> {}] {}",
1207 approval.id,
1208 approval.risk_classification,
1209 approval.policy_decision,
1210 approval.proposed_action
1211 );
1212 if let Some(args) = approval.args_summary {
1213 println!(" args: {}", args);
1214 }
1215 if let Some(checkpoint_id) = approval.checkpoint_id {
1216 println!(" checkpoint: {}", checkpoint_id);
1217 }
1218 if approval.pending_action_json.is_some() {
1219 println!(" pending action: recorded");
1220 }
1221 }
1222 Ok(())
1223}
1224
1225fn approve(id: &str) -> Result<()> {
1226 let result = RuntimeClient::auto().approve(id)?;
1227 println!("Approved {}", id);
1228 if result.replayed {
1229 println!("{}", result.summary);
1230 }
1231 Ok(())
1232}
1233
1234fn deny(id: &str) -> Result<()> {
1235 let _ = RuntimeClient::auto().deny(id)?;
1236 println!("Denied {}", id);
1237 Ok(())
1238}
1239
1240fn show_tool_runs(limit: usize) -> Result<()> {
1241 let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1242 runs.truncate(limit);
1243 if runs.is_empty() {
1244 println!("No tool runs recorded yet.");
1245 return Ok(());
1246 }
1247 for run in runs {
1248 println!(
1249 "{} [{}] {} started {}",
1250 run.id, run.status, run.tool_name, run.started_at
1251 );
1252 if let Some(turn_id) = run.turn_id {
1253 println!(" turn: {}", turn_id);
1254 }
1255 if let Some(call_id) = run.call_id {
1256 println!(" call: {}", call_id);
1257 }
1258 if let Some(finished_at) = run.finished_at {
1259 println!(" finished: {}", finished_at);
1260 }
1261 }
1262 Ok(())
1263}
1264
1265fn show_checkpoints(limit: usize) -> Result<()> {
1266 let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1267 checkpoints.truncate(limit);
1268 if checkpoints.is_empty() {
1269 println!("No checkpoints recorded yet.");
1270 return Ok(());
1271 }
1272 for checkpoint in checkpoints {
1273 println!(
1274 "{} {} {}",
1275 checkpoint.id, checkpoint.created_at, checkpoint.project_path
1276 );
1277 println!(" snapshot: {}", checkpoint.snapshot_path);
1278 println!(" files: {}", checkpoint.changed_files_json);
1279 if let Some(approval_id) = checkpoint.approval_id {
1280 println!(" approval: {}", approval_id);
1281 }
1282 }
1283 Ok(())
1284}
1285
1286fn restore_checkpoint(id: &str) -> Result<()> {
1287 let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
1288 println!("Restored {} ({} files)", manifest.id, manifest.files.len());
1289 if let Some(repo) = manifest.shadow_git_repo {
1290 println!("Shadow repo: {}", repo);
1291 }
1292 if let Some(commit) = manifest.shadow_git_commit {
1293 println!("Shadow commit: {}", commit);
1294 }
1295 if let Some(action) = manifest.pending_action {
1296 println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
1297 }
1298 Ok(())
1299}
1300
1301fn handle_plugin(command: &PluginCommand) -> Result<()> {
1302 match command {
1303 PluginCommand::Install { path } => {
1304 let preview = crate::runtime::plugin_permission_preview(path)?;
1305 print_plugin_permission_preview(&preview);
1306 let record = crate::runtime::install_plugin_from_path(path)?;
1307 println!("Installed plugin {} ({})", record.name, record.id);
1308 },
1309 PluginCommand::List => {
1310 let plugins = RuntimeClient::auto().list_plugins()?.value;
1311 if plugins.is_empty() {
1312 println!("No plugins installed.");
1313 } else {
1314 for plugin in plugins {
1315 println!(
1316 "{} [{}] {} ({})",
1317 plugin.id,
1318 if plugin.enabled {
1319 "enabled"
1320 } else {
1321 "disabled"
1322 },
1323 plugin.name,
1324 plugin.source
1325 );
1326 }
1327 }
1328 },
1329 PluginCommand::Enable { id } => {
1330 RuntimeClient::auto().set_plugin_enabled(id, true)?;
1331 println!("Enabled plugin {}", id);
1332 },
1333 PluginCommand::Disable { id } => {
1334 RuntimeClient::auto().set_plugin_enabled(id, false)?;
1335 println!("Disabled plugin {}", id);
1336 },
1337 PluginCommand::Audit { path } => {
1338 let manifest_path = if path.is_dir() {
1339 path.join("plugin.toml")
1340 } else {
1341 path.clone()
1342 };
1343 let raw = std::fs::read_to_string(&manifest_path)?;
1344 let manifest: crate::runtime::PluginManifest = toml::from_str(&raw)?;
1345 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1346 crate::runtime::validate_plugin_manifest(&manifest, root)?;
1347 let preview = crate::runtime::plugin_permission_preview(path)?;
1348 println!("Plugin manifest is valid: {}", manifest.name);
1349 print_plugin_permission_preview(&preview);
1350 },
1351 }
1352 Ok(())
1353}
1354
1355fn print_plugin_permission_preview(preview: &crate::runtime::PluginPermissionPreview) {
1356 println!("Permission preview for plugin {}", preview.name);
1357 if preview.declared_permissions.is_empty() && preview.permissions_toml.is_none() {
1358 println!(" permissions: (none declared)");
1359 } else {
1360 if !preview.declared_permissions.is_empty() {
1361 println!(" declared: {}", preview.declared_permissions.join(", "));
1362 }
1363 if let Some(value) = &preview.permissions_toml {
1364 println!(
1365 " permissions.toml: {}",
1366 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
1367 );
1368 }
1369 }
1370 if !preview.hooks.is_empty() {
1371 println!(" hooks: {}", preview.hooks.join(", "));
1372 }
1373 if !preview.mcp.is_empty() {
1374 println!(" mcp: {}", preview.mcp.join(", "));
1375 }
1376 if !preview.bin.is_empty() {
1377 println!(" bin: {}", preview.bin.join(", "));
1378 }
1379}
1380
1381fn pair(label: Option<&str>) -> Result<()> {
1382 let (token, hash) = crate::runtime::generate_pairing_token()?;
1383 let record = RuntimeStore::open_default()?
1384 .pairing_tokens()
1385 .create(&hash, label)?;
1386 println!("Pairing token id: {}", record.id);
1387 println!("Pairing token: {}", token);
1388 println!(
1389 "Use with daemon JSON by setting {}.",
1390 crate::runtime::daemon::DAEMON_TOKEN_ENV
1391 );
1392 println!("Store this now; Mermaid will not print it again.");
1393 Ok(())
1394}
1395
1396fn show_logs(id: &str) -> Result<()> {
1397 let content = RuntimeClient::auto().process_log(id, None)?.content;
1398 print!("{}", content);
1399 Ok(())
1400}
1401
1402fn stop_process(id: &str) -> Result<()> {
1403 let process = RuntimeClient::auto().stop_process(id)?.item;
1404 println!("Stopped process {} (pid {})", id, process.pid);
1405 Ok(())
1406}
1407
1408fn restart_process(id: &str) -> Result<()> {
1409 let process = RuntimeClient::auto().restart_process(id)?.item;
1410 println!("Restarted process {} (pid {})", id, process.pid);
1411 Ok(())
1412}
1413
1414fn open_target(target: &str) -> Result<()> {
1415 if RuntimeClient::auto().open_process(target).is_err() {
1416 crate::utils::open_file(target);
1417 }
1418 Ok(())
1419}
1420
1421fn show_ports() -> Result<()> {
1422 print!("{}", RuntimeClient::auto().ports()?.ports);
1423 Ok(())
1424}
1425
1426pub async fn list_models(config: &Config) -> Result<()> {
1428 let ollama_models = list_ollama_models(config).await;
1429 if ollama_models.is_empty() {
1430 println!("No Ollama models installed locally.");
1431 } else {
1432 println!("Ollama models (local/cloud):");
1433 for name in &ollama_models {
1434 println!(" - ollama/{}", name);
1435 }
1436 }
1437
1438 println!("\nConfigured remote providers:");
1439 let mut any = false;
1440 for profile in PROVIDER_REGISTRY {
1441 let env = config
1442 .providers
1443 .get(profile.name)
1444 .and_then(|c| c.api_key_env.as_deref())
1445 .unwrap_or(profile.api_key_env);
1446 if resolve_api_key(env, None).is_some() {
1447 any = true;
1448 println!(" - {} (via ${})", profile.name, env);
1449 }
1450 }
1451 if !any {
1452 println!(" (none — set a provider API key env var to enable)");
1453 }
1454 println!("\nSwitch models in-session with /model <name>.");
1455 Ok(())
1456}
1457
1458async fn list_ollama_models(config: &Config) -> Vec<String> {
1462 use crate::models::adapters::ollama::OllamaAdapter;
1463 let backend = BackendConfig {
1464 ollama_url: format!("http://{}:{}", config.ollama.host, config.ollama.port),
1465 timeout_secs: 5,
1466 max_idle_per_host: 2,
1467 };
1468 match OllamaAdapter::new("__list__", Arc::new(backend)).await {
1469 Ok(adapter) => adapter.list_models().await.unwrap_or_default(),
1470 Err(_) => Vec::new(),
1471 }
1472}
1473
1474pub fn show_version() {
1476 println!("Mermaid v{}", env!("CARGO_PKG_VERSION"));
1477 println!(" An open-source, model-agnostic AI pair programmer");
1478}
1479
1480const RELEASE_LATEST_API: &str =
1481 "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
1482const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
1483const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
1484
1485async fn run_update(check: bool, force: bool) -> Result<()> {
1491 let current = env!("CARGO_PKG_VERSION");
1492 println!("Installed: v{current}");
1493
1494 let client = reqwest::Client::builder()
1495 .timeout(std::time::Duration::from_secs(15))
1496 .build()?;
1497 let resp = client
1498 .get(RELEASE_LATEST_API)
1499 .header("User-Agent", "mermaid-cli")
1500 .header("Accept", "application/vnd.github+json")
1501 .send()
1502 .await
1503 .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
1504 if !resp.status().is_success() {
1505 bail!("GitHub Releases API returned HTTP {}", resp.status());
1506 }
1507 let release: serde_json::Value = resp.json().await?;
1508 let tag = release
1509 .get("tag_name")
1510 .and_then(|v| v.as_str())
1511 .ok_or_else(|| anyhow!("release response had no tag_name"))?;
1512 println!("Latest: {tag}");
1513
1514 let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
1515 if check {
1516 if up_to_date {
1517 println!("You're on the latest version.");
1518 } else {
1519 println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
1520 }
1521 return Ok(());
1522 }
1523 if up_to_date && !force {
1524 println!("Already up to date.");
1525 return Ok(());
1526 }
1527
1528 let exe =
1530 std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
1531 let install_dir = exe
1532 .parent()
1533 .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
1534 println!("Updating {} …", install_dir.display());
1535 run_install_script(&client, install_dir).await?;
1536 println!("Updated. New version takes effect on the next run.");
1537 Ok(())
1538}
1539
1540async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
1543 let windows = cfg!(target_os = "windows");
1544 let url = if windows {
1545 INSTALL_PS1_URL
1546 } else {
1547 INSTALL_SH_URL
1548 };
1549 let script = client
1550 .get(url)
1551 .header("User-Agent", "mermaid-cli")
1552 .send()
1553 .await
1554 .map_err(|e| anyhow!("could not fetch install script: {e}"))?
1555 .error_for_status()?
1556 .text()
1557 .await?;
1558
1559 let ext = if windows { "ps1" } else { "sh" };
1560 let script_path =
1561 std::env::temp_dir().join(format!("mermaid-update-{}.{ext}", std::process::id()));
1562 std::fs::write(&script_path, script)
1563 .map_err(|e| anyhow!("could not stage install script: {e}"))?;
1564
1565 let mut cmd = if windows {
1566 let mut c = tokio::process::Command::new("powershell");
1567 c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
1568 c.arg(&script_path);
1569 c
1570 } else {
1571 let mut c = tokio::process::Command::new("sh");
1572 c.arg(&script_path);
1573 c
1574 };
1575 cmd.env("MERMAID_INSTALL_DIR", install_dir)
1576 .env("MERMAID_NO_MODIFY_PATH", "1");
1577
1578 let status = cmd
1579 .status()
1580 .await
1581 .map_err(|e| anyhow!("could not run install script: {e}"))?;
1582 let _ = std::fs::remove_file(&script_path);
1583 if !status.success() {
1584 bail!("install script exited with {:?}", status.code());
1585 }
1586 Ok(())
1587}
1588
1589fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
1591 let core = s.trim().trim_start_matches('v');
1592 let core = core.split(['-', '+']).next().unwrap_or(core);
1593 let mut parts = core.split('.');
1594 let major = parts.next()?.parse().ok()?;
1595 let minor = parts.next().unwrap_or("0").parse().ok()?;
1596 let patch = parts.next().unwrap_or("0").parse().ok()?;
1597 Some((major, minor, patch))
1598}
1599
1600fn version_at_least(current: &str, latest: &str) -> bool {
1604 match (parse_semver(current), parse_semver(latest)) {
1605 (Some(c), Some(l)) => c >= l,
1606 _ => current == latest,
1607 }
1608}
1609
1610fn show_mcp_servers() {
1612 let config = load_config().unwrap_or_default();
1613
1614 if config.mcp_servers.is_empty() {
1615 println!("No MCP servers configured.\n");
1616 println!("Add one with: mermaid add <name>");
1617 println!("Examples:");
1618 println!(" mermaid add context7 # Library documentation");
1619 println!(" mermaid add playwright # Browser automation");
1620 println!(" mermaid add memory # Persistent knowledge graph");
1621 return;
1622 }
1623
1624 println!("Configured MCP servers:\n");
1625 for (name, server_cfg) in &config.mcp_servers {
1626 let package = server_cfg
1627 .args
1628 .iter()
1629 .find(|a| !a.starts_with('-'))
1630 .unwrap_or(&server_cfg.command);
1631 let env_keys: Vec<&String> = server_cfg.env.keys().collect();
1632 let env_display = if env_keys.is_empty() {
1633 String::new()
1634 } else {
1635 format!(
1636 " (env: {})",
1637 env_keys
1638 .iter()
1639 .map(|k| k.as_str())
1640 .collect::<Vec<_>>()
1641 .join(", ")
1642 )
1643 };
1644 println!(" {} — {}{}", name, package, env_display);
1645 }
1646 println!("\nManage with: mermaid add <name> / mermaid remove <name>");
1647}
1648
1649async fn show_status(config: &Config) -> Result<()> {
1651 println!("Mermaid Status:");
1652 println!();
1653
1654 let mut available: Vec<&'static str> = Vec::new();
1657 for profile in PROVIDER_REGISTRY {
1658 let env = config
1659 .providers
1660 .get(profile.name)
1661 .and_then(|c| c.api_key_env.as_deref())
1662 .unwrap_or(profile.api_key_env);
1663 if resolve_api_key(env, None).is_some() {
1664 available.push(profile.name);
1665 }
1666 }
1667 if available.is_empty() {
1668 println!(" [WARNING] Remote providers: none (no API keys in env)");
1669 } else {
1670 println!(" [OK] Remote providers: {}", available.join(", "));
1671 }
1672
1673 if is_ollama_installed() {
1675 let models = list_ollama_models(config).await;
1676 if models.is_empty() {
1677 println!(" [WARNING] Ollama: Installed (no models)");
1678 } else {
1679 println!(" [OK] Ollama: Running ({} models installed)", models.len());
1680 for model in models.iter().take(3) {
1681 println!(" - {}", model);
1682 }
1683 if models.len() > 3 {
1684 println!(" ... and {} more", models.len() - 3);
1685 }
1686 }
1687 } else {
1688 println!(" [ERROR] Ollama: Not installed");
1689 }
1690
1691 if let Ok(config_dir) = get_config_dir() {
1693 let config_path = config_dir.join("config.toml");
1694 if config_path.exists() {
1695 println!(" [OK] Configuration: {}", config_path.display());
1696 } else {
1697 println!(" [WARNING] Configuration: Not found (using defaults)");
1698 }
1699 }
1700
1701 if config.mcp_servers.is_empty() {
1703 println!(" [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
1704 } else {
1705 println!(
1706 " [OK] MCP Servers: {} configured",
1707 config.mcp_servers.len()
1708 );
1709 for (name, server_cfg) in &config.mcp_servers {
1710 println!(
1711 " - {} ({})",
1712 name,
1713 server_cfg.args.get(1).unwrap_or(&server_cfg.command)
1714 );
1715 }
1716 }
1717
1718 {
1721 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1722 let paths = crate::app::instructions::find_instruction_files(&cwd);
1723 if paths.is_empty() {
1724 println!(" [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
1725 } else {
1726 match crate::app::instructions::load_from_paths(&paths) {
1727 Some(loaded) => {
1728 let files = loaded
1729 .sources
1730 .iter()
1731 .map(|source| {
1732 source
1733 .path
1734 .file_name()
1735 .and_then(|name| name.to_str())
1736 .unwrap_or("instructions")
1737 })
1738 .collect::<Vec<_>>()
1739 .join(", ");
1740 println!(
1741 " [OK] Project instructions: {} at {} ({} bytes{})",
1742 files,
1743 loaded.path.display(),
1744 loaded.byte_len,
1745 if loaded.truncated { ", truncated" } else { "" }
1746 );
1747 },
1748 None => {
1749 println!(
1750 " [WARNING] Project instructions: found but unreadable ({})",
1751 paths
1752 .iter()
1753 .map(|path| path.display().to_string())
1754 .collect::<Vec<_>>()
1755 .join(", ")
1756 );
1757 },
1758 }
1759 }
1760 }
1761
1762 show_provider_status(config);
1766
1767 println!("\n Environment:");
1769 if std::env::var("OLLAMA_API_KEY").is_ok() {
1770 println!(" - OLLAMA_API_KEY: Set (for Ollama Cloud)");
1771 }
1772
1773 println!();
1774 Ok(())
1775}
1776
1777fn show_provider_status(config: &Config) {
1782 let mut configured: Vec<(String, String)> = Vec::new(); let anth_cfg = config.providers.get("anthropic");
1787 if resolve_api_key(
1788 "ANTHROPIC_API_KEY",
1789 anth_cfg.and_then(|c| c.api_key_env.as_deref()),
1790 )
1791 .is_some()
1792 {
1793 let url = anth_cfg
1794 .and_then(|c| c.base_url.clone())
1795 .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
1796 configured.push(("anthropic".to_string(), url));
1797 }
1798
1799 let gem_cfg = config.providers.get("gemini");
1801 if resolve_api_key_with_fallback(
1802 "GOOGLE_API_KEY",
1803 "GEMINI_API_KEY",
1804 gem_cfg.and_then(|c| c.api_key_env.as_deref()),
1805 )
1806 .is_some()
1807 {
1808 let url = gem_cfg
1809 .and_then(|c| c.base_url.clone())
1810 .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
1811 configured.push(("gemini".to_string(), url));
1812 }
1813
1814 for profile in PROVIDER_REGISTRY {
1815 let user_cfg = config.providers.get(profile.name);
1816 let api_key_present = resolve_api_key(
1817 profile.api_key_env,
1818 user_cfg.and_then(|c| c.api_key_env.as_deref()),
1819 )
1820 .is_some();
1821 if api_key_present {
1822 let url = user_cfg
1823 .and_then(|c| c.base_url.clone())
1824 .unwrap_or_else(|| profile.base_url.to_string());
1825 configured.push((profile.name.to_string(), url));
1826 }
1827 }
1828
1829 for (name, cfg) in &config.providers {
1832 if name == "anthropic" || name == "gemini" || lookup_provider(name).is_some() {
1833 continue;
1834 }
1835 if let (Some(url), Some(env)) = (&cfg.base_url, cfg.api_key_env.as_deref())
1836 && resolve_api_key(env, None).is_some()
1837 {
1838 configured.push((name.clone(), url.clone()));
1839 }
1840 }
1841
1842 if configured.is_empty() {
1843 println!(
1844 " [INFO] Remote providers: None configured (set $ANTHROPIC_API_KEY, \
1845 $GOOGLE_API_KEY, $OPENAI_API_KEY, $GROQ_API_KEY, $OPENROUTER_API_KEY, etc., or \
1846 add [providers.<name>] to config.toml)"
1847 );
1848 } else {
1849 println!(" [OK] Remote providers: {} configured", configured.len());
1850 for (name, url) in configured {
1851 println!(" - {} ({})", name, url);
1852 }
1853 }
1854}
1855
1856fn handle_pr(command: &PrCommand) -> Result<()> {
1858 match command {
1859 PrCommand::Create {
1860 title,
1861 body,
1862 summary,
1863 base,
1864 draft,
1865 web,
1866 provider,
1867 } => create_pr(CreatePrArgs {
1868 title: title.as_deref(),
1869 body: body.as_deref(),
1870 summary: summary.as_deref(),
1871 base: base.as_deref(),
1872 draft: *draft,
1873 web: *web,
1874 provider: *provider,
1875 }),
1876 }
1877}
1878
1879struct CreatePrArgs<'a> {
1880 title: Option<&'a str>,
1881 body: Option<&'a str>,
1882 summary: Option<&'a Path>,
1883 base: Option<&'a str>,
1884 draft: bool,
1885 web: bool,
1886 provider: Option<GitHost>,
1887}
1888
1889fn create_pr(args: CreatePrArgs) -> Result<()> {
1894 let body = match args.summary {
1896 Some(path) => Some(
1897 std::fs::read_to_string(path)
1898 .with_context(|| format!("failed to read summary file {}", path.display()))?,
1899 ),
1900 None => args.body.map(str::to_string),
1901 };
1902
1903 let host = match args.provider {
1904 Some(host) => host,
1905 None => detect_git_host()?,
1906 };
1907
1908 let (cli, install_hint) = match host {
1909 GitHost::Github => (
1910 "gh",
1911 "Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.",
1912 ),
1913 GitHost::Gitlab => (
1914 "glab",
1915 "Install the GitLab CLI (https://gitlab.com/gitlab-org/cli) and run `glab auth login`.",
1916 ),
1917 };
1918 if which::which(cli).is_err() {
1919 anyhow::bail!("`{cli}` was not found on your PATH. {install_hint}");
1920 }
1921
1922 let argv = build_pr_argv(
1923 host,
1924 args.title,
1925 body.as_deref(),
1926 args.base,
1927 args.draft,
1928 args.web,
1929 );
1930
1931 println!("Creating pull/merge request via `{cli}`…");
1932 let status = std::process::Command::new(cli)
1933 .args(&argv)
1934 .status()
1935 .with_context(|| format!("failed to run `{cli}`"))?;
1936 anyhow::ensure!(status.success(), "`{cli}` exited unsuccessfully ({status})");
1937 Ok(())
1938}
1939
1940fn detect_git_host() -> Result<GitHost> {
1943 if let Some(host) = git_origin_host() {
1944 return Ok(host);
1945 }
1946 if which::which("gh").is_ok() {
1947 return Ok(GitHost::Github);
1948 }
1949 if which::which("glab").is_ok() {
1950 return Ok(GitHost::Gitlab);
1951 }
1952 anyhow::bail!(
1953 "could not detect a Git host from the `origin` remote. Pass `--provider github|gitlab` and install the matching CLI (`gh`/`glab`)."
1954 )
1955}
1956
1957fn git_origin_host() -> Option<GitHost> {
1958 let output = std::process::Command::new("git")
1959 .args(["config", "--get", "remote.origin.url"])
1960 .output()
1961 .ok()?;
1962 if !output.status.success() {
1963 return None;
1964 }
1965 host_from_remote_url(String::from_utf8_lossy(&output.stdout).trim())
1966}
1967
1968fn host_from_remote_url(url: &str) -> Option<GitHost> {
1969 let lower = url.to_ascii_lowercase();
1970 if lower.contains("github.com") {
1971 Some(GitHost::Github)
1972 } else if lower.contains("gitlab") {
1973 Some(GitHost::Gitlab)
1974 } else {
1975 None
1976 }
1977}
1978
1979fn build_pr_argv(
1981 host: GitHost,
1982 title: Option<&str>,
1983 body: Option<&str>,
1984 base: Option<&str>,
1985 draft: bool,
1986 web: bool,
1987) -> Vec<String> {
1988 let s = |v: &str| v.to_string();
1989 let has_content = title.is_some() || body.is_some();
1990 let mut argv = Vec::new();
1991 match host {
1992 GitHost::Github => {
1993 argv.push(s("pr"));
1994 argv.push(s("create"));
1995 if web {
1996 argv.push(s("--web"));
1997 }
1998 if draft {
1999 argv.push(s("--draft"));
2000 }
2001 if let Some(title) = title {
2002 argv.push(s("--title"));
2003 argv.push(s(title));
2004 }
2005 if let Some(body) = body {
2006 argv.push(s("--body"));
2007 argv.push(s(body));
2008 }
2009 if !has_content && !web {
2013 argv.push(s("--fill"));
2014 }
2015 if let Some(base) = base {
2016 argv.push(s("--base"));
2017 argv.push(s(base));
2018 }
2019 },
2020 GitHost::Gitlab => {
2021 argv.push(s("mr"));
2022 argv.push(s("create"));
2023 if web {
2024 argv.push(s("--web"));
2025 }
2026 if draft {
2027 argv.push(s("--draft"));
2028 }
2029 if let Some(title) = title {
2030 argv.push(s("--title"));
2031 argv.push(s(title));
2032 }
2033 if let Some(body) = body {
2034 argv.push(s("--description"));
2035 argv.push(s(body));
2036 }
2037 if !has_content && !web {
2038 argv.push(s("--fill"));
2039 }
2040 if let Some(base) = base {
2041 argv.push(s("--target-branch"));
2042 argv.push(s(base));
2043 }
2044 },
2045 }
2046 argv
2047}
2048
2049#[cfg(test)]
2050mod tests {
2051 use super::*;
2052
2053 #[test]
2054 fn version_compare_handles_update_logic() {
2055 assert!(version_at_least("0.10.2", "0.10.2"));
2057 assert!(version_at_least("0.11.0", "0.10.2"));
2058 assert!(version_at_least("1.0.0", "0.99.99"));
2059 assert!(!version_at_least("0.10.1", "0.10.2"));
2061 assert!(!version_at_least("0.9.0", "0.10.0"));
2062 assert!(!version_at_least("0.10.2", "0.11.0"));
2063 assert!(version_at_least("0.10.2", "v0.10.2"));
2065 assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2066 assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2067 assert!(!version_at_least("0.10.2", "not-a-version"));
2069 }
2070
2071 #[test]
2072 fn host_from_remote_url_detects_provider() {
2073 assert_eq!(
2074 host_from_remote_url("https://github.com/foo/bar.git"),
2075 Some(GitHost::Github)
2076 );
2077 assert_eq!(
2078 host_from_remote_url("git@github.com:foo/bar.git"),
2079 Some(GitHost::Github)
2080 );
2081 assert_eq!(
2082 host_from_remote_url("https://gitlab.com/foo/bar.git"),
2083 Some(GitHost::Gitlab)
2084 );
2085 assert_eq!(
2086 host_from_remote_url("git@gitlab.example.com:foo/bar.git"),
2087 Some(GitHost::Gitlab)
2088 );
2089 assert_eq!(host_from_remote_url("https://bitbucket.org/foo/bar"), None);
2090 }
2091
2092 #[test]
2093 fn build_pr_argv_github_with_content() {
2094 let argv = build_pr_argv(
2095 GitHost::Github,
2096 Some("T"),
2097 Some("B"),
2098 Some("main"),
2099 true,
2100 false,
2101 );
2102 assert_eq!(
2103 argv,
2104 vec![
2105 "pr", "create", "--draft", "--title", "T", "--body", "B", "--base", "main"
2106 ]
2107 );
2108 }
2109
2110 #[test]
2111 fn build_pr_argv_github_fills_without_content() {
2112 let argv = build_pr_argv(GitHost::Github, None, None, None, false, false);
2113 assert!(argv.contains(&"--fill".to_string()));
2114 assert!(!argv.contains(&"--title".to_string()));
2115 }
2116
2117 #[test]
2118 fn build_pr_argv_web_skips_fill() {
2119 let argv = build_pr_argv(GitHost::Github, None, None, None, false, true);
2120 assert!(argv.contains(&"--web".to_string()));
2121 assert!(!argv.contains(&"--fill".to_string()));
2122 }
2123
2124 #[test]
2125 fn build_pr_argv_gitlab_uses_mr_and_target_branch() {
2126 let argv = build_pr_argv(GitHost::Gitlab, Some("T"), None, Some("main"), false, false);
2127 assert_eq!(&argv[0..2], &["mr", "create"]);
2128 assert!(argv.windows(2).any(|w| w == ["--target-branch", "main"]));
2129 assert!(argv.contains(&"--title".to_string()));
2130 }
2131
2132 #[test]
2133 fn qa_compact_smoke_persists_conversation_and_archive() {
2134 let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2135 std::fs::create_dir_all(&dir).unwrap();
2136
2137 let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2138
2139 assert!(report.ok);
2140 assert!(report.archived_messages > 0);
2141 assert!(report.preserved_messages > 0);
2142 assert!(report.replacement_messages >= 3);
2143 assert!(
2144 std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2145 "conversation path should exist"
2146 );
2147 assert!(
2148 std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2149 "archive path should exist"
2150 );
2151
2152 let _ = std::fs::remove_dir_all(dir);
2153 }
2154
2155 #[test]
2156 fn qa_model_id_falls_back_to_deterministic() {
2157 assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2158 }
2159
2160 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2161 let nanos = std::time::SystemTime::now()
2162 .duration_since(std::time::UNIX_EPOCH)
2163 .map(|duration| duration.as_nanos())
2164 .unwrap_or_default();
2165 std::env::temp_dir().join(format!("{name}-{nanos}"))
2166 }
2167}