Skip to main content

difflore_cli/mcp_install/
uninstall.rs

1//! MCP uninstaller: the inverse of `install_all`. Removes the `difflore`
2//! entry (and DiffLore hook groups) from every surface DiffLore wired, using
3//! the canonical record at `~/.difflore/mcp.json` to know what was installed,
4//! then deletes that record. Mirrors `install.rs` in structure and style.
5//!
6//! Every per-surface remover is a safe no-op when no DiffLore entry exists,
7//! so a missing/corrupt canonical record degrades gracefully to "attempt
8//! every surface" rather than stranding a half-installed machine.
9
10use std::collections::BTreeSet;
11
12use colored::Colorize;
13
14use super::{
15    Status, TargetOutcome,
16    common::{canonical_target_key, delete_canonical_record, read_canonical_record_targets},
17    registry::{self, AGENTS, AgentSpec},
18};
19use crate::style::{self, sym};
20
21/// Whether the `claude` remover (which undoes both the MCP entry *and* the
22/// lifecycle hooks) should run for a recorded-key set. The record may list
23/// either surface — `claude` (MCP) or `claude hooks` — so match on both.
24fn record_selects_claude(recorded_keys: &BTreeSet<String>) -> bool {
25    recorded_keys.contains("claude") || recorded_keys.contains("claude hooks")
26}
27
28/// Pure selection step: pick the `AGENTS` rows to uninstall for
29/// `recorded_keys`. An empty set means "no usable record" and selects every
30/// surface so a missing or corrupt record still fully cleans up. Claude Code
31/// hooks have no standalone remover (the Claude Code MCP row covers them), so
32/// that row is dropped here — keeping the exact shape of the legacy list while
33/// reading from the single registry table. Split out so the dispatch policy can
34/// be unit-tested without touching the filesystem or PATH.
35fn selected_specs(recorded_keys: &BTreeSet<String>) -> Vec<&'static AgentSpec> {
36    let attempt_all = recorded_keys.is_empty();
37    AGENTS
38        .iter()
39        // Claude Code hooks ride inside the Claude Code MCP remover.
40        .filter(|spec| spec.name != "Claude Code hooks")
41        .filter(|spec| {
42            if attempt_all {
43                return true;
44            }
45            if spec.name == "Claude Code" {
46                // The Claude Code MCP remover also strips the lifecycle hooks,
47                // so one remover covers Claude MCP + Claude hooks.
48                return record_selects_claude(recorded_keys);
49            }
50            recorded_keys.contains(&canonical_target_key(spec.name))
51        })
52        .collect()
53}
54
55fn uninstall_all_targets(recorded_keys: &BTreeSet<String>, dry_run: bool) -> Vec<TargetOutcome> {
56    selected_specs(recorded_keys)
57        .into_iter()
58        .map(|spec| registry::uninstall(spec, dry_run))
59        .collect()
60}
61
62const fn uninstall_outcome_verb(status: &Status, dry_run: bool) -> &'static str {
63    match status {
64        Status::Removed | Status::Installed | Status::Updated if dry_run => "would remove",
65        Status::Removed | Status::Installed | Status::Updated => "removed",
66        Status::Skipped(_) => "nothing to remove",
67        Status::Error(_) => "error",
68    }
69}
70
71fn print_uninstall_outcomes(outcomes: &[TargetOutcome], dry_run: bool) {
72    let mut nothing_to_remove: Vec<&str> = Vec::new();
73    for o in outcomes {
74        if matches!(o.status, Status::Skipped(_)) {
75            nothing_to_remove.push(o.name);
76            continue;
77        }
78        let plain_verb = uninstall_outcome_verb(&o.status, dry_run);
79        let (mark, verb) = match &o.status {
80            Status::Error(_) => (style::err(sym::ERR), style::danger(plain_verb)),
81            _ if dry_run => (style::amber("·"), style::amber(plain_verb)),
82            _ => (style::ok(sym::OK), style::emerald(plain_verb)),
83        };
84        println!("  {mark} {:<14} {verb}", o.name.bold());
85        let sub = match &o.status {
86            Status::Error(r) => r.as_str(),
87            _ => o.detail.as_str(),
88        };
89        if !sub.is_empty() {
90            println!("      {}", style::pewter(sub));
91        }
92    }
93    if !nothing_to_remove.is_empty() {
94        println!(
95            "  {} {}",
96            style::pewter("·"),
97            style::pewter(&format!(
98                "no DiffLore entry found (already clean): {}",
99                nothing_to_remove.join(", ")
100            ))
101        );
102    }
103}
104
105fn removed_outcome_names(outcomes: &[TargetOutcome]) -> Vec<&'static str> {
106    outcomes
107        .iter()
108        .filter(|o| matches!(o.status, Status::Removed))
109        .map(|o| o.name)
110        .collect()
111}
112
113fn errored_outcome_names(outcomes: &[TargetOutcome]) -> Vec<&'static str> {
114    outcomes
115        .iter()
116        .filter(|o| matches!(o.status, Status::Error(_)))
117        .map(|o| o.name)
118        .collect()
119}
120
121// ── Public entry point ─────────────────────────────────────────────────────
122
123pub fn uninstall_all(dry_run: bool) {
124    let recorded = read_canonical_record_targets();
125    let recorded_keys: BTreeSet<String> =
126        recorded.iter().map(|t| canonical_target_key(t)).collect();
127
128    let message = if dry_run {
129        "Checking DiffLore MCP removal plan for every recorded agent"
130    } else {
131        "Removing DiffLore MCP server from every recorded agent"
132    };
133    let dry_tag = if dry_run {
134        format!(" {}", style::amber("(dry-run; no changes)"))
135    } else {
136        String::new()
137    };
138    println!(
139        "{} {}{dry_tag}",
140        style::emerald(sym::TIP),
141        style::pewter(message),
142    );
143    if recorded_keys.is_empty() {
144        println!(
145            "  {} {}",
146            style::pewter("·"),
147            style::pewter(
148                "no canonical record (~/.difflore/mcp.json) — scanning every supported surface"
149            ),
150        );
151    } else {
152        println!(
153            "  {} {} {}",
154            style::pewter("recorded targets:"),
155            style::emerald(&recorded.join(", ")),
156            style::pewter(&format!("({})", recorded.len())),
157        );
158    }
159    println!();
160
161    let outcomes = uninstall_all_targets(&recorded_keys, dry_run);
162    print_uninstall_outcomes(&outcomes, dry_run);
163
164    let removed = removed_outcome_names(&outcomes);
165    let errored = errored_outcome_names(&outcomes);
166
167    println!();
168    if dry_run {
169        println!(
170            "{} dry-run only: no MCP config, hooks, or the canonical record were changed.",
171            style::emerald(sym::TIP)
172        );
173        println!(
174            "  {} apply with {} when the plan looks right.",
175            style::pewter(sym::BULLET),
176            style::cmd("difflore agents uninstall"),
177        );
178        return;
179    }
180
181    // Only delete the canonical record on a clean real run. If a surface
182    // errored, keep the record so a re-run (or `difflore agents status`)
183    // still knows what remains wired.
184    if errored.is_empty() {
185        match delete_canonical_record() {
186            Ok(Some(path)) => println!(
187                "{} removed canonical record {}",
188                style::ok(sym::OK),
189                style::pewter(&path.display().to_string()),
190            ),
191            Ok(None) => {}
192            Err(e) => eprintln!(
193                "{} failed to remove canonical record: {e}",
194                style::warn("warning:")
195            ),
196        }
197    } else {
198        eprintln!(
199            "{} {} failed to clean up; canonical record kept so {} can show what remains.",
200            style::warn("warning:"),
201            errored.join(", "),
202            style::cmd("difflore agents status"),
203        );
204    }
205
206    println!();
207    if removed.is_empty() && errored.is_empty() {
208        println!(
209            "{} nothing to remove — DiffLore was not wired into any detected agent.",
210            style::emerald(sym::TIP)
211        );
212    } else {
213        println!(
214            "{} restart/reload any open agents so they drop the DiffLore memory server.",
215            style::emerald(sym::TIP),
216        );
217        println!(
218            "  {} re-add later with {} when you want team review memory back.",
219            style::pewter(sym::BULLET),
220            style::cmd("difflore agents install"),
221        );
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn uninstall_verbs_describe_removal_plan_and_execution() {
231        assert_eq!(
232            uninstall_outcome_verb(&Status::Removed, true),
233            "would remove"
234        );
235        assert_eq!(uninstall_outcome_verb(&Status::Removed, false), "removed");
236        assert_eq!(
237            uninstall_outcome_verb(&Status::Skipped("x".into()), false),
238            "nothing to remove"
239        );
240        assert_eq!(
241            uninstall_outcome_verb(&Status::Error("x".into()), false),
242            "error"
243        );
244    }
245
246    fn selected_names(recorded_keys: &BTreeSet<String>) -> Vec<String> {
247        selected_specs(recorded_keys)
248            .into_iter()
249            .map(|spec| canonical_target_key(spec.name))
250            .collect()
251    }
252
253    #[test]
254    fn dispatch_filters_to_recorded_targets_when_record_present() {
255        // Only Cursor + Cursor hooks recorded → only those surfaces selected.
256        let recorded: BTreeSet<String> = [
257            canonical_target_key("Cursor"),
258            canonical_target_key("Cursor hooks"),
259        ]
260        .into_iter()
261        .collect();
262        assert_eq!(selected_names(&recorded), vec!["cursor", "cursor hooks"]);
263    }
264
265    #[test]
266    fn claude_hooks_only_record_still_selects_the_claude_remover() {
267        // The MCP probe can fail while hooks remain, leaving only the hook
268        // surface recorded. The combined claude remover must still run.
269        let recorded: BTreeSet<String> =
270            std::iter::once(canonical_target_key("Claude Code hooks")).collect();
271        assert!(selected_names(&recorded).contains(&"claude".to_owned()));
272    }
273
274    #[test]
275    fn dispatch_attempts_every_surface_when_record_empty() {
276        let empty = BTreeSet::new();
277        // Mirrors the install dispatch breadth (10 agents + 3 hook surfaces);
278        // Claude Code hooks fold into the Claude Code remover, so the standalone
279        // hooks row is excluded — every AGENTS row except that one.
280        let expected = AGENTS
281            .iter()
282            .filter(|s| s.name != "Claude Code hooks")
283            .count();
284        assert_eq!(selected_specs(&empty).len(), expected);
285        let names = selected_names(&empty);
286        assert!(names.contains(&"claude".to_owned()));
287        assert!(names.contains(&"windsurf hooks".to_owned()));
288    }
289
290    #[test]
291    fn removed_and_errored_partitions_match_status() {
292        let outcomes = vec![
293            TargetOutcome {
294                name: "Cursor",
295                status: Status::Removed,
296                detail: String::new(),
297            },
298            TargetOutcome {
299                name: "Gemini",
300                status: Status::Skipped("none".into()),
301                detail: String::new(),
302            },
303            TargetOutcome {
304                name: "Goose",
305                status: Status::Error("boom".into()),
306                detail: String::new(),
307            },
308        ];
309        assert_eq!(removed_outcome_names(&outcomes), vec!["Cursor"]);
310        assert_eq!(errored_outcome_names(&outcomes), vec!["Goose"]);
311    }
312}