Skip to main content

mermaid_cli/cli/
commands.rs

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