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