Skip to main content

spool/installers/
claude.rs

1//! Claude Code installer adapter.
2//!
3//! ## What we touch
4//! - `~/.claude.json`        — `mcpServers.spool` entry (R1)
5//! - `~/.claude/settings.json` — `hooks.{event}` arrays, append-only
6//!   per-event entries pointing to our hook scripts (R2)
7//! - `~/.claude/hooks/spool-*.sh`         — five hook scripts (R2)
8//! - `~/.claude/commands/spool-*.md`      — four slash commands (R2)
9//! - `~/.claude/skills/spool-runtime/SKILL.md` — runtime skill (R2)
10//!
11//! All sibling tools (proxyman, pencil, `bd prime`, Trellis, …) stay
12//! untouched. Every hook script / command / skill file we write uses a
13//! `spool-` prefix so uninstall can sweep precisely.
14//!
15//! ## Binary resolution
16//! - When `ctx.binary_path` is set, we use it verbatim (escape hatch
17//!   for power users running an alternate build).
18//! - Otherwise we default to `~/.cargo/bin/spool-mcp`. We deliberately
19//!   do NOT shell out to `cargo install --path .` from the installer:
20//!   that would couple `spool mcp install` to a long-running build
21//!   step that's hard to recover from inside a hook context. The
22//!   `diagnose` step warns when the binary is missing so `spool mcp
23//!   doctor` (R5) can guide the user. Until then, the user is
24//!   expected to run `cargo install --path .` once before
25//!   `spool mcp install`.
26//!
27//! ## Idempotency
28//! Re-running `install` after a successful install is a no-op when
29//! every desired piece already matches what's on disk. Hook script
30//! bodies are compared byte-for-byte; the report distinguishes
31//! `Installed` (some change written) from `Unchanged` (everything
32//! already in place).
33
34use anyhow::{Context, Result};
35use std::path::{Path, PathBuf};
36
37use super::shared::{
38    self, McpMergeOutcome, McpRemoveOutcome, SettingsHookOutcome, build_mcp_entry, merge_mcp_entry,
39    purge_settings_hook_entries, remove_mcp_entry, upsert_settings_hook_command,
40};
41use super::templates::{
42    self, CommandSpec, HookSpec, SkillSpec, claude_command_specs, claude_hook_specs,
43    claude_skill_specs,
44};
45use super::{
46    ClientId, DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport,
47    InstallStatus, Installer, UninstallReport, UninstallStatus, UpdateReport, UpdateStatus,
48};
49
50pub struct ClaudeInstaller {
51    home_override: Option<PathBuf>,
52}
53
54impl ClaudeInstaller {
55    pub fn new() -> Self {
56        Self {
57            home_override: None,
58        }
59    }
60
61    /// Test-only constructor: route file lookups under `root` instead
62    /// of the real `$HOME`. Used by unit tests + cli_smoke to keep the
63    /// user's actual `~/.claude*` untouched.
64    #[doc(hidden)]
65    pub fn with_home_root(root: PathBuf) -> Self {
66        Self {
67            home_override: Some(root),
68        }
69    }
70
71    fn home(&self) -> Result<PathBuf> {
72        match &self.home_override {
73            Some(p) => Ok(p.clone()),
74            None => shared::home_dir(),
75        }
76    }
77
78    fn claude_config_path(&self) -> Result<PathBuf> {
79        Ok(self.home()?.join(".claude.json"))
80    }
81
82    fn settings_path(&self) -> Result<PathBuf> {
83        Ok(self.home()?.join(".claude").join("settings.json"))
84    }
85
86    fn hooks_dir(&self) -> Result<PathBuf> {
87        Ok(self.home()?.join(".claude").join("hooks"))
88    }
89
90    fn commands_dir(&self) -> Result<PathBuf> {
91        Ok(self.home()?.join(".claude").join("commands"))
92    }
93
94    fn skills_dir(&self) -> Result<PathBuf> {
95        Ok(self.home()?.join(".claude").join("skills"))
96    }
97
98    fn default_binary_path(&self) -> Result<PathBuf> {
99        Ok(self.home()?.join(".cargo").join("bin").join("spool-mcp"))
100    }
101
102    fn resolve_binary_path(&self, ctx: &InstallContext) -> Result<PathBuf> {
103        match &ctx.binary_path {
104            Some(p) => Ok(p.clone()),
105            None => self.default_binary_path(),
106        }
107    }
108
109    fn validate_inputs(&self, ctx: &InstallContext, binary_path: &Path) -> Result<()> {
110        if !ctx.config_path.is_absolute() {
111            anyhow::bail!(
112                "config path must be absolute, got: {}",
113                ctx.config_path.display()
114            );
115        }
116        if !binary_path.is_absolute() {
117            anyhow::bail!(
118                "binary path must be absolute, got: {}",
119                binary_path.display()
120            );
121        }
122        Ok(())
123    }
124}
125
126impl Default for ClaudeInstaller {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl Installer for ClaudeInstaller {
133    fn id(&self) -> ClientId {
134        ClientId::Claude
135    }
136
137    fn detect(&self) -> Result<bool> {
138        let claude_dir = self.home()?.join(".claude");
139        Ok(claude_dir.exists() || self.claude_config_path()?.exists())
140    }
141
142    fn install(&self, ctx: &InstallContext) -> Result<InstallReport> {
143        let binary_path = self.resolve_binary_path(ctx)?;
144        self.validate_inputs(ctx, &binary_path)?;
145        shared::ensure_config_exists(&ctx.config_path)?;
146
147        let mut planned_writes: Vec<PathBuf> = Vec::new();
148        let mut backups: Vec<PathBuf> = Vec::new();
149        let mut notes: Vec<String> = Vec::new();
150
151        // ── 1. mcpServers entry in ~/.claude.json ──────────────────
152        let claude_config_path = self.claude_config_path()?;
153        let mut claude_doc = shared::read_json_or_empty(&claude_config_path)?;
154        let desired = build_mcp_entry(&binary_path, &ctx.config_path);
155        let mcp_outcome = merge_mcp_entry(&mut claude_doc, "spool", desired, ctx.force);
156
157        if !binary_path.exists() {
158            notes.push(format!(
159                "spool-mcp binary not found at {}. Run `cargo install --path .` or pass --binary-path.",
160                binary_path.display()
161            ));
162        }
163
164        let mcp_status = match mcp_outcome {
165            McpMergeOutcome::Inserted => MergeStatus::Changed,
166            McpMergeOutcome::Unchanged => MergeStatus::Unchanged,
167            McpMergeOutcome::Conflict {
168                force_applied: true,
169            } => {
170                notes.push(format!(
171                    "Existing spool mcpServers entry was overwritten (force=true). Backup at {}.",
172                    claude_config_path.display()
173                ));
174                MergeStatus::Changed
175            }
176            McpMergeOutcome::Conflict {
177                force_applied: false,
178            } => {
179                notes.push(
180                    "Existing spool mcpServers entry differs. Re-run with --force to overwrite, or `spool mcp uninstall` first."
181                        .to_string(),
182                );
183                MergeStatus::Conflict
184            }
185        };
186
187        // Short-circuit on hard mcpServers conflict — we don't keep
188        // going to write hooks/commands/skills until the user resolves
189        // the registration mismatch.
190        if matches!(mcp_status, MergeStatus::Conflict) {
191            return Ok(InstallReport {
192                client: ClientId::Claude.as_str().to_string(),
193                binary_path,
194                config_path: ctx.config_path.clone(),
195                status: InstallStatus::Conflict,
196                planned_writes,
197                backups,
198                notes,
199            });
200        }
201
202        // ── 2. settings.json hooks entries ─────────────────────────
203        let settings_path = self.settings_path()?;
204        let mut settings_doc = shared::read_json_or_empty(&settings_path)?;
205        let hooks_dir = self.hooks_dir()?;
206        let hook_specs = claude_hook_specs();
207        let mut settings_changed = false;
208        for spec in &hook_specs {
209            let target_path = hooks_dir.join(spec.file_name);
210            let target_str = target_path.to_string_lossy().into_owned();
211            match upsert_settings_hook_command(&mut settings_doc, spec.hook_event, &target_str) {
212                SettingsHookOutcome::Appended => settings_changed = true,
213                SettingsHookOutcome::Unchanged => {}
214            }
215        }
216
217        // ── 3. plan hook script writes ─────────────────────────────
218        let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
219        let hook_files: Vec<HookFilePlan> = hook_specs
220            .iter()
221            .map(|spec| HookFilePlan {
222                spec,
223                target_path: hooks_dir.join(spec.file_name),
224                rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
225            })
226            .collect();
227        let hook_files_changed = hook_files
228            .iter()
229            .any(|p| !file_has_exact_contents(&p.target_path, &p.rendered));
230
231        // ── 4. plan command + skill writes ─────────────────────────
232        let commands_dir = self.commands_dir()?;
233        let command_specs = claude_command_specs();
234        let command_files: Vec<CommandFilePlan> = command_specs
235            .iter()
236            .map(|spec| CommandFilePlan {
237                spec,
238                target_path: commands_dir.join(spec.file_name),
239            })
240            .collect();
241        let commands_changed = command_files
242            .iter()
243            .any(|p| !file_has_exact_contents(&p.target_path, p.spec.body));
244
245        let skills_dir = self.skills_dir()?;
246        let skill_specs = claude_skill_specs();
247        let skill_files: Vec<SkillFilePlan> = skill_specs
248            .iter()
249            .map(|spec| SkillFilePlan {
250                spec,
251                target_path: skills_dir.join(spec.dir_name).join("SKILL.md"),
252            })
253            .collect();
254        let skills_changed = skill_files
255            .iter()
256            .any(|p| !file_has_exact_contents(&p.target_path, p.spec.body));
257
258        // ── 5. dry-run preview ─────────────────────────────────────
259        let any_change = matches!(mcp_status, MergeStatus::Changed)
260            || settings_changed
261            || hook_files_changed
262            || commands_changed
263            || skills_changed;
264
265        if ctx.dry_run {
266            if matches!(mcp_status, MergeStatus::Changed) {
267                planned_writes.push(claude_config_path.clone());
268            }
269            if settings_changed {
270                planned_writes.push(settings_path.clone());
271            }
272            for plan in &hook_files {
273                if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
274                    planned_writes.push(plan.target_path.clone());
275                }
276            }
277            for plan in &command_files {
278                if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
279                    planned_writes.push(plan.target_path.clone());
280                }
281            }
282            for plan in &skill_files {
283                if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
284                    planned_writes.push(plan.target_path.clone());
285                }
286            }
287            return Ok(InstallReport {
288                client: ClientId::Claude.as_str().to_string(),
289                binary_path,
290                config_path: ctx.config_path.clone(),
291                status: if any_change {
292                    InstallStatus::DryRun
293                } else {
294                    InstallStatus::Unchanged
295                },
296                planned_writes,
297                backups,
298                notes,
299            });
300        }
301
302        // ── 6. apply mcpServers ────────────────────────────────────
303        if matches!(mcp_status, MergeStatus::Changed) {
304            if let Some(b) = shared::backup_file(&claude_config_path)
305                .with_context(|| format!("backing up {}", claude_config_path.display()))?
306            {
307                backups.push(b);
308            }
309            shared::write_json_atomic(&claude_config_path, &claude_doc)
310                .with_context(|| format!("writing {}", claude_config_path.display()))?;
311            planned_writes.push(claude_config_path.clone());
312        }
313
314        // ── 7. apply settings.json hooks ───────────────────────────
315        if settings_changed {
316            if let Some(b) = shared::backup_file(&settings_path)
317                .with_context(|| format!("backing up {}", settings_path.display()))?
318            {
319                backups.push(b);
320            }
321            shared::write_json_atomic(&settings_path, &settings_doc)
322                .with_context(|| format!("writing {}", settings_path.display()))?;
323            planned_writes.push(settings_path.clone());
324        }
325
326        // ── 8. apply hook script files ─────────────────────────────
327        if !hooks_dir.exists() {
328            std::fs::create_dir_all(&hooks_dir)
329                .with_context(|| format!("creating {}", hooks_dir.display()))?;
330        }
331        for plan in &hook_files {
332            if file_has_exact_contents(&plan.target_path, &plan.rendered) {
333                continue;
334            }
335            std::fs::write(&plan.target_path, &plan.rendered)
336                .with_context(|| format!("writing {}", plan.target_path.display()))?;
337            set_executable(&plan.target_path)?;
338            planned_writes.push(plan.target_path.clone());
339        }
340
341        // ── 9. apply commands ──────────────────────────────────────
342        if !commands_dir.exists() {
343            std::fs::create_dir_all(&commands_dir)
344                .with_context(|| format!("creating {}", commands_dir.display()))?;
345        }
346        for plan in &command_files {
347            if file_has_exact_contents(&plan.target_path, plan.spec.body) {
348                continue;
349            }
350            std::fs::write(&plan.target_path, plan.spec.body)
351                .with_context(|| format!("writing {}", plan.target_path.display()))?;
352            planned_writes.push(plan.target_path.clone());
353        }
354
355        // ── 10. apply skills ───────────────────────────────────────
356        for plan in &skill_files {
357            if file_has_exact_contents(&plan.target_path, plan.spec.body) {
358                continue;
359            }
360            if let Some(parent) = plan.target_path.parent()
361                && !parent.exists()
362            {
363                std::fs::create_dir_all(parent)
364                    .with_context(|| format!("creating {}", parent.display()))?;
365            }
366            std::fs::write(&plan.target_path, plan.spec.body)
367                .with_context(|| format!("writing {}", plan.target_path.display()))?;
368            planned_writes.push(plan.target_path.clone());
369        }
370
371        let final_status = if any_change {
372            InstallStatus::Installed
373        } else {
374            InstallStatus::Unchanged
375        };
376
377        Ok(InstallReport {
378            client: ClientId::Claude.as_str().to_string(),
379            binary_path,
380            config_path: ctx.config_path.clone(),
381            status: final_status,
382            planned_writes,
383            backups,
384            notes,
385        })
386    }
387
388    fn update(&self, ctx: &InstallContext) -> Result<UpdateReport> {
389        let mut updated_paths: Vec<PathBuf> = Vec::new();
390        let mut notes: Vec<String> = Vec::new();
391
392        // ── 1. Check if spool is installed (mcpServers entry exists) ──
393        let claude_config_path = self.claude_config_path()?;
394        if !claude_config_path.exists() {
395            return Ok(UpdateReport {
396                client: ClientId::Claude.as_str().to_string(),
397                status: UpdateStatus::NotInstalled,
398                updated_paths,
399                notes: vec!["spool is not installed (no ~/.claude.json found). Run `spool mcp install` first.".to_string()],
400            });
401        }
402        let claude_doc = shared::read_json_or_empty(&claude_config_path)?;
403        let mcp_entry = claude_doc.get("mcpServers").and_then(|v| v.get("spool"));
404        if mcp_entry.is_none() {
405            return Ok(UpdateReport {
406                client: ClientId::Claude.as_str().to_string(),
407                status: UpdateStatus::NotInstalled,
408                updated_paths,
409                notes: vec![
410                    "spool is not registered in mcpServers. Run `spool mcp install` first."
411                        .to_string(),
412                ],
413            });
414        }
415
416        // ── 2. Resolve binary path from existing entry or ctx override ──
417        let binary_path = match &ctx.binary_path {
418            Some(p) => p.clone(),
419            None => {
420                // Try to read from the existing mcpServers entry
421                let existing_bin = mcp_entry
422                    .and_then(|e| e.get("command"))
423                    .and_then(|c| c.as_str())
424                    .map(PathBuf::from);
425                match existing_bin {
426                    Some(p) => p,
427                    None => self.default_binary_path()?,
428                }
429            }
430        };
431        let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
432
433        // ── 3. Re-render hooks and compare with on-disk ───────────────
434        let hooks_dir = self.hooks_dir()?;
435        let hook_specs = claude_hook_specs();
436        let hook_files: Vec<HookFilePlan> = hook_specs
437            .iter()
438            .map(|spec| HookFilePlan {
439                spec,
440                target_path: hooks_dir.join(spec.file_name),
441                rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
442            })
443            .collect();
444
445        // ── 4. Re-render commands and compare ─────────────────────────
446        let commands_dir = self.commands_dir()?;
447        let command_specs = claude_command_specs();
448        let command_files: Vec<CommandFilePlan> = command_specs
449            .iter()
450            .map(|spec| CommandFilePlan {
451                spec,
452                target_path: commands_dir.join(spec.file_name),
453            })
454            .collect();
455
456        // ── 5. Re-render skills and compare ──────────────────────────
457        let skills_dir = self.skills_dir()?;
458        let skill_specs = claude_skill_specs();
459        let skill_files: Vec<SkillFilePlan> = skill_specs
460            .iter()
461            .map(|spec| SkillFilePlan {
462                spec,
463                target_path: skills_dir.join(spec.dir_name).join("SKILL.md"),
464            })
465            .collect();
466
467        // ── 6. Collect diffs ──────────────────────────────────────────
468        let mut diffs: Vec<PathBuf> = Vec::new();
469        for plan in &hook_files {
470            if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
471                diffs.push(plan.target_path.clone());
472            }
473        }
474        for plan in &command_files {
475            if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
476                diffs.push(plan.target_path.clone());
477            }
478        }
479        for plan in &skill_files {
480            if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
481                diffs.push(plan.target_path.clone());
482            }
483        }
484
485        if diffs.is_empty() {
486            return Ok(UpdateReport {
487                client: ClientId::Claude.as_str().to_string(),
488                status: UpdateStatus::Unchanged,
489                updated_paths,
490                notes,
491            });
492        }
493
494        // ── 7. Dry-run: report what would change ─────────────────────
495        if ctx.dry_run {
496            return Ok(UpdateReport {
497                client: ClientId::Claude.as_str().to_string(),
498                status: UpdateStatus::DryRun,
499                updated_paths: diffs,
500                notes,
501            });
502        }
503
504        // ── 8. Apply hook script writes ──────────────────────────────
505        if !hooks_dir.exists() {
506            std::fs::create_dir_all(&hooks_dir)
507                .with_context(|| format!("creating {}", hooks_dir.display()))?;
508        }
509        for plan in &hook_files {
510            if file_has_exact_contents(&plan.target_path, &plan.rendered) {
511                continue;
512            }
513            std::fs::write(&plan.target_path, &plan.rendered)
514                .with_context(|| format!("writing {}", plan.target_path.display()))?;
515            set_executable(&plan.target_path)?;
516            updated_paths.push(plan.target_path.clone());
517        }
518
519        // ── 9. Apply command writes ──────────────────────────────────
520        if !commands_dir.exists() {
521            std::fs::create_dir_all(&commands_dir)
522                .with_context(|| format!("creating {}", commands_dir.display()))?;
523        }
524        for plan in &command_files {
525            if file_has_exact_contents(&plan.target_path, plan.spec.body) {
526                continue;
527            }
528            std::fs::write(&plan.target_path, plan.spec.body)
529                .with_context(|| format!("writing {}", plan.target_path.display()))?;
530            updated_paths.push(plan.target_path.clone());
531        }
532
533        // ── 10. Apply skill writes ──────────────────────────────────
534        for plan in &skill_files {
535            if file_has_exact_contents(&plan.target_path, plan.spec.body) {
536                continue;
537            }
538            if let Some(parent) = plan.target_path.parent()
539                && !parent.exists()
540            {
541                std::fs::create_dir_all(parent)
542                    .with_context(|| format!("creating {}", parent.display()))?;
543            }
544            std::fs::write(&plan.target_path, plan.spec.body)
545                .with_context(|| format!("writing {}", plan.target_path.display()))?;
546            updated_paths.push(plan.target_path.clone());
547        }
548
549        if !updated_paths.is_empty() {
550            notes.push(format!(
551                "{} file(s) updated to latest templates.",
552                updated_paths.len()
553            ));
554        }
555
556        Ok(UpdateReport {
557            client: ClientId::Claude.as_str().to_string(),
558            status: UpdateStatus::Updated,
559            updated_paths,
560            notes,
561        })
562    }
563
564    fn uninstall(&self, ctx: &InstallContext) -> Result<UninstallReport> {
565        let claude_config_path = self.claude_config_path()?;
566        let settings_path = self.settings_path()?;
567        let hooks_dir = self.hooks_dir()?;
568        let commands_dir = self.commands_dir()?;
569        let skills_dir = self.skills_dir()?;
570
571        let mut notes: Vec<String> = Vec::new();
572        let mut removed_paths: Vec<PathBuf> = Vec::new();
573        let mut backups: Vec<PathBuf> = Vec::new();
574        let mut any_change = false;
575
576        // ── 1. mcpServers ──────────────────────────────────────────
577        let claude_doc_after_purge = if claude_config_path.exists() {
578            let mut doc = shared::read_json_or_empty(&claude_config_path)?;
579            match remove_mcp_entry(&mut doc, "spool") {
580                McpRemoveOutcome::Removed => {
581                    any_change = true;
582                    Some(doc)
583                }
584                McpRemoveOutcome::NotPresent => None,
585            }
586        } else {
587            None
588        };
589
590        // ── 2. settings.json hooks purge ───────────────────────────
591        let settings_doc_after_purge = if settings_path.exists() {
592            let mut doc = shared::read_json_or_empty(&settings_path)?;
593            let removed = purge_settings_hook_entries(&mut doc, "spool-");
594            if removed > 0 {
595                any_change = true;
596                Some(doc)
597            } else {
598                None
599            }
600        } else {
601            None
602        };
603
604        // ── 3. plan file removals ──────────────────────────────────
605        let hook_files: Vec<PathBuf> = claude_hook_specs()
606            .iter()
607            .map(|s| hooks_dir.join(s.file_name))
608            .filter(|p| p.exists())
609            .collect();
610        if !hook_files.is_empty() {
611            any_change = true;
612        }
613
614        let command_files: Vec<PathBuf> = claude_command_specs()
615            .iter()
616            .map(|s| commands_dir.join(s.file_name))
617            .filter(|p| p.exists())
618            .collect();
619        if !command_files.is_empty() {
620            any_change = true;
621        }
622
623        let skill_dirs: Vec<PathBuf> = claude_skill_specs()
624            .iter()
625            .map(|s| skills_dir.join(s.dir_name))
626            .filter(|p| p.exists())
627            .collect();
628        if !skill_dirs.is_empty() {
629            any_change = true;
630        }
631
632        if !any_change {
633            notes.push("nothing to uninstall — no spool artifacts found.".to_string());
634            return Ok(UninstallReport {
635                client: ClientId::Claude.as_str().to_string(),
636                status: UninstallStatus::NotInstalled,
637                removed_paths,
638                backups,
639                notes,
640            });
641        }
642
643        if ctx.dry_run {
644            if claude_doc_after_purge.is_some() {
645                removed_paths.push(claude_config_path);
646            }
647            if settings_doc_after_purge.is_some() {
648                removed_paths.push(settings_path);
649            }
650            removed_paths.extend(hook_files);
651            removed_paths.extend(command_files);
652            removed_paths.extend(skill_dirs);
653            return Ok(UninstallReport {
654                client: ClientId::Claude.as_str().to_string(),
655                status: UninstallStatus::DryRun,
656                removed_paths,
657                backups,
658                notes,
659            });
660        }
661
662        // ── 4. apply mcpServers purge ──────────────────────────────
663        if let Some(doc) = claude_doc_after_purge {
664            if let Some(b) = shared::backup_file(&claude_config_path)? {
665                backups.push(b);
666            }
667            shared::write_json_atomic(&claude_config_path, &doc)?;
668            removed_paths.push(claude_config_path);
669        }
670
671        // ── 5. apply settings.json purge ───────────────────────────
672        if let Some(doc) = settings_doc_after_purge {
673            if let Some(b) = shared::backup_file(&settings_path)? {
674                backups.push(b);
675            }
676            shared::write_json_atomic(&settings_path, &doc)?;
677            removed_paths.push(settings_path);
678        }
679
680        // ── 6. delete hook scripts / commands / skills ─────────────
681        for p in hook_files {
682            std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
683            removed_paths.push(p);
684        }
685        for p in command_files {
686            std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
687            removed_paths.push(p);
688        }
689        for p in skill_dirs {
690            std::fs::remove_dir_all(&p).with_context(|| format!("removing {}", p.display()))?;
691            removed_paths.push(p);
692        }
693
694        Ok(UninstallReport {
695            client: ClientId::Claude.as_str().to_string(),
696            status: UninstallStatus::Removed,
697            removed_paths,
698            backups,
699            notes,
700        })
701    }
702
703    fn diagnose(&self, ctx: &InstallContext) -> Result<DiagnosticReport> {
704        let mut checks = Vec::new();
705
706        // Claude config presence
707        let config_doc_path = self.claude_config_path()?;
708        let config_status = if config_doc_path.exists() {
709            DiagnosticStatus::Ok
710        } else {
711            DiagnosticStatus::Warn
712        };
713        checks.push(DiagnosticCheck {
714            name: "claude_config_exists".into(),
715            status: config_status,
716            detail: format!("{}", config_doc_path.display()),
717        });
718
719        // mcpServers.spool registration
720        let registration_status = if config_doc_path.exists() {
721            let doc = shared::read_json_or_empty(&config_doc_path)?;
722            if doc.get("mcpServers").and_then(|v| v.get("spool")).is_some() {
723                DiagnosticStatus::Ok
724            } else {
725                DiagnosticStatus::Warn
726            }
727        } else {
728            DiagnosticStatus::NotApplicable
729        };
730        checks.push(DiagnosticCheck {
731            name: "mcp_servers_spool_registered".into(),
732            status: registration_status,
733            detail: "mcpServers.spool entry presence".into(),
734        });
735
736        // Binary path
737        let binary_path = self.resolve_binary_path(ctx)?;
738        let binary_status = if binary_path.exists() {
739            DiagnosticStatus::Ok
740        } else {
741            DiagnosticStatus::Fail
742        };
743        checks.push(DiagnosticCheck {
744            name: "spool_mcp_binary".into(),
745            status: binary_status,
746            detail: format!("{}", binary_path.display()),
747        });
748
749        // Config TOML readable
750        let toml_status = if ctx.config_path.exists() {
751            DiagnosticStatus::Ok
752        } else {
753            DiagnosticStatus::Fail
754        };
755        checks.push(DiagnosticCheck {
756            name: "spool_config_readable".into(),
757            status: toml_status,
758            detail: format!("{}", ctx.config_path.display()),
759        });
760
761        // settings.json hooks have at least one spool- entry
762        let settings_path = self.settings_path()?;
763        let hooks_registered_status = if settings_path.exists() {
764            let doc = shared::read_json_or_empty(&settings_path)?;
765            if has_any_spool_hook_entry(&doc) {
766                DiagnosticStatus::Ok
767            } else {
768                DiagnosticStatus::Warn
769            }
770        } else {
771            DiagnosticStatus::Warn
772        };
773        checks.push(DiagnosticCheck {
774            name: "claude_settings_hooks_registered".into(),
775            status: hooks_registered_status,
776            detail: format!("{}", settings_path.display()),
777        });
778
779        // hook script files present
780        let hooks_dir = self.hooks_dir()?;
781        let mut missing: Vec<String> = Vec::new();
782        for spec in claude_hook_specs() {
783            let p = hooks_dir.join(spec.file_name);
784            if !p.exists() {
785                missing.push(spec.file_name.to_string());
786            }
787        }
788        let hook_files_status = if missing.is_empty() {
789            DiagnosticStatus::Ok
790        } else {
791            DiagnosticStatus::Warn
792        };
793        let hook_files_detail = if missing.is_empty() {
794            format!("{} (5/5 present)", hooks_dir.display())
795        } else {
796            format!("{} missing: {}", hooks_dir.display(), missing.join(", "))
797        };
798        checks.push(DiagnosticCheck {
799            name: "spool_hook_scripts".into(),
800            status: hook_files_status,
801            detail: hook_files_detail,
802        });
803
804        // skill present
805        let skills_dir = self.skills_dir()?;
806        let skill_present = claude_skill_specs()
807            .iter()
808            .all(|s| skills_dir.join(s.dir_name).join("SKILL.md").exists());
809        checks.push(DiagnosticCheck {
810            name: "spool_skill_present".into(),
811            status: if skill_present {
812                DiagnosticStatus::Ok
813            } else {
814                DiagnosticStatus::Warn
815            },
816            detail: format!("{}", skills_dir.display()),
817        });
818
819        Ok(DiagnosticReport {
820            client: ClientId::Claude.as_str().to_string(),
821            checks,
822        })
823    }
824}
825
826// ─────────────────────────────────────────────────────────────────────
827// internal plan / merge types
828// ─────────────────────────────────────────────────────────────────────
829
830#[derive(Debug, Clone, Copy, PartialEq, Eq)]
831enum MergeStatus {
832    Changed,
833    Unchanged,
834    Conflict,
835}
836
837struct HookFilePlan<'a> {
838    #[allow(dead_code)] // kept for symmetry with command/skill plans + future debug
839    spec: &'a HookSpec,
840    target_path: PathBuf,
841    rendered: String,
842}
843
844struct CommandFilePlan<'a> {
845    spec: &'a CommandSpec,
846    target_path: PathBuf,
847}
848
849struct SkillFilePlan<'a> {
850    spec: &'a SkillSpec,
851    target_path: PathBuf,
852}
853
854fn file_has_exact_contents(path: &Path, expected: &str) -> bool {
855    if !path.exists() {
856        return false;
857    }
858    match std::fs::read_to_string(path) {
859        Ok(actual) => actual == expected,
860        Err(_) => false,
861    }
862}
863
864fn has_any_spool_hook_entry(doc: &serde_json::Value) -> bool {
865    let Some(hooks) = doc.get("hooks").and_then(|v| v.as_object()) else {
866        return false;
867    };
868    for entries in hooks.values() {
869        let Some(arr) = entries.as_array() else {
870            continue;
871        };
872        for entry in arr {
873            let Some(inner) = entry.get("hooks").and_then(|v| v.as_array()) else {
874                continue;
875            };
876            for h in inner {
877                if let Some(cmd) = h.get("command").and_then(|c| c.as_str())
878                    && cmd.contains("spool-")
879                {
880                    return true;
881                }
882            }
883        }
884    }
885    false
886}
887
888#[cfg(unix)]
889fn set_executable(path: &Path) -> Result<()> {
890    use std::os::unix::fs::PermissionsExt;
891    let mut perms = std::fs::metadata(path)
892        .with_context(|| format!("stat {}", path.display()))?
893        .permissions();
894    perms.set_mode(0o755);
895    std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
896    Ok(())
897}
898
899#[cfg(not(unix))]
900fn set_executable(_path: &Path) -> Result<()> {
901    // Hooks on Windows aren't expected to be invoked via shebang;
902    // Claude Code uses CMD/PowerShell. Out of scope for R2.
903    Ok(())
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909    use serde_json::json;
910    use std::fs;
911    use tempfile::tempdir;
912
913    fn setup() -> (tempfile::TempDir, ClaudeInstaller, InstallContext) {
914        let temp = tempdir().unwrap();
915        let home = temp.path().to_path_buf();
916        let installer = ClaudeInstaller::with_home_root(home.clone());
917
918        let config_path = home.join("spool.toml");
919        fs::write(&config_path, "[vault]\nroot=\"/tmp\"\n").unwrap();
920        let binary_path = home.join("fake-spool-mcp");
921        fs::write(&binary_path, "#!/bin/sh\nexit 0\n").unwrap();
922
923        let ctx = InstallContext {
924            binary_path: Some(binary_path),
925            config_path,
926            dry_run: false,
927            force: false,
928        };
929        (temp, installer, ctx)
930    }
931
932    #[test]
933    fn detect_returns_false_when_no_claude_dir() {
934        let temp = tempdir().unwrap();
935        let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
936        assert!(!installer.detect().unwrap());
937    }
938
939    #[test]
940    fn detect_returns_true_when_claude_dir_present() {
941        let temp = tempdir().unwrap();
942        fs::create_dir_all(temp.path().join(".claude")).unwrap();
943        let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
944        assert!(installer.detect().unwrap());
945    }
946
947    #[test]
948    fn install_writes_full_payload_on_first_run() {
949        let (temp, installer, ctx) = setup();
950        let report = installer.install(&ctx).unwrap();
951        assert_eq!(report.status, InstallStatus::Installed);
952
953        // Files written:
954        assert!(temp.path().join(".claude.json").exists());
955        assert!(temp.path().join(".claude").join("settings.json").exists());
956        for spec in claude_hook_specs() {
957            let p = temp
958                .path()
959                .join(".claude")
960                .join("hooks")
961                .join(spec.file_name);
962            assert!(p.exists(), "{} missing", p.display());
963        }
964        for spec in claude_command_specs() {
965            let p = temp
966                .path()
967                .join(".claude")
968                .join("commands")
969                .join(spec.file_name);
970            assert!(p.exists(), "{} missing", p.display());
971        }
972        let skill_path = temp
973            .path()
974            .join(".claude")
975            .join("skills")
976            .join("spool-runtime")
977            .join("SKILL.md");
978        assert!(skill_path.exists());
979
980        // mcpServers.spool written
981        let claude: serde_json::Value =
982            serde_json::from_str(&fs::read_to_string(temp.path().join(".claude.json")).unwrap())
983                .unwrap();
984        assert!(claude["mcpServers"]["spool"].is_object());
985
986        // settings.json hooks each have one spool- entry
987        let settings: serde_json::Value = serde_json::from_str(
988            &fs::read_to_string(temp.path().join(".claude").join("settings.json")).unwrap(),
989        )
990        .unwrap();
991        for spec in claude_hook_specs() {
992            let entries = settings["hooks"][spec.hook_event].as_array().unwrap();
993            assert!(
994                entries.iter().any(|e| e["hooks"][0]["command"]
995                    .as_str()
996                    .is_some_and(|c| c.contains("spool-"))),
997                "{} entry missing in settings.json",
998                spec.hook_event
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn install_preserves_existing_session_start_hook() {
1005        let (temp, installer, ctx) = setup();
1006        // Pre-seed `bd prime` style entry — same shape as the real
1007        // user has on their machine.
1008        fs::create_dir_all(temp.path().join(".claude")).unwrap();
1009        let preexisting = json!({
1010            "hooks": {
1011                "SessionStart": [
1012                    {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
1013                ]
1014            }
1015        });
1016        fs::write(
1017            temp.path().join(".claude").join("settings.json"),
1018            serde_json::to_string_pretty(&preexisting).unwrap(),
1019        )
1020        .unwrap();
1021
1022        let report = installer.install(&ctx).unwrap();
1023        assert_eq!(report.status, InstallStatus::Installed);
1024
1025        let settings: serde_json::Value = serde_json::from_str(
1026            &fs::read_to_string(temp.path().join(".claude").join("settings.json")).unwrap(),
1027        )
1028        .unwrap();
1029        let session_entries = settings["hooks"]["SessionStart"].as_array().unwrap();
1030        assert_eq!(session_entries.len(), 2);
1031        assert_eq!(session_entries[0]["hooks"][0]["command"], "bd prime");
1032        let spool_cmd = session_entries[1]["hooks"][0]["command"].as_str().unwrap();
1033        assert!(spool_cmd.contains("spool-SessionStart.sh"));
1034    }
1035
1036    #[test]
1037    fn install_dry_run_does_not_write_anything() {
1038        let (temp, installer, mut ctx) = setup();
1039        ctx.dry_run = true;
1040        let report = installer.install(&ctx).unwrap();
1041        assert_eq!(report.status, InstallStatus::DryRun);
1042        assert!(!report.planned_writes.is_empty());
1043        // No directories should be created.
1044        assert!(!temp.path().join(".claude").exists());
1045        assert!(!temp.path().join(".claude.json").exists());
1046    }
1047
1048    #[test]
1049    fn install_unchanged_on_repeat() {
1050        let (_temp, installer, ctx) = setup();
1051        let _ = installer.install(&ctx).unwrap();
1052        let second = installer.install(&ctx).unwrap();
1053        assert_eq!(second.status, InstallStatus::Unchanged);
1054    }
1055
1056    #[test]
1057    fn install_re_renders_hook_when_template_drifts() {
1058        let (temp, installer, ctx) = setup();
1059        let _ = installer.install(&ctx).unwrap();
1060        let stop_path = temp
1061            .path()
1062            .join(".claude")
1063            .join("hooks")
1064            .join("spool-Stop.sh");
1065        fs::write(&stop_path, "tampered\n").unwrap();
1066        let report = installer.install(&ctx).unwrap();
1067        assert_eq!(report.status, InstallStatus::Installed);
1068        let restored = fs::read_to_string(&stop_path).unwrap();
1069        assert!(!restored.contains("tampered"));
1070        assert!(restored.contains("hook stop"));
1071    }
1072
1073    #[test]
1074    fn install_marks_conflict_when_existing_mcp_entry_differs() {
1075        let (temp, installer, ctx) = setup();
1076        let claude_json = temp.path().join(".claude.json");
1077        let preexisting = json!({
1078            "mcpServers": {
1079                "spool": {"type": "stdio", "command": "/old/path", "args": []}
1080            }
1081        });
1082        fs::write(
1083            &claude_json,
1084            serde_json::to_string_pretty(&preexisting).unwrap(),
1085        )
1086        .unwrap();
1087
1088        let report = installer.install(&ctx).unwrap();
1089        assert_eq!(report.status, InstallStatus::Conflict);
1090        // Conflict path short-circuits — no hook scripts get written.
1091        assert!(!temp.path().join(".claude").join("hooks").exists());
1092    }
1093
1094    #[test]
1095    fn install_force_overrides_conflict() {
1096        let (temp, installer, mut ctx) = setup();
1097        ctx.force = true;
1098        let claude_json = temp.path().join(".claude.json");
1099        let preexisting = json!({
1100            "mcpServers": {
1101                "spool": {"type": "stdio", "command": "/old/path", "args": []}
1102            }
1103        });
1104        fs::write(
1105            &claude_json,
1106            serde_json::to_string_pretty(&preexisting).unwrap(),
1107        )
1108        .unwrap();
1109        let report = installer.install(&ctx).unwrap();
1110        assert_eq!(report.status, InstallStatus::Installed);
1111        assert!(temp.path().join(".claude").join("hooks").exists());
1112    }
1113
1114    #[test]
1115    fn install_records_warning_when_binary_missing() {
1116        let temp = tempdir().unwrap();
1117        let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
1118        let config_path = temp.path().join("spool.toml");
1119        fs::write(&config_path, "x=1").unwrap();
1120        let ctx = InstallContext {
1121            binary_path: Some(temp.path().join("nope")),
1122            config_path,
1123            dry_run: false,
1124            force: false,
1125        };
1126        let report = installer.install(&ctx).unwrap();
1127        assert!(report.notes.iter().any(|n| n.contains("not found")));
1128    }
1129
1130    #[test]
1131    fn install_rejects_relative_binary_path() {
1132        let (_temp, installer, mut ctx) = setup();
1133        ctx.binary_path = Some(PathBuf::from("relative/path"));
1134        let err = installer.install(&ctx).unwrap_err();
1135        assert!(err.to_string().contains("binary path must be absolute"));
1136    }
1137
1138    #[test]
1139    fn install_rejects_relative_config_path() {
1140        let (_temp, installer, mut ctx) = setup();
1141        ctx.config_path = PathBuf::from("relative/spool.toml");
1142        let err = installer.install(&ctx).unwrap_err();
1143        assert!(err.to_string().contains("config path must be absolute"));
1144    }
1145
1146    #[cfg(unix)]
1147    #[test]
1148    fn install_makes_hook_scripts_executable() {
1149        use std::os::unix::fs::PermissionsExt;
1150        let (temp, installer, ctx) = setup();
1151        let _ = installer.install(&ctx).unwrap();
1152        let session = temp
1153            .path()
1154            .join(".claude")
1155            .join("hooks")
1156            .join("spool-SessionStart.sh");
1157        let perms = fs::metadata(&session).unwrap().permissions();
1158        assert_eq!(perms.mode() & 0o777, 0o755);
1159    }
1160
1161    #[test]
1162    fn uninstall_removes_full_payload() {
1163        let (temp, installer, ctx) = setup();
1164        let _ = installer.install(&ctx).unwrap();
1165
1166        let report = installer.uninstall(&ctx).unwrap();
1167        assert_eq!(report.status, UninstallStatus::Removed);
1168
1169        assert!(
1170            !temp
1171                .path()
1172                .join(".claude")
1173                .join("hooks")
1174                .join("spool-SessionStart.sh")
1175                .exists()
1176        );
1177        assert!(
1178            !temp
1179                .path()
1180                .join(".claude")
1181                .join("commands")
1182                .join("spool-wakeup.md")
1183                .exists()
1184        );
1185        assert!(
1186            !temp
1187                .path()
1188                .join(".claude")
1189                .join("skills")
1190                .join("spool-runtime")
1191                .exists()
1192        );
1193        let claude: serde_json::Value =
1194            serde_json::from_str(&fs::read_to_string(temp.path().join(".claude.json")).unwrap())
1195                .unwrap();
1196        assert!(claude["mcpServers"].get("spool").is_none());
1197    }
1198
1199    #[test]
1200    fn uninstall_preserves_other_hooks() {
1201        let (temp, installer, ctx) = setup();
1202        // Pre-seed bd prime, install spool, uninstall, expect bd prime
1203        // still there.
1204        fs::create_dir_all(temp.path().join(".claude")).unwrap();
1205        let preexisting = json!({
1206            "hooks": {
1207                "SessionStart": [
1208                    {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
1209                ]
1210            }
1211        });
1212        fs::write(
1213            temp.path().join(".claude").join("settings.json"),
1214            serde_json::to_string_pretty(&preexisting).unwrap(),
1215        )
1216        .unwrap();
1217        let _ = installer.install(&ctx).unwrap();
1218        let _ = installer.uninstall(&ctx).unwrap();
1219
1220        let settings: serde_json::Value = serde_json::from_str(
1221            &fs::read_to_string(temp.path().join(".claude").join("settings.json")).unwrap(),
1222        )
1223        .unwrap();
1224        let entries = settings["hooks"]["SessionStart"].as_array().unwrap();
1225        assert_eq!(entries.len(), 1);
1226        assert_eq!(entries[0]["hooks"][0]["command"], "bd prime");
1227    }
1228
1229    #[test]
1230    fn uninstall_not_installed_when_clean() {
1231        let (_temp, installer, ctx) = setup();
1232        let report = installer.uninstall(&ctx).unwrap();
1233        assert_eq!(report.status, UninstallStatus::NotInstalled);
1234    }
1235
1236    #[test]
1237    fn uninstall_dry_run_changes_nothing() {
1238        let (temp, installer, mut ctx) = setup();
1239        let _ = installer.install(&ctx).unwrap();
1240        ctx.dry_run = true;
1241        let report = installer.uninstall(&ctx).unwrap();
1242        assert_eq!(report.status, UninstallStatus::DryRun);
1243        assert!(
1244            temp.path()
1245                .join(".claude")
1246                .join("hooks")
1247                .join("spool-SessionStart.sh")
1248                .exists(),
1249            "dry-run must keep file"
1250        );
1251    }
1252
1253    #[test]
1254    fn diagnose_reports_full_check_set_after_install() {
1255        let (_temp, installer, ctx) = setup();
1256        let _ = installer.install(&ctx).unwrap();
1257        let report = installer.diagnose(&ctx).unwrap();
1258        let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
1259        for expected in [
1260            "claude_config_exists",
1261            "mcp_servers_spool_registered",
1262            "spool_mcp_binary",
1263            "spool_config_readable",
1264            "claude_settings_hooks_registered",
1265            "spool_hook_scripts",
1266            "spool_skill_present",
1267        ] {
1268            assert!(names.contains(&expected), "missing check {}", expected);
1269        }
1270        let hooks_check = report
1271            .checks
1272            .iter()
1273            .find(|c| c.name == "claude_settings_hooks_registered")
1274            .unwrap();
1275        assert_eq!(hooks_check.status, DiagnosticStatus::Ok);
1276    }
1277
1278    #[test]
1279    fn diagnose_warns_when_hooks_not_registered() {
1280        let temp = tempdir().unwrap();
1281        let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
1282        fs::create_dir_all(temp.path().join(".claude")).unwrap();
1283        fs::write(temp.path().join(".claude.json"), r#"{"mcpServers":{}}"#).unwrap();
1284        let config_path = temp.path().join("spool.toml");
1285        fs::write(&config_path, "x=1").unwrap();
1286        let binary_path = temp.path().join("fake-spool-mcp");
1287        fs::write(&binary_path, "").unwrap();
1288        let ctx = InstallContext {
1289            binary_path: Some(binary_path),
1290            config_path,
1291            dry_run: false,
1292            force: false,
1293        };
1294        let report = installer.diagnose(&ctx).unwrap();
1295        let hooks_check = report
1296            .checks
1297            .iter()
1298            .find(|c| c.name == "claude_settings_hooks_registered")
1299            .unwrap();
1300        assert_eq!(hooks_check.status, DiagnosticStatus::Warn);
1301    }
1302
1303    // ─── update tests ─────────────────────────────────────────────────
1304
1305    #[test]
1306    fn update_returns_not_installed_when_no_mcp_entry() {
1307        let (_temp, installer, ctx) = setup();
1308        // No install has been done, so no mcpServers entry exists.
1309        let report = installer.update(&ctx).unwrap();
1310        assert_eq!(report.status, UpdateStatus::NotInstalled);
1311        assert!(
1312            report
1313                .notes
1314                .iter()
1315                .any(|n| n.contains("not installed") || n.contains("not registered"))
1316        );
1317    }
1318
1319    #[test]
1320    fn update_re_renders_drifted_hooks() {
1321        let (temp, installer, ctx) = setup();
1322        // First install to set up everything.
1323        let _ = installer.install(&ctx).unwrap();
1324
1325        // Tamper with a hook script to simulate template drift.
1326        let stop_path = temp
1327            .path()
1328            .join(".claude")
1329            .join("hooks")
1330            .join("spool-Stop.sh");
1331        fs::write(&stop_path, "tampered content\n").unwrap();
1332
1333        let report = installer.update(&ctx).unwrap();
1334        assert_eq!(report.status, UpdateStatus::Updated);
1335        assert!(report.updated_paths.contains(&stop_path));
1336
1337        // Verify the file was restored.
1338        let restored = fs::read_to_string(&stop_path).unwrap();
1339        assert!(!restored.contains("tampered"));
1340        assert!(restored.contains("hook stop"));
1341    }
1342
1343    #[test]
1344    fn update_unchanged_when_templates_match() {
1345        let (_temp, installer, ctx) = setup();
1346        // Install, then update — should be unchanged.
1347        let _ = installer.install(&ctx).unwrap();
1348        let report = installer.update(&ctx).unwrap();
1349        assert_eq!(report.status, UpdateStatus::Unchanged);
1350        assert!(report.updated_paths.is_empty());
1351    }
1352
1353    #[test]
1354    fn update_dry_run_does_not_write() {
1355        let (temp, installer, mut ctx) = setup();
1356        // Install first.
1357        let _ = installer.install(&ctx).unwrap();
1358
1359        // Tamper with a hook.
1360        let stop_path = temp
1361            .path()
1362            .join(".claude")
1363            .join("hooks")
1364            .join("spool-Stop.sh");
1365        fs::write(&stop_path, "tampered\n").unwrap();
1366
1367        // Update with dry_run.
1368        ctx.dry_run = true;
1369        let report = installer.update(&ctx).unwrap();
1370        assert_eq!(report.status, UpdateStatus::DryRun);
1371        assert!(!report.updated_paths.is_empty());
1372
1373        // File should still be tampered (not written).
1374        let content = fs::read_to_string(&stop_path).unwrap();
1375        assert!(content.contains("tampered"));
1376    }
1377
1378    #[test]
1379    fn update_uses_binary_path_from_existing_mcp_entry() {
1380        let (temp, installer, ctx) = setup();
1381        // Install with a specific binary path.
1382        let _ = installer.install(&ctx).unwrap();
1383
1384        // Tamper with a hook to force re-render.
1385        let session_path = temp
1386            .path()
1387            .join(".claude")
1388            .join("hooks")
1389            .join("spool-SessionStart.sh");
1390        fs::write(&session_path, "old\n").unwrap();
1391
1392        // Update without specifying binary_path — should read from mcpServers.
1393        let update_ctx = InstallContext {
1394            binary_path: None,
1395            config_path: ctx.config_path.clone(),
1396            dry_run: false,
1397            force: false,
1398        };
1399        let report = installer.update(&update_ctx).unwrap();
1400        assert_eq!(report.status, UpdateStatus::Updated);
1401
1402        // The restored hook should contain the original binary path.
1403        let restored = fs::read_to_string(&session_path).unwrap();
1404        let expected_bin = ctx.binary_path.unwrap();
1405        let expected_spool = expected_bin.parent().unwrap().join("spool");
1406        assert!(restored.contains(&expected_spool.to_string_lossy().to_string()));
1407    }
1408}