Skip to main content

difflore_cli/mcp_install/
status_display.rs

1use std::path::Path;
2
3use colored::Colorize;
4
5use super::{
6    CanonicalRecordState, InstallState, RuntimeProbeState,
7    diagnosis::install_repair_targets_for_snapshot,
8    snapshot::{collect_status_snapshot, collect_status_snapshot_with_runtime_probe},
9};
10use crate::style::{self, sym};
11
12pub fn status(json: bool) {
13    let snapshot = collect_status_snapshot_with_runtime_probe();
14
15    if json {
16        println!("{}", crate::commands::util::json_or(&snapshot, "{}"));
17        return;
18    }
19
20    let mut ready: Vec<&str> = Vec::new();
21    let mut drift: Vec<&str> = Vec::new();
22    let mut conflicts: Vec<&str> = Vec::new();
23    let mut not_detected: Vec<&str> = Vec::new();
24
25    for client in &snapshot.clients {
26        match (client.detected, client.state) {
27            (true, InstallState::Installed) => ready.push(client.name),
28            (true, InstallState::Conflict) => conflicts.push(client.name),
29            (true, InstallState::NotInstalled | InstallState::Unknown) => {
30                drift.push(client.name);
31            }
32            (false, _) => not_detected.push(client.name),
33        }
34    }
35
36    println!(
37        "{} {}",
38        style::pewter("DiffLore MCP binary:"),
39        style::emerald(&display_mcp_binary(&snapshot.binary))
40    );
41    if let Some(probe) = &snapshot.runtime_probe {
42        let (mark, label) = match probe.state {
43            RuntimeProbeState::Ok => (style::ok(sym::OK), style::emerald("passed")),
44            RuntimeProbeState::Failed => (style::err(sym::ERR), style::danger("failed")),
45            RuntimeProbeState::Timeout => (style::amber("!"), style::amber("timeout")),
46        };
47        println!(
48            "{} {} {}",
49            style::pewter("MCP runtime self-check:"),
50            mark,
51            label
52        );
53        println!(
54            "  {}",
55            style::pewter(&public_status_detail(&probe.detail, &snapshot.binary))
56        );
57    }
58    if !matches!(
59        snapshot.canonical_record.state,
60        CanonicalRecordState::Present
61    ) {
62        let (mark, label) = match snapshot.canonical_record.state {
63            CanonicalRecordState::Present => (style::ok(sym::OK), style::emerald("present")),
64            CanonicalRecordState::Missing => (style::amber("!"), style::amber("not created yet")),
65            CanonicalRecordState::Stale => (style::amber("!"), style::amber("stale")),
66            CanonicalRecordState::Conflict => (style::err(sym::ERR), style::danger("conflict")),
67        };
68        println!("{} {} {}", style::pewter("Install record:"), mark, label);
69        if let Some(detail) = &snapshot.canonical_record.detail {
70            println!(
71                "  {}",
72                style::pewter(&public_status_detail(detail, &snapshot.binary))
73            );
74        }
75    }
76    println!();
77
78    let mut wrote_section = if ready.is_empty() {
79        false
80    } else {
81        println!("  {}", style::pewter("Ready"));
82        for name in &ready {
83            println!("    {} {}", style::ok(sym::OK), name.bold());
84        }
85        true
86    };
87    if !drift.is_empty() {
88        if wrote_section {
89            println!();
90        }
91        println!("  {}", style::pewter("Detected but not wired"));
92        for name in &drift {
93            println!("    {} {}", style::amber("ยท"), name.bold());
94        }
95        wrote_section = true;
96    }
97    if !conflicts.is_empty() {
98        if wrote_section {
99            println!();
100        }
101        println!("  {}", style::pewter("Conflict"));
102        for client in snapshot
103            .clients
104            .iter()
105            .filter(|c| c.detected && matches!(c.state, InstallState::Conflict))
106        {
107            println!("    {} {}", style::err(sym::ERR), client.name.bold());
108            println!(
109                "        {}",
110                style::pewter(&format!(
111                    "{} MCP entry points to a different binary; `difflore agents install` will rewrite it",
112                    client.name
113                ))
114            );
115        }
116        wrote_section = true;
117    }
118    if !not_detected.is_empty() {
119        if wrote_section {
120            println!();
121        }
122        println!(
123            "  {} {}",
124            style::pewter("Not detected:"),
125            style::pewter(&format!("{} agents", not_detected.len()))
126        );
127    }
128
129    let record_needs_action = !matches!(
130        snapshot.canonical_record.state,
131        CanonicalRecordState::Present
132    );
133    let needs_action = record_needs_action || !drift.is_empty() || !conflicts.is_empty();
134    if needs_action {
135        println!();
136        // Direct command: when we already know agents need wiring, skip
137        // the broader first-time setup summary.
138        println!("  next: {}", style::cmd("difflore agents install"));
139    }
140}
141
142fn display_mcp_binary(binary: &str) -> String {
143    let Some(file_name) = Path::new(binary).file_name().and_then(|name| name.to_str()) else {
144        return binary.to_owned();
145    };
146    if matches!(file_name, "difflore" | "difflore.exe") {
147        "difflore".to_owned()
148    } else {
149        binary.to_owned()
150    }
151}
152
153fn public_status_detail(detail: &str, binary: &str) -> String {
154    let mut out = detail.replace(binary, "difflore").replace('\\', "/");
155    for (suffix, label) in [
156        ("/.github/copilot/mcp.json", "~/.github/copilot/mcp.json"),
157        (
158            "/.gemini/antigravity/mcp_config.json",
159            "~/.gemini/antigravity/mcp_config.json",
160        ),
161        ("/.codex/config.toml", "~/.codex/config.toml"),
162        ("/.claude/settings.json", "~/.claude/settings.json"),
163        ("/.cursor/mcp.json", "~/.cursor/mcp.json"),
164        ("/.config/crush/mcp.json", "~/.config/crush/mcp.json"),
165        ("/.warp/mcp.json", "~/.warp/mcp.json"),
166    ] {
167        out = replace_path_ending(&out, suffix, label);
168    }
169    scrub_command_path(&out)
170}
171
172fn replace_path_ending(input: &str, suffix: &str, label: &str) -> String {
173    let Some(pos) = input.find(suffix) else {
174        return input.to_owned();
175    };
176    let start = input[..pos]
177        .rfind(|c: char| c.is_whitespace() || matches!(c, '(' | '`' | '='))
178        .map_or(0, |idx| idx + 1);
179    let end = pos + suffix.len();
180    let mut out = input.to_owned();
181    out.replace_range(start..end, label);
182    out
183}
184
185fn scrub_command_path(input: &str) -> String {
186    let Some(pos) = input.find("command=") else {
187        return input.to_owned();
188    };
189    let value_start = pos + "command=".len();
190    let tail = &input[value_start..];
191    let value_len = tail.find([',', ')', ' ']).unwrap_or(tail.len());
192    let value = &tail[..value_len];
193    let normalized = value.replace('\\', "/");
194    if normalized.ends_with("/difflore.exe") || normalized.ends_with("/difflore") {
195        let mut out = input.to_owned();
196        out.replace_range(value_start..value_start + value_len, "difflore");
197        out
198    } else {
199        input.to_owned()
200    }
201}
202
203/// Names of AI-agent surfaces detected on this machine that don't yet have
204/// `DiffLore` installed. Empty means everything wireable is wired.
205pub fn detect_install_drift() -> Vec<&'static str> {
206    let snapshot = collect_status_snapshot();
207    snapshot
208        .clients
209        .iter()
210        .filter(|c| c.detected && !matches!(c.state, InstallState::Installed))
211        .map(|c| c.name)
212        .collect()
213}
214
215/// Client display names whose MCP wiring can be refreshed by rerunning
216/// the idempotent installer. Includes classic drift (detected but not
217/// installed/conflicting) plus canonical-record drift such as hooks that
218/// exist on disk but were not captured in `~/.difflore/mcp.json`.
219pub fn detect_install_repair_targets() -> Vec<String> {
220    let snapshot = collect_status_snapshot();
221    install_repair_targets_for_snapshot(&snapshot)
222}
223
224pub async fn maybe_print_mcp_hint() {
225    match difflore_core::settings::get().await {
226        Ok(s) if !s.hints_mcp => return,
227        _ => {}
228    }
229
230    let drift = detect_install_drift();
231    if drift.is_empty() {
232        return;
233    }
234
235    let names = drift.join(", ");
236    println!();
237    println!(
238        "{} {} detected without DiffLore โ€” install once so rules reach your next agent run:",
239        style::emerald(sym::TIP),
240        style::ident(&names),
241    );
242    println!("  โ†’ run {}", style::cmd("difflore init"));
243}