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