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