Skip to main content

mermaid_cli/cli/
commands.rs

1use anyhow::{Context, Result, anyhow, bail};
2use std::path::Path;
3use std::sync::Arc;
4
5use crate::{
6    app::{Config, get_config_dir, init_config, load_config_or_warn},
7    domain::{
8        ChatRequest, Cmd, CompactionRecord, CompactionResult, CompactionTrigger, Msg, SlashCmd,
9        State, build_replacement_messages, estimate_context_usage_for_request, prepare_compaction,
10        update,
11    },
12    models::{BackendConfig, ChatMessage, Model, PROVIDER_REGISTRY, lookup_provider},
13    ollama::is_installed as is_ollama_installed,
14    runtime::{NewProviderProbe, RuntimeClient, RuntimeStore, TaskRecord},
15    session::ConversationManager,
16    utils::{resolve_api_key, resolve_api_key_with_fallback},
17};
18
19use super::{Commands, GitHost, OutputFormat, PairCommand, PluginCommand, PrCommand, QaCommand};
20
21/// Handle CLI subcommands
22/// Returns Ok(true) if the command was handled and we should exit
23/// Returns Ok(false) if we should continue to the main application
24pub async fn handle_command(
25    command: &Commands,
26    config: &Config,
27    cwd: &Path,
28    cli_model: Option<&str>,
29) -> Result<bool> {
30    match command {
31        Commands::Init => {
32            println!("Initializing Mermaid configuration...");
33            init_config()?;
34            println!("Configuration initialized successfully!");
35            Ok(true)
36        },
37        Commands::List => {
38            list_models(config).await?;
39            Ok(true)
40        },
41        Commands::Models => {
42            show_models(config).await?;
43            Ok(true)
44        },
45        Commands::ModelInfo { model } => {
46            show_model_info(model, config).await?;
47            Ok(true)
48        },
49        Commands::Version => {
50            show_version();
51            Ok(true)
52        },
53        Commands::Update { check, force } => {
54            run_update(*check, *force).await?;
55            Ok(true)
56        },
57        Commands::Status => {
58            show_status(config).await?;
59            Ok(true)
60        },
61        Commands::Doctor { format } => {
62            show_doctor(config, cwd, cli_model, *format).await?;
63            Ok(true)
64        },
65        Commands::SelfTest {
66            format,
67            keep_workspace,
68        } => {
69            run_self_test(config, *format, *keep_workspace)?;
70            Ok(true)
71        },
72        Commands::Tasks { limit } => {
73            show_tasks(*limit)?;
74            Ok(true)
75        },
76        Commands::Task { id } => {
77            show_task(id)?;
78            Ok(true)
79        },
80        Commands::Processes { limit } => {
81            show_processes(*limit)?;
82            Ok(true)
83        },
84        Commands::Logs { id } => {
85            show_logs(id)?;
86            Ok(true)
87        },
88        Commands::Stop { id } => {
89            stop_process(id)?;
90            Ok(true)
91        },
92        Commands::Restart { id } => {
93            restart_process(id)?;
94            Ok(true)
95        },
96        Commands::Open { target } => {
97            open_target(target)?;
98            Ok(true)
99        },
100        Commands::Ports => {
101            show_ports()?;
102            Ok(true)
103        },
104        Commands::Approvals => {
105            show_approvals()?;
106            Ok(true)
107        },
108        Commands::Approve { id } => {
109            approve(id)?;
110            Ok(true)
111        },
112        Commands::Deny { id } => {
113            deny(id)?;
114            Ok(true)
115        },
116        Commands::ToolRuns { limit } => {
117            show_tool_runs(*limit)?;
118            Ok(true)
119        },
120        Commands::Checkpoints { limit } => {
121            show_checkpoints(*limit)?;
122            Ok(true)
123        },
124        Commands::Restore { id, force } => {
125            restore_checkpoint(id, *force)?;
126            Ok(true)
127        },
128        Commands::Plugin { command } => {
129            handle_plugin(command)?;
130            Ok(true)
131        },
132        Commands::Daemon { command } => {
133            super::daemon::handle_daemon_command(command)?;
134            Ok(true)
135        },
136        Commands::Pair { command } => {
137            handle_pair(command)?;
138            Ok(true)
139        },
140        Commands::Qa { command } => {
141            handle_qa(command, config, cwd)?;
142            Ok(true)
143        },
144        Commands::Add { name, yes } => {
145            crate::mcp::add_server(name, *yes).await?;
146            Ok(true)
147        },
148        Commands::Remove { name } => {
149            crate::mcp::remove_server(name).await?;
150            Ok(true)
151        },
152        Commands::Pr { command } => {
153            handle_pr(command)?;
154            Ok(true)
155        },
156        Commands::Mcp => {
157            show_mcp_servers();
158            Ok(true)
159        },
160        Commands::CloudSetup => {
161            // Interactive stdin prompt — runs before the TUI enters
162            // raw mode so rpassword works. The in-TUI slash command
163            // `/cloud-setup` just points users here.
164            let _ = crate::ollama::setup_cloud_interactive();
165            Ok(true)
166        },
167        Commands::Chat => Ok(false),       // Continue to chat interface
168        Commands::Run { .. } => Ok(false), // Handled by main.rs
169    }
170}
171
172fn handle_qa(command: &QaCommand, config: &Config, cwd: &Path) -> Result<()> {
173    match command {
174        QaCommand::CompactSmoke { turns, format } => {
175            let report = match run_qa_compact_smoke(config, cwd, *turns) {
176                Ok(report) => report,
177                Err(err) => QaCompactSmokeReport::failed(cwd, *turns, err.to_string()),
178            };
179            print_qa_compact_report(&report, *format)?;
180            anyhow::ensure!(report.ok, "qa compact smoke failed");
181            Ok(())
182        },
183    }
184}
185
186#[derive(Debug, serde::Serialize)]
187struct DoctorReport {
188    ok: bool,
189    cwd: String,
190    active_model: Option<String>,
191    model_error: Option<String>,
192    model_capabilities: Option<DoctorModelCapabilities>,
193    safety_mode: String,
194    checkpoint_on_mutation: bool,
195    prompt_customized: bool,
196    ollama: DoctorCheck,
197    remote_providers: Vec<String>,
198    project_instructions: DoctorCheck,
199    tools: Vec<String>,
200    runtime: DoctorRuntime,
201    next_steps: Vec<String>,
202}
203
204#[derive(Debug, serde::Serialize)]
205struct DoctorModelCapabilities {
206    provider: String,
207    name: String,
208    supports_tools: bool,
209    supports_vision: bool,
210    reasoning: String,
211    max_context_tokens: Option<usize>,
212}
213
214#[derive(Debug, serde::Serialize)]
215struct DoctorCheck {
216    status: &'static str,
217    message: String,
218}
219
220#[derive(Debug, serde::Serialize)]
221struct DoctorRuntime {
222    daemon: DoctorCheck,
223    local_store: DoctorCheck,
224}
225
226async fn show_doctor(
227    config: &Config,
228    cwd: &Path,
229    cli_model: Option<&str>,
230    format: OutputFormat,
231) -> Result<()> {
232    let active_model_result = crate::app::resolve_model_id(cli_model, config).await;
233    let (active_model, model_error, model_capabilities) = match active_model_result {
234        Ok(model) => {
235            let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(&model);
236            (
237                Some(model),
238                None,
239                Some(DoctorModelCapabilities {
240                    provider: snapshot.provider,
241                    name: snapshot.model,
242                    supports_tools: snapshot.supports_tools,
243                    supports_vision: snapshot.supports_vision,
244                    reasoning: snapshot.reasoning,
245                    max_context_tokens: snapshot.max_context_tokens,
246                }),
247            )
248        },
249        Err(err) => (None, Some(err.to_string()), None),
250    };
251
252    let ollama_models = if is_ollama_installed() {
253        list_ollama_models(config).await
254    } else {
255        Vec::new()
256    };
257    let ollama = if !is_ollama_installed() {
258        DoctorCheck {
259            status: "warning",
260            message: "Ollama is not installed; remote providers can still work if configured."
261                .to_string(),
262        }
263    } else if ollama_models.is_empty() {
264        DoctorCheck {
265            status: "warning",
266            message: "Ollama is installed but no local/cloud models were listed.".to_string(),
267        }
268    } else {
269        DoctorCheck {
270            status: "ok",
271            message: format!("Ollama reachable with {} models.", ollama_models.len()),
272        }
273    };
274
275    let remote_providers = configured_remote_providers(config);
276    let instruction_paths = crate::app::instructions::find_instruction_files(cwd);
277    let project_instructions = if instruction_paths.is_empty() {
278        DoctorCheck {
279            status: "info",
280            message: "No AGENTS.md or MERMAID.md found.".to_string(),
281        }
282    } else if let Some(loaded) = crate::app::instructions::load_from_paths(&instruction_paths) {
283        DoctorCheck {
284            status: "ok",
285            message: format!(
286                "{} bytes loaded from {} source(s){}.",
287                loaded.byte_len,
288                loaded.sources.len(),
289                if loaded.truncated { " (truncated)" } else { "" }
290            ),
291        }
292    } else {
293        DoctorCheck {
294            status: "warning",
295            message: "Instruction files were found but could not be loaded.".to_string(),
296        }
297    };
298
299    let daemon = match RuntimeClient::daemon().health() {
300        Ok(read) => DoctorCheck {
301            status: "ok",
302            message: format!("daemon attached; database {}", read.value.database),
303        },
304        Err(err) => DoctorCheck {
305            status: "info",
306            message: format!("daemon not attached; CLI will use local runtime store ({err})"),
307        },
308    };
309    let local_store = match RuntimeClient::local().health() {
310        Ok(read) => DoctorCheck {
311            status: "ok",
312            message: format!("local runtime store ready at {}", read.value.database),
313        },
314        Err(err) => DoctorCheck {
315            status: "warning",
316            message: format!("local runtime store unavailable: {err}"),
317        },
318    };
319
320    let has_ollama_key = std::env::var("OLLAMA_API_KEY").is_ok();
321    let mut tools = vec![
322        "read/edit/write files".to_string(),
323        "run shell commands".to_string(),
324        "create checkpoints before risky mutations".to_string(),
325    ];
326    // web_fetch: native always works (no key); the Ollama backend needs a key.
327    match config.web.fetch_backend {
328        crate::app::FetchBackend::Native => tools.push("web_fetch (native, no key)".to_string()),
329        crate::app::FetchBackend::Ollama if has_ollama_key => {
330            tools.push("web_fetch (Ollama Cloud)".to_string())
331        },
332        crate::app::FetchBackend::Ollama => {},
333    }
334    // web_search: `auto` resolves to Ollama when a key is present, else a
335    // mermaid-managed local SearXNG; `searxng`/`ollama` force one backend.
336    match config.web.search_backend {
337        crate::app::SearchBackend::Auto if has_ollama_key => {
338            tools.push("web_search (auto: Ollama Cloud)".to_string())
339        },
340        crate::app::SearchBackend::Auto => {
341            tools.push("web_search (auto: managed local SearXNG)".to_string())
342        },
343        crate::app::SearchBackend::Searxng => {
344            tools.push(format!("web_search (SearXNG {})", config.web.searxng_url))
345        },
346        crate::app::SearchBackend::Ollama if has_ollama_key => {
347            tools.push("web_search (Ollama Cloud)".to_string())
348        },
349        crate::app::SearchBackend::Ollama => {},
350    }
351    if !config.mcp_servers.is_empty() {
352        tools.push(format!(
353            "{} configured MCP server(s)",
354            config.mcp_servers.len()
355        ));
356    }
357
358    let mut next_steps = Vec::new();
359    if active_model.is_none() {
360        next_steps.push(
361            "Pick a model with `mermaid --model <provider/model>` or run `mermaid list`."
362                .to_string(),
363        );
364    }
365    if remote_providers.is_empty() && ollama_models.is_empty() {
366        next_steps.push(
367            "Install or start Ollama, pull a model, or set a remote provider API key.".to_string(),
368        );
369    }
370    if instruction_paths.is_empty() {
371        next_steps.push("Optional: add MERMAID.md or AGENTS.md with project-specific run commands and conventions.".to_string());
372    }
373    if matches!(config.web.search_backend, crate::app::SearchBackend::Ollama) && !has_ollama_key {
374        next_steps.push(
375            "Web search needs a backend: set OLLAMA_API_KEY (run `mermaid cloud-setup`) or set `[web] search_backend = \"searxng\"` with a running SearXNG.".to_string(),
376        );
377    }
378    if matches!(config.web.search_backend, crate::app::SearchBackend::Auto)
379        && !has_ollama_key
380        && which::which("podman").is_err()
381        && which::which("docker").is_err()
382    {
383        next_steps.push(
384            "Web search (auto) starts a local SearXNG container, but no podman or docker was found. Install one, or set OLLAMA_API_KEY.".to_string(),
385        );
386    }
387    if next_steps.is_empty() {
388        next_steps.push(
389            "Start Mermaid with `mermaid` or run one prompt with `mermaid run \"...\"`."
390                .to_string(),
391        );
392    }
393
394    let ok = active_model.is_some()
395        && local_store.status != "warning"
396        && (ollama.status == "ok" || !remote_providers.is_empty());
397    let report = DoctorReport {
398        ok,
399        cwd: cwd.display().to_string(),
400        active_model,
401        model_error,
402        model_capabilities,
403        safety_mode: safety_mode_name(config.safety.mode).to_string(),
404        checkpoint_on_mutation: config.safety.checkpoint_on_mutation,
405        prompt_customized: config.prompt.is_customized(),
406        ollama,
407        remote_providers,
408        project_instructions,
409        tools,
410        runtime: DoctorRuntime {
411            daemon,
412            local_store,
413        },
414        next_steps,
415    };
416    print_doctor_report(&report, format)
417}
418
419fn print_doctor_report(report: &DoctorReport, format: OutputFormat) -> Result<()> {
420    match format {
421        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
422        OutputFormat::Markdown => {
423            println!("# Mermaid Doctor\n");
424            print_doctor_text(report);
425        },
426        OutputFormat::Text => print_doctor_text(report),
427    }
428    Ok(())
429}
430
431fn print_doctor_text(report: &DoctorReport) {
432    println!(
433        "Mermaid Doctor: {}",
434        if report.ok {
435            "ready"
436        } else {
437            "needs attention"
438        }
439    );
440    println!("Project: {}", report.cwd);
441    match (&report.active_model, &report.model_error) {
442        (Some(model), _) => println!("  [OK] Active model: {model}"),
443        (None, Some(error)) => println!("  [WARNING] Active model: {error}"),
444        _ => println!("  [WARNING] Active model: unresolved"),
445    }
446    if let Some(caps) = &report.model_capabilities {
447        println!(
448            "       provider={} tools={} vision={} reasoning={} context={}",
449            caps.provider,
450            caps.supports_tools,
451            caps.supports_vision,
452            caps.reasoning,
453            caps.max_context_tokens
454                .map(|n| n.to_string())
455                .unwrap_or_else(|| "unknown".to_string())
456        );
457    }
458    println!(
459        "  [{}] Ollama: {}",
460        label(report.ollama.status),
461        report.ollama.message
462    );
463    println!(
464        "  [INFO] Remote providers: {}",
465        if report.remote_providers.is_empty() {
466            "none configured".to_string()
467        } else {
468            report.remote_providers.join(", ")
469        }
470    );
471    println!(
472        "  [{}] Project instructions: {}",
473        label(report.project_instructions.status),
474        report.project_instructions.message
475    );
476    println!(
477        "  [INFO] Safety: mode={}, checkpoint_on_mutation={}",
478        report.safety_mode, report.checkpoint_on_mutation
479    );
480    println!(
481        "  [INFO] Prompt customization: {}",
482        if report.prompt_customized {
483            "active"
484        } else {
485            "default"
486        }
487    );
488    println!(
489        "  [{}] Runtime daemon: {}",
490        label(report.runtime.daemon.status),
491        report.runtime.daemon.message
492    );
493    println!(
494        "  [{}] Runtime store: {}",
495        label(report.runtime.local_store.status),
496        report.runtime.local_store.message
497    );
498    println!("  [OK] Tool surface:");
499    for tool in &report.tools {
500        println!("       - {tool}");
501    }
502    println!("\nNext steps:");
503    for step in &report.next_steps {
504        println!("  - {step}");
505    }
506}
507
508#[derive(Debug, serde::Serialize)]
509struct SelfTestReport {
510    ok: bool,
511    workspace: String,
512    checks: Vec<String>,
513    compact_smoke: QaCompactSmokeReport,
514    runtime_store: DoctorCheck,
515    kept_workspace: bool,
516}
517
518fn run_self_test(config: &Config, format: OutputFormat, keep_workspace: bool) -> Result<()> {
519    let workspace = std::env::temp_dir().join(format!("mermaid-self-test-{}", fresh_qa_id()));
520    std::fs::create_dir_all(&workspace)
521        .with_context(|| format!("failed to create {}", workspace.display()))?;
522
523    let compact_smoke = match run_qa_compact_smoke(config, &workspace, 6) {
524        Ok(report) => report,
525        Err(err) => QaCompactSmokeReport::failed(&workspace, 6, err.to_string()),
526    };
527    let runtime_store = match RuntimeClient::local().health() {
528        Ok(read) => DoctorCheck {
529            status: "ok",
530            message: format!("local runtime store ready at {}", read.value.database),
531        },
532        Err(err) => DoctorCheck {
533            status: "warning",
534            message: err.to_string(),
535        },
536    };
537
538    let checks = vec![
539        "compact smoke exercises reducer compaction path".to_string(),
540        "compact smoke persists conversation and archive artifacts".to_string(),
541        "local runtime store opens without daemon".to_string(),
542    ];
543    let ok = compact_smoke.ok && runtime_store.status == "ok";
544    let report = SelfTestReport {
545        ok,
546        workspace: workspace.display().to_string(),
547        checks,
548        compact_smoke,
549        runtime_store,
550        kept_workspace: keep_workspace,
551    };
552
553    print_self_test_report(&report, format)?;
554    if !keep_workspace {
555        let _ = std::fs::remove_dir_all(&workspace);
556    }
557    anyhow::ensure!(report.ok, "mermaid self-test failed");
558    Ok(())
559}
560
561fn print_self_test_report(report: &SelfTestReport, format: OutputFormat) -> Result<()> {
562    match format {
563        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
564        OutputFormat::Markdown => {
565            println!("# Mermaid Self-Test\n");
566            print_self_test_text(report);
567        },
568        OutputFormat::Text => print_self_test_text(report),
569    }
570    Ok(())
571}
572
573fn print_self_test_text(report: &SelfTestReport) {
574    println!(
575        "Mermaid self-test: {}",
576        if report.ok { "ok" } else { "failed" }
577    );
578    println!("workspace: {}", report.workspace);
579    println!(
580        "compact smoke: {}",
581        if report.compact_smoke.ok {
582            "ok"
583        } else {
584            "failed"
585        }
586    );
587    println!("runtime store: {}", report.runtime_store.message);
588    println!("checks:");
589    for check in &report.checks {
590        println!("  - {check}");
591    }
592    if !report.ok
593        && let Some(failure) = &report.compact_smoke.failure
594    {
595        println!("failure: {failure}");
596    }
597}
598
599fn label(status: &str) -> &'static str {
600    match status {
601        "ok" => "OK",
602        "warning" => "WARNING",
603        "error" => "ERROR",
604        _ => "INFO",
605    }
606}
607
608fn safety_mode_name(mode: crate::runtime::SafetyMode) -> &'static str {
609    mode.as_str()
610}
611
612fn configured_remote_providers(config: &Config) -> Vec<String> {
613    let mut names = Vec::new();
614    for profile in PROVIDER_REGISTRY {
615        let env = config
616            .providers
617            .get(profile.name)
618            .and_then(|c| c.api_key_env.as_deref())
619            .unwrap_or(profile.api_key_env);
620        if resolve_api_key(env, None).is_some() {
621            names.push(profile.name.to_string());
622        }
623    }
624    for (name, provider) in &config.providers {
625        if names.iter().any(|existing| existing == name) {
626            continue;
627        }
628        if let Some(env) = provider.api_key_env.as_deref()
629            && resolve_api_key(env, None).is_some()
630        {
631            names.push(name.clone());
632        }
633    }
634    names.sort();
635    names
636}
637
638#[derive(Debug, serde::Serialize)]
639struct QaCompactSmokeReport {
640    ok: bool,
641    turns: usize,
642    archived_messages: usize,
643    preserved_messages: usize,
644    replacement_messages: usize,
645    conversation_path: Option<String>,
646    archive_path: Option<String>,
647    checks: Vec<String>,
648    failure: Option<String>,
649}
650
651impl QaCompactSmokeReport {
652    fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
653        Self {
654            ok: false,
655            turns,
656            archived_messages: 0,
657            preserved_messages: 0,
658            replacement_messages: 0,
659            conversation_path: Some(
660                cwd.join(".mermaid")
661                    .join("conversations")
662                    .display()
663                    .to_string(),
664            ),
665            archive_path: None,
666            checks: Vec::new(),
667            failure: Some(failure),
668        }
669    }
670}
671
672fn run_qa_compact_smoke(
673    config: &Config,
674    cwd: &Path,
675    requested_turns: usize,
676) -> Result<QaCompactSmokeReport> {
677    let turns = requested_turns.max(3);
678    let mut state = State::new(
679        config.clone(),
680        cwd.to_path_buf(),
681        qa_model_id(config),
682        chrono::Local::now(),
683    );
684    for message in synthetic_compaction_messages(turns) {
685        state.session.append(message, state.now);
686    }
687
688    let (state_after_slash, compact_cmds) = update(
689        state,
690        Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
691    );
692    let turn = state_after_slash
693        .turn
694        .id()
695        .context("manual compaction did not enter a compaction turn")?;
696    let request = compact_cmds
697        .iter()
698        .find_map(|cmd| match cmd {
699            Cmd::CompactConversation { request, .. } => Some(request.clone()),
700            _ => None,
701        })
702        .context("manual compaction did not emit a CompactConversation command")?;
703
704    let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
705    let prepared = prepare_compaction(&request, Some(100_000))
706        .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
707    anyhow::ensure!(
708        !prepared.archived_messages.is_empty(),
709        "compaction archived no messages"
710    );
711    anyhow::ensure!(
712        !prepared.preserved_messages.is_empty(),
713        "compaction preserved no messages"
714    );
715
716    let summary = deterministic_compaction_summary(&prepared, turns);
717    let mut record = CompactionRecord {
718        id: format!("qa_compact_{}", fresh_qa_id()),
719        trigger: CompactionTrigger::Manual,
720        created_at: chrono::Local::now(),
721        before_tokens: before_snapshot.used_tokens,
722        after_tokens: 0,
723        archived_message_count: prepared.archived_messages.len(),
724        preserved_message_count: prepared.preserved_messages.len(),
725        summary_tokens: summary.len().div_ceil(4),
726        duration_secs: 0.0,
727        verified: true,
728        verification_error: None,
729        focus: Some("qa compact smoke".to_string()),
730        archive_path: None,
731    };
732    let mut replacement = build_replacement_messages(&summary, &prepared, &record);
733    let mut after_chat: ChatRequest = request.chat.clone();
734    after_chat.messages = replacement.clone();
735    let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
736    record.after_tokens = after_snapshot.used_tokens;
737    replacement = build_replacement_messages(&summary, &prepared, &record);
738    after_chat.messages = replacement.clone();
739    after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
740
741    let result = CompactionResult {
742        record,
743        replacement_messages: replacement,
744        archived_messages: prepared.archived_messages,
745        before_snapshot,
746        after_snapshot,
747        usage: None,
748    };
749    let (final_state, save_cmds) =
750        update(state_after_slash, Msg::CompactionFinished { turn, result });
751
752    let manager = ConversationManager::new(cwd)?;
753    let mut conversation_path = None;
754    let mut archive_path = None;
755    for cmd in save_cmds {
756        match cmd {
757            Cmd::SaveConversation(conversation) => {
758                manager.save_conversation(&conversation)?;
759                conversation_path = Some(
760                    manager
761                        .conversations_dir()
762                        .join(format!("{}.json", conversation.id))
763                        .display()
764                        .to_string(),
765                );
766            },
767            Cmd::SaveCompactionArchive {
768                archive,
769                conversation,
770                ..
771            } => {
772                // Archive first, then the stripped conversation (same order
773                // as the live effect path), with `?` so a failed archive
774                // aborts before the conversation is overwritten.
775                archive_path = Some(
776                    manager
777                        .save_compaction_archive(&archive)?
778                        .display()
779                        .to_string(),
780                );
781                manager.save_conversation(&conversation)?;
782                conversation_path = Some(
783                    manager
784                        .conversations_dir()
785                        .join(format!("{}.json", conversation.id))
786                        .display()
787                        .to_string(),
788                );
789            },
790            _ => {},
791        }
792    }
793
794    let conversation_path = conversation_path.context("compaction did not save conversation")?;
795    let archive_path = archive_path.context("compaction did not save archive")?;
796    let messages = final_state.session.messages();
797    let compactions = &final_state.session.conversation.compactions;
798
799    let mut checks = Vec::new();
800    anyhow::ensure!(
801        !compactions.is_empty(),
802        "conversation did not record compaction metadata"
803    );
804    checks.push("conversation records compaction metadata".to_string());
805    anyhow::ensure!(
806        messages
807            .first()
808            .is_some_and(|msg| msg.kind == crate::models::ChatMessageKind::ContextCheckpoint),
809        "replacement does not start with a context checkpoint"
810    );
811    checks.push("replacement starts with context checkpoint".to_string());
812    anyhow::ensure!(
813        std::path::Path::new(&conversation_path).exists(),
814        "conversation file missing after save"
815    );
816    checks.push("conversation file saved".to_string());
817    anyhow::ensure!(
818        std::path::Path::new(&archive_path).exists(),
819        "compaction archive file missing after save"
820    );
821    checks.push("archive file saved".to_string());
822    anyhow::ensure!(
823        compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
824        "compaction did not archive and preserve messages"
825    );
826    checks.push("archived and preserved message counts are non-zero".to_string());
827
828    Ok(QaCompactSmokeReport {
829        ok: true,
830        turns,
831        archived_messages: compactions[0].archived_message_count,
832        preserved_messages: compactions[0].preserved_message_count,
833        replacement_messages: messages.len(),
834        conversation_path: Some(conversation_path),
835        archive_path: Some(archive_path),
836        checks,
837        failure: None,
838    })
839}
840
841fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
842    match format {
843        OutputFormat::Json => {
844            println!("{}", serde_json::to_string_pretty(report)?);
845        },
846        OutputFormat::Text => {
847            println!(
848                "qa compact smoke: {}",
849                if report.ok { "ok" } else { "failed" }
850            );
851            println!("turns: {}", report.turns);
852            println!("archived messages: {}", report.archived_messages);
853            println!("preserved messages: {}", report.preserved_messages);
854            println!("replacement messages: {}", report.replacement_messages);
855            if let Some(path) = &report.conversation_path {
856                println!("conversation: {path}");
857            }
858            if let Some(path) = &report.archive_path {
859                println!("archive: {path}");
860            }
861            if let Some(failure) = &report.failure {
862                println!("failure: {failure}");
863            }
864        },
865        OutputFormat::Markdown => {
866            println!(
867                "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
868                if report.ok { "ok" } else { "failed" },
869                report.turns,
870                report.archived_messages,
871                report.preserved_messages,
872                report.replacement_messages
873            );
874            if let Some(path) = &report.conversation_path {
875                println!("- Conversation: `{path}`");
876            }
877            if let Some(path) = &report.archive_path {
878                println!("- Archive: `{path}`");
879            }
880            if let Some(failure) = &report.failure {
881                println!("\nFailure: `{failure}`");
882            }
883        },
884    }
885    Ok(())
886}
887
888fn qa_model_id(config: &Config) -> String {
889    if let Some(model) = config
890        .last_used_model
891        .as_ref()
892        .filter(|value| !value.is_empty())
893    {
894        return model.clone();
895    }
896    if !config.default_model.name.is_empty() {
897        if config.default_model.provider.is_empty() {
898            return config.default_model.name.clone();
899        }
900        return format!(
901            "{}/{}",
902            config.default_model.provider, config.default_model.name
903        );
904    }
905    "qa/deterministic".to_string()
906}
907
908fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
909    let mut messages = Vec::with_capacity(turns.saturating_mul(2));
910    for idx in 1..=turns {
911        messages.push(ChatMessage::user(format!(
912            "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
913        )));
914        messages.push(ChatMessage::assistant(format!(
915            "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}."
916        )));
917    }
918    messages
919}
920
921fn deterministic_compaction_summary(
922    prepared: &crate::domain::PreparedCompaction,
923    turns: usize,
924) -> String {
925    format!(
926        "## 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.",
927        prepared.archived_messages.len(),
928        prepared.preserved_messages.len()
929    )
930}
931
932fn fresh_qa_id() -> u128 {
933    std::time::SystemTime::now()
934        .duration_since(std::time::UNIX_EPOCH)
935        .map(|duration| duration.as_nanos())
936        .unwrap_or_default()
937}
938
939fn show_tasks(limit: usize) -> Result<()> {
940    let read = RuntimeClient::auto().list_tasks(limit)?;
941    let mut tasks = read.value;
942    tasks.truncate(limit);
943    println!("Mermaid runtime tasks");
944    println!("Source: {}", read.source.as_str());
945    println!();
946    if tasks.is_empty() {
947        println!("No tasks recorded yet.");
948        return Ok(());
949    }
950    for task in tasks {
951        println!(
952            "{}  [{}] {}  {}  {}",
953            task.id, task.status, task.priority, task.updated_at, task.title
954        );
955        println!("    project: {}", task.project_path);
956        println!("    model: {}", task.model_id);
957    }
958    Ok(())
959}
960
961fn show_task(id: &str) -> Result<()> {
962    let detail = RuntimeClient::auto().task_detail(id)?.value;
963    print_task_detail(&detail.task);
964    let events = detail.events;
965    if !events.is_empty() {
966        println!();
967        println!("Timeline:");
968        for event in events {
969            println!("  {}  {}  {}", event.created_at, event.kind, event.message);
970        }
971    }
972    Ok(())
973}
974
975fn print_task_detail(task: &TaskRecord) {
976    println!("Task: {}", task.id);
977    println!("Title: {}", task.title);
978    println!("Status: {}", task.status);
979    println!("Priority: {}", task.priority);
980    println!("Project: {}", task.project_path);
981    println!("Model: {}", task.model_id);
982    if let Some(conversation_id) = &task.conversation_id {
983        println!("Conversation: {}", conversation_id);
984    }
985    println!("Created: {}", task.created_at);
986    println!("Updated: {}", task.updated_at);
987    if let Some(report) = &task.final_report {
988        println!();
989        println!("Final report:");
990        println!("{}", sanitize_terminal_text(report));
991    }
992}
993
994fn show_processes(limit: usize) -> Result<()> {
995    let read = RuntimeClient::auto().list_processes(limit)?;
996    let mut processes = read.value;
997    processes.truncate(limit);
998    println!("Mermaid runtime processes");
999    println!("Source: {}", read.source.as_str());
1000    println!();
1001    if processes.is_empty() {
1002        println!("No processes recorded yet.");
1003        return Ok(());
1004    }
1005    for process in processes {
1006        println!(
1007            "{}  pid={}  status={}  {}",
1008            process.id,
1009            process.pid,
1010            process.status.as_str(),
1011            process.command
1012        );
1013        if let Some(task_id) = process.task_id {
1014            println!("    task: {}", task_id);
1015        }
1016        if let Some(cwd) = process.cwd {
1017            println!("    cwd: {}", cwd);
1018        }
1019        if let Some(log_path) = process.log_path {
1020            println!("    log: {}", log_path);
1021        }
1022        if let Some(url) = process.detected_url {
1023            println!("    url: {}", url);
1024        }
1025    }
1026    Ok(())
1027}
1028
1029async fn show_models(config: &Config) -> Result<()> {
1030    list_models(config).await?;
1031    probe_configured_provider_models(config).await?;
1032    let store = RuntimeStore::open_default()?;
1033    let probes = store.provider_probes().list(None, None)?;
1034    if !probes.is_empty() {
1035        println!("\nCached capability probes:");
1036        for probe in probes {
1037            println!(
1038                "  - {}/{} {}={} ({})",
1039                probe.provider,
1040                probe.model_id,
1041                probe.capability_key,
1042                probe.capability_value,
1043                probe.confidence
1044            );
1045        }
1046    }
1047    Ok(())
1048}
1049
1050async fn show_model_info(model: &str, config: &Config) -> Result<()> {
1051    let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(model);
1052    let store = RuntimeStore::open_default()?;
1053    let provider = snapshot.provider.clone();
1054
1055    // The static snapshot has no context window for Ollama. Probe `/api/show` for
1056    // the model's real one so this reports a real number, not "unknown".
1057    let mut context_tokens = snapshot.max_context_tokens;
1058    let mut context_confidence = "static";
1059    if provider == "ollama" {
1060        let backend = std::sync::Arc::new(crate::providers::factory::ollama_backend_config(config));
1061        if let Ok(adapter) =
1062            crate::models::adapters::ollama::OllamaAdapter::new(&snapshot.model, backend).await
1063            && let Some(info) = adapter.show_model_info().await
1064            && let Some(ctx) = info.context_length
1065        {
1066            context_tokens = Some(ctx);
1067            context_confidence = "probed";
1068        }
1069    }
1070
1071    for (key, value) in [
1072        ("supports_tools", snapshot.supports_tools.to_string()),
1073        ("supports_vision", snapshot.supports_vision.to_string()),
1074        ("reasoning", snapshot.reasoning.clone()),
1075    ] {
1076        let _ = store.provider_probes().upsert(NewProviderProbe {
1077            provider: provider.clone(),
1078            model_id: snapshot.model.clone(),
1079            capability_key: key.to_string(),
1080            capability_value: value,
1081            confidence: "static".to_string(),
1082            error: None,
1083        });
1084    }
1085    // Context window separately — probed (Ollama) or static.
1086    let _ = store.provider_probes().upsert(NewProviderProbe {
1087        provider: provider.clone(),
1088        model_id: snapshot.model.clone(),
1089        capability_key: "max_context_tokens".to_string(),
1090        capability_value: context_tokens
1091            .map(|n| n.to_string())
1092            .unwrap_or_else(|| "unknown".to_string()),
1093        confidence: context_confidence.to_string(),
1094        error: None,
1095    });
1096    println!("Model: {}", model);
1097    println!("Provider: {}", snapshot.provider);
1098    println!("Name: {}", snapshot.model);
1099    println!("Supports tools: {}", snapshot.supports_tools);
1100    println!("Supports vision: {}", snapshot.supports_vision);
1101    println!("Reasoning: {}", snapshot.reasoning);
1102    println!(
1103        "Context: {}",
1104        context_tokens
1105            .map(|n| n.to_string())
1106            .unwrap_or_else(|| "unknown".to_string())
1107    );
1108    if let Some(profile) = lookup_provider(&snapshot.provider) {
1109        for (key, value) in [
1110            (
1111                "max_output_tokens_param",
1112                format!("{:?}", profile.max_tokens_param),
1113            ),
1114            (
1115                "parallel_tool_calls",
1116                (!profile
1117                    .disable_parallel_tool_calls_for
1118                    .iter()
1119                    .any(|disabled| *disabled == snapshot.model))
1120                .to_string(),
1121            ),
1122            (
1123                "reasoning_parameter_shape",
1124                format!("{:?}", profile.reasoning_strategy),
1125            ),
1126            (
1127                "streaming_usage_available",
1128                "provider_dependent".to_string(),
1129            ),
1130            ("token_usage_field_shape", "openai_compatible".to_string()),
1131        ] {
1132            let _ = store.provider_probes().upsert(NewProviderProbe {
1133                provider: provider.clone(),
1134                model_id: snapshot.model.clone(),
1135                capability_key: key.to_string(),
1136                capability_value: value,
1137                confidence: "static".to_string(),
1138                error: None,
1139            });
1140        }
1141        println!("Token budget field: {:?}", profile.max_tokens_param);
1142        println!(
1143            "Single-tool-call models: {}",
1144            if profile.disable_parallel_tool_calls_for.is_empty() {
1145                "(none)".to_string()
1146            } else {
1147                profile.disable_parallel_tool_calls_for.join(", ")
1148            }
1149        );
1150    }
1151    Ok(())
1152}
1153
1154async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1155    let client = reqwest::Client::builder()
1156        .timeout(std::time::Duration::from_secs(5))
1157        .build()?;
1158    for profile in PROVIDER_REGISTRY {
1159        let user_cfg = config.providers.get(profile.name);
1160        let env = user_cfg
1161            .and_then(|c| c.api_key_env.as_deref())
1162            .unwrap_or(profile.api_key_env);
1163        let Some(api_key) = resolve_api_key(env, None) else {
1164            continue;
1165        };
1166        let base_url = user_cfg
1167            .and_then(|c| c.base_url.clone())
1168            .unwrap_or_else(|| profile.base_url.to_string());
1169        let url = format!("{}/models", base_url.trim_end_matches('/'));
1170        let mut request = client.get(&url).bearer_auth(api_key);
1171        for (name, value) in profile.extra_headers {
1172            request = request.header(*name, *value);
1173        }
1174        if let Some(user_cfg) = user_cfg {
1175            for (name, value) in &user_cfg.extra_headers {
1176                request = request.header(name, value);
1177            }
1178        }
1179
1180        let result = request.send().await;
1181        match result {
1182            Ok(response) if response.status().is_success() => {
1183                let status = response.status();
1184                let body: serde_json::Value = response.json().await.unwrap_or_default();
1185                let ids = body
1186                    .get("data")
1187                    .and_then(|v| v.as_array())
1188                    .map(|items| {
1189                        items
1190                            .iter()
1191                            .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1192                            .map(str::to_string)
1193                            .collect::<Vec<_>>()
1194                    })
1195                    .unwrap_or_default();
1196                record_provider_probe(
1197                    profile.name,
1198                    "*",
1199                    "models_availability",
1200                    &format!("available:{}:{}", status.as_u16(), ids.len()),
1201                    "probed",
1202                    None,
1203                );
1204                for model_id in ids.into_iter().take(200) {
1205                    record_provider_probe(
1206                        profile.name,
1207                        &model_id,
1208                        "model_listed",
1209                        "true",
1210                        "listed",
1211                        None,
1212                    );
1213                }
1214            },
1215            Ok(response) => {
1216                record_provider_probe(
1217                    profile.name,
1218                    "*",
1219                    "models_availability",
1220                    "failed",
1221                    "failed",
1222                    Some(format!("HTTP {}", response.status().as_u16())),
1223                );
1224            },
1225            Err(error) => {
1226                record_provider_probe(
1227                    profile.name,
1228                    "*",
1229                    "models_availability",
1230                    "failed",
1231                    "failed",
1232                    Some(error.to_string()),
1233                );
1234            },
1235        }
1236    }
1237    Ok(())
1238}
1239
1240fn record_provider_probe(
1241    provider: &str,
1242    model_id: &str,
1243    key: &str,
1244    value: &str,
1245    confidence: &str,
1246    error: Option<String>,
1247) {
1248    if let Ok(store) = RuntimeStore::open_default() {
1249        let _ = store.provider_probes().upsert(NewProviderProbe {
1250            provider: provider.to_string(),
1251            model_id: model_id.to_string(),
1252            capability_key: key.to_string(),
1253            capability_value: value.to_string(),
1254            confidence: confidence.to_string(),
1255            error,
1256        });
1257    }
1258}
1259
1260fn show_approvals() -> Result<()> {
1261    let approvals = RuntimeClient::auto().list_approvals()?.value;
1262    if approvals.is_empty() {
1263        println!("No pending approvals.");
1264        return Ok(());
1265    }
1266    for approval in approvals {
1267        println!(
1268            "{} [{} -> {}] {}",
1269            approval.id,
1270            approval.risk_classification,
1271            approval.policy_decision,
1272            approval.proposed_action
1273        );
1274        if let Some(args) = approval.args_summary {
1275            println!("    args: {}", args);
1276        }
1277        if let Some(checkpoint_id) = approval.checkpoint_id {
1278            println!("    checkpoint: {}", checkpoint_id);
1279        }
1280        if approval.pending_action_json.is_some() {
1281            println!("    pending action: recorded");
1282        }
1283    }
1284    Ok(())
1285}
1286
1287fn approve(id: &str) -> Result<()> {
1288    let result = RuntimeClient::auto().approve(id)?;
1289    println!("Approved {}", id);
1290    if result.replayed {
1291        println!("{}", result.summary);
1292    }
1293    Ok(())
1294}
1295
1296fn deny(id: &str) -> Result<()> {
1297    let _ = RuntimeClient::auto().deny(id)?;
1298    println!("Denied {}", id);
1299    Ok(())
1300}
1301
1302fn show_tool_runs(limit: usize) -> Result<()> {
1303    let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1304    runs.truncate(limit);
1305    if runs.is_empty() {
1306        println!("No tool runs recorded yet.");
1307        return Ok(());
1308    }
1309    for run in runs {
1310        println!(
1311            "{} [{}] {} started {}",
1312            run.id, run.status, run.tool_name, run.started_at
1313        );
1314        if let Some(turn_id) = run.turn_id {
1315            println!("    turn: {}", turn_id);
1316        }
1317        if let Some(call_id) = run.call_id {
1318            println!("    call: {}", call_id);
1319        }
1320        if let Some(finished_at) = run.finished_at {
1321            println!("    finished: {}", finished_at);
1322        }
1323    }
1324    Ok(())
1325}
1326
1327fn show_checkpoints(limit: usize) -> Result<()> {
1328    let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1329    checkpoints.truncate(limit);
1330    if checkpoints.is_empty() {
1331        println!("No checkpoints recorded yet.");
1332        return Ok(());
1333    }
1334    for checkpoint in checkpoints {
1335        println!(
1336            "{}  {}  {}",
1337            checkpoint.id, checkpoint.created_at, checkpoint.project_path
1338        );
1339        println!("    snapshot: {}", checkpoint.snapshot_path);
1340        println!("    files: {}", checkpoint.changed_files_json);
1341        if let Some(approval_id) = checkpoint.approval_id {
1342            println!("    approval: {}", approval_id);
1343        }
1344    }
1345    Ok(())
1346}
1347
1348fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
1349    // Restoring overwrites the working tree from the checkpoint. Confirm first
1350    // (default NO); `--force` is the scripted-use bypass, and a non-interactive
1351    // session without it refuses rather than clobbering the tree unprompted (#113).
1352    if !crate::utils::confirm_or_refuse(
1353        &format!("Restore checkpoint {id}? This overwrites the current working tree."),
1354        force,
1355    )? {
1356        println!("Restore cancelled.");
1357        return Ok(());
1358    }
1359    let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
1360    println!("Restored {} ({} files)", manifest.id, manifest.files.len());
1361    if let Some(repo) = manifest.shadow_git_repo {
1362        println!("Shadow repo: {}", repo);
1363    }
1364    if let Some(commit) = manifest.shadow_git_commit {
1365        println!("Shadow commit: {}", commit);
1366    }
1367    if let Some(action) = manifest.pending_action {
1368        println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
1369    }
1370    Ok(())
1371}
1372
1373fn handle_plugin(command: &PluginCommand) -> Result<()> {
1374    match command {
1375        PluginCommand::Install { path } => {
1376            let preview = crate::runtime::plugin_capability_preview(path)?;
1377            print_plugin_capability_preview(&preview);
1378            let record = crate::runtime::install_plugin_from_path(path)?;
1379            println!(
1380                "Installed plugin {} ({}) — DISABLED.",
1381                record.name, record.id
1382            );
1383            println!(
1384                "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
1385                record.id
1386            );
1387        },
1388        PluginCommand::List => {
1389            let plugins = RuntimeClient::auto().list_plugins()?.value;
1390            if plugins.is_empty() {
1391                println!("No plugins installed.");
1392            } else {
1393                for plugin in plugins {
1394                    println!(
1395                        "{} [{}] {} ({})",
1396                        plugin.id,
1397                        if plugin.enabled {
1398                            "enabled"
1399                        } else {
1400                            "disabled"
1401                        },
1402                        plugin.name,
1403                        plugin.source
1404                    );
1405                }
1406            }
1407        },
1408        PluginCommand::Enable { id } => {
1409            // Surface what the plugin declares before activating its native code.
1410            let client = RuntimeClient::auto();
1411            if let Some(plugin) = client
1412                .list_plugins()?
1413                .value
1414                .into_iter()
1415                .find(|p| p.id == *id || p.name == *id)
1416                && let Ok(preview) =
1417                    crate::runtime::plugin_capability_preview(Path::new(&plugin.source))
1418            {
1419                print_plugin_capability_preview(&preview);
1420            }
1421            client.set_plugin_enabled(id, true)?;
1422            println!("Enabled plugin {} — its hooks will now run.", id);
1423        },
1424        PluginCommand::Disable { id } => {
1425            RuntimeClient::auto().set_plugin_enabled(id, false)?;
1426            println!("Disabled plugin {}", id);
1427        },
1428        PluginCommand::Audit { path } => {
1429            let manifest_path = if path.is_dir() {
1430                path.join("plugin.toml")
1431            } else {
1432                path.clone()
1433            };
1434            let raw = std::fs::read_to_string(&manifest_path)?;
1435            let manifest: crate::runtime::PluginManifest = toml::from_str(&raw)?;
1436            let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1437            crate::runtime::validate_plugin_manifest(&manifest, root)?;
1438            let preview = crate::runtime::plugin_capability_preview(path)?;
1439            println!("Plugin manifest is valid: {}", manifest.name);
1440            print_plugin_capability_preview(&preview);
1441        },
1442    }
1443    Ok(())
1444}
1445
1446fn print_plugin_capability_preview(preview: &crate::runtime::PluginCapabilityPreview) {
1447    println!(
1448        "Capabilities declared by plugin {} (advisory, not sandbox-enforced):",
1449        preview.name
1450    );
1451    if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
1452        println!("  capabilities: (none declared)");
1453    } else {
1454        if !preview.declared_capabilities.is_empty() {
1455            println!("  declared: {}", preview.declared_capabilities.join(", "));
1456        }
1457        if let Some(value) = &preview.capabilities_toml {
1458            println!(
1459                "  capabilities.toml: {}",
1460                serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
1461            );
1462        }
1463    }
1464    if !preview.hooks.is_empty() {
1465        println!("  hooks: {}", preview.hooks.join(", "));
1466    }
1467    if !preview.mcp.is_empty() {
1468        println!("  mcp: {}", preview.mcp.join(", "));
1469    }
1470    if !preview.bin.is_empty() {
1471        println!("  bin: {}", preview.bin.join(", "));
1472    }
1473}
1474
1475fn handle_pair(command: &PairCommand) -> Result<()> {
1476    let store = RuntimeStore::open_default()?;
1477    match command {
1478        PairCommand::Create { label, ttl_days } => {
1479            let ttl = ttl_days.unwrap_or(crate::runtime::DEFAULT_PAIRING_TTL_DAYS);
1480            let expires_at = crate::runtime::pairing_expiry_from_now(ttl);
1481            let (token, hash) = crate::runtime::generate_pairing_token()?;
1482            let record =
1483                store
1484                    .pairing_tokens()
1485                    .create(&hash, label.as_deref(), expires_at.as_deref())?;
1486            println!("Pairing token id: {}", record.id);
1487            println!("Pairing token: {}", token);
1488            println!(
1489                "Expires: {}",
1490                record.expires_at.as_deref().unwrap_or("never")
1491            );
1492            println!(
1493                "Use with daemon JSON by setting {}.",
1494                crate::runtime::daemon::DAEMON_TOKEN_ENV
1495            );
1496            println!("Store this now; Mermaid will not print it again.");
1497        },
1498        PairCommand::List => {
1499            let tokens = store.pairing_tokens().list()?;
1500            if tokens.is_empty() {
1501                println!("No pairing tokens.");
1502            } else {
1503                // Never print token_hash — only the non-secret metadata.
1504                for t in tokens {
1505                    println!(
1506                        "{} [{}] label={} created={} expires={} last_used={}",
1507                        t.id,
1508                        if t.enabled { "active" } else { "revoked" },
1509                        t.label.as_deref().unwrap_or("-"),
1510                        t.created_at,
1511                        t.expires_at.as_deref().unwrap_or("never"),
1512                        t.last_used_at.as_deref().unwrap_or("never"),
1513                    );
1514                }
1515            }
1516        },
1517        PairCommand::Revoke { id } => {
1518            if store.pairing_tokens().revoke(id)? {
1519                println!("Revoked pairing token {id}");
1520            } else {
1521                println!("No active pairing token with id {id}");
1522            }
1523        },
1524    }
1525    Ok(())
1526}
1527
1528/// Strip terminal control sequences from untrusted subprocess output before
1529/// printing it to a cooked terminal. Managed-process logs / reports / port
1530/// listings are attacker-influenceable (a dev server can emit anything), so a
1531/// raw `print!` would let escape sequences execute — OSC-52 clipboard writes,
1532/// window-title/prompt rewrites, cursor moves used for spoofing. Keeps `\n` and
1533/// `\t`; drops every ESC-introduced sequence (CSI / OSC / DCS / PM / APC / SOS
1534/// and simple two-/three-byte forms) and all other C0/C1 control characters
1535/// (incl. `\r` and DEL). See F49.
1536fn sanitize_terminal_text(input: &str) -> String {
1537    let mut out = String::with_capacity(input.len());
1538    let mut chars = input.chars();
1539    while let Some(c) = chars.next() {
1540        match c {
1541            '\n' | '\t' => out.push(c),
1542            '\u{1b}' => match chars.next() {
1543                // CSI: ESC '[' params/intermediates then a final byte
1544                // (0x40-0x7e), which is also dropped.
1545                Some('[') => {
1546                    for p in chars.by_ref() {
1547                        if ('@'..='~').contains(&p) {
1548                            break;
1549                        }
1550                    }
1551                },
1552                // String sequences (OSC ']', DCS 'P', PM '^', APC '_', SOS 'X'):
1553                // arbitrary body terminated by BEL or ST (ESC '\').
1554                Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
1555                    while let Some(p) = chars.next() {
1556                        if p == '\u{07}' {
1557                            break;
1558                        }
1559                        if p == '\u{1b}' {
1560                            // ESC here starts ST (ESC '\'); drop the trailing '\'.
1561                            let mut peek = chars.clone();
1562                            if peek.next() == Some('\\') {
1563                                chars = peek;
1564                            }
1565                            break;
1566                        }
1567                    }
1568                },
1569                // Other ESC forms: optional intermediates (0x20-0x2f) then a
1570                // final byte; drop them all.
1571                Some(mut b) => {
1572                    while ('\u{20}'..='\u{2f}').contains(&b) {
1573                        match chars.next() {
1574                            Some(next) => b = next,
1575                            None => break,
1576                        }
1577                    }
1578                },
1579                None => {},
1580            },
1581            // Drop DEL, all other C0 controls (incl. `\r`), and C1 controls.
1582            c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
1583            c => out.push(c),
1584        }
1585    }
1586    out
1587}
1588
1589fn show_logs(id: &str) -> Result<()> {
1590    let content = RuntimeClient::auto().process_log(id, None)?.content;
1591    print!("{}", sanitize_terminal_text(&content));
1592    Ok(())
1593}
1594
1595fn stop_process(id: &str) -> Result<()> {
1596    let process = RuntimeClient::auto().stop_process(id)?.item;
1597    println!("Stopped process {} (pid {})", id, process.pid);
1598    Ok(())
1599}
1600
1601fn restart_process(id: &str) -> Result<()> {
1602    let process = RuntimeClient::auto().restart_process(id)?.item;
1603    println!("Restarted process {} (pid {})", id, process.pid);
1604    Ok(())
1605}
1606
1607fn open_target(target: &str) -> Result<()> {
1608    if RuntimeClient::auto().open_process(target).is_err() {
1609        crate::utils::open_file(target);
1610    }
1611    Ok(())
1612}
1613
1614fn show_ports() -> Result<()> {
1615    let ports = RuntimeClient::auto().ports()?.ports;
1616    print!("{}", sanitize_terminal_text(&ports));
1617    Ok(())
1618}
1619
1620/// List available models across all backends (honors user config).
1621pub async fn list_models(config: &Config) -> Result<()> {
1622    let ollama_models = list_ollama_models(config).await;
1623    if ollama_models.is_empty() {
1624        println!("No Ollama models installed locally.");
1625    } else {
1626        println!("Ollama models (local/cloud):");
1627        for name in &ollama_models {
1628            println!("  - ollama/{}", name);
1629        }
1630    }
1631
1632    println!("\nConfigured remote providers:");
1633    let mut any = false;
1634    for profile in PROVIDER_REGISTRY {
1635        let env = config
1636            .providers
1637            .get(profile.name)
1638            .and_then(|c| c.api_key_env.as_deref())
1639            .unwrap_or(profile.api_key_env);
1640        if resolve_api_key(env, None).is_some() {
1641            any = true;
1642            println!("  - {} (via ${})", profile.name, env);
1643        }
1644    }
1645    if !any {
1646        println!("  (none — set a provider API key env var to enable)");
1647    }
1648    println!("\nSwitch models in-session with /model <name>.");
1649    Ok(())
1650}
1651
1652/// Ask the local Ollama daemon for its list of models. Empty on
1653/// failure — the status widget separately shows whether Ollama is
1654/// reachable.
1655async fn list_ollama_models(config: &Config) -> Vec<String> {
1656    use crate::models::adapters::ollama::OllamaAdapter;
1657    let backend = BackendConfig {
1658        ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
1659        timeout_secs: 5,
1660        max_idle_per_host: 2,
1661    };
1662    match OllamaAdapter::new("__list__", Arc::new(backend)).await {
1663        Ok(adapter) => adapter.list_models().await.unwrap_or_default(),
1664        Err(_) => Vec::new(),
1665    }
1666}
1667
1668/// Show version information
1669pub fn show_version() {
1670    println!("Mermaid v{}", env!("CARGO_PKG_VERSION"));
1671    println!("   An open-source, model-agnostic AI pair programmer");
1672}
1673
1674const RELEASE_LATEST_API: &str =
1675    "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
1676const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
1677const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
1678
1679/// `mermaid update` — check GitHub Releases for a newer version and, unless
1680/// `--check`, re-run the platform install script to replace this binary in
1681/// place. The install script is the single source of truth for the
1682/// download + checksum + replace (incl. the running-exe rename on Windows), so
1683/// there's no archive-handling logic (or extra dependency) here.
1684async fn run_update(check: bool, force: bool) -> Result<()> {
1685    let current = env!("CARGO_PKG_VERSION");
1686    println!("Installed: v{current}");
1687
1688    let client = reqwest::Client::builder()
1689        .timeout(std::time::Duration::from_secs(15))
1690        .build()?;
1691    let resp = client
1692        .get(RELEASE_LATEST_API)
1693        .header("User-Agent", "mermaid-cli")
1694        .header("Accept", "application/vnd.github+json")
1695        .send()
1696        .await
1697        .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
1698    if !resp.status().is_success() {
1699        bail!("GitHub Releases API returned HTTP {}", resp.status());
1700    }
1701    let release: serde_json::Value = resp.json().await?;
1702    let tag = release
1703        .get("tag_name")
1704        .and_then(|v| v.as_str())
1705        .ok_or_else(|| anyhow!("release response had no tag_name"))?;
1706    println!("Latest:    {tag}");
1707
1708    let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
1709    if check {
1710        if up_to_date {
1711            println!("You're on the latest version.");
1712        } else {
1713            println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
1714        }
1715        return Ok(());
1716    }
1717    if up_to_date && !force {
1718        println!("Already up to date.");
1719        return Ok(());
1720    }
1721
1722    // Replace the binary in the directory it's running from, in place.
1723    let exe =
1724        std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
1725    let install_dir = exe
1726        .parent()
1727        .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
1728
1729    // Confirm before fetching + running the install script — it executes
1730    // downloaded shell/PowerShell and replaces the running binary. `--force` is
1731    // the scripted-use bypass; a non-interactive session without it refuses
1732    // rather than running fetched code unprompted (#110).
1733    let script_url = if cfg!(target_os = "windows") {
1734        INSTALL_PS1_URL
1735    } else {
1736        INSTALL_SH_URL
1737    };
1738    if !crate::utils::confirm_or_refuse(
1739        &format!(
1740            "About to download and run {script_url} to replace {}.",
1741            install_dir.display()
1742        ),
1743        force,
1744    )? {
1745        println!("Update cancelled.");
1746        return Ok(());
1747    }
1748
1749    println!("Updating {} …", install_dir.display());
1750    run_install_script(&client, install_dir).await?;
1751    println!("Updated. New version takes effect on the next run.");
1752    Ok(())
1753}
1754
1755/// Fetch the platform install script from the Pages site and run it, pointed at
1756/// `install_dir` so it updates in place without touching PATH.
1757async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
1758    let windows = cfg!(target_os = "windows");
1759    let url = if windows {
1760        INSTALL_PS1_URL
1761    } else {
1762        INSTALL_SH_URL
1763    };
1764    let script = client
1765        .get(url)
1766        .header("User-Agent", "mermaid-cli")
1767        .send()
1768        .await
1769        .map_err(|e| anyhow!("could not fetch install script: {e}"))?
1770        .error_for_status()?
1771        .text()
1772        .await?;
1773
1774    let ext = if windows { "ps1" } else { "sh" };
1775    // Stage the fetched script in the per-user 0700 private temp dir, created
1776    // exclusively (O_EXCL → never follows/opens a pre-planted symlink) so a local
1777    // attacker can neither redirect the write nor swap the file between write and
1778    // exec (#F50). The previous world-readable, predictable
1779    // `temp_dir()/mermaid-update-<pid>.<ext>` allowed both a symlink redirect and
1780    // a write→exec TOCTOU.
1781    let dir = crate::utils::private_temp_dir()
1782        .map_err(|e| anyhow!("could not create private temp dir for install script: {e}"))?;
1783    let nanos = std::time::SystemTime::now()
1784        .duration_since(std::time::UNIX_EPOCH)
1785        .map(|d| d.as_nanos())
1786        .unwrap_or_default();
1787    let script_path = dir.join(format!(
1788        "mermaid-update-{}-{nanos}.{ext}",
1789        std::process::id()
1790    ));
1791    stage_install_script(&script_path, script.as_bytes())
1792        .map_err(|e| anyhow!("could not stage install script: {e}"))?;
1793
1794    let mut cmd = if windows {
1795        let mut c = tokio::process::Command::new("powershell");
1796        c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
1797        c.arg(&script_path);
1798        c
1799    } else {
1800        let mut c = tokio::process::Command::new("sh");
1801        c.arg(&script_path);
1802        c
1803    };
1804    cmd.env("MERMAID_INSTALL_DIR", install_dir)
1805        .env("MERMAID_NO_MODIFY_PATH", "1");
1806
1807    let status = cmd
1808        .status()
1809        .await
1810        .map_err(|e| anyhow!("could not run install script: {e}"))?;
1811    let _ = std::fs::remove_file(&script_path);
1812    if !status.success() {
1813        bail!("install script exited with {:?}", status.code());
1814    }
1815    Ok(())
1816}
1817
1818/// Write the fetched install script to `path`, creating it **exclusively** so a
1819/// symlink pre-planted at the path is refused (`O_EXCL` never follows) and the
1820/// staged code is owner-only (`0600` file inside the `0700` private dir). This
1821/// closes the symlink-redirect and write→exec TOCTOU that the old predictable,
1822/// world-readable temp path left open (#F50).
1823fn stage_install_script(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1824    use std::io::Write;
1825    #[cfg(unix)]
1826    let mut file = {
1827        use std::os::unix::fs::OpenOptionsExt;
1828        std::fs::OpenOptions::new()
1829            .write(true)
1830            .create_new(true)
1831            .mode(0o600)
1832            .open(path)?
1833    };
1834    #[cfg(not(unix))]
1835    let mut file = std::fs::OpenOptions::new()
1836        .write(true)
1837        .create_new(true)
1838        .open(path)?;
1839    file.write_all(bytes)
1840}
1841
1842/// Parse a `[v]MAJOR.MINOR.PATCH[-pre][+build]` string into a comparable tuple.
1843fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
1844    let core = s.trim().trim_start_matches('v');
1845    let core = core.split(['-', '+']).next().unwrap_or(core);
1846    let mut parts = core.split('.');
1847    let major = parts.next()?.parse().ok()?;
1848    let minor = parts.next().unwrap_or("0").parse().ok()?;
1849    let patch = parts.next().unwrap_or("0").parse().ok()?;
1850    Some((major, minor, patch))
1851}
1852
1853/// True iff `current` is at least `latest` (no update needed). Unparseable
1854/// versions fall back to string equality, so we never falsely report
1855/// up-to-date on garbage — at worst we re-run the (idempotent) installer.
1856fn version_at_least(current: &str, latest: &str) -> bool {
1857    match (parse_semver(current), parse_semver(latest)) {
1858        (Some(c), Some(l)) => c >= l,
1859        _ => current == latest,
1860    }
1861}
1862
1863/// Show configured MCP servers
1864fn show_mcp_servers() {
1865    let config = load_config_or_warn();
1866
1867    if config.mcp_servers.is_empty() {
1868        println!("No MCP servers configured.\n");
1869        println!("Add one with: mermaid add <name>");
1870        println!("Examples:");
1871        println!("  mermaid add context7     # Library documentation");
1872        println!("  mermaid add playwright   # Browser automation");
1873        println!("  mermaid add memory       # Persistent knowledge graph");
1874        return;
1875    }
1876
1877    println!("Configured MCP servers:\n");
1878    for (name, server_cfg) in &config.mcp_servers {
1879        let package = server_cfg
1880            .args
1881            .iter()
1882            .find(|a| !a.starts_with('-'))
1883            .unwrap_or(&server_cfg.command);
1884        let env_keys: Vec<&String> = server_cfg.env.keys().collect();
1885        let env_display = if env_keys.is_empty() {
1886            String::new()
1887        } else {
1888            format!(
1889                " (env: {})",
1890                env_keys
1891                    .iter()
1892                    .map(|k| k.as_str())
1893                    .collect::<Vec<_>>()
1894                    .join(", ")
1895            )
1896        };
1897        println!("  {} — {}{}", name, package, env_display);
1898    }
1899    println!("\nManage with: mermaid add <name> / mermaid remove <name>");
1900}
1901
1902/// Show status of all dependencies
1903async fn show_status(config: &Config) -> Result<()> {
1904    println!("Mermaid Status:");
1905    println!();
1906
1907    // Check remote providers by API-key env presence (matches the
1908    // routing ProviderFactory uses when the user picks a model).
1909    let mut available: Vec<&'static str> = Vec::new();
1910    for profile in PROVIDER_REGISTRY {
1911        let env = config
1912            .providers
1913            .get(profile.name)
1914            .and_then(|c| c.api_key_env.as_deref())
1915            .unwrap_or(profile.api_key_env);
1916        if resolve_api_key(env, None).is_some() {
1917            available.push(profile.name);
1918        }
1919    }
1920    if available.is_empty() {
1921        println!("  [WARNING] Remote providers: none (no API keys in env)");
1922    } else {
1923        println!("  [OK] Remote providers: {}", available.join(", "));
1924    }
1925
1926    // Check Ollama (via HTTP, so remote deployments are honored).
1927    if is_ollama_installed() {
1928        let models = list_ollama_models(config).await;
1929        if models.is_empty() {
1930            println!("  [WARNING] Ollama: Installed (no models)");
1931        } else {
1932            println!("  [OK] Ollama: Running ({} models installed)", models.len());
1933            for model in models.iter().take(3) {
1934                println!("      - {}", model);
1935            }
1936            if models.len() > 3 {
1937                println!("      ... and {} more", models.len() - 3);
1938            }
1939        }
1940    } else {
1941        println!("  [ERROR] Ollama: Not installed");
1942    }
1943
1944    // Check configuration (uses platform-specific path via ProjectDirs)
1945    if let Ok(config_dir) = get_config_dir() {
1946        let config_path = config_dir.join("config.toml");
1947        if config_path.exists() {
1948            println!("  [OK] Configuration: {}", config_path.display());
1949        } else {
1950            println!("  [WARNING] Configuration: Not found (using defaults)");
1951        }
1952    }
1953
1954    // MCP Servers
1955    if config.mcp_servers.is_empty() {
1956        println!("  [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
1957    } else {
1958        println!(
1959            "  [OK] MCP Servers: {} configured",
1960            config.mcp_servers.len()
1961        );
1962        for (name, server_cfg) in &config.mcp_servers {
1963            println!(
1964                "      - {} ({})",
1965                name,
1966                server_cfg.args.get(1).unwrap_or(&server_cfg.command)
1967            );
1968        }
1969    }
1970
1971    // Project instructions (Step 5h). Walks UP from cwd to git root or
1972    // $HOME to find the nearest supported instruction files.
1973    {
1974        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1975        let paths = crate::app::instructions::find_instruction_files(&cwd);
1976        if paths.is_empty() {
1977            println!("  [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
1978        } else {
1979            match crate::app::instructions::load_from_paths(&paths) {
1980                Some(loaded) => {
1981                    let files = loaded
1982                        .sources
1983                        .iter()
1984                        .map(|source| {
1985                            source
1986                                .path
1987                                .file_name()
1988                                .and_then(|name| name.to_str())
1989                                .unwrap_or("instructions")
1990                        })
1991                        .collect::<Vec<_>>()
1992                        .join(", ");
1993                    println!(
1994                        "  [OK] Project instructions: {} at {} ({} bytes{})",
1995                        files,
1996                        loaded.path.display(),
1997                        loaded.byte_len,
1998                        if loaded.truncated { ", truncated" } else { "" }
1999                    );
2000                },
2001                None => {
2002                    println!(
2003                        "  [WARNING] Project instructions: found but unreadable ({})",
2004                        paths
2005                            .iter()
2006                            .map(|path| path.display().to_string())
2007                            .collect::<Vec<_>>()
2008                            .join(", ")
2009                    );
2010                },
2011            }
2012        }
2013    }
2014
2015    // OpenAI-compatible providers — list anything from the built-in
2016    // registry whose API key resolves, plus any user-defined custom
2017    // providers. No network probe (would slow `mermaid status`).
2018    show_provider_status(config);
2019
2020    // Environment variables (for API providers)
2021    println!("\n  Environment:");
2022    if std::env::var("OLLAMA_API_KEY").is_ok() {
2023        println!("    - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2024    }
2025
2026    println!();
2027    Ok(())
2028}
2029
2030/// Print the remote-providers status block. Includes Anthropic (bespoke
2031/// Messages API) and any OpenAI-compatible provider whose API key resolves.
2032/// Custom providers from `[providers.<name>]` are listed if `base_url`
2033/// and `api_key_env` are both set and the env var resolves.
2034fn show_provider_status(config: &Config) {
2035    let mut configured: Vec<(String, String)> = Vec::new(); // (name, base_url)
2036
2037    // Anthropic — checked first because it's not in the OpenAI-compat
2038    // registry but is a top-tier provider users care about.
2039    let anth_cfg = config.providers.get("anthropic");
2040    if resolve_api_key(
2041        "ANTHROPIC_API_KEY",
2042        anth_cfg.and_then(|c| c.api_key_env.as_deref()),
2043    )
2044    .is_some()
2045    {
2046        let url = anth_cfg
2047            .and_then(|c| c.base_url.clone())
2048            .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
2049        configured.push(("anthropic".to_string(), url));
2050    }
2051
2052    // Gemini — also bespoke (not in OpenAI-compat registry).
2053    let gem_cfg = config.providers.get("gemini");
2054    if resolve_api_key_with_fallback(
2055        "GOOGLE_API_KEY",
2056        "GEMINI_API_KEY",
2057        gem_cfg.and_then(|c| c.api_key_env.as_deref()),
2058    )
2059    .is_some()
2060    {
2061        let url = gem_cfg
2062            .and_then(|c| c.base_url.clone())
2063            .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
2064        configured.push(("gemini".to_string(), url));
2065    }
2066
2067    for profile in PROVIDER_REGISTRY {
2068        let user_cfg = config.providers.get(profile.name);
2069        let api_key_present = resolve_api_key(
2070            profile.api_key_env,
2071            user_cfg.and_then(|c| c.api_key_env.as_deref()),
2072        )
2073        .is_some();
2074        if api_key_present {
2075            let url = user_cfg
2076                .and_then(|c| c.base_url.clone())
2077                .unwrap_or_else(|| profile.base_url.to_string());
2078            configured.push((profile.name.to_string(), url));
2079        }
2080    }
2081
2082    // Custom providers — anything in config.providers not in registry
2083    // and not "anthropic" / "gemini" (already handled above).
2084    for (name, cfg) in &config.providers {
2085        if name == "anthropic" || name == "gemini" || lookup_provider(name).is_some() {
2086            continue;
2087        }
2088        if let (Some(url), Some(env)) = (&cfg.base_url, cfg.api_key_env.as_deref())
2089            && resolve_api_key(env, None).is_some()
2090        {
2091            configured.push((name.clone(), url.clone()));
2092        }
2093    }
2094
2095    if configured.is_empty() {
2096        println!(
2097            "  [INFO] Remote providers: None configured (set $ANTHROPIC_API_KEY, \
2098             $GOOGLE_API_KEY, $OPENAI_API_KEY, $GROQ_API_KEY, $OPENROUTER_API_KEY, etc., or \
2099             add [providers.<name>] to config.toml)"
2100        );
2101    } else {
2102        println!("  [OK] Remote providers: {} configured", configured.len());
2103        for (name, url) in configured {
2104            println!("      - {} ({})", name, url);
2105        }
2106    }
2107}
2108
2109/// Dispatch `mermaid pr` subcommands.
2110fn handle_pr(command: &PrCommand) -> Result<()> {
2111    match command {
2112        PrCommand::Create {
2113            title,
2114            body,
2115            summary,
2116            base,
2117            draft,
2118            web,
2119            provider,
2120        } => create_pr(CreatePrArgs {
2121            title: title.as_deref(),
2122            body: body.as_deref(),
2123            summary: summary.as_deref(),
2124            base: base.as_deref(),
2125            draft: *draft,
2126            web: *web,
2127            provider: *provider,
2128        }),
2129    }
2130}
2131
2132struct CreatePrArgs<'a> {
2133    title: Option<&'a str>,
2134    body: Option<&'a str>,
2135    summary: Option<&'a Path>,
2136    base: Option<&'a str>,
2137    draft: bool,
2138    web: bool,
2139    provider: Option<GitHost>,
2140}
2141
2142/// Create a PR/MR by driving the host's official CLI (`gh`/`glab`), reusing
2143/// its authentication. We wrap the platform CLI rather than reimplementing
2144/// per-provider REST clients (issue #2): it reuses existing `gh auth` /
2145/// `glab auth`, handles each host's quirks, and keeps the surface tiny.
2146fn create_pr(args: CreatePrArgs) -> Result<()> {
2147    // Body precedence: --summary <file> wins over inline --body.
2148    let body = match args.summary {
2149        Some(path) => Some(
2150            std::fs::read_to_string(path)
2151                .with_context(|| format!("failed to read summary file {}", path.display()))?,
2152        ),
2153        None => args.body.map(str::to_string),
2154    };
2155
2156    let host = match args.provider {
2157        Some(host) => host,
2158        None => detect_git_host()?,
2159    };
2160
2161    let (cli, install_hint) = match host {
2162        GitHost::Github => (
2163            "gh",
2164            "Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.",
2165        ),
2166        GitHost::Gitlab => (
2167            "glab",
2168            "Install the GitLab CLI (https://gitlab.com/gitlab-org/cli) and run `glab auth login`.",
2169        ),
2170    };
2171    if which::which(cli).is_err() {
2172        anyhow::bail!("`{cli}` was not found on your PATH. {install_hint}");
2173    }
2174
2175    let argv = build_pr_argv(
2176        host,
2177        args.title,
2178        body.as_deref(),
2179        args.base,
2180        args.draft,
2181        args.web,
2182    );
2183
2184    println!("Creating pull/merge request via `{cli}`…");
2185    let status = std::process::Command::new(cli)
2186        .args(&argv)
2187        .status()
2188        .with_context(|| format!("failed to run `{cli}`"))?;
2189    anyhow::ensure!(status.success(), "`{cli}` exited unsuccessfully ({status})");
2190    Ok(())
2191}
2192
2193/// Auto-detect the host: prefer the `origin` remote URL, else fall back to
2194/// whichever provider CLI is installed.
2195fn detect_git_host() -> Result<GitHost> {
2196    if let Some(host) = git_origin_host() {
2197        return Ok(host);
2198    }
2199    if which::which("gh").is_ok() {
2200        return Ok(GitHost::Github);
2201    }
2202    if which::which("glab").is_ok() {
2203        return Ok(GitHost::Gitlab);
2204    }
2205    anyhow::bail!(
2206        "could not detect a Git host from the `origin` remote. Pass `--provider github|gitlab` and install the matching CLI (`gh`/`glab`)."
2207    )
2208}
2209
2210fn git_origin_host() -> Option<GitHost> {
2211    let output = std::process::Command::new("git")
2212        .args(["config", "--get", "remote.origin.url"])
2213        .output()
2214        .ok()?;
2215    if !output.status.success() {
2216        return None;
2217    }
2218    host_from_remote_url(String::from_utf8_lossy(&output.stdout).trim())
2219}
2220
2221fn host_from_remote_url(url: &str) -> Option<GitHost> {
2222    let lower = url.to_ascii_lowercase();
2223    if lower.contains("github.com") {
2224        Some(GitHost::Github)
2225    } else if lower.contains("gitlab") {
2226        Some(GitHost::Gitlab)
2227    } else {
2228        None
2229    }
2230}
2231
2232/// Build the argv passed to the host CLI. Pure (no I/O), so it's unit-tested.
2233fn build_pr_argv(
2234    host: GitHost,
2235    title: Option<&str>,
2236    body: Option<&str>,
2237    base: Option<&str>,
2238    draft: bool,
2239    web: bool,
2240) -> Vec<String> {
2241    let s = |v: &str| v.to_string();
2242    let has_content = title.is_some() || body.is_some();
2243    let mut argv = Vec::new();
2244    match host {
2245        GitHost::Github => {
2246            argv.push(s("pr"));
2247            argv.push(s("create"));
2248            if web {
2249                argv.push(s("--web"));
2250            }
2251            if draft {
2252                argv.push(s("--draft"));
2253            }
2254            if let Some(title) = title {
2255                argv.push(s("--title"));
2256                argv.push(s(title));
2257            }
2258            if let Some(body) = body {
2259                argv.push(s("--body"));
2260                argv.push(s(body));
2261            }
2262            // No explicit content (and not the web form) → let gh fill the
2263            // title/body from the branch's commits rather than blocking on an
2264            // interactive prompt.
2265            if !has_content && !web {
2266                argv.push(s("--fill"));
2267            }
2268            if let Some(base) = base {
2269                argv.push(s("--base"));
2270                argv.push(s(base));
2271            }
2272        },
2273        GitHost::Gitlab => {
2274            argv.push(s("mr"));
2275            argv.push(s("create"));
2276            if web {
2277                argv.push(s("--web"));
2278            }
2279            if draft {
2280                argv.push(s("--draft"));
2281            }
2282            if let Some(title) = title {
2283                argv.push(s("--title"));
2284                argv.push(s(title));
2285            }
2286            if let Some(body) = body {
2287                argv.push(s("--description"));
2288                argv.push(s(body));
2289            }
2290            if !has_content && !web {
2291                argv.push(s("--fill"));
2292            }
2293            if let Some(base) = base {
2294                argv.push(s("--target-branch"));
2295                argv.push(s(base));
2296            }
2297        },
2298    }
2299    argv
2300}
2301
2302#[cfg(test)]
2303mod tests {
2304    use super::*;
2305
2306    #[test]
2307    fn sanitize_terminal_text_strips_control_sequences() {
2308        // Plain text and the allowed whitespace pass through unchanged.
2309        assert_eq!(
2310            sanitize_terminal_text("hello\tworld\nline two"),
2311            "hello\tworld\nline two"
2312        );
2313        // CSI color sequence is removed, surrounding text kept.
2314        assert_eq!(
2315            sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
2316            "RED text"
2317        );
2318        // OSC-52 clipboard write (BEL-terminated) is removed whole.
2319        assert_eq!(
2320            sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
2321            "beforeafter"
2322        );
2323        // OSC window-title rewrite terminated by ST (ESC '\').
2324        assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
2325        // Charset-designation (ESC '(' 'B') drops its final byte too.
2326        assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
2327        // Bare CR and a C1 control are dropped; \n is preserved.
2328        assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
2329    }
2330
2331    #[test]
2332    fn version_compare_handles_update_logic() {
2333        // Up to date / newer than latest ⇒ no update.
2334        assert!(version_at_least("0.10.2", "0.10.2"));
2335        assert!(version_at_least("0.11.0", "0.10.2"));
2336        assert!(version_at_least("1.0.0", "0.99.99"));
2337        // Older ⇒ update available.
2338        assert!(!version_at_least("0.10.1", "0.10.2"));
2339        assert!(!version_at_least("0.9.0", "0.10.0"));
2340        assert!(!version_at_least("0.10.2", "0.11.0"));
2341        // Pre-release/build suffixes and `v` prefixes are tolerated.
2342        assert!(version_at_least("0.10.2", "v0.10.2"));
2343        assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2344        assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2345        // Garbage never falsely reports up-to-date unless identical.
2346        assert!(!version_at_least("0.10.2", "not-a-version"));
2347    }
2348
2349    #[test]
2350    fn host_from_remote_url_detects_provider() {
2351        assert_eq!(
2352            host_from_remote_url("https://github.com/foo/bar.git"),
2353            Some(GitHost::Github)
2354        );
2355        assert_eq!(
2356            host_from_remote_url("git@github.com:foo/bar.git"),
2357            Some(GitHost::Github)
2358        );
2359        assert_eq!(
2360            host_from_remote_url("https://gitlab.com/foo/bar.git"),
2361            Some(GitHost::Gitlab)
2362        );
2363        assert_eq!(
2364            host_from_remote_url("git@gitlab.example.com:foo/bar.git"),
2365            Some(GitHost::Gitlab)
2366        );
2367        assert_eq!(host_from_remote_url("https://bitbucket.org/foo/bar"), None);
2368    }
2369
2370    #[test]
2371    fn build_pr_argv_github_with_content() {
2372        let argv = build_pr_argv(
2373            GitHost::Github,
2374            Some("T"),
2375            Some("B"),
2376            Some("main"),
2377            true,
2378            false,
2379        );
2380        assert_eq!(
2381            argv,
2382            vec![
2383                "pr", "create", "--draft", "--title", "T", "--body", "B", "--base", "main"
2384            ]
2385        );
2386    }
2387
2388    #[test]
2389    fn build_pr_argv_github_fills_without_content() {
2390        let argv = build_pr_argv(GitHost::Github, None, None, None, false, false);
2391        assert!(argv.contains(&"--fill".to_string()));
2392        assert!(!argv.contains(&"--title".to_string()));
2393    }
2394
2395    #[test]
2396    fn build_pr_argv_web_skips_fill() {
2397        let argv = build_pr_argv(GitHost::Github, None, None, None, false, true);
2398        assert!(argv.contains(&"--web".to_string()));
2399        assert!(!argv.contains(&"--fill".to_string()));
2400    }
2401
2402    #[test]
2403    fn build_pr_argv_gitlab_uses_mr_and_target_branch() {
2404        let argv = build_pr_argv(GitHost::Gitlab, Some("T"), None, Some("main"), false, false);
2405        assert_eq!(&argv[0..2], &["mr", "create"]);
2406        assert!(argv.windows(2).any(|w| w == ["--target-branch", "main"]));
2407        assert!(argv.contains(&"--title".to_string()));
2408    }
2409
2410    #[test]
2411    fn qa_compact_smoke_persists_conversation_and_archive() {
2412        let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2413        std::fs::create_dir_all(&dir).unwrap();
2414
2415        let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2416
2417        assert!(report.ok);
2418        assert!(report.archived_messages > 0);
2419        assert!(report.preserved_messages > 0);
2420        assert!(report.replacement_messages >= 3);
2421        assert!(
2422            std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2423            "conversation path should exist"
2424        );
2425        assert!(
2426            std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2427            "archive path should exist"
2428        );
2429
2430        let _ = std::fs::remove_dir_all(dir);
2431    }
2432
2433    #[test]
2434    fn qa_model_id_falls_back_to_deterministic() {
2435        assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2436    }
2437
2438    fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2439        let nanos = std::time::SystemTime::now()
2440            .duration_since(std::time::UNIX_EPOCH)
2441            .map(|duration| duration.as_nanos())
2442            .unwrap_or_default();
2443        std::env::temp_dir().join(format!("{name}-{nanos}"))
2444    }
2445}