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 utils::{resolve_api_key, resolve_api_key_with_fallback},
17};
18
19use super::{Commands, GitHost, OutputFormat, PairCommand, PluginCommand, PrCommand, QaCommand};
20
21pub async fn handle_command(
25 command: &Commands,
26 config: &Config,
27 cwd: &Path,
28 cli_model: Option<&str>,
29) -> Result<bool> {
30 match command {
31 Commands::Init => {
32 println!("Initializing Mermaid configuration...");
33 init_config()?;
34 println!("Configuration initialized successfully!");
35 Ok(true)
36 },
37 Commands::List => {
38 list_models(config).await?;
39 Ok(true)
40 },
41 Commands::Models => {
42 show_models(config).await?;
43 Ok(true)
44 },
45 Commands::ModelInfo { model } => {
46 show_model_info(model)?;
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, force } => {
125 restore_checkpoint(id, *force)?;
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 { command } => {
137 handle_pair(command)?;
138 Ok(true)
139 },
140 Commands::Qa { command } => {
141 handle_qa(command, config, cwd)?;
142 Ok(true)
143 },
144 Commands::Add { name, yes } => {
145 crate::mcp::add_server(name, *yes).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, force: bool) -> Result<()> {
1287 if !crate::utils::confirm_or_refuse(
1291 &format!("Restore checkpoint {id}? This overwrites the current working tree."),
1292 force,
1293 )? {
1294 println!("Restore cancelled.");
1295 return Ok(());
1296 }
1297 let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
1298 println!("Restored {} ({} files)", manifest.id, manifest.files.len());
1299 if let Some(repo) = manifest.shadow_git_repo {
1300 println!("Shadow repo: {}", repo);
1301 }
1302 if let Some(commit) = manifest.shadow_git_commit {
1303 println!("Shadow commit: {}", commit);
1304 }
1305 if let Some(action) = manifest.pending_action {
1306 println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
1307 }
1308 Ok(())
1309}
1310
1311fn handle_plugin(command: &PluginCommand) -> Result<()> {
1312 match command {
1313 PluginCommand::Install { path } => {
1314 let preview = crate::runtime::plugin_capability_preview(path)?;
1315 print_plugin_capability_preview(&preview);
1316 let record = crate::runtime::install_plugin_from_path(path)?;
1317 println!(
1318 "Installed plugin {} ({}) — DISABLED.",
1319 record.name, record.id
1320 );
1321 println!(
1322 "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
1323 record.id
1324 );
1325 },
1326 PluginCommand::List => {
1327 let plugins = RuntimeClient::auto().list_plugins()?.value;
1328 if plugins.is_empty() {
1329 println!("No plugins installed.");
1330 } else {
1331 for plugin in plugins {
1332 println!(
1333 "{} [{}] {} ({})",
1334 plugin.id,
1335 if plugin.enabled {
1336 "enabled"
1337 } else {
1338 "disabled"
1339 },
1340 plugin.name,
1341 plugin.source
1342 );
1343 }
1344 }
1345 },
1346 PluginCommand::Enable { id } => {
1347 let client = RuntimeClient::auto();
1349 if let Some(plugin) = client
1350 .list_plugins()?
1351 .value
1352 .into_iter()
1353 .find(|p| p.id == *id || p.name == *id)
1354 && let Ok(preview) =
1355 crate::runtime::plugin_capability_preview(Path::new(&plugin.source))
1356 {
1357 print_plugin_capability_preview(&preview);
1358 }
1359 client.set_plugin_enabled(id, true)?;
1360 println!("Enabled plugin {} — its hooks will now run.", id);
1361 },
1362 PluginCommand::Disable { id } => {
1363 RuntimeClient::auto().set_plugin_enabled(id, false)?;
1364 println!("Disabled plugin {}", id);
1365 },
1366 PluginCommand::Audit { path } => {
1367 let manifest_path = if path.is_dir() {
1368 path.join("plugin.toml")
1369 } else {
1370 path.clone()
1371 };
1372 let raw = std::fs::read_to_string(&manifest_path)?;
1373 let manifest: crate::runtime::PluginManifest = toml::from_str(&raw)?;
1374 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1375 crate::runtime::validate_plugin_manifest(&manifest, root)?;
1376 let preview = crate::runtime::plugin_capability_preview(path)?;
1377 println!("Plugin manifest is valid: {}", manifest.name);
1378 print_plugin_capability_preview(&preview);
1379 },
1380 }
1381 Ok(())
1382}
1383
1384fn print_plugin_capability_preview(preview: &crate::runtime::PluginCapabilityPreview) {
1385 println!(
1386 "Capabilities declared by plugin {} (advisory, not sandbox-enforced):",
1387 preview.name
1388 );
1389 if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
1390 println!(" capabilities: (none declared)");
1391 } else {
1392 if !preview.declared_capabilities.is_empty() {
1393 println!(" declared: {}", preview.declared_capabilities.join(", "));
1394 }
1395 if let Some(value) = &preview.capabilities_toml {
1396 println!(
1397 " capabilities.toml: {}",
1398 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
1399 );
1400 }
1401 }
1402 if !preview.hooks.is_empty() {
1403 println!(" hooks: {}", preview.hooks.join(", "));
1404 }
1405 if !preview.mcp.is_empty() {
1406 println!(" mcp: {}", preview.mcp.join(", "));
1407 }
1408 if !preview.bin.is_empty() {
1409 println!(" bin: {}", preview.bin.join(", "));
1410 }
1411}
1412
1413fn handle_pair(command: &PairCommand) -> Result<()> {
1414 let store = RuntimeStore::open_default()?;
1415 match command {
1416 PairCommand::Create { label, ttl_days } => {
1417 let ttl = ttl_days.unwrap_or(crate::runtime::DEFAULT_PAIRING_TTL_DAYS);
1418 let expires_at = crate::runtime::pairing_expiry_from_now(ttl);
1419 let (token, hash) = crate::runtime::generate_pairing_token()?;
1420 let record =
1421 store
1422 .pairing_tokens()
1423 .create(&hash, label.as_deref(), expires_at.as_deref())?;
1424 println!("Pairing token id: {}", record.id);
1425 println!("Pairing token: {}", token);
1426 println!(
1427 "Expires: {}",
1428 record.expires_at.as_deref().unwrap_or("never")
1429 );
1430 println!(
1431 "Use with daemon JSON by setting {}.",
1432 crate::runtime::daemon::DAEMON_TOKEN_ENV
1433 );
1434 println!("Store this now; Mermaid will not print it again.");
1435 },
1436 PairCommand::List => {
1437 let tokens = store.pairing_tokens().list()?;
1438 if tokens.is_empty() {
1439 println!("No pairing tokens.");
1440 } else {
1441 for t in tokens {
1443 println!(
1444 "{} [{}] label={} created={} expires={} last_used={}",
1445 t.id,
1446 if t.enabled { "active" } else { "revoked" },
1447 t.label.as_deref().unwrap_or("-"),
1448 t.created_at,
1449 t.expires_at.as_deref().unwrap_or("never"),
1450 t.last_used_at.as_deref().unwrap_or("never"),
1451 );
1452 }
1453 }
1454 },
1455 PairCommand::Revoke { id } => {
1456 if store.pairing_tokens().revoke(id)? {
1457 println!("Revoked pairing token {id}");
1458 } else {
1459 println!("No active pairing token with id {id}");
1460 }
1461 },
1462 }
1463 Ok(())
1464}
1465
1466fn show_logs(id: &str) -> Result<()> {
1467 let content = RuntimeClient::auto().process_log(id, None)?.content;
1468 print!("{}", content);
1469 Ok(())
1470}
1471
1472fn stop_process(id: &str) -> Result<()> {
1473 let process = RuntimeClient::auto().stop_process(id)?.item;
1474 println!("Stopped process {} (pid {})", id, process.pid);
1475 Ok(())
1476}
1477
1478fn restart_process(id: &str) -> Result<()> {
1479 let process = RuntimeClient::auto().restart_process(id)?.item;
1480 println!("Restarted process {} (pid {})", id, process.pid);
1481 Ok(())
1482}
1483
1484fn open_target(target: &str) -> Result<()> {
1485 if RuntimeClient::auto().open_process(target).is_err() {
1486 crate::utils::open_file(target);
1487 }
1488 Ok(())
1489}
1490
1491fn show_ports() -> Result<()> {
1492 print!("{}", RuntimeClient::auto().ports()?.ports);
1493 Ok(())
1494}
1495
1496pub async fn list_models(config: &Config) -> Result<()> {
1498 let ollama_models = list_ollama_models(config).await;
1499 if ollama_models.is_empty() {
1500 println!("No Ollama models installed locally.");
1501 } else {
1502 println!("Ollama models (local/cloud):");
1503 for name in &ollama_models {
1504 println!(" - ollama/{}", name);
1505 }
1506 }
1507
1508 println!("\nConfigured remote providers:");
1509 let mut any = false;
1510 for profile in PROVIDER_REGISTRY {
1511 let env = config
1512 .providers
1513 .get(profile.name)
1514 .and_then(|c| c.api_key_env.as_deref())
1515 .unwrap_or(profile.api_key_env);
1516 if resolve_api_key(env, None).is_some() {
1517 any = true;
1518 println!(" - {} (via ${})", profile.name, env);
1519 }
1520 }
1521 if !any {
1522 println!(" (none — set a provider API key env var to enable)");
1523 }
1524 println!("\nSwitch models in-session with /model <name>.");
1525 Ok(())
1526}
1527
1528async fn list_ollama_models(config: &Config) -> Vec<String> {
1532 use crate::models::adapters::ollama::OllamaAdapter;
1533 let backend = BackendConfig {
1534 ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
1535 timeout_secs: 5,
1536 max_idle_per_host: 2,
1537 };
1538 match OllamaAdapter::new("__list__", Arc::new(backend)).await {
1539 Ok(adapter) => adapter.list_models().await.unwrap_or_default(),
1540 Err(_) => Vec::new(),
1541 }
1542}
1543
1544pub fn show_version() {
1546 println!("Mermaid v{}", env!("CARGO_PKG_VERSION"));
1547 println!(" An open-source, model-agnostic AI pair programmer");
1548}
1549
1550const RELEASE_LATEST_API: &str =
1551 "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
1552const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
1553const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
1554
1555async fn run_update(check: bool, force: bool) -> Result<()> {
1561 let current = env!("CARGO_PKG_VERSION");
1562 println!("Installed: v{current}");
1563
1564 let client = reqwest::Client::builder()
1565 .timeout(std::time::Duration::from_secs(15))
1566 .build()?;
1567 let resp = client
1568 .get(RELEASE_LATEST_API)
1569 .header("User-Agent", "mermaid-cli")
1570 .header("Accept", "application/vnd.github+json")
1571 .send()
1572 .await
1573 .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
1574 if !resp.status().is_success() {
1575 bail!("GitHub Releases API returned HTTP {}", resp.status());
1576 }
1577 let release: serde_json::Value = resp.json().await?;
1578 let tag = release
1579 .get("tag_name")
1580 .and_then(|v| v.as_str())
1581 .ok_or_else(|| anyhow!("release response had no tag_name"))?;
1582 println!("Latest: {tag}");
1583
1584 let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
1585 if check {
1586 if up_to_date {
1587 println!("You're on the latest version.");
1588 } else {
1589 println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
1590 }
1591 return Ok(());
1592 }
1593 if up_to_date && !force {
1594 println!("Already up to date.");
1595 return Ok(());
1596 }
1597
1598 let exe =
1600 std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
1601 let install_dir = exe
1602 .parent()
1603 .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
1604
1605 let script_url = if cfg!(target_os = "windows") {
1610 INSTALL_PS1_URL
1611 } else {
1612 INSTALL_SH_URL
1613 };
1614 if !crate::utils::confirm_or_refuse(
1615 &format!(
1616 "About to download and run {script_url} to replace {}.",
1617 install_dir.display()
1618 ),
1619 force,
1620 )? {
1621 println!("Update cancelled.");
1622 return Ok(());
1623 }
1624
1625 println!("Updating {} …", install_dir.display());
1626 run_install_script(&client, install_dir).await?;
1627 println!("Updated. New version takes effect on the next run.");
1628 Ok(())
1629}
1630
1631async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
1634 let windows = cfg!(target_os = "windows");
1635 let url = if windows {
1636 INSTALL_PS1_URL
1637 } else {
1638 INSTALL_SH_URL
1639 };
1640 let script = client
1641 .get(url)
1642 .header("User-Agent", "mermaid-cli")
1643 .send()
1644 .await
1645 .map_err(|e| anyhow!("could not fetch install script: {e}"))?
1646 .error_for_status()?
1647 .text()
1648 .await?;
1649
1650 let ext = if windows { "ps1" } else { "sh" };
1651 let script_path =
1652 std::env::temp_dir().join(format!("mermaid-update-{}.{ext}", std::process::id()));
1653 std::fs::write(&script_path, script)
1654 .map_err(|e| anyhow!("could not stage install script: {e}"))?;
1655
1656 let mut cmd = if windows {
1657 let mut c = tokio::process::Command::new("powershell");
1658 c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
1659 c.arg(&script_path);
1660 c
1661 } else {
1662 let mut c = tokio::process::Command::new("sh");
1663 c.arg(&script_path);
1664 c
1665 };
1666 cmd.env("MERMAID_INSTALL_DIR", install_dir)
1667 .env("MERMAID_NO_MODIFY_PATH", "1");
1668
1669 let status = cmd
1670 .status()
1671 .await
1672 .map_err(|e| anyhow!("could not run install script: {e}"))?;
1673 let _ = std::fs::remove_file(&script_path);
1674 if !status.success() {
1675 bail!("install script exited with {:?}", status.code());
1676 }
1677 Ok(())
1678}
1679
1680fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
1682 let core = s.trim().trim_start_matches('v');
1683 let core = core.split(['-', '+']).next().unwrap_or(core);
1684 let mut parts = core.split('.');
1685 let major = parts.next()?.parse().ok()?;
1686 let minor = parts.next().unwrap_or("0").parse().ok()?;
1687 let patch = parts.next().unwrap_or("0").parse().ok()?;
1688 Some((major, minor, patch))
1689}
1690
1691fn version_at_least(current: &str, latest: &str) -> bool {
1695 match (parse_semver(current), parse_semver(latest)) {
1696 (Some(c), Some(l)) => c >= l,
1697 _ => current == latest,
1698 }
1699}
1700
1701fn show_mcp_servers() {
1703 let config = load_config_or_warn();
1704
1705 if config.mcp_servers.is_empty() {
1706 println!("No MCP servers configured.\n");
1707 println!("Add one with: mermaid add <name>");
1708 println!("Examples:");
1709 println!(" mermaid add context7 # Library documentation");
1710 println!(" mermaid add playwright # Browser automation");
1711 println!(" mermaid add memory # Persistent knowledge graph");
1712 return;
1713 }
1714
1715 println!("Configured MCP servers:\n");
1716 for (name, server_cfg) in &config.mcp_servers {
1717 let package = server_cfg
1718 .args
1719 .iter()
1720 .find(|a| !a.starts_with('-'))
1721 .unwrap_or(&server_cfg.command);
1722 let env_keys: Vec<&String> = server_cfg.env.keys().collect();
1723 let env_display = if env_keys.is_empty() {
1724 String::new()
1725 } else {
1726 format!(
1727 " (env: {})",
1728 env_keys
1729 .iter()
1730 .map(|k| k.as_str())
1731 .collect::<Vec<_>>()
1732 .join(", ")
1733 )
1734 };
1735 println!(" {} — {}{}", name, package, env_display);
1736 }
1737 println!("\nManage with: mermaid add <name> / mermaid remove <name>");
1738}
1739
1740async fn show_status(config: &Config) -> Result<()> {
1742 println!("Mermaid Status:");
1743 println!();
1744
1745 let mut available: Vec<&'static str> = Vec::new();
1748 for profile in PROVIDER_REGISTRY {
1749 let env = config
1750 .providers
1751 .get(profile.name)
1752 .and_then(|c| c.api_key_env.as_deref())
1753 .unwrap_or(profile.api_key_env);
1754 if resolve_api_key(env, None).is_some() {
1755 available.push(profile.name);
1756 }
1757 }
1758 if available.is_empty() {
1759 println!(" [WARNING] Remote providers: none (no API keys in env)");
1760 } else {
1761 println!(" [OK] Remote providers: {}", available.join(", "));
1762 }
1763
1764 if is_ollama_installed() {
1766 let models = list_ollama_models(config).await;
1767 if models.is_empty() {
1768 println!(" [WARNING] Ollama: Installed (no models)");
1769 } else {
1770 println!(" [OK] Ollama: Running ({} models installed)", models.len());
1771 for model in models.iter().take(3) {
1772 println!(" - {}", model);
1773 }
1774 if models.len() > 3 {
1775 println!(" ... and {} more", models.len() - 3);
1776 }
1777 }
1778 } else {
1779 println!(" [ERROR] Ollama: Not installed");
1780 }
1781
1782 if let Ok(config_dir) = get_config_dir() {
1784 let config_path = config_dir.join("config.toml");
1785 if config_path.exists() {
1786 println!(" [OK] Configuration: {}", config_path.display());
1787 } else {
1788 println!(" [WARNING] Configuration: Not found (using defaults)");
1789 }
1790 }
1791
1792 if config.mcp_servers.is_empty() {
1794 println!(" [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
1795 } else {
1796 println!(
1797 " [OK] MCP Servers: {} configured",
1798 config.mcp_servers.len()
1799 );
1800 for (name, server_cfg) in &config.mcp_servers {
1801 println!(
1802 " - {} ({})",
1803 name,
1804 server_cfg.args.get(1).unwrap_or(&server_cfg.command)
1805 );
1806 }
1807 }
1808
1809 {
1812 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1813 let paths = crate::app::instructions::find_instruction_files(&cwd);
1814 if paths.is_empty() {
1815 println!(" [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
1816 } else {
1817 match crate::app::instructions::load_from_paths(&paths) {
1818 Some(loaded) => {
1819 let files = loaded
1820 .sources
1821 .iter()
1822 .map(|source| {
1823 source
1824 .path
1825 .file_name()
1826 .and_then(|name| name.to_str())
1827 .unwrap_or("instructions")
1828 })
1829 .collect::<Vec<_>>()
1830 .join(", ");
1831 println!(
1832 " [OK] Project instructions: {} at {} ({} bytes{})",
1833 files,
1834 loaded.path.display(),
1835 loaded.byte_len,
1836 if loaded.truncated { ", truncated" } else { "" }
1837 );
1838 },
1839 None => {
1840 println!(
1841 " [WARNING] Project instructions: found but unreadable ({})",
1842 paths
1843 .iter()
1844 .map(|path| path.display().to_string())
1845 .collect::<Vec<_>>()
1846 .join(", ")
1847 );
1848 },
1849 }
1850 }
1851 }
1852
1853 show_provider_status(config);
1857
1858 println!("\n Environment:");
1860 if std::env::var("OLLAMA_API_KEY").is_ok() {
1861 println!(" - OLLAMA_API_KEY: Set (for Ollama Cloud)");
1862 }
1863
1864 println!();
1865 Ok(())
1866}
1867
1868fn show_provider_status(config: &Config) {
1873 let mut configured: Vec<(String, String)> = Vec::new(); let anth_cfg = config.providers.get("anthropic");
1878 if resolve_api_key(
1879 "ANTHROPIC_API_KEY",
1880 anth_cfg.and_then(|c| c.api_key_env.as_deref()),
1881 )
1882 .is_some()
1883 {
1884 let url = anth_cfg
1885 .and_then(|c| c.base_url.clone())
1886 .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
1887 configured.push(("anthropic".to_string(), url));
1888 }
1889
1890 let gem_cfg = config.providers.get("gemini");
1892 if resolve_api_key_with_fallback(
1893 "GOOGLE_API_KEY",
1894 "GEMINI_API_KEY",
1895 gem_cfg.and_then(|c| c.api_key_env.as_deref()),
1896 )
1897 .is_some()
1898 {
1899 let url = gem_cfg
1900 .and_then(|c| c.base_url.clone())
1901 .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
1902 configured.push(("gemini".to_string(), url));
1903 }
1904
1905 for profile in PROVIDER_REGISTRY {
1906 let user_cfg = config.providers.get(profile.name);
1907 let api_key_present = resolve_api_key(
1908 profile.api_key_env,
1909 user_cfg.and_then(|c| c.api_key_env.as_deref()),
1910 )
1911 .is_some();
1912 if api_key_present {
1913 let url = user_cfg
1914 .and_then(|c| c.base_url.clone())
1915 .unwrap_or_else(|| profile.base_url.to_string());
1916 configured.push((profile.name.to_string(), url));
1917 }
1918 }
1919
1920 for (name, cfg) in &config.providers {
1923 if name == "anthropic" || name == "gemini" || lookup_provider(name).is_some() {
1924 continue;
1925 }
1926 if let (Some(url), Some(env)) = (&cfg.base_url, cfg.api_key_env.as_deref())
1927 && resolve_api_key(env, None).is_some()
1928 {
1929 configured.push((name.clone(), url.clone()));
1930 }
1931 }
1932
1933 if configured.is_empty() {
1934 println!(
1935 " [INFO] Remote providers: None configured (set $ANTHROPIC_API_KEY, \
1936 $GOOGLE_API_KEY, $OPENAI_API_KEY, $GROQ_API_KEY, $OPENROUTER_API_KEY, etc., or \
1937 add [providers.<name>] to config.toml)"
1938 );
1939 } else {
1940 println!(" [OK] Remote providers: {} configured", configured.len());
1941 for (name, url) in configured {
1942 println!(" - {} ({})", name, url);
1943 }
1944 }
1945}
1946
1947fn handle_pr(command: &PrCommand) -> Result<()> {
1949 match command {
1950 PrCommand::Create {
1951 title,
1952 body,
1953 summary,
1954 base,
1955 draft,
1956 web,
1957 provider,
1958 } => create_pr(CreatePrArgs {
1959 title: title.as_deref(),
1960 body: body.as_deref(),
1961 summary: summary.as_deref(),
1962 base: base.as_deref(),
1963 draft: *draft,
1964 web: *web,
1965 provider: *provider,
1966 }),
1967 }
1968}
1969
1970struct CreatePrArgs<'a> {
1971 title: Option<&'a str>,
1972 body: Option<&'a str>,
1973 summary: Option<&'a Path>,
1974 base: Option<&'a str>,
1975 draft: bool,
1976 web: bool,
1977 provider: Option<GitHost>,
1978}
1979
1980fn create_pr(args: CreatePrArgs) -> Result<()> {
1985 let body = match args.summary {
1987 Some(path) => Some(
1988 std::fs::read_to_string(path)
1989 .with_context(|| format!("failed to read summary file {}", path.display()))?,
1990 ),
1991 None => args.body.map(str::to_string),
1992 };
1993
1994 let host = match args.provider {
1995 Some(host) => host,
1996 None => detect_git_host()?,
1997 };
1998
1999 let (cli, install_hint) = match host {
2000 GitHost::Github => (
2001 "gh",
2002 "Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.",
2003 ),
2004 GitHost::Gitlab => (
2005 "glab",
2006 "Install the GitLab CLI (https://gitlab.com/gitlab-org/cli) and run `glab auth login`.",
2007 ),
2008 };
2009 if which::which(cli).is_err() {
2010 anyhow::bail!("`{cli}` was not found on your PATH. {install_hint}");
2011 }
2012
2013 let argv = build_pr_argv(
2014 host,
2015 args.title,
2016 body.as_deref(),
2017 args.base,
2018 args.draft,
2019 args.web,
2020 );
2021
2022 println!("Creating pull/merge request via `{cli}`…");
2023 let status = std::process::Command::new(cli)
2024 .args(&argv)
2025 .status()
2026 .with_context(|| format!("failed to run `{cli}`"))?;
2027 anyhow::ensure!(status.success(), "`{cli}` exited unsuccessfully ({status})");
2028 Ok(())
2029}
2030
2031fn detect_git_host() -> Result<GitHost> {
2034 if let Some(host) = git_origin_host() {
2035 return Ok(host);
2036 }
2037 if which::which("gh").is_ok() {
2038 return Ok(GitHost::Github);
2039 }
2040 if which::which("glab").is_ok() {
2041 return Ok(GitHost::Gitlab);
2042 }
2043 anyhow::bail!(
2044 "could not detect a Git host from the `origin` remote. Pass `--provider github|gitlab` and install the matching CLI (`gh`/`glab`)."
2045 )
2046}
2047
2048fn git_origin_host() -> Option<GitHost> {
2049 let output = std::process::Command::new("git")
2050 .args(["config", "--get", "remote.origin.url"])
2051 .output()
2052 .ok()?;
2053 if !output.status.success() {
2054 return None;
2055 }
2056 host_from_remote_url(String::from_utf8_lossy(&output.stdout).trim())
2057}
2058
2059fn host_from_remote_url(url: &str) -> Option<GitHost> {
2060 let lower = url.to_ascii_lowercase();
2061 if lower.contains("github.com") {
2062 Some(GitHost::Github)
2063 } else if lower.contains("gitlab") {
2064 Some(GitHost::Gitlab)
2065 } else {
2066 None
2067 }
2068}
2069
2070fn build_pr_argv(
2072 host: GitHost,
2073 title: Option<&str>,
2074 body: Option<&str>,
2075 base: Option<&str>,
2076 draft: bool,
2077 web: bool,
2078) -> Vec<String> {
2079 let s = |v: &str| v.to_string();
2080 let has_content = title.is_some() || body.is_some();
2081 let mut argv = Vec::new();
2082 match host {
2083 GitHost::Github => {
2084 argv.push(s("pr"));
2085 argv.push(s("create"));
2086 if web {
2087 argv.push(s("--web"));
2088 }
2089 if draft {
2090 argv.push(s("--draft"));
2091 }
2092 if let Some(title) = title {
2093 argv.push(s("--title"));
2094 argv.push(s(title));
2095 }
2096 if let Some(body) = body {
2097 argv.push(s("--body"));
2098 argv.push(s(body));
2099 }
2100 if !has_content && !web {
2104 argv.push(s("--fill"));
2105 }
2106 if let Some(base) = base {
2107 argv.push(s("--base"));
2108 argv.push(s(base));
2109 }
2110 },
2111 GitHost::Gitlab => {
2112 argv.push(s("mr"));
2113 argv.push(s("create"));
2114 if web {
2115 argv.push(s("--web"));
2116 }
2117 if draft {
2118 argv.push(s("--draft"));
2119 }
2120 if let Some(title) = title {
2121 argv.push(s("--title"));
2122 argv.push(s(title));
2123 }
2124 if let Some(body) = body {
2125 argv.push(s("--description"));
2126 argv.push(s(body));
2127 }
2128 if !has_content && !web {
2129 argv.push(s("--fill"));
2130 }
2131 if let Some(base) = base {
2132 argv.push(s("--target-branch"));
2133 argv.push(s(base));
2134 }
2135 },
2136 }
2137 argv
2138}
2139
2140#[cfg(test)]
2141mod tests {
2142 use super::*;
2143
2144 #[test]
2145 fn version_compare_handles_update_logic() {
2146 assert!(version_at_least("0.10.2", "0.10.2"));
2148 assert!(version_at_least("0.11.0", "0.10.2"));
2149 assert!(version_at_least("1.0.0", "0.99.99"));
2150 assert!(!version_at_least("0.10.1", "0.10.2"));
2152 assert!(!version_at_least("0.9.0", "0.10.0"));
2153 assert!(!version_at_least("0.10.2", "0.11.0"));
2154 assert!(version_at_least("0.10.2", "v0.10.2"));
2156 assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2157 assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2158 assert!(!version_at_least("0.10.2", "not-a-version"));
2160 }
2161
2162 #[test]
2163 fn host_from_remote_url_detects_provider() {
2164 assert_eq!(
2165 host_from_remote_url("https://github.com/foo/bar.git"),
2166 Some(GitHost::Github)
2167 );
2168 assert_eq!(
2169 host_from_remote_url("git@github.com:foo/bar.git"),
2170 Some(GitHost::Github)
2171 );
2172 assert_eq!(
2173 host_from_remote_url("https://gitlab.com/foo/bar.git"),
2174 Some(GitHost::Gitlab)
2175 );
2176 assert_eq!(
2177 host_from_remote_url("git@gitlab.example.com:foo/bar.git"),
2178 Some(GitHost::Gitlab)
2179 );
2180 assert_eq!(host_from_remote_url("https://bitbucket.org/foo/bar"), None);
2181 }
2182
2183 #[test]
2184 fn build_pr_argv_github_with_content() {
2185 let argv = build_pr_argv(
2186 GitHost::Github,
2187 Some("T"),
2188 Some("B"),
2189 Some("main"),
2190 true,
2191 false,
2192 );
2193 assert_eq!(
2194 argv,
2195 vec![
2196 "pr", "create", "--draft", "--title", "T", "--body", "B", "--base", "main"
2197 ]
2198 );
2199 }
2200
2201 #[test]
2202 fn build_pr_argv_github_fills_without_content() {
2203 let argv = build_pr_argv(GitHost::Github, None, None, None, false, false);
2204 assert!(argv.contains(&"--fill".to_string()));
2205 assert!(!argv.contains(&"--title".to_string()));
2206 }
2207
2208 #[test]
2209 fn build_pr_argv_web_skips_fill() {
2210 let argv = build_pr_argv(GitHost::Github, None, None, None, false, true);
2211 assert!(argv.contains(&"--web".to_string()));
2212 assert!(!argv.contains(&"--fill".to_string()));
2213 }
2214
2215 #[test]
2216 fn build_pr_argv_gitlab_uses_mr_and_target_branch() {
2217 let argv = build_pr_argv(GitHost::Gitlab, Some("T"), None, Some("main"), false, false);
2218 assert_eq!(&argv[0..2], &["mr", "create"]);
2219 assert!(argv.windows(2).any(|w| w == ["--target-branch", "main"]));
2220 assert!(argv.contains(&"--title".to_string()));
2221 }
2222
2223 #[test]
2224 fn qa_compact_smoke_persists_conversation_and_archive() {
2225 let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2226 std::fs::create_dir_all(&dir).unwrap();
2227
2228 let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2229
2230 assert!(report.ok);
2231 assert!(report.archived_messages > 0);
2232 assert!(report.preserved_messages > 0);
2233 assert!(report.replacement_messages >= 3);
2234 assert!(
2235 std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2236 "conversation path should exist"
2237 );
2238 assert!(
2239 std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2240 "archive path should exist"
2241 );
2242
2243 let _ = std::fs::remove_dir_all(dir);
2244 }
2245
2246 #[test]
2247 fn qa_model_id_falls_back_to_deterministic() {
2248 assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2249 }
2250
2251 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2252 let nanos = std::time::SystemTime::now()
2253 .duration_since(std::time::UNIX_EPOCH)
2254 .map(|duration| duration.as_nanos())
2255 .unwrap_or_default();
2256 std::env::temp_dir().join(format!("{name}-{nanos}"))
2257 }
2258}