1mod types;
28pub use types::*;
29
30mod display;
31
32mod plan;
33use plan::{build_lock_entry, decide_action_for_entry, prepare_writes};
34
35use anyhow::{Result, bail};
36
37use crate::checksum;
38use crate::config;
39use crate::context::AppContext;
40use crate::frontmatter;
41use crate::lockfile;
42use crate::platform::Platform;
43use crate::platform_iter;
44use crate::skill_fs;
45use crate::targets;
46use crate::types::{ArtifactKind, SourceEntry, SourceType};
47
48pub struct SkillInstaller {
54 tool: ToolIdentity,
55}
56
57impl SkillInstaller {
58 pub fn new(tool: ToolIdentity) -> Self {
60 Self { tool }
61 }
62
63 pub fn plan(
67 &self,
68 skill: &BundledSkill,
69 scope: Scope,
70 force: bool,
71 ctx: &AppContext<'_>,
72 ) -> Result<InstallPlan> {
73 if !skill.has_skill_md() {
74 bail!("BundledSkill for '{}' is missing SKILL.md", self.tool.name);
75 }
76
77 let files = frontmatter::reconcile_skill_version(&skill.files, &self.tool.version);
81 let source_checksum = skill_fs::checksum_bundled(&files);
82 let install_scope = scope.to_install_scope();
83
84 let platform_targets =
85 targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
86
87 let cmx_managed = config::managed_platforms(ctx.fs, ctx.paths)?.is_some();
88 let cmx_present = cmx_managed || {
89 platform_iter::views_for(ctx.paths, platform_iter::all(), ArtifactKind::Skill).any(
91 |view| {
92 lockfile::load(install_scope, ctx.fs, &view.paths)
93 .ok()
94 .is_some_and(|l| !l.packages.is_empty())
95 },
96 )
97 };
98
99 let mut target_plans = Vec::new();
100 for &platform in &platform_targets {
106 let pv = ctx.paths.with_platform(platform);
107 let dest_dir = pv.require_install_dir(ArtifactKind::Skill, install_scope)?;
108 let skill_dest = dest_dir.join(&self.tool.name);
109
110 let planned_files: Vec<PlannedFile> = files
112 .iter()
113 .map(|f| PlannedFile {
114 rel_path: f.rel_path.clone(),
115 dest_path: skill_dest.join(&f.rel_path),
116 })
117 .collect();
118
119 let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
121 let action = if let Some(entry) = lock.packages.get(&self.tool.name) {
122 decide_action_for_entry(
123 entry,
124 &self.tool.version,
125 &source_checksum,
126 force,
127 &skill_dest,
128 ctx,
129 )?
130 } else if ctx.fs.exists(&skill_dest) {
131 TargetAction::Install
133 } else {
134 TargetAction::Install
135 };
136
137 target_plans.push(TargetPlan {
138 platform,
139 scope: install_scope,
140 dest_dir: skill_dest,
141 files: planned_files,
142 action,
143 cmx_managed,
144 });
145 }
146
147 Ok(InstallPlan {
148 tool: self.tool.clone(),
149 scope: install_scope,
150 source_checksum,
151 cmx_present,
152 force,
153 targets: target_plans,
154 })
155 }
156
157 pub fn apply(
164 &self,
165 skill: &BundledSkill,
166 plan: &InstallPlan,
167 ctx: &AppContext<'_>,
168 ) -> Result<Report> {
169 if plan.is_blocked() {
170 bail!(
171 "Install plan for '{}' is blocked. Run with force=true to override.",
172 self.tool.name
173 );
174 }
175
176 let files = frontmatter::reconcile_skill_version(&skill.files, &self.tool.version);
179
180 let current_checksum = skill_fs::checksum_bundled(&files);
182 if current_checksum != plan.source_checksum {
183 bail!(
184 "Parity check failed for '{}': the BundledSkill has changed since plan() was called.",
185 self.tool.name
186 );
187 }
188
189 let PreparedWrites {
190 dirs_to_write,
191 discarded_paths_by_dir,
192 } = prepare_writes(plan, &files, ctx)?;
193
194 for dir in &dirs_to_write {
196 skill_fs::write_skill_files(dir, &files, ctx.fs)?;
197 }
198
199 let installed_checksum = plan.source_checksum.clone();
200 let installed_at = ctx.clock.now().to_rfc3339();
201
202 let mut targets: Vec<TargetOutcome> = Vec::new();
203
204 for target in &plan.targets {
205 if !target.action.will_write() {
206 targets.push(TargetOutcome {
207 platform: target.platform,
208 dest_dir: target.dest_dir.clone(),
209 action: target.action.clone(),
210 files_written: 0,
211 installed_checksum: None,
212 discarded_paths: Vec::new(),
213 });
214 continue;
215 }
216
217 let pv = ctx.paths.with_platform(target.platform);
219 lockfile::mutate(target.scope, ctx.fs, &pv, |lock| {
220 lock.packages.insert(
221 self.tool.name.clone(),
222 build_lock_entry(&self.tool, &installed_checksum, &installed_at),
223 );
224 })?;
225
226 targets.push(TargetOutcome {
227 platform: target.platform,
228 dest_dir: target.dest_dir.clone(),
229 action: target.action.clone(),
230 files_written: target.files.len(),
231 installed_checksum: Some(installed_checksum.clone()),
232 discarded_paths: discarded_paths_by_dir
233 .get(&target.dest_dir)
234 .cloned()
235 .unwrap_or_default(),
236 });
237 }
238
239 let source_registered = if plan.cmx_present
241 && config::managed_platforms(ctx.fs, ctx.paths)?.is_some()
242 {
243 let source_name = format!("bundled:{}", self.tool.name);
244 let home =
246 config::resolve_artifact_home(&config::load_config(ctx.fs, ctx.paths)?, ctx.paths);
247 let materialized = home.join("skills").join(&self.tool.name);
248 skill_fs::write_skill_files(&materialized, &files, ctx.fs)?;
249
250 config::mutate_sources(ctx.fs, ctx.paths, |sources| {
251 sources.sources.entry(source_name.clone()).or_insert_with(|| SourceEntry {
252 source_type: SourceType::Local,
253 path: Some(materialized.clone()),
254 url: None,
255 local_clone: None,
256 branch: None,
257 last_updated: Some(ctx.clock.now().to_rfc3339()),
258 });
259 Ok(())
260 })?;
261 true
262 } else {
263 false
264 };
265
266 Ok(Report {
267 tool: self.tool.clone(),
268 scope: plan.scope,
269 targets,
270 source_registered,
271 })
272 }
273
274 pub fn status(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<Status> {
276 let install_scope = scope.to_install_scope();
277 let platform_targets =
278 targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
279
280 let mut target_statuses = Vec::new();
281 for &platform in &platform_targets {
282 let pv = ctx.paths.with_platform(platform);
283 let skill_dir = pv
284 .install_dir(ArtifactKind::Skill, install_scope)
285 .map(|d| d.join(&self.tool.name));
286
287 let installed = skill_dir.as_ref().is_some_and(|d| ctx.fs.exists(d));
288
289 let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
290 let lock_entry = lock.packages.get(&self.tool.name);
291 let tracked = lock_entry.is_some();
292 let installed_version = lock_entry.and_then(|e| e.version.clone());
293
294 let drifted = if installed && tracked {
295 if let (Some(dir), Some(entry)) = (&skill_dir, lock_entry) {
296 checksum::is_locally_modified(dir, ArtifactKind::Skill, entry, ctx.fs)
297 .unwrap_or(false)
298 } else {
299 false
300 }
301 } else {
302 false
303 };
304
305 target_statuses.push(TargetStatus {
306 platform,
307 installed,
308 installed_version,
309 drifted,
310 tracked,
311 });
312 }
313
314 Ok(Status {
315 tool_name: self.tool.name.clone(),
316 scope: install_scope,
317 targets: target_statuses,
318 })
319 }
320
321 pub fn remove(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<RemoveReport> {
323 let install_scope = scope.to_install_scope();
324 let platform_targets = config::managed_or_all_platforms(ctx.fs, ctx.paths)?
325 .into_iter()
326 .filter(|p| p.supports(ArtifactKind::Skill))
327 .collect::<Vec<_>>();
328
329 let mut dirs_to_delete: std::collections::BTreeSet<std::path::PathBuf> =
330 std::collections::BTreeSet::new();
331 let mut platforms_cleared: Vec<Platform> = Vec::new();
332 let mut was_tracked = false;
333
334 for &platform in &platform_targets {
335 let pv = ctx.paths.with_platform(platform);
336
337 if let Some(dir) = pv.install_dir(ArtifactKind::Skill, install_scope) {
339 let skill_dir = dir.join(&self.tool.name);
340 if ctx.fs.exists(&skill_dir) {
341 dirs_to_delete.insert(skill_dir);
342 }
343 }
344
345 let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
347 if lock.packages.contains_key(&self.tool.name) {
348 lockfile::mutate(install_scope, ctx.fs, &pv, |l| {
349 l.packages.remove(&self.tool.name);
350 })?;
351 platforms_cleared.push(platform);
352 was_tracked = true;
353 }
354 }
355
356 let was_on_disk = !dirs_to_delete.is_empty();
357 let removed_dirs: Vec<std::path::PathBuf> = dirs_to_delete.into_iter().collect();
358 for dir in &removed_dirs {
359 ctx.fs.remove_dir_all(dir)?;
360 }
361
362 let source_name = format!("bundled:{}", self.tool.name);
364 let source_unregistered = if let Ok(sources) = config::load_sources(ctx.fs, ctx.paths) {
365 if sources.sources.contains_key(&source_name) {
366 if let Some(entry) = sources.sources.get(&source_name)
368 && let Some(path) = &entry.path
369 && ctx.fs.exists(path)
370 {
371 ctx.fs.remove_dir_all(path)?;
372 }
373 config::mutate_sources(ctx.fs, ctx.paths, |s| {
374 s.sources.remove(&source_name);
375 Ok(())
376 })?;
377 true
378 } else {
379 false
380 }
381 } else {
382 false
383 };
384
385 Ok(RemoveReport {
386 tool_name: self.tool.name.clone(),
387 scope: install_scope,
388 removed_dirs,
389 platforms_cleared,
390 source_unregistered,
391 was_on_disk,
392 was_tracked,
393 })
394 }
395}
396
397#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::gateway::Filesystem as _;
405 use crate::skill_fs::SkillFile;
406 use crate::test_support::TestContext;
407 use crate::types::{CmxConfig, InstallScope, LockEntry, LockSource};
408 use crate::{checksum, config};
409 use std::collections::BTreeSet;
410
411 fn make_file(rel: &str, content: &str) -> SkillFile {
412 SkillFile {
413 rel_path: std::path::PathBuf::from(rel),
414 bytes: content.as_bytes().to_vec(),
415 }
416 }
417
418 fn sample_skill(version: &str) -> BundledSkill {
423 BundledSkill::from_files(vec![
424 make_file(
425 "SKILL.md",
426 &format!("---\nmetadata:\n version: \"{version}\"\n---\n# Sample skill\n"),
427 ),
428 make_file("scripts/tool.py", "print('hello')"),
429 ])
430 }
431
432 fn installer(version: &str) -> SkillInstaller {
433 SkillInstaller::new(ToolIdentity {
434 name: "sample".to_string(),
435 version: version.to_string(),
436 })
437 }
438
439 #[test]
444 fn checksum_bundled_matches_checksum_dir_after_write() {
445 let t = TestContext::new();
446 let skill = sample_skill("1.0.0");
447 let expected = skill_fs::checksum_bundled(&skill.files);
448 let dest = std::path::PathBuf::from("/dest/sample");
449 skill_fs::write_skill_files(&dest, &skill.files, &t.fs).unwrap();
450 let on_disk = checksum::checksum_dir(&dest, &t.fs).unwrap();
451 assert_eq!(expected, on_disk, "in-memory checksum must match disk checksum");
452 }
453
454 #[test]
455 fn dotfiles_and_transient_excluded_from_write_and_checksum() {
456 let files = vec![
457 make_file("SKILL.md", "# skill"),
458 make_file(".hidden", "hidden"),
459 make_file("node_modules/dep.js", "vendor"),
460 ];
461 let bundled_cs = skill_fs::checksum_bundled(&files);
462
463 let only_skill = vec![make_file("SKILL.md", "# skill")];
465 let expected_cs = skill_fs::checksum_bundled(&only_skill);
466 assert_eq!(
467 bundled_cs, expected_cs,
468 "dotfiles and transient must be excluded from checksum"
469 );
470 }
471
472 #[test]
473 fn write_skill_files_creates_nested_dirs() {
474 let t = TestContext::new();
475 let files = vec![
476 make_file("SKILL.md", "# skill"),
477 make_file("scripts/sub/tool.py", "code"),
478 ];
479 skill_fs::write_skill_files(std::path::Path::new("/dest/s"), &files, &t.fs).unwrap();
480 assert!(t.fs.file_exists(std::path::Path::new("/dest/s/SKILL.md")));
481 assert!(t.fs.file_exists(std::path::Path::new("/dest/s/scripts/sub/tool.py")));
482 }
483
484 #[test]
489 fn fresh_machine_produces_single_claude_target_install() {
490 let t = TestContext::new();
491 let ctx = t.ctx();
492 let skill = sample_skill("1.0.0");
493 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
494
495 assert_eq!(plan.targets.len(), 1);
496 assert_eq!(plan.targets[0].platform, Platform::Claude);
497 assert!(matches!(plan.targets[0].action, TargetAction::Install));
498 assert!(!plan.cmx_present);
499 }
500
501 #[test]
502 fn cmx_config_two_platforms_produces_two_targets_cmx_managed() {
503 let t = TestContext::new();
504 let cfg = CmxConfig {
505 platforms: vec![Platform::Claude, Platform::Codex],
506 ..Default::default()
507 };
508 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
509
510 let ctx = t.ctx();
511 let skill = sample_skill("1.0.0");
512 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
513
514 let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
515 assert!(platforms.contains(&Platform::Claude), "should include Claude");
516 assert!(platforms.contains(&Platform::Codex), "should include Codex");
517 assert!(plan.targets[0].cmx_managed, "cmx_managed should be true");
518 }
519
520 #[test]
521 fn no_config_but_non_empty_codex_lock_targets_codex() {
522 let t = TestContext::new();
523 let codex_paths = t.paths.with_platform(Platform::Codex);
524 crate::test_support::save_lock_with_entry(
525 &t.fs,
526 &codex_paths,
527 "other-skill",
528 crate::test_support::sample_lock_entry(),
529 InstallScope::Global,
530 );
531
532 let ctx = t.ctx();
533 let skill = sample_skill("1.0.0");
534 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
535
536 let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
537 assert!(
538 platforms.contains(&Platform::Codex),
539 "Codex lock non-empty → should be targeted"
540 );
541 }
542
543 fn plan_with_locked_version(
548 t: &TestContext,
549 locked_version: &str,
550 locked_checksum: &str,
551 bundled_version: &str,
552 force: bool,
553 ) -> InstallPlan {
554 let claude_paths = t.paths.with_platform(Platform::Claude);
556 let skill_dir = claude_paths
557 .install_dir(ArtifactKind::Skill, InstallScope::Global)
558 .unwrap()
559 .join("sample");
560 t.fs.add_dir(skill_dir.clone());
561
562 crate::test_support::save_lock_with_entry(
563 &t.fs,
564 &claude_paths,
565 "sample",
566 LockEntry {
567 artifact_type: ArtifactKind::Skill,
568 version: Some(locked_version.to_string()),
569 installed_at: "2024-01-01T00:00:00Z".to_string(),
570 source: LockSource {
571 repo: "bundled:sample".to_string(),
572 path: "skills/sample".to_string(),
573 },
574 source_checksum: locked_checksum.to_string(),
575 installed_checksum: locked_checksum.to_string(),
576 },
577 InstallScope::Global,
578 );
579 let skill = sample_skill(bundled_version);
580 let ctx = t.ctx();
581 installer(bundled_version).plan(&skill, Scope::Global, force, &ctx).unwrap()
582 }
583
584 #[test]
585 fn older_lock_version_produces_update() {
586 let t = TestContext::new();
587 let plan = plan_with_locked_version(&t, "0.9.0", "sha256:old", "1.0.0", false);
588 assert!(matches!(plan.targets[0].action, TargetAction::Update { .. }));
589 }
590
591 #[test]
592 fn same_version_identical_checksum_on_disk_produces_skip() {
593 let t = TestContext::new();
594 let skill = sample_skill("1.0.0");
595 let checksum = skill_fs::checksum_bundled(&skill.files);
596
597 let claude_paths = t.paths.with_platform(Platform::Claude);
598 let skill_dir = claude_paths
599 .install_dir(ArtifactKind::Skill, InstallScope::Global)
600 .unwrap()
601 .join("sample");
602 skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
603
604 crate::test_support::save_lock_with_entry(
605 &t.fs,
606 &claude_paths,
607 "sample",
608 LockEntry {
609 artifact_type: ArtifactKind::Skill,
610 version: Some("1.0.0".to_string()),
611 installed_at: "2024-01-01T00:00:00Z".to_string(),
612 source: LockSource {
613 repo: "bundled:sample".to_string(),
614 path: "skills/sample".to_string(),
615 },
616 source_checksum: checksum.clone(),
617 installed_checksum: checksum.clone(),
618 },
619 InstallScope::Global,
620 );
621
622 let ctx = t.ctx();
623 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
624 assert!(matches!(plan.targets[0].action, TargetAction::Skip));
625 }
626
627 #[test]
628 fn same_version_differing_content_no_force_produces_drifted_skip() {
629 let t = TestContext::new();
630 let skill = sample_skill("1.0.0");
631 let checksum = skill_fs::checksum_bundled(&skill.files);
632
633 let claude_paths = t.paths.with_platform(Platform::Claude);
634 let skill_dir = claude_paths
635 .install_dir(ArtifactKind::Skill, InstallScope::Global)
636 .unwrap()
637 .join("sample");
638 t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
639
640 crate::test_support::save_lock_with_entry(
641 &t.fs,
642 &claude_paths,
643 "sample",
644 LockEntry {
645 artifact_type: ArtifactKind::Skill,
646 version: Some("1.0.0".to_string()),
647 installed_at: "2024-01-01T00:00:00Z".to_string(),
648 source: LockSource {
649 repo: "bundled:sample".to_string(),
650 path: "skills/sample".to_string(),
651 },
652 source_checksum: checksum.clone(),
653 installed_checksum: checksum,
654 },
655 InstallScope::Global,
656 );
657
658 let ctx = t.ctx();
659 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
660 assert!(matches!(plan.targets[0].action, TargetAction::DriftedSkip { .. }));
661 }
662
663 #[test]
664 fn same_version_differing_content_with_force_produces_update() {
665 let t = TestContext::new();
666 let skill = sample_skill("1.0.0");
667 let checksum = skill_fs::checksum_bundled(&skill.files);
668
669 let claude_paths = t.paths.with_platform(Platform::Claude);
670 let skill_dir = claude_paths
671 .install_dir(ArtifactKind::Skill, InstallScope::Global)
672 .unwrap()
673 .join("sample");
674 t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
675
676 crate::test_support::save_lock_with_entry(
677 &t.fs,
678 &claude_paths,
679 "sample",
680 LockEntry {
681 artifact_type: ArtifactKind::Skill,
682 version: Some("1.0.0".to_string()),
683 installed_at: "2024-01-01T00:00:00Z".to_string(),
684 source: LockSource {
685 repo: "bundled:sample".to_string(),
686 path: "skills/sample".to_string(),
687 },
688 source_checksum: checksum.clone(),
689 installed_checksum: checksum,
690 },
691 InstallScope::Global,
692 );
693
694 let ctx = t.ctx();
695 let plan = installer("1.0.0").plan(&skill, Scope::Global, true, &ctx).unwrap();
696 assert!(matches!(plan.targets[0].action, TargetAction::Update { .. }));
697 }
698
699 #[test]
700 fn newer_lock_no_force_produces_refuse_newer_and_is_blocked() {
701 let t = TestContext::new();
702 let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", false);
703 assert!(matches!(plan.targets[0].action, TargetAction::RefuseNewer { .. }));
704 assert!(plan.is_blocked());
705 }
706
707 #[test]
708 fn newer_lock_with_force_produces_downgrade() {
709 let t = TestContext::new();
710 let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", true);
711 assert!(matches!(plan.targets[0].action, TargetAction::Downgrade { .. }));
712 assert!(!plan.is_blocked());
713 }
714
715 #[test]
716 fn non_semver_versions_use_string_equality_fallback() {
717 let t = TestContext::new();
719 let skill = sample_skill("v1-alpha");
720 let checksum = skill_fs::checksum_bundled(&skill.files);
721
722 let claude_paths = t.paths.with_platform(Platform::Claude);
723 let skill_dir = claude_paths
724 .install_dir(ArtifactKind::Skill, InstallScope::Global)
725 .unwrap()
726 .join("sample");
727 skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
728
729 crate::test_support::save_lock_with_entry(
730 &t.fs,
731 &claude_paths,
732 "sample",
733 LockEntry {
734 artifact_type: ArtifactKind::Skill,
735 version: Some("v1-alpha".to_string()),
736 installed_at: "2024-01-01T00:00:00Z".to_string(),
737 source: LockSource {
738 repo: "bundled:sample".to_string(),
739 path: "skills/sample".to_string(),
740 },
741 source_checksum: checksum.clone(),
742 installed_checksum: checksum.clone(),
743 },
744 InstallScope::Global,
745 );
746
747 let ctx = t.ctx();
749 let plan = installer("v1-alpha").plan(&skill, Scope::Global, false, &ctx).unwrap();
750 assert!(
757 matches!(plan.targets[0].action, TargetAction::Skip)
758 || matches!(plan.targets[0].action, TargetAction::Install),
759 "non-semver equal versions should not produce RefuseNewer or Downgrade"
760 );
761 }
762
763 #[test]
764 fn missing_skill_md_returns_error() {
765 let t = TestContext::new();
766 let skill = BundledSkill::from_files(vec![make_file("scripts/tool.py", "code")]);
767 let ctx = t.ctx();
768 let result = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx);
769 assert!(result.is_err());
770 assert!(result.unwrap_err().to_string().contains("SKILL.md"));
771 }
772
773 #[test]
778 fn apply_fresh_machine_writes_files_and_lock_source_not_registered() {
779 let t = TestContext::new();
780 let skill = sample_skill("1.0.0");
781 let ctx = t.ctx();
782 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
783 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
784
785 assert_eq!(report.applied().count(), 1);
786 assert!(!report.source_registered, "no managed set → no source registration");
787
788 let skill_dir = t
790 .paths
791 .install_dir(ArtifactKind::Skill, InstallScope::Global)
792 .unwrap()
793 .join("sample");
794 assert!(t.fs.file_exists(&skill_dir.join("SKILL.md")));
795
796 let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
798 assert!(lock.packages.contains_key("sample"));
799 }
800
801 #[test]
802 fn installed_checksum_equals_source_checksum() {
803 let t = TestContext::new();
804 let skill = sample_skill("1.0.0");
805 let ctx = t.ctx();
806 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
807 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
808
809 let first_applied = report.applied().next().unwrap();
810 assert_eq!(first_applied.installed_checksum.as_deref().unwrap(), plan.source_checksum);
811 }
812
813 #[test]
814 fn cmx_managed_registers_source_and_materializes_dir() {
815 let t = TestContext::new();
816 let cfg = CmxConfig {
817 platforms: vec![Platform::Claude],
818 ..Default::default()
819 };
820 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
821
822 let skill = sample_skill("1.0.0");
823 let ctx = t.ctx();
824 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
825 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
826
827 assert!(report.source_registered, "managed set → source should be registered");
828
829 let sources = config::load_sources(&t.fs, &t.paths).unwrap();
830 assert!(sources.sources.contains_key("bundled:sample"), "source entry should exist");
831 }
832
833 #[test]
834 fn skip_and_drifted_skip_plan_writes_nothing() {
835 let t = TestContext::new();
836 let skill = sample_skill("1.0.0");
837 let checksum = skill_fs::checksum_bundled(&skill.files);
838
839 let claude_paths = t.paths.with_platform(Platform::Claude);
840 let skill_dir = claude_paths
841 .install_dir(ArtifactKind::Skill, InstallScope::Global)
842 .unwrap()
843 .join("sample");
844 skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
845
846 crate::test_support::save_lock_with_entry(
847 &t.fs,
848 &claude_paths,
849 "sample",
850 LockEntry {
851 artifact_type: ArtifactKind::Skill,
852 version: Some("1.0.0".to_string()),
853 installed_at: "2024-01-01T00:00:00Z".to_string(),
854 source: LockSource {
855 repo: "bundled:sample".to_string(),
856 path: "skills/sample".to_string(),
857 },
858 source_checksum: checksum.clone(),
859 installed_checksum: checksum.clone(),
860 },
861 InstallScope::Global,
862 );
863
864 let ctx = t.ctx();
865 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
866 assert!(matches!(plan.targets[0].action, TargetAction::Skip));
868 assert_eq!(plan.write_count(), 0);
869 }
870
871 #[test]
872 fn blocked_plan_returns_err_on_apply() {
873 let t = TestContext::new();
874 let skill = sample_skill("1.0.0");
876 let claude_paths = t.paths.with_platform(Platform::Claude);
877 let skill_dir = claude_paths
878 .install_dir(ArtifactKind::Skill, InstallScope::Global)
879 .unwrap()
880 .join("sample");
881 t.fs.add_dir(skill_dir);
882
883 crate::test_support::save_lock_with_entry(
884 &t.fs,
885 &claude_paths,
886 "sample",
887 LockEntry {
888 artifact_type: ArtifactKind::Skill,
889 version: Some("2.0.0".to_string()),
890 installed_at: "2024-01-01T00:00:00Z".to_string(),
891 source: LockSource {
892 repo: "bundled:sample".to_string(),
893 path: "skills/sample".to_string(),
894 },
895 source_checksum: "sha256:abc".to_string(),
896 installed_checksum: "sha256:abc".to_string(),
897 },
898 InstallScope::Global,
899 );
900
901 let ctx = t.ctx();
902 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
903 assert!(plan.is_blocked());
904
905 let result = installer("1.0.0").apply(&skill, &plan, &ctx);
906 assert!(result.is_err());
907 }
908
909 #[test]
910 fn parity_guard_rejects_mismatched_bundled_skill() {
911 let t = TestContext::new();
912 let skill_v1 = sample_skill("1.0.0");
913 let ctx = t.ctx();
914 let plan = installer("1.0.0").plan(&skill_v1, Scope::Global, false, &ctx).unwrap();
915
916 let skill_v2 = BundledSkill::from_files(vec![
920 make_file("SKILL.md", "---\nmetadata:\n version: \"1.0.0\"\n---\n# DIFFERENT body\n"),
921 make_file("scripts/tool.py", "print('hello')"),
922 ]);
923 let result = installer("1.0.0").apply(&skill_v2, &plan, &ctx);
924 assert!(result.is_err());
925 assert!(result.unwrap_err().to_string().contains("Parity"));
926 }
927
928 #[test]
929 fn shared_dir_managed_codex_pi_written_once_both_locks_updated() {
930 let t = TestContext::new();
931 let cfg = CmxConfig {
933 platforms: vec![Platform::Codex, Platform::Pi],
934 ..Default::default()
935 };
936 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
937
938 let skill = sample_skill("1.0.0");
939 let ctx = t.ctx();
940 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
941 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
942
943 let codex_paths = t.paths.with_platform(Platform::Codex);
945 let pi_paths = t.paths.with_platform(Platform::Pi);
946
947 let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
948 let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
949
950 assert!(codex_lock.packages.contains_key("sample"), "Codex lock should have entry");
951 assert!(pi_lock.packages.contains_key("sample"), "Pi lock should have entry");
952 }
953
954 #[test]
955 fn on_disk_file_set_matches_planned_dest_paths() {
956 let t = TestContext::new();
957 let skill = sample_skill("1.0.0");
958 let ctx = t.ctx();
959 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
960 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
961
962 for target in &plan.targets {
963 for pf in &target.files {
964 assert!(
965 t.fs.file_exists(&pf.dest_path),
966 "expected file on disk: {}",
967 pf.dest_path.display()
968 );
969 }
970 }
971 }
972
973 #[test]
978 fn not_installed_on_fresh_machine() {
979 let t = TestContext::new();
980 let ctx = t.ctx();
981 let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
982 assert!(!status.targets[0].installed);
983 assert!(!status.targets[0].tracked);
984 }
985
986 #[test]
987 fn after_apply_installed_tracked_version_matches_not_drifted() {
988 let t = TestContext::new();
989 let skill = sample_skill("1.0.0");
990 let ctx = t.ctx();
991 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
992 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
993
994 let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
995 assert!(status.targets[0].installed);
996 assert!(status.targets[0].tracked);
997 assert_eq!(status.targets[0].installed_version.as_deref(), Some("1.0.0"));
998 assert!(!status.targets[0].drifted);
999 }
1000
1001 #[test]
1002 fn mutate_skill_md_on_disk_produces_drifted() {
1003 let t = TestContext::new();
1004 let skill = sample_skill("1.0.0");
1005 let ctx = t.ctx();
1006 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1007 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1008
1009 let skill_dir = t
1011 .paths
1012 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1013 .unwrap()
1014 .join("sample");
1015 t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# MODIFIED\n");
1016
1017 let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1018 assert!(status.targets[0].drifted, "mutated SKILL.md should report drifted");
1019 }
1020
1021 #[test]
1026 fn remove_deletes_dir_and_clears_lock() {
1027 let t = TestContext::new();
1028 let skill = sample_skill("1.0.0");
1029 let ctx = t.ctx();
1030 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1031 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1032
1033 let skill_dir = t
1034 .paths
1035 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1036 .unwrap()
1037 .join("sample");
1038 assert!(t.fs.exists(&skill_dir));
1039
1040 let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1041 assert!(report.was_on_disk);
1042 assert!(report.was_tracked);
1043 assert!(!t.fs.exists(&skill_dir));
1044
1045 let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
1046 assert!(!lock.packages.contains_key("sample"));
1047 }
1048
1049 #[test]
1050 fn shared_dir_managed_codex_pi_removed_once_both_locks_cleared() {
1051 let t = TestContext::new();
1052 let cfg = CmxConfig {
1053 platforms: vec![Platform::Codex, Platform::Pi],
1054 ..Default::default()
1055 };
1056 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1057
1058 let skill = sample_skill("1.0.0");
1059 let ctx = t.ctx();
1060 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1061 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1062
1063 let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1064 assert!(report.was_on_disk);
1065 assert!(report.platforms_cleared.contains(&Platform::Codex));
1066 assert!(report.platforms_cleared.contains(&Platform::Pi));
1067
1068 let codex_paths = t.paths.with_platform(Platform::Codex);
1070 let pi_paths = t.paths.with_platform(Platform::Pi);
1071 let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
1072 let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
1073 assert!(!codex_lock.packages.contains_key("sample"));
1074 assert!(!pi_lock.packages.contains_key("sample"));
1075 }
1076
1077 #[test]
1078 fn cmx_managed_remove_clears_source_and_materialized_dir() {
1079 let t = TestContext::new();
1080 let cfg = CmxConfig {
1081 platforms: vec![Platform::Claude],
1082 ..Default::default()
1083 };
1084 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1085
1086 let skill = sample_skill("1.0.0");
1087 let ctx = t.ctx();
1088 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1089 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1090
1091 let sources = config::load_sources(&t.fs, &t.paths).unwrap();
1093 assert!(sources.sources.contains_key("bundled:sample"));
1094
1095 let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1096 assert!(report.source_unregistered);
1097
1098 let sources_after = config::load_sources(&t.fs, &t.paths).unwrap();
1099 assert!(!sources_after.sources.contains_key("bundled:sample"));
1100 }
1101
1102 #[test]
1103 fn remove_when_nothing_installed_returns_ok_all_false() {
1104 let t = TestContext::new();
1105 let ctx = t.ctx();
1106 let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1107 assert!(!report.was_on_disk);
1108 assert!(!report.was_tracked);
1109 assert!(!report.source_unregistered);
1110 }
1111
1112 #[test]
1117 fn single_md_builds_single_skill_md() {
1118 let skill = BundledSkill::single_md("---\nversion: 1.0.0\n---\n# My skill\n");
1119 assert_eq!(skill.files.len(), 1);
1120 assert!(skill.has_skill_md());
1121 assert_eq!(skill.files[0].rel_path, std::path::PathBuf::from("SKILL.md"));
1122 }
1123
1124 #[test]
1125 fn tool_identity_new_sets_fields() {
1126 let id = ToolIdentity::new("mytool", "1.2.3");
1127 assert_eq!(id.name, "mytool");
1128 assert_eq!(id.version, "1.2.3");
1129 }
1130
1131 #[test]
1132 fn scope_partial_eq() {
1133 assert_eq!(Scope::Global, Scope::Global);
1134 assert_eq!(Scope::Local, Scope::Local);
1135 assert_ne!(Scope::Global, Scope::Local);
1136 }
1137
1138 #[test]
1143 fn skipped_target_outcome_carries_dest_dir() {
1144 let t = TestContext::new();
1145 let skill = sample_skill("1.0.0");
1146 let checksum = skill_fs::checksum_bundled(&skill.files);
1147
1148 let claude_paths = t.paths.with_platform(Platform::Claude);
1149 let skill_dir = claude_paths
1150 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1151 .unwrap()
1152 .join("sample");
1153 skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1154
1155 crate::test_support::save_lock_with_entry(
1156 &t.fs,
1157 &claude_paths,
1158 "sample",
1159 LockEntry {
1160 artifact_type: ArtifactKind::Skill,
1161 version: Some("1.0.0".to_string()),
1162 installed_at: "2024-01-01T00:00:00Z".to_string(),
1163 source: LockSource {
1164 repo: "bundled:sample".to_string(),
1165 path: "skills/sample".to_string(),
1166 },
1167 source_checksum: checksum.clone(),
1168 installed_checksum: checksum.clone(),
1169 },
1170 InstallScope::Global,
1171 );
1172
1173 let ctx = t.ctx();
1174 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1175 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1176
1177 let skip = report.skipped().next().expect("expected a skipped target");
1179 assert!(
1180 !skip.dest_dir.as_os_str().is_empty(),
1181 "dest_dir must be non-empty on skipped target"
1182 );
1183 assert!(matches!(skip.action, TargetAction::Skip));
1184 assert_eq!(skip.installed_checksum, None);
1185 }
1186
1187 #[test]
1188 fn drifted_skip_outcome_is_distinguishable_from_plain_skip() {
1189 let t = TestContext::new();
1190 let skill = sample_skill("1.0.0");
1191 let checksum = skill_fs::checksum_bundled(&skill.files);
1192
1193 let claude_paths = t.paths.with_platform(Platform::Claude);
1194 let skill_dir = claude_paths
1195 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1196 .unwrap()
1197 .join("sample");
1198 t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1199
1200 crate::test_support::save_lock_with_entry(
1201 &t.fs,
1202 &claude_paths,
1203 "sample",
1204 LockEntry {
1205 artifact_type: ArtifactKind::Skill,
1206 version: Some("1.0.0".to_string()),
1207 installed_at: "2024-01-01T00:00:00Z".to_string(),
1208 source: LockSource {
1209 repo: "bundled:sample".to_string(),
1210 path: "skills/sample".to_string(),
1211 },
1212 source_checksum: checksum.clone(),
1213 installed_checksum: checksum,
1214 },
1215 InstallScope::Global,
1216 );
1217
1218 let ctx = t.ctx();
1219 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1220 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1221
1222 let skip = report.skipped().next().expect("expected a skipped target");
1223 assert!(
1224 matches!(skip.action, TargetAction::DriftedSkip { .. }),
1225 "action must be DriftedSkip, not plain Skip"
1226 );
1227 }
1228
1229 #[test]
1230 fn force_overwrites_drifted_copy_and_reports_update() {
1231 let t = TestContext::new();
1232 let skill = sample_skill("1.0.0");
1233 let checksum = skill_fs::checksum_bundled(&skill.files);
1234
1235 let claude_paths = t.paths.with_platform(Platform::Claude);
1236 let skill_dir = claude_paths
1237 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1238 .unwrap()
1239 .join("sample");
1240 let skill_md = skill_dir.join("SKILL.md");
1241 let local_only = skill_dir.join("local-only.md");
1242 t.fs.add_file(&skill_md, "---\nversion: 1.0.0\n---\n# Modified\n");
1243 t.fs.add_file(&local_only, "scratch\n");
1244
1245 crate::test_support::save_lock_with_entry(
1246 &t.fs,
1247 &claude_paths,
1248 "sample",
1249 LockEntry {
1250 artifact_type: ArtifactKind::Skill,
1251 version: Some("1.0.0".to_string()),
1252 installed_at: "2024-01-01T00:00:00Z".to_string(),
1253 source: LockSource {
1254 repo: "bundled:sample".to_string(),
1255 path: "skills/sample".to_string(),
1256 },
1257 source_checksum: checksum.clone(),
1258 installed_checksum: checksum.clone(),
1259 },
1260 InstallScope::Global,
1261 );
1262
1263 let ctx = t.ctx();
1264 let plan = installer("1.0.0").plan(&skill, Scope::Global, true, &ctx).unwrap();
1265 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1266
1267 let updated = report.applied().next().expect("expected an updated target");
1268 assert!(matches!(updated.action, TargetAction::Update { .. }));
1269 let discarded: BTreeSet<_> = updated.discarded_paths.iter().cloned().collect();
1270 assert_eq!(
1271 discarded,
1272 BTreeSet::from([
1273 local_only.clone(),
1274 skill_md.clone(),
1275 skill_dir.join("scripts/tool.py")
1276 ])
1277 );
1278 assert_eq!(
1279 t.fs.read_to_string(&skill_md).unwrap(),
1280 "---\nmetadata:\n version: \"1.0.0\"\n---\n# Sample skill\n"
1281 );
1282 assert!(!t.fs.exists(&local_only));
1283 assert_eq!(checksum::checksum_dir(&skill_dir, &t.fs).unwrap(), checksum);
1284 }
1285}