1use anyhow::{Context, Result, anyhow, bail};
2use std::path::Path;
3
4use mermaid_runtime::{NewProviderProbe, RuntimeStore, TaskRecord};
5
6use mermaid_model::models::{ChatMessage, PROVIDER_REGISTRY, lookup_provider};
7
8use mermaid_domain::Config;
9
10use mermaid_domain::{
11 ChatRequest, Cmd, CompactionEvent, CompactionResult, CompactionTrigger, Msg, SlashCmd, State,
12 build_replacement_messages, estimate_context_usage_for_request, prepare_compaction, update,
13};
14
15use crate::{
16 app::{get_config_dir, init_config, load_config_or_warn},
17 ollama::{LocalModelListing, is_installed as is_ollama_installed, observe_models},
18 providers::discovery::{configured_remote_provider_names, configured_remote_providers},
19 runtime_client::{RuntimeClient, record_static_provider_probes},
20 session::ConversationManager,
21};
22
23use super::{Commands, GitHost, OutputFormat, PairCommand, PluginCommand, PrCommand, QaCommand};
24
25#[expect(
37 clippy::too_many_lines,
38 reason = "predates the lint; see .github/baselines/expect_budget.txt"
39)]
40pub async fn handle_command(
41 command: &Commands,
42 config: &Config,
43 cwd: &Path,
44 cli_model: Option<&str>,
45) -> Result<bool> {
46 match command {
47 Commands::Init => {
48 println!("Initializing Mermaid configuration...");
49 init_config()?;
50 println!("Configuration initialized successfully!");
51 Ok(true)
52 },
53 Commands::List => {
54 list_models(config).await?;
55 Ok(true)
56 },
57 Commands::Models => {
58 show_models(config).await?;
59 Ok(true)
60 },
61 Commands::ModelInfo { model } => {
62 show_model_info(model, config).await?;
63 Ok(true)
64 },
65 Commands::Version => {
66 show_version();
67 Ok(true)
68 },
69 Commands::Update { check, force } => {
70 run_update(*check, *force).await?;
71 Ok(true)
72 },
73 Commands::Status => {
74 show_status(config).await?;
75 Ok(true)
76 },
77 Commands::Doctor { format } => {
78 show_doctor(config, cwd, cli_model, *format).await?;
79 Ok(true)
80 },
81 Commands::Feedback { stdout, format } => {
82 super::feedback::run_feedback(config, cwd, cli_model, *stdout, *format).await?;
83 Ok(true)
84 },
85 Commands::SelfTest {
86 format,
87 keep_workspace,
88 } => {
89 run_self_test(config, *format, *keep_workspace)?;
90 Ok(true)
91 },
92 Commands::Tasks { limit } => {
93 show_tasks(*limit)?;
94 Ok(true)
95 },
96 Commands::Task { id, follow, send } => {
97 if let Some(text) = send {
98 send_to_task(id, text)?;
99 } else if *follow {
100 follow_task(id)?;
101 } else {
102 show_task(id)?;
103 }
104 Ok(true)
105 },
106 Commands::Processes { limit } => {
107 show_processes(*limit)?;
108 Ok(true)
109 },
110 Commands::Logs { id } => {
111 show_logs(id)?;
112 Ok(true)
113 },
114 Commands::Stop { id } => {
115 stop_process(id)?;
116 Ok(true)
117 },
118 Commands::Restart { id } => {
119 restart_process(id)?;
120 Ok(true)
121 },
122 Commands::Open { target } => {
123 open_target(target)?;
124 Ok(true)
125 },
126 Commands::Ports => {
127 show_ports()?;
128 Ok(true)
129 },
130 Commands::Approvals => {
131 show_approvals()?;
132 Ok(true)
133 },
134 Commands::Approve { id } => {
135 approve(id)?;
136 Ok(true)
137 },
138 Commands::Deny { id } => {
139 deny(id)?;
140 Ok(true)
141 },
142 Commands::Cancel { id } => {
143 cancel_task(id)?;
144 Ok(true)
145 },
146 Commands::ToolRuns { limit } => {
147 show_tool_runs(*limit)?;
148 Ok(true)
149 },
150 Commands::Checkpoints { limit } => {
151 show_checkpoints(*limit)?;
152 Ok(true)
153 },
154 Commands::Restore { id, force } => {
155 restore_checkpoint(id, *force)?;
156 Ok(true)
157 },
158 Commands::Plugin { command } => {
159 handle_plugin(command)?;
160 Ok(true)
161 },
162 Commands::Daemon { command } => {
163 super::daemon::handle_daemon_command(command)?;
164 Ok(true)
165 },
166 Commands::Pair { command } => {
167 handle_pair(command)?;
168 Ok(true)
169 },
170 Commands::Qa { command } => {
171 handle_qa(command, config, cwd)?;
172 Ok(true)
173 },
174 Commands::Add {
175 name,
176 yes,
177 command,
178 arg,
179 env,
180 url,
181 header,
182 env_header,
183 } => {
184 match url {
187 Some(url) => {
188 crate::mcp::add_http_server(
189 name,
190 url.clone(),
191 header.clone(),
192 env_header.clone(),
193 )
194 .await?;
195 },
196 None => {
197 crate::mcp::add_server(name, *yes, command.clone(), arg.clone(), env.clone())
198 .await?;
199 },
200 }
201 Ok(true)
202 },
203 Commands::Remove { name } => {
204 crate::mcp::remove_server(name).await?;
205 Ok(true)
206 },
207 Commands::Pr { command } => {
208 handle_pr(command)?;
209 Ok(true)
210 },
211 Commands::Mcp => {
212 show_mcp_servers();
213 Ok(true)
214 },
215 Commands::Login { provider } => {
216 login(provider.as_deref(), config)?;
217 Ok(true)
218 },
219 Commands::Logout { provider } => {
220 logout(provider, config)?;
221 Ok(true)
222 },
223 Commands::CloudSetup => {
224 let _ = crate::ollama::setup_cloud_interactive();
228 Ok(true)
229 },
230 Commands::Chat => Ok(false), Commands::Run { .. } => Ok(false), }
233}
234
235fn handle_qa(command: &QaCommand, config: &Config, cwd: &Path) -> Result<()> {
236 match command {
237 QaCommand::CompactSmoke { turns, format } => {
238 let report = match run_qa_compact_smoke(config, cwd, *turns) {
239 Ok(report) => report,
240 Err(err) => QaCompactSmokeReport::failed(cwd, *turns, err.to_string()),
241 };
242 print_qa_compact_report(&report, *format)?;
243 anyhow::ensure!(report.ok, "qa compact smoke failed");
244 Ok(())
245 },
246 }
247}
248
249#[derive(Debug, serde::Serialize)]
250pub(crate) struct DoctorReport {
251 pub(crate) ok: bool,
252 pub(crate) cwd: String,
253 pub(crate) active_profile: Option<String>,
255 pub(crate) active_model: Option<String>,
256 pub(crate) model_error: Option<String>,
257 pub(crate) model_capabilities: Option<DoctorCapabilities>,
258 pub(crate) safety_mode: String,
259 pub(crate) checkpoint_on_mutation: bool,
260 pub(crate) prompt_customized: bool,
261 pub(crate) ollama: DoctorCheck,
262 pub(crate) remote_providers: Vec<String>,
263 pub(crate) provider_problems: Vec<DoctorProviderProblem>,
266 pub(crate) project_instructions: DoctorCheck,
267 pub(crate) tools: Vec<String>,
268 pub(crate) runtime: DoctorRuntime,
269 pub(crate) session_logs: DoctorCheck,
271 pub(crate) next_steps: Vec<String>,
272}
273
274#[derive(Debug, serde::Serialize)]
275pub(crate) struct DoctorCapabilities {
276 pub(crate) provider: String,
277 pub(crate) name: String,
278 pub(crate) supports_tools: bool,
279 pub(crate) supports_vision: bool,
280 pub(crate) reasoning: String,
281 pub(crate) max_context_tokens: Option<usize>,
282}
283
284#[derive(Debug, serde::Serialize)]
285pub(crate) struct DoctorCheck {
286 pub(crate) status: &'static str,
287 pub(crate) message: String,
288}
289
290#[derive(Debug, serde::Serialize)]
293pub(crate) struct DoctorProviderProblem {
294 pub(crate) name: String,
295 pub(crate) reason: String,
296}
297
298#[derive(Debug, serde::Serialize)]
299pub(crate) struct DoctorRuntime {
300 pub(crate) daemon: DoctorCheck,
301 pub(crate) local_store: DoctorCheck,
302}
303
304fn web_doctor_entries(config: &Config) -> (Vec<String>, Vec<String>) {
305 let capabilities = crate::providers::tool::web::WebCapabilities::resolve(&config.web);
306 let mut tools = Vec::new();
307 let mut next_steps = Vec::new();
308 for (name, status) in [
309 ("web_fetch", capabilities.fetch),
310 ("web_search", capabilities.search),
311 ] {
312 if config.safety.network == mermaid_domain::NetworkPolicy::Deny {
313 next_steps.push(format!(
314 "{name} is disabled by safety.network = \"deny\" (selected backend '{}'; {}).",
315 status.backend, status.trust_destination
316 ));
317 } else if status.available {
318 tools.push(format!(
319 "{name} ({}; {})",
320 status.backend, status.trust_destination
321 ));
322 } else {
323 next_steps.push(format!(
324 "{name} is unavailable with backend '{}': {}.",
325 status.backend,
326 status
327 .reason
328 .as_deref()
329 .unwrap_or("the selected backend could not be initialized")
330 ));
331 }
332 }
333 (tools, next_steps)
334}
335
336async fn show_doctor(
337 config: &Config,
338 cwd: &Path,
339 cli_model: Option<&str>,
340 format: OutputFormat,
341) -> Result<()> {
342 let report = build_doctor_report(config, cwd, cli_model).await;
343 print_doctor_report(&report, format)
344}
345
346#[expect(
349 clippy::too_many_lines,
350 reason = "predates the lint; see .github/baselines/expect_budget.txt"
351)]
352pub(crate) async fn build_doctor_report(
353 config: &Config,
354 cwd: &Path,
355 cli_model: Option<&str>,
356) -> DoctorReport {
357 let active_model_result = crate::app::resolve_model_id(cli_model, config).await;
358 let (active_model, model_error, model_capabilities) = match active_model_result {
359 Ok(model) => {
360 let snapshot = mermaid_domain::ProviderCapabilitySnapshot::from_model_id(&model);
361 (
362 Some(model),
363 None,
364 Some(DoctorCapabilities {
365 provider: snapshot.provider,
366 name: snapshot.model,
367 supports_tools: snapshot.supports_tools,
368 supports_vision: snapshot.supports_vision,
369 reasoning: snapshot.reasoning,
370 max_context_tokens: snapshot.max_context_tokens,
371 }),
372 )
373 },
374 Err(err) => (None, Some(err.to_string()), None),
375 };
376
377 let ollama_models = if is_ollama_installed() {
382 observe_models(config).await
383 } else {
384 LocalModelListing::Unreachable
385 };
386 let ollama = if !is_ollama_installed() {
387 DoctorCheck {
388 status: "warning",
389 message: "Ollama is not installed; remote providers can still work if configured."
390 .to_string(),
391 }
392 } else {
393 match &ollama_models {
394 LocalModelListing::Unreachable => DoctorCheck {
395 status: "warning",
396 message: "Ollama is installed but not running; mermaid starts it \
397 automatically when an Ollama model is used."
398 .to_string(),
399 },
400 LocalModelListing::Live(models) if models.is_empty() => DoctorCheck {
401 status: "warning",
402 message: "Ollama is running but no local/cloud models were listed.".to_string(),
403 },
404 LocalModelListing::Live(models) => DoctorCheck {
405 status: "ok",
406 message: format!("Ollama reachable with {} models.", models.len()),
407 },
408 LocalModelListing::FromDisk(models) => DoctorCheck {
412 status: "ok",
413 message: format!(
414 "Ollama installed, not running — {} model(s) on disk; starts \
415 automatically when used.",
416 models.len()
417 ),
418 },
419 }
420 };
421
422 let remote_providers = configured_remote_provider_names(config);
423 let provider_problems = crate::providers::provider_problems(config)
424 .into_iter()
425 .map(|problem| DoctorProviderProblem {
426 name: problem.name,
427 reason: problem.reason,
428 })
429 .collect::<Vec<_>>();
430 let instruction_paths = crate::app::instructions::find_instruction_files(cwd);
431 let project_instructions = if instruction_paths.is_empty() {
432 DoctorCheck {
433 status: "info",
434 message: "No AGENTS.md or MERMAID.md found.".to_string(),
435 }
436 } else if let Some(loaded) = crate::app::instructions::load_from_paths(&instruction_paths) {
437 DoctorCheck {
438 status: "ok",
439 message: format!(
440 "{} bytes loaded from {} source(s){}.",
441 loaded.byte_len,
442 loaded.sources.len(),
443 if loaded.truncated { " (truncated)" } else { "" }
444 ),
445 }
446 } else {
447 DoctorCheck {
448 status: "warning",
449 message: "Instruction files were found but could not be loaded.".to_string(),
450 }
451 };
452
453 let daemon = match RuntimeClient::daemon().health() {
454 Ok(read) => DoctorCheck {
455 status: "ok",
456 message: format!("daemon attached; database {}", read.value.database),
457 },
458 Err(err) => DoctorCheck {
459 status: "info",
460 message: format!("daemon not attached; CLI will use local runtime store ({err})"),
461 },
462 };
463 let local_store = match RuntimeClient::local().health() {
464 Ok(read) => DoctorCheck {
465 status: "ok",
466 message: format!("local runtime store ready at {}", read.value.database),
467 },
468 Err(err) => DoctorCheck {
469 status: "warning",
470 message: format!("local runtime store unavailable: {err}"),
471 },
472 };
473
474 let mut tools = vec![
475 "read/edit/write files".to_string(),
476 "run shell commands".to_string(),
477 "create checkpoints before risky mutations".to_string(),
478 ];
479 let (web_tools, web_next_steps) = web_doctor_entries(config);
480 tools.extend(web_tools);
481 if !config.mcp_servers.is_empty() {
482 tools.push(format!(
483 "{} configured MCP server(s)",
484 config.mcp_servers.len()
485 ));
486 }
487 if let Some(skills) = crate::app::skills::load(cwd) {
488 tools.push(format!(
489 "{} skill(s) discovered (SKILL.md playbooks)",
490 skills.entries.len()
491 ));
492 }
493
494 let mut next_steps = web_next_steps;
495 if active_model.is_none() {
496 next_steps.push(
497 "Pick a model with `mermaid --model <provider/model>` or run `mermaid list`."
498 .to_string(),
499 );
500 }
501 if remote_providers.is_empty() && ollama_models.models().unwrap_or_default().is_empty() {
502 next_steps.push(
503 "Install or start Ollama, pull a model, or set a remote provider API key.".to_string(),
504 );
505 }
506 if instruction_paths.is_empty() {
507 next_steps.push("Optional: add MERMAID.md or AGENTS.md with project-specific run commands and conventions.".to_string());
508 }
509 if next_steps.is_empty() {
510 next_steps.push(
511 "Start Mermaid with `mermaid` or run one prompt with `mermaid run \"...\"`."
512 .to_string(),
513 );
514 }
515
516 let session_logs = session_log_drift(cwd);
517
518 let ok = active_model.is_some()
519 && local_store.status != "warning"
520 && (ollama.status == "ok" || !remote_providers.is_empty());
521 DoctorReport {
522 ok,
523 cwd: cwd.display().to_string(),
524 active_profile: config.active_profile.clone(),
525 active_model,
526 model_error,
527 model_capabilities,
528 safety_mode: safety_mode_name(config.safety.mode).to_string(),
529 checkpoint_on_mutation: config.safety.checkpoint_on_mutation,
530 prompt_customized: config.prompt.is_customized(),
531 ollama,
532 remote_providers,
533 provider_problems,
534 project_instructions,
535 tools,
536 runtime: DoctorRuntime {
537 daemon,
538 local_store,
539 },
540 session_logs,
541 next_steps,
542 }
543}
544
545fn session_log_drift(cwd: &Path) -> DoctorCheck {
559 let Ok(manager) = ConversationManager::new(cwd) else {
560 return DoctorCheck {
561 status: "ok",
562 message: "no .mermaid directory in this project".to_string(),
563 };
564 };
565 let Ok(metas) = manager.list_conversation_metas() else {
566 return DoctorCheck {
567 status: "warning",
568 message: "could not list this project's sessions".to_string(),
569 };
570 };
571
572 let mut checked = 0usize;
573 let mut drifted = Vec::new();
574 for meta in &metas {
575 let Ok(Some(folded)) = manager.fold_conversation_from_log(&meta.id) else {
576 continue;
579 };
580 let Ok(loaded) = manager.load_conversation(&meta.id) else {
581 drifted.push(format!("{} (would not load)", meta.id));
582 continue;
583 };
584 checked += 1;
585 let same = folded.messages().len() == loaded.messages().len()
589 && folded
590 .messages()
591 .iter()
592 .zip(loaded.messages())
593 .all(|(a, b)| a.role == b.role && a.content == b.content);
594 if !same {
595 drifted.push(format!(
596 "{} (log folds to {} messages, resume loads {})",
597 meta.id,
598 folded.messages().len(),
599 loaded.messages().len()
600 ));
601 }
602 }
603
604 if drifted.is_empty() {
605 return DoctorCheck {
606 status: "ok",
607 message: match checked {
608 0 => "no session event logs in this project yet".to_string(),
609 1 => "1 session: its log and checkpoint agree".to_string(),
610 n => format!("{n} sessions: every log and checkpoint agree"),
611 },
612 };
613 }
614 DoctorCheck {
615 status: "warning",
616 message: format!(
617 "{} of {checked} sessions disagree with their log: {}",
618 drifted.len(),
619 drifted.join(", ")
620 ),
621 }
622}
623
624fn print_doctor_report(report: &DoctorReport, format: OutputFormat) -> Result<()> {
625 match format {
626 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
627 OutputFormat::Ndjson => println!("{}", serde_json::to_string(report)?),
628 OutputFormat::Markdown => {
629 println!("# Mermaid Doctor\n");
630 print_doctor_text(report);
631 },
632 OutputFormat::Text => print_doctor_text(report),
633 }
634 Ok(())
635}
636
637fn print_doctor_text(report: &DoctorReport) {
638 println!(
639 "Mermaid Doctor: {}",
640 if report.ok {
641 "ready"
642 } else {
643 "needs attention"
644 }
645 );
646 println!("Project: {}", report.cwd);
647 match (&report.active_model, &report.model_error) {
648 (Some(model), _) => println!(" [OK] Active model: {model}"),
649 (None, Some(error)) => println!(" [WARNING] Active model: {error}"),
650 _ => println!(" [WARNING] Active model: unresolved"),
651 }
652 if let Some(caps) = &report.model_capabilities {
653 println!(
654 " provider={} tools={} vision={} reasoning={} context={}",
655 caps.provider,
656 caps.supports_tools,
657 caps.supports_vision,
658 caps.reasoning,
659 caps.max_context_tokens
660 .map(|n| n.to_string())
661 .unwrap_or_else(|| "unknown".to_string())
662 );
663 }
664 println!(
665 " [{}] Ollama: {}",
666 label(report.ollama.status),
667 report.ollama.message
668 );
669 println!(
670 " [INFO] Remote providers: {}",
671 if report.remote_providers.is_empty() {
672 "none configured".to_string()
673 } else {
674 report.remote_providers.join(", ")
675 }
676 );
677 for problem in &report.provider_problems {
678 println!(
679 " [WARNING] Provider {} is configured but unusable: {}",
680 problem.name, problem.reason
681 );
682 }
683 println!(
684 " [{}] Project instructions: {}",
685 label(report.project_instructions.status),
686 report.project_instructions.message
687 );
688 println!(
689 " [INFO] Safety: mode={}, checkpoint_on_mutation={}",
690 report.safety_mode, report.checkpoint_on_mutation
691 );
692 if let Some(profile) = &report.active_profile {
693 println!(" [INFO] Config profile: {profile}");
694 }
695 println!(
696 " [INFO] Prompt customization: {}",
697 if report.prompt_customized {
698 "active"
699 } else {
700 "default"
701 }
702 );
703 println!(
704 " [{}] Runtime daemon: {}",
705 label(report.runtime.daemon.status),
706 report.runtime.daemon.message
707 );
708 println!(
709 " [{}] Runtime store: {}",
710 label(report.runtime.local_store.status),
711 report.runtime.local_store.message
712 );
713 println!(
714 " [{}] Session logs: {}",
715 label(report.session_logs.status),
716 report.session_logs.message
717 );
718 println!(" [OK] Tool surface:");
719 for tool in &report.tools {
720 println!(" - {tool}");
721 }
722 println!("\nNext steps:");
723 for step in &report.next_steps {
724 println!(" - {step}");
725 }
726}
727
728#[derive(Debug, serde::Serialize)]
729struct SelfTestReport {
730 ok: bool,
731 workspace: String,
732 checks: Vec<String>,
733 compact_smoke: QaCompactSmokeReport,
734 runtime_store: DoctorCheck,
735 kept_workspace: bool,
736}
737
738fn run_self_test(config: &Config, format: OutputFormat, keep_workspace: bool) -> Result<()> {
739 let workspace = std::env::temp_dir().join(format!("mermaid-self-test-{}", fresh_qa_id()));
740 std::fs::create_dir_all(&workspace)
741 .with_context(|| format!("failed to create {}", workspace.display()))?;
742
743 let compact_smoke = match run_qa_compact_smoke(config, &workspace, 6) {
744 Ok(report) => report,
745 Err(err) => QaCompactSmokeReport::failed(&workspace, 6, err.to_string()),
746 };
747 let runtime_store = match RuntimeClient::local().health() {
748 Ok(read) => DoctorCheck {
749 status: "ok",
750 message: format!("local runtime store ready at {}", read.value.database),
751 },
752 Err(err) => DoctorCheck {
753 status: "warning",
754 message: format!("{err:#}"),
761 },
762 };
763
764 let sandbox_available = mermaid_runtime::network_killswitch_available();
768 let fs_sandbox_available = mermaid_runtime::fs_confinement_available();
769 let (network_check, fs_check) = if cfg!(target_os = "linux") {
770 (
771 "network kill-switch (seccomp) builds on this platform",
772 "filesystem confinement (Landlock) ruleset builds on this platform",
773 )
774 } else if cfg!(target_os = "macos") {
775 (
776 "network sandbox (Seatbelt via sandbox-exec) available on this platform",
777 "filesystem confinement (Seatbelt via sandbox-exec) available on this platform",
778 )
779 } else {
780 (
781 "network sandbox backend available on this platform",
782 "filesystem confinement backend available on this platform",
783 )
784 };
785 let checks = vec![
786 "compact smoke exercises reducer compaction path".to_string(),
787 "compact smoke persists conversation and archive artifacts".to_string(),
788 "local runtime store opens without daemon".to_string(),
789 format!(
790 "{network_check}: {}",
791 if sandbox_available { "yes" } else { "no" }
792 ),
793 format!(
794 "{fs_check}: {}",
795 if fs_sandbox_available { "yes" } else { "no" }
796 ),
797 ];
798 let sandbox_expected = cfg!(any(target_os = "linux", target_os = "macos"));
802 let ok = compact_smoke.ok
803 && runtime_store.status == "ok"
804 && (!sandbox_expected || (sandbox_available && fs_sandbox_available));
805 let report = SelfTestReport {
806 ok,
807 workspace: workspace.display().to_string(),
808 checks,
809 compact_smoke,
810 runtime_store,
811 kept_workspace: keep_workspace,
812 };
813
814 print_self_test_report(&report, format)?;
815 if !keep_workspace {
816 let _ = std::fs::remove_dir_all(&workspace);
817 }
818 anyhow::ensure!(report.ok, "mermaid self-test failed");
819 Ok(())
820}
821
822fn print_self_test_report(report: &SelfTestReport, format: OutputFormat) -> Result<()> {
823 match format {
824 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
825 OutputFormat::Ndjson => println!("{}", serde_json::to_string(report)?),
826 OutputFormat::Markdown => {
827 println!("# Mermaid Self-Test\n");
828 print_self_test_text(report);
829 },
830 OutputFormat::Text => print_self_test_text(report),
831 }
832 Ok(())
833}
834
835fn print_self_test_text(report: &SelfTestReport) {
836 println!(
837 "Mermaid self-test: {}",
838 if report.ok { "ok" } else { "failed" }
839 );
840 println!("workspace: {}", report.workspace);
841 println!(
842 "compact smoke: {}",
843 if report.compact_smoke.ok {
844 "ok"
845 } else {
846 "failed"
847 }
848 );
849 println!("runtime store: {}", report.runtime_store.message);
850 println!("checks:");
851 for check in &report.checks {
852 println!(" - {check}");
853 }
854 if !report.ok
855 && let Some(failure) = &report.compact_smoke.failure
856 {
857 println!("failure: {failure}");
858 }
859}
860
861fn label(status: &str) -> &'static str {
862 match status {
863 "ok" => "OK",
864 "warning" => "WARNING",
865 "error" => "ERROR",
866 _ => "INFO",
867 }
868}
869
870fn safety_mode_name(mode: mermaid_runtime::SafetyMode) -> &'static str {
871 mode.as_str()
872}
873
874fn login_providers(config: &Config) -> Vec<(String, String, Option<String>)> {
878 let over = |name: &str| {
879 config
880 .providers
881 .get(name)
882 .and_then(|c| c.api_key_env.clone())
883 };
884 let mut rows: Vec<(String, String, Option<String>)> = vec![
885 (
886 "anthropic".to_string(),
887 "ANTHROPIC_API_KEY".to_string(),
888 over("anthropic"),
889 ),
890 (
891 "gemini".to_string(),
892 "GOOGLE_API_KEY".to_string(),
893 over("gemini"),
894 ),
895 (
896 "meta".to_string(),
897 crate::providers::model::meta::DEFAULT_API_KEY_ENV.to_string(),
898 over("meta"),
899 ),
900 (
901 "ollama".to_string(),
902 "OLLAMA_API_KEY".to_string(),
903 over("ollama"),
904 ),
905 ];
906 for profile in PROVIDER_REGISTRY {
907 rows.push((
908 profile.name.to_string(),
909 profile.api_key_env.to_string(),
910 over(profile.name),
911 ));
912 }
913 for (name, cfg) in &config.providers {
914 if rows.iter().any(|(n, _, _)| n == name) {
915 continue;
916 }
917 if let Some(env) = &cfg.api_key_env {
919 rows.push((name.clone(), env.clone(), None));
920 }
921 }
922 rows.sort_by(|a, b| a.0.cmp(&b.0));
923 rows
924}
925
926fn login(provider: Option<&str>, config: &Config) -> Result<()> {
930 let rows = login_providers(config);
931 let Some(provider) = provider else {
932 println!(
933 "Provider API-key status (env beats keyring; `mermaid login <provider>` stores a key):\n"
934 );
935 for (name, default_env, override_env) in &rows {
936 let source = mermaid_model::utils::provider_key_source(
937 name,
938 default_env,
939 override_env.as_deref(),
940 );
941 let env_name = override_env.as_deref().unwrap_or(default_env);
942 println!(" {name:<14} {source:<8} (${env_name})");
943 }
944 return Ok(());
945 };
946 let provider = provider.to_lowercase();
947 let Some((name, default_env, override_env)) = rows.into_iter().find(|(n, _, _)| n == &provider)
948 else {
949 let names: Vec<String> = login_providers(config)
950 .into_iter()
951 .map(|(n, _, _)| n)
952 .collect();
953 anyhow::bail!(
954 "unknown provider '{}'; known: {}",
955 provider,
956 names.join(", ")
957 );
958 };
959 let key = rpassword::prompt_password(format!("API key for {name} (input hidden): "))
960 .context("read API key")?;
961 let key = key.trim();
962 anyhow::ensure!(!key.is_empty(), "no key entered; nothing stored");
963 let store = mermaid_model::utils::default_store();
964 store
965 .set(&name, key)
966 .with_context(|| format!("store key for {name}"))?;
967 println!(
968 "Stored key for {} in {} (service \"mermaid\").",
969 name,
970 store.label()
971 );
972 if mermaid_model::utils::resolve_api_key(&default_env, override_env.as_deref()).is_some() {
974 let env_name = override_env.as_deref().unwrap_or(&default_env);
975 println!("Note: ${env_name} is currently set and takes precedence over the stored key.");
976 }
977 Ok(())
978}
979
980fn logout(provider: &str, config: &Config) -> Result<()> {
983 let provider = provider.to_lowercase();
984 let _ = config;
987 let store = mermaid_model::utils::default_store();
988 if store
989 .delete(&provider)
990 .with_context(|| format!("delete key for {provider}"))?
991 {
992 println!(
993 "Removed stored key for {} from {}.",
994 provider,
995 store.label()
996 );
997 } else {
998 println!("No stored key for {provider}.");
999 }
1000 Ok(())
1001}
1002
1003fn meta_api_key(config: &Config) -> Option<String> {
1004 mermaid_model::utils::resolve_provider_key(
1005 "meta",
1006 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1007 config
1008 .providers
1009 .get("meta")
1010 .and_then(|provider| provider.api_key_env.as_deref()),
1011 )
1012}
1013
1014fn meta_base_url(config: &Config) -> String {
1015 config
1016 .providers
1017 .get("meta")
1018 .and_then(|provider| provider.base_url.clone())
1019 .unwrap_or_else(|| crate::providers::model::meta::DEFAULT_BASE_URL.to_string())
1020}
1021
1022#[derive(Debug, serde::Serialize)]
1023struct QaCompactSmokeReport {
1024 ok: bool,
1025 turns: usize,
1026 archived_messages: usize,
1027 preserved_messages: usize,
1028 replacement_messages: usize,
1029 conversation_path: Option<String>,
1030 archive_path: Option<String>,
1031 checks: Vec<String>,
1032 failure: Option<String>,
1033}
1034
1035impl QaCompactSmokeReport {
1036 fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
1037 Self {
1038 ok: false,
1039 turns,
1040 archived_messages: 0,
1041 preserved_messages: 0,
1042 replacement_messages: 0,
1043 conversation_path: Some(
1044 cwd.join(".mermaid")
1045 .join("conversations")
1046 .display()
1047 .to_string(),
1048 ),
1049 archive_path: None,
1050 checks: Vec::new(),
1051 failure: Some(failure),
1052 }
1053 }
1054}
1055
1056#[expect(
1057 clippy::too_many_lines,
1058 reason = "predates the lint; see .github/baselines/expect_budget.txt"
1059)]
1060fn run_qa_compact_smoke(
1061 config: &Config,
1062 cwd: &Path,
1063 requested_turns: usize,
1064) -> Result<QaCompactSmokeReport> {
1065 let turns = requested_turns.max(3);
1066 let mut state = State::new(
1067 config.clone(),
1068 cwd.to_path_buf(),
1069 qa_model_id(config),
1070 chrono::Local::now(),
1071 std::env::temp_dir(),
1072 );
1073 for message in synthetic_compaction_messages(turns) {
1074 state.session.append(message, state.now);
1075 }
1076 let pre_manager = ConversationManager::new(cwd)?;
1082 let pre_snapshot = state.session.snapshot_conversation();
1083 let pre_events = state.session.drain_events(&pre_snapshot);
1084 pre_manager.append_session_events(&pre_snapshot, &pre_events)?;
1085 pre_manager.save_conversation(&pre_snapshot)?;
1086 let dropped_probe = state
1087 .session
1088 .messages()
1089 .first()
1090 .map(|message| message.content.clone())
1091 .context("synthetic history is empty")?;
1092
1093 let (state_after_slash, compact_cmds) = update(
1094 state,
1095 Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
1096 );
1097 let turn = state_after_slash
1098 .turn
1099 .id()
1100 .context("manual compaction did not enter a compaction turn")?;
1101 let request = compact_cmds
1102 .iter()
1103 .find_map(|cmd| match cmd {
1104 Cmd::CompactConversation { request, .. } => Some(request.clone()),
1105 _ => None,
1106 })
1107 .context("manual compaction did not emit a CompactConversation command")?;
1108
1109 let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
1110 let prepared = prepare_compaction(&request, Some(100_000))
1111 .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
1112 anyhow::ensure!(
1113 !prepared.archived_messages.is_empty(),
1114 "compaction archived no messages"
1115 );
1116 anyhow::ensure!(
1117 !prepared.preserved_messages.is_empty(),
1118 "compaction preserved no messages"
1119 );
1120
1121 let summary = deterministic_compaction_summary(&prepared, turns);
1122 let mut record = CompactionEvent {
1123 id: format!("qa_compact_{}", fresh_qa_id()),
1124 trigger: CompactionTrigger::Manual,
1125 created_at: chrono::Local::now(),
1126 before_tokens: before_snapshot.used_tokens,
1127 after_tokens: 0,
1128 archived_message_count: prepared.archived_messages.len(),
1129 preserved_message_count: prepared.preserved_messages.len(),
1130 preserved_turn_count: prepared
1131 .preserved_messages
1132 .iter()
1133 .filter(|message| message.role == mermaid_model::models::MessageRole::User)
1134 .count(),
1135 summary_tokens: summary.len().div_ceil(4),
1136 duration_secs: 0.0,
1137 review_status: mermaid_domain::CompactionReviewStatus::DraftValidated,
1138 review_error: None,
1139 focus: Some("qa compact smoke".to_string()),
1140 archive_path: None,
1141 };
1142 let mut replacement = build_replacement_messages(&summary, &prepared, &record);
1143 let mut after_chat: ChatRequest = request.chat.clone();
1144 after_chat.messages = replacement.clone();
1145 let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1146 record.after_tokens = after_snapshot.used_tokens;
1147 replacement = build_replacement_messages(&summary, &prepared, &record);
1148 after_chat.messages = replacement.clone();
1149 after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1150
1151 let result = CompactionResult {
1152 record,
1153 replacement_messages: replacement,
1154 archived_messages: prepared.archived_messages,
1155 before_snapshot,
1156 after_snapshot,
1157 usage: None,
1158 source_boundaries: Vec::new(),
1159 };
1160 let (final_state, save_cmds) =
1161 update(state_after_slash, Msg::CompactionFinished { turn, result });
1162
1163 let manager = ConversationManager::new(cwd)?;
1164 let mut conversation_path = None;
1165 let mut archive_path = None;
1166 for cmd in save_cmds {
1167 match cmd {
1168 Cmd::SaveConversation {
1169 snapshot: conversation,
1170 ..
1171 } => {
1172 manager.save_conversation(&conversation)?;
1173 conversation_path = Some(
1174 manager
1175 .conversations_dir()
1176 .join(format!("{}.json", conversation.id))
1177 .display()
1178 .to_string(),
1179 );
1180 },
1181 Cmd::SaveCompaction {
1182 conversation,
1183 events,
1184 ..
1185 } => {
1186 manager.append_session_events(&conversation, &events)?;
1191 archive_path = Some(
1192 manager
1193 .event_log_path(&conversation.id)
1194 .display()
1195 .to_string(),
1196 );
1197 manager.save_conversation(&conversation)?;
1198 conversation_path = Some(
1199 manager
1200 .conversations_dir()
1201 .join(format!("{}.json", conversation.id))
1202 .display()
1203 .to_string(),
1204 );
1205 },
1206 _ => {},
1207 }
1208 }
1209
1210 let conversation_path = conversation_path.context("compaction did not save conversation")?;
1211 let archive_path = archive_path.context("compaction did not save archive")?;
1212 let messages = final_state.session.messages();
1213 let compactions = &final_state.session.conversation.compactions;
1214
1215 let mut checks = Vec::new();
1216 anyhow::ensure!(
1217 !compactions.is_empty(),
1218 "conversation did not record compaction metadata"
1219 );
1220 checks.push("conversation records compaction metadata".to_string());
1221 anyhow::ensure!(
1222 messages.first().is_some_and(
1223 |msg| msg.kind == mermaid_model::models::ChatMessageKind::ContextCheckpoint
1224 ),
1225 "replacement does not start with a context checkpoint"
1226 );
1227 checks.push("replacement starts with context checkpoint".to_string());
1228 anyhow::ensure!(
1229 std::path::Path::new(&conversation_path).exists(),
1230 "conversation file missing after save"
1231 );
1232 checks.push("conversation file saved".to_string());
1233 anyhow::ensure!(
1234 std::path::Path::new(&archive_path).exists(),
1235 "session event log missing after compaction save"
1236 );
1237 checks.push("session event log saved".to_string());
1238 let log = std::fs::read_to_string(&archive_path).context("read the session event log")?;
1243 anyhow::ensure!(
1244 log.contains("\"type\":\"compaction\""),
1245 "event log has no compaction boundary"
1246 );
1247 anyhow::ensure!(
1248 !messages
1249 .iter()
1250 .any(|message| message.content == dropped_probe),
1251 "the probe message was not actually dropped by the compaction"
1252 );
1253 anyhow::ensure!(
1254 log.contains(dropped_probe.trim()),
1255 "event log lost a message the compaction dropped"
1256 );
1257 checks.push("dropped messages survive in the event log".to_string());
1258 anyhow::ensure!(
1259 compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
1260 "compaction did not archive and preserve messages"
1261 );
1262 checks.push("archived and preserved message counts are non-zero".to_string());
1263
1264 Ok(QaCompactSmokeReport {
1265 ok: true,
1266 turns,
1267 archived_messages: compactions[0].archived_message_count,
1268 preserved_messages: compactions[0].preserved_message_count,
1269 replacement_messages: messages.len(),
1270 conversation_path: Some(conversation_path),
1271 archive_path: Some(archive_path),
1272 checks,
1273 failure: None,
1274 })
1275}
1276
1277fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
1278 match format {
1279 OutputFormat::Json => {
1280 println!("{}", serde_json::to_string_pretty(report)?);
1281 },
1282 OutputFormat::Ndjson => {
1283 println!("{}", serde_json::to_string(report)?);
1284 },
1285 OutputFormat::Text => {
1286 println!(
1287 "qa compact smoke: {}",
1288 if report.ok { "ok" } else { "failed" }
1289 );
1290 println!("turns: {}", report.turns);
1291 println!("archived messages: {}", report.archived_messages);
1292 println!("preserved messages: {}", report.preserved_messages);
1293 println!("replacement messages: {}", report.replacement_messages);
1294 if let Some(path) = &report.conversation_path {
1295 println!("conversation: {path}");
1296 }
1297 if let Some(path) = &report.archive_path {
1298 println!("archive: {path}");
1299 }
1300 if let Some(failure) = &report.failure {
1301 println!("failure: {failure}");
1302 }
1303 },
1304 OutputFormat::Markdown => {
1305 println!(
1306 "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
1307 if report.ok { "ok" } else { "failed" },
1308 report.turns,
1309 report.archived_messages,
1310 report.preserved_messages,
1311 report.replacement_messages
1312 );
1313 if let Some(path) = &report.conversation_path {
1314 println!("- Conversation: `{path}`");
1315 }
1316 if let Some(path) = &report.archive_path {
1317 println!("- Archive: `{path}`");
1318 }
1319 if let Some(failure) = &report.failure {
1320 println!("\nFailure: `{failure}`");
1321 }
1322 },
1323 }
1324 Ok(())
1325}
1326
1327fn qa_model_id(config: &Config) -> String {
1328 if let Some(model) = config
1329 .last_used_model
1330 .as_ref()
1331 .filter(|value| !value.is_empty())
1332 {
1333 return model.clone();
1334 }
1335 if !config.default_model.name.is_empty() {
1336 if config.default_model.provider.is_empty() {
1337 return config.default_model.name.clone();
1338 }
1339 return format!(
1340 "{}/{}",
1341 config.default_model.provider, config.default_model.name
1342 );
1343 }
1344 "qa/deterministic".to_string()
1345}
1346
1347fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
1348 let mut messages = Vec::with_capacity(turns.saturating_mul(2));
1349 for idx in 1..=turns {
1350 messages.push(ChatMessage::user(format!(
1351 "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
1352 )));
1353 messages.push(ChatMessage::assistant(format!(
1354 "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}."
1355 )));
1356 }
1357 messages
1358}
1359
1360fn deterministic_compaction_summary(
1361 prepared: &mermaid_domain::PreparedCompaction,
1362 turns: usize,
1363) -> String {
1364 format!(
1365 "## 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.",
1366 prepared.archived_messages.len(),
1367 prepared.preserved_messages.len()
1368 )
1369}
1370
1371fn fresh_qa_id() -> u128 {
1372 std::time::SystemTime::now()
1373 .duration_since(std::time::UNIX_EPOCH)
1374 .map(|duration| duration.as_nanos())
1375 .unwrap_or_default()
1376}
1377
1378fn show_tasks(limit: usize) -> Result<()> {
1379 let read = RuntimeClient::auto().list_tasks(limit)?;
1380 let mut tasks = read.value;
1381 tasks.truncate(limit);
1382 println!("Mermaid runtime tasks");
1383 println!("Source: {}", read.source.as_str());
1384 println!();
1385 if tasks.is_empty() {
1386 println!("No tasks recorded yet.");
1387 return Ok(());
1388 }
1389 for task in tasks {
1390 println!(
1391 "{} [{}] {} {} {}",
1392 task.id, task.status, task.priority, task.updated_at, task.title
1393 );
1394 println!(" project: {}", task.project_path);
1395 println!(" model: {}", task.model_id);
1396 }
1397 Ok(())
1398}
1399
1400fn show_task(id: &str) -> Result<()> {
1401 let detail = RuntimeClient::auto().task_detail(id)?.value;
1402 print_task_detail(&detail.task);
1403 let events = detail.events;
1404 if !events.is_empty() {
1405 println!();
1406 println!("Timeline:");
1407 for event in events {
1408 println!(" {} {} {}", event.created_at, event.kind, event.message);
1409 }
1410 }
1411 Ok(())
1412}
1413
1414fn print_task_detail(task: &TaskRecord) {
1415 println!("Task: {}", task.id);
1416 println!("Title: {}", task.title);
1417 println!("Status: {}", task.status);
1418 println!("Priority: {}", task.priority);
1419 println!("Project: {}", task.project_path);
1420 println!("Model: {}", task.model_id);
1421 if let Some(conversation_id) = &task.conversation_id {
1422 println!("Conversation: {conversation_id}");
1423 }
1424 println!("Created: {}", task.created_at);
1425 println!("Updated: {}", task.updated_at);
1426 if let Some(report) = &task.final_report {
1427 println!();
1428 println!("Final report:");
1429 println!("{}", sanitize_terminal_text(report));
1430 }
1431}
1432
1433fn show_processes(limit: usize) -> Result<()> {
1434 let read = RuntimeClient::auto().list_processes(limit)?;
1435 let mut processes = read.value;
1436 processes.truncate(limit);
1437 println!("Mermaid runtime processes");
1438 println!("Source: {}", read.source.as_str());
1439 println!();
1440 if processes.is_empty() {
1441 println!("No processes recorded yet.");
1442 return Ok(());
1443 }
1444 for process in processes {
1445 println!(
1446 "{} pid={} status={} {}",
1447 process.id,
1448 process.pid,
1449 process.status.as_str(),
1450 process.command
1451 );
1452 if let Some(task_id) = process.task_id {
1453 println!(" task: {task_id}");
1454 }
1455 if let Some(cwd) = process.cwd {
1456 println!(" cwd: {cwd}");
1457 }
1458 if let Some(log_path) = process.log_path {
1459 println!(" log: {log_path}");
1460 }
1461 if let Some(url) = process.detected_url {
1462 println!(" url: {url}");
1463 }
1464 }
1465 Ok(())
1466}
1467
1468async fn show_models(config: &Config) -> Result<()> {
1469 list_models(config).await?;
1470 probe_configured_provider_models(config).await?;
1471 let store = RuntimeStore::open_default()?;
1472 let probes = store.provider_probes().list(None, None)?;
1473 if !probes.is_empty() {
1474 println!("\nCached capability probes:");
1475 for probe in probes {
1476 println!(
1477 " - {}/{} {}={} ({})",
1478 probe.provider,
1479 probe.model_id,
1480 probe.capability_key,
1481 probe.capability_value,
1482 probe.confidence
1483 );
1484 }
1485 }
1486 Ok(())
1487}
1488
1489async fn show_model_info(model: &str, config: &Config) -> Result<()> {
1490 let snapshot = mermaid_domain::ProviderCapabilitySnapshot::from_model_id(model);
1491 let store = RuntimeStore::open_default()?;
1492 let provider = snapshot.provider.clone();
1493
1494 let mut context_tokens = snapshot.max_context_tokens;
1501 let mut context_confidence = "static";
1502 let mut output_tokens = snapshot.max_output_tokens;
1503 let mut output_confidence = "static";
1504 let factory = crate::providers::ProviderFactory::new(config.clone());
1505 if let Ok(live) = factory.resolve(model).await {
1506 let probe_request = ChatRequest {
1507 model_id: model.to_string(),
1508 messages: vec![],
1509 system_prompt: String::new(),
1510 instructions: None,
1511 reasoning: mermaid_model::models::ReasoningLevel::None,
1512 temperature: 0.0,
1513 max_tokens: 0,
1514 tools: vec![],
1515 ollama_num_ctx: None,
1516 ollama_allow_ram_offload: None,
1517 resolved_context_window: None,
1518 resolved_max_output: None,
1519 output_schema: None,
1520 suppress_auto_compact: false,
1521 suppressed_builtin_tools: Vec::new(),
1522 };
1523 let sizing = live.resolve_context_window(&probe_request).await;
1524 if let Some(window) = sizing.model_max.or(sizing.effective) {
1525 context_tokens = Some(window);
1526 context_confidence = "probed";
1527 }
1528 if let Some(output) = sizing.max_output {
1529 output_tokens = Some(output);
1530 output_confidence = "probed";
1531 }
1532 }
1533
1534 for (key, value) in [
1535 ("supports_tools", snapshot.supports_tools.to_string()),
1536 ("supports_vision", snapshot.supports_vision.to_string()),
1537 ("reasoning", snapshot.reasoning.clone()),
1538 ] {
1539 let _ = store.provider_probes().upsert(NewProviderProbe {
1540 provider: provider.clone(),
1541 model_id: snapshot.model.clone(),
1542 capability_key: key.to_string(),
1543 capability_value: value,
1544 confidence: "static".to_string(),
1545 error: None,
1546 });
1547 }
1548 let _ = store.provider_probes().upsert(NewProviderProbe {
1550 provider: provider.clone(),
1551 model_id: snapshot.model.clone(),
1552 capability_key: "max_context_tokens".to_string(),
1553 capability_value: context_tokens
1554 .map(|n| n.to_string())
1555 .unwrap_or_else(|| "unknown".to_string()),
1556 confidence: context_confidence.to_string(),
1557 error: None,
1558 });
1559 println!("Model: {model}");
1560 println!("Provider: {}", snapshot.provider);
1561 println!("Name: {}", snapshot.model);
1562 println!("Supports tools: {}", snapshot.supports_tools);
1563 println!("Supports vision: {}", snapshot.supports_vision);
1564 println!("Reasoning: {}", snapshot.reasoning);
1565 println!(
1566 "Context: {}",
1567 context_tokens
1568 .map(|n| format!("{n} ({context_confidence})"))
1569 .unwrap_or_else(|| "unknown".to_string())
1570 );
1571 println!(
1572 "Output limit: {}",
1573 output_tokens
1574 .map(|n| format!("{n} ({output_confidence})"))
1575 .unwrap_or_else(|| {
1576 "unknown (discovered live from the provider's models endpoint when exposed)"
1577 .to_string()
1578 })
1579 );
1580 if let Some(profile) = lookup_provider(&snapshot.provider) {
1581 record_static_provider_probes(&store, profile, &provider, &snapshot.model);
1582 println!("Token budget field: {:?}", profile.max_tokens_param);
1583 println!(
1584 "Single-tool-call models: {}",
1585 if profile.disable_parallel_tool_calls_for.is_empty() {
1586 "(none)".to_string()
1587 } else {
1588 profile.disable_parallel_tool_calls_for.join(", ")
1589 }
1590 );
1591 }
1592 Ok(())
1593}
1594
1595async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1596 let client = reqwest::Client::builder()
1597 .timeout(std::time::Duration::from_secs(5))
1598 .build()?;
1599 for profile in PROVIDER_REGISTRY {
1600 let user_cfg = config.providers.get(profile.name);
1601 let Some(api_key) = mermaid_model::utils::resolve_provider_key(
1602 profile.name,
1603 profile.api_key_env,
1604 user_cfg.and_then(|c| c.api_key_env.as_deref()),
1605 ) else {
1606 continue;
1607 };
1608 let Some(base_url) = crate::providers::factory::discovery_base_url(
1609 profile,
1610 user_cfg.and_then(|c| c.base_url.clone()),
1611 ) else {
1612 record_provider_probe(
1616 profile.name,
1617 "*",
1618 "models_availability",
1619 "failed",
1620 "failed",
1621 Some("CLOUDFLARE_ACCOUNT_ID not set".to_string()),
1622 );
1623 continue;
1624 };
1625 let url = format!("{}/models", base_url.trim_end_matches('/'));
1626 let mut request = client.get(&url).bearer_auth(api_key);
1627 for (name, value) in profile.extra_headers {
1628 request = request.header(*name, *value);
1629 }
1630 if let Some(user_cfg) = user_cfg {
1631 for (name, value) in &user_cfg.extra_headers {
1632 request = request.header(name, value);
1633 }
1634 }
1635
1636 let result = request.send().await;
1637 match result {
1638 Ok(response) if response.status().is_success() => {
1639 let status = response.status();
1640 let body: serde_json::Value = response.json().await.unwrap_or_default();
1641 let ids = body
1642 .get("data")
1643 .and_then(|v| v.as_array())
1644 .map(|items| {
1645 items
1646 .iter()
1647 .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1648 .map(str::to_string)
1649 .collect::<Vec<_>>()
1650 })
1651 .unwrap_or_default();
1652 record_provider_probe(
1653 profile.name,
1654 "*",
1655 "models_availability",
1656 &format!("available:{}:{}", status.as_u16(), ids.len()),
1657 "probed",
1658 None,
1659 );
1660 for model_id in ids.into_iter().take(200) {
1661 record_provider_probe(
1662 profile.name,
1663 &model_id,
1664 "model_listed",
1665 "true",
1666 "listed",
1667 None,
1668 );
1669 }
1670 },
1671 Ok(response) => {
1672 record_provider_probe(
1673 profile.name,
1674 "*",
1675 "models_availability",
1676 "failed",
1677 "failed",
1678 Some(format!("HTTP {}", response.status().as_u16())),
1679 );
1680 },
1681 Err(error) => {
1682 record_provider_probe(
1683 profile.name,
1684 "*",
1685 "models_availability",
1686 "failed",
1687 "failed",
1688 Some(error.to_string()),
1689 );
1690 },
1691 }
1692 }
1693 probe_meta_models(&client, config).await;
1694 Ok(())
1695}
1696
1697async fn probe_meta_models(client: &reqwest::Client, config: &Config) {
1698 let Some(api_key) = meta_api_key(config) else {
1699 return;
1700 };
1701 let url = format!("{}/models", meta_base_url(config).trim_end_matches('/'));
1702 let mut request = client.get(&url).bearer_auth(api_key);
1703 if let Some(provider) = config.providers.get("meta") {
1704 for (name, value) in &provider.extra_headers {
1705 request = request.header(name, value);
1706 }
1707 for (name, env_var) in &provider.env_headers {
1708 if let Ok(value) = std::env::var(env_var) {
1709 request = request.header(name, value);
1710 }
1711 }
1712 }
1713 match request.send().await {
1714 Ok(response) if response.status().is_success() => {
1715 let status = response.status();
1716 let body: serde_json::Value = response.json().await.unwrap_or_default();
1717 let ids = body
1718 .get("data")
1719 .and_then(serde_json::Value::as_array)
1720 .into_iter()
1721 .flatten()
1722 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1723 .map(str::to_string)
1724 .collect::<Vec<_>>();
1725 record_provider_probe(
1726 "meta",
1727 "*",
1728 "models_availability",
1729 &format!("available:{}:{}", status.as_u16(), ids.len()),
1730 "probed",
1731 None,
1732 );
1733 for model_id in ids.into_iter().take(200) {
1734 record_provider_probe("meta", &model_id, "model_listed", "true", "listed", None);
1735 }
1736 },
1737 Ok(response) => record_provider_probe(
1738 "meta",
1739 "*",
1740 "models_availability",
1741 "failed",
1742 "failed",
1743 Some(format!("HTTP {}", response.status().as_u16())),
1744 ),
1745 Err(error) => record_provider_probe(
1746 "meta",
1747 "*",
1748 "models_availability",
1749 "failed",
1750 "failed",
1751 Some(error.to_string()),
1752 ),
1753 }
1754}
1755
1756fn record_provider_probe(
1757 provider: &str,
1758 model_id: &str,
1759 key: &str,
1760 value: &str,
1761 confidence: &str,
1762 error: Option<String>,
1763) {
1764 if let Ok(store) = RuntimeStore::open_default() {
1765 let _ = store.provider_probes().upsert(NewProviderProbe {
1766 provider: provider.to_string(),
1767 model_id: model_id.to_string(),
1768 capability_key: key.to_string(),
1769 capability_value: value.to_string(),
1770 confidence: confidence.to_string(),
1771 error,
1772 });
1773 }
1774}
1775
1776fn show_approvals() -> Result<()> {
1777 let approvals = RuntimeClient::auto().list_approvals()?.value;
1778 if approvals.is_empty() {
1779 println!("No pending approvals.");
1780 return Ok(());
1781 }
1782 for approval in approvals {
1783 println!(
1784 "{} [{} -> {}] {}",
1785 approval.id,
1786 approval.risk_classification,
1787 approval.policy_decision,
1788 approval.proposed_action
1789 );
1790 if let Some(args) = approval.args_summary {
1791 println!(" args: {args}");
1792 }
1793 if let Some(checkpoint_id) = approval.checkpoint_id {
1794 println!(" checkpoint: {checkpoint_id}");
1795 }
1796 if approval.pending_action_json.is_some() {
1797 println!(" pending action: recorded");
1798 }
1799 }
1800 Ok(())
1801}
1802
1803fn approve(id: &str) -> Result<()> {
1804 let result = RuntimeClient::auto().approve(id)?;
1805 println!("Approved {id}");
1806 if result.replayed {
1807 println!("{}", result.summary);
1808 }
1809 Ok(())
1810}
1811
1812fn deny(id: &str) -> Result<()> {
1813 let _ = RuntimeClient::auto().deny(id)?;
1814 println!("Denied {id}");
1815 Ok(())
1816}
1817
1818fn follow_task(id: &str) -> Result<()> {
1823 let lines = mermaid_runtime::subscribe_daemon_lines(
1824 crate::runtime_client::DaemonRequest::SubscribeTask {
1825 task_id: id.to_string(),
1826 }
1827 .to_wire(),
1828 )
1829 .context("mermaid task --follow needs a running daemon (`mermaid daemon start`)")?;
1830 let mut saw_any = false;
1831 for line in lines {
1832 let line = line?;
1833 if line.trim().is_empty() {
1834 continue;
1835 }
1836 if !saw_any {
1838 saw_any = true;
1839 let ack: serde_json::Value =
1840 serde_json::from_str(line.trim()).context("daemon returned invalid JSON")?;
1841 if ack.get("ok").and_then(|v| v.as_bool()) == Some(false) {
1842 anyhow::bail!(
1843 "{}",
1844 ack.get("error")
1845 .and_then(|v| v.as_str())
1846 .unwrap_or("subscribe failed")
1847 );
1848 }
1849 println!("{}", line.trim());
1850 continue;
1851 }
1852 println!("{}", line.trim());
1853 if serde_json::from_str::<serde_json::Value>(line.trim())
1854 .ok()
1855 .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_string))
1856 .as_deref()
1857 == Some("result")
1858 {
1859 return Ok(());
1860 }
1861 }
1862 if saw_any {
1863 anyhow::bail!("stream ended without a result (daemon restarted mid-run?)");
1864 }
1865 anyhow::bail!("daemon closed the connection without responding");
1866}
1867
1868fn send_to_task(id: &str, text: &str) -> Result<()> {
1874 if text.trim().is_empty() {
1875 anyhow::bail!("nothing to send: --send needs a prompt");
1876 }
1877 mermaid_runtime::request_daemon_json(
1878 crate::runtime_client::DaemonRequest::SendToTask {
1879 id: id.to_string(),
1880 text: text.to_string(),
1881 }
1882 .to_wire(),
1883 )?;
1884 println!("Sent to {id}; it answers after the turn it is on.");
1885 Ok(())
1886}
1887
1888fn cancel_task(id: &str) -> Result<()> {
1893 match mermaid_runtime::request_daemon_json(
1894 crate::runtime_client::DaemonRequest::CancelTask { id: id.to_string() }.to_wire(),
1895 ) {
1896 Ok(response) => {
1897 if response.get("cancelling").and_then(|v| v.as_bool()) == Some(true) {
1898 println!("Cancelling {id} (running; the agent unwinds gracefully)");
1899 } else {
1900 println!("Cancelled {id}");
1901 }
1902 Ok(())
1903 },
1904 Err(daemon_err) => {
1905 let store = mermaid_runtime::RuntimeStore::open_default()?;
1906 match store.tasks().get(id)? {
1907 Some(task) if task.status == mermaid_runtime::TaskStatus::Queued => {
1908 store.tasks().update_status(
1909 id,
1910 mermaid_runtime::TaskStatus::Cancelled,
1911 Some("cancelled before start"),
1912 )?;
1913 println!("Cancelled {id} (was queued; daemon unreachable)");
1914 Ok(())
1915 },
1916 Some(task) => anyhow::bail!(
1917 "task {} is {} and the daemon request failed: {}",
1918 id,
1919 task.status,
1920 daemon_err
1921 ),
1922 None => anyhow::bail!("task not found: {id}"),
1923 }
1924 },
1925 }
1926}
1927
1928fn show_tool_runs(limit: usize) -> Result<()> {
1929 let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1930 runs.truncate(limit);
1931 if runs.is_empty() {
1932 println!("No tool runs recorded yet.");
1933 return Ok(());
1934 }
1935 for run in runs {
1936 println!(
1937 "{} [{}] {} started {}",
1938 run.id, run.status, run.tool_name, run.started_at
1939 );
1940 if let Some(turn_id) = run.turn_id {
1941 println!(" turn: {turn_id}");
1942 }
1943 if let Some(call_id) = run.call_id {
1944 println!(" call: {call_id}");
1945 }
1946 if let Some(finished_at) = run.finished_at {
1947 println!(" finished: {finished_at}");
1948 }
1949 }
1950 Ok(())
1951}
1952
1953fn show_checkpoints(limit: usize) -> Result<()> {
1954 let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1955 checkpoints.truncate(limit);
1956 if checkpoints.is_empty() {
1957 println!("No checkpoints recorded yet.");
1958 return Ok(());
1959 }
1960 for checkpoint in checkpoints {
1961 println!(
1962 "{} {} {}",
1963 checkpoint.id, checkpoint.created_at, checkpoint.project_path
1964 );
1965 println!(" snapshot: {}", checkpoint.snapshot_path);
1966 println!(" files: {}", checkpoint.changed_files_json);
1967 if let Some(approval_id) = checkpoint.approval_id {
1968 println!(" approval: {approval_id}");
1969 }
1970 }
1971 Ok(())
1972}
1973
1974fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
1975 if !mermaid_model::utils::confirm_or_refuse(
1979 &format!("Restore checkpoint {id}? This overwrites the current working tree."),
1980 force,
1981 )? {
1982 println!("Restore cancelled.");
1983 return Ok(());
1984 }
1985 let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
1986 println!("Restored {} ({} files)", manifest.id, manifest.files.len());
1987 if let Some(repo) = manifest.shadow_git_repo {
1988 println!("Shadow repo: {repo}");
1989 }
1990 if let Some(commit) = manifest.shadow_git_commit {
1991 println!("Shadow commit: {commit}");
1992 }
1993 if let Some(action) = manifest.pending_action {
1994 println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
1995 }
1996 Ok(())
1997}
1998
1999fn handle_plugin(command: &PluginCommand) -> Result<()> {
2000 match command {
2001 PluginCommand::Install { path } => {
2002 let preview = mermaid_runtime::plugin_capability_preview(path)?;
2003 print_plugin_capability_preview(&preview);
2004 let record = mermaid_runtime::install_plugin_from_path(path)?;
2005 println!(
2006 "Installed plugin {} ({}) — DISABLED.",
2007 record.name, record.id
2008 );
2009 println!(
2010 "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
2011 record.id
2012 );
2013 },
2014 PluginCommand::List => {
2015 let plugins = RuntimeClient::auto().list_plugins()?.value;
2016 if plugins.is_empty() {
2017 println!("No plugins installed.");
2018 } else {
2019 for plugin in plugins {
2020 println!(
2021 "{} [{}] {} ({})",
2022 plugin.id,
2023 if plugin.enabled {
2024 "enabled"
2025 } else {
2026 "disabled"
2027 },
2028 plugin.name,
2029 plugin.source
2030 );
2031 }
2032 }
2033 },
2034 PluginCommand::Enable { id } => {
2035 let client = RuntimeClient::auto();
2037 if let Some(plugin) = client
2038 .list_plugins()?
2039 .value
2040 .into_iter()
2041 .find(|p| p.id == *id || p.name == *id)
2042 && let Ok(preview) =
2043 mermaid_runtime::plugin_capability_preview(Path::new(&plugin.source))
2044 {
2045 print_plugin_capability_preview(&preview);
2046 }
2047 client.set_plugin_enabled(id, true)?;
2048 println!("Enabled plugin {id} — its hooks will now run.");
2049 },
2050 PluginCommand::Disable { id } => {
2051 RuntimeClient::auto().set_plugin_enabled(id, false)?;
2052 println!("Disabled plugin {id}");
2053 },
2054 PluginCommand::Audit { path } => {
2055 let manifest_path = if path.is_dir() {
2056 path.join("plugin.toml")
2057 } else {
2058 path.clone()
2059 };
2060 let raw = std::fs::read_to_string(&manifest_path)?;
2061 let manifest: mermaid_runtime::PluginManifest = toml::from_str(&raw)?;
2062 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
2063 mermaid_runtime::validate_plugin_manifest(&manifest, root)?;
2064 let preview = mermaid_runtime::plugin_capability_preview(path)?;
2065 println!("Plugin manifest is valid: {}", manifest.name);
2066 print_plugin_capability_preview(&preview);
2067 },
2068 }
2069 Ok(())
2070}
2071
2072fn print_plugin_capability_preview(preview: &mermaid_runtime::PluginCapabilityPreview) {
2073 println!(
2074 "ModelCapabilities declared by plugin {} (advisory, not sandbox-enforced):",
2075 preview.name
2076 );
2077 if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
2078 println!(" capabilities: (none declared)");
2079 } else {
2080 if !preview.declared_capabilities.is_empty() {
2081 println!(" declared: {}", preview.declared_capabilities.join(", "));
2082 }
2083 if let Some(value) = &preview.capabilities_toml {
2084 println!(
2085 " capabilities.toml: {}",
2086 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
2087 );
2088 }
2089 }
2090 if !preview.hooks.is_empty() {
2091 println!(" hooks: {}", preview.hooks.join(", "));
2092 }
2093 if !preview.mcp.is_empty() {
2094 println!(" mcp: {}", preview.mcp.join(", "));
2095 }
2096 if !preview.bin.is_empty() {
2097 println!(" bin: {}", preview.bin.join(", "));
2098 }
2099}
2100
2101fn handle_pair(command: &PairCommand) -> Result<()> {
2102 let store = RuntimeStore::open_default()?;
2103 match command {
2104 PairCommand::Create { label, ttl_days } => {
2105 let ttl = ttl_days.unwrap_or(mermaid_runtime::DEFAULT_PAIRING_TTL_DAYS);
2106 let expires_at = mermaid_runtime::pairing_expiry_from_now(ttl);
2107 let (token, hash) = mermaid_runtime::generate_pairing_token()?;
2108 let record =
2109 store
2110 .pairing_tokens()
2111 .create(&hash, label.as_deref(), expires_at.as_deref())?;
2112 println!("Pairing token id: {}", record.id);
2113 println!("Pairing token: {token}");
2114 println!(
2115 "Expires: {}",
2116 record.expires_at.as_deref().unwrap_or("never")
2117 );
2118 println!(
2119 "Use with daemon JSON by setting {}.",
2120 mermaid_runtime::daemon::DAEMON_TOKEN_ENV
2121 );
2122 println!("Store this now; Mermaid will not print it again.");
2123 },
2124 PairCommand::List => {
2125 let tokens = store.pairing_tokens().list()?;
2126 if tokens.is_empty() {
2127 println!("No pairing tokens.");
2128 } else {
2129 for t in tokens {
2131 println!(
2132 "{} [{}] label={} created={} expires={} last_used={}",
2133 t.id,
2134 if t.enabled { "active" } else { "revoked" },
2135 t.label.as_deref().unwrap_or("-"),
2136 t.created_at,
2137 t.expires_at.as_deref().unwrap_or("never"),
2138 t.last_used_at.as_deref().unwrap_or("never"),
2139 );
2140 }
2141 }
2142 },
2143 PairCommand::Revoke { id } => {
2144 if store.pairing_tokens().revoke(id)? {
2145 println!("Revoked pairing token {id}");
2146 } else {
2147 println!("No active pairing token with id {id}");
2148 }
2149 },
2150 }
2151 Ok(())
2152}
2153
2154fn sanitize_terminal_text(input: &str) -> String {
2163 let mut out = String::with_capacity(input.len());
2164 let mut chars = input.chars();
2165 while let Some(c) = chars.next() {
2166 match c {
2167 '\n' | '\t' => out.push(c),
2168 '\u{1b}' => match chars.next() {
2169 Some('[') => {
2172 for p in chars.by_ref() {
2173 if ('@'..='~').contains(&p) {
2174 break;
2175 }
2176 }
2177 },
2178 Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
2181 while let Some(p) = chars.next() {
2182 if p == '\u{07}' {
2183 break;
2184 }
2185 if p == '\u{1b}' {
2186 let mut peek = chars.clone();
2188 if peek.next() == Some('\\') {
2189 chars = peek;
2190 }
2191 break;
2192 }
2193 }
2194 },
2195 Some(mut b) => {
2198 while ('\u{20}'..='\u{2f}').contains(&b) {
2199 match chars.next() {
2200 Some(next) => b = next,
2201 None => break,
2202 }
2203 }
2204 },
2205 None => {},
2206 },
2207 c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
2209 c => out.push(c),
2210 }
2211 }
2212 out
2213}
2214
2215fn show_logs(id: &str) -> Result<()> {
2216 let content = RuntimeClient::auto().process_log(id, None)?.content;
2217 print!("{}", sanitize_terminal_text(&content));
2218 Ok(())
2219}
2220
2221fn stop_process(id: &str) -> Result<()> {
2222 let process = RuntimeClient::auto().stop_process(id)?.item;
2223 println!("Stopped process {} (pid {})", id, process.pid);
2224 Ok(())
2225}
2226
2227fn restart_process(id: &str) -> Result<()> {
2228 let process = RuntimeClient::auto().restart_process(id)?.item;
2229 println!("Restarted process {} (pid {})", id, process.pid);
2230 Ok(())
2231}
2232
2233fn open_target(target: &str) -> Result<()> {
2234 if RuntimeClient::auto().open_process(target).is_err() {
2235 mermaid_model::utils::open_file(target);
2236 }
2237 Ok(())
2238}
2239
2240fn show_ports() -> Result<()> {
2241 let ports = RuntimeClient::auto().ports()?.ports;
2242 print!("{}", sanitize_terminal_text(&ports));
2243 Ok(())
2244}
2245
2246pub async fn list_models(config: &Config) -> Result<()> {
2261 match observe_models(config).await {
2262 LocalModelListing::Unreachable if is_ollama_installed() => {
2263 println!("Ollama is installed but not running, and its model store could not be read.");
2264 println!("(It starts automatically when you use an Ollama model.)");
2265 },
2266 LocalModelListing::Unreachable => println!("Ollama is not installed; no local models."),
2267 LocalModelListing::Live(models) if models.is_empty() => {
2268 println!("No Ollama models installed locally.");
2269 },
2270 LocalModelListing::Live(models) => {
2271 println!("Ollama models (local/cloud):");
2272 for name in &models {
2273 println!(" - ollama/{name}");
2274 }
2275 },
2276 LocalModelListing::FromDisk(models) => {
2277 println!(
2278 "Ollama models (installed; server not running — starts automatically on use):"
2279 );
2280 for name in &models {
2281 println!(" - ollama/{name}");
2282 }
2283 },
2284 }
2285
2286 println!("\nConfigured remote providers:");
2287 let catalogs = crate::providers::discovery::provider_catalogs(config).await;
2288 if catalogs.is_empty() {
2289 println!(" (none — set a provider API key env var to enable)");
2290 }
2291 for catalog in &catalogs {
2292 println!(
2293 " - {} ({}) {}",
2294 catalog.provider.name,
2295 catalog.provider.source_label(),
2296 catalog.provider.endpoint
2297 );
2298 match &catalog.models {
2299 None => {
2302 println!(" (model list unavailable — the provider's /models did not answer)")
2303 },
2304 Some(models) if models.is_empty() => println!(" (provider lists no models)"),
2305 Some(models) => {
2306 for id in models {
2307 println!(" {}/{}", catalog.provider.name, id);
2308 }
2309 },
2310 }
2311 }
2312
2313 let problems = crate::providers::provider_problems(config);
2317 if !problems.is_empty() {
2318 println!("\nConfigured but not usable:");
2319 for problem in &problems {
2320 println!(" - {}: {}", problem.name, problem.reason);
2321 }
2322 }
2323
2324 println!("\nSwitch models in-session with /model <name>.");
2325 Ok(())
2326}
2327
2328pub fn show_version() {
2330 println!("Mermaid v{}", env!("CARGO_PKG_VERSION"));
2331 println!(" An open-source, model-agnostic AI pair programmer");
2332}
2333
2334const RELEASE_LATEST_API: &str =
2335 "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
2336const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
2337const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
2338
2339async fn run_update(check: bool, force: bool) -> Result<()> {
2345 let current = env!("CARGO_PKG_VERSION");
2346 println!("Installed: v{current}");
2347
2348 let client = reqwest::Client::builder()
2349 .timeout(std::time::Duration::from_secs(15))
2350 .build()?;
2351 let resp = client
2352 .get(RELEASE_LATEST_API)
2353 .header("User-Agent", "mermaid-cli")
2354 .header("Accept", "application/vnd.github+json")
2355 .send()
2356 .await
2357 .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
2358 if !resp.status().is_success() {
2359 bail!("GitHub Releases API returned HTTP {}", resp.status());
2360 }
2361 let release: serde_json::Value = resp.json().await?;
2362 let tag = release
2363 .get("tag_name")
2364 .and_then(|v| v.as_str())
2365 .ok_or_else(|| anyhow!("release response had no tag_name"))?;
2366 println!("Latest: {tag}");
2367
2368 let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
2369 if check {
2370 if up_to_date {
2371 println!("You're on the latest version.");
2372 } else {
2373 println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
2374 }
2375 return Ok(());
2376 }
2377 if up_to_date && !force {
2378 println!("Already up to date.");
2379 return Ok(());
2380 }
2381
2382 let exe =
2384 std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
2385 let install_dir = exe
2386 .parent()
2387 .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
2388
2389 let script_url = if cfg!(target_os = "windows") {
2394 INSTALL_PS1_URL
2395 } else {
2396 INSTALL_SH_URL
2397 };
2398 if !mermaid_model::utils::confirm_or_refuse(
2399 &format!(
2400 "About to download and run {script_url} to replace {}.",
2401 install_dir.display()
2402 ),
2403 force,
2404 )? {
2405 println!("Update cancelled.");
2406 return Ok(());
2407 }
2408
2409 println!("Updating {} …", install_dir.display());
2410 run_install_script(&client, install_dir).await?;
2411 println!("Updated. New version takes effect on the next run.");
2412 Ok(())
2413}
2414
2415async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
2418 let windows = cfg!(target_os = "windows");
2419 let url = if windows {
2420 INSTALL_PS1_URL
2421 } else {
2422 INSTALL_SH_URL
2423 };
2424 let script = client
2425 .get(url)
2426 .header("User-Agent", "mermaid-cli")
2427 .send()
2428 .await
2429 .map_err(|e| anyhow!("could not fetch install script: {e}"))?
2430 .error_for_status()?
2431 .text()
2432 .await?;
2433
2434 let ext = if windows { "ps1" } else { "sh" };
2435 let dir = mermaid_model::utils::private_temp_dir()
2442 .map_err(|e| anyhow!("could not create private temp dir for install script: {e}"))?;
2443 let nanos = std::time::SystemTime::now()
2444 .duration_since(std::time::UNIX_EPOCH)
2445 .map(|d| d.as_nanos())
2446 .unwrap_or_default();
2447 let script_path = dir.join(format!(
2448 "mermaid-update-{}-{nanos}.{ext}",
2449 std::process::id()
2450 ));
2451 stage_install_script(&script_path, script.as_bytes())
2452 .map_err(|e| anyhow!("could not stage install script: {e}"))?;
2453
2454 let mut cmd = if windows {
2455 let mut c = tokio::process::Command::new("powershell");
2456 c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
2457 c.arg(&script_path);
2458 c
2459 } else {
2460 let mut c = tokio::process::Command::new("sh");
2461 c.arg(&script_path);
2462 c
2463 };
2464 cmd.env("MERMAID_INSTALL_DIR", install_dir)
2465 .env("MERMAID_NO_MODIFY_PATH", "1");
2466
2467 let status = cmd
2468 .status()
2469 .await
2470 .map_err(|e| anyhow!("could not run install script: {e}"))?;
2471 let _ = std::fs::remove_file(&script_path);
2472 if !status.success() {
2473 bail!("install script exited with {:?}", status.code());
2474 }
2475 Ok(())
2476}
2477
2478fn stage_install_script(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2484 use std::io::Write;
2485 #[cfg(unix)]
2486 let mut file = {
2487 use std::os::unix::fs::OpenOptionsExt;
2488 std::fs::OpenOptions::new()
2489 .write(true)
2490 .create_new(true)
2491 .mode(0o600)
2492 .open(path)?
2493 };
2494 #[cfg(not(unix))]
2495 let mut file = std::fs::OpenOptions::new()
2496 .write(true)
2497 .create_new(true)
2498 .open(path)?;
2499 file.write_all(bytes)
2500}
2501
2502fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
2504 let core = s.trim().trim_start_matches('v');
2505 let core = core.split(['-', '+']).next().unwrap_or(core);
2506 let mut parts = core.split('.');
2507 let major = parts.next()?.parse().ok()?;
2508 let minor = parts.next().unwrap_or("0").parse().ok()?;
2509 let patch = parts.next().unwrap_or("0").parse().ok()?;
2510 Some((major, minor, patch))
2511}
2512
2513fn version_at_least(current: &str, latest: &str) -> bool {
2517 match (parse_semver(current), parse_semver(latest)) {
2518 (Some(c), Some(l)) => c >= l,
2519 _ => current == latest,
2520 }
2521}
2522
2523fn show_mcp_servers() {
2525 let config = load_config_or_warn();
2526
2527 if config.mcp_servers.is_empty() {
2528 println!("No MCP servers configured.\n");
2529 println!("Add one with: mermaid add <name>");
2530 println!("Examples:");
2531 println!(" mermaid add context7 # Library documentation");
2532 println!(" mermaid add playwright # Browser automation");
2533 println!(" mermaid add memory # Persistent knowledge graph");
2534 return;
2535 }
2536
2537 println!("Configured MCP servers:\n");
2538 for (name, server_cfg) in &config.mcp_servers {
2539 let package: &str = match &server_cfg.url {
2541 Some(url) => url,
2542 None => server_cfg
2543 .args
2544 .iter()
2545 .find(|a| !a.starts_with('-'))
2546 .map(String::as_str)
2547 .unwrap_or(server_cfg.command.as_str()),
2548 };
2549 let env_keys: Vec<&String> = server_cfg.env.keys().collect();
2550 let env_display = if env_keys.is_empty() {
2551 String::new()
2552 } else {
2553 format!(
2554 " (env: {})",
2555 env_keys
2556 .iter()
2557 .map(|k| k.as_str())
2558 .collect::<Vec<_>>()
2559 .join(", ")
2560 )
2561 };
2562 println!(" {name} — {package}{env_display}");
2563 }
2564 println!("\nManage with: mermaid add <name> / mermaid remove <name>");
2565}
2566
2567#[expect(
2569 clippy::too_many_lines,
2570 reason = "predates the lint; see .github/baselines/expect_budget.txt"
2571)]
2572async fn show_status(config: &Config) -> Result<()> {
2573 println!("Mermaid Status:");
2574 println!();
2575
2576 let available = configured_remote_providers(config);
2581 if available.is_empty() {
2582 println!(
2583 " [WARNING] Remote providers: none (no API keys in env or keyring; `mermaid login <provider>`)"
2584 );
2585 } else {
2586 println!(" [OK] Remote providers: {} configured", available.len());
2587 for provider in &available {
2588 println!(
2589 " - {} ({}) {}",
2590 provider.name,
2591 provider.source_label(),
2592 provider.endpoint
2593 );
2594 }
2595 }
2596 let problems = crate::providers::provider_problems(config);
2600 if !problems.is_empty() {
2601 println!(
2602 " [WARNING] Providers configured but not usable: {}",
2603 problems.len()
2604 );
2605 for problem in &problems {
2606 println!(" - {}: {}", problem.name, problem.reason);
2607 }
2608 }
2609
2610 if is_ollama_installed() {
2616 let preview = |models: &[String]| {
2617 for model in models.iter().take(3) {
2618 println!(" - {model}");
2619 }
2620 if models.len() > 3 {
2621 println!(" ... and {} more", models.len() - 3);
2622 }
2623 };
2624 match observe_models(config).await {
2625 LocalModelListing::Unreachable => println!(
2626 " [WARNING] Ollama: Installed but not running (started automatically \
2627 when an Ollama model is used)"
2628 ),
2629 LocalModelListing::Live(models) if models.is_empty() => {
2630 println!(" [WARNING] Ollama: Running (no models installed)");
2631 },
2632 LocalModelListing::Live(models) => {
2633 println!(" [OK] Ollama: Running ({} models installed)", models.len());
2634 preview(&models);
2635 },
2636 LocalModelListing::FromDisk(models) => {
2637 println!(
2638 " [OK] Ollama: Not running ({} models installed on disk; starts \
2639 automatically when used)",
2640 models.len()
2641 );
2642 preview(&models);
2643 },
2644 }
2645 } else if available.is_empty() {
2646 println!(" [WARNING] Ollama: Not installed (and no remote provider configured)");
2647 } else {
2648 println!(" [INFO] Ollama: Not installed (only needed for local models)");
2651 }
2652
2653 if let Ok(config_dir) = get_config_dir() {
2655 let config_path = config_dir.join("config.toml");
2656 if config_path.exists() {
2657 println!(" [OK] Configuration: {}", config_path.display());
2658 } else {
2659 println!(" [WARNING] Configuration: Not found (using defaults)");
2660 }
2661 }
2662
2663 if config.mcp_servers.is_empty() {
2665 println!(" [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
2666 } else {
2667 println!(
2668 " [OK] MCP Servers: {} configured",
2669 config.mcp_servers.len()
2670 );
2671 for (name, server_cfg) in &config.mcp_servers {
2672 let target: &str = match &server_cfg.url {
2673 Some(url) => url,
2674 None => server_cfg
2675 .args
2676 .get(1)
2677 .map(String::as_str)
2678 .unwrap_or(server_cfg.command.as_str()),
2679 };
2680 println!(" - {name} ({target})");
2681 }
2682 }
2683
2684 {
2687 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2688 let paths = crate::app::instructions::find_instruction_files(&cwd);
2689 if paths.is_empty() {
2690 println!(" [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
2691 } else {
2692 match crate::app::instructions::load_from_paths(&paths) {
2693 Some(loaded) => {
2694 let files = loaded
2695 .sources
2696 .iter()
2697 .map(|source| {
2698 source
2699 .path
2700 .file_name()
2701 .and_then(|name| name.to_str())
2702 .unwrap_or("instructions")
2703 })
2704 .collect::<Vec<_>>()
2705 .join(", ");
2706 println!(
2707 " [OK] Project instructions: {} at {} ({} bytes{})",
2708 files,
2709 loaded.path.display(),
2710 loaded.byte_len,
2711 if loaded.truncated { ", truncated" } else { "" }
2712 );
2713 },
2714 None => {
2715 println!(
2716 " [WARNING] Project instructions: found but unreadable ({})",
2717 paths
2718 .iter()
2719 .map(|path| path.display().to_string())
2720 .collect::<Vec<_>>()
2721 .join(", ")
2722 );
2723 },
2724 }
2725 }
2726 }
2727
2728 println!("\n Environment:");
2730 if std::env::var("OLLAMA_API_KEY").is_ok() {
2731 println!(" - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2732 }
2733
2734 println!();
2735 Ok(())
2736}
2737
2738fn handle_pr(command: &PrCommand) -> Result<()> {
2740 match command {
2741 PrCommand::Create {
2742 title,
2743 body,
2744 summary,
2745 base,
2746 draft,
2747 web,
2748 provider,
2749 } => create_pr(CreatePrArgs {
2750 title: title.as_deref(),
2751 body: body.as_deref(),
2752 summary: summary.as_deref(),
2753 base: base.as_deref(),
2754 draft: *draft,
2755 web: *web,
2756 provider: *provider,
2757 }),
2758 }
2759}
2760
2761struct CreatePrArgs<'a> {
2762 title: Option<&'a str>,
2763 body: Option<&'a str>,
2764 summary: Option<&'a Path>,
2765 base: Option<&'a str>,
2766 draft: bool,
2767 web: bool,
2768 provider: Option<GitHost>,
2769}
2770
2771fn create_pr(args: CreatePrArgs) -> Result<()> {
2776 let body = match args.summary {
2778 Some(path) => Some(
2779 std::fs::read_to_string(path)
2780 .with_context(|| format!("failed to read summary file {}", path.display()))?,
2781 ),
2782 None => args.body.map(str::to_string),
2783 };
2784
2785 let host = match args.provider {
2786 Some(host) => host,
2787 None => detect_git_host()?,
2788 };
2789
2790 let (cli, install_hint) = match host {
2791 GitHost::Github => (
2792 "gh",
2793 "Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.",
2794 ),
2795 GitHost::Gitlab => (
2796 "glab",
2797 "Install the GitLab CLI (https://gitlab.com/gitlab-org/cli) and run `glab auth login`.",
2798 ),
2799 };
2800 if which::which(cli).is_err() {
2801 anyhow::bail!("`{cli}` was not found on your PATH. {install_hint}");
2802 }
2803
2804 let argv = build_pr_argv(
2805 host,
2806 args.title,
2807 body.as_deref(),
2808 args.base,
2809 args.draft,
2810 args.web,
2811 );
2812
2813 println!("Creating pull/merge request via `{cli}`…");
2814 let status = std::process::Command::new(cli)
2815 .args(&argv)
2816 .status()
2817 .with_context(|| format!("failed to run `{cli}`"))?;
2818 anyhow::ensure!(status.success(), "`{cli}` exited unsuccessfully ({status})");
2819 Ok(())
2820}
2821
2822fn detect_git_host() -> Result<GitHost> {
2825 if let Some(host) = git_origin_host() {
2826 return Ok(host);
2827 }
2828 if which::which("gh").is_ok() {
2829 return Ok(GitHost::Github);
2830 }
2831 if which::which("glab").is_ok() {
2832 return Ok(GitHost::Gitlab);
2833 }
2834 anyhow::bail!(
2835 "could not detect a Git host from the `origin` remote. Pass `--provider github|gitlab` and install the matching CLI (`gh`/`glab`)."
2836 )
2837}
2838
2839fn git_origin_host() -> Option<GitHost> {
2840 let output = std::process::Command::new("git")
2841 .args(["config", "--get", "remote.origin.url"])
2842 .output()
2843 .ok()?;
2844 if !output.status.success() {
2845 return None;
2846 }
2847 host_from_remote_url(String::from_utf8_lossy(&output.stdout).trim())
2848}
2849
2850fn host_from_remote_url(url: &str) -> Option<GitHost> {
2851 let lower = url.to_ascii_lowercase();
2852 if lower.contains("github.com") {
2853 Some(GitHost::Github)
2854 } else if lower.contains("gitlab") {
2855 Some(GitHost::Gitlab)
2856 } else {
2857 None
2858 }
2859}
2860
2861fn build_pr_argv(
2863 host: GitHost,
2864 title: Option<&str>,
2865 body: Option<&str>,
2866 base: Option<&str>,
2867 draft: bool,
2868 web: bool,
2869) -> Vec<String> {
2870 let s = |v: &str| v.to_string();
2871 let has_content = title.is_some() || body.is_some();
2872 let mut argv = Vec::new();
2873 match host {
2874 GitHost::Github => {
2875 argv.push(s("pr"));
2876 argv.push(s("create"));
2877 if web {
2878 argv.push(s("--web"));
2879 }
2880 if draft {
2881 argv.push(s("--draft"));
2882 }
2883 if let Some(title) = title {
2884 argv.push(s("--title"));
2885 argv.push(s(title));
2886 }
2887 if let Some(body) = body {
2888 argv.push(s("--body"));
2889 argv.push(s(body));
2890 }
2891 if !has_content && !web {
2895 argv.push(s("--fill"));
2896 }
2897 if let Some(base) = base {
2898 argv.push(s("--base"));
2899 argv.push(s(base));
2900 }
2901 },
2902 GitHost::Gitlab => {
2903 argv.push(s("mr"));
2904 argv.push(s("create"));
2905 if web {
2906 argv.push(s("--web"));
2907 }
2908 if draft {
2909 argv.push(s("--draft"));
2910 }
2911 if let Some(title) = title {
2912 argv.push(s("--title"));
2913 argv.push(s(title));
2914 }
2915 if let Some(body) = body {
2916 argv.push(s("--description"));
2917 argv.push(s(body));
2918 }
2919 if !has_content && !web {
2920 argv.push(s("--fill"));
2921 }
2922 if let Some(base) = base {
2923 argv.push(s("--target-branch"));
2924 argv.push(s(base));
2925 }
2926 },
2927 }
2928 argv
2929}
2930
2931#[cfg(test)]
2932mod tests {
2933 use super::*;
2934
2935 #[test]
2936 fn session_log_drift_reports_agreement_and_catches_a_diverged_checkpoint() {
2937 let root = unique_temp_dir("mermaid-doctor-drift");
2938 let _ = std::fs::remove_dir_all(&root);
2939 std::fs::create_dir_all(&root).expect("project dir");
2940
2941 let empty = session_log_drift(&root);
2943 assert_eq!(empty.status, "ok", "{}", empty.message);
2944
2945 let manager = ConversationManager::new(&root).expect("manager");
2947 let mut state = mermaid_domain::State::new(
2948 Config::default(),
2949 root.clone(),
2950 "ollama/test".to_string(),
2951 chrono::Local::now(),
2952 std::env::temp_dir(),
2953 );
2954 state
2955 .session
2956 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
2957 let snapshot = state.session.snapshot_conversation();
2958 let events = state.session.drain_events(&snapshot);
2959 manager
2960 .append_session_events(&snapshot, &events)
2961 .expect("append");
2962 manager.save_conversation(&snapshot).expect("checkpoint");
2963
2964 let agreed = session_log_drift(&root);
2965 assert_eq!(agreed.status, "ok", "{}", agreed.message);
2966 assert!(agreed.message.contains('1'), "{}", agreed.message);
2967
2968 let path = manager
2971 .conversations_dir()
2972 .join(format!("{}.json", snapshot.id));
2973 let mut value: serde_json::Value =
2974 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2975 let planted = serde_json::to_value(mermaid_model::models::ChatMessage::user(
2976 "never reached the log",
2977 ))
2978 .unwrap();
2979 value
2980 .get_mut("messages")
2981 .and_then(serde_json::Value::as_array_mut)
2982 .expect("messages array")
2983 .push(planted);
2984 std::fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
2985
2986 let drifted = session_log_drift(&root);
2987 assert_eq!(drifted.status, "warning", "{}", drifted.message);
2988 assert!(
2989 drifted.message.contains(&snapshot.id),
2990 "the warning must name the session: {}",
2991 drifted.message
2992 );
2993 let _ = std::fs::remove_dir_all(&root);
2994 }
2995
2996 #[test]
2997 fn doctor_uses_resolved_keyless_web_capabilities() {
2998 let config = Config {
2999 web: mermaid_domain::WebConfig {
3000 fetch_backend: mermaid_domain::FetchBackend::Native,
3001 search_backend: mermaid_domain::SearchBackend::Searxng,
3002 searxng_url: "http://127.0.0.1:8080".to_string(),
3003 ..mermaid_domain::WebConfig::default()
3004 },
3005 ..Config::default()
3006 };
3007 let (tools, next_steps) = web_doctor_entries(&config);
3008 assert!(
3009 tools
3010 .iter()
3011 .any(|entry| entry.contains("web_fetch (native"))
3012 );
3013 assert!(
3014 tools
3015 .iter()
3016 .any(|entry| entry.contains("web_search (searxng"))
3017 );
3018 assert!(next_steps.is_empty(), "unexpected warnings: {next_steps:?}");
3019 assert!(
3020 tools.iter().all(|entry| !entry.contains("container")),
3021 "doctor must describe the selected capability, not stale container setup"
3022 );
3023 }
3024
3025 #[test]
3026 fn doctor_reports_global_network_deny_instead_of_advertising_web() {
3027 let mut config = Config::default();
3028 config.web.search_backend = mermaid_domain::SearchBackend::Searxng;
3029 config.web.searxng_url = "http://127.0.0.1:8080".to_string();
3030 config.safety.network = mermaid_domain::NetworkPolicy::Deny;
3031
3032 let (tools, next_steps) = web_doctor_entries(&config);
3033 assert!(
3034 tools
3035 .iter()
3036 .all(|entry| !entry.starts_with("web_fetch") && !entry.starts_with("web_search")),
3037 "network-denied tools were advertised: {tools:?}"
3038 );
3039 for name in ["web_fetch", "web_search"] {
3040 assert!(
3041 next_steps.iter().any(|entry| {
3042 entry.contains(name) && entry.contains("safety.network = \"deny\"")
3043 }),
3044 "missing network-deny explanation for {name}: {next_steps:?}"
3045 );
3046 }
3047 }
3048
3049 #[test]
3050 fn sanitize_terminal_text_strips_control_sequences() {
3051 assert_eq!(
3053 sanitize_terminal_text("hello\tworld\nline two"),
3054 "hello\tworld\nline two"
3055 );
3056 assert_eq!(
3058 sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
3059 "RED text"
3060 );
3061 assert_eq!(
3063 sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
3064 "beforeafter"
3065 );
3066 assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
3068 assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
3070 assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
3072 }
3073
3074 #[test]
3075 fn version_compare_handles_update_logic() {
3076 assert!(version_at_least("0.10.2", "0.10.2"));
3078 assert!(version_at_least("0.11.0", "0.10.2"));
3079 assert!(version_at_least("1.0.0", "0.99.99"));
3080 assert!(!version_at_least("0.10.1", "0.10.2"));
3082 assert!(!version_at_least("0.9.0", "0.10.0"));
3083 assert!(!version_at_least("0.10.2", "0.11.0"));
3084 assert!(version_at_least("0.10.2", "v0.10.2"));
3086 assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
3087 assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
3088 assert!(!version_at_least("0.10.2", "not-a-version"));
3090 }
3091
3092 #[test]
3093 fn host_from_remote_url_detects_provider() {
3094 assert_eq!(
3095 host_from_remote_url("https://github.com/foo/bar.git"),
3096 Some(GitHost::Github)
3097 );
3098 assert_eq!(
3099 host_from_remote_url("git@github.com:foo/bar.git"),
3100 Some(GitHost::Github)
3101 );
3102 assert_eq!(
3103 host_from_remote_url("https://gitlab.com/foo/bar.git"),
3104 Some(GitHost::Gitlab)
3105 );
3106 assert_eq!(
3107 host_from_remote_url("git@gitlab.example.com:foo/bar.git"),
3108 Some(GitHost::Gitlab)
3109 );
3110 assert_eq!(host_from_remote_url("https://bitbucket.org/foo/bar"), None);
3111 }
3112
3113 #[test]
3114 fn build_pr_argv_github_with_content() {
3115 let argv = build_pr_argv(
3116 GitHost::Github,
3117 Some("T"),
3118 Some("B"),
3119 Some("main"),
3120 true,
3121 false,
3122 );
3123 assert_eq!(
3124 argv,
3125 vec![
3126 "pr", "create", "--draft", "--title", "T", "--body", "B", "--base", "main"
3127 ]
3128 );
3129 }
3130
3131 #[test]
3132 fn build_pr_argv_github_fills_without_content() {
3133 let argv = build_pr_argv(GitHost::Github, None, None, None, false, false);
3134 assert!(argv.contains(&"--fill".to_string()));
3135 assert!(!argv.contains(&"--title".to_string()));
3136 }
3137
3138 #[test]
3139 fn build_pr_argv_web_skips_fill() {
3140 let argv = build_pr_argv(GitHost::Github, None, None, None, false, true);
3141 assert!(argv.contains(&"--web".to_string()));
3142 assert!(!argv.contains(&"--fill".to_string()));
3143 }
3144
3145 #[test]
3146 fn build_pr_argv_gitlab_uses_mr_and_target_branch() {
3147 let argv = build_pr_argv(GitHost::Gitlab, Some("T"), None, Some("main"), false, false);
3148 assert_eq!(&argv[0..2], &["mr", "create"]);
3149 assert!(argv.windows(2).any(|w| w == ["--target-branch", "main"]));
3150 assert!(argv.contains(&"--title".to_string()));
3151 }
3152
3153 #[test]
3154 fn qa_compact_smoke_persists_conversation_and_archive() {
3155 let dir = unique_temp_dir("mermaid-qa-compact-smoke");
3156 std::fs::create_dir_all(&dir).unwrap();
3157
3158 let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
3159
3160 assert!(report.ok);
3161 assert!(report.archived_messages > 0);
3162 assert!(report.preserved_messages > 0);
3163 assert!(report.replacement_messages >= 3);
3164 assert!(
3165 std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
3166 "conversation path should exist"
3167 );
3168 assert!(
3169 std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
3170 "archive path should exist"
3171 );
3172
3173 let _ = std::fs::remove_dir_all(dir);
3174 }
3175
3176 #[test]
3177 fn qa_model_id_falls_back_to_deterministic() {
3178 assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
3179 }
3180
3181 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
3182 let nanos = std::time::SystemTime::now()
3183 .duration_since(std::time::UNIX_EPOCH)
3184 .map(|duration| duration.as_nanos())
3185 .unwrap_or_default();
3186 std::env::temp_dir().join(format!("{name}-{nanos}"))
3187 }
3188}