Skip to main content

mermaid_cli/cli/
commands.rs

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