Skip to main content

mermaid_cli/cli/
commands.rs

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