Skip to main content

mermaid_cli/cli/
commands.rs

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
21/// Handle CLI subcommands
22/// Returns Ok(true) if the command was handled and we should exit
23/// Returns Ok(false) if we should continue to the main application
24pub 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            // Interactive stdin prompt — runs before the TUI enters
158            // raw mode so rpassword works. The in-TUI slash command
159            // `/cloud-setup` just points users here.
160            let _ = crate::ollama::setup_cloud_interactive();
161            Ok(true)
162        },
163        Commands::Chat => Ok(false),       // Continue to chat interface
164        Commands::Run { .. } => Ok(false), // Handled by main.rs
165    }
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 first, then the stripped conversation (same order
727                // as the live effect path), with `?` so a failed archive
728                // aborts before the conversation is overwritten.
729                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
1422/// List available models across all backends (honors user config).
1423pub 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
1454/// Ask the local Ollama daemon for its list of models. Empty on
1455/// failure — the status widget separately shows whether Ollama is
1456/// reachable.
1457async 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
1470/// Show version information
1471pub fn show_version() {
1472    println!("Mermaid v{}", env!("CARGO_PKG_VERSION"));
1473    println!("   An open-source, model-agnostic AI pair programmer");
1474}
1475
1476/// Show configured MCP servers
1477fn 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
1515/// Show status of all dependencies
1516async fn show_status(config: &Config) -> Result<()> {
1517    println!("Mermaid Status:");
1518    println!();
1519
1520    // Check remote providers by API-key env presence (matches the
1521    // routing ProviderFactory uses when the user picks a model).
1522    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    // Check Ollama (via HTTP, so remote deployments are honored).
1540    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    // Check configuration (uses platform-specific path via ProjectDirs)
1558    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    // MCP Servers
1568    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    // Project instructions (Step 5h). Walks UP from cwd to git root or
1585    // $HOME to find the nearest supported instruction files.
1586    {
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    // OpenAI-compatible providers — list anything from the built-in
1629    // registry whose API key resolves, plus any user-defined custom
1630    // providers. No network probe (would slow `mermaid status`).
1631    show_provider_status(config);
1632
1633    // Environment variables (for API providers)
1634    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
1643/// Print the remote-providers status block. Includes Anthropic (bespoke
1644/// Messages API) and any OpenAI-compatible provider whose API key resolves.
1645/// Custom providers from `[providers.<name>]` are listed if `base_url`
1646/// and `api_key_env` are both set and the env var resolves.
1647fn show_provider_status(config: &Config) {
1648    let mut configured: Vec<(String, String)> = Vec::new(); // (name, base_url)
1649
1650    // Anthropic — checked first because it's not in the OpenAI-compat
1651    // registry but is a top-tier provider users care about.
1652    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    // Gemini — also bespoke (not in OpenAI-compat registry).
1666    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    // Custom providers — anything in config.providers not in registry
1696    // and not "anthropic" / "gemini" (already handled above).
1697    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
1722/// Dispatch `mermaid pr` subcommands.
1723fn 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
1755/// Create a PR/MR by driving the host's official CLI (`gh`/`glab`), reusing
1756/// its authentication. We wrap the platform CLI rather than reimplementing
1757/// per-provider REST clients (issue #2): it reuses existing `gh auth` /
1758/// `glab auth`, handles each host's quirks, and keeps the surface tiny.
1759fn create_pr(args: CreatePrArgs) -> Result<()> {
1760    // Body precedence: --summary <file> wins over inline --body.
1761    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
1806/// Auto-detect the host: prefer the `origin` remote URL, else fall back to
1807/// whichever provider CLI is installed.
1808fn 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
1845/// Build the argv passed to the host CLI. Pure (no I/O), so it's unit-tested.
1846fn 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            // No explicit content (and not the web form) → let gh fill the
1876            // title/body from the branch's commits rather than blocking on an
1877            // interactive prompt.
1878            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}