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