1use 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 let binary_path = match &ctx.binary_path {
418 Some(p) => p.clone(),
419 None => {
420 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
831enum MergeStatus {
832 Changed,
833 Unchanged,
834 Conflict,
835}
836
837struct HookFilePlan<'a> {
838 #[allow(dead_code)] 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 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 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 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 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 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 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 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 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 #[test]
1306 fn update_returns_not_installed_when_no_mcp_entry() {
1307 let (_temp, installer, ctx) = setup();
1308 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 let _ = installer.install(&ctx).unwrap();
1324
1325 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 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 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 let _ = installer.install(&ctx).unwrap();
1358
1359 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 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 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 let _ = installer.install(&ctx).unwrap();
1383
1384 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 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 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}