Skip to main content

mermaid_cli/cli/
commands.rs

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