Skip to main content

mermaid_cli/cli/
commands.rs

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