Skip to main content

mermaid_cli/cli/
commands.rs

1use anyhow::{Context, Result, anyhow, bail};
2use std::path::Path;
3use std::sync::Arc;
4
5use crate::{
6    app::{Config, get_config_dir, init_config, load_config_or_warn},
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, PairCommand, 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, config).await?;
47            Ok(true)
48        },
49        Commands::Version => {
50            show_version();
51            Ok(true)
52        },
53        Commands::Update { check, force } => {
54            run_update(*check, *force).await?;
55            Ok(true)
56        },
57        Commands::Status => {
58            show_status(config).await?;
59            Ok(true)
60        },
61        Commands::Doctor { format } => {
62            show_doctor(config, cwd, cli_model, *format).await?;
63            Ok(true)
64        },
65        Commands::SelfTest {
66            format,
67            keep_workspace,
68        } => {
69            run_self_test(config, *format, *keep_workspace)?;
70            Ok(true)
71        },
72        Commands::Tasks { limit } => {
73            show_tasks(*limit)?;
74            Ok(true)
75        },
76        Commands::Task { id } => {
77            show_task(id)?;
78            Ok(true)
79        },
80        Commands::Processes { limit } => {
81            show_processes(*limit)?;
82            Ok(true)
83        },
84        Commands::Logs { id } => {
85            show_logs(id)?;
86            Ok(true)
87        },
88        Commands::Stop { id } => {
89            stop_process(id)?;
90            Ok(true)
91        },
92        Commands::Restart { id } => {
93            restart_process(id)?;
94            Ok(true)
95        },
96        Commands::Open { target } => {
97            open_target(target)?;
98            Ok(true)
99        },
100        Commands::Ports => {
101            show_ports()?;
102            Ok(true)
103        },
104        Commands::Approvals => {
105            show_approvals()?;
106            Ok(true)
107        },
108        Commands::Approve { id } => {
109            approve(id)?;
110            Ok(true)
111        },
112        Commands::Deny { id } => {
113            deny(id)?;
114            Ok(true)
115        },
116        Commands::ToolRuns { limit } => {
117            show_tool_runs(*limit)?;
118            Ok(true)
119        },
120        Commands::Checkpoints { limit } => {
121            show_checkpoints(*limit)?;
122            Ok(true)
123        },
124        Commands::Restore { id, force } => {
125            restore_checkpoint(id, *force)?;
126            Ok(true)
127        },
128        Commands::Plugin { command } => {
129            handle_plugin(command)?;
130            Ok(true)
131        },
132        Commands::Daemon { command } => {
133            super::daemon::handle_daemon_command(command)?;
134            Ok(true)
135        },
136        Commands::Pair { command } => {
137            handle_pair(command)?;
138            Ok(true)
139        },
140        Commands::Qa { command } => {
141            handle_qa(command, config, cwd)?;
142            Ok(true)
143        },
144        Commands::Add { name, yes } => {
145            crate::mcp::add_server(name, *yes).await?;
146            Ok(true)
147        },
148        Commands::Remove { name } => {
149            crate::mcp::remove_server(name).await?;
150            Ok(true)
151        },
152        Commands::Pr { command } => {
153            handle_pr(command)?;
154            Ok(true)
155        },
156        Commands::Mcp => {
157            show_mcp_servers();
158            Ok(true)
159        },
160        Commands::CloudSetup => {
161            // Interactive stdin prompt — runs before the TUI enters
162            // raw mode so rpassword works. The in-TUI slash command
163            // `/cloud-setup` just points users here.
164            let _ = crate::ollama::setup_cloud_interactive();
165            Ok(true)
166        },
167        Commands::Chat => Ok(false),       // Continue to chat interface
168        Commands::Run { .. } => Ok(false), // Handled by main.rs
169    }
170}
171
172fn handle_qa(command: &QaCommand, config: &Config, cwd: &Path) -> Result<()> {
173    match command {
174        QaCommand::CompactSmoke { turns, format } => {
175            let report = match run_qa_compact_smoke(config, cwd, *turns) {
176                Ok(report) => report,
177                Err(err) => QaCompactSmokeReport::failed(cwd, *turns, err.to_string()),
178            };
179            print_qa_compact_report(&report, *format)?;
180            anyhow::ensure!(report.ok, "qa compact smoke failed");
181            Ok(())
182        },
183    }
184}
185
186#[derive(Debug, serde::Serialize)]
187struct DoctorReport {
188    ok: bool,
189    cwd: String,
190    active_model: Option<String>,
191    model_error: Option<String>,
192    model_capabilities: Option<DoctorModelCapabilities>,
193    safety_mode: String,
194    checkpoint_on_mutation: bool,
195    prompt_customized: bool,
196    ollama: DoctorCheck,
197    remote_providers: Vec<String>,
198    project_instructions: DoctorCheck,
199    tools: Vec<String>,
200    runtime: DoctorRuntime,
201    next_steps: Vec<String>,
202}
203
204#[derive(Debug, serde::Serialize)]
205struct DoctorModelCapabilities {
206    provider: String,
207    name: String,
208    supports_tools: bool,
209    supports_vision: bool,
210    reasoning: String,
211    max_context_tokens: Option<usize>,
212}
213
214#[derive(Debug, serde::Serialize)]
215struct DoctorCheck {
216    status: &'static str,
217    message: String,
218}
219
220#[derive(Debug, serde::Serialize)]
221struct DoctorRuntime {
222    daemon: DoctorCheck,
223    local_store: DoctorCheck,
224}
225
226async fn show_doctor(
227    config: &Config,
228    cwd: &Path,
229    cli_model: Option<&str>,
230    format: OutputFormat,
231) -> Result<()> {
232    let active_model_result = crate::app::resolve_model_id(cli_model, config).await;
233    let (active_model, model_error, model_capabilities) = match active_model_result {
234        Ok(model) => {
235            let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(&model);
236            (
237                Some(model),
238                None,
239                Some(DoctorModelCapabilities {
240                    provider: snapshot.provider,
241                    name: snapshot.model,
242                    supports_tools: snapshot.supports_tools,
243                    supports_vision: snapshot.supports_vision,
244                    reasoning: snapshot.reasoning,
245                    max_context_tokens: snapshot.max_context_tokens,
246                }),
247            )
248        },
249        Err(err) => (None, Some(err.to_string()), None),
250    };
251
252    // Diagnostics observe, they don't heal: autostart=false so `doctor` can
253    // actually report a dead server instead of reviving it mid-check. `None`
254    // (unreachable) vs `Some(vec![])` (running, nothing pulled) get distinct
255    // messages; the "next steps" nudge below only needs "no usable models".
256    let ollama_models = if is_ollama_installed() {
257        list_ollama_models(config, false).await
258    } else {
259        None
260    };
261    let ollama = if !is_ollama_installed() {
262        DoctorCheck {
263            status: "warning",
264            message: "Ollama is not installed; remote providers can still work if configured."
265                .to_string(),
266        }
267    } else {
268        match &ollama_models {
269            None => DoctorCheck {
270                status: "warning",
271                message: "Ollama is installed but not running; mermaid starts it \
272                          automatically when an Ollama model is used."
273                    .to_string(),
274            },
275            Some(models) if models.is_empty() => DoctorCheck {
276                status: "warning",
277                message: "Ollama is running but no local/cloud models were listed.".to_string(),
278            },
279            Some(models) => DoctorCheck {
280                status: "ok",
281                message: format!("Ollama reachable with {} models.", models.len()),
282            },
283        }
284    };
285
286    let remote_providers = configured_remote_providers(config);
287    let instruction_paths = crate::app::instructions::find_instruction_files(cwd);
288    let project_instructions = if instruction_paths.is_empty() {
289        DoctorCheck {
290            status: "info",
291            message: "No AGENTS.md or MERMAID.md found.".to_string(),
292        }
293    } else if let Some(loaded) = crate::app::instructions::load_from_paths(&instruction_paths) {
294        DoctorCheck {
295            status: "ok",
296            message: format!(
297                "{} bytes loaded from {} source(s){}.",
298                loaded.byte_len,
299                loaded.sources.len(),
300                if loaded.truncated { " (truncated)" } else { "" }
301            ),
302        }
303    } else {
304        DoctorCheck {
305            status: "warning",
306            message: "Instruction files were found but could not be loaded.".to_string(),
307        }
308    };
309
310    let daemon = match RuntimeClient::daemon().health() {
311        Ok(read) => DoctorCheck {
312            status: "ok",
313            message: format!("daemon attached; database {}", read.value.database),
314        },
315        Err(err) => DoctorCheck {
316            status: "info",
317            message: format!("daemon not attached; CLI will use local runtime store ({err})"),
318        },
319    };
320    let local_store = match RuntimeClient::local().health() {
321        Ok(read) => DoctorCheck {
322            status: "ok",
323            message: format!("local runtime store ready at {}", read.value.database),
324        },
325        Err(err) => DoctorCheck {
326            status: "warning",
327            message: format!("local runtime store unavailable: {err}"),
328        },
329    };
330
331    let has_ollama_key = std::env::var("OLLAMA_API_KEY").is_ok();
332    let mut tools = vec![
333        "read/edit/write files".to_string(),
334        "run shell commands".to_string(),
335        "create checkpoints before risky mutations".to_string(),
336    ];
337    // web_fetch: native always works (no key); the Ollama backend needs a key.
338    match config.web.fetch_backend {
339        crate::app::FetchBackend::Native => tools.push("web_fetch (native, no key)".to_string()),
340        crate::app::FetchBackend::Ollama if has_ollama_key => {
341            tools.push("web_fetch (Ollama Cloud)".to_string())
342        },
343        crate::app::FetchBackend::Ollama => {},
344    }
345    // web_search: `auto` resolves to Ollama when a key is present, else a
346    // mermaid-managed local SearXNG; `searxng`/`ollama` force one backend.
347    match config.web.search_backend {
348        crate::app::SearchBackend::Auto if has_ollama_key => {
349            tools.push("web_search (auto: Ollama Cloud)".to_string())
350        },
351        crate::app::SearchBackend::Auto => {
352            tools.push("web_search (auto: managed local SearXNG)".to_string())
353        },
354        crate::app::SearchBackend::Searxng => {
355            tools.push(format!("web_search (SearXNG {})", config.web.searxng_url))
356        },
357        crate::app::SearchBackend::Ollama if has_ollama_key => {
358            tools.push("web_search (Ollama Cloud)".to_string())
359        },
360        crate::app::SearchBackend::Ollama => {},
361    }
362    if !config.mcp_servers.is_empty() {
363        tools.push(format!(
364            "{} configured MCP server(s)",
365            config.mcp_servers.len()
366        ));
367    }
368
369    let mut next_steps = Vec::new();
370    if active_model.is_none() {
371        next_steps.push(
372            "Pick a model with `mermaid --model <provider/model>` or run `mermaid list`."
373                .to_string(),
374        );
375    }
376    if remote_providers.is_empty() && ollama_models.as_deref().unwrap_or_default().is_empty() {
377        next_steps.push(
378            "Install or start Ollama, pull a model, or set a remote provider API key.".to_string(),
379        );
380    }
381    if instruction_paths.is_empty() {
382        next_steps.push("Optional: add MERMAID.md or AGENTS.md with project-specific run commands and conventions.".to_string());
383    }
384    if matches!(config.web.search_backend, crate::app::SearchBackend::Ollama) && !has_ollama_key {
385        next_steps.push(
386            "Web search needs a backend: set OLLAMA_API_KEY (run `mermaid cloud-setup`) or set `[web] search_backend = \"searxng\"` with a running SearXNG.".to_string(),
387        );
388    }
389    if matches!(config.web.search_backend, crate::app::SearchBackend::Auto)
390        && !has_ollama_key
391        && which::which("podman").is_err()
392        && which::which("docker").is_err()
393    {
394        next_steps.push(
395            "Web search (auto) starts a local SearXNG container, but no podman or docker was found. Install one, or set OLLAMA_API_KEY.".to_string(),
396        );
397    }
398    if next_steps.is_empty() {
399        next_steps.push(
400            "Start Mermaid with `mermaid` or run one prompt with `mermaid run \"...\"`."
401                .to_string(),
402        );
403    }
404
405    let ok = active_model.is_some()
406        && local_store.status != "warning"
407        && (ollama.status == "ok" || !remote_providers.is_empty());
408    let report = DoctorReport {
409        ok,
410        cwd: cwd.display().to_string(),
411        active_model,
412        model_error,
413        model_capabilities,
414        safety_mode: safety_mode_name(config.safety.mode).to_string(),
415        checkpoint_on_mutation: config.safety.checkpoint_on_mutation,
416        prompt_customized: config.prompt.is_customized(),
417        ollama,
418        remote_providers,
419        project_instructions,
420        tools,
421        runtime: DoctorRuntime {
422            daemon,
423            local_store,
424        },
425        next_steps,
426    };
427    print_doctor_report(&report, format)
428}
429
430fn print_doctor_report(report: &DoctorReport, format: OutputFormat) -> Result<()> {
431    match format {
432        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
433        OutputFormat::Markdown => {
434            println!("# Mermaid Doctor\n");
435            print_doctor_text(report);
436        },
437        OutputFormat::Text => print_doctor_text(report),
438    }
439    Ok(())
440}
441
442fn print_doctor_text(report: &DoctorReport) {
443    println!(
444        "Mermaid Doctor: {}",
445        if report.ok {
446            "ready"
447        } else {
448            "needs attention"
449        }
450    );
451    println!("Project: {}", report.cwd);
452    match (&report.active_model, &report.model_error) {
453        (Some(model), _) => println!("  [OK] Active model: {model}"),
454        (None, Some(error)) => println!("  [WARNING] Active model: {error}"),
455        _ => println!("  [WARNING] Active model: unresolved"),
456    }
457    if let Some(caps) = &report.model_capabilities {
458        println!(
459            "       provider={} tools={} vision={} reasoning={} context={}",
460            caps.provider,
461            caps.supports_tools,
462            caps.supports_vision,
463            caps.reasoning,
464            caps.max_context_tokens
465                .map(|n| n.to_string())
466                .unwrap_or_else(|| "unknown".to_string())
467        );
468    }
469    println!(
470        "  [{}] Ollama: {}",
471        label(report.ollama.status),
472        report.ollama.message
473    );
474    println!(
475        "  [INFO] Remote providers: {}",
476        if report.remote_providers.is_empty() {
477            "none configured".to_string()
478        } else {
479            report.remote_providers.join(", ")
480        }
481    );
482    println!(
483        "  [{}] Project instructions: {}",
484        label(report.project_instructions.status),
485        report.project_instructions.message
486    );
487    println!(
488        "  [INFO] Safety: mode={}, checkpoint_on_mutation={}",
489        report.safety_mode, report.checkpoint_on_mutation
490    );
491    println!(
492        "  [INFO] Prompt customization: {}",
493        if report.prompt_customized {
494            "active"
495        } else {
496            "default"
497        }
498    );
499    println!(
500        "  [{}] Runtime daemon: {}",
501        label(report.runtime.daemon.status),
502        report.runtime.daemon.message
503    );
504    println!(
505        "  [{}] Runtime store: {}",
506        label(report.runtime.local_store.status),
507        report.runtime.local_store.message
508    );
509    println!("  [OK] Tool surface:");
510    for tool in &report.tools {
511        println!("       - {tool}");
512    }
513    println!("\nNext steps:");
514    for step in &report.next_steps {
515        println!("  - {step}");
516    }
517}
518
519#[derive(Debug, serde::Serialize)]
520struct SelfTestReport {
521    ok: bool,
522    workspace: String,
523    checks: Vec<String>,
524    compact_smoke: QaCompactSmokeReport,
525    runtime_store: DoctorCheck,
526    kept_workspace: bool,
527}
528
529fn run_self_test(config: &Config, format: OutputFormat, keep_workspace: bool) -> Result<()> {
530    let workspace = std::env::temp_dir().join(format!("mermaid-self-test-{}", fresh_qa_id()));
531    std::fs::create_dir_all(&workspace)
532        .with_context(|| format!("failed to create {}", workspace.display()))?;
533
534    let compact_smoke = match run_qa_compact_smoke(config, &workspace, 6) {
535        Ok(report) => report,
536        Err(err) => QaCompactSmokeReport::failed(&workspace, 6, err.to_string()),
537    };
538    let runtime_store = match RuntimeClient::local().health() {
539        Ok(read) => DoctorCheck {
540            status: "ok",
541            message: format!("local runtime store ready at {}", read.value.database),
542        },
543        Err(err) => DoctorCheck {
544            status: "warning",
545            message: err.to_string(),
546        },
547    };
548
549    let checks = vec![
550        "compact smoke exercises reducer compaction path".to_string(),
551        "compact smoke persists conversation and archive artifacts".to_string(),
552        "local runtime store opens without daemon".to_string(),
553    ];
554    let ok = compact_smoke.ok && runtime_store.status == "ok";
555    let report = SelfTestReport {
556        ok,
557        workspace: workspace.display().to_string(),
558        checks,
559        compact_smoke,
560        runtime_store,
561        kept_workspace: keep_workspace,
562    };
563
564    print_self_test_report(&report, format)?;
565    if !keep_workspace {
566        let _ = std::fs::remove_dir_all(&workspace);
567    }
568    anyhow::ensure!(report.ok, "mermaid self-test failed");
569    Ok(())
570}
571
572fn print_self_test_report(report: &SelfTestReport, format: OutputFormat) -> Result<()> {
573    match format {
574        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
575        OutputFormat::Markdown => {
576            println!("# Mermaid Self-Test\n");
577            print_self_test_text(report);
578        },
579        OutputFormat::Text => print_self_test_text(report),
580    }
581    Ok(())
582}
583
584fn print_self_test_text(report: &SelfTestReport) {
585    println!(
586        "Mermaid self-test: {}",
587        if report.ok { "ok" } else { "failed" }
588    );
589    println!("workspace: {}", report.workspace);
590    println!(
591        "compact smoke: {}",
592        if report.compact_smoke.ok {
593            "ok"
594        } else {
595            "failed"
596        }
597    );
598    println!("runtime store: {}", report.runtime_store.message);
599    println!("checks:");
600    for check in &report.checks {
601        println!("  - {check}");
602    }
603    if !report.ok
604        && let Some(failure) = &report.compact_smoke.failure
605    {
606        println!("failure: {failure}");
607    }
608}
609
610fn label(status: &str) -> &'static str {
611    match status {
612        "ok" => "OK",
613        "warning" => "WARNING",
614        "error" => "ERROR",
615        _ => "INFO",
616    }
617}
618
619fn safety_mode_name(mode: crate::runtime::SafetyMode) -> &'static str {
620    mode.as_str()
621}
622
623fn configured_remote_providers(config: &Config) -> Vec<String> {
624    let mut names = Vec::new();
625    for profile in PROVIDER_REGISTRY {
626        let env = config
627            .providers
628            .get(profile.name)
629            .and_then(|c| c.api_key_env.as_deref())
630            .unwrap_or(profile.api_key_env);
631        if resolve_api_key(env, None).is_some() {
632            names.push(profile.name.to_string());
633        }
634    }
635    for (name, provider) in &config.providers {
636        if names.iter().any(|existing| existing == name) {
637            continue;
638        }
639        if let Some(env) = provider.api_key_env.as_deref()
640            && resolve_api_key(env, None).is_some()
641        {
642            names.push(name.clone());
643        }
644    }
645    names.sort();
646    names
647}
648
649#[derive(Debug, serde::Serialize)]
650struct QaCompactSmokeReport {
651    ok: bool,
652    turns: usize,
653    archived_messages: usize,
654    preserved_messages: usize,
655    replacement_messages: usize,
656    conversation_path: Option<String>,
657    archive_path: Option<String>,
658    checks: Vec<String>,
659    failure: Option<String>,
660}
661
662impl QaCompactSmokeReport {
663    fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
664        Self {
665            ok: false,
666            turns,
667            archived_messages: 0,
668            preserved_messages: 0,
669            replacement_messages: 0,
670            conversation_path: Some(
671                cwd.join(".mermaid")
672                    .join("conversations")
673                    .display()
674                    .to_string(),
675            ),
676            archive_path: None,
677            checks: Vec::new(),
678            failure: Some(failure),
679        }
680    }
681}
682
683fn run_qa_compact_smoke(
684    config: &Config,
685    cwd: &Path,
686    requested_turns: usize,
687) -> Result<QaCompactSmokeReport> {
688    let turns = requested_turns.max(3);
689    let mut state = State::new(
690        config.clone(),
691        cwd.to_path_buf(),
692        qa_model_id(config),
693        chrono::Local::now(),
694    );
695    for message in synthetic_compaction_messages(turns) {
696        state.session.append(message, state.now);
697    }
698
699    let (state_after_slash, compact_cmds) = update(
700        state,
701        Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
702    );
703    let turn = state_after_slash
704        .turn
705        .id()
706        .context("manual compaction did not enter a compaction turn")?;
707    let request = compact_cmds
708        .iter()
709        .find_map(|cmd| match cmd {
710            Cmd::CompactConversation { request, .. } => Some(request.clone()),
711            _ => None,
712        })
713        .context("manual compaction did not emit a CompactConversation command")?;
714
715    let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
716    let prepared = prepare_compaction(&request, Some(100_000))
717        .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
718    anyhow::ensure!(
719        !prepared.archived_messages.is_empty(),
720        "compaction archived no messages"
721    );
722    anyhow::ensure!(
723        !prepared.preserved_messages.is_empty(),
724        "compaction preserved no messages"
725    );
726
727    let summary = deterministic_compaction_summary(&prepared, turns);
728    let mut record = CompactionRecord {
729        id: format!("qa_compact_{}", fresh_qa_id()),
730        trigger: CompactionTrigger::Manual,
731        created_at: chrono::Local::now(),
732        before_tokens: before_snapshot.used_tokens,
733        after_tokens: 0,
734        archived_message_count: prepared.archived_messages.len(),
735        preserved_message_count: prepared.preserved_messages.len(),
736        summary_tokens: summary.len().div_ceil(4),
737        duration_secs: 0.0,
738        verified: true,
739        verification_error: None,
740        focus: Some("qa compact smoke".to_string()),
741        archive_path: None,
742    };
743    let mut replacement = build_replacement_messages(&summary, &prepared, &record);
744    let mut after_chat: ChatRequest = request.chat.clone();
745    after_chat.messages = replacement.clone();
746    let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
747    record.after_tokens = after_snapshot.used_tokens;
748    replacement = build_replacement_messages(&summary, &prepared, &record);
749    after_chat.messages = replacement.clone();
750    after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
751
752    let result = CompactionResult {
753        record,
754        replacement_messages: replacement,
755        archived_messages: prepared.archived_messages,
756        before_snapshot,
757        after_snapshot,
758        usage: None,
759    };
760    let (final_state, save_cmds) =
761        update(state_after_slash, Msg::CompactionFinished { turn, result });
762
763    let manager = ConversationManager::new(cwd)?;
764    let mut conversation_path = None;
765    let mut archive_path = None;
766    for cmd in save_cmds {
767        match cmd {
768            Cmd::SaveConversation(conversation) => {
769                manager.save_conversation(&conversation)?;
770                conversation_path = Some(
771                    manager
772                        .conversations_dir()
773                        .join(format!("{}.json", conversation.id))
774                        .display()
775                        .to_string(),
776                );
777            },
778            Cmd::SaveCompactionArchive {
779                archive,
780                conversation,
781                ..
782            } => {
783                // Archive first, then the stripped conversation (same order
784                // as the live effect path), with `?` so a failed archive
785                // aborts before the conversation is overwritten.
786                archive_path = Some(
787                    manager
788                        .save_compaction_archive(&archive)?
789                        .display()
790                        .to_string(),
791                );
792                manager.save_conversation(&conversation)?;
793                conversation_path = Some(
794                    manager
795                        .conversations_dir()
796                        .join(format!("{}.json", conversation.id))
797                        .display()
798                        .to_string(),
799                );
800            },
801            _ => {},
802        }
803    }
804
805    let conversation_path = conversation_path.context("compaction did not save conversation")?;
806    let archive_path = archive_path.context("compaction did not save archive")?;
807    let messages = final_state.session.messages();
808    let compactions = &final_state.session.conversation.compactions;
809
810    let mut checks = Vec::new();
811    anyhow::ensure!(
812        !compactions.is_empty(),
813        "conversation did not record compaction metadata"
814    );
815    checks.push("conversation records compaction metadata".to_string());
816    anyhow::ensure!(
817        messages
818            .first()
819            .is_some_and(|msg| msg.kind == crate::models::ChatMessageKind::ContextCheckpoint),
820        "replacement does not start with a context checkpoint"
821    );
822    checks.push("replacement starts with context checkpoint".to_string());
823    anyhow::ensure!(
824        std::path::Path::new(&conversation_path).exists(),
825        "conversation file missing after save"
826    );
827    checks.push("conversation file saved".to_string());
828    anyhow::ensure!(
829        std::path::Path::new(&archive_path).exists(),
830        "compaction archive file missing after save"
831    );
832    checks.push("archive file saved".to_string());
833    anyhow::ensure!(
834        compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
835        "compaction did not archive and preserve messages"
836    );
837    checks.push("archived and preserved message counts are non-zero".to_string());
838
839    Ok(QaCompactSmokeReport {
840        ok: true,
841        turns,
842        archived_messages: compactions[0].archived_message_count,
843        preserved_messages: compactions[0].preserved_message_count,
844        replacement_messages: messages.len(),
845        conversation_path: Some(conversation_path),
846        archive_path: Some(archive_path),
847        checks,
848        failure: None,
849    })
850}
851
852fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
853    match format {
854        OutputFormat::Json => {
855            println!("{}", serde_json::to_string_pretty(report)?);
856        },
857        OutputFormat::Text => {
858            println!(
859                "qa compact smoke: {}",
860                if report.ok { "ok" } else { "failed" }
861            );
862            println!("turns: {}", report.turns);
863            println!("archived messages: {}", report.archived_messages);
864            println!("preserved messages: {}", report.preserved_messages);
865            println!("replacement messages: {}", report.replacement_messages);
866            if let Some(path) = &report.conversation_path {
867                println!("conversation: {path}");
868            }
869            if let Some(path) = &report.archive_path {
870                println!("archive: {path}");
871            }
872            if let Some(failure) = &report.failure {
873                println!("failure: {failure}");
874            }
875        },
876        OutputFormat::Markdown => {
877            println!(
878                "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
879                if report.ok { "ok" } else { "failed" },
880                report.turns,
881                report.archived_messages,
882                report.preserved_messages,
883                report.replacement_messages
884            );
885            if let Some(path) = &report.conversation_path {
886                println!("- Conversation: `{path}`");
887            }
888            if let Some(path) = &report.archive_path {
889                println!("- Archive: `{path}`");
890            }
891            if let Some(failure) = &report.failure {
892                println!("\nFailure: `{failure}`");
893            }
894        },
895    }
896    Ok(())
897}
898
899fn qa_model_id(config: &Config) -> String {
900    if let Some(model) = config
901        .last_used_model
902        .as_ref()
903        .filter(|value| !value.is_empty())
904    {
905        return model.clone();
906    }
907    if !config.default_model.name.is_empty() {
908        if config.default_model.provider.is_empty() {
909            return config.default_model.name.clone();
910        }
911        return format!(
912            "{}/{}",
913            config.default_model.provider, config.default_model.name
914        );
915    }
916    "qa/deterministic".to_string()
917}
918
919fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
920    let mut messages = Vec::with_capacity(turns.saturating_mul(2));
921    for idx in 1..=turns {
922        messages.push(ChatMessage::user(format!(
923            "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
924        )));
925        messages.push(ChatMessage::assistant(format!(
926            "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}."
927        )));
928    }
929    messages
930}
931
932fn deterministic_compaction_summary(
933    prepared: &crate::domain::PreparedCompaction,
934    turns: usize,
935) -> String {
936    format!(
937        "## 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.",
938        prepared.archived_messages.len(),
939        prepared.preserved_messages.len()
940    )
941}
942
943fn fresh_qa_id() -> u128 {
944    std::time::SystemTime::now()
945        .duration_since(std::time::UNIX_EPOCH)
946        .map(|duration| duration.as_nanos())
947        .unwrap_or_default()
948}
949
950fn show_tasks(limit: usize) -> Result<()> {
951    let read = RuntimeClient::auto().list_tasks(limit)?;
952    let mut tasks = read.value;
953    tasks.truncate(limit);
954    println!("Mermaid runtime tasks");
955    println!("Source: {}", read.source.as_str());
956    println!();
957    if tasks.is_empty() {
958        println!("No tasks recorded yet.");
959        return Ok(());
960    }
961    for task in tasks {
962        println!(
963            "{}  [{}] {}  {}  {}",
964            task.id, task.status, task.priority, task.updated_at, task.title
965        );
966        println!("    project: {}", task.project_path);
967        println!("    model: {}", task.model_id);
968    }
969    Ok(())
970}
971
972fn show_task(id: &str) -> Result<()> {
973    let detail = RuntimeClient::auto().task_detail(id)?.value;
974    print_task_detail(&detail.task);
975    let events = detail.events;
976    if !events.is_empty() {
977        println!();
978        println!("Timeline:");
979        for event in events {
980            println!("  {}  {}  {}", event.created_at, event.kind, event.message);
981        }
982    }
983    Ok(())
984}
985
986fn print_task_detail(task: &TaskRecord) {
987    println!("Task: {}", task.id);
988    println!("Title: {}", task.title);
989    println!("Status: {}", task.status);
990    println!("Priority: {}", task.priority);
991    println!("Project: {}", task.project_path);
992    println!("Model: {}", task.model_id);
993    if let Some(conversation_id) = &task.conversation_id {
994        println!("Conversation: {}", conversation_id);
995    }
996    println!("Created: {}", task.created_at);
997    println!("Updated: {}", task.updated_at);
998    if let Some(report) = &task.final_report {
999        println!();
1000        println!("Final report:");
1001        println!("{}", sanitize_terminal_text(report));
1002    }
1003}
1004
1005fn show_processes(limit: usize) -> Result<()> {
1006    let read = RuntimeClient::auto().list_processes(limit)?;
1007    let mut processes = read.value;
1008    processes.truncate(limit);
1009    println!("Mermaid runtime processes");
1010    println!("Source: {}", read.source.as_str());
1011    println!();
1012    if processes.is_empty() {
1013        println!("No processes recorded yet.");
1014        return Ok(());
1015    }
1016    for process in processes {
1017        println!(
1018            "{}  pid={}  status={}  {}",
1019            process.id,
1020            process.pid,
1021            process.status.as_str(),
1022            process.command
1023        );
1024        if let Some(task_id) = process.task_id {
1025            println!("    task: {}", task_id);
1026        }
1027        if let Some(cwd) = process.cwd {
1028            println!("    cwd: {}", cwd);
1029        }
1030        if let Some(log_path) = process.log_path {
1031            println!("    log: {}", log_path);
1032        }
1033        if let Some(url) = process.detected_url {
1034            println!("    url: {}", url);
1035        }
1036    }
1037    Ok(())
1038}
1039
1040async fn show_models(config: &Config) -> Result<()> {
1041    list_models(config).await?;
1042    probe_configured_provider_models(config).await?;
1043    let store = RuntimeStore::open_default()?;
1044    let probes = store.provider_probes().list(None, None)?;
1045    if !probes.is_empty() {
1046        println!("\nCached capability probes:");
1047        for probe in probes {
1048            println!(
1049                "  - {}/{} {}={} ({})",
1050                probe.provider,
1051                probe.model_id,
1052                probe.capability_key,
1053                probe.capability_value,
1054                probe.confidence
1055            );
1056        }
1057    }
1058    Ok(())
1059}
1060
1061async fn show_model_info(model: &str, config: &Config) -> Result<()> {
1062    let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(model);
1063    let store = RuntimeStore::open_default()?;
1064    let provider = snapshot.provider.clone();
1065
1066    // The static snapshot has no context window for Ollama. Probe `/api/show` for
1067    // the model's real one so this reports a real number, not "unknown".
1068    let mut context_tokens = snapshot.max_context_tokens;
1069    let mut context_confidence = "static";
1070    if provider == "ollama" {
1071        let backend = std::sync::Arc::new(crate::providers::factory::ollama_backend_config(config));
1072        if let Ok(adapter) =
1073            crate::models::adapters::ollama::OllamaAdapter::new(&snapshot.model, backend).await
1074            && let Some(info) = adapter.show_model_info().await
1075            && let Some(ctx) = info.context_length
1076        {
1077            context_tokens = Some(ctx);
1078            context_confidence = "probed";
1079        }
1080    }
1081
1082    for (key, value) in [
1083        ("supports_tools", snapshot.supports_tools.to_string()),
1084        ("supports_vision", snapshot.supports_vision.to_string()),
1085        ("reasoning", snapshot.reasoning.clone()),
1086    ] {
1087        let _ = store.provider_probes().upsert(NewProviderProbe {
1088            provider: provider.clone(),
1089            model_id: snapshot.model.clone(),
1090            capability_key: key.to_string(),
1091            capability_value: value,
1092            confidence: "static".to_string(),
1093            error: None,
1094        });
1095    }
1096    // Context window separately — probed (Ollama) or static.
1097    let _ = store.provider_probes().upsert(NewProviderProbe {
1098        provider: provider.clone(),
1099        model_id: snapshot.model.clone(),
1100        capability_key: "max_context_tokens".to_string(),
1101        capability_value: context_tokens
1102            .map(|n| n.to_string())
1103            .unwrap_or_else(|| "unknown".to_string()),
1104        confidence: context_confidence.to_string(),
1105        error: None,
1106    });
1107    println!("Model: {}", model);
1108    println!("Provider: {}", snapshot.provider);
1109    println!("Name: {}", snapshot.model);
1110    println!("Supports tools: {}", snapshot.supports_tools);
1111    println!("Supports vision: {}", snapshot.supports_vision);
1112    println!("Reasoning: {}", snapshot.reasoning);
1113    println!(
1114        "Context: {}",
1115        context_tokens
1116            .map(|n| n.to_string())
1117            .unwrap_or_else(|| "unknown".to_string())
1118    );
1119    if let Some(profile) = lookup_provider(&snapshot.provider) {
1120        for (key, value) in [
1121            (
1122                "max_output_tokens_param",
1123                format!("{:?}", profile.max_tokens_param),
1124            ),
1125            (
1126                "parallel_tool_calls",
1127                (!profile
1128                    .disable_parallel_tool_calls_for
1129                    .iter()
1130                    .any(|disabled| *disabled == snapshot.model))
1131                .to_string(),
1132            ),
1133            (
1134                "reasoning_parameter_shape",
1135                format!("{:?}", profile.reasoning_strategy),
1136            ),
1137            (
1138                "streaming_usage_available",
1139                "provider_dependent".to_string(),
1140            ),
1141            ("token_usage_field_shape", "openai_compatible".to_string()),
1142        ] {
1143            let _ = store.provider_probes().upsert(NewProviderProbe {
1144                provider: provider.clone(),
1145                model_id: snapshot.model.clone(),
1146                capability_key: key.to_string(),
1147                capability_value: value,
1148                confidence: "static".to_string(),
1149                error: None,
1150            });
1151        }
1152        println!("Token budget field: {:?}", profile.max_tokens_param);
1153        println!(
1154            "Single-tool-call models: {}",
1155            if profile.disable_parallel_tool_calls_for.is_empty() {
1156                "(none)".to_string()
1157            } else {
1158                profile.disable_parallel_tool_calls_for.join(", ")
1159            }
1160        );
1161    }
1162    Ok(())
1163}
1164
1165async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1166    let client = reqwest::Client::builder()
1167        .timeout(std::time::Duration::from_secs(5))
1168        .build()?;
1169    for profile in PROVIDER_REGISTRY {
1170        let user_cfg = config.providers.get(profile.name);
1171        let env = user_cfg
1172            .and_then(|c| c.api_key_env.as_deref())
1173            .unwrap_or(profile.api_key_env);
1174        let Some(api_key) = resolve_api_key(env, None) else {
1175            continue;
1176        };
1177        let base_url = user_cfg
1178            .and_then(|c| c.base_url.clone())
1179            .unwrap_or_else(|| profile.base_url.to_string());
1180        let url = format!("{}/models", base_url.trim_end_matches('/'));
1181        let mut request = client.get(&url).bearer_auth(api_key);
1182        for (name, value) in profile.extra_headers {
1183            request = request.header(*name, *value);
1184        }
1185        if let Some(user_cfg) = user_cfg {
1186            for (name, value) in &user_cfg.extra_headers {
1187                request = request.header(name, value);
1188            }
1189        }
1190
1191        let result = request.send().await;
1192        match result {
1193            Ok(response) if response.status().is_success() => {
1194                let status = response.status();
1195                let body: serde_json::Value = response.json().await.unwrap_or_default();
1196                let ids = body
1197                    .get("data")
1198                    .and_then(|v| v.as_array())
1199                    .map(|items| {
1200                        items
1201                            .iter()
1202                            .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1203                            .map(str::to_string)
1204                            .collect::<Vec<_>>()
1205                    })
1206                    .unwrap_or_default();
1207                record_provider_probe(
1208                    profile.name,
1209                    "*",
1210                    "models_availability",
1211                    &format!("available:{}:{}", status.as_u16(), ids.len()),
1212                    "probed",
1213                    None,
1214                );
1215                for model_id in ids.into_iter().take(200) {
1216                    record_provider_probe(
1217                        profile.name,
1218                        &model_id,
1219                        "model_listed",
1220                        "true",
1221                        "listed",
1222                        None,
1223                    );
1224                }
1225            },
1226            Ok(response) => {
1227                record_provider_probe(
1228                    profile.name,
1229                    "*",
1230                    "models_availability",
1231                    "failed",
1232                    "failed",
1233                    Some(format!("HTTP {}", response.status().as_u16())),
1234                );
1235            },
1236            Err(error) => {
1237                record_provider_probe(
1238                    profile.name,
1239                    "*",
1240                    "models_availability",
1241                    "failed",
1242                    "failed",
1243                    Some(error.to_string()),
1244                );
1245            },
1246        }
1247    }
1248    Ok(())
1249}
1250
1251fn record_provider_probe(
1252    provider: &str,
1253    model_id: &str,
1254    key: &str,
1255    value: &str,
1256    confidence: &str,
1257    error: Option<String>,
1258) {
1259    if let Ok(store) = RuntimeStore::open_default() {
1260        let _ = store.provider_probes().upsert(NewProviderProbe {
1261            provider: provider.to_string(),
1262            model_id: model_id.to_string(),
1263            capability_key: key.to_string(),
1264            capability_value: value.to_string(),
1265            confidence: confidence.to_string(),
1266            error,
1267        });
1268    }
1269}
1270
1271fn show_approvals() -> Result<()> {
1272    let approvals = RuntimeClient::auto().list_approvals()?.value;
1273    if approvals.is_empty() {
1274        println!("No pending approvals.");
1275        return Ok(());
1276    }
1277    for approval in approvals {
1278        println!(
1279            "{} [{} -> {}] {}",
1280            approval.id,
1281            approval.risk_classification,
1282            approval.policy_decision,
1283            approval.proposed_action
1284        );
1285        if let Some(args) = approval.args_summary {
1286            println!("    args: {}", args);
1287        }
1288        if let Some(checkpoint_id) = approval.checkpoint_id {
1289            println!("    checkpoint: {}", checkpoint_id);
1290        }
1291        if approval.pending_action_json.is_some() {
1292            println!("    pending action: recorded");
1293        }
1294    }
1295    Ok(())
1296}
1297
1298fn approve(id: &str) -> Result<()> {
1299    let result = RuntimeClient::auto().approve(id)?;
1300    println!("Approved {}", id);
1301    if result.replayed {
1302        println!("{}", result.summary);
1303    }
1304    Ok(())
1305}
1306
1307fn deny(id: &str) -> Result<()> {
1308    let _ = RuntimeClient::auto().deny(id)?;
1309    println!("Denied {}", id);
1310    Ok(())
1311}
1312
1313fn show_tool_runs(limit: usize) -> Result<()> {
1314    let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1315    runs.truncate(limit);
1316    if runs.is_empty() {
1317        println!("No tool runs recorded yet.");
1318        return Ok(());
1319    }
1320    for run in runs {
1321        println!(
1322            "{} [{}] {} started {}",
1323            run.id, run.status, run.tool_name, run.started_at
1324        );
1325        if let Some(turn_id) = run.turn_id {
1326            println!("    turn: {}", turn_id);
1327        }
1328        if let Some(call_id) = run.call_id {
1329            println!("    call: {}", call_id);
1330        }
1331        if let Some(finished_at) = run.finished_at {
1332            println!("    finished: {}", finished_at);
1333        }
1334    }
1335    Ok(())
1336}
1337
1338fn show_checkpoints(limit: usize) -> Result<()> {
1339    let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1340    checkpoints.truncate(limit);
1341    if checkpoints.is_empty() {
1342        println!("No checkpoints recorded yet.");
1343        return Ok(());
1344    }
1345    for checkpoint in checkpoints {
1346        println!(
1347            "{}  {}  {}",
1348            checkpoint.id, checkpoint.created_at, checkpoint.project_path
1349        );
1350        println!("    snapshot: {}", checkpoint.snapshot_path);
1351        println!("    files: {}", checkpoint.changed_files_json);
1352        if let Some(approval_id) = checkpoint.approval_id {
1353            println!("    approval: {}", approval_id);
1354        }
1355    }
1356    Ok(())
1357}
1358
1359fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
1360    // Restoring overwrites the working tree from the checkpoint. Confirm first
1361    // (default NO); `--force` is the scripted-use bypass, and a non-interactive
1362    // session without it refuses rather than clobbering the tree unprompted (#113).
1363    if !crate::utils::confirm_or_refuse(
1364        &format!("Restore checkpoint {id}? This overwrites the current working tree."),
1365        force,
1366    )? {
1367        println!("Restore cancelled.");
1368        return Ok(());
1369    }
1370    let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
1371    println!("Restored {} ({} files)", manifest.id, manifest.files.len());
1372    if let Some(repo) = manifest.shadow_git_repo {
1373        println!("Shadow repo: {}", repo);
1374    }
1375    if let Some(commit) = manifest.shadow_git_commit {
1376        println!("Shadow commit: {}", commit);
1377    }
1378    if let Some(action) = manifest.pending_action {
1379        println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
1380    }
1381    Ok(())
1382}
1383
1384fn handle_plugin(command: &PluginCommand) -> Result<()> {
1385    match command {
1386        PluginCommand::Install { path } => {
1387            let preview = crate::runtime::plugin_capability_preview(path)?;
1388            print_plugin_capability_preview(&preview);
1389            let record = crate::runtime::install_plugin_from_path(path)?;
1390            println!(
1391                "Installed plugin {} ({}) — DISABLED.",
1392                record.name, record.id
1393            );
1394            println!(
1395                "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
1396                record.id
1397            );
1398        },
1399        PluginCommand::List => {
1400            let plugins = RuntimeClient::auto().list_plugins()?.value;
1401            if plugins.is_empty() {
1402                println!("No plugins installed.");
1403            } else {
1404                for plugin in plugins {
1405                    println!(
1406                        "{} [{}] {} ({})",
1407                        plugin.id,
1408                        if plugin.enabled {
1409                            "enabled"
1410                        } else {
1411                            "disabled"
1412                        },
1413                        plugin.name,
1414                        plugin.source
1415                    );
1416                }
1417            }
1418        },
1419        PluginCommand::Enable { id } => {
1420            // Surface what the plugin declares before activating its native code.
1421            let client = RuntimeClient::auto();
1422            if let Some(plugin) = client
1423                .list_plugins()?
1424                .value
1425                .into_iter()
1426                .find(|p| p.id == *id || p.name == *id)
1427                && let Ok(preview) =
1428                    crate::runtime::plugin_capability_preview(Path::new(&plugin.source))
1429            {
1430                print_plugin_capability_preview(&preview);
1431            }
1432            client.set_plugin_enabled(id, true)?;
1433            println!("Enabled plugin {} — its hooks will now run.", id);
1434        },
1435        PluginCommand::Disable { id } => {
1436            RuntimeClient::auto().set_plugin_enabled(id, false)?;
1437            println!("Disabled plugin {}", id);
1438        },
1439        PluginCommand::Audit { path } => {
1440            let manifest_path = if path.is_dir() {
1441                path.join("plugin.toml")
1442            } else {
1443                path.clone()
1444            };
1445            let raw = std::fs::read_to_string(&manifest_path)?;
1446            let manifest: crate::runtime::PluginManifest = toml::from_str(&raw)?;
1447            let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1448            crate::runtime::validate_plugin_manifest(&manifest, root)?;
1449            let preview = crate::runtime::plugin_capability_preview(path)?;
1450            println!("Plugin manifest is valid: {}", manifest.name);
1451            print_plugin_capability_preview(&preview);
1452        },
1453    }
1454    Ok(())
1455}
1456
1457fn print_plugin_capability_preview(preview: &crate::runtime::PluginCapabilityPreview) {
1458    println!(
1459        "Capabilities declared by plugin {} (advisory, not sandbox-enforced):",
1460        preview.name
1461    );
1462    if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
1463        println!("  capabilities: (none declared)");
1464    } else {
1465        if !preview.declared_capabilities.is_empty() {
1466            println!("  declared: {}", preview.declared_capabilities.join(", "));
1467        }
1468        if let Some(value) = &preview.capabilities_toml {
1469            println!(
1470                "  capabilities.toml: {}",
1471                serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
1472            );
1473        }
1474    }
1475    if !preview.hooks.is_empty() {
1476        println!("  hooks: {}", preview.hooks.join(", "));
1477    }
1478    if !preview.mcp.is_empty() {
1479        println!("  mcp: {}", preview.mcp.join(", "));
1480    }
1481    if !preview.bin.is_empty() {
1482        println!("  bin: {}", preview.bin.join(", "));
1483    }
1484}
1485
1486fn handle_pair(command: &PairCommand) -> Result<()> {
1487    let store = RuntimeStore::open_default()?;
1488    match command {
1489        PairCommand::Create { label, ttl_days } => {
1490            let ttl = ttl_days.unwrap_or(crate::runtime::DEFAULT_PAIRING_TTL_DAYS);
1491            let expires_at = crate::runtime::pairing_expiry_from_now(ttl);
1492            let (token, hash) = crate::runtime::generate_pairing_token()?;
1493            let record =
1494                store
1495                    .pairing_tokens()
1496                    .create(&hash, label.as_deref(), expires_at.as_deref())?;
1497            println!("Pairing token id: {}", record.id);
1498            println!("Pairing token: {}", token);
1499            println!(
1500                "Expires: {}",
1501                record.expires_at.as_deref().unwrap_or("never")
1502            );
1503            println!(
1504                "Use with daemon JSON by setting {}.",
1505                crate::runtime::daemon::DAEMON_TOKEN_ENV
1506            );
1507            println!("Store this now; Mermaid will not print it again.");
1508        },
1509        PairCommand::List => {
1510            let tokens = store.pairing_tokens().list()?;
1511            if tokens.is_empty() {
1512                println!("No pairing tokens.");
1513            } else {
1514                // Never print token_hash — only the non-secret metadata.
1515                for t in tokens {
1516                    println!(
1517                        "{} [{}] label={} created={} expires={} last_used={}",
1518                        t.id,
1519                        if t.enabled { "active" } else { "revoked" },
1520                        t.label.as_deref().unwrap_or("-"),
1521                        t.created_at,
1522                        t.expires_at.as_deref().unwrap_or("never"),
1523                        t.last_used_at.as_deref().unwrap_or("never"),
1524                    );
1525                }
1526            }
1527        },
1528        PairCommand::Revoke { id } => {
1529            if store.pairing_tokens().revoke(id)? {
1530                println!("Revoked pairing token {id}");
1531            } else {
1532                println!("No active pairing token with id {id}");
1533            }
1534        },
1535    }
1536    Ok(())
1537}
1538
1539/// Strip terminal control sequences from untrusted subprocess output before
1540/// printing it to a cooked terminal. Managed-process logs / reports / port
1541/// listings are attacker-influenceable (a dev server can emit anything), so a
1542/// raw `print!` would let escape sequences execute — OSC-52 clipboard writes,
1543/// window-title/prompt rewrites, cursor moves used for spoofing. Keeps `\n` and
1544/// `\t`; drops every ESC-introduced sequence (CSI / OSC / DCS / PM / APC / SOS
1545/// and simple two-/three-byte forms) and all other C0/C1 control characters
1546/// (incl. `\r` and DEL). See F49.
1547fn sanitize_terminal_text(input: &str) -> String {
1548    let mut out = String::with_capacity(input.len());
1549    let mut chars = input.chars();
1550    while let Some(c) = chars.next() {
1551        match c {
1552            '\n' | '\t' => out.push(c),
1553            '\u{1b}' => match chars.next() {
1554                // CSI: ESC '[' params/intermediates then a final byte
1555                // (0x40-0x7e), which is also dropped.
1556                Some('[') => {
1557                    for p in chars.by_ref() {
1558                        if ('@'..='~').contains(&p) {
1559                            break;
1560                        }
1561                    }
1562                },
1563                // String sequences (OSC ']', DCS 'P', PM '^', APC '_', SOS 'X'):
1564                // arbitrary body terminated by BEL or ST (ESC '\').
1565                Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
1566                    while let Some(p) = chars.next() {
1567                        if p == '\u{07}' {
1568                            break;
1569                        }
1570                        if p == '\u{1b}' {
1571                            // ESC here starts ST (ESC '\'); drop the trailing '\'.
1572                            let mut peek = chars.clone();
1573                            if peek.next() == Some('\\') {
1574                                chars = peek;
1575                            }
1576                            break;
1577                        }
1578                    }
1579                },
1580                // Other ESC forms: optional intermediates (0x20-0x2f) then a
1581                // final byte; drop them all.
1582                Some(mut b) => {
1583                    while ('\u{20}'..='\u{2f}').contains(&b) {
1584                        match chars.next() {
1585                            Some(next) => b = next,
1586                            None => break,
1587                        }
1588                    }
1589                },
1590                None => {},
1591            },
1592            // Drop DEL, all other C0 controls (incl. `\r`), and C1 controls.
1593            c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
1594            c => out.push(c),
1595        }
1596    }
1597    out
1598}
1599
1600fn show_logs(id: &str) -> Result<()> {
1601    let content = RuntimeClient::auto().process_log(id, None)?.content;
1602    print!("{}", sanitize_terminal_text(&content));
1603    Ok(())
1604}
1605
1606fn stop_process(id: &str) -> Result<()> {
1607    let process = RuntimeClient::auto().stop_process(id)?.item;
1608    println!("Stopped process {} (pid {})", id, process.pid);
1609    Ok(())
1610}
1611
1612fn restart_process(id: &str) -> Result<()> {
1613    let process = RuntimeClient::auto().restart_process(id)?.item;
1614    println!("Restarted process {} (pid {})", id, process.pid);
1615    Ok(())
1616}
1617
1618fn open_target(target: &str) -> Result<()> {
1619    if RuntimeClient::auto().open_process(target).is_err() {
1620        crate::utils::open_file(target);
1621    }
1622    Ok(())
1623}
1624
1625fn show_ports() -> Result<()> {
1626    let ports = RuntimeClient::auto().ports()?.ports;
1627    print!("{}", sanitize_terminal_text(&ports));
1628    Ok(())
1629}
1630
1631/// List available models across all backends (honors user config).
1632pub async fn list_models(config: &Config) -> Result<()> {
1633    // User intent is "show me my models" — reviving a dead local server IS
1634    // the job here (autostart=true), unlike the observe-only diagnostics.
1635    let ollama_models = list_ollama_models(config, true).await.unwrap_or_default();
1636    if ollama_models.is_empty() {
1637        println!("No Ollama models installed locally.");
1638    } else {
1639        println!("Ollama models (local/cloud):");
1640        for name in &ollama_models {
1641            println!("  - ollama/{}", name);
1642        }
1643    }
1644
1645    println!("\nConfigured remote providers:");
1646    let mut any = false;
1647    for profile in PROVIDER_REGISTRY {
1648        let env = config
1649            .providers
1650            .get(profile.name)
1651            .and_then(|c| c.api_key_env.as_deref())
1652            .unwrap_or(profile.api_key_env);
1653        if resolve_api_key(env, None).is_some() {
1654            any = true;
1655            println!("  - {} (via ${})", profile.name, env);
1656        }
1657    }
1658    if !any {
1659        println!("  (none — set a provider API key env var to enable)");
1660    }
1661    println!("\nSwitch models in-session with /model <name>.");
1662    Ok(())
1663}
1664
1665/// Ask the local Ollama daemon for its list of models. `None` when the
1666/// server couldn't be reached (distinct from `Some(vec![])` — running but
1667/// nothing pulled) so the diagnostic verbs can report a dead server
1668/// truthfully. `autostart` decides whether a dead *local* server may be
1669/// revived: user-intent verbs (`mermaid models` / `list`) pass `true`;
1670/// diagnostics (`status` / `doctor`) pass `false` so observing state never
1671/// mutates it ([ollama] auto_start still gates the `true` case).
1672async fn list_ollama_models(config: &Config, autostart: bool) -> Option<Vec<String>> {
1673    use crate::models::adapters::ollama::OllamaAdapter;
1674    let backend = BackendConfig {
1675        ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
1676        timeout_secs: 5,
1677        max_idle_per_host: 2,
1678        ollama_autostart: autostart && config.ollama.auto_start,
1679    };
1680    match OllamaAdapter::new("__list__", Arc::new(backend)).await {
1681        Ok(adapter) => adapter.list_models().await.ok(),
1682        Err(_) => None,
1683    }
1684}
1685
1686/// Show version information
1687pub fn show_version() {
1688    println!("Mermaid v{}", env!("CARGO_PKG_VERSION"));
1689    println!("   An open-source, model-agnostic AI pair programmer");
1690}
1691
1692const RELEASE_LATEST_API: &str =
1693    "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
1694const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
1695const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
1696
1697/// `mermaid update` — check GitHub Releases for a newer version and, unless
1698/// `--check`, re-run the platform install script to replace this binary in
1699/// place. The install script is the single source of truth for the
1700/// download + checksum + replace (incl. the running-exe rename on Windows), so
1701/// there's no archive-handling logic (or extra dependency) here.
1702async fn run_update(check: bool, force: bool) -> Result<()> {
1703    let current = env!("CARGO_PKG_VERSION");
1704    println!("Installed: v{current}");
1705
1706    let client = reqwest::Client::builder()
1707        .timeout(std::time::Duration::from_secs(15))
1708        .build()?;
1709    let resp = client
1710        .get(RELEASE_LATEST_API)
1711        .header("User-Agent", "mermaid-cli")
1712        .header("Accept", "application/vnd.github+json")
1713        .send()
1714        .await
1715        .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
1716    if !resp.status().is_success() {
1717        bail!("GitHub Releases API returned HTTP {}", resp.status());
1718    }
1719    let release: serde_json::Value = resp.json().await?;
1720    let tag = release
1721        .get("tag_name")
1722        .and_then(|v| v.as_str())
1723        .ok_or_else(|| anyhow!("release response had no tag_name"))?;
1724    println!("Latest:    {tag}");
1725
1726    let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
1727    if check {
1728        if up_to_date {
1729            println!("You're on the latest version.");
1730        } else {
1731            println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
1732        }
1733        return Ok(());
1734    }
1735    if up_to_date && !force {
1736        println!("Already up to date.");
1737        return Ok(());
1738    }
1739
1740    // Replace the binary in the directory it's running from, in place.
1741    let exe =
1742        std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
1743    let install_dir = exe
1744        .parent()
1745        .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
1746
1747    // Confirm before fetching + running the install script — it executes
1748    // downloaded shell/PowerShell and replaces the running binary. `--force` is
1749    // the scripted-use bypass; a non-interactive session without it refuses
1750    // rather than running fetched code unprompted (#110).
1751    let script_url = if cfg!(target_os = "windows") {
1752        INSTALL_PS1_URL
1753    } else {
1754        INSTALL_SH_URL
1755    };
1756    if !crate::utils::confirm_or_refuse(
1757        &format!(
1758            "About to download and run {script_url} to replace {}.",
1759            install_dir.display()
1760        ),
1761        force,
1762    )? {
1763        println!("Update cancelled.");
1764        return Ok(());
1765    }
1766
1767    println!("Updating {} …", install_dir.display());
1768    run_install_script(&client, install_dir).await?;
1769    println!("Updated. New version takes effect on the next run.");
1770    Ok(())
1771}
1772
1773/// Fetch the platform install script from the Pages site and run it, pointed at
1774/// `install_dir` so it updates in place without touching PATH.
1775async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
1776    let windows = cfg!(target_os = "windows");
1777    let url = if windows {
1778        INSTALL_PS1_URL
1779    } else {
1780        INSTALL_SH_URL
1781    };
1782    let script = client
1783        .get(url)
1784        .header("User-Agent", "mermaid-cli")
1785        .send()
1786        .await
1787        .map_err(|e| anyhow!("could not fetch install script: {e}"))?
1788        .error_for_status()?
1789        .text()
1790        .await?;
1791
1792    let ext = if windows { "ps1" } else { "sh" };
1793    // Stage the fetched script in the per-user 0700 private temp dir, created
1794    // exclusively (O_EXCL → never follows/opens a pre-planted symlink) so a local
1795    // attacker can neither redirect the write nor swap the file between write and
1796    // exec (#F50). The previous world-readable, predictable
1797    // `temp_dir()/mermaid-update-<pid>.<ext>` allowed both a symlink redirect and
1798    // a write→exec TOCTOU.
1799    let dir = crate::utils::private_temp_dir()
1800        .map_err(|e| anyhow!("could not create private temp dir for install script: {e}"))?;
1801    let nanos = std::time::SystemTime::now()
1802        .duration_since(std::time::UNIX_EPOCH)
1803        .map(|d| d.as_nanos())
1804        .unwrap_or_default();
1805    let script_path = dir.join(format!(
1806        "mermaid-update-{}-{nanos}.{ext}",
1807        std::process::id()
1808    ));
1809    stage_install_script(&script_path, script.as_bytes())
1810        .map_err(|e| anyhow!("could not stage install script: {e}"))?;
1811
1812    let mut cmd = if windows {
1813        let mut c = tokio::process::Command::new("powershell");
1814        c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
1815        c.arg(&script_path);
1816        c
1817    } else {
1818        let mut c = tokio::process::Command::new("sh");
1819        c.arg(&script_path);
1820        c
1821    };
1822    cmd.env("MERMAID_INSTALL_DIR", install_dir)
1823        .env("MERMAID_NO_MODIFY_PATH", "1");
1824
1825    let status = cmd
1826        .status()
1827        .await
1828        .map_err(|e| anyhow!("could not run install script: {e}"))?;
1829    let _ = std::fs::remove_file(&script_path);
1830    if !status.success() {
1831        bail!("install script exited with {:?}", status.code());
1832    }
1833    Ok(())
1834}
1835
1836/// Write the fetched install script to `path`, creating it **exclusively** so a
1837/// symlink pre-planted at the path is refused (`O_EXCL` never follows) and the
1838/// staged code is owner-only (`0600` file inside the `0700` private dir). This
1839/// closes the symlink-redirect and write→exec TOCTOU that the old predictable,
1840/// world-readable temp path left open (#F50).
1841fn stage_install_script(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1842    use std::io::Write;
1843    #[cfg(unix)]
1844    let mut file = {
1845        use std::os::unix::fs::OpenOptionsExt;
1846        std::fs::OpenOptions::new()
1847            .write(true)
1848            .create_new(true)
1849            .mode(0o600)
1850            .open(path)?
1851    };
1852    #[cfg(not(unix))]
1853    let mut file = std::fs::OpenOptions::new()
1854        .write(true)
1855        .create_new(true)
1856        .open(path)?;
1857    file.write_all(bytes)
1858}
1859
1860/// Parse a `[v]MAJOR.MINOR.PATCH[-pre][+build]` string into a comparable tuple.
1861fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
1862    let core = s.trim().trim_start_matches('v');
1863    let core = core.split(['-', '+']).next().unwrap_or(core);
1864    let mut parts = core.split('.');
1865    let major = parts.next()?.parse().ok()?;
1866    let minor = parts.next().unwrap_or("0").parse().ok()?;
1867    let patch = parts.next().unwrap_or("0").parse().ok()?;
1868    Some((major, minor, patch))
1869}
1870
1871/// True iff `current` is at least `latest` (no update needed). Unparseable
1872/// versions fall back to string equality, so we never falsely report
1873/// up-to-date on garbage — at worst we re-run the (idempotent) installer.
1874fn version_at_least(current: &str, latest: &str) -> bool {
1875    match (parse_semver(current), parse_semver(latest)) {
1876        (Some(c), Some(l)) => c >= l,
1877        _ => current == latest,
1878    }
1879}
1880
1881/// Show configured MCP servers
1882fn show_mcp_servers() {
1883    let config = load_config_or_warn();
1884
1885    if config.mcp_servers.is_empty() {
1886        println!("No MCP servers configured.\n");
1887        println!("Add one with: mermaid add <name>");
1888        println!("Examples:");
1889        println!("  mermaid add context7     # Library documentation");
1890        println!("  mermaid add playwright   # Browser automation");
1891        println!("  mermaid add memory       # Persistent knowledge graph");
1892        return;
1893    }
1894
1895    println!("Configured MCP servers:\n");
1896    for (name, server_cfg) in &config.mcp_servers {
1897        let package = server_cfg
1898            .args
1899            .iter()
1900            .find(|a| !a.starts_with('-'))
1901            .unwrap_or(&server_cfg.command);
1902        let env_keys: Vec<&String> = server_cfg.env.keys().collect();
1903        let env_display = if env_keys.is_empty() {
1904            String::new()
1905        } else {
1906            format!(
1907                " (env: {})",
1908                env_keys
1909                    .iter()
1910                    .map(|k| k.as_str())
1911                    .collect::<Vec<_>>()
1912                    .join(", ")
1913            )
1914        };
1915        println!("  {} — {}{}", name, package, env_display);
1916    }
1917    println!("\nManage with: mermaid add <name> / mermaid remove <name>");
1918}
1919
1920/// Show status of all dependencies
1921async fn show_status(config: &Config) -> Result<()> {
1922    println!("Mermaid Status:");
1923    println!();
1924
1925    // Check remote providers by API-key env presence (matches the
1926    // routing ProviderFactory uses when the user picks a model).
1927    let mut available: Vec<&'static str> = Vec::new();
1928    for profile in PROVIDER_REGISTRY {
1929        let env = config
1930            .providers
1931            .get(profile.name)
1932            .and_then(|c| c.api_key_env.as_deref())
1933            .unwrap_or(profile.api_key_env);
1934        if resolve_api_key(env, None).is_some() {
1935            available.push(profile.name);
1936        }
1937    }
1938    if available.is_empty() {
1939        println!("  [WARNING] Remote providers: none (no API keys in env)");
1940    } else {
1941        println!("  [OK] Remote providers: {}", available.join(", "));
1942    }
1943
1944    // Check Ollama (via HTTP, so remote deployments are honored).
1945    // Diagnostics observe, they don't heal: autostart=false, otherwise a
1946    // status check would start the server and then report "Running" —
1947    // never able to observe the dead state it exists to surface.
1948    if is_ollama_installed() {
1949        match list_ollama_models(config, false).await {
1950            None => println!(
1951                "  [WARNING] Ollama: Installed but not running (started automatically \
1952                 when an Ollama model is used)"
1953            ),
1954            Some(models) if models.is_empty() => {
1955                println!("  [WARNING] Ollama: Running (no models installed)");
1956            },
1957            Some(models) => {
1958                println!("  [OK] Ollama: Running ({} models installed)", models.len());
1959                for model in models.iter().take(3) {
1960                    println!("      - {}", model);
1961                }
1962                if models.len() > 3 {
1963                    println!("      ... and {} more", models.len() - 3);
1964                }
1965            },
1966        }
1967    } else {
1968        println!("  [ERROR] Ollama: Not installed");
1969    }
1970
1971    // Check configuration (uses platform-specific path via ProjectDirs)
1972    if let Ok(config_dir) = get_config_dir() {
1973        let config_path = config_dir.join("config.toml");
1974        if config_path.exists() {
1975            println!("  [OK] Configuration: {}", config_path.display());
1976        } else {
1977            println!("  [WARNING] Configuration: Not found (using defaults)");
1978        }
1979    }
1980
1981    // MCP Servers
1982    if config.mcp_servers.is_empty() {
1983        println!("  [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
1984    } else {
1985        println!(
1986            "  [OK] MCP Servers: {} configured",
1987            config.mcp_servers.len()
1988        );
1989        for (name, server_cfg) in &config.mcp_servers {
1990            println!(
1991                "      - {} ({})",
1992                name,
1993                server_cfg.args.get(1).unwrap_or(&server_cfg.command)
1994            );
1995        }
1996    }
1997
1998    // Project instructions (Step 5h). Walks UP from cwd to git root or
1999    // $HOME to find the nearest supported instruction files.
2000    {
2001        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2002        let paths = crate::app::instructions::find_instruction_files(&cwd);
2003        if paths.is_empty() {
2004            println!("  [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
2005        } else {
2006            match crate::app::instructions::load_from_paths(&paths) {
2007                Some(loaded) => {
2008                    let files = loaded
2009                        .sources
2010                        .iter()
2011                        .map(|source| {
2012                            source
2013                                .path
2014                                .file_name()
2015                                .and_then(|name| name.to_str())
2016                                .unwrap_or("instructions")
2017                        })
2018                        .collect::<Vec<_>>()
2019                        .join(", ");
2020                    println!(
2021                        "  [OK] Project instructions: {} at {} ({} bytes{})",
2022                        files,
2023                        loaded.path.display(),
2024                        loaded.byte_len,
2025                        if loaded.truncated { ", truncated" } else { "" }
2026                    );
2027                },
2028                None => {
2029                    println!(
2030                        "  [WARNING] Project instructions: found but unreadable ({})",
2031                        paths
2032                            .iter()
2033                            .map(|path| path.display().to_string())
2034                            .collect::<Vec<_>>()
2035                            .join(", ")
2036                    );
2037                },
2038            }
2039        }
2040    }
2041
2042    // OpenAI-compatible providers — list anything from the built-in
2043    // registry whose API key resolves, plus any user-defined custom
2044    // providers. No network probe (would slow `mermaid status`).
2045    show_provider_status(config);
2046
2047    // Environment variables (for API providers)
2048    println!("\n  Environment:");
2049    if std::env::var("OLLAMA_API_KEY").is_ok() {
2050        println!("    - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2051    }
2052
2053    println!();
2054    Ok(())
2055}
2056
2057/// Print the remote-providers status block. Includes Anthropic (bespoke
2058/// Messages API) and any OpenAI-compatible provider whose API key resolves.
2059/// Custom providers from `[providers.<name>]` are listed if `base_url`
2060/// and `api_key_env` are both set and the env var resolves.
2061fn show_provider_status(config: &Config) {
2062    let mut configured: Vec<(String, String)> = Vec::new(); // (name, base_url)
2063
2064    // Anthropic — checked first because it's not in the OpenAI-compat
2065    // registry but is a top-tier provider users care about.
2066    let anth_cfg = config.providers.get("anthropic");
2067    if resolve_api_key(
2068        "ANTHROPIC_API_KEY",
2069        anth_cfg.and_then(|c| c.api_key_env.as_deref()),
2070    )
2071    .is_some()
2072    {
2073        let url = anth_cfg
2074            .and_then(|c| c.base_url.clone())
2075            .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
2076        configured.push(("anthropic".to_string(), url));
2077    }
2078
2079    // Gemini — also bespoke (not in OpenAI-compat registry).
2080    let gem_cfg = config.providers.get("gemini");
2081    if resolve_api_key_with_fallback(
2082        "GOOGLE_API_KEY",
2083        "GEMINI_API_KEY",
2084        gem_cfg.and_then(|c| c.api_key_env.as_deref()),
2085    )
2086    .is_some()
2087    {
2088        let url = gem_cfg
2089            .and_then(|c| c.base_url.clone())
2090            .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
2091        configured.push(("gemini".to_string(), url));
2092    }
2093
2094    for profile in PROVIDER_REGISTRY {
2095        let user_cfg = config.providers.get(profile.name);
2096        let api_key_present = resolve_api_key(
2097            profile.api_key_env,
2098            user_cfg.and_then(|c| c.api_key_env.as_deref()),
2099        )
2100        .is_some();
2101        if api_key_present {
2102            let url = user_cfg
2103                .and_then(|c| c.base_url.clone())
2104                .unwrap_or_else(|| profile.base_url.to_string());
2105            configured.push((profile.name.to_string(), url));
2106        }
2107    }
2108
2109    // Custom providers — anything in config.providers not in registry
2110    // and not "anthropic" / "gemini" (already handled above).
2111    for (name, cfg) in &config.providers {
2112        if name == "anthropic" || name == "gemini" || lookup_provider(name).is_some() {
2113            continue;
2114        }
2115        if let (Some(url), Some(env)) = (&cfg.base_url, cfg.api_key_env.as_deref())
2116            && resolve_api_key(env, None).is_some()
2117        {
2118            configured.push((name.clone(), url.clone()));
2119        }
2120    }
2121
2122    if configured.is_empty() {
2123        println!(
2124            "  [INFO] Remote providers: None configured (set $ANTHROPIC_API_KEY, \
2125             $GOOGLE_API_KEY, $OPENAI_API_KEY, $GROQ_API_KEY, $OPENROUTER_API_KEY, etc., or \
2126             add [providers.<name>] to config.toml)"
2127        );
2128    } else {
2129        println!("  [OK] Remote providers: {} configured", configured.len());
2130        for (name, url) in configured {
2131            println!("      - {} ({})", name, url);
2132        }
2133    }
2134}
2135
2136/// Dispatch `mermaid pr` subcommands.
2137fn handle_pr(command: &PrCommand) -> Result<()> {
2138    match command {
2139        PrCommand::Create {
2140            title,
2141            body,
2142            summary,
2143            base,
2144            draft,
2145            web,
2146            provider,
2147        } => create_pr(CreatePrArgs {
2148            title: title.as_deref(),
2149            body: body.as_deref(),
2150            summary: summary.as_deref(),
2151            base: base.as_deref(),
2152            draft: *draft,
2153            web: *web,
2154            provider: *provider,
2155        }),
2156    }
2157}
2158
2159struct CreatePrArgs<'a> {
2160    title: Option<&'a str>,
2161    body: Option<&'a str>,
2162    summary: Option<&'a Path>,
2163    base: Option<&'a str>,
2164    draft: bool,
2165    web: bool,
2166    provider: Option<GitHost>,
2167}
2168
2169/// Create a PR/MR by driving the host's official CLI (`gh`/`glab`), reusing
2170/// its authentication. We wrap the platform CLI rather than reimplementing
2171/// per-provider REST clients (issue #2): it reuses existing `gh auth` /
2172/// `glab auth`, handles each host's quirks, and keeps the surface tiny.
2173fn create_pr(args: CreatePrArgs) -> Result<()> {
2174    // Body precedence: --summary <file> wins over inline --body.
2175    let body = match args.summary {
2176        Some(path) => Some(
2177            std::fs::read_to_string(path)
2178                .with_context(|| format!("failed to read summary file {}", path.display()))?,
2179        ),
2180        None => args.body.map(str::to_string),
2181    };
2182
2183    let host = match args.provider {
2184        Some(host) => host,
2185        None => detect_git_host()?,
2186    };
2187
2188    let (cli, install_hint) = match host {
2189        GitHost::Github => (
2190            "gh",
2191            "Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.",
2192        ),
2193        GitHost::Gitlab => (
2194            "glab",
2195            "Install the GitLab CLI (https://gitlab.com/gitlab-org/cli) and run `glab auth login`.",
2196        ),
2197    };
2198    if which::which(cli).is_err() {
2199        anyhow::bail!("`{cli}` was not found on your PATH. {install_hint}");
2200    }
2201
2202    let argv = build_pr_argv(
2203        host,
2204        args.title,
2205        body.as_deref(),
2206        args.base,
2207        args.draft,
2208        args.web,
2209    );
2210
2211    println!("Creating pull/merge request via `{cli}`…");
2212    let status = std::process::Command::new(cli)
2213        .args(&argv)
2214        .status()
2215        .with_context(|| format!("failed to run `{cli}`"))?;
2216    anyhow::ensure!(status.success(), "`{cli}` exited unsuccessfully ({status})");
2217    Ok(())
2218}
2219
2220/// Auto-detect the host: prefer the `origin` remote URL, else fall back to
2221/// whichever provider CLI is installed.
2222fn detect_git_host() -> Result<GitHost> {
2223    if let Some(host) = git_origin_host() {
2224        return Ok(host);
2225    }
2226    if which::which("gh").is_ok() {
2227        return Ok(GitHost::Github);
2228    }
2229    if which::which("glab").is_ok() {
2230        return Ok(GitHost::Gitlab);
2231    }
2232    anyhow::bail!(
2233        "could not detect a Git host from the `origin` remote. Pass `--provider github|gitlab` and install the matching CLI (`gh`/`glab`)."
2234    )
2235}
2236
2237fn git_origin_host() -> Option<GitHost> {
2238    let output = std::process::Command::new("git")
2239        .args(["config", "--get", "remote.origin.url"])
2240        .output()
2241        .ok()?;
2242    if !output.status.success() {
2243        return None;
2244    }
2245    host_from_remote_url(String::from_utf8_lossy(&output.stdout).trim())
2246}
2247
2248fn host_from_remote_url(url: &str) -> Option<GitHost> {
2249    let lower = url.to_ascii_lowercase();
2250    if lower.contains("github.com") {
2251        Some(GitHost::Github)
2252    } else if lower.contains("gitlab") {
2253        Some(GitHost::Gitlab)
2254    } else {
2255        None
2256    }
2257}
2258
2259/// Build the argv passed to the host CLI. Pure (no I/O), so it's unit-tested.
2260fn build_pr_argv(
2261    host: GitHost,
2262    title: Option<&str>,
2263    body: Option<&str>,
2264    base: Option<&str>,
2265    draft: bool,
2266    web: bool,
2267) -> Vec<String> {
2268    let s = |v: &str| v.to_string();
2269    let has_content = title.is_some() || body.is_some();
2270    let mut argv = Vec::new();
2271    match host {
2272        GitHost::Github => {
2273            argv.push(s("pr"));
2274            argv.push(s("create"));
2275            if web {
2276                argv.push(s("--web"));
2277            }
2278            if draft {
2279                argv.push(s("--draft"));
2280            }
2281            if let Some(title) = title {
2282                argv.push(s("--title"));
2283                argv.push(s(title));
2284            }
2285            if let Some(body) = body {
2286                argv.push(s("--body"));
2287                argv.push(s(body));
2288            }
2289            // No explicit content (and not the web form) → let gh fill the
2290            // title/body from the branch's commits rather than blocking on an
2291            // interactive prompt.
2292            if !has_content && !web {
2293                argv.push(s("--fill"));
2294            }
2295            if let Some(base) = base {
2296                argv.push(s("--base"));
2297                argv.push(s(base));
2298            }
2299        },
2300        GitHost::Gitlab => {
2301            argv.push(s("mr"));
2302            argv.push(s("create"));
2303            if web {
2304                argv.push(s("--web"));
2305            }
2306            if draft {
2307                argv.push(s("--draft"));
2308            }
2309            if let Some(title) = title {
2310                argv.push(s("--title"));
2311                argv.push(s(title));
2312            }
2313            if let Some(body) = body {
2314                argv.push(s("--description"));
2315                argv.push(s(body));
2316            }
2317            if !has_content && !web {
2318                argv.push(s("--fill"));
2319            }
2320            if let Some(base) = base {
2321                argv.push(s("--target-branch"));
2322                argv.push(s(base));
2323            }
2324        },
2325    }
2326    argv
2327}
2328
2329#[cfg(test)]
2330mod tests {
2331    use super::*;
2332
2333    #[test]
2334    fn sanitize_terminal_text_strips_control_sequences() {
2335        // Plain text and the allowed whitespace pass through unchanged.
2336        assert_eq!(
2337            sanitize_terminal_text("hello\tworld\nline two"),
2338            "hello\tworld\nline two"
2339        );
2340        // CSI color sequence is removed, surrounding text kept.
2341        assert_eq!(
2342            sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
2343            "RED text"
2344        );
2345        // OSC-52 clipboard write (BEL-terminated) is removed whole.
2346        assert_eq!(
2347            sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
2348            "beforeafter"
2349        );
2350        // OSC window-title rewrite terminated by ST (ESC '\').
2351        assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
2352        // Charset-designation (ESC '(' 'B') drops its final byte too.
2353        assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
2354        // Bare CR and a C1 control are dropped; \n is preserved.
2355        assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
2356    }
2357
2358    #[test]
2359    fn version_compare_handles_update_logic() {
2360        // Up to date / newer than latest ⇒ no update.
2361        assert!(version_at_least("0.10.2", "0.10.2"));
2362        assert!(version_at_least("0.11.0", "0.10.2"));
2363        assert!(version_at_least("1.0.0", "0.99.99"));
2364        // Older ⇒ update available.
2365        assert!(!version_at_least("0.10.1", "0.10.2"));
2366        assert!(!version_at_least("0.9.0", "0.10.0"));
2367        assert!(!version_at_least("0.10.2", "0.11.0"));
2368        // Pre-release/build suffixes and `v` prefixes are tolerated.
2369        assert!(version_at_least("0.10.2", "v0.10.2"));
2370        assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2371        assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2372        // Garbage never falsely reports up-to-date unless identical.
2373        assert!(!version_at_least("0.10.2", "not-a-version"));
2374    }
2375
2376    #[test]
2377    fn host_from_remote_url_detects_provider() {
2378        assert_eq!(
2379            host_from_remote_url("https://github.com/foo/bar.git"),
2380            Some(GitHost::Github)
2381        );
2382        assert_eq!(
2383            host_from_remote_url("git@github.com:foo/bar.git"),
2384            Some(GitHost::Github)
2385        );
2386        assert_eq!(
2387            host_from_remote_url("https://gitlab.com/foo/bar.git"),
2388            Some(GitHost::Gitlab)
2389        );
2390        assert_eq!(
2391            host_from_remote_url("git@gitlab.example.com:foo/bar.git"),
2392            Some(GitHost::Gitlab)
2393        );
2394        assert_eq!(host_from_remote_url("https://bitbucket.org/foo/bar"), None);
2395    }
2396
2397    #[test]
2398    fn build_pr_argv_github_with_content() {
2399        let argv = build_pr_argv(
2400            GitHost::Github,
2401            Some("T"),
2402            Some("B"),
2403            Some("main"),
2404            true,
2405            false,
2406        );
2407        assert_eq!(
2408            argv,
2409            vec![
2410                "pr", "create", "--draft", "--title", "T", "--body", "B", "--base", "main"
2411            ]
2412        );
2413    }
2414
2415    #[test]
2416    fn build_pr_argv_github_fills_without_content() {
2417        let argv = build_pr_argv(GitHost::Github, None, None, None, false, false);
2418        assert!(argv.contains(&"--fill".to_string()));
2419        assert!(!argv.contains(&"--title".to_string()));
2420    }
2421
2422    #[test]
2423    fn build_pr_argv_web_skips_fill() {
2424        let argv = build_pr_argv(GitHost::Github, None, None, None, false, true);
2425        assert!(argv.contains(&"--web".to_string()));
2426        assert!(!argv.contains(&"--fill".to_string()));
2427    }
2428
2429    #[test]
2430    fn build_pr_argv_gitlab_uses_mr_and_target_branch() {
2431        let argv = build_pr_argv(GitHost::Gitlab, Some("T"), None, Some("main"), false, false);
2432        assert_eq!(&argv[0..2], &["mr", "create"]);
2433        assert!(argv.windows(2).any(|w| w == ["--target-branch", "main"]));
2434        assert!(argv.contains(&"--title".to_string()));
2435    }
2436
2437    #[test]
2438    fn qa_compact_smoke_persists_conversation_and_archive() {
2439        let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2440        std::fs::create_dir_all(&dir).unwrap();
2441
2442        let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2443
2444        assert!(report.ok);
2445        assert!(report.archived_messages > 0);
2446        assert!(report.preserved_messages > 0);
2447        assert!(report.replacement_messages >= 3);
2448        assert!(
2449            std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2450            "conversation path should exist"
2451        );
2452        assert!(
2453            std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2454            "archive path should exist"
2455        );
2456
2457        let _ = std::fs::remove_dir_all(dir);
2458    }
2459
2460    #[test]
2461    fn qa_model_id_falls_back_to_deterministic() {
2462        assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2463    }
2464
2465    fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2466        let nanos = std::time::SystemTime::now()
2467            .duration_since(std::time::UNIX_EPOCH)
2468            .map(|duration| duration.as_nanos())
2469            .unwrap_or_default();
2470        std::env::temp_dir().join(format!("{name}-{nanos}"))
2471    }
2472}