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