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            native_tools: mermaid_model::models::NativeTools::default(),
1564        };
1565        let sizing = live.resolve_context_window(&probe_request).await;
1566        if let Some(window) = sizing.model_max.or(sizing.effective) {
1567            context_tokens = Some(window);
1568            context_confidence = "probed";
1569        }
1570        if let Some(output) = sizing.max_output {
1571            output_tokens = Some(output);
1572            output_confidence = "probed";
1573        }
1574    }
1575
1576    for (key, value) in [
1577        ("supports_tools", snapshot.supports_tools.to_string()),
1578        ("supports_vision", snapshot.supports_vision.to_string()),
1579        ("reasoning", snapshot.reasoning.clone()),
1580    ] {
1581        let _ = store.provider_probes().upsert(NewProviderProbe {
1582            provider: provider.clone(),
1583            model_id: snapshot.model.clone(),
1584            capability_key: key.to_string(),
1585            capability_value: value,
1586            confidence: "static".to_string(),
1587            error: None,
1588        });
1589    }
1590    // Context window separately — probed (Ollama) or static.
1591    let _ = store.provider_probes().upsert(NewProviderProbe {
1592        provider: provider.clone(),
1593        model_id: snapshot.model.clone(),
1594        capability_key: "max_context_tokens".to_string(),
1595        capability_value: context_tokens
1596            .map(|n| n.to_string())
1597            .unwrap_or_else(|| "unknown".to_string()),
1598        confidence: context_confidence.to_string(),
1599        error: None,
1600    });
1601    println!("Model: {model}");
1602    println!("Provider: {}", snapshot.provider);
1603    println!("Name: {}", snapshot.model);
1604    println!("Supports tools: {}", snapshot.supports_tools);
1605    println!("Supports vision: {}", snapshot.supports_vision);
1606    println!("Reasoning: {}", snapshot.reasoning);
1607    println!(
1608        "Context: {}",
1609        context_tokens
1610            .map(|n| format!("{n} ({context_confidence})"))
1611            .unwrap_or_else(|| "unknown".to_string())
1612    );
1613    println!(
1614        "Output limit: {}",
1615        output_tokens
1616            .map(|n| format!("{n} ({output_confidence})"))
1617            .unwrap_or_else(|| {
1618                "unknown (discovered live from the provider's models endpoint when exposed)"
1619                    .to_string()
1620            })
1621    );
1622    if let Some(profile) = lookup_provider(&snapshot.provider) {
1623        record_static_provider_probes(&store, profile, &provider, &snapshot.model);
1624        println!("Token budget field: {:?}", profile.max_tokens_param);
1625        println!(
1626            "Single-tool-call models: {}",
1627            if profile.disable_parallel_tool_calls_for.is_empty() {
1628                "(none)".to_string()
1629            } else {
1630                profile.disable_parallel_tool_calls_for.join(", ")
1631            }
1632        );
1633    }
1634    Ok(())
1635}
1636
1637async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1638    let client = reqwest::Client::builder()
1639        .timeout(std::time::Duration::from_secs(5))
1640        .build()?;
1641    for profile in PROVIDER_REGISTRY {
1642        let user_cfg = config.providers.get(profile.name);
1643        let Some(api_key) = mermaid_model::utils::resolve_provider_key(
1644            profile.name,
1645            profile.api_key_env,
1646            user_cfg.and_then(|c| c.api_key_env.as_deref()),
1647        ) else {
1648            continue;
1649        };
1650        let Some(base_url) = crate::providers::factory::discovery_base_url(
1651            profile,
1652            user_cfg.and_then(|c| c.base_url.clone()),
1653        ) else {
1654            // cloudflare with a token but no CLOUDFLARE_ACCOUNT_ID: there is no
1655            // real endpoint to probe — record the misconfiguration instead of a
1656            // guaranteed 404 against the registry placeholder.
1657            record_provider_probe(
1658                profile.name,
1659                "*",
1660                "models_availability",
1661                "failed",
1662                "failed",
1663                Some("CLOUDFLARE_ACCOUNT_ID not set".to_string()),
1664            );
1665            continue;
1666        };
1667        let url = format!("{}/models", base_url.trim_end_matches('/'));
1668        let mut request = client.get(&url).bearer_auth(api_key);
1669        for (name, value) in profile.extra_headers {
1670            request = request.header(*name, *value);
1671        }
1672        if let Some(user_cfg) = user_cfg {
1673            for (name, value) in &user_cfg.extra_headers {
1674                request = request.header(name, value);
1675            }
1676        }
1677
1678        let result = request.send().await;
1679        match result {
1680            Ok(response) if response.status().is_success() => {
1681                let status = response.status();
1682                let body: serde_json::Value = response.json().await.unwrap_or_default();
1683                let ids = body
1684                    .get("data")
1685                    .and_then(|v| v.as_array())
1686                    .map(|items| {
1687                        items
1688                            .iter()
1689                            .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1690                            .map(str::to_string)
1691                            .collect::<Vec<_>>()
1692                    })
1693                    .unwrap_or_default();
1694                record_provider_probe(
1695                    profile.name,
1696                    "*",
1697                    "models_availability",
1698                    &format!("available:{}:{}", status.as_u16(), ids.len()),
1699                    "probed",
1700                    None,
1701                );
1702                for model_id in ids.into_iter().take(200) {
1703                    record_provider_probe(
1704                        profile.name,
1705                        &model_id,
1706                        "model_listed",
1707                        "true",
1708                        "listed",
1709                        None,
1710                    );
1711                }
1712            },
1713            Ok(response) => {
1714                record_provider_probe(
1715                    profile.name,
1716                    "*",
1717                    "models_availability",
1718                    "failed",
1719                    "failed",
1720                    Some(format!("HTTP {}", response.status().as_u16())),
1721                );
1722            },
1723            Err(error) => {
1724                record_provider_probe(
1725                    profile.name,
1726                    "*",
1727                    "models_availability",
1728                    "failed",
1729                    "failed",
1730                    Some(error.to_string()),
1731                );
1732            },
1733        }
1734    }
1735    probe_meta_models(&client, config).await;
1736    Ok(())
1737}
1738
1739async fn probe_meta_models(client: &reqwest::Client, config: &Config) {
1740    let Some(api_key) = meta_api_key(config) else {
1741        return;
1742    };
1743    let url = format!("{}/models", meta_base_url(config).trim_end_matches('/'));
1744    let mut request = client.get(&url).bearer_auth(api_key);
1745    if let Some(provider) = config.providers.get("meta") {
1746        for (name, value) in &provider.extra_headers {
1747            request = request.header(name, value);
1748        }
1749        for (name, env_var) in &provider.env_headers {
1750            if let Ok(value) = std::env::var(env_var) {
1751                request = request.header(name, value);
1752            }
1753        }
1754    }
1755    match request.send().await {
1756        Ok(response) if response.status().is_success() => {
1757            let status = response.status();
1758            let body: serde_json::Value = response.json().await.unwrap_or_default();
1759            let ids = body
1760                .get("data")
1761                .and_then(serde_json::Value::as_array)
1762                .into_iter()
1763                .flatten()
1764                .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1765                .map(str::to_string)
1766                .collect::<Vec<_>>();
1767            record_provider_probe(
1768                "meta",
1769                "*",
1770                "models_availability",
1771                &format!("available:{}:{}", status.as_u16(), ids.len()),
1772                "probed",
1773                None,
1774            );
1775            for model_id in ids.into_iter().take(200) {
1776                record_provider_probe("meta", &model_id, "model_listed", "true", "listed", None);
1777            }
1778        },
1779        Ok(response) => record_provider_probe(
1780            "meta",
1781            "*",
1782            "models_availability",
1783            "failed",
1784            "failed",
1785            Some(format!("HTTP {}", response.status().as_u16())),
1786        ),
1787        Err(error) => record_provider_probe(
1788            "meta",
1789            "*",
1790            "models_availability",
1791            "failed",
1792            "failed",
1793            Some(error.to_string()),
1794        ),
1795    }
1796}
1797
1798fn record_provider_probe(
1799    provider: &str,
1800    model_id: &str,
1801    key: &str,
1802    value: &str,
1803    confidence: &str,
1804    error: Option<String>,
1805) {
1806    if let Ok(store) = RuntimeStore::open_default() {
1807        let _ = store.provider_probes().upsert(NewProviderProbe {
1808            provider: provider.to_string(),
1809            model_id: model_id.to_string(),
1810            capability_key: key.to_string(),
1811            capability_value: value.to_string(),
1812            confidence: confidence.to_string(),
1813            error,
1814        });
1815    }
1816}
1817
1818fn show_approvals() -> Result<()> {
1819    let approvals = RuntimeClient::auto().list_approvals()?.value;
1820    if approvals.is_empty() {
1821        println!("No pending approvals.");
1822        return Ok(());
1823    }
1824    for approval in approvals {
1825        println!(
1826            "{} [{} -> {}] {}",
1827            approval.id,
1828            approval.risk_classification,
1829            approval.policy_decision,
1830            approval.proposed_action
1831        );
1832        if let Some(args) = approval.args_summary {
1833            println!("    args: {args}");
1834        }
1835        if let Some(checkpoint_id) = approval.checkpoint_id {
1836            println!("    checkpoint: {checkpoint_id}");
1837        }
1838        if approval.pending_action_json.is_some() {
1839            println!("    pending action: recorded");
1840        }
1841    }
1842    Ok(())
1843}
1844
1845fn approve(id: &str) -> Result<()> {
1846    let result = RuntimeClient::auto().approve(id)?;
1847    println!("Approved {id}");
1848    if result.replayed {
1849        println!("{}", result.summary);
1850    }
1851    Ok(())
1852}
1853
1854fn deny(id: &str) -> Result<()> {
1855    let _ = RuntimeClient::auto().deny(id)?;
1856    println!("Denied {id}");
1857    Ok(())
1858}
1859
1860/// `mermaid task <id> --follow`: attach to the daemon's live `RunEvent`
1861/// stream for a task and print it as NDJSON until the terminal `result`.
1862/// Daemon-only — there is no local fallback (the events only exist while the
1863/// daemon executes the run).
1864fn follow_task(id: &str) -> Result<()> {
1865    let lines = mermaid_runtime::subscribe_daemon_lines(
1866        crate::runtime_client::DaemonRequest::SubscribeTask {
1867            task_id: id.to_string(),
1868        }
1869        .to_wire(),
1870    )
1871    .context("mermaid task --follow needs a running daemon (`mermaid daemon start`)")?;
1872    let mut saw_any = false;
1873    for line in lines {
1874        let line = line?;
1875        if line.trim().is_empty() {
1876            continue;
1877        }
1878        // The ack line carries ok:false on unknown task / auth failure.
1879        if !saw_any {
1880            saw_any = true;
1881            let ack: serde_json::Value =
1882                serde_json::from_str(line.trim()).context("daemon returned invalid JSON")?;
1883            if ack.get("ok").and_then(|v| v.as_bool()) == Some(false) {
1884                anyhow::bail!(
1885                    "{}",
1886                    ack.get("error")
1887                        .and_then(|v| v.as_str())
1888                        .unwrap_or("subscribe failed")
1889                );
1890            }
1891            println!("{}", line.trim());
1892            continue;
1893        }
1894        println!("{}", line.trim());
1895        if serde_json::from_str::<serde_json::Value>(line.trim())
1896            .ok()
1897            .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_string))
1898            .as_deref()
1899            == Some("result")
1900        {
1901            return Ok(());
1902        }
1903    }
1904    if saw_any {
1905        anyhow::bail!("stream ended without a result (daemon restarted mid-run?)");
1906    }
1907    anyhow::bail!("daemon closed the connection without responding");
1908}
1909
1910/// Put a prompt into a task that is already running.
1911///
1912/// Daemon-only by nature: it holds the live mailboxes, and a mailbox is the
1913/// only way into a reducer that is mid-turn. A finished task has no reducer,
1914/// so the error says what to reach for instead.
1915fn send_to_task(id: &str, text: &str) -> Result<()> {
1916    if text.trim().is_empty() {
1917        anyhow::bail!("nothing to send: --send needs a prompt");
1918    }
1919    mermaid_runtime::request_daemon_json(
1920        crate::runtime_client::DaemonRequest::SendToTask {
1921            id: id.to_string(),
1922            text: text.to_string(),
1923        }
1924        .to_wire(),
1925    )?;
1926    println!("Sent to {id}; it answers after the turn it is on.");
1927    Ok(())
1928}
1929
1930/// Cancel a daemon task. Cancelling a *running* task must reach the daemon —
1931/// it holds the in-flight cancellation tokens. A *queued* task can be
1932/// cancelled straight in the local store when no daemon is reachable, since
1933/// queued tasks only ever execute via the daemon's claim query.
1934fn cancel_task(id: &str) -> Result<()> {
1935    match mermaid_runtime::request_daemon_json(
1936        crate::runtime_client::DaemonRequest::CancelTask { id: id.to_string() }.to_wire(),
1937    ) {
1938        Ok(response) => {
1939            if response.get("cancelling").and_then(|v| v.as_bool()) == Some(true) {
1940                println!("Cancelling {id} (running; the agent unwinds gracefully)");
1941            } else {
1942                println!("Cancelled {id}");
1943            }
1944            Ok(())
1945        },
1946        Err(daemon_err) => {
1947            let store = mermaid_runtime::RuntimeStore::open_default()?;
1948            match store.tasks().get(id)? {
1949                Some(task) if task.status == mermaid_runtime::TaskStatus::Queued => {
1950                    store.tasks().update_status(
1951                        id,
1952                        mermaid_runtime::TaskStatus::Cancelled,
1953                        Some("cancelled before start"),
1954                    )?;
1955                    println!("Cancelled {id} (was queued; daemon unreachable)");
1956                    Ok(())
1957                },
1958                Some(task) => anyhow::bail!(
1959                    "task {} is {} and the daemon request failed: {}",
1960                    id,
1961                    task.status,
1962                    daemon_err
1963                ),
1964                None => anyhow::bail!("task not found: {id}"),
1965            }
1966        },
1967    }
1968}
1969
1970fn show_tool_runs(limit: usize) -> Result<()> {
1971    let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1972    runs.truncate(limit);
1973    if runs.is_empty() {
1974        println!("No tool runs recorded yet.");
1975        return Ok(());
1976    }
1977    for run in runs {
1978        println!(
1979            "{} [{}] {} started {}",
1980            run.id, run.status, run.tool_name, run.started_at
1981        );
1982        if let Some(turn_id) = run.turn_id {
1983            println!("    turn: {turn_id}");
1984        }
1985        if let Some(call_id) = run.call_id {
1986            println!("    call: {call_id}");
1987        }
1988        if let Some(finished_at) = run.finished_at {
1989            println!("    finished: {finished_at}");
1990        }
1991    }
1992    Ok(())
1993}
1994
1995fn show_checkpoints(limit: usize) -> Result<()> {
1996    let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1997    checkpoints.truncate(limit);
1998    if checkpoints.is_empty() {
1999        println!("No checkpoints recorded yet.");
2000        return Ok(());
2001    }
2002    for checkpoint in checkpoints {
2003        println!(
2004            "{}  {}  {}",
2005            checkpoint.id, checkpoint.created_at, checkpoint.project_path
2006        );
2007        println!("    snapshot: {}", checkpoint.snapshot_path);
2008        println!("    files: {}", checkpoint.changed_files_json);
2009        if let Some(approval_id) = checkpoint.approval_id {
2010            println!("    approval: {approval_id}");
2011        }
2012    }
2013    Ok(())
2014}
2015
2016fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
2017    // Restoring overwrites the working tree from the checkpoint. Confirm first
2018    // (default NO); `--force` is the scripted-use bypass, and a non-interactive
2019    // session without it refuses rather than clobbering the tree unprompted (#113).
2020    if !mermaid_model::utils::confirm_or_refuse(
2021        &format!("Restore checkpoint {id}? This overwrites the current working tree."),
2022        force,
2023    )? {
2024        println!("Restore cancelled.");
2025        return Ok(());
2026    }
2027    let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
2028    println!("Restored {} ({} files)", manifest.id, manifest.files.len());
2029    if let Some(repo) = manifest.shadow_git_repo {
2030        println!("Shadow repo: {repo}");
2031    }
2032    if let Some(commit) = manifest.shadow_git_commit {
2033        println!("Shadow commit: {commit}");
2034    }
2035    if let Some(action) = manifest.pending_action {
2036        println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
2037    }
2038    Ok(())
2039}
2040
2041fn handle_plugin(command: &PluginCommand) -> Result<()> {
2042    match command {
2043        PluginCommand::Install { path } => {
2044            let preview = mermaid_runtime::plugin_capability_preview(path)?;
2045            print_plugin_capability_preview(&preview);
2046            let record = mermaid_runtime::install_plugin_from_path(path)?;
2047            println!(
2048                "Installed plugin {} ({}) — DISABLED.",
2049                record.name, record.id
2050            );
2051            println!(
2052                "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
2053                record.id
2054            );
2055        },
2056        PluginCommand::List => {
2057            let plugins = RuntimeClient::auto().list_plugins()?.value;
2058            if plugins.is_empty() {
2059                println!("No plugins installed.");
2060            } else {
2061                for plugin in plugins {
2062                    println!(
2063                        "{} [{}] {} ({})",
2064                        plugin.id,
2065                        if plugin.enabled {
2066                            "enabled"
2067                        } else {
2068                            "disabled"
2069                        },
2070                        plugin.name,
2071                        plugin.source
2072                    );
2073                }
2074            }
2075        },
2076        PluginCommand::Enable { id } => {
2077            // Surface what the plugin declares before activating its native code.
2078            let client = RuntimeClient::auto();
2079            if let Some(plugin) = client
2080                .list_plugins()?
2081                .value
2082                .into_iter()
2083                .find(|p| p.id == *id || p.name == *id)
2084                && let Ok(preview) =
2085                    mermaid_runtime::plugin_capability_preview(Path::new(&plugin.source))
2086            {
2087                print_plugin_capability_preview(&preview);
2088            }
2089            client.set_plugin_enabled(id, true)?;
2090            println!("Enabled plugin {id} — its hooks will now run.");
2091        },
2092        PluginCommand::Disable { id } => {
2093            RuntimeClient::auto().set_plugin_enabled(id, false)?;
2094            println!("Disabled plugin {id}");
2095        },
2096        PluginCommand::Audit { path } => {
2097            let manifest_path = if path.is_dir() {
2098                path.join("plugin.toml")
2099            } else {
2100                path.clone()
2101            };
2102            let raw = std::fs::read_to_string(&manifest_path)?;
2103            let manifest: mermaid_runtime::PluginManifest = toml::from_str(&raw)?;
2104            let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
2105            mermaid_runtime::validate_plugin_manifest(&manifest, root)?;
2106            let preview = mermaid_runtime::plugin_capability_preview(path)?;
2107            println!("Plugin manifest is valid: {}", manifest.name);
2108            print_plugin_capability_preview(&preview);
2109        },
2110    }
2111    Ok(())
2112}
2113
2114fn print_plugin_capability_preview(preview: &mermaid_runtime::PluginCapabilityPreview) {
2115    println!(
2116        "ModelCapabilities declared by plugin {} (advisory, not sandbox-enforced):",
2117        preview.name
2118    );
2119    if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
2120        println!("  capabilities: (none declared)");
2121    } else {
2122        if !preview.declared_capabilities.is_empty() {
2123            println!("  declared: {}", preview.declared_capabilities.join(", "));
2124        }
2125        if let Some(value) = &preview.capabilities_toml {
2126            println!(
2127                "  capabilities.toml: {}",
2128                serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
2129            );
2130        }
2131    }
2132    if !preview.hooks.is_empty() {
2133        println!("  hooks: {}", preview.hooks.join(", "));
2134    }
2135    if !preview.mcp.is_empty() {
2136        println!("  mcp: {}", preview.mcp.join(", "));
2137    }
2138    if !preview.bin.is_empty() {
2139        println!("  bin: {}", preview.bin.join(", "));
2140    }
2141}
2142
2143fn handle_pair(command: &PairCommand) -> Result<()> {
2144    let store = RuntimeStore::open_default()?;
2145    match command {
2146        PairCommand::Create { label, ttl_days } => {
2147            let ttl = ttl_days.unwrap_or(mermaid_runtime::DEFAULT_PAIRING_TTL_DAYS);
2148            let expires_at = mermaid_runtime::pairing_expiry_from_now(ttl);
2149            let (token, hash) = mermaid_runtime::generate_pairing_token()?;
2150            let record =
2151                store
2152                    .pairing_tokens()
2153                    .create(&hash, label.as_deref(), expires_at.as_deref())?;
2154            println!("Pairing token id: {}", record.id);
2155            println!("Pairing token: {token}");
2156            println!(
2157                "Expires: {}",
2158                record.expires_at.as_deref().unwrap_or("never")
2159            );
2160            println!(
2161                "Use with daemon JSON by setting {}.",
2162                mermaid_runtime::daemon::DAEMON_TOKEN_ENV
2163            );
2164            println!("Store this now; Mermaid will not print it again.");
2165        },
2166        PairCommand::List => {
2167            let tokens = store.pairing_tokens().list()?;
2168            if tokens.is_empty() {
2169                println!("No pairing tokens.");
2170            } else {
2171                // Never print token_hash — only the non-secret metadata.
2172                for t in tokens {
2173                    println!(
2174                        "{} [{}] label={} created={} expires={} last_used={}",
2175                        t.id,
2176                        if t.enabled { "active" } else { "revoked" },
2177                        t.label.as_deref().unwrap_or("-"),
2178                        t.created_at,
2179                        t.expires_at.as_deref().unwrap_or("never"),
2180                        t.last_used_at.as_deref().unwrap_or("never"),
2181                    );
2182                }
2183            }
2184        },
2185        PairCommand::Revoke { id } => {
2186            if store.pairing_tokens().revoke(id)? {
2187                println!("Revoked pairing token {id}");
2188            } else {
2189                println!("No active pairing token with id {id}");
2190            }
2191        },
2192    }
2193    Ok(())
2194}
2195
2196/// Strip terminal control sequences from untrusted subprocess output before
2197/// printing it to a cooked terminal. Managed-process logs / reports / port
2198/// listings are attacker-influenceable (a dev server can emit anything), so a
2199/// raw `print!` would let escape sequences execute — OSC-52 clipboard writes,
2200/// window-title/prompt rewrites, cursor moves used for spoofing. Keeps `\n` and
2201/// `\t`; drops every ESC-introduced sequence (CSI / OSC / DCS / PM / APC / SOS
2202/// and simple two-/three-byte forms) and all other C0/C1 control characters
2203/// (incl. `\r` and DEL). See F49.
2204fn sanitize_terminal_text(input: &str) -> String {
2205    let mut out = String::with_capacity(input.len());
2206    let mut chars = input.chars();
2207    while let Some(c) = chars.next() {
2208        match c {
2209            '\n' | '\t' => out.push(c),
2210            '\u{1b}' => match chars.next() {
2211                // CSI: ESC '[' params/intermediates then a final byte
2212                // (0x40-0x7e), which is also dropped.
2213                Some('[') => {
2214                    for p in chars.by_ref() {
2215                        if ('@'..='~').contains(&p) {
2216                            break;
2217                        }
2218                    }
2219                },
2220                // String sequences (OSC ']', DCS 'P', PM '^', APC '_', SOS 'X'):
2221                // arbitrary body terminated by BEL or ST (ESC '\').
2222                Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
2223                    while let Some(p) = chars.next() {
2224                        if p == '\u{07}' {
2225                            break;
2226                        }
2227                        if p == '\u{1b}' {
2228                            // ESC here starts ST (ESC '\'); drop the trailing '\'.
2229                            let mut peek = chars.clone();
2230                            if peek.next() == Some('\\') {
2231                                chars = peek;
2232                            }
2233                            break;
2234                        }
2235                    }
2236                },
2237                // Other ESC forms: optional intermediates (0x20-0x2f) then a
2238                // final byte; drop them all.
2239                Some(mut b) => {
2240                    while ('\u{20}'..='\u{2f}').contains(&b) {
2241                        match chars.next() {
2242                            Some(next) => b = next,
2243                            None => break,
2244                        }
2245                    }
2246                },
2247                None => {},
2248            },
2249            // Drop DEL, all other C0 controls (incl. `\r`), and C1 controls.
2250            c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
2251            c => out.push(c),
2252        }
2253    }
2254    out
2255}
2256
2257fn show_logs(id: &str) -> Result<()> {
2258    let content = RuntimeClient::auto().process_log(id, None)?.content;
2259    print!("{}", sanitize_terminal_text(&content));
2260    Ok(())
2261}
2262
2263fn stop_process(id: &str) -> Result<()> {
2264    let process = RuntimeClient::auto().stop_process(id)?.item;
2265    println!("Stopped process {} (pid {})", id, process.pid);
2266    Ok(())
2267}
2268
2269fn restart_process(id: &str) -> Result<()> {
2270    let process = RuntimeClient::auto().restart_process(id)?.item;
2271    println!("Restarted process {} (pid {})", id, process.pid);
2272    Ok(())
2273}
2274
2275fn open_target(target: &str) -> Result<()> {
2276    if RuntimeClient::auto().open_process(target).is_err() {
2277        mermaid_model::utils::open_file(target);
2278    }
2279    Ok(())
2280}
2281
2282fn show_ports() -> Result<()> {
2283    let ports = RuntimeClient::auto().ports()?.ports;
2284    print!("{}", sanitize_terminal_text(&ports));
2285    Ok(())
2286}
2287
2288/// List available models across all backends (honors user config).
2289/// Read-only: a dead local server is reported, never resurrected — a
2290/// cloud-model user who stopped Ollama on purpose must be able to
2291/// enumerate without a surprise VRAM grab. A stopped server does not hide
2292/// the installed set: the on-disk manifest store answers for it
2293/// (`FromDisk`), labeled so the user knows the server will start on use.
2294///
2295/// # Errors
2296///
2297/// Only writing to stdout. Every "nothing to list" case — Ollama not
2298/// installed, installed but stopped with an unreadable store, installed with
2299/// no models, no configured remote providers — is `Ok` and prints what it
2300/// found, because a listing verb that exits nonzero because the answer is
2301/// empty is answering a different question.
2302pub async fn list_models(config: &Config) -> Result<()> {
2303    match observe_models(config).await {
2304        LocalModelListing::Unreachable if is_ollama_installed() => {
2305            println!("Ollama is installed but not running, and its model store could not be read.");
2306            println!("(It starts automatically when you use an Ollama model.)");
2307        },
2308        LocalModelListing::Unreachable => println!("Ollama is not installed; no local models."),
2309        LocalModelListing::Live(models) if models.is_empty() => {
2310            println!("No Ollama models installed locally.");
2311        },
2312        LocalModelListing::Live(models) => {
2313            println!("Ollama models (local/cloud):");
2314            for name in &models {
2315                println!("  - ollama/{name}");
2316            }
2317        },
2318        LocalModelListing::FromDisk(models) => {
2319            println!(
2320                "Ollama models (installed; server not running — starts automatically on use):"
2321            );
2322            for name in &models {
2323                println!("  - ollama/{name}");
2324            }
2325        },
2326    }
2327
2328    println!("\nConfigured remote providers:");
2329    let catalogs = crate::providers::discovery::provider_catalogs(config).await;
2330    if catalogs.is_empty() {
2331        println!("  (none — set a provider API key env var to enable)");
2332    }
2333    for catalog in &catalogs {
2334        println!(
2335            "  - {} ({}) {}",
2336            catalog.provider.name,
2337            catalog.provider.source_label(),
2338            catalog.provider.endpoint
2339        );
2340        match &catalog.models {
2341            // The key resolves but the catalog didn't answer. Say so instead of
2342            // printing an empty list that reads as "this provider has nothing".
2343            None => {
2344                println!("      (model list unavailable — the provider's /models did not answer)")
2345            },
2346            Some(models) if models.is_empty() => println!("      (provider lists no models)"),
2347            Some(models) => {
2348                for id in models {
2349                    println!("      {}/{}", catalog.provider.name, id);
2350                }
2351            },
2352        }
2353    }
2354
2355    // A provider the user started configuring that still cannot be built. It
2356    // belongs on the "what can I use" surface precisely because the answer is
2357    // "not this, and here is the one thing missing".
2358    let problems = crate::providers::provider_problems(config);
2359    if !problems.is_empty() {
2360        println!("\nConfigured but not usable:");
2361        for problem in &problems {
2362            println!("  - {}: {}", problem.name, problem.reason);
2363        }
2364    }
2365
2366    println!("\nSwitch models in-session with /model <name>.");
2367    Ok(())
2368}
2369
2370const RELEASE_LATEST_API: &str =
2371    "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
2372const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
2373const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
2374
2375/// `mermaid update` — check GitHub Releases for a newer version and, unless
2376/// `--check`, re-run the platform install script to replace this binary in
2377/// place. The install script is the single source of truth for the
2378/// download + checksum + replace (incl. the running-exe rename on Windows), so
2379/// there's no archive-handling logic (or extra dependency) here.
2380async fn run_update(check: bool, force: bool) -> Result<()> {
2381    let current = env!("CARGO_PKG_VERSION");
2382    println!("Installed: v{current}");
2383
2384    let client = reqwest::Client::builder()
2385        .timeout(std::time::Duration::from_secs(15))
2386        .build()?;
2387    let resp = client
2388        .get(RELEASE_LATEST_API)
2389        .header("User-Agent", "mermaid-cli")
2390        .header("Accept", "application/vnd.github+json")
2391        .send()
2392        .await
2393        .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
2394    if !resp.status().is_success() {
2395        bail!("GitHub Releases API returned HTTP {}", resp.status());
2396    }
2397    let release: serde_json::Value = resp.json().await?;
2398    let tag = release
2399        .get("tag_name")
2400        .and_then(|v| v.as_str())
2401        .ok_or_else(|| anyhow!("release response had no tag_name"))?;
2402    println!("Latest:    {tag}");
2403
2404    let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
2405    if check {
2406        if up_to_date {
2407            println!("You're on the latest version.");
2408        } else {
2409            println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
2410        }
2411        return Ok(());
2412    }
2413    if up_to_date && !force {
2414        println!("Already up to date.");
2415        return Ok(());
2416    }
2417
2418    // Replace the binary in the directory it's running from, in place.
2419    let exe =
2420        std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
2421    let install_dir = exe
2422        .parent()
2423        .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
2424
2425    // Confirm before fetching + running the install script — it executes
2426    // downloaded shell/PowerShell and replaces the running binary. `--force` is
2427    // the scripted-use bypass; a non-interactive session without it refuses
2428    // rather than running fetched code unprompted (#110).
2429    let script_url = if cfg!(target_os = "windows") {
2430        INSTALL_PS1_URL
2431    } else {
2432        INSTALL_SH_URL
2433    };
2434    if !mermaid_model::utils::confirm_or_refuse(
2435        &format!(
2436            "About to download and run {script_url} to replace {}.",
2437            install_dir.display()
2438        ),
2439        force,
2440    )? {
2441        println!("Update cancelled.");
2442        return Ok(());
2443    }
2444
2445    println!("Updating {} …", install_dir.display());
2446    run_install_script(&client, install_dir).await?;
2447    println!("Updated. New version takes effect on the next run.");
2448    Ok(())
2449}
2450
2451/// Fetch the platform install script from the Pages site and run it, pointed at
2452/// `install_dir` so it updates in place without touching PATH.
2453async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
2454    let windows = cfg!(target_os = "windows");
2455    let url = if windows {
2456        INSTALL_PS1_URL
2457    } else {
2458        INSTALL_SH_URL
2459    };
2460    let script = client
2461        .get(url)
2462        .header("User-Agent", "mermaid-cli")
2463        .send()
2464        .await
2465        .map_err(|e| anyhow!("could not fetch install script: {e}"))?
2466        .error_for_status()?
2467        .text()
2468        .await?;
2469
2470    let ext = if windows { "ps1" } else { "sh" };
2471    // Stage the fetched script in the per-user 0700 private temp dir, created
2472    // exclusively (O_EXCL → never follows/opens a pre-planted symlink) so a local
2473    // attacker can neither redirect the write nor swap the file between write and
2474    // exec (#F50). The previous world-readable, predictable
2475    // `temp_dir()/mermaid-update-<pid>.<ext>` allowed both a symlink redirect and
2476    // a write→exec TOCTOU.
2477    let dir = mermaid_model::utils::private_temp_dir()
2478        .map_err(|e| anyhow!("could not create private temp dir for install script: {e}"))?;
2479    let nanos = std::time::SystemTime::now()
2480        .duration_since(std::time::UNIX_EPOCH)
2481        .map(|d| d.as_nanos())
2482        .unwrap_or_default();
2483    let script_path = dir.join(format!(
2484        "mermaid-update-{}-{nanos}.{ext}",
2485        std::process::id()
2486    ));
2487    stage_install_script(&script_path, script.as_bytes())
2488        .map_err(|e| anyhow!("could not stage install script: {e}"))?;
2489
2490    let mut cmd = if windows {
2491        let mut c = tokio::process::Command::new("powershell");
2492        c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
2493        c.arg(&script_path);
2494        c
2495    } else {
2496        let mut c = tokio::process::Command::new("sh");
2497        c.arg(&script_path);
2498        c
2499    };
2500    cmd.env("MERMAID_INSTALL_DIR", install_dir)
2501        .env("MERMAID_NO_MODIFY_PATH", "1");
2502
2503    let status = cmd
2504        .status()
2505        .await
2506        .map_err(|e| anyhow!("could not run install script: {e}"))?;
2507    let _ = std::fs::remove_file(&script_path);
2508    if !status.success() {
2509        bail!("install script exited with {:?}", status.code());
2510    }
2511    Ok(())
2512}
2513
2514/// Write the fetched install script to `path`, creating it **exclusively** so a
2515/// symlink pre-planted at the path is refused (`O_EXCL` never follows) and the
2516/// staged code is owner-only (`0600` file inside the `0700` private dir). This
2517/// closes the symlink-redirect and write→exec TOCTOU that the old predictable,
2518/// world-readable temp path left open (#F50).
2519fn stage_install_script(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2520    use std::io::Write;
2521    #[cfg(unix)]
2522    let mut file = {
2523        use std::os::unix::fs::OpenOptionsExt;
2524        std::fs::OpenOptions::new()
2525            .write(true)
2526            .create_new(true)
2527            .mode(0o600)
2528            .open(path)?
2529    };
2530    #[cfg(not(unix))]
2531    let mut file = std::fs::OpenOptions::new()
2532        .write(true)
2533        .create_new(true)
2534        .open(path)?;
2535    file.write_all(bytes)
2536}
2537
2538/// Parse a `[v]MAJOR.MINOR.PATCH[-pre][+build]` string into a comparable tuple.
2539fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
2540    let core = s.trim().trim_start_matches('v');
2541    let core = core.split(['-', '+']).next().unwrap_or(core);
2542    let mut parts = core.split('.');
2543    let major = parts.next()?.parse().ok()?;
2544    let minor = parts.next().unwrap_or("0").parse().ok()?;
2545    let patch = parts.next().unwrap_or("0").parse().ok()?;
2546    Some((major, minor, patch))
2547}
2548
2549/// True iff `current` is at least `latest` (no update needed). Unparseable
2550/// versions fall back to string equality, so we never falsely report
2551/// up-to-date on garbage — at worst we re-run the (idempotent) installer.
2552fn version_at_least(current: &str, latest: &str) -> bool {
2553    match (parse_semver(current), parse_semver(latest)) {
2554        (Some(c), Some(l)) => c >= l,
2555        _ => current == latest,
2556    }
2557}
2558
2559/// Show configured MCP servers
2560fn show_mcp_servers() {
2561    let config = load_config_or_warn();
2562
2563    if config.mcp_servers.is_empty() {
2564        println!("No MCP servers configured.\n");
2565        println!("Add one with: mermaid add <name>");
2566        println!("Examples:");
2567        println!("  mermaid add context7     # Library documentation");
2568        println!("  mermaid add playwright   # Browser automation");
2569        println!("  mermaid add memory       # Persistent knowledge graph");
2570        return;
2571    }
2572
2573    println!("Configured MCP servers:\n");
2574    for (name, server_cfg) in &config.mcp_servers {
2575        // Remote servers show their endpoint; stdio servers their package.
2576        let package: &str = match &server_cfg.url {
2577            Some(url) => url,
2578            None => server_cfg
2579                .args
2580                .iter()
2581                .find(|a| !a.starts_with('-'))
2582                .map(String::as_str)
2583                .unwrap_or(server_cfg.command.as_str()),
2584        };
2585        let env_keys: Vec<&String> = server_cfg.env.keys().collect();
2586        let env_display = if env_keys.is_empty() {
2587            String::new()
2588        } else {
2589            format!(
2590                " (env: {})",
2591                env_keys
2592                    .iter()
2593                    .map(|k| k.as_str())
2594                    .collect::<Vec<_>>()
2595                    .join(", ")
2596            )
2597        };
2598        println!("  {name} — {package}{env_display}");
2599    }
2600    println!("\nManage with: mermaid add <name> / mermaid remove <name>");
2601}
2602
2603/// Show status of all dependencies
2604async fn show_status(config: &Config) -> Result<()> {
2605    println!("Mermaid Status:");
2606    println!();
2607
2608    // Remote providers: one block, listing exactly what `ProviderFactory`
2609    // could build right now — name, where the key came from, and the endpoint
2610    // requests would go to. This used to be printed twice, by two walks that
2611    // disagreed with each other and with the factory; see `providers::discovery`.
2612    let available = configured_remote_providers(config);
2613    if available.is_empty() {
2614        println!(
2615            "  [WARNING] Remote providers: none (no API keys in env or keyring; `mermaid login <provider>`)"
2616        );
2617    } else {
2618        println!("  [OK] Remote providers: {} configured", available.len());
2619        for provider in &available {
2620            println!(
2621                "      - {} ({}) {}",
2622                provider.name,
2623                provider.source_label(),
2624                provider.endpoint
2625            );
2626        }
2627    }
2628    // Half-configured providers are the ones worth a warning: the user set
2629    // something up and it still cannot be used. The reason is the factory's own
2630    // error, so it says exactly what a real request would have said.
2631    let problems = crate::providers::provider_problems(config);
2632    if !problems.is_empty() {
2633        println!(
2634            "  [WARNING] Providers configured but not usable: {}",
2635            problems.len()
2636        );
2637        for problem in &problems {
2638            println!("      - {}: {}", problem.name, problem.reason);
2639        }
2640    }
2641
2642    print_ollama_status(config, !available.is_empty()).await;
2643
2644    // Check configuration (uses platform-specific path via ProjectDirs)
2645    if let Ok(config_dir) = get_config_dir() {
2646        let config_path = config_dir.join("config.toml");
2647        if config_path.exists() {
2648            println!("  [OK] Configuration: {}", config_path.display());
2649        } else {
2650            println!("  [WARNING] Configuration: Not found (using defaults)");
2651        }
2652    }
2653
2654    // MCP Servers
2655    if config.mcp_servers.is_empty() {
2656        println!("  [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
2657    } else {
2658        println!(
2659            "  [OK] MCP Servers: {} configured",
2660            config.mcp_servers.len()
2661        );
2662        for (name, server_cfg) in &config.mcp_servers {
2663            let target: &str = match &server_cfg.url {
2664                Some(url) => url,
2665                None => server_cfg
2666                    .args
2667                    .get(1)
2668                    .map(String::as_str)
2669                    .unwrap_or(server_cfg.command.as_str()),
2670            };
2671            println!("      - {name} ({target})");
2672        }
2673    }
2674
2675    print_project_instructions_status();
2676
2677    // Environment variables (for API providers)
2678    println!("\n  Environment:");
2679    if std::env::var("OLLAMA_API_KEY").is_ok() {
2680        println!("    - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2681    }
2682
2683    println!();
2684    Ok(())
2685}
2686
2687/// The Ollama line(s) of `mermaid status`. `has_remote` softens a missing
2688/// install to INFO: remote providers cover every model this machine needs.
2689async fn print_ollama_status(config: &Config, has_remote: bool) {
2690    // Check Ollama (via HTTP, so remote deployments are honored).
2691    // Diagnostics observe, they don't heal: the shared observe path keeps
2692    // autostart off, otherwise a status check would start the server and then
2693    // report "Running" — never able to observe the dead state it exists to
2694    // surface. A dead server with a readable store still lists (`FromDisk`).
2695    if is_ollama_installed() {
2696        let preview = |models: &[String]| {
2697            for model in models.iter().take(3) {
2698                println!("      - {model}");
2699            }
2700            if models.len() > 3 {
2701                println!("      ... and {} more", models.len() - 3);
2702            }
2703        };
2704        match observe_models(config).await {
2705            LocalModelListing::Unreachable => println!(
2706                "  [WARNING] Ollama: Installed but not running (started automatically \
2707                 when an Ollama model is used)"
2708            ),
2709            LocalModelListing::Live(models) if models.is_empty() => {
2710                println!("  [WARNING] Ollama: Running (no models installed)");
2711            },
2712            LocalModelListing::Live(models) => {
2713                println!("  [OK] Ollama: Running ({} models installed)", models.len());
2714                preview(&models);
2715            },
2716            LocalModelListing::FromDisk(models) => {
2717                println!(
2718                    "  [OK] Ollama: Not running ({} models installed on disk; starts \
2719                     automatically when used)",
2720                    models.len()
2721                );
2722                preview(&models);
2723            },
2724        }
2725    } else if !has_remote {
2726        println!("  [WARNING] Ollama: Not installed (and no remote provider configured)");
2727    } else {
2728        // Not a failure: the configured remote providers cover every model
2729        // this machine needs. Ollama is only required for local models.
2730        println!("  [INFO] Ollama: Not installed (only needed for local models)");
2731    }
2732}
2733
2734/// Project instructions (Step 5h). Walks UP from cwd to git root or
2735/// $HOME to find the nearest supported instruction files.
2736fn print_project_instructions_status() {
2737    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2738    let paths = crate::app::instructions::find_instruction_files(&cwd);
2739    if paths.is_empty() {
2740        println!("  [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
2741    } else {
2742        match crate::app::instructions::load_from_paths(&paths) {
2743            Some(loaded) => {
2744                let files = loaded
2745                    .sources
2746                    .iter()
2747                    .map(|source| {
2748                        source
2749                            .path
2750                            .file_name()
2751                            .and_then(|name| name.to_str())
2752                            .unwrap_or("instructions")
2753                    })
2754                    .collect::<Vec<_>>()
2755                    .join(", ");
2756                println!(
2757                    "  [OK] Project instructions: {} at {} ({} bytes{})",
2758                    files,
2759                    loaded.path.display(),
2760                    loaded.byte_len,
2761                    if loaded.truncated { ", truncated" } else { "" }
2762                );
2763            },
2764            None => {
2765                println!(
2766                    "  [WARNING] Project instructions: found but unreadable ({})",
2767                    paths
2768                        .iter()
2769                        .map(|path| path.display().to_string())
2770                        .collect::<Vec<_>>()
2771                        .join(", ")
2772                );
2773            },
2774        }
2775    }
2776}
2777
2778#[cfg(test)]
2779mod tests {
2780    use super::*;
2781
2782    #[test]
2783    fn session_log_drift_reports_agreement_and_catches_a_diverged_checkpoint() {
2784        let root = unique_temp_dir("mermaid-doctor-drift");
2785        let _ = std::fs::remove_dir_all(&root);
2786        std::fs::create_dir_all(&root).expect("project dir");
2787
2788        // No sessions yet: nothing to disagree about.
2789        let empty = session_log_drift(&root);
2790        assert_eq!(empty.status, "ok", "{}", empty.message);
2791
2792        // A real session, saved the way the persistence chain does.
2793        let manager = ConversationManager::new(&root).expect("manager");
2794        let mut state = mermaid_domain::State::new(
2795            Config::default(),
2796            root.clone(),
2797            "ollama/test".to_string(),
2798            chrono::Local::now(),
2799            std::env::temp_dir(),
2800        );
2801        state
2802            .session
2803            .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
2804        let snapshot = state.session.snapshot_conversation();
2805        let events = state.session.drain_events(&snapshot);
2806        manager
2807            .append_session_events(&snapshot, &events)
2808            .expect("append");
2809        manager.save_conversation(&snapshot).expect("checkpoint");
2810
2811        let agreed = session_log_drift(&root);
2812        assert_eq!(agreed.status, "ok", "{}", agreed.message);
2813        assert!(agreed.message.contains('1'), "{}", agreed.message);
2814
2815        // Now make the checkpoint claim a message the log never had -- the
2816        // shape a missed event emission would leave behind.
2817        let path = manager
2818            .conversations_dir()
2819            .join(format!("{}.json", snapshot.id));
2820        let mut value: serde_json::Value =
2821            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2822        let planted = serde_json::to_value(mermaid_model::models::ChatMessage::user(
2823            "never reached the log",
2824        ))
2825        .unwrap();
2826        value
2827            .get_mut("messages")
2828            .and_then(serde_json::Value::as_array_mut)
2829            .expect("messages array")
2830            .push(planted);
2831        std::fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
2832
2833        let drifted = session_log_drift(&root);
2834        assert_eq!(drifted.status, "warning", "{}", drifted.message);
2835        assert!(
2836            drifted.message.contains(&snapshot.id),
2837            "the warning must name the session: {}",
2838            drifted.message
2839        );
2840        let _ = std::fs::remove_dir_all(&root);
2841    }
2842
2843    #[test]
2844    fn doctor_uses_resolved_keyless_web_capabilities() {
2845        let config = Config {
2846            web: mermaid_domain::WebConfig {
2847                fetch_backend: mermaid_domain::FetchBackend::Native,
2848                search_backend: mermaid_domain::SearchBackend::Searxng,
2849                searxng_url: "http://127.0.0.1:8080".to_string(),
2850                ..mermaid_domain::WebConfig::default()
2851            },
2852            ..Config::default()
2853        };
2854        let (tools, next_steps) = web_doctor_entries(&config);
2855        assert!(
2856            tools
2857                .iter()
2858                .any(|entry| entry.contains("web_fetch (native"))
2859        );
2860        assert!(
2861            tools
2862                .iter()
2863                .any(|entry| entry.contains("web_search (searxng"))
2864        );
2865        assert!(next_steps.is_empty(), "unexpected warnings: {next_steps:?}");
2866        assert!(
2867            tools.iter().all(|entry| !entry.contains("container")),
2868            "doctor must describe the selected capability, not stale container setup"
2869        );
2870    }
2871
2872    #[test]
2873    fn doctor_reports_global_network_deny_instead_of_advertising_web() {
2874        let mut config = Config::default();
2875        config.web.search_backend = mermaid_domain::SearchBackend::Searxng;
2876        config.web.searxng_url = "http://127.0.0.1:8080".to_string();
2877        config.safety.network = mermaid_domain::NetworkPolicy::Deny;
2878
2879        let (tools, next_steps) = web_doctor_entries(&config);
2880        assert!(
2881            tools
2882                .iter()
2883                .all(|entry| !entry.starts_with("web_fetch") && !entry.starts_with("web_search")),
2884            "network-denied tools were advertised: {tools:?}"
2885        );
2886        for name in ["web_fetch", "web_search"] {
2887            assert!(
2888                next_steps.iter().any(|entry| {
2889                    entry.contains(name) && entry.contains("safety.network = \"deny\"")
2890                }),
2891                "missing network-deny explanation for {name}: {next_steps:?}"
2892            );
2893        }
2894    }
2895
2896    #[test]
2897    fn sanitize_terminal_text_strips_control_sequences() {
2898        // Plain text and the allowed whitespace pass through unchanged.
2899        assert_eq!(
2900            sanitize_terminal_text("hello\tworld\nline two"),
2901            "hello\tworld\nline two"
2902        );
2903        // CSI color sequence is removed, surrounding text kept.
2904        assert_eq!(
2905            sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
2906            "RED text"
2907        );
2908        // OSC-52 clipboard write (BEL-terminated) is removed whole.
2909        assert_eq!(
2910            sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
2911            "beforeafter"
2912        );
2913        // OSC window-title rewrite terminated by ST (ESC '\').
2914        assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
2915        // Charset-designation (ESC '(' 'B') drops its final byte too.
2916        assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
2917        // Bare CR and a C1 control are dropped; \n is preserved.
2918        assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
2919    }
2920
2921    #[test]
2922    fn version_compare_handles_update_logic() {
2923        // Up to date / newer than latest ⇒ no update.
2924        assert!(version_at_least("0.10.2", "0.10.2"));
2925        assert!(version_at_least("0.11.0", "0.10.2"));
2926        assert!(version_at_least("1.0.0", "0.99.99"));
2927        // Older ⇒ update available.
2928        assert!(!version_at_least("0.10.1", "0.10.2"));
2929        assert!(!version_at_least("0.9.0", "0.10.0"));
2930        assert!(!version_at_least("0.10.2", "0.11.0"));
2931        // Pre-release/build suffixes and `v` prefixes are tolerated.
2932        assert!(version_at_least("0.10.2", "v0.10.2"));
2933        assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2934        assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2935        // Garbage never falsely reports up-to-date unless identical.
2936        assert!(!version_at_least("0.10.2", "not-a-version"));
2937    }
2938
2939    #[test]
2940    fn qa_compact_smoke_persists_conversation_and_archive() {
2941        let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2942        std::fs::create_dir_all(&dir).unwrap();
2943
2944        let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2945
2946        assert!(report.ok);
2947        assert!(report.archived_messages > 0);
2948        assert!(report.preserved_messages > 0);
2949        assert!(report.replacement_messages >= 3);
2950        assert!(
2951            std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2952            "conversation path should exist"
2953        );
2954        assert!(
2955            std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2956            "archive path should exist"
2957        );
2958
2959        let _ = std::fs::remove_dir_all(dir);
2960    }
2961
2962    #[test]
2963    fn qa_model_id_falls_back_to_deterministic() {
2964        assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2965    }
2966
2967    fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2968        let nanos = std::time::SystemTime::now()
2969            .duration_since(std::time::UNIX_EPOCH)
2970            .map(|duration| duration.as_nanos())
2971            .unwrap_or_default();
2972        std::env::temp_dir().join(format!("{name}-{nanos}"))
2973    }
2974}