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    ];
821    // Platforms with a sandbox backend must have it working; platforms
822    // without one truthfully report "no" above without failing the whole self-test.
823    let sandbox_expected = cfg!(any(
824        target_os = "linux",
825        target_os = "macos",
826        target_os = "windows"
827    ));
828    let ok = compact_smoke.ok
829        && runtime_store.status == "ok"
830        && (!sandbox_expected || (sandbox_available && fs_sandbox_available));
831    let report = SelfTestReport {
832        ok,
833        workspace: workspace.display().to_string(),
834        checks,
835        compact_smoke,
836        runtime_store,
837        kept_workspace: keep_workspace,
838    };
839
840    print_self_test_report(&report, format)?;
841    if !keep_workspace {
842        let _ = std::fs::remove_dir_all(&workspace);
843    }
844    anyhow::ensure!(report.ok, "mermaid self-test failed");
845    Ok(())
846}
847
848fn print_self_test_report(report: &SelfTestReport, format: OutputFormat) -> Result<()> {
849    match format {
850        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
851        OutputFormat::Ndjson => println!("{}", serde_json::to_string(report)?),
852        OutputFormat::Markdown => {
853            println!("# Mermaid Self-Test\n");
854            print_self_test_text(report);
855        },
856        OutputFormat::Text => print_self_test_text(report),
857    }
858    Ok(())
859}
860
861fn print_self_test_text(report: &SelfTestReport) {
862    println!(
863        "Mermaid self-test: {}",
864        if report.ok { "ok" } else { "failed" }
865    );
866    println!("workspace: {}", report.workspace);
867    println!(
868        "compact smoke: {}",
869        if report.compact_smoke.ok {
870            "ok"
871        } else {
872            "failed"
873        }
874    );
875    println!("runtime store: {}", report.runtime_store.message);
876    println!("checks:");
877    for check in &report.checks {
878        println!("  - {check}");
879    }
880    if !report.ok
881        && let Some(failure) = &report.compact_smoke.failure
882    {
883        println!("failure: {failure}");
884    }
885}
886
887fn label(status: &str) -> &'static str {
888    match status {
889        "ok" => "OK",
890        "warning" => "WARNING",
891        "error" => "ERROR",
892        _ => "INFO",
893    }
894}
895
896fn safety_mode_name(mode: mermaid_runtime::SafetyMode) -> &'static str {
897    mode.as_str()
898}
899
900/// Every provider `mermaid login` can store a key for: the bespoke
901/// providers, the OpenAI-compat registry, and user-defined `[providers.*]`
902/// entries. Yields `(name, default_env, override_env)`.
903fn login_providers(config: &Config) -> Vec<(String, String, Option<String>)> {
904    let over = |name: &str| {
905        config
906            .providers
907            .get(name)
908            .and_then(|c| c.api_key_env.clone())
909    };
910    let mut rows: Vec<(String, String, Option<String>)> = vec![
911        (
912            "anthropic".to_string(),
913            "ANTHROPIC_API_KEY".to_string(),
914            over("anthropic"),
915        ),
916        (
917            "gemini".to_string(),
918            "GOOGLE_API_KEY".to_string(),
919            over("gemini"),
920        ),
921        (
922            "meta".to_string(),
923            crate::providers::model::meta::DEFAULT_API_KEY_ENV.to_string(),
924            over("meta"),
925        ),
926        (
927            "ollama".to_string(),
928            "OLLAMA_API_KEY".to_string(),
929            over("ollama"),
930        ),
931    ];
932    for profile in PROVIDER_REGISTRY {
933        rows.push((
934            profile.name.to_string(),
935            profile.api_key_env.to_string(),
936            over(profile.name),
937        ));
938    }
939    for (name, cfg) in &config.providers {
940        if rows.iter().any(|(n, _, _)| n == name) {
941            continue;
942        }
943        // Custom providers: their api_key_env IS the default env.
944        if let Some(env) = &cfg.api_key_env {
945            rows.push((name.clone(), env.clone(), None));
946        }
947    }
948    rows.sort_by(|a, b| a.0.cmp(&b.0));
949    rows
950}
951
952/// `mermaid login [provider]`: no arg lists key status; with a provider,
953/// prompt (hidden input) and store the key in the OS keyring. Env vars keep
954/// absolute precedence over stored keys.
955fn login(provider: Option<&str>, config: &Config) -> Result<()> {
956    let rows = login_providers(config);
957    let Some(provider) = provider else {
958        println!(
959            "Provider API-key status (env beats keyring; `mermaid login <provider>` stores a key):\n"
960        );
961        for (name, default_env, override_env) in &rows {
962            let source = mermaid_model::utils::provider_key_source(
963                name,
964                default_env,
965                override_env.as_deref(),
966            );
967            let env_name = override_env.as_deref().unwrap_or(default_env);
968            println!("  {name:<14} {source:<8} (${env_name})");
969        }
970        return Ok(());
971    };
972    let provider = provider.to_lowercase();
973    let Some((name, default_env, override_env)) = rows.into_iter().find(|(n, _, _)| n == &provider)
974    else {
975        let names: Vec<String> = login_providers(config)
976            .into_iter()
977            .map(|(n, _, _)| n)
978            .collect();
979        anyhow::bail!(
980            "unknown provider '{}'; known: {}",
981            provider,
982            names.join(", ")
983        );
984    };
985    let key = rpassword::prompt_password(format!("API key for {name} (input hidden): "))
986        .context("read API key")?;
987    let key = key.trim();
988    anyhow::ensure!(!key.is_empty(), "no key entered; nothing stored");
989    let store = mermaid_model::utils::default_store();
990    store
991        .set(&name, key)
992        .with_context(|| format!("store key for {name}"))?;
993    println!(
994        "Stored key for {} in {} (service \"mermaid\").",
995        name,
996        store.label()
997    );
998    // The env var, when set, silently wins — say so now, not at 2am.
999    if mermaid_model::utils::resolve_api_key(&default_env, override_env.as_deref()).is_some() {
1000        let env_name = override_env.as_deref().unwrap_or(&default_env);
1001        println!("Note: ${env_name} is currently set and takes precedence over the stored key.");
1002    }
1003    Ok(())
1004}
1005
1006/// `mermaid logout <provider>`: delete the stored key (reports whether
1007/// anything was stored).
1008fn logout(provider: &str, config: &Config) -> Result<()> {
1009    let provider = provider.to_lowercase();
1010    // Unknown names are allowed here — a key may be stored for a provider
1011    // that was since removed from config; deleting it must stay possible.
1012    let _ = config;
1013    let store = mermaid_model::utils::default_store();
1014    if store
1015        .delete(&provider)
1016        .with_context(|| format!("delete key for {provider}"))?
1017    {
1018        println!(
1019            "Removed stored key for {} from {}.",
1020            provider,
1021            store.label()
1022        );
1023    } else {
1024        println!("No stored key for {provider}.");
1025    }
1026    Ok(())
1027}
1028
1029fn meta_api_key(config: &Config) -> Option<String> {
1030    mermaid_model::utils::resolve_provider_key(
1031        "meta",
1032        crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1033        config
1034            .providers
1035            .get("meta")
1036            .and_then(|provider| provider.api_key_env.as_deref()),
1037    )
1038}
1039
1040fn meta_base_url(config: &Config) -> String {
1041    config
1042        .providers
1043        .get("meta")
1044        .and_then(|provider| provider.base_url.clone())
1045        .unwrap_or_else(|| crate::providers::model::meta::DEFAULT_BASE_URL.to_string())
1046}
1047
1048#[derive(Debug, serde::Serialize)]
1049struct QaCompactSmokeReport {
1050    ok: bool,
1051    turns: usize,
1052    archived_messages: usize,
1053    preserved_messages: usize,
1054    replacement_messages: usize,
1055    conversation_path: Option<String>,
1056    archive_path: Option<String>,
1057    checks: Vec<String>,
1058    failure: Option<String>,
1059}
1060
1061impl QaCompactSmokeReport {
1062    fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
1063        Self {
1064            ok: false,
1065            turns,
1066            archived_messages: 0,
1067            preserved_messages: 0,
1068            replacement_messages: 0,
1069            conversation_path: Some(
1070                cwd.join(".mermaid")
1071                    .join("conversations")
1072                    .display()
1073                    .to_string(),
1074            ),
1075            archive_path: None,
1076            checks: Vec::new(),
1077            failure: Some(failure),
1078        }
1079    }
1080}
1081
1082#[expect(
1083    clippy::too_many_lines,
1084    reason = "an in-binary smoke test with a test's shape: build a synthetic session, drive a \
1085     manual compaction through the reducer, replay the save commands, then assert on the files \
1086     and record each check for the report; the arrange/act/assert steps read as one scenario and \
1087     would lose their sequence split across helpers"
1088)]
1089fn run_qa_compact_smoke(
1090    config: &Config,
1091    cwd: &Path,
1092    requested_turns: usize,
1093) -> Result<QaCompactSmokeReport> {
1094    let turns = requested_turns.max(3);
1095    let mut state = State::new(
1096        config.clone(),
1097        cwd.to_path_buf(),
1098        qa_model_id(config),
1099        chrono::Local::now(),
1100        std::env::temp_dir(),
1101    );
1102    for message in synthetic_compaction_messages(turns) {
1103        state.session.append(message, state.now);
1104    }
1105    // Persist the PRE-compaction session the way the live cadence would
1106    // (the reducer saves after nearly every message). Without this the log
1107    // would be created by the compaction save itself and never hold the
1108    // messages the compaction drops — which is exactly what the check at
1109    // the end of this smoke is about.
1110    let pre_manager = ConversationManager::new(cwd)?;
1111    let pre_snapshot = state.session.snapshot_conversation();
1112    let pre_events = state.session.drain_events(&pre_snapshot);
1113    pre_manager.append_session_events(&pre_snapshot, &pre_events)?;
1114    pre_manager.save_conversation(&pre_snapshot)?;
1115    let dropped_probe = state
1116        .session
1117        .messages()
1118        .first()
1119        .map(|message| message.content.clone())
1120        .context("synthetic history is empty")?;
1121
1122    let (state_after_slash, compact_cmds) = update(
1123        state,
1124        Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
1125    );
1126    let turn = state_after_slash
1127        .turn
1128        .id()
1129        .context("manual compaction did not enter a compaction turn")?;
1130    let request = compact_cmds
1131        .iter()
1132        .find_map(|cmd| match cmd {
1133            Cmd::CompactConversation { request, .. } => Some(request.clone()),
1134            _ => None,
1135        })
1136        .context("manual compaction did not emit a CompactConversation command")?;
1137
1138    let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
1139    let prepared = prepare_compaction(&request, Some(100_000))
1140        .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
1141    anyhow::ensure!(
1142        !prepared.archived_messages.is_empty(),
1143        "compaction archived no messages"
1144    );
1145    anyhow::ensure!(
1146        !prepared.preserved_messages.is_empty(),
1147        "compaction preserved no messages"
1148    );
1149
1150    let summary = deterministic_compaction_summary(&prepared, turns);
1151    let mut record = CompactionEvent {
1152        id: format!("qa_compact_{}", fresh_qa_id()),
1153        trigger: CompactionTrigger::Manual,
1154        created_at: chrono::Local::now(),
1155        before_tokens: before_snapshot.used_tokens,
1156        after_tokens: 0,
1157        archived_message_count: prepared.archived_messages.len(),
1158        preserved_message_count: prepared.preserved_messages.len(),
1159        preserved_turn_count: prepared
1160            .preserved_messages
1161            .iter()
1162            .filter(|message| message.role == mermaid_model::models::MessageRole::User)
1163            .count(),
1164        summary_tokens: summary.len().div_ceil(4),
1165        duration_secs: 0.0,
1166        review_status: mermaid_domain::CompactionReviewStatus::DraftValidated,
1167        review_error: None,
1168        focus: Some("qa compact smoke".to_string()),
1169        archive_path: None,
1170    };
1171    let mut replacement = build_replacement_messages(&summary, &prepared, &record);
1172    let mut after_chat: ChatRequest = request.chat.clone();
1173    after_chat.messages = replacement.clone();
1174    let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1175    record.after_tokens = after_snapshot.used_tokens;
1176    replacement = build_replacement_messages(&summary, &prepared, &record);
1177    after_chat.messages = replacement.clone();
1178    after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
1179
1180    let result = CompactionResult {
1181        record,
1182        replacement_messages: replacement,
1183        archived_messages: prepared.archived_messages,
1184        before_snapshot,
1185        after_snapshot,
1186        usage: None,
1187        source_boundaries: Vec::new(),
1188    };
1189    let (final_state, save_cmds) =
1190        update(state_after_slash, Msg::CompactionFinished { turn, result });
1191
1192    let manager = ConversationManager::new(cwd)?;
1193    let mut conversation_path = None;
1194    let mut archive_path = None;
1195    for cmd in save_cmds {
1196        match cmd {
1197            Cmd::SaveConversation {
1198                snapshot: conversation,
1199                ..
1200            } => {
1201                manager.save_conversation(&conversation)?;
1202                conversation_path = Some(
1203                    manager
1204                        .conversations_dir()
1205                        .join(format!("{}.json", conversation.id))
1206                        .display()
1207                        .to_string(),
1208                );
1209            },
1210            Cmd::SaveCompaction {
1211                conversation,
1212                events,
1213                ..
1214            } => {
1215                // Boundary event first, then the stripped conversation (same
1216                // order as the live effect path), with `?` so a failed
1217                // append aborts before the conversation is overwritten —
1218                // the log is the only record of the dropped messages.
1219                manager.append_session_events(&conversation, &events)?;
1220                archive_path = Some(
1221                    manager
1222                        .event_log_path(&conversation.id)
1223                        .display()
1224                        .to_string(),
1225                );
1226                manager.save_conversation(&conversation)?;
1227                conversation_path = Some(
1228                    manager
1229                        .conversations_dir()
1230                        .join(format!("{}.json", conversation.id))
1231                        .display()
1232                        .to_string(),
1233                );
1234            },
1235            _ => {},
1236        }
1237    }
1238
1239    let conversation_path = conversation_path.context("compaction did not save conversation")?;
1240    let archive_path = archive_path.context("compaction did not save archive")?;
1241    let messages = final_state.session.messages();
1242    let compactions = &final_state.session.conversation.compactions;
1243
1244    let mut checks = Vec::new();
1245    anyhow::ensure!(
1246        !compactions.is_empty(),
1247        "conversation did not record compaction metadata"
1248    );
1249    checks.push("conversation records compaction metadata".to_string());
1250    anyhow::ensure!(
1251        messages.first().is_some_and(
1252            |msg| msg.kind == mermaid_model::models::ChatMessageKind::ContextCheckpoint
1253        ),
1254        "replacement does not start with a context checkpoint"
1255    );
1256    checks.push("replacement starts with context checkpoint".to_string());
1257    anyhow::ensure!(
1258        std::path::Path::new(&conversation_path).exists(),
1259        "conversation file missing after save"
1260    );
1261    checks.push("conversation file saved".to_string());
1262    anyhow::ensure!(
1263        std::path::Path::new(&archive_path).exists(),
1264        "session event log missing after compaction save"
1265    );
1266    checks.push("session event log saved".to_string());
1267    // The claim the archive file used to carry: the dropped messages are
1268    // still recoverable. They are not copied anywhere now, so read the log
1269    // and require both the boundary marker and a message the compaction
1270    // removed from the live transcript.
1271    let log = std::fs::read_to_string(&archive_path).context("read the session event log")?;
1272    anyhow::ensure!(
1273        log.contains("\"type\":\"compaction\""),
1274        "event log has no compaction boundary"
1275    );
1276    anyhow::ensure!(
1277        !messages
1278            .iter()
1279            .any(|message| message.content == dropped_probe),
1280        "the probe message was not actually dropped by the compaction"
1281    );
1282    anyhow::ensure!(
1283        log.contains(dropped_probe.trim()),
1284        "event log lost a message the compaction dropped"
1285    );
1286    checks.push("dropped messages survive in the event log".to_string());
1287    anyhow::ensure!(
1288        compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
1289        "compaction did not archive and preserve messages"
1290    );
1291    checks.push("archived and preserved message counts are non-zero".to_string());
1292
1293    Ok(QaCompactSmokeReport {
1294        ok: true,
1295        turns,
1296        archived_messages: compactions[0].archived_message_count,
1297        preserved_messages: compactions[0].preserved_message_count,
1298        replacement_messages: messages.len(),
1299        conversation_path: Some(conversation_path),
1300        archive_path: Some(archive_path),
1301        checks,
1302        failure: None,
1303    })
1304}
1305
1306fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
1307    match format {
1308        OutputFormat::Json => {
1309            println!("{}", serde_json::to_string_pretty(report)?);
1310        },
1311        OutputFormat::Ndjson => {
1312            println!("{}", serde_json::to_string(report)?);
1313        },
1314        OutputFormat::Text => {
1315            println!(
1316                "qa compact smoke: {}",
1317                if report.ok { "ok" } else { "failed" }
1318            );
1319            println!("turns: {}", report.turns);
1320            println!("archived messages: {}", report.archived_messages);
1321            println!("preserved messages: {}", report.preserved_messages);
1322            println!("replacement messages: {}", report.replacement_messages);
1323            if let Some(path) = &report.conversation_path {
1324                println!("conversation: {path}");
1325            }
1326            if let Some(path) = &report.archive_path {
1327                println!("archive: {path}");
1328            }
1329            if let Some(failure) = &report.failure {
1330                println!("failure: {failure}");
1331            }
1332        },
1333        OutputFormat::Markdown => {
1334            println!(
1335                "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
1336                if report.ok { "ok" } else { "failed" },
1337                report.turns,
1338                report.archived_messages,
1339                report.preserved_messages,
1340                report.replacement_messages
1341            );
1342            if let Some(path) = &report.conversation_path {
1343                println!("- Conversation: `{path}`");
1344            }
1345            if let Some(path) = &report.archive_path {
1346                println!("- Archive: `{path}`");
1347            }
1348            if let Some(failure) = &report.failure {
1349                println!("\nFailure: `{failure}`");
1350            }
1351        },
1352    }
1353    Ok(())
1354}
1355
1356fn qa_model_id(config: &Config) -> String {
1357    if let Some(model) = config
1358        .last_used_model
1359        .as_ref()
1360        .filter(|value| !value.is_empty())
1361    {
1362        return model.clone();
1363    }
1364    if !config.default_model.name.is_empty() {
1365        if config.default_model.provider.is_empty() {
1366            return config.default_model.name.clone();
1367        }
1368        return format!(
1369            "{}/{}",
1370            config.default_model.provider, config.default_model.name
1371        );
1372    }
1373    "qa/deterministic".to_string()
1374}
1375
1376fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
1377    let mut messages = Vec::with_capacity(turns.saturating_mul(2));
1378    for idx in 1..=turns {
1379        messages.push(ChatMessage::user(format!(
1380            "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
1381        )));
1382        messages.push(ChatMessage::assistant(format!(
1383            "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}."
1384        )));
1385    }
1386    messages
1387}
1388
1389fn deterministic_compaction_summary(
1390    prepared: &mermaid_domain::PreparedCompaction,
1391    turns: usize,
1392) -> String {
1393    format!(
1394        "## Goal\n- Verify Mermaid can compact a multi-turn conversation through the reducer path.\n\n## User Preferences And Constraints\n- Headless QA must not require a human to open the TUI.\n\n## Project State\n- Synthetic QA conversation seeded with {turns} user/assistant turns.\n\n## Completed Work\n- Prepared compaction archived {} messages and preserved {} messages.\n\n## Current Work\n- Running deterministic compact smoke from the hidden QA command.\n\n## Key Decisions\n- Use deterministic summary text so fast QA does not call a real model.\n\n## Critical Files And Symbols\n- src/domain/compaction.rs: compaction preparation and replacement shape.\n- src/domain/reducer.rs: manual compaction completion handling.\n- scripts/qa_mermaid.py: headless QA harness.\n\n## Commands Tests And Results\n- mermaid qa compact-smoke --format json: running inside this smoke.\n\n## Open Questions Or Risks\n- Full TUI automation remains intentionally deferred.\n\n## Next Steps\n- Keep using the real-model QA tier for end-to-end dogfood checks.",
1395        prepared.archived_messages.len(),
1396        prepared.preserved_messages.len()
1397    )
1398}
1399
1400fn fresh_qa_id() -> u128 {
1401    std::time::SystemTime::now()
1402        .duration_since(std::time::UNIX_EPOCH)
1403        .map(|duration| duration.as_nanos())
1404        .unwrap_or_default()
1405}
1406
1407fn show_tasks(limit: usize) -> Result<()> {
1408    let read = RuntimeClient::auto().list_tasks(limit)?;
1409    let mut tasks = read.value;
1410    tasks.truncate(limit);
1411    println!("Mermaid runtime tasks");
1412    println!("Source: {}", read.source.as_str());
1413    println!();
1414    if tasks.is_empty() {
1415        println!("No tasks recorded yet.");
1416        return Ok(());
1417    }
1418    for task in tasks {
1419        println!(
1420            "{}  [{}] {}  {}  {}",
1421            task.id, task.status, task.priority, task.updated_at, task.title
1422        );
1423        println!("    project: {}", task.project_path);
1424        println!("    model: {}", task.model_id);
1425    }
1426    Ok(())
1427}
1428
1429fn show_task(id: &str) -> Result<()> {
1430    let detail = RuntimeClient::auto().task_detail(id)?.value;
1431    print_task_detail(&detail.task);
1432    let events = detail.events;
1433    if !events.is_empty() {
1434        println!();
1435        println!("Timeline:");
1436        for event in events {
1437            println!("  {}  {}  {}", event.created_at, event.kind, event.message);
1438        }
1439    }
1440    Ok(())
1441}
1442
1443fn print_task_detail(task: &TaskRecord) {
1444    println!("Task: {}", task.id);
1445    println!("Title: {}", task.title);
1446    println!("Status: {}", task.status);
1447    println!("Priority: {}", task.priority);
1448    println!("Project: {}", task.project_path);
1449    println!("Model: {}", task.model_id);
1450    if let Some(conversation_id) = &task.conversation_id {
1451        println!("Conversation: {conversation_id}");
1452    }
1453    println!("Created: {}", task.created_at);
1454    println!("Updated: {}", task.updated_at);
1455    if let Some(report) = &task.final_report {
1456        println!();
1457        println!("Final report:");
1458        println!("{}", sanitize_terminal_text(report));
1459    }
1460}
1461
1462fn show_processes(limit: usize) -> Result<()> {
1463    let read = RuntimeClient::auto().list_processes(limit)?;
1464    let mut processes = read.value;
1465    processes.truncate(limit);
1466    println!("Mermaid runtime processes");
1467    println!("Source: {}", read.source.as_str());
1468    println!();
1469    if processes.is_empty() {
1470        println!("No processes recorded yet.");
1471        return Ok(());
1472    }
1473    for process in processes {
1474        println!(
1475            "{}  pid={}  status={}  {}",
1476            process.id,
1477            process.pid,
1478            process.status.as_str(),
1479            process.command
1480        );
1481        if let Some(task_id) = process.task_id {
1482            println!("    task: {task_id}");
1483        }
1484        if let Some(cwd) = process.cwd {
1485            println!("    cwd: {cwd}");
1486        }
1487        if let Some(log_path) = process.log_path {
1488            println!("    log: {log_path}");
1489        }
1490        if let Some(url) = process.detected_url {
1491            println!("    url: {url}");
1492        }
1493    }
1494    Ok(())
1495}
1496
1497async fn show_models(config: &Config) -> Result<()> {
1498    list_models(config).await?;
1499    probe_configured_provider_models(config).await?;
1500    let store = RuntimeStore::open_default()?;
1501    let probes = store.provider_probes().list(None, None)?;
1502    if !probes.is_empty() {
1503        println!("\nCached capability probes:");
1504        for probe in probes {
1505            println!(
1506                "  - {}/{} {}={} ({})",
1507                probe.provider,
1508                probe.model_id,
1509                probe.capability_key,
1510                probe.capability_value,
1511                probe.confidence
1512            );
1513        }
1514    }
1515    Ok(())
1516}
1517
1518async fn show_model_info(model: &str, config: &Config) -> Result<()> {
1519    let snapshot = mermaid_domain::ProviderCapabilitySnapshot::from_model_id(model);
1520    let store = RuntimeStore::open_default()?;
1521    let provider = snapshot.provider.clone();
1522
1523    // The static snapshot has no limits for providers that discover them live.
1524    // Resolve through the same provider path a real turn uses — cache-first
1525    // via `provider_probes`, one live fetch on a miss (Ollama `/api/show`,
1526    // Anthropic/Gemini models endpoints, OpenAI-compat `/models` metadata) —
1527    // so this reports real numbers, not "unknown". Falls back to the static
1528    // snapshot when the provider can't be built (e.g. no API key configured).
1529    let mut context_tokens = snapshot.max_context_tokens;
1530    let mut context_confidence = "static";
1531    let mut output_tokens = snapshot.max_output_tokens;
1532    let mut output_confidence = "static";
1533    let factory = crate::providers::ProviderFactory::new(config.clone());
1534    if let Ok(live) = factory.resolve(model).await {
1535        let probe_request = ChatRequest {
1536            model_id: model.to_string(),
1537            messages: vec![],
1538            system_prompt: String::new(),
1539            instructions: None,
1540            reasoning: mermaid_model::models::ReasoningLevel::None,
1541            temperature: 0.0,
1542            max_tokens: 0,
1543            tools: vec![],
1544            ollama_num_ctx: None,
1545            ollama_allow_ram_offload: None,
1546            resolved_context_window: None,
1547            resolved_max_output: None,
1548            output_schema: None,
1549            suppress_auto_compact: false,
1550            suppressed_builtin_tools: Vec::new(),
1551        };
1552        let sizing = live.resolve_context_window(&probe_request).await;
1553        if let Some(window) = sizing.model_max.or(sizing.effective) {
1554            context_tokens = Some(window);
1555            context_confidence = "probed";
1556        }
1557        if let Some(output) = sizing.max_output {
1558            output_tokens = Some(output);
1559            output_confidence = "probed";
1560        }
1561    }
1562
1563    for (key, value) in [
1564        ("supports_tools", snapshot.supports_tools.to_string()),
1565        ("supports_vision", snapshot.supports_vision.to_string()),
1566        ("reasoning", snapshot.reasoning.clone()),
1567    ] {
1568        let _ = store.provider_probes().upsert(NewProviderProbe {
1569            provider: provider.clone(),
1570            model_id: snapshot.model.clone(),
1571            capability_key: key.to_string(),
1572            capability_value: value,
1573            confidence: "static".to_string(),
1574            error: None,
1575        });
1576    }
1577    // Context window separately — probed (Ollama) or static.
1578    let _ = store.provider_probes().upsert(NewProviderProbe {
1579        provider: provider.clone(),
1580        model_id: snapshot.model.clone(),
1581        capability_key: "max_context_tokens".to_string(),
1582        capability_value: context_tokens
1583            .map(|n| n.to_string())
1584            .unwrap_or_else(|| "unknown".to_string()),
1585        confidence: context_confidence.to_string(),
1586        error: None,
1587    });
1588    println!("Model: {model}");
1589    println!("Provider: {}", snapshot.provider);
1590    println!("Name: {}", snapshot.model);
1591    println!("Supports tools: {}", snapshot.supports_tools);
1592    println!("Supports vision: {}", snapshot.supports_vision);
1593    println!("Reasoning: {}", snapshot.reasoning);
1594    println!(
1595        "Context: {}",
1596        context_tokens
1597            .map(|n| format!("{n} ({context_confidence})"))
1598            .unwrap_or_else(|| "unknown".to_string())
1599    );
1600    println!(
1601        "Output limit: {}",
1602        output_tokens
1603            .map(|n| format!("{n} ({output_confidence})"))
1604            .unwrap_or_else(|| {
1605                "unknown (discovered live from the provider's models endpoint when exposed)"
1606                    .to_string()
1607            })
1608    );
1609    if let Some(profile) = lookup_provider(&snapshot.provider) {
1610        record_static_provider_probes(&store, profile, &provider, &snapshot.model);
1611        println!("Token budget field: {:?}", profile.max_tokens_param);
1612        println!(
1613            "Single-tool-call models: {}",
1614            if profile.disable_parallel_tool_calls_for.is_empty() {
1615                "(none)".to_string()
1616            } else {
1617                profile.disable_parallel_tool_calls_for.join(", ")
1618            }
1619        );
1620    }
1621    Ok(())
1622}
1623
1624async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1625    let client = reqwest::Client::builder()
1626        .timeout(std::time::Duration::from_secs(5))
1627        .build()?;
1628    for profile in PROVIDER_REGISTRY {
1629        let user_cfg = config.providers.get(profile.name);
1630        let Some(api_key) = mermaid_model::utils::resolve_provider_key(
1631            profile.name,
1632            profile.api_key_env,
1633            user_cfg.and_then(|c| c.api_key_env.as_deref()),
1634        ) else {
1635            continue;
1636        };
1637        let Some(base_url) = crate::providers::factory::discovery_base_url(
1638            profile,
1639            user_cfg.and_then(|c| c.base_url.clone()),
1640        ) else {
1641            // cloudflare with a token but no CLOUDFLARE_ACCOUNT_ID: there is no
1642            // real endpoint to probe — record the misconfiguration instead of a
1643            // guaranteed 404 against the registry placeholder.
1644            record_provider_probe(
1645                profile.name,
1646                "*",
1647                "models_availability",
1648                "failed",
1649                "failed",
1650                Some("CLOUDFLARE_ACCOUNT_ID not set".to_string()),
1651            );
1652            continue;
1653        };
1654        let url = format!("{}/models", base_url.trim_end_matches('/'));
1655        let mut request = client.get(&url).bearer_auth(api_key);
1656        for (name, value) in profile.extra_headers {
1657            request = request.header(*name, *value);
1658        }
1659        if let Some(user_cfg) = user_cfg {
1660            for (name, value) in &user_cfg.extra_headers {
1661                request = request.header(name, value);
1662            }
1663        }
1664
1665        let result = request.send().await;
1666        match result {
1667            Ok(response) if response.status().is_success() => {
1668                let status = response.status();
1669                let body: serde_json::Value = response.json().await.unwrap_or_default();
1670                let ids = body
1671                    .get("data")
1672                    .and_then(|v| v.as_array())
1673                    .map(|items| {
1674                        items
1675                            .iter()
1676                            .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1677                            .map(str::to_string)
1678                            .collect::<Vec<_>>()
1679                    })
1680                    .unwrap_or_default();
1681                record_provider_probe(
1682                    profile.name,
1683                    "*",
1684                    "models_availability",
1685                    &format!("available:{}:{}", status.as_u16(), ids.len()),
1686                    "probed",
1687                    None,
1688                );
1689                for model_id in ids.into_iter().take(200) {
1690                    record_provider_probe(
1691                        profile.name,
1692                        &model_id,
1693                        "model_listed",
1694                        "true",
1695                        "listed",
1696                        None,
1697                    );
1698                }
1699            },
1700            Ok(response) => {
1701                record_provider_probe(
1702                    profile.name,
1703                    "*",
1704                    "models_availability",
1705                    "failed",
1706                    "failed",
1707                    Some(format!("HTTP {}", response.status().as_u16())),
1708                );
1709            },
1710            Err(error) => {
1711                record_provider_probe(
1712                    profile.name,
1713                    "*",
1714                    "models_availability",
1715                    "failed",
1716                    "failed",
1717                    Some(error.to_string()),
1718                );
1719            },
1720        }
1721    }
1722    probe_meta_models(&client, config).await;
1723    Ok(())
1724}
1725
1726async fn probe_meta_models(client: &reqwest::Client, config: &Config) {
1727    let Some(api_key) = meta_api_key(config) else {
1728        return;
1729    };
1730    let url = format!("{}/models", meta_base_url(config).trim_end_matches('/'));
1731    let mut request = client.get(&url).bearer_auth(api_key);
1732    if let Some(provider) = config.providers.get("meta") {
1733        for (name, value) in &provider.extra_headers {
1734            request = request.header(name, value);
1735        }
1736        for (name, env_var) in &provider.env_headers {
1737            if let Ok(value) = std::env::var(env_var) {
1738                request = request.header(name, value);
1739            }
1740        }
1741    }
1742    match request.send().await {
1743        Ok(response) if response.status().is_success() => {
1744            let status = response.status();
1745            let body: serde_json::Value = response.json().await.unwrap_or_default();
1746            let ids = body
1747                .get("data")
1748                .and_then(serde_json::Value::as_array)
1749                .into_iter()
1750                .flatten()
1751                .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1752                .map(str::to_string)
1753                .collect::<Vec<_>>();
1754            record_provider_probe(
1755                "meta",
1756                "*",
1757                "models_availability",
1758                &format!("available:{}:{}", status.as_u16(), ids.len()),
1759                "probed",
1760                None,
1761            );
1762            for model_id in ids.into_iter().take(200) {
1763                record_provider_probe("meta", &model_id, "model_listed", "true", "listed", None);
1764            }
1765        },
1766        Ok(response) => record_provider_probe(
1767            "meta",
1768            "*",
1769            "models_availability",
1770            "failed",
1771            "failed",
1772            Some(format!("HTTP {}", response.status().as_u16())),
1773        ),
1774        Err(error) => record_provider_probe(
1775            "meta",
1776            "*",
1777            "models_availability",
1778            "failed",
1779            "failed",
1780            Some(error.to_string()),
1781        ),
1782    }
1783}
1784
1785fn record_provider_probe(
1786    provider: &str,
1787    model_id: &str,
1788    key: &str,
1789    value: &str,
1790    confidence: &str,
1791    error: Option<String>,
1792) {
1793    if let Ok(store) = RuntimeStore::open_default() {
1794        let _ = store.provider_probes().upsert(NewProviderProbe {
1795            provider: provider.to_string(),
1796            model_id: model_id.to_string(),
1797            capability_key: key.to_string(),
1798            capability_value: value.to_string(),
1799            confidence: confidence.to_string(),
1800            error,
1801        });
1802    }
1803}
1804
1805fn show_approvals() -> Result<()> {
1806    let approvals = RuntimeClient::auto().list_approvals()?.value;
1807    if approvals.is_empty() {
1808        println!("No pending approvals.");
1809        return Ok(());
1810    }
1811    for approval in approvals {
1812        println!(
1813            "{} [{} -> {}] {}",
1814            approval.id,
1815            approval.risk_classification,
1816            approval.policy_decision,
1817            approval.proposed_action
1818        );
1819        if let Some(args) = approval.args_summary {
1820            println!("    args: {args}");
1821        }
1822        if let Some(checkpoint_id) = approval.checkpoint_id {
1823            println!("    checkpoint: {checkpoint_id}");
1824        }
1825        if approval.pending_action_json.is_some() {
1826            println!("    pending action: recorded");
1827        }
1828    }
1829    Ok(())
1830}
1831
1832fn approve(id: &str) -> Result<()> {
1833    let result = RuntimeClient::auto().approve(id)?;
1834    println!("Approved {id}");
1835    if result.replayed {
1836        println!("{}", result.summary);
1837    }
1838    Ok(())
1839}
1840
1841fn deny(id: &str) -> Result<()> {
1842    let _ = RuntimeClient::auto().deny(id)?;
1843    println!("Denied {id}");
1844    Ok(())
1845}
1846
1847/// `mermaid task <id> --follow`: attach to the daemon's live `RunEvent`
1848/// stream for a task and print it as NDJSON until the terminal `result`.
1849/// Daemon-only — there is no local fallback (the events only exist while the
1850/// daemon executes the run).
1851fn follow_task(id: &str) -> Result<()> {
1852    let lines = mermaid_runtime::subscribe_daemon_lines(
1853        crate::runtime_client::DaemonRequest::SubscribeTask {
1854            task_id: id.to_string(),
1855        }
1856        .to_wire(),
1857    )
1858    .context("mermaid task --follow needs a running daemon (`mermaid daemon start`)")?;
1859    let mut saw_any = false;
1860    for line in lines {
1861        let line = line?;
1862        if line.trim().is_empty() {
1863            continue;
1864        }
1865        // The ack line carries ok:false on unknown task / auth failure.
1866        if !saw_any {
1867            saw_any = true;
1868            let ack: serde_json::Value =
1869                serde_json::from_str(line.trim()).context("daemon returned invalid JSON")?;
1870            if ack.get("ok").and_then(|v| v.as_bool()) == Some(false) {
1871                anyhow::bail!(
1872                    "{}",
1873                    ack.get("error")
1874                        .and_then(|v| v.as_str())
1875                        .unwrap_or("subscribe failed")
1876                );
1877            }
1878            println!("{}", line.trim());
1879            continue;
1880        }
1881        println!("{}", line.trim());
1882        if serde_json::from_str::<serde_json::Value>(line.trim())
1883            .ok()
1884            .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_string))
1885            .as_deref()
1886            == Some("result")
1887        {
1888            return Ok(());
1889        }
1890    }
1891    if saw_any {
1892        anyhow::bail!("stream ended without a result (daemon restarted mid-run?)");
1893    }
1894    anyhow::bail!("daemon closed the connection without responding");
1895}
1896
1897/// Put a prompt into a task that is already running.
1898///
1899/// Daemon-only by nature: it holds the live mailboxes, and a mailbox is the
1900/// only way into a reducer that is mid-turn. A finished task has no reducer,
1901/// so the error says what to reach for instead.
1902fn send_to_task(id: &str, text: &str) -> Result<()> {
1903    if text.trim().is_empty() {
1904        anyhow::bail!("nothing to send: --send needs a prompt");
1905    }
1906    mermaid_runtime::request_daemon_json(
1907        crate::runtime_client::DaemonRequest::SendToTask {
1908            id: id.to_string(),
1909            text: text.to_string(),
1910        }
1911        .to_wire(),
1912    )?;
1913    println!("Sent to {id}; it answers after the turn it is on.");
1914    Ok(())
1915}
1916
1917/// Cancel a daemon task. Cancelling a *running* task must reach the daemon —
1918/// it holds the in-flight cancellation tokens. A *queued* task can be
1919/// cancelled straight in the local store when no daemon is reachable, since
1920/// queued tasks only ever execute via the daemon's claim query.
1921fn cancel_task(id: &str) -> Result<()> {
1922    match mermaid_runtime::request_daemon_json(
1923        crate::runtime_client::DaemonRequest::CancelTask { id: id.to_string() }.to_wire(),
1924    ) {
1925        Ok(response) => {
1926            if response.get("cancelling").and_then(|v| v.as_bool()) == Some(true) {
1927                println!("Cancelling {id} (running; the agent unwinds gracefully)");
1928            } else {
1929                println!("Cancelled {id}");
1930            }
1931            Ok(())
1932        },
1933        Err(daemon_err) => {
1934            let store = mermaid_runtime::RuntimeStore::open_default()?;
1935            match store.tasks().get(id)? {
1936                Some(task) if task.status == mermaid_runtime::TaskStatus::Queued => {
1937                    store.tasks().update_status(
1938                        id,
1939                        mermaid_runtime::TaskStatus::Cancelled,
1940                        Some("cancelled before start"),
1941                    )?;
1942                    println!("Cancelled {id} (was queued; daemon unreachable)");
1943                    Ok(())
1944                },
1945                Some(task) => anyhow::bail!(
1946                    "task {} is {} and the daemon request failed: {}",
1947                    id,
1948                    task.status,
1949                    daemon_err
1950                ),
1951                None => anyhow::bail!("task not found: {id}"),
1952            }
1953        },
1954    }
1955}
1956
1957fn show_tool_runs(limit: usize) -> Result<()> {
1958    let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1959    runs.truncate(limit);
1960    if runs.is_empty() {
1961        println!("No tool runs recorded yet.");
1962        return Ok(());
1963    }
1964    for run in runs {
1965        println!(
1966            "{} [{}] {} started {}",
1967            run.id, run.status, run.tool_name, run.started_at
1968        );
1969        if let Some(turn_id) = run.turn_id {
1970            println!("    turn: {turn_id}");
1971        }
1972        if let Some(call_id) = run.call_id {
1973            println!("    call: {call_id}");
1974        }
1975        if let Some(finished_at) = run.finished_at {
1976            println!("    finished: {finished_at}");
1977        }
1978    }
1979    Ok(())
1980}
1981
1982fn show_checkpoints(limit: usize) -> Result<()> {
1983    let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1984    checkpoints.truncate(limit);
1985    if checkpoints.is_empty() {
1986        println!("No checkpoints recorded yet.");
1987        return Ok(());
1988    }
1989    for checkpoint in checkpoints {
1990        println!(
1991            "{}  {}  {}",
1992            checkpoint.id, checkpoint.created_at, checkpoint.project_path
1993        );
1994        println!("    snapshot: {}", checkpoint.snapshot_path);
1995        println!("    files: {}", checkpoint.changed_files_json);
1996        if let Some(approval_id) = checkpoint.approval_id {
1997            println!("    approval: {approval_id}");
1998        }
1999    }
2000    Ok(())
2001}
2002
2003fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
2004    // Restoring overwrites the working tree from the checkpoint. Confirm first
2005    // (default NO); `--force` is the scripted-use bypass, and a non-interactive
2006    // session without it refuses rather than clobbering the tree unprompted (#113).
2007    if !mermaid_model::utils::confirm_or_refuse(
2008        &format!("Restore checkpoint {id}? This overwrites the current working tree."),
2009        force,
2010    )? {
2011        println!("Restore cancelled.");
2012        return Ok(());
2013    }
2014    let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
2015    println!("Restored {} ({} files)", manifest.id, manifest.files.len());
2016    if let Some(repo) = manifest.shadow_git_repo {
2017        println!("Shadow repo: {repo}");
2018    }
2019    if let Some(commit) = manifest.shadow_git_commit {
2020        println!("Shadow commit: {commit}");
2021    }
2022    if let Some(action) = manifest.pending_action {
2023        println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
2024    }
2025    Ok(())
2026}
2027
2028fn handle_plugin(command: &PluginCommand) -> Result<()> {
2029    match command {
2030        PluginCommand::Install { path } => {
2031            let preview = mermaid_runtime::plugin_capability_preview(path)?;
2032            print_plugin_capability_preview(&preview);
2033            let record = mermaid_runtime::install_plugin_from_path(path)?;
2034            println!(
2035                "Installed plugin {} ({}) — DISABLED.",
2036                record.name, record.id
2037            );
2038            println!(
2039                "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
2040                record.id
2041            );
2042        },
2043        PluginCommand::List => {
2044            let plugins = RuntimeClient::auto().list_plugins()?.value;
2045            if plugins.is_empty() {
2046                println!("No plugins installed.");
2047            } else {
2048                for plugin in plugins {
2049                    println!(
2050                        "{} [{}] {} ({})",
2051                        plugin.id,
2052                        if plugin.enabled {
2053                            "enabled"
2054                        } else {
2055                            "disabled"
2056                        },
2057                        plugin.name,
2058                        plugin.source
2059                    );
2060                }
2061            }
2062        },
2063        PluginCommand::Enable { id } => {
2064            // Surface what the plugin declares before activating its native code.
2065            let client = RuntimeClient::auto();
2066            if let Some(plugin) = client
2067                .list_plugins()?
2068                .value
2069                .into_iter()
2070                .find(|p| p.id == *id || p.name == *id)
2071                && let Ok(preview) =
2072                    mermaid_runtime::plugin_capability_preview(Path::new(&plugin.source))
2073            {
2074                print_plugin_capability_preview(&preview);
2075            }
2076            client.set_plugin_enabled(id, true)?;
2077            println!("Enabled plugin {id} — its hooks will now run.");
2078        },
2079        PluginCommand::Disable { id } => {
2080            RuntimeClient::auto().set_plugin_enabled(id, false)?;
2081            println!("Disabled plugin {id}");
2082        },
2083        PluginCommand::Audit { path } => {
2084            let manifest_path = if path.is_dir() {
2085                path.join("plugin.toml")
2086            } else {
2087                path.clone()
2088            };
2089            let raw = std::fs::read_to_string(&manifest_path)?;
2090            let manifest: mermaid_runtime::PluginManifest = toml::from_str(&raw)?;
2091            let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
2092            mermaid_runtime::validate_plugin_manifest(&manifest, root)?;
2093            let preview = mermaid_runtime::plugin_capability_preview(path)?;
2094            println!("Plugin manifest is valid: {}", manifest.name);
2095            print_plugin_capability_preview(&preview);
2096        },
2097    }
2098    Ok(())
2099}
2100
2101fn print_plugin_capability_preview(preview: &mermaid_runtime::PluginCapabilityPreview) {
2102    println!(
2103        "ModelCapabilities declared by plugin {} (advisory, not sandbox-enforced):",
2104        preview.name
2105    );
2106    if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
2107        println!("  capabilities: (none declared)");
2108    } else {
2109        if !preview.declared_capabilities.is_empty() {
2110            println!("  declared: {}", preview.declared_capabilities.join(", "));
2111        }
2112        if let Some(value) = &preview.capabilities_toml {
2113            println!(
2114                "  capabilities.toml: {}",
2115                serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
2116            );
2117        }
2118    }
2119    if !preview.hooks.is_empty() {
2120        println!("  hooks: {}", preview.hooks.join(", "));
2121    }
2122    if !preview.mcp.is_empty() {
2123        println!("  mcp: {}", preview.mcp.join(", "));
2124    }
2125    if !preview.bin.is_empty() {
2126        println!("  bin: {}", preview.bin.join(", "));
2127    }
2128}
2129
2130fn handle_pair(command: &PairCommand) -> Result<()> {
2131    let store = RuntimeStore::open_default()?;
2132    match command {
2133        PairCommand::Create { label, ttl_days } => {
2134            let ttl = ttl_days.unwrap_or(mermaid_runtime::DEFAULT_PAIRING_TTL_DAYS);
2135            let expires_at = mermaid_runtime::pairing_expiry_from_now(ttl);
2136            let (token, hash) = mermaid_runtime::generate_pairing_token()?;
2137            let record =
2138                store
2139                    .pairing_tokens()
2140                    .create(&hash, label.as_deref(), expires_at.as_deref())?;
2141            println!("Pairing token id: {}", record.id);
2142            println!("Pairing token: {token}");
2143            println!(
2144                "Expires: {}",
2145                record.expires_at.as_deref().unwrap_or("never")
2146            );
2147            println!(
2148                "Use with daemon JSON by setting {}.",
2149                mermaid_runtime::daemon::DAEMON_TOKEN_ENV
2150            );
2151            println!("Store this now; Mermaid will not print it again.");
2152        },
2153        PairCommand::List => {
2154            let tokens = store.pairing_tokens().list()?;
2155            if tokens.is_empty() {
2156                println!("No pairing tokens.");
2157            } else {
2158                // Never print token_hash — only the non-secret metadata.
2159                for t in tokens {
2160                    println!(
2161                        "{} [{}] label={} created={} expires={} last_used={}",
2162                        t.id,
2163                        if t.enabled { "active" } else { "revoked" },
2164                        t.label.as_deref().unwrap_or("-"),
2165                        t.created_at,
2166                        t.expires_at.as_deref().unwrap_or("never"),
2167                        t.last_used_at.as_deref().unwrap_or("never"),
2168                    );
2169                }
2170            }
2171        },
2172        PairCommand::Revoke { id } => {
2173            if store.pairing_tokens().revoke(id)? {
2174                println!("Revoked pairing token {id}");
2175            } else {
2176                println!("No active pairing token with id {id}");
2177            }
2178        },
2179    }
2180    Ok(())
2181}
2182
2183/// Strip terminal control sequences from untrusted subprocess output before
2184/// printing it to a cooked terminal. Managed-process logs / reports / port
2185/// listings are attacker-influenceable (a dev server can emit anything), so a
2186/// raw `print!` would let escape sequences execute — OSC-52 clipboard writes,
2187/// window-title/prompt rewrites, cursor moves used for spoofing. Keeps `\n` and
2188/// `\t`; drops every ESC-introduced sequence (CSI / OSC / DCS / PM / APC / SOS
2189/// and simple two-/three-byte forms) and all other C0/C1 control characters
2190/// (incl. `\r` and DEL). See F49.
2191fn sanitize_terminal_text(input: &str) -> String {
2192    let mut out = String::with_capacity(input.len());
2193    let mut chars = input.chars();
2194    while let Some(c) = chars.next() {
2195        match c {
2196            '\n' | '\t' => out.push(c),
2197            '\u{1b}' => match chars.next() {
2198                // CSI: ESC '[' params/intermediates then a final byte
2199                // (0x40-0x7e), which is also dropped.
2200                Some('[') => {
2201                    for p in chars.by_ref() {
2202                        if ('@'..='~').contains(&p) {
2203                            break;
2204                        }
2205                    }
2206                },
2207                // String sequences (OSC ']', DCS 'P', PM '^', APC '_', SOS 'X'):
2208                // arbitrary body terminated by BEL or ST (ESC '\').
2209                Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
2210                    while let Some(p) = chars.next() {
2211                        if p == '\u{07}' {
2212                            break;
2213                        }
2214                        if p == '\u{1b}' {
2215                            // ESC here starts ST (ESC '\'); drop the trailing '\'.
2216                            let mut peek = chars.clone();
2217                            if peek.next() == Some('\\') {
2218                                chars = peek;
2219                            }
2220                            break;
2221                        }
2222                    }
2223                },
2224                // Other ESC forms: optional intermediates (0x20-0x2f) then a
2225                // final byte; drop them all.
2226                Some(mut b) => {
2227                    while ('\u{20}'..='\u{2f}').contains(&b) {
2228                        match chars.next() {
2229                            Some(next) => b = next,
2230                            None => break,
2231                        }
2232                    }
2233                },
2234                None => {},
2235            },
2236            // Drop DEL, all other C0 controls (incl. `\r`), and C1 controls.
2237            c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
2238            c => out.push(c),
2239        }
2240    }
2241    out
2242}
2243
2244fn show_logs(id: &str) -> Result<()> {
2245    let content = RuntimeClient::auto().process_log(id, None)?.content;
2246    print!("{}", sanitize_terminal_text(&content));
2247    Ok(())
2248}
2249
2250fn stop_process(id: &str) -> Result<()> {
2251    let process = RuntimeClient::auto().stop_process(id)?.item;
2252    println!("Stopped process {} (pid {})", id, process.pid);
2253    Ok(())
2254}
2255
2256fn restart_process(id: &str) -> Result<()> {
2257    let process = RuntimeClient::auto().restart_process(id)?.item;
2258    println!("Restarted process {} (pid {})", id, process.pid);
2259    Ok(())
2260}
2261
2262fn open_target(target: &str) -> Result<()> {
2263    if RuntimeClient::auto().open_process(target).is_err() {
2264        mermaid_model::utils::open_file(target);
2265    }
2266    Ok(())
2267}
2268
2269fn show_ports() -> Result<()> {
2270    let ports = RuntimeClient::auto().ports()?.ports;
2271    print!("{}", sanitize_terminal_text(&ports));
2272    Ok(())
2273}
2274
2275/// List available models across all backends (honors user config).
2276/// Read-only: a dead local server is reported, never resurrected — a
2277/// cloud-model user who stopped Ollama on purpose must be able to
2278/// enumerate without a surprise VRAM grab. A stopped server does not hide
2279/// the installed set: the on-disk manifest store answers for it
2280/// (`FromDisk`), labeled so the user knows the server will start on use.
2281///
2282/// # Errors
2283///
2284/// Only writing to stdout. Every "nothing to list" case — Ollama not
2285/// installed, installed but stopped with an unreadable store, installed with
2286/// no models, no configured remote providers — is `Ok` and prints what it
2287/// found, because a listing verb that exits nonzero because the answer is
2288/// empty is answering a different question.
2289pub async fn list_models(config: &Config) -> Result<()> {
2290    match observe_models(config).await {
2291        LocalModelListing::Unreachable if is_ollama_installed() => {
2292            println!("Ollama is installed but not running, and its model store could not be read.");
2293            println!("(It starts automatically when you use an Ollama model.)");
2294        },
2295        LocalModelListing::Unreachable => println!("Ollama is not installed; no local models."),
2296        LocalModelListing::Live(models) if models.is_empty() => {
2297            println!("No Ollama models installed locally.");
2298        },
2299        LocalModelListing::Live(models) => {
2300            println!("Ollama models (local/cloud):");
2301            for name in &models {
2302                println!("  - ollama/{name}");
2303            }
2304        },
2305        LocalModelListing::FromDisk(models) => {
2306            println!(
2307                "Ollama models (installed; server not running — starts automatically on use):"
2308            );
2309            for name in &models {
2310                println!("  - ollama/{name}");
2311            }
2312        },
2313    }
2314
2315    println!("\nConfigured remote providers:");
2316    let catalogs = crate::providers::discovery::provider_catalogs(config).await;
2317    if catalogs.is_empty() {
2318        println!("  (none — set a provider API key env var to enable)");
2319    }
2320    for catalog in &catalogs {
2321        println!(
2322            "  - {} ({}) {}",
2323            catalog.provider.name,
2324            catalog.provider.source_label(),
2325            catalog.provider.endpoint
2326        );
2327        match &catalog.models {
2328            // The key resolves but the catalog didn't answer. Say so instead of
2329            // printing an empty list that reads as "this provider has nothing".
2330            None => {
2331                println!("      (model list unavailable — the provider's /models did not answer)")
2332            },
2333            Some(models) if models.is_empty() => println!("      (provider lists no models)"),
2334            Some(models) => {
2335                for id in models {
2336                    println!("      {}/{}", catalog.provider.name, id);
2337                }
2338            },
2339        }
2340    }
2341
2342    // A provider the user started configuring that still cannot be built. It
2343    // belongs on the "what can I use" surface precisely because the answer is
2344    // "not this, and here is the one thing missing".
2345    let problems = crate::providers::provider_problems(config);
2346    if !problems.is_empty() {
2347        println!("\nConfigured but not usable:");
2348        for problem in &problems {
2349            println!("  - {}: {}", problem.name, problem.reason);
2350        }
2351    }
2352
2353    println!("\nSwitch models in-session with /model <name>.");
2354    Ok(())
2355}
2356
2357const RELEASE_LATEST_API: &str =
2358    "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
2359const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
2360const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
2361
2362/// `mermaid update` — check GitHub Releases for a newer version and, unless
2363/// `--check`, re-run the platform install script to replace this binary in
2364/// place. The install script is the single source of truth for the
2365/// download + checksum + replace (incl. the running-exe rename on Windows), so
2366/// there's no archive-handling logic (or extra dependency) here.
2367async fn run_update(check: bool, force: bool) -> Result<()> {
2368    let current = env!("CARGO_PKG_VERSION");
2369    println!("Installed: v{current}");
2370
2371    let client = reqwest::Client::builder()
2372        .timeout(std::time::Duration::from_secs(15))
2373        .build()?;
2374    let resp = client
2375        .get(RELEASE_LATEST_API)
2376        .header("User-Agent", "mermaid-cli")
2377        .header("Accept", "application/vnd.github+json")
2378        .send()
2379        .await
2380        .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
2381    if !resp.status().is_success() {
2382        bail!("GitHub Releases API returned HTTP {}", resp.status());
2383    }
2384    let release: serde_json::Value = resp.json().await?;
2385    let tag = release
2386        .get("tag_name")
2387        .and_then(|v| v.as_str())
2388        .ok_or_else(|| anyhow!("release response had no tag_name"))?;
2389    println!("Latest:    {tag}");
2390
2391    let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
2392    if check {
2393        if up_to_date {
2394            println!("You're on the latest version.");
2395        } else {
2396            println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
2397        }
2398        return Ok(());
2399    }
2400    if up_to_date && !force {
2401        println!("Already up to date.");
2402        return Ok(());
2403    }
2404
2405    // Replace the binary in the directory it's running from, in place.
2406    let exe =
2407        std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
2408    let install_dir = exe
2409        .parent()
2410        .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
2411
2412    // Confirm before fetching + running the install script — it executes
2413    // downloaded shell/PowerShell and replaces the running binary. `--force` is
2414    // the scripted-use bypass; a non-interactive session without it refuses
2415    // rather than running fetched code unprompted (#110).
2416    let script_url = if cfg!(target_os = "windows") {
2417        INSTALL_PS1_URL
2418    } else {
2419        INSTALL_SH_URL
2420    };
2421    if !mermaid_model::utils::confirm_or_refuse(
2422        &format!(
2423            "About to download and run {script_url} to replace {}.",
2424            install_dir.display()
2425        ),
2426        force,
2427    )? {
2428        println!("Update cancelled.");
2429        return Ok(());
2430    }
2431
2432    println!("Updating {} …", install_dir.display());
2433    run_install_script(&client, install_dir).await?;
2434    println!("Updated. New version takes effect on the next run.");
2435    Ok(())
2436}
2437
2438/// Fetch the platform install script from the Pages site and run it, pointed at
2439/// `install_dir` so it updates in place without touching PATH.
2440async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
2441    let windows = cfg!(target_os = "windows");
2442    let url = if windows {
2443        INSTALL_PS1_URL
2444    } else {
2445        INSTALL_SH_URL
2446    };
2447    let script = client
2448        .get(url)
2449        .header("User-Agent", "mermaid-cli")
2450        .send()
2451        .await
2452        .map_err(|e| anyhow!("could not fetch install script: {e}"))?
2453        .error_for_status()?
2454        .text()
2455        .await?;
2456
2457    let ext = if windows { "ps1" } else { "sh" };
2458    // Stage the fetched script in the per-user 0700 private temp dir, created
2459    // exclusively (O_EXCL → never follows/opens a pre-planted symlink) so a local
2460    // attacker can neither redirect the write nor swap the file between write and
2461    // exec (#F50). The previous world-readable, predictable
2462    // `temp_dir()/mermaid-update-<pid>.<ext>` allowed both a symlink redirect and
2463    // a write→exec TOCTOU.
2464    let dir = mermaid_model::utils::private_temp_dir()
2465        .map_err(|e| anyhow!("could not create private temp dir for install script: {e}"))?;
2466    let nanos = std::time::SystemTime::now()
2467        .duration_since(std::time::UNIX_EPOCH)
2468        .map(|d| d.as_nanos())
2469        .unwrap_or_default();
2470    let script_path = dir.join(format!(
2471        "mermaid-update-{}-{nanos}.{ext}",
2472        std::process::id()
2473    ));
2474    stage_install_script(&script_path, script.as_bytes())
2475        .map_err(|e| anyhow!("could not stage install script: {e}"))?;
2476
2477    let mut cmd = if windows {
2478        let mut c = tokio::process::Command::new("powershell");
2479        c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
2480        c.arg(&script_path);
2481        c
2482    } else {
2483        let mut c = tokio::process::Command::new("sh");
2484        c.arg(&script_path);
2485        c
2486    };
2487    cmd.env("MERMAID_INSTALL_DIR", install_dir)
2488        .env("MERMAID_NO_MODIFY_PATH", "1");
2489
2490    let status = cmd
2491        .status()
2492        .await
2493        .map_err(|e| anyhow!("could not run install script: {e}"))?;
2494    let _ = std::fs::remove_file(&script_path);
2495    if !status.success() {
2496        bail!("install script exited with {:?}", status.code());
2497    }
2498    Ok(())
2499}
2500
2501/// Write the fetched install script to `path`, creating it **exclusively** so a
2502/// symlink pre-planted at the path is refused (`O_EXCL` never follows) and the
2503/// staged code is owner-only (`0600` file inside the `0700` private dir). This
2504/// closes the symlink-redirect and write→exec TOCTOU that the old predictable,
2505/// world-readable temp path left open (#F50).
2506fn stage_install_script(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2507    use std::io::Write;
2508    #[cfg(unix)]
2509    let mut file = {
2510        use std::os::unix::fs::OpenOptionsExt;
2511        std::fs::OpenOptions::new()
2512            .write(true)
2513            .create_new(true)
2514            .mode(0o600)
2515            .open(path)?
2516    };
2517    #[cfg(not(unix))]
2518    let mut file = std::fs::OpenOptions::new()
2519        .write(true)
2520        .create_new(true)
2521        .open(path)?;
2522    file.write_all(bytes)
2523}
2524
2525/// Parse a `[v]MAJOR.MINOR.PATCH[-pre][+build]` string into a comparable tuple.
2526fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
2527    let core = s.trim().trim_start_matches('v');
2528    let core = core.split(['-', '+']).next().unwrap_or(core);
2529    let mut parts = core.split('.');
2530    let major = parts.next()?.parse().ok()?;
2531    let minor = parts.next().unwrap_or("0").parse().ok()?;
2532    let patch = parts.next().unwrap_or("0").parse().ok()?;
2533    Some((major, minor, patch))
2534}
2535
2536/// True iff `current` is at least `latest` (no update needed). Unparseable
2537/// versions fall back to string equality, so we never falsely report
2538/// up-to-date on garbage — at worst we re-run the (idempotent) installer.
2539fn version_at_least(current: &str, latest: &str) -> bool {
2540    match (parse_semver(current), parse_semver(latest)) {
2541        (Some(c), Some(l)) => c >= l,
2542        _ => current == latest,
2543    }
2544}
2545
2546/// Show configured MCP servers
2547fn show_mcp_servers() {
2548    let config = load_config_or_warn();
2549
2550    if config.mcp_servers.is_empty() {
2551        println!("No MCP servers configured.\n");
2552        println!("Add one with: mermaid add <name>");
2553        println!("Examples:");
2554        println!("  mermaid add context7     # Library documentation");
2555        println!("  mermaid add playwright   # Browser automation");
2556        println!("  mermaid add memory       # Persistent knowledge graph");
2557        return;
2558    }
2559
2560    println!("Configured MCP servers:\n");
2561    for (name, server_cfg) in &config.mcp_servers {
2562        // Remote servers show their endpoint; stdio servers their package.
2563        let package: &str = match &server_cfg.url {
2564            Some(url) => url,
2565            None => server_cfg
2566                .args
2567                .iter()
2568                .find(|a| !a.starts_with('-'))
2569                .map(String::as_str)
2570                .unwrap_or(server_cfg.command.as_str()),
2571        };
2572        let env_keys: Vec<&String> = server_cfg.env.keys().collect();
2573        let env_display = if env_keys.is_empty() {
2574            String::new()
2575        } else {
2576            format!(
2577                " (env: {})",
2578                env_keys
2579                    .iter()
2580                    .map(|k| k.as_str())
2581                    .collect::<Vec<_>>()
2582                    .join(", ")
2583            )
2584        };
2585        println!("  {name} — {package}{env_display}");
2586    }
2587    println!("\nManage with: mermaid add <name> / mermaid remove <name>");
2588}
2589
2590/// Show status of all dependencies
2591async fn show_status(config: &Config) -> Result<()> {
2592    println!("Mermaid Status:");
2593    println!();
2594
2595    // Remote providers: one block, listing exactly what `ProviderFactory`
2596    // could build right now — name, where the key came from, and the endpoint
2597    // requests would go to. This used to be printed twice, by two walks that
2598    // disagreed with each other and with the factory; see `providers::discovery`.
2599    let available = configured_remote_providers(config);
2600    if available.is_empty() {
2601        println!(
2602            "  [WARNING] Remote providers: none (no API keys in env or keyring; `mermaid login <provider>`)"
2603        );
2604    } else {
2605        println!("  [OK] Remote providers: {} configured", available.len());
2606        for provider in &available {
2607            println!(
2608                "      - {} ({}) {}",
2609                provider.name,
2610                provider.source_label(),
2611                provider.endpoint
2612            );
2613        }
2614    }
2615    // Half-configured providers are the ones worth a warning: the user set
2616    // something up and it still cannot be used. The reason is the factory's own
2617    // error, so it says exactly what a real request would have said.
2618    let problems = crate::providers::provider_problems(config);
2619    if !problems.is_empty() {
2620        println!(
2621            "  [WARNING] Providers configured but not usable: {}",
2622            problems.len()
2623        );
2624        for problem in &problems {
2625            println!("      - {}: {}", problem.name, problem.reason);
2626        }
2627    }
2628
2629    print_ollama_status(config, !available.is_empty()).await;
2630
2631    // Check configuration (uses platform-specific path via ProjectDirs)
2632    if let Ok(config_dir) = get_config_dir() {
2633        let config_path = config_dir.join("config.toml");
2634        if config_path.exists() {
2635            println!("  [OK] Configuration: {}", config_path.display());
2636        } else {
2637            println!("  [WARNING] Configuration: Not found (using defaults)");
2638        }
2639    }
2640
2641    // MCP Servers
2642    if config.mcp_servers.is_empty() {
2643        println!("  [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
2644    } else {
2645        println!(
2646            "  [OK] MCP Servers: {} configured",
2647            config.mcp_servers.len()
2648        );
2649        for (name, server_cfg) in &config.mcp_servers {
2650            let target: &str = match &server_cfg.url {
2651                Some(url) => url,
2652                None => server_cfg
2653                    .args
2654                    .get(1)
2655                    .map(String::as_str)
2656                    .unwrap_or(server_cfg.command.as_str()),
2657            };
2658            println!("      - {name} ({target})");
2659        }
2660    }
2661
2662    print_project_instructions_status();
2663
2664    // Environment variables (for API providers)
2665    println!("\n  Environment:");
2666    if std::env::var("OLLAMA_API_KEY").is_ok() {
2667        println!("    - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2668    }
2669
2670    println!();
2671    Ok(())
2672}
2673
2674/// The Ollama line(s) of `mermaid status`. `has_remote` softens a missing
2675/// install to INFO: remote providers cover every model this machine needs.
2676async fn print_ollama_status(config: &Config, has_remote: bool) {
2677    // Check Ollama (via HTTP, so remote deployments are honored).
2678    // Diagnostics observe, they don't heal: the shared observe path keeps
2679    // autostart off, otherwise a status check would start the server and then
2680    // report "Running" — never able to observe the dead state it exists to
2681    // surface. A dead server with a readable store still lists (`FromDisk`).
2682    if is_ollama_installed() {
2683        let preview = |models: &[String]| {
2684            for model in models.iter().take(3) {
2685                println!("      - {model}");
2686            }
2687            if models.len() > 3 {
2688                println!("      ... and {} more", models.len() - 3);
2689            }
2690        };
2691        match observe_models(config).await {
2692            LocalModelListing::Unreachable => println!(
2693                "  [WARNING] Ollama: Installed but not running (started automatically \
2694                 when an Ollama model is used)"
2695            ),
2696            LocalModelListing::Live(models) if models.is_empty() => {
2697                println!("  [WARNING] Ollama: Running (no models installed)");
2698            },
2699            LocalModelListing::Live(models) => {
2700                println!("  [OK] Ollama: Running ({} models installed)", models.len());
2701                preview(&models);
2702            },
2703            LocalModelListing::FromDisk(models) => {
2704                println!(
2705                    "  [OK] Ollama: Not running ({} models installed on disk; starts \
2706                     automatically when used)",
2707                    models.len()
2708                );
2709                preview(&models);
2710            },
2711        }
2712    } else if !has_remote {
2713        println!("  [WARNING] Ollama: Not installed (and no remote provider configured)");
2714    } else {
2715        // Not a failure: the configured remote providers cover every model
2716        // this machine needs. Ollama is only required for local models.
2717        println!("  [INFO] Ollama: Not installed (only needed for local models)");
2718    }
2719}
2720
2721/// Project instructions (Step 5h). Walks UP from cwd to git root or
2722/// $HOME to find the nearest supported instruction files.
2723fn print_project_instructions_status() {
2724    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2725    let paths = crate::app::instructions::find_instruction_files(&cwd);
2726    if paths.is_empty() {
2727        println!("  [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
2728    } else {
2729        match crate::app::instructions::load_from_paths(&paths) {
2730            Some(loaded) => {
2731                let files = loaded
2732                    .sources
2733                    .iter()
2734                    .map(|source| {
2735                        source
2736                            .path
2737                            .file_name()
2738                            .and_then(|name| name.to_str())
2739                            .unwrap_or("instructions")
2740                    })
2741                    .collect::<Vec<_>>()
2742                    .join(", ");
2743                println!(
2744                    "  [OK] Project instructions: {} at {} ({} bytes{})",
2745                    files,
2746                    loaded.path.display(),
2747                    loaded.byte_len,
2748                    if loaded.truncated { ", truncated" } else { "" }
2749                );
2750            },
2751            None => {
2752                println!(
2753                    "  [WARNING] Project instructions: found but unreadable ({})",
2754                    paths
2755                        .iter()
2756                        .map(|path| path.display().to_string())
2757                        .collect::<Vec<_>>()
2758                        .join(", ")
2759                );
2760            },
2761        }
2762    }
2763}
2764
2765#[cfg(test)]
2766mod tests {
2767    use super::*;
2768
2769    #[test]
2770    fn session_log_drift_reports_agreement_and_catches_a_diverged_checkpoint() {
2771        let root = unique_temp_dir("mermaid-doctor-drift");
2772        let _ = std::fs::remove_dir_all(&root);
2773        std::fs::create_dir_all(&root).expect("project dir");
2774
2775        // No sessions yet: nothing to disagree about.
2776        let empty = session_log_drift(&root);
2777        assert_eq!(empty.status, "ok", "{}", empty.message);
2778
2779        // A real session, saved the way the persistence chain does.
2780        let manager = ConversationManager::new(&root).expect("manager");
2781        let mut state = mermaid_domain::State::new(
2782            Config::default(),
2783            root.clone(),
2784            "ollama/test".to_string(),
2785            chrono::Local::now(),
2786            std::env::temp_dir(),
2787        );
2788        state
2789            .session
2790            .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
2791        let snapshot = state.session.snapshot_conversation();
2792        let events = state.session.drain_events(&snapshot);
2793        manager
2794            .append_session_events(&snapshot, &events)
2795            .expect("append");
2796        manager.save_conversation(&snapshot).expect("checkpoint");
2797
2798        let agreed = session_log_drift(&root);
2799        assert_eq!(agreed.status, "ok", "{}", agreed.message);
2800        assert!(agreed.message.contains('1'), "{}", agreed.message);
2801
2802        // Now make the checkpoint claim a message the log never had -- the
2803        // shape a missed event emission would leave behind.
2804        let path = manager
2805            .conversations_dir()
2806            .join(format!("{}.json", snapshot.id));
2807        let mut value: serde_json::Value =
2808            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2809        let planted = serde_json::to_value(mermaid_model::models::ChatMessage::user(
2810            "never reached the log",
2811        ))
2812        .unwrap();
2813        value
2814            .get_mut("messages")
2815            .and_then(serde_json::Value::as_array_mut)
2816            .expect("messages array")
2817            .push(planted);
2818        std::fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
2819
2820        let drifted = session_log_drift(&root);
2821        assert_eq!(drifted.status, "warning", "{}", drifted.message);
2822        assert!(
2823            drifted.message.contains(&snapshot.id),
2824            "the warning must name the session: {}",
2825            drifted.message
2826        );
2827        let _ = std::fs::remove_dir_all(&root);
2828    }
2829
2830    #[test]
2831    fn doctor_uses_resolved_keyless_web_capabilities() {
2832        let config = Config {
2833            web: mermaid_domain::WebConfig {
2834                fetch_backend: mermaid_domain::FetchBackend::Native,
2835                search_backend: mermaid_domain::SearchBackend::Searxng,
2836                searxng_url: "http://127.0.0.1:8080".to_string(),
2837                ..mermaid_domain::WebConfig::default()
2838            },
2839            ..Config::default()
2840        };
2841        let (tools, next_steps) = web_doctor_entries(&config);
2842        assert!(
2843            tools
2844                .iter()
2845                .any(|entry| entry.contains("web_fetch (native"))
2846        );
2847        assert!(
2848            tools
2849                .iter()
2850                .any(|entry| entry.contains("web_search (searxng"))
2851        );
2852        assert!(next_steps.is_empty(), "unexpected warnings: {next_steps:?}");
2853        assert!(
2854            tools.iter().all(|entry| !entry.contains("container")),
2855            "doctor must describe the selected capability, not stale container setup"
2856        );
2857    }
2858
2859    #[test]
2860    fn doctor_reports_global_network_deny_instead_of_advertising_web() {
2861        let mut config = Config::default();
2862        config.web.search_backend = mermaid_domain::SearchBackend::Searxng;
2863        config.web.searxng_url = "http://127.0.0.1:8080".to_string();
2864        config.safety.network = mermaid_domain::NetworkPolicy::Deny;
2865
2866        let (tools, next_steps) = web_doctor_entries(&config);
2867        assert!(
2868            tools
2869                .iter()
2870                .all(|entry| !entry.starts_with("web_fetch") && !entry.starts_with("web_search")),
2871            "network-denied tools were advertised: {tools:?}"
2872        );
2873        for name in ["web_fetch", "web_search"] {
2874            assert!(
2875                next_steps.iter().any(|entry| {
2876                    entry.contains(name) && entry.contains("safety.network = \"deny\"")
2877                }),
2878                "missing network-deny explanation for {name}: {next_steps:?}"
2879            );
2880        }
2881    }
2882
2883    #[test]
2884    fn sanitize_terminal_text_strips_control_sequences() {
2885        // Plain text and the allowed whitespace pass through unchanged.
2886        assert_eq!(
2887            sanitize_terminal_text("hello\tworld\nline two"),
2888            "hello\tworld\nline two"
2889        );
2890        // CSI color sequence is removed, surrounding text kept.
2891        assert_eq!(
2892            sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
2893            "RED text"
2894        );
2895        // OSC-52 clipboard write (BEL-terminated) is removed whole.
2896        assert_eq!(
2897            sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
2898            "beforeafter"
2899        );
2900        // OSC window-title rewrite terminated by ST (ESC '\').
2901        assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
2902        // Charset-designation (ESC '(' 'B') drops its final byte too.
2903        assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
2904        // Bare CR and a C1 control are dropped; \n is preserved.
2905        assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
2906    }
2907
2908    #[test]
2909    fn version_compare_handles_update_logic() {
2910        // Up to date / newer than latest ⇒ no update.
2911        assert!(version_at_least("0.10.2", "0.10.2"));
2912        assert!(version_at_least("0.11.0", "0.10.2"));
2913        assert!(version_at_least("1.0.0", "0.99.99"));
2914        // Older ⇒ update available.
2915        assert!(!version_at_least("0.10.1", "0.10.2"));
2916        assert!(!version_at_least("0.9.0", "0.10.0"));
2917        assert!(!version_at_least("0.10.2", "0.11.0"));
2918        // Pre-release/build suffixes and `v` prefixes are tolerated.
2919        assert!(version_at_least("0.10.2", "v0.10.2"));
2920        assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2921        assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2922        // Garbage never falsely reports up-to-date unless identical.
2923        assert!(!version_at_least("0.10.2", "not-a-version"));
2924    }
2925
2926    #[test]
2927    fn qa_compact_smoke_persists_conversation_and_archive() {
2928        let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2929        std::fs::create_dir_all(&dir).unwrap();
2930
2931        let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2932
2933        assert!(report.ok);
2934        assert!(report.archived_messages > 0);
2935        assert!(report.preserved_messages > 0);
2936        assert!(report.replacement_messages >= 3);
2937        assert!(
2938            std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2939            "conversation path should exist"
2940        );
2941        assert!(
2942            std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2943            "archive path should exist"
2944        );
2945
2946        let _ = std::fs::remove_dir_all(dir);
2947    }
2948
2949    #[test]
2950    fn qa_model_id_falls_back_to_deterministic() {
2951        assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2952    }
2953
2954    fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2955        let nanos = std::time::SystemTime::now()
2956            .duration_since(std::time::UNIX_EPOCH)
2957            .map(|duration| duration.as_nanos())
2958            .unwrap_or_default();
2959        std::env::temp_dir().join(format!("{name}-{nanos}"))
2960    }
2961}