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 match mode {
589 crate::runtime::SafetyMode::ReadOnly => "read_only",
590 crate::runtime::SafetyMode::Ask => "ask",
591 crate::runtime::SafetyMode::AutoReview => "auto_review",
592 crate::runtime::SafetyMode::FullAccess => "full_access",
593 }
594}
595
596fn configured_remote_providers(config: &Config) -> Vec<String> {
597 let mut names = Vec::new();
598 for profile in PROVIDER_REGISTRY {
599 let env = config
600 .providers
601 .get(profile.name)
602 .and_then(|c| c.api_key_env.as_deref())
603 .unwrap_or(profile.api_key_env);
604 if resolve_api_key(env, None).is_some() {
605 names.push(profile.name.to_string());
606 }
607 }
608 for (name, provider) in &config.providers {
609 if names.iter().any(|existing| existing == name) {
610 continue;
611 }
612 if let Some(env) = provider.api_key_env.as_deref()
613 && resolve_api_key(env, None).is_some()
614 {
615 names.push(name.clone());
616 }
617 }
618 names.sort();
619 names
620}
621
622#[derive(Debug, serde::Serialize)]
623struct QaCompactSmokeReport {
624 ok: bool,
625 turns: usize,
626 archived_messages: usize,
627 preserved_messages: usize,
628 replacement_messages: usize,
629 conversation_path: Option<String>,
630 archive_path: Option<String>,
631 checks: Vec<String>,
632 failure: Option<String>,
633}
634
635impl QaCompactSmokeReport {
636 fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
637 Self {
638 ok: false,
639 turns,
640 archived_messages: 0,
641 preserved_messages: 0,
642 replacement_messages: 0,
643 conversation_path: Some(
644 cwd.join(".mermaid")
645 .join("conversations")
646 .display()
647 .to_string(),
648 ),
649 archive_path: None,
650 checks: Vec::new(),
651 failure: Some(failure),
652 }
653 }
654}
655
656fn run_qa_compact_smoke(
657 config: &Config,
658 cwd: &Path,
659 requested_turns: usize,
660) -> Result<QaCompactSmokeReport> {
661 let turns = requested_turns.max(3);
662 let mut state = State::new(config.clone(), cwd.to_path_buf(), qa_model_id(config));
663 for message in synthetic_compaction_messages(turns) {
664 state.session.append(message);
665 }
666
667 let (state_after_slash, compact_cmds) = update(
668 state,
669 Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
670 );
671 let turn = state_after_slash
672 .turn
673 .id()
674 .context("manual compaction did not enter a compaction turn")?;
675 let request = compact_cmds
676 .iter()
677 .find_map(|cmd| match cmd {
678 Cmd::CompactConversation { request, .. } => Some(request.clone()),
679 _ => None,
680 })
681 .context("manual compaction did not emit a CompactConversation command")?;
682
683 let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
684 let prepared = prepare_compaction(&request, Some(100_000))
685 .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
686 anyhow::ensure!(
687 !prepared.archived_messages.is_empty(),
688 "compaction archived no messages"
689 );
690 anyhow::ensure!(
691 !prepared.preserved_messages.is_empty(),
692 "compaction preserved no messages"
693 );
694
695 let summary = deterministic_compaction_summary(&prepared, turns);
696 let mut record = CompactionRecord {
697 id: format!("qa_compact_{}", fresh_qa_id()),
698 trigger: CompactionTrigger::Manual,
699 created_at: chrono::Local::now(),
700 before_tokens: before_snapshot.used_tokens,
701 after_tokens: 0,
702 archived_message_count: prepared.archived_messages.len(),
703 preserved_message_count: prepared.preserved_messages.len(),
704 summary_tokens: summary.len().div_ceil(4),
705 duration_secs: 0.0,
706 verified: true,
707 verification_error: None,
708 focus: Some("qa compact smoke".to_string()),
709 archive_path: None,
710 };
711 let mut replacement = build_replacement_messages(&summary, &prepared, &record);
712 let mut after_chat: ChatRequest = request.chat.clone();
713 after_chat.messages = replacement.clone();
714 let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
715 record.after_tokens = after_snapshot.used_tokens;
716 replacement = build_replacement_messages(&summary, &prepared, &record);
717 after_chat.messages = replacement.clone();
718 after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
719
720 let result = CompactionResult {
721 record,
722 replacement_messages: replacement,
723 archived_messages: prepared.archived_messages,
724 before_snapshot,
725 after_snapshot,
726 usage: None,
727 };
728 let (final_state, save_cmds) =
729 update(state_after_slash, Msg::CompactionFinished { turn, result });
730
731 let manager = ConversationManager::new(cwd)?;
732 let mut conversation_path = None;
733 let mut archive_path = None;
734 for cmd in save_cmds {
735 match cmd {
736 Cmd::SaveConversation(conversation) => {
737 manager.save_conversation(&conversation)?;
738 conversation_path = Some(
739 manager
740 .conversations_dir()
741 .join(format!("{}.json", conversation.id))
742 .display()
743 .to_string(),
744 );
745 },
746 Cmd::SaveCompactionArchive {
747 archive,
748 conversation,
749 ..
750 } => {
751 archive_path = Some(
755 manager
756 .save_compaction_archive(&archive)?
757 .display()
758 .to_string(),
759 );
760 manager.save_conversation(&conversation)?;
761 conversation_path = Some(
762 manager
763 .conversations_dir()
764 .join(format!("{}.json", conversation.id))
765 .display()
766 .to_string(),
767 );
768 },
769 _ => {},
770 }
771 }
772
773 let conversation_path = conversation_path.context("compaction did not save conversation")?;
774 let archive_path = archive_path.context("compaction did not save archive")?;
775 let messages = final_state.session.messages();
776 let compactions = &final_state.session.conversation.compactions;
777
778 let mut checks = Vec::new();
779 anyhow::ensure!(
780 !compactions.is_empty(),
781 "conversation did not record compaction metadata"
782 );
783 checks.push("conversation records compaction metadata".to_string());
784 anyhow::ensure!(
785 messages
786 .first()
787 .is_some_and(|msg| msg.kind == crate::models::ChatMessageKind::ContextCheckpoint),
788 "replacement does not start with a context checkpoint"
789 );
790 checks.push("replacement starts with context checkpoint".to_string());
791 anyhow::ensure!(
792 std::path::Path::new(&conversation_path).exists(),
793 "conversation file missing after save"
794 );
795 checks.push("conversation file saved".to_string());
796 anyhow::ensure!(
797 std::path::Path::new(&archive_path).exists(),
798 "compaction archive file missing after save"
799 );
800 checks.push("archive file saved".to_string());
801 anyhow::ensure!(
802 compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
803 "compaction did not archive and preserve messages"
804 );
805 checks.push("archived and preserved message counts are non-zero".to_string());
806
807 Ok(QaCompactSmokeReport {
808 ok: true,
809 turns,
810 archived_messages: compactions[0].archived_message_count,
811 preserved_messages: compactions[0].preserved_message_count,
812 replacement_messages: messages.len(),
813 conversation_path: Some(conversation_path),
814 archive_path: Some(archive_path),
815 checks,
816 failure: None,
817 })
818}
819
820fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
821 match format {
822 OutputFormat::Json => {
823 println!("{}", serde_json::to_string_pretty(report)?);
824 },
825 OutputFormat::Text => {
826 println!(
827 "qa compact smoke: {}",
828 if report.ok { "ok" } else { "failed" }
829 );
830 println!("turns: {}", report.turns);
831 println!("archived messages: {}", report.archived_messages);
832 println!("preserved messages: {}", report.preserved_messages);
833 println!("replacement messages: {}", report.replacement_messages);
834 if let Some(path) = &report.conversation_path {
835 println!("conversation: {path}");
836 }
837 if let Some(path) = &report.archive_path {
838 println!("archive: {path}");
839 }
840 if let Some(failure) = &report.failure {
841 println!("failure: {failure}");
842 }
843 },
844 OutputFormat::Markdown => {
845 println!(
846 "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
847 if report.ok { "ok" } else { "failed" },
848 report.turns,
849 report.archived_messages,
850 report.preserved_messages,
851 report.replacement_messages
852 );
853 if let Some(path) = &report.conversation_path {
854 println!("- Conversation: `{path}`");
855 }
856 if let Some(path) = &report.archive_path {
857 println!("- Archive: `{path}`");
858 }
859 if let Some(failure) = &report.failure {
860 println!("\nFailure: `{failure}`");
861 }
862 },
863 }
864 Ok(())
865}
866
867fn qa_model_id(config: &Config) -> String {
868 if let Some(model) = config
869 .last_used_model
870 .as_ref()
871 .filter(|value| !value.is_empty())
872 {
873 return model.clone();
874 }
875 if !config.default_model.name.is_empty() {
876 if config.default_model.provider.is_empty() {
877 return config.default_model.name.clone();
878 }
879 return format!(
880 "{}/{}",
881 config.default_model.provider, config.default_model.name
882 );
883 }
884 "qa/deterministic".to_string()
885}
886
887fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
888 let mut messages = Vec::with_capacity(turns.saturating_mul(2));
889 for idx in 1..=turns {
890 messages.push(ChatMessage::user(format!(
891 "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
892 )));
893 messages.push(ChatMessage::assistant(format!(
894 "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}."
895 )));
896 }
897 messages
898}
899
900fn deterministic_compaction_summary(
901 prepared: &crate::domain::PreparedCompaction,
902 turns: usize,
903) -> String {
904 format!(
905 "## 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.",
906 prepared.archived_messages.len(),
907 prepared.preserved_messages.len()
908 )
909}
910
911fn fresh_qa_id() -> u128 {
912 std::time::SystemTime::now()
913 .duration_since(std::time::UNIX_EPOCH)
914 .map(|duration| duration.as_nanos())
915 .unwrap_or_default()
916}
917
918fn show_tasks(limit: usize) -> Result<()> {
919 let read = RuntimeClient::auto().list_tasks(limit)?;
920 let mut tasks = read.value;
921 tasks.truncate(limit);
922 println!("Mermaid runtime tasks");
923 println!("Source: {}", read.source.as_str());
924 println!();
925 if tasks.is_empty() {
926 println!("No tasks recorded yet.");
927 return Ok(());
928 }
929 for task in tasks {
930 println!(
931 "{} [{}] {} {} {}",
932 task.id, task.status, task.priority, task.updated_at, task.title
933 );
934 println!(" project: {}", task.project_path);
935 println!(" model: {}", task.model_id);
936 }
937 Ok(())
938}
939
940fn show_task(id: &str) -> Result<()> {
941 let detail = RuntimeClient::auto().task_detail(id)?.value;
942 print_task_detail(&detail.task);
943 let events = detail.events;
944 if !events.is_empty() {
945 println!();
946 println!("Timeline:");
947 for event in events {
948 println!(" {} {} {}", event.created_at, event.kind, event.message);
949 }
950 }
951 Ok(())
952}
953
954fn print_task_detail(task: &TaskRecord) {
955 println!("Task: {}", task.id);
956 println!("Title: {}", task.title);
957 println!("Status: {}", task.status);
958 println!("Priority: {}", task.priority);
959 println!("Project: {}", task.project_path);
960 println!("Model: {}", task.model_id);
961 if let Some(conversation_id) = &task.conversation_id {
962 println!("Conversation: {}", conversation_id);
963 }
964 println!("Created: {}", task.created_at);
965 println!("Updated: {}", task.updated_at);
966 if let Some(report) = &task.final_report {
967 println!();
968 println!("Final report:");
969 println!("{}", report);
970 }
971}
972
973fn show_processes(limit: usize) -> Result<()> {
974 let read = RuntimeClient::auto().list_processes(limit)?;
975 let mut processes = read.value;
976 processes.truncate(limit);
977 println!("Mermaid runtime processes");
978 println!("Source: {}", read.source.as_str());
979 println!();
980 if processes.is_empty() {
981 println!("No processes recorded yet.");
982 return Ok(());
983 }
984 for process in processes {
985 println!(
986 "{} pid={} status={} {}",
987 process.id,
988 process.pid,
989 process.status.as_str(),
990 process.command
991 );
992 if let Some(task_id) = process.task_id {
993 println!(" task: {}", task_id);
994 }
995 if let Some(cwd) = process.cwd {
996 println!(" cwd: {}", cwd);
997 }
998 if let Some(log_path) = process.log_path {
999 println!(" log: {}", log_path);
1000 }
1001 if let Some(url) = process.detected_url {
1002 println!(" url: {}", url);
1003 }
1004 }
1005 Ok(())
1006}
1007
1008async fn show_models(config: &Config) -> Result<()> {
1009 list_models(config).await?;
1010 probe_configured_provider_models(config).await?;
1011 let store = RuntimeStore::open_default()?;
1012 let probes = store.provider_probes().list(None, None)?;
1013 if !probes.is_empty() {
1014 println!("\nCached capability probes:");
1015 for probe in probes {
1016 println!(
1017 " - {}/{} {}={} ({})",
1018 probe.provider,
1019 probe.model_id,
1020 probe.capability_key,
1021 probe.capability_value,
1022 probe.confidence
1023 );
1024 }
1025 }
1026 Ok(())
1027}
1028
1029fn show_model_info(model: &str) -> Result<()> {
1030 let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(model);
1031 let store = RuntimeStore::open_default()?;
1032 let provider = snapshot.provider.clone();
1033 for (key, value) in [
1034 ("supports_tools", snapshot.supports_tools.to_string()),
1035 ("supports_vision", snapshot.supports_vision.to_string()),
1036 ("reasoning", snapshot.reasoning.clone()),
1037 (
1038 "max_context_tokens",
1039 snapshot
1040 .max_context_tokens
1041 .map(|n| n.to_string())
1042 .unwrap_or_else(|| "unknown".to_string()),
1043 ),
1044 ] {
1045 let _ = store.provider_probes().upsert(NewProviderProbe {
1046 provider: provider.clone(),
1047 model_id: snapshot.model.clone(),
1048 capability_key: key.to_string(),
1049 capability_value: value,
1050 confidence: "static".to_string(),
1051 error: None,
1052 });
1053 }
1054 println!("Model: {}", model);
1055 println!("Provider: {}", snapshot.provider);
1056 println!("Name: {}", snapshot.model);
1057 println!("Supports tools: {}", snapshot.supports_tools);
1058 println!("Supports vision: {}", snapshot.supports_vision);
1059 println!("Reasoning: {}", snapshot.reasoning);
1060 println!(
1061 "Context: {}",
1062 snapshot
1063 .max_context_tokens
1064 .map(|n| n.to_string())
1065 .unwrap_or_else(|| "unknown".to_string())
1066 );
1067 if let Some(profile) = lookup_provider(&snapshot.provider) {
1068 for (key, value) in [
1069 (
1070 "max_output_tokens_param",
1071 format!("{:?}", profile.max_tokens_param),
1072 ),
1073 (
1074 "parallel_tool_calls",
1075 (!profile
1076 .disable_parallel_tool_calls_for
1077 .iter()
1078 .any(|disabled| *disabled == snapshot.model))
1079 .to_string(),
1080 ),
1081 (
1082 "reasoning_parameter_shape",
1083 format!("{:?}", profile.reasoning_strategy),
1084 ),
1085 (
1086 "streaming_usage_available",
1087 "provider_dependent".to_string(),
1088 ),
1089 ("token_usage_field_shape", "openai_compatible".to_string()),
1090 ] {
1091 let _ = store.provider_probes().upsert(NewProviderProbe {
1092 provider: provider.clone(),
1093 model_id: snapshot.model.clone(),
1094 capability_key: key.to_string(),
1095 capability_value: value,
1096 confidence: "static".to_string(),
1097 error: None,
1098 });
1099 }
1100 println!("Token budget field: {:?}", profile.max_tokens_param);
1101 println!(
1102 "Single-tool-call models: {}",
1103 if profile.disable_parallel_tool_calls_for.is_empty() {
1104 "(none)".to_string()
1105 } else {
1106 profile.disable_parallel_tool_calls_for.join(", ")
1107 }
1108 );
1109 }
1110 Ok(())
1111}
1112
1113async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1114 let client = reqwest::Client::builder()
1115 .timeout(std::time::Duration::from_secs(5))
1116 .build()?;
1117 for profile in PROVIDER_REGISTRY {
1118 let user_cfg = config.providers.get(profile.name);
1119 let env = user_cfg
1120 .and_then(|c| c.api_key_env.as_deref())
1121 .unwrap_or(profile.api_key_env);
1122 let Some(api_key) = resolve_api_key(env, None) else {
1123 continue;
1124 };
1125 let base_url = user_cfg
1126 .and_then(|c| c.base_url.clone())
1127 .unwrap_or_else(|| profile.base_url.to_string());
1128 let url = format!("{}/models", base_url.trim_end_matches('/'));
1129 let mut request = client.get(&url).bearer_auth(api_key);
1130 for (name, value) in profile.extra_headers {
1131 request = request.header(*name, *value);
1132 }
1133 if let Some(user_cfg) = user_cfg {
1134 for (name, value) in &user_cfg.extra_headers {
1135 request = request.header(name, value);
1136 }
1137 }
1138
1139 let result = request.send().await;
1140 match result {
1141 Ok(response) if response.status().is_success() => {
1142 let status = response.status();
1143 let body: serde_json::Value = response.json().await.unwrap_or_default();
1144 let ids = body
1145 .get("data")
1146 .and_then(|v| v.as_array())
1147 .map(|items| {
1148 items
1149 .iter()
1150 .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1151 .map(str::to_string)
1152 .collect::<Vec<_>>()
1153 })
1154 .unwrap_or_default();
1155 record_provider_probe(
1156 profile.name,
1157 "*",
1158 "models_availability",
1159 &format!("available:{}:{}", status.as_u16(), ids.len()),
1160 "probed",
1161 None,
1162 );
1163 for model_id in ids.into_iter().take(200) {
1164 record_provider_probe(
1165 profile.name,
1166 &model_id,
1167 "model_listed",
1168 "true",
1169 "listed",
1170 None,
1171 );
1172 }
1173 },
1174 Ok(response) => {
1175 record_provider_probe(
1176 profile.name,
1177 "*",
1178 "models_availability",
1179 "failed",
1180 "failed",
1181 Some(format!("HTTP {}", response.status().as_u16())),
1182 );
1183 },
1184 Err(error) => {
1185 record_provider_probe(
1186 profile.name,
1187 "*",
1188 "models_availability",
1189 "failed",
1190 "failed",
1191 Some(error.to_string()),
1192 );
1193 },
1194 }
1195 }
1196 Ok(())
1197}
1198
1199fn record_provider_probe(
1200 provider: &str,
1201 model_id: &str,
1202 key: &str,
1203 value: &str,
1204 confidence: &str,
1205 error: Option<String>,
1206) {
1207 if let Ok(store) = RuntimeStore::open_default() {
1208 let _ = store.provider_probes().upsert(NewProviderProbe {
1209 provider: provider.to_string(),
1210 model_id: model_id.to_string(),
1211 capability_key: key.to_string(),
1212 capability_value: value.to_string(),
1213 confidence: confidence.to_string(),
1214 error,
1215 });
1216 }
1217}
1218
1219fn show_approvals() -> Result<()> {
1220 let approvals = RuntimeClient::auto().list_approvals()?.value;
1221 if approvals.is_empty() {
1222 println!("No pending approvals.");
1223 return Ok(());
1224 }
1225 for approval in approvals {
1226 println!(
1227 "{} [{} -> {}] {}",
1228 approval.id,
1229 approval.risk_classification,
1230 approval.policy_decision,
1231 approval.proposed_action
1232 );
1233 if let Some(args) = approval.args_summary {
1234 println!(" args: {}", args);
1235 }
1236 if let Some(checkpoint_id) = approval.checkpoint_id {
1237 println!(" checkpoint: {}", checkpoint_id);
1238 }
1239 if approval.pending_action_json.is_some() {
1240 println!(" pending action: recorded");
1241 }
1242 }
1243 Ok(())
1244}
1245
1246fn approve(id: &str) -> Result<()> {
1247 let result = RuntimeClient::auto().approve(id)?;
1248 println!("Approved {}", id);
1249 if result.replayed {
1250 println!("{}", result.summary);
1251 }
1252 Ok(())
1253}
1254
1255fn deny(id: &str) -> Result<()> {
1256 let _ = RuntimeClient::auto().deny(id)?;
1257 println!("Denied {}", id);
1258 Ok(())
1259}
1260
1261fn show_tool_runs(limit: usize) -> Result<()> {
1262 let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1263 runs.truncate(limit);
1264 if runs.is_empty() {
1265 println!("No tool runs recorded yet.");
1266 return Ok(());
1267 }
1268 for run in runs {
1269 println!(
1270 "{} [{}] {} started {}",
1271 run.id, run.status, run.tool_name, run.started_at
1272 );
1273 if let Some(turn_id) = run.turn_id {
1274 println!(" turn: {}", turn_id);
1275 }
1276 if let Some(call_id) = run.call_id {
1277 println!(" call: {}", call_id);
1278 }
1279 if let Some(finished_at) = run.finished_at {
1280 println!(" finished: {}", finished_at);
1281 }
1282 }
1283 Ok(())
1284}
1285
1286fn show_checkpoints(limit: usize) -> Result<()> {
1287 let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1288 checkpoints.truncate(limit);
1289 if checkpoints.is_empty() {
1290 println!("No checkpoints recorded yet.");
1291 return Ok(());
1292 }
1293 for checkpoint in checkpoints {
1294 println!(
1295 "{} {} {}",
1296 checkpoint.id, checkpoint.created_at, checkpoint.project_path
1297 );
1298 println!(" snapshot: {}", checkpoint.snapshot_path);
1299 println!(" files: {}", checkpoint.changed_files_json);
1300 if let Some(approval_id) = checkpoint.approval_id {
1301 println!(" approval: {}", approval_id);
1302 }
1303 }
1304 Ok(())
1305}
1306
1307fn restore_checkpoint(id: &str) -> Result<()> {
1308 let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
1309 println!("Restored {} ({} files)", manifest.id, manifest.files.len());
1310 if let Some(repo) = manifest.shadow_git_repo {
1311 println!("Shadow repo: {}", repo);
1312 }
1313 if let Some(commit) = manifest.shadow_git_commit {
1314 println!("Shadow commit: {}", commit);
1315 }
1316 if let Some(action) = manifest.pending_action {
1317 println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
1318 }
1319 Ok(())
1320}
1321
1322fn show_memory(project: Option<&Path>) -> Result<()> {
1323 let project_path = project.map(|p| p.display().to_string()).or_else(|| {
1324 std::env::current_dir()
1325 .ok()
1326 .map(|p| p.display().to_string())
1327 });
1328 let entries = RuntimeClient::auto()
1329 .list_memory(project_path.as_deref())?
1330 .value;
1331 if entries.is_empty() {
1332 println!("No memory entries.");
1333 return Ok(());
1334 }
1335 for entry in entries {
1336 println!(
1337 "{} [{}] {} = {}",
1338 entry.id, entry.scope, entry.key, entry.value
1339 );
1340 }
1341 Ok(())
1342}
1343
1344fn remember(key: &str, value: &str, project: Option<&Path>) -> Result<()> {
1345 let project_path = project.map(|p| p.display().to_string()).or_else(|| {
1346 std::env::current_dir()
1347 .ok()
1348 .map(|p| p.display().to_string())
1349 });
1350 let entry = RuntimeClient::auto()
1351 .remember_memory(project_path, key, value, "cli")?
1352 .value;
1353 println!("Remembered {} ({})", entry.key, entry.id);
1354 Ok(())
1355}
1356
1357fn forget(id: &str) -> Result<()> {
1358 RuntimeClient::auto().forget_memory(id)?;
1359 println!("Forgot {}", id);
1360 Ok(())
1361}
1362
1363fn memory_edit(id: &str, value: &str) -> Result<()> {
1364 let entry = RuntimeClient::auto().edit_memory(id, value, "cli")?.value;
1365 println!("Updated {} ({})", entry.key, entry.id);
1366 Ok(())
1367}
1368
1369fn handle_plugin(command: &PluginCommand) -> Result<()> {
1370 match command {
1371 PluginCommand::Install { path } => {
1372 let preview = crate::runtime::plugin_permission_preview(path)?;
1373 print_plugin_permission_preview(&preview);
1374 let record = crate::runtime::install_plugin_from_path(path)?;
1375 println!("Installed plugin {} ({})", record.name, record.id);
1376 },
1377 PluginCommand::List => {
1378 let plugins = RuntimeClient::auto().list_plugins()?.value;
1379 if plugins.is_empty() {
1380 println!("No plugins installed.");
1381 } else {
1382 for plugin in plugins {
1383 println!(
1384 "{} [{}] {} ({})",
1385 plugin.id,
1386 if plugin.enabled {
1387 "enabled"
1388 } else {
1389 "disabled"
1390 },
1391 plugin.name,
1392 plugin.source
1393 );
1394 }
1395 }
1396 },
1397 PluginCommand::Enable { id } => {
1398 RuntimeClient::auto().set_plugin_enabled(id, true)?;
1399 println!("Enabled plugin {}", id);
1400 },
1401 PluginCommand::Disable { id } => {
1402 RuntimeClient::auto().set_plugin_enabled(id, false)?;
1403 println!("Disabled plugin {}", id);
1404 },
1405 PluginCommand::Audit { path } => {
1406 let manifest_path = if path.is_dir() {
1407 path.join("plugin.toml")
1408 } else {
1409 path.clone()
1410 };
1411 let raw = std::fs::read_to_string(&manifest_path)?;
1412 let manifest: crate::runtime::PluginManifest = toml::from_str(&raw)?;
1413 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1414 crate::runtime::validate_plugin_manifest(&manifest, root)?;
1415 let preview = crate::runtime::plugin_permission_preview(path)?;
1416 println!("Plugin manifest is valid: {}", manifest.name);
1417 print_plugin_permission_preview(&preview);
1418 },
1419 }
1420 Ok(())
1421}
1422
1423fn print_plugin_permission_preview(preview: &crate::runtime::PluginPermissionPreview) {
1424 println!("Permission preview for plugin {}", preview.name);
1425 if preview.declared_permissions.is_empty() && preview.permissions_toml.is_none() {
1426 println!(" permissions: (none declared)");
1427 } else {
1428 if !preview.declared_permissions.is_empty() {
1429 println!(" declared: {}", preview.declared_permissions.join(", "));
1430 }
1431 if let Some(value) = &preview.permissions_toml {
1432 println!(
1433 " permissions.toml: {}",
1434 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
1435 );
1436 }
1437 }
1438 if !preview.hooks.is_empty() {
1439 println!(" hooks: {}", preview.hooks.join(", "));
1440 }
1441 if !preview.mcp.is_empty() {
1442 println!(" mcp: {}", preview.mcp.join(", "));
1443 }
1444 if !preview.bin.is_empty() {
1445 println!(" bin: {}", preview.bin.join(", "));
1446 }
1447}
1448
1449fn pair(label: Option<&str>) -> Result<()> {
1450 let (token, hash) = crate::runtime::generate_pairing_token()?;
1451 let record = RuntimeStore::open_default()?
1452 .pairing_tokens()
1453 .create(&hash, label)?;
1454 println!("Pairing token id: {}", record.id);
1455 println!("Pairing token: {}", token);
1456 println!(
1457 "Use with daemon JSON by setting {}.",
1458 crate::runtime::daemon::DAEMON_TOKEN_ENV
1459 );
1460 println!("Store this now; Mermaid will not print it again.");
1461 Ok(())
1462}
1463
1464fn show_logs(id: &str) -> Result<()> {
1465 let content = RuntimeClient::auto().process_log(id, None)?.content;
1466 print!("{}", content);
1467 Ok(())
1468}
1469
1470fn stop_process(id: &str) -> Result<()> {
1471 let process = RuntimeClient::auto().stop_process(id)?.item;
1472 println!("Stopped process {} (pid {})", id, process.pid);
1473 Ok(())
1474}
1475
1476fn restart_process(id: &str) -> Result<()> {
1477 let process = RuntimeClient::auto().restart_process(id)?.item;
1478 println!("Restarted process {} (pid {})", id, process.pid);
1479 Ok(())
1480}
1481
1482fn open_target(target: &str) -> Result<()> {
1483 if RuntimeClient::auto().open_process(target).is_err() {
1484 crate::utils::open_file(target);
1485 }
1486 Ok(())
1487}
1488
1489fn show_ports() -> Result<()> {
1490 print!("{}", RuntimeClient::auto().ports()?.ports);
1491 Ok(())
1492}
1493
1494pub async fn list_models(config: &Config) -> Result<()> {
1496 let ollama_models = list_ollama_models(config).await;
1497 if ollama_models.is_empty() {
1498 println!("No Ollama models installed locally.");
1499 } else {
1500 println!("Ollama models (local/cloud):");
1501 for name in &ollama_models {
1502 println!(" - ollama/{}", name);
1503 }
1504 }
1505
1506 println!("\nConfigured remote providers:");
1507 let mut any = false;
1508 for profile in PROVIDER_REGISTRY {
1509 let env = config
1510 .providers
1511 .get(profile.name)
1512 .and_then(|c| c.api_key_env.as_deref())
1513 .unwrap_or(profile.api_key_env);
1514 if resolve_api_key(env, None).is_some() {
1515 any = true;
1516 println!(" - {} (via ${})", profile.name, env);
1517 }
1518 }
1519 if !any {
1520 println!(" (none — set a provider API key env var to enable)");
1521 }
1522 println!("\nSwitch models in-session with /model <name>.");
1523 Ok(())
1524}
1525
1526async fn list_ollama_models(config: &Config) -> Vec<String> {
1530 use crate::models::adapters::ollama::OllamaAdapter;
1531 let backend = BackendConfig {
1532 ollama_url: format!("http://{}:{}", config.ollama.host, config.ollama.port),
1533 timeout_secs: 5,
1534 max_idle_per_host: 2,
1535 };
1536 match OllamaAdapter::new("__list__", Arc::new(backend)).await {
1537 Ok(adapter) => adapter.list_models().await.unwrap_or_default(),
1538 Err(_) => Vec::new(),
1539 }
1540}
1541
1542pub fn show_version() {
1544 println!("Mermaid v{}", env!("CARGO_PKG_VERSION"));
1545 println!(" An open-source, model-agnostic AI pair programmer");
1546}
1547
1548fn show_mcp_servers() {
1550 let config = load_config().unwrap_or_default();
1551
1552 if config.mcp_servers.is_empty() {
1553 println!("No MCP servers configured.\n");
1554 println!("Add one with: mermaid add <name>");
1555 println!("Examples:");
1556 println!(" mermaid add context7 # Library documentation");
1557 println!(" mermaid add playwright # Browser automation");
1558 println!(" mermaid add memory # Persistent knowledge graph");
1559 return;
1560 }
1561
1562 println!("Configured MCP servers:\n");
1563 for (name, server_cfg) in &config.mcp_servers {
1564 let package = server_cfg
1565 .args
1566 .iter()
1567 .find(|a| !a.starts_with('-'))
1568 .unwrap_or(&server_cfg.command);
1569 let env_keys: Vec<&String> = server_cfg.env.keys().collect();
1570 let env_display = if env_keys.is_empty() {
1571 String::new()
1572 } else {
1573 format!(
1574 " (env: {})",
1575 env_keys
1576 .iter()
1577 .map(|k| k.as_str())
1578 .collect::<Vec<_>>()
1579 .join(", ")
1580 )
1581 };
1582 println!(" {} — {}{}", name, package, env_display);
1583 }
1584 println!("\nManage with: mermaid add <name> / mermaid remove <name>");
1585}
1586
1587async fn show_status(config: &Config) -> Result<()> {
1589 println!("Mermaid Status:");
1590 println!();
1591
1592 let mut available: Vec<&'static str> = Vec::new();
1595 for profile in PROVIDER_REGISTRY {
1596 let env = config
1597 .providers
1598 .get(profile.name)
1599 .and_then(|c| c.api_key_env.as_deref())
1600 .unwrap_or(profile.api_key_env);
1601 if resolve_api_key(env, None).is_some() {
1602 available.push(profile.name);
1603 }
1604 }
1605 if available.is_empty() {
1606 println!(" [WARNING] Remote providers: none (no API keys in env)");
1607 } else {
1608 println!(" [OK] Remote providers: {}", available.join(", "));
1609 }
1610
1611 if is_ollama_installed() {
1613 let models = list_ollama_models(config).await;
1614 if models.is_empty() {
1615 println!(" [WARNING] Ollama: Installed (no models)");
1616 } else {
1617 println!(" [OK] Ollama: Running ({} models installed)", models.len());
1618 for model in models.iter().take(3) {
1619 println!(" - {}", model);
1620 }
1621 if models.len() > 3 {
1622 println!(" ... and {} more", models.len() - 3);
1623 }
1624 }
1625 } else {
1626 println!(" [ERROR] Ollama: Not installed");
1627 }
1628
1629 if let Ok(config_dir) = get_config_dir() {
1631 let config_path = config_dir.join("config.toml");
1632 if config_path.exists() {
1633 println!(" [OK] Configuration: {}", config_path.display());
1634 } else {
1635 println!(" [WARNING] Configuration: Not found (using defaults)");
1636 }
1637 }
1638
1639 if config.mcp_servers.is_empty() {
1641 println!(" [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
1642 } else {
1643 println!(
1644 " [OK] MCP Servers: {} configured",
1645 config.mcp_servers.len()
1646 );
1647 for (name, server_cfg) in &config.mcp_servers {
1648 println!(
1649 " - {} ({})",
1650 name,
1651 server_cfg.args.get(1).unwrap_or(&server_cfg.command)
1652 );
1653 }
1654 }
1655
1656 {
1659 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1660 let paths = crate::app::instructions::find_instruction_files(&cwd);
1661 if paths.is_empty() {
1662 println!(
1663 " [INFO] Project instructions: not found (MERMAID.md, AGENTS.md, CLAUDE.md, GEMINI.md)"
1664 );
1665 } else {
1666 match crate::app::instructions::load_from_paths(&paths) {
1667 Some(loaded) => {
1668 let files = loaded
1669 .sources
1670 .iter()
1671 .map(|source| {
1672 source
1673 .path
1674 .file_name()
1675 .and_then(|name| name.to_str())
1676 .unwrap_or("instructions")
1677 })
1678 .collect::<Vec<_>>()
1679 .join(", ");
1680 println!(
1681 " [OK] Project instructions: {} at {} ({} bytes{})",
1682 files,
1683 loaded.path.display(),
1684 loaded.byte_len,
1685 if loaded.truncated { ", truncated" } else { "" }
1686 );
1687 },
1688 None => {
1689 println!(
1690 " [WARNING] Project instructions: found but unreadable ({})",
1691 paths
1692 .iter()
1693 .map(|path| path.display().to_string())
1694 .collect::<Vec<_>>()
1695 .join(", ")
1696 );
1697 },
1698 }
1699 }
1700 }
1701
1702 show_provider_status(config);
1706
1707 println!("\n Environment:");
1709 if std::env::var("OLLAMA_API_KEY").is_ok() {
1710 println!(" - OLLAMA_API_KEY: Set (for Ollama Cloud)");
1711 }
1712
1713 println!();
1714 Ok(())
1715}
1716
1717fn show_provider_status(config: &Config) {
1722 let mut configured: Vec<(String, String)> = Vec::new(); let anth_cfg = config.providers.get("anthropic");
1727 if resolve_api_key(
1728 "ANTHROPIC_API_KEY",
1729 anth_cfg.and_then(|c| c.api_key_env.as_deref()),
1730 )
1731 .is_some()
1732 {
1733 let url = anth_cfg
1734 .and_then(|c| c.base_url.clone())
1735 .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
1736 configured.push(("anthropic".to_string(), url));
1737 }
1738
1739 let gem_cfg = config.providers.get("gemini");
1741 if resolve_api_key_with_fallback(
1742 "GOOGLE_API_KEY",
1743 "GEMINI_API_KEY",
1744 gem_cfg.and_then(|c| c.api_key_env.as_deref()),
1745 )
1746 .is_some()
1747 {
1748 let url = gem_cfg
1749 .and_then(|c| c.base_url.clone())
1750 .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
1751 configured.push(("gemini".to_string(), url));
1752 }
1753
1754 for profile in PROVIDER_REGISTRY {
1755 let user_cfg = config.providers.get(profile.name);
1756 let api_key_present = resolve_api_key(
1757 profile.api_key_env,
1758 user_cfg.and_then(|c| c.api_key_env.as_deref()),
1759 )
1760 .is_some();
1761 if api_key_present {
1762 let url = user_cfg
1763 .and_then(|c| c.base_url.clone())
1764 .unwrap_or_else(|| profile.base_url.to_string());
1765 configured.push((profile.name.to_string(), url));
1766 }
1767 }
1768
1769 for (name, cfg) in &config.providers {
1772 if name == "anthropic" || name == "gemini" || lookup_provider(name).is_some() {
1773 continue;
1774 }
1775 if let (Some(url), Some(env)) = (&cfg.base_url, cfg.api_key_env.as_deref())
1776 && resolve_api_key(env, None).is_some()
1777 {
1778 configured.push((name.clone(), url.clone()));
1779 }
1780 }
1781
1782 if configured.is_empty() {
1783 println!(
1784 " [INFO] Remote providers: None configured (set $ANTHROPIC_API_KEY, \
1785 $GOOGLE_API_KEY, $OPENAI_API_KEY, $GROQ_API_KEY, $OPENROUTER_API_KEY, etc., or \
1786 add [providers.<name>] to config.toml)"
1787 );
1788 } else {
1789 println!(" [OK] Remote providers: {} configured", configured.len());
1790 for (name, url) in configured {
1791 println!(" - {} ({})", name, url);
1792 }
1793 }
1794}
1795
1796fn handle_pr(command: &PrCommand) -> Result<()> {
1798 match command {
1799 PrCommand::Create {
1800 title,
1801 body,
1802 summary,
1803 base,
1804 draft,
1805 web,
1806 provider,
1807 } => create_pr(CreatePrArgs {
1808 title: title.as_deref(),
1809 body: body.as_deref(),
1810 summary: summary.as_deref(),
1811 base: base.as_deref(),
1812 draft: *draft,
1813 web: *web,
1814 provider: *provider,
1815 }),
1816 }
1817}
1818
1819struct CreatePrArgs<'a> {
1820 title: Option<&'a str>,
1821 body: Option<&'a str>,
1822 summary: Option<&'a Path>,
1823 base: Option<&'a str>,
1824 draft: bool,
1825 web: bool,
1826 provider: Option<GitHost>,
1827}
1828
1829fn create_pr(args: CreatePrArgs) -> Result<()> {
1834 let body = match args.summary {
1836 Some(path) => Some(
1837 std::fs::read_to_string(path)
1838 .with_context(|| format!("failed to read summary file {}", path.display()))?,
1839 ),
1840 None => args.body.map(str::to_string),
1841 };
1842
1843 let host = match args.provider {
1844 Some(host) => host,
1845 None => detect_git_host()?,
1846 };
1847
1848 let (cli, install_hint) = match host {
1849 GitHost::Github => (
1850 "gh",
1851 "Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.",
1852 ),
1853 GitHost::Gitlab => (
1854 "glab",
1855 "Install the GitLab CLI (https://gitlab.com/gitlab-org/cli) and run `glab auth login`.",
1856 ),
1857 };
1858 if which::which(cli).is_err() {
1859 anyhow::bail!("`{cli}` was not found on your PATH. {install_hint}");
1860 }
1861
1862 let argv = build_pr_argv(
1863 host,
1864 args.title,
1865 body.as_deref(),
1866 args.base,
1867 args.draft,
1868 args.web,
1869 );
1870
1871 println!("Creating pull/merge request via `{cli}`…");
1872 let status = std::process::Command::new(cli)
1873 .args(&argv)
1874 .status()
1875 .with_context(|| format!("failed to run `{cli}`"))?;
1876 anyhow::ensure!(status.success(), "`{cli}` exited unsuccessfully ({status})");
1877 Ok(())
1878}
1879
1880fn detect_git_host() -> Result<GitHost> {
1883 if let Some(host) = git_origin_host() {
1884 return Ok(host);
1885 }
1886 if which::which("gh").is_ok() {
1887 return Ok(GitHost::Github);
1888 }
1889 if which::which("glab").is_ok() {
1890 return Ok(GitHost::Gitlab);
1891 }
1892 anyhow::bail!(
1893 "could not detect a Git host from the `origin` remote. Pass `--provider github|gitlab` and install the matching CLI (`gh`/`glab`)."
1894 )
1895}
1896
1897fn git_origin_host() -> Option<GitHost> {
1898 let output = std::process::Command::new("git")
1899 .args(["config", "--get", "remote.origin.url"])
1900 .output()
1901 .ok()?;
1902 if !output.status.success() {
1903 return None;
1904 }
1905 host_from_remote_url(String::from_utf8_lossy(&output.stdout).trim())
1906}
1907
1908fn host_from_remote_url(url: &str) -> Option<GitHost> {
1909 let lower = url.to_ascii_lowercase();
1910 if lower.contains("github.com") {
1911 Some(GitHost::Github)
1912 } else if lower.contains("gitlab") {
1913 Some(GitHost::Gitlab)
1914 } else {
1915 None
1916 }
1917}
1918
1919fn build_pr_argv(
1921 host: GitHost,
1922 title: Option<&str>,
1923 body: Option<&str>,
1924 base: Option<&str>,
1925 draft: bool,
1926 web: bool,
1927) -> Vec<String> {
1928 let s = |v: &str| v.to_string();
1929 let has_content = title.is_some() || body.is_some();
1930 let mut argv = Vec::new();
1931 match host {
1932 GitHost::Github => {
1933 argv.push(s("pr"));
1934 argv.push(s("create"));
1935 if web {
1936 argv.push(s("--web"));
1937 }
1938 if draft {
1939 argv.push(s("--draft"));
1940 }
1941 if let Some(title) = title {
1942 argv.push(s("--title"));
1943 argv.push(s(title));
1944 }
1945 if let Some(body) = body {
1946 argv.push(s("--body"));
1947 argv.push(s(body));
1948 }
1949 if !has_content && !web {
1953 argv.push(s("--fill"));
1954 }
1955 if let Some(base) = base {
1956 argv.push(s("--base"));
1957 argv.push(s(base));
1958 }
1959 },
1960 GitHost::Gitlab => {
1961 argv.push(s("mr"));
1962 argv.push(s("create"));
1963 if web {
1964 argv.push(s("--web"));
1965 }
1966 if draft {
1967 argv.push(s("--draft"));
1968 }
1969 if let Some(title) = title {
1970 argv.push(s("--title"));
1971 argv.push(s(title));
1972 }
1973 if let Some(body) = body {
1974 argv.push(s("--description"));
1975 argv.push(s(body));
1976 }
1977 if !has_content && !web {
1978 argv.push(s("--fill"));
1979 }
1980 if let Some(base) = base {
1981 argv.push(s("--target-branch"));
1982 argv.push(s(base));
1983 }
1984 },
1985 }
1986 argv
1987}
1988
1989#[cfg(test)]
1990mod tests {
1991 use super::*;
1992
1993 #[test]
1994 fn host_from_remote_url_detects_provider() {
1995 assert_eq!(
1996 host_from_remote_url("https://github.com/foo/bar.git"),
1997 Some(GitHost::Github)
1998 );
1999 assert_eq!(
2000 host_from_remote_url("git@github.com:foo/bar.git"),
2001 Some(GitHost::Github)
2002 );
2003 assert_eq!(
2004 host_from_remote_url("https://gitlab.com/foo/bar.git"),
2005 Some(GitHost::Gitlab)
2006 );
2007 assert_eq!(
2008 host_from_remote_url("git@gitlab.example.com:foo/bar.git"),
2009 Some(GitHost::Gitlab)
2010 );
2011 assert_eq!(host_from_remote_url("https://bitbucket.org/foo/bar"), None);
2012 }
2013
2014 #[test]
2015 fn build_pr_argv_github_with_content() {
2016 let argv = build_pr_argv(
2017 GitHost::Github,
2018 Some("T"),
2019 Some("B"),
2020 Some("main"),
2021 true,
2022 false,
2023 );
2024 assert_eq!(
2025 argv,
2026 vec![
2027 "pr", "create", "--draft", "--title", "T", "--body", "B", "--base", "main"
2028 ]
2029 );
2030 }
2031
2032 #[test]
2033 fn build_pr_argv_github_fills_without_content() {
2034 let argv = build_pr_argv(GitHost::Github, None, None, None, false, false);
2035 assert!(argv.contains(&"--fill".to_string()));
2036 assert!(!argv.contains(&"--title".to_string()));
2037 }
2038
2039 #[test]
2040 fn build_pr_argv_web_skips_fill() {
2041 let argv = build_pr_argv(GitHost::Github, None, None, None, false, true);
2042 assert!(argv.contains(&"--web".to_string()));
2043 assert!(!argv.contains(&"--fill".to_string()));
2044 }
2045
2046 #[test]
2047 fn build_pr_argv_gitlab_uses_mr_and_target_branch() {
2048 let argv = build_pr_argv(GitHost::Gitlab, Some("T"), None, Some("main"), false, false);
2049 assert_eq!(&argv[0..2], &["mr", "create"]);
2050 assert!(argv.windows(2).any(|w| w == ["--target-branch", "main"]));
2051 assert!(argv.contains(&"--title".to_string()));
2052 }
2053
2054 #[test]
2055 fn qa_compact_smoke_persists_conversation_and_archive() {
2056 let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2057 std::fs::create_dir_all(&dir).unwrap();
2058
2059 let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2060
2061 assert!(report.ok);
2062 assert!(report.archived_messages > 0);
2063 assert!(report.preserved_messages > 0);
2064 assert!(report.replacement_messages >= 3);
2065 assert!(
2066 std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2067 "conversation path should exist"
2068 );
2069 assert!(
2070 std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2071 "archive path should exist"
2072 );
2073
2074 let _ = std::fs::remove_dir_all(dir);
2075 }
2076
2077 #[test]
2078 fn qa_model_id_falls_back_to_deterministic() {
2079 assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2080 }
2081
2082 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2083 let nanos = std::time::SystemTime::now()
2084 .duration_since(std::time::UNIX_EPOCH)
2085 .map(|duration| duration.as_nanos())
2086 .unwrap_or_default();
2087 std::env::temp_dir().join(format!("{name}-{nanos}"))
2088 }
2089}