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