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