Skip to main content

difflore_cli/mcp_install/
install.rs

1use std::collections::BTreeSet;
2
3use colored::Colorize;
4
5use super::{
6    InstallState, Status, TargetOutcome,
7    common::{canonical_target_key, resolve_difflore_binary, write_install_manifest},
8    diagnosis::client_name_for_surface,
9    manifest::{self, ManifestTarget},
10    registry::{self, AGENTS, AgentSpec, BlockKind},
11    snapshot::{collect_agent_statuses, installed_targets_from_agents},
12};
13use crate::style::{self, sym};
14
15fn successful_outcome_names(outcomes: &[TargetOutcome]) -> Vec<&'static str> {
16    outcomes
17        .iter()
18        .filter(|o| matches!(o.status, Status::Installed | Status::Updated))
19        .map(|o| o.name)
20        .collect()
21}
22
23pub(super) fn failed_outcome_names(outcomes: &[TargetOutcome]) -> Vec<&'static str> {
24    outcomes
25        .iter()
26        .filter(|o| matches!(o.status, Status::Error(_)))
27        .map(|o| o.name)
28        .collect()
29}
30
31pub(super) fn outcome_client_names(outcomes: &[TargetOutcome]) -> Vec<String> {
32    outcomes
33        .iter()
34        .filter(|o| matches!(o.status, Status::Installed | Status::Updated))
35        .map(|o| client_name_for_surface(o.name).to_owned())
36        .collect::<BTreeSet<_>>()
37        .into_iter()
38        .collect()
39}
40
41fn installed_surface_keys(bin: &str) -> BTreeSet<String> {
42    collect_agent_statuses(bin)
43        .into_iter()
44        .filter(|agent| matches!(agent.state, InstallState::Installed))
45        .map(|agent| canonical_target_key(agent.name))
46        .collect()
47}
48
49pub(super) fn outcome_already_installed(
50    outcome: &TargetOutcome,
51    installed_surfaces: &BTreeSet<String>,
52) -> bool {
53    installed_surfaces.contains(&canonical_target_key(outcome.name))
54        || skipped_because_already_installed(&outcome.status)
55}
56
57fn skipped_because_already_installed(status: &Status) -> bool {
58    matches!(status, Status::Skipped(reason) if reason.contains("already installed"))
59}
60
61pub(super) const fn install_outcome_verb(
62    status: &Status,
63    dry_run: bool,
64    already_installed: bool,
65) -> &'static str {
66    match status {
67        Status::Installed | Status::Updated | Status::Skipped(_)
68            if dry_run && already_installed =>
69        {
70            "already installed"
71        }
72        Status::Installed if dry_run => "would install",
73        Status::Updated if dry_run => "would update",
74        Status::Installed => "installed",
75        Status::Updated => "updated",
76        // `Removed` never arises on the install path; map it defensively.
77        Status::Removed if dry_run => "would remove",
78        Status::Removed => "removed",
79        Status::Skipped(_) => "skipped",
80        Status::Error(_) => "error",
81    }
82}
83
84pub(super) const fn should_write_canonical_record(
85    dry_run: bool,
86    installed: &[&str],
87    failed: &[&str],
88) -> bool {
89    !dry_run && !installed.is_empty() && failed.is_empty()
90}
91
92// ── Public entry points ──────────────────────────────────────────────────
93
94pub fn install_all(dry_run: bool) {
95    let cli_bin = match resolve_difflore_binary() {
96        Ok(b) => b,
97        Err(e) => crate::commands::util::exit_err(&e),
98    };
99    let mcp_bin = cli_bin.clone();
100
101    let install_message = if dry_run {
102        "Checking DiffLore MCP install plan for every detected agent"
103    } else {
104        "Installing DiffLore MCP server to every detected agent"
105    };
106    let dry_tag = if dry_run {
107        format!(" {}", style::amber("(dry-run; no changes)"))
108    } else {
109        String::new()
110    };
111    println!(
112        "{} {}{dry_tag}",
113        style::emerald(sym::TIP),
114        style::pewter(install_message),
115    );
116    println!(
117        "  {} {} {}",
118        style::pewter("mcp command:"),
119        style::emerald("difflore"),
120        style::emerald("mcp-server")
121    );
122    println!();
123
124    let outcomes = install_all_targets(&mcp_bin, &cli_bin, dry_run);
125    let installed_surfaces = if dry_run {
126        installed_surface_keys(&mcp_bin)
127    } else {
128        BTreeSet::new()
129    };
130    print_install_outcomes(&outcomes, dry_run, &installed_surfaces, &mcp_bin);
131
132    let installed = successful_outcome_names(&outcomes);
133    let has_detected_or_planned = if dry_run {
134        outcomes.iter().any(|o| {
135            matches!(o.status, Status::Installed | Status::Updated)
136                || outcome_already_installed(o, &installed_surfaces)
137        })
138    } else {
139        !installed.is_empty()
140    };
141
142    let failed = failed_outcome_names(&outcomes);
143    if should_write_canonical_record(dry_run, &installed, &failed) {
144        let agents = collect_agent_statuses(&mcp_bin);
145        let current_installed = installed_targets_from_agents(&agents);
146        let record_targets = if current_installed.is_empty() {
147            installed.as_slice()
148        } else {
149            current_installed.as_slice()
150        };
151        // Build the v2 manifest: per-target config path + block_kind +
152        // block_version + a hash of the exact block we rendered, preserving the
153        // prior `installed_at` for any target we re-installed. The v1
154        // `command`/`args`/`installed_targets` fields are still emitted for
155        // compatibility readers.
156        let prior = manifest::load();
157        let manifest_targets =
158            manifest::build_targets(record_targets, &mcp_bin, &cli_bin, prior.as_ref());
159        if let Err(e) = write_install_manifest(&mcp_bin, manifest_targets) {
160            eprintln!(
161                "{} failed to write canonical record: {e}",
162                style::warn("warning:")
163            );
164        }
165    } else if !dry_run && !installed.is_empty() && !failed.is_empty() {
166        eprintln!(
167            "{} partial MCP install: canonical record not updated because {} failed. Run {} after fixing those clients.",
168            style::warn("warning:"),
169            failed.join(", "),
170            style::cmd("difflore agents status"),
171        );
172    }
173
174    if !has_detected_or_planned {
175        println!();
176        println!(
177            "{} no agents were detected. Install a supported agent (Claude Code, Codex, Cursor, Gemini, Copilot CLI, Antigravity, Goose, Crush, Roo Code, Warp) and re-run.",
178            style::warn("!")
179        );
180        return;
181    }
182
183    print_post_install_help(dry_run, &outcomes);
184}
185
186/// One row → one outcome. The single `AGENTS` table now drives the entire
187/// install dispatch; adding an agent row makes it install automatically.
188/// Claude Code hooks ride along inside the Claude Code MCP install (its row's
189/// installer is a no-op skip), exactly as in the legacy hand-coded list.
190fn install_all_targets(mcp_bin: &str, cli_bin: &str, dry_run: bool) -> Vec<TargetOutcome> {
191    AGENTS
192        .iter()
193        // The Claude Code hooks surface is installed as a side effect of the
194        // Claude Code MCP row; omit its standalone (skip) outcome so the
195        // printed plan matches the legacy 13-line dispatch.
196        .filter(|spec| spec.name != "Claude Code hooks")
197        .map(|spec| registry::install(spec, mcp_bin, cli_bin, dry_run))
198        .collect()
199}
200
201fn print_install_outcomes(
202    outcomes: &[TargetOutcome],
203    dry_run: bool,
204    installed_surfaces: &BTreeSet<String>,
205    mcp_bin: &str,
206) {
207    let mut skipped_summary: Vec<&str> = Vec::new();
208    for o in outcomes {
209        let already_installed = dry_run && outcome_already_installed(o, installed_surfaces);
210        if dry_run && !already_installed && matches!(o.status, Status::Skipped(_)) {
211            skipped_summary.push(o.name);
212            continue;
213        }
214        let plain_verb = install_outcome_verb(&o.status, dry_run, already_installed);
215        let (mark, verb) = match &o.status {
216            Status::Installed | Status::Updated | Status::Skipped(_) if already_installed => {
217                (style::ok(sym::OK), style::emerald(plain_verb))
218            }
219            Status::Installed | Status::Updated if dry_run => {
220                (style::amber("·"), style::amber(plain_verb))
221            }
222            // Removed isn't reachable on the install path, but Installed/Updated
223            // and Removed all render the same OK/emerald line.
224            Status::Installed | Status::Updated | Status::Removed => {
225                (style::ok(sym::OK), style::emerald(plain_verb))
226            }
227            Status::Skipped(_) => (style::pewter("·"), style::pewter(plain_verb)),
228            Status::Error(_) => (style::err(sym::ERR), style::danger(plain_verb)),
229        };
230        println!("  {mark} {:<14} {verb}", o.name.bold());
231        let sub = match &o.status {
232            Status::Skipped(r) | Status::Error(r) => r.as_str(),
233            _ => o.detail.as_str(),
234        };
235        if !sub.is_empty() {
236            println!(
237                "      {}",
238                style::pewter(&public_install_detail(sub, mcp_bin))
239            );
240        }
241    }
242    if !skipped_summary.is_empty() {
243        let (hooks, agents): (Vec<_>, Vec<_>) = skipped_summary
244            .into_iter()
245            .partition(|name| name.to_ascii_lowercase().contains("hooks"));
246        let mut agents = agents;
247        if hooks.contains(&"Windsurf hooks") && !agents.contains(&"Windsurf") {
248            agents.push("Windsurf");
249        }
250        if !agents.is_empty() {
251            println!(
252                "  {} {}",
253                style::pewter("·"),
254                style::pewter(&format!(
255                    "agents skipped/not detected: {}",
256                    agents.join(", ")
257                ))
258            );
259        }
260        if !hooks.is_empty() {
261            println!(
262                "  {} {}",
263                style::pewter("·"),
264                style::pewter(&format!("hooks skipped/not detected: {}", hooks.join(", ")))
265            );
266        }
267    }
268}
269
270fn public_install_detail(detail: &str, mcp_bin: &str) -> String {
271    let mut out = detail.replace(mcp_bin, "difflore");
272    let normalized = out.replace('\\', "/");
273    for (suffix, label) in [
274        ("/.github/copilot/mcp.json", "~/.github/copilot/mcp.json"),
275        (
276            "/.gemini/antigravity/mcp_config.json",
277            "~/.gemini/antigravity/mcp_config.json",
278        ),
279        ("/.config/crush/mcp.json", "~/.config/crush/mcp.json"),
280        ("/.roo/mcp.json", "./.roo/mcp.json"),
281        ("/.warp/mcp.json", "~/.warp/mcp.json"),
282    ] {
283        if normalized.ends_with(suffix) {
284            return label.to_owned();
285        }
286        if let Some(pos) = normalized.find(suffix) {
287            let before = &normalized[..pos];
288            let after = &normalized[pos + suffix.len()..];
289            out = format!(
290                "{}{}{}",
291                &normalized[..before.rfind(' ').map_or(0, |i| i + 1)],
292                label,
293                after
294            );
295        }
296    }
297    out
298}
299
300static MCP_TOOLS_HELP: &[(&str, &str)] = &[
301    (
302        "search_rules",
303        "        — compact rule index (~80 tok/result), ids only",
304    ),
305    (
306        "get_rules",
307        "           — fetch full rule bodies by ids (batch after search_rules)",
308    ),
309    (
310        "get_past_verdicts",
311        "     — recall past PR review decisions",
312    ),
313    (
314        "remember_rule",
315        "        — save \"remember this rule\" moments mid-chat",
316    ),
317];
318
319fn print_post_install_help(dry_run: bool, outcomes: &[TargetOutcome]) {
320    let clients = outcome_client_names(outcomes);
321    let restart_targets = if clients.is_empty() {
322        "any agent you use with DiffLore".to_owned()
323    } else {
324        clients.join(", ")
325    };
326    println!();
327    if dry_run {
328        println!(
329            "{} dry-run only: no MCP config or hooks were changed.",
330            style::emerald(sym::TIP)
331        );
332        println!(
333            "  {} apply with {} when the plan looks right.",
334            style::pewter(sym::BULLET),
335            style::cmd("difflore agents install"),
336        );
337    } else {
338        println!(
339            "{} restart/reload {} so they pick up the new DiffLore memory server.",
340            style::emerald(sym::TIP),
341            if clients.is_empty() {
342                "Claude/Codex/Cursor/etc.".to_owned()
343            } else {
344                clients.join(", ")
345            }
346        );
347    }
348    println!(
349        "  {} installed once; use {} later to refresh team review memory.",
350        style::pewter(sym::BULLET),
351        style::cmd("difflore cloud sync"),
352    );
353    println!();
354    println!(
355        "{} review-memory tools your local agent can now call:",
356        style::emerald(sym::TIP)
357    );
358    for (name, desc) in MCP_TOOLS_HELP {
359        println!("  • {}{desc}", style::ident(name));
360    }
361    println!();
362    println!(
363        "  {} For large rule libraries prefer search_rules → get_rules (~10× fewer tokens on large libraries).",
364        style::pewter("ℹ")
365    );
366    println!();
367    println!("{} verification loop:", style::emerald(sym::TIP));
368    println!(
369        "  {} run {} after applying to verify config, runtime startup, tool listing, and the built-in search_rules self-check.",
370        style::pewter(sym::BULLET),
371        style::cmd("difflore agents status"),
372    );
373    println!(
374        "  {} restart/reload: {}.",
375        style::pewter(sym::BULLET),
376        style::ident(&restart_targets),
377    );
378    println!(
379        "  {} in one restarted agent, call {} to verify DiffLore MCP can recall review memory.",
380        style::pewter(sym::BULLET),
381        style::cmd("search_rules"),
382    );
383}
384
385// ── Safe-upgrade lifecycle (`difflore agents update`) ─────────────────────
386
387/// What `update` decides to do with one manifest target. Pure (filesystem +
388/// hashing happen in the caller); split out so the compare/skip/upgrade policy
389/// is unit-testable without touching disk.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub(super) enum UpdateAction {
392    /// Already current (hash matches, version current) — no rewrite.
393    UpToDate,
394    /// Block was unchanged since DiffLore wrote it AND a newer version exists →
395    /// re-render in place and re-stamp (carries old→new version for the report).
396    Upgrade { from: u32, to: u32 },
397    /// v1-record / unknown-hash target whose on-disk block matches our standard
398    /// render → adopt it (stamp the hash + current version) without rewriting.
399    Adopt,
400    /// On-disk block differs from the recorded hash (or, for a v1 record, from
401    /// our standard render): the human edited it. Skip unless `--force`.
402    SkippedLocalEdits,
403    /// `--force` opted in to overwrite a locally-edited block with our render.
404    ForceOverwrite,
405    /// Config file missing or difflore block absent → offer reinstall.
406    Gone,
407    /// External-CLI-managed and the shape version bumped → re-issue the CLI add.
408    ReissueCli { from: u32, to: u32 },
409    /// External-CLI-managed and already at the current shape version.
410    UpToDateExternal,
411}
412
413/// Decide the action for one target from the facts: the recorded hash (`None`
414/// for v1 records / external-cli), the recorded version, the current in-binary
415/// version, the *on-disk* hash (`None` = gone), and the *standard render* hash
416/// (what the current writer would produce; used for v1 adoption).
417pub(super) fn plan_update_target(
418    is_external: bool,
419    recorded_hash: Option<&str>,
420    recorded_version: u32,
421    current_version: u32,
422    on_disk_hash: Option<&str>,
423    standard_render_hash: Option<&str>,
424) -> UpdateAction {
425    if is_external {
426        return if recorded_version < current_version {
427            UpdateAction::ReissueCli {
428                from: recorded_version,
429                to: current_version,
430            }
431        } else {
432            UpdateAction::UpToDateExternal
433        };
434    }
435
436    // No difflore block on disk → it's gone (uninstalled or never installed).
437    let Some(on_disk) = on_disk_hash else {
438        return UpdateAction::Gone;
439    };
440
441    match recorded_hash {
442        // We know exactly what we last wrote.
443        Some(recorded) if recorded == on_disk => {
444            // Byte-identical to our record → safe to upgrade in place.
445            if recorded_version < current_version {
446                UpdateAction::Upgrade {
447                    from: recorded_version,
448                    to: current_version,
449                }
450            } else {
451                UpdateAction::UpToDate
452            }
453        }
454        Some(_) => UpdateAction::SkippedLocalEdits,
455        // v1 record adoption: no recorded hash. If the on-disk block matches
456        // the current writer's standard render, adopt; otherwise skip.
457        None => {
458            if standard_render_hash == Some(on_disk) {
459                UpdateAction::Adopt
460            } else {
461                UpdateAction::SkippedLocalEdits
462            }
463        }
464    }
465}
466
467pub fn update_all(dry_run: bool, force: bool) {
468    let cli_bin = match resolve_difflore_binary() {
469        Ok(b) => b,
470        Err(e) => crate::commands::util::exit_err(&e),
471    };
472    let mcp_bin = cli_bin.clone();
473
474    let Some(mut manifest) = manifest::load() else {
475        println!(
476            "{} no DiffLore install manifest (~/.difflore/mcp.json) found.",
477            style::warn("!")
478        );
479        println!(
480            "  {} run {} first to wire DiffLore into your agents.",
481            style::pewter(sym::BULLET),
482            style::cmd("difflore agents install"),
483        );
484        return;
485    };
486
487    // v1-record migration: an old record has no per-target `targets`
488    // array, only `installed_targets`. Seed provisional targets (hash unknown)
489    // so the loop's adoption path can recognise + claim our standard blocks
490    // without ever clobbering a user edit.
491    if manifest.targets.is_empty() && !manifest.installed_targets.is_empty() {
492        manifest.targets = manifest::v1_provisional_targets(&manifest.installed_targets);
493    }
494
495    let message = if dry_run {
496        "Checking DiffLore block upgrade plan for every recorded target"
497    } else {
498        "Upgrading DiffLore blocks that are unchanged since DiffLore wrote them"
499    };
500    let dry_tag = if dry_run {
501        format!(" {}", style::amber("(dry-run; no changes)"))
502    } else {
503        String::new()
504    };
505    println!(
506        "{} {}{dry_tag}",
507        style::emerald(sym::TIP),
508        style::pewter(message),
509    );
510    if force {
511        println!(
512            "  {} {}",
513            style::amber("·"),
514            style::amber("--force: locally-edited blocks will be overwritten"),
515        );
516    }
517    println!();
518
519    let mut any_changed = false;
520    let mut any_gone = false;
521    let mut any_skipped = false;
522    // Index targets by surface_key so we can re-stamp them in place.
523    for idx in 0..manifest.targets.len() {
524        let target_name = manifest.targets[idx].name.clone();
525        let Some(spec) = registry::find_spec(&target_name) else {
526            // Unknown surface (e.g. an agent removed from the registry) — leave
527            // the row untouched rather than guessing.
528            continue;
529        };
530        let block_kind = registry::block_kind_of(spec);
531        let current_version = block_kind.current_version();
532        let is_external = block_kind == BlockKind::ExternalCli;
533
534        let on_disk_hash = if is_external {
535            None
536        } else {
537            manifest::on_disk_block_hash(spec, &cli_bin)
538        };
539        let standard_render_hash = if is_external {
540            None
541        } else {
542            manifest::render_block_hash(spec, &mcp_bin, &cli_bin)
543        };
544
545        let recorded_version = manifest.targets[idx].block_version;
546        let recorded_hash = manifest.targets[idx].block_hash.clone();
547
548        let mut action = plan_update_target(
549            is_external,
550            recorded_hash.as_deref(),
551            recorded_version,
552            current_version,
553            on_disk_hash.as_deref(),
554            standard_render_hash.as_deref(),
555        );
556        // `--force` overrides the protected "local edits" skip, overwriting the
557        // hand-edited block with our current render (the legacy unconditional
558        // behaviour, now explicit and opt-in).
559        if force && matches!(action, UpdateAction::SkippedLocalEdits) {
560            action = UpdateAction::ForceOverwrite;
561        }
562
563        let changed = apply_update_action(
564            &action,
565            spec,
566            &mut manifest.targets[idx],
567            &mcp_bin,
568            &cli_bin,
569            current_version,
570            standard_render_hash.as_deref(),
571            on_disk_hash.as_deref(),
572            dry_run,
573        );
574        any_changed |= changed;
575        any_gone |= matches!(action, UpdateAction::Gone);
576        any_skipped |= matches!(action, UpdateAction::SkippedLocalEdits);
577    }
578
579    // Persist re-stamped versions/hashes only on a real run that changed
580    // something (dry-run touches nothing; a no-op run leaves the file alone).
581    if !dry_run && any_changed {
582        // A v1 record we just adopted/upgraded becomes a v2 manifest on save.
583        manifest.manifest_version = manifest::MANIFEST_VERSION;
584        if let Err(e) = manifest::save(&manifest) {
585            eprintln!(
586                "{} failed to update install manifest: {e}",
587                style::warn("warning:")
588            );
589        }
590    }
591
592    print_update_footer(dry_run, force, any_changed, any_gone, any_skipped);
593}
594
595/// Execute (or, in `dry_run`, just report) one [`UpdateAction`] against its
596/// target, re-stamping the manifest row on a real upgrade/adopt. Returns
597/// whether the manifest was mutated (so the caller knows to persist).
598#[allow(clippy::too_many_arguments)]
599// reason: each input is an independent fact the action needs; bundling them
600// into a struct would add indirection without improving clarity.
601fn apply_update_action(
602    action: &UpdateAction,
603    spec: &'static AgentSpec,
604    target: &mut ManifestTarget,
605    mcp_bin: &str,
606    cli_bin: &str,
607    current_version: u32,
608    standard_render_hash: Option<&str>,
609    on_disk_hash: Option<&str>,
610    dry_run: bool,
611) -> bool {
612    let now = manifest::now_rfc3339();
613    match action {
614        UpdateAction::UpToDate => {
615            report_update_line(
616                spec.name,
617                style::pewter("·"),
618                style::pewter("up to date"),
619                "",
620            );
621            false
622        }
623        UpdateAction::UpToDateExternal => {
624            report_update_line(
625                spec.name,
626                style::pewter("·"),
627                style::pewter("up to date"),
628                &format!(
629                    "managed by {} (no local block to upgrade)",
630                    external_cli_label(spec)
631                ),
632            );
633            false
634        }
635        UpdateAction::Adopt => {
636            let verb = if dry_run { "would adopt" } else { "adopted" };
637            report_update_line(
638                spec.name,
639                style::ok(sym::OK),
640                style::emerald(verb),
641                "recognised the on-disk block as DiffLore's standard render",
642            );
643            if dry_run {
644                return false;
645            }
646            target.block_hash = standard_render_hash.or(on_disk_hash).map(ToOwned::to_owned);
647            target.block_version = current_version;
648            target.updated_at = now;
649            true
650        }
651        UpdateAction::Upgrade { from, to } => {
652            let verb = if dry_run {
653                format!("would upgrade v{from}→v{to}")
654            } else {
655                format!("upgraded v{from}→v{to}")
656            };
657            report_update_line(spec.name, style::ok(sym::OK), style::emerald(&verb), "");
658            if dry_run {
659                return false;
660            }
661            // Re-render the current block in place (the registry installer
662            // performs the same destructive merge that replaces our block).
663            // Claude Code hooks have no standalone installer — they ride the
664            // Claude Code MCP install — so re-render through that surface.
665            let render_spec = effective_install_spec(spec);
666            let outcome = registry::install(render_spec, mcp_bin, cli_bin, false);
667            if let Status::Error(e) = &outcome.status {
668                eprintln!("      {}", style::danger(e));
669                return false;
670            }
671            // Re-hash from the standard render we just wrote, then re-stamp.
672            target.block_hash = standard_render_hash
673                .map(ToOwned::to_owned)
674                .or_else(|| manifest::on_disk_block_hash(spec, cli_bin));
675            target.block_version = current_version;
676            target.updated_at = now;
677            true
678        }
679        UpdateAction::ReissueCli { from, to } => {
680            let verb = if dry_run {
681                format!(
682                    "would re-issue {} add (v{from}→v{to})",
683                    external_cli_label(spec)
684                )
685            } else {
686                format!("re-issued {} add (v{from}→v{to})", external_cli_label(spec))
687            };
688            report_update_line(spec.name, style::ok(sym::OK), style::emerald(&verb), "");
689            if dry_run {
690                return false;
691            }
692            // Re-run the idempotent CLI add through the registry driver.
693            let outcome = registry::install(spec, mcp_bin, cli_bin, false);
694            if let Status::Error(e) = &outcome.status {
695                eprintln!("      {}", style::danger(e));
696                return false;
697            }
698            target.block_version = current_version;
699            target.updated_at = now;
700            true
701        }
702        UpdateAction::Gone => {
703            report_update_line(
704                spec.name,
705                style::warn("!"),
706                style::amber("gone"),
707                "no DiffLore block on disk — reinstall with `difflore agents install`",
708            );
709            false
710        }
711        UpdateAction::SkippedLocalEdits => {
712            report_update_line(
713                spec.name,
714                style::pewter("·"),
715                style::pewter("skipped: local edits since DiffLore wrote it"),
716                &format!(
717                    "{} — re-run with --force to overwrite",
718                    target.config_path.as_deref().map_or_else(
719                        || spec.display.to_owned(),
720                        |p| public_install_detail(p, mcp_bin)
721                    ),
722                ),
723            );
724            false
725        }
726        UpdateAction::ForceOverwrite => {
727            let verb = if dry_run {
728                "would overwrite (--force)"
729            } else {
730                "overwrote (--force)"
731            };
732            report_update_line(
733                spec.name,
734                style::ok(sym::OK),
735                style::amber(verb),
736                "replaced the locally-edited block with DiffLore's current render",
737            );
738            if dry_run {
739                return false;
740            }
741            let render_spec = effective_install_spec(spec);
742            let outcome = registry::install(render_spec, mcp_bin, cli_bin, false);
743            if let Status::Error(e) = &outcome.status {
744                eprintln!("      {}", style::danger(e));
745                return false;
746            }
747            target.block_hash = standard_render_hash
748                .map(ToOwned::to_owned)
749                .or_else(|| manifest::on_disk_block_hash(spec, cli_bin));
750            target.block_version = current_version;
751            target.updated_at = now;
752            true
753        }
754    }
755}
756
757/// The surface to drive `registry::install` through when re-rendering `spec`'s
758/// block. Claude Code hooks have no standalone installer (their merge rides the
759/// Claude Code MCP install), so re-render them via the Claude Code MCP row;
760/// every other surface re-renders through itself.
761fn effective_install_spec(spec: &'static AgentSpec) -> &'static AgentSpec {
762    if spec.name == "Claude Code hooks"
763        && let Some(claude) = registry::find_spec("Claude Code")
764    {
765        return claude;
766    }
767    spec
768}
769
770fn external_cli_label(spec: &AgentSpec) -> &'static str {
771    // Codex / Claude are the only external-CLI surfaces; key off the name.
772    match spec.name {
773        "Codex" => "codex",
774        _ => "claude",
775    }
776}
777
778// `mark`/`verb` are freshly-constructed styled strings passed exactly once; by
779// value is the natural signature for this small print helper (no caller reuses
780// them), so the needless-pass-by-value pedantic lint is a false positive here.
781#[allow(clippy::needless_pass_by_value)]
782fn report_update_line(
783    name: &str,
784    mark: colored::ColoredString,
785    verb: colored::ColoredString,
786    sub: &str,
787) {
788    println!("  {mark} {:<14} {verb}", name.bold());
789    if !sub.is_empty() {
790        println!("      {}", style::pewter(sub));
791    }
792}
793
794fn print_update_footer(dry_run: bool, force: bool, changed: bool, gone: bool, skipped: bool) {
795    println!();
796    if dry_run {
797        println!(
798            "{} dry-run only: no blocks were re-rendered and the manifest was not touched.",
799            style::emerald(sym::TIP)
800        );
801        println!(
802            "  {} apply with {} when the plan looks right.",
803            style::pewter(sym::BULLET),
804            style::cmd("difflore agents update"),
805        );
806        return;
807    }
808    if changed {
809        println!(
810            "{} restart/reload the affected agents so they pick up the refreshed DiffLore blocks.",
811            style::emerald(sym::TIP),
812        );
813    } else {
814        println!(
815            "{} everything is already up to date — no blocks needed re-rendering.",
816            style::emerald(sym::TIP),
817        );
818    }
819    if skipped && !force {
820        println!(
821            "  {} some blocks were skipped because they had local edits; re-run with {} to overwrite them.",
822            style::pewter(sym::BULLET),
823            style::cmd("difflore agents update --force"),
824        );
825    }
826    if gone {
827        println!(
828            "  {} some recorded targets had no DiffLore block on disk; reinstall with {}.",
829            style::pewter(sym::BULLET),
830            style::cmd("difflore agents install"),
831        );
832    }
833}
834
835#[cfg(test)]
836mod update_tests {
837    use super::*;
838
839    #[test]
840    fn external_cli_reissues_only_on_version_bump() {
841        // Behind → re-issue; current → up to date. (No file ever touched.)
842        assert_eq!(
843            plan_update_target(true, None, 1, 2, None, None),
844            UpdateAction::ReissueCli { from: 1, to: 2 }
845        );
846        assert_eq!(
847            plan_update_target(true, None, 2, 2, None, None),
848            UpdateAction::UpToDateExternal
849        );
850    }
851
852    #[test]
853    fn unchanged_block_behind_version_upgrades() {
854        // recorded hash == on-disk hash, version behind → in-place upgrade.
855        assert_eq!(
856            plan_update_target(
857                false,
858                Some("sha256:aa"),
859                1,
860                2,
861                Some("sha256:aa"),
862                Some("sha256:bb")
863            ),
864            UpdateAction::Upgrade { from: 1, to: 2 }
865        );
866    }
867
868    #[test]
869    fn unchanged_block_at_current_version_is_up_to_date() {
870        assert_eq!(
871            plan_update_target(
872                false,
873                Some("sha256:aa"),
874                1,
875                1,
876                Some("sha256:aa"),
877                Some("sha256:aa")
878            ),
879            UpdateAction::UpToDate
880        );
881    }
882
883    #[test]
884    fn edited_block_is_skipped_not_clobbered() {
885        // recorded hash != on-disk hash → human edited it → skip.
886        assert_eq!(
887            plan_update_target(
888                false,
889                Some("sha256:aa"),
890                1,
891                2,
892                Some("sha256:zz"),
893                Some("sha256:bb")
894            ),
895            UpdateAction::SkippedLocalEdits
896        );
897    }
898
899    #[test]
900    fn missing_on_disk_block_is_gone() {
901        assert_eq!(
902            plan_update_target(false, Some("sha256:aa"), 1, 1, None, Some("sha256:bb")),
903            UpdateAction::Gone
904        );
905    }
906
907    #[test]
908    fn v1_record_adopts_standard_render_else_skips() {
909        // No recorded hash (v1 record). On-disk == standard render → adopt.
910        assert_eq!(
911            plan_update_target(false, None, 1, 1, Some("sha256:std"), Some("sha256:std")),
912            UpdateAction::Adopt
913        );
914        // On-disk != standard render → it's been edited → skip (don't clobber).
915        assert_eq!(
916            plan_update_target(false, None, 1, 1, Some("sha256:edited"), Some("sha256:std")),
917            UpdateAction::SkippedLocalEdits
918        );
919    }
920}