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