difflore_cli/mcp_install/
status_display.rs1use 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 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
203pub 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
215pub 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}