1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use skillfile_core::conflict::{read_conflict, write_conflict};
5use skillfile_core::error::SkillfileError;
6use skillfile_core::lock::{lock_key, read_lock};
7use skillfile_core::models::{
8 short_sha, ConflictState, Entry, InstallOptions, InstallTarget, Manifest,
9};
10use skillfile_core::parser::{parse_manifest, MANIFEST_NAME};
11use skillfile_core::patch::{
12 apply_patch_pure, dir_patch_path, generate_patch, has_patch, patches_root, read_patch,
13 remove_patch, walkdir, write_dir_patch, write_patch,
14};
15use skillfile_core::progress;
16use skillfile_sources::strategy::{content_file, is_dir_entry};
17use skillfile_sources::sync::{cmd_sync, vendor_dir_for};
18
19use crate::adapter::{adapters, DeployRequest};
20use crate::paths::{installed_dir_files, installed_path, source_path};
21
22fn to_patch_conflict(err: &SkillfileError, entry_name: &str) -> SkillfileError {
28 SkillfileError::PatchConflict {
29 message: err.to_string(),
30 entry_name: entry_name.to_string(),
31 }
32}
33
34struct PatchCtx<'a> {
36 entry: &'a Entry,
37 repo_root: &'a Path,
38}
39
40fn rebase_single_patch(
43 ctx: &PatchCtx<'_>,
44 source: &Path,
45 patched: &str,
46) -> Result<(), SkillfileError> {
47 let cache_text = std::fs::read_to_string(source)?;
48 let new_patch = generate_patch(&cache_text, patched, &format!("{}.md", ctx.entry.name));
49 if new_patch.is_empty() {
50 remove_patch(ctx.entry, ctx.repo_root)?;
51 } else {
52 write_patch(ctx.entry, &new_patch, ctx.repo_root)?;
53 }
54 Ok(())
55}
56
57fn apply_single_file_patch(
60 ctx: &PatchCtx<'_>,
61 dest: &Path,
62 source: &Path,
63) -> Result<(), SkillfileError> {
64 if !has_patch(ctx.entry, ctx.repo_root) {
65 return Ok(());
66 }
67 let patch_text = read_patch(ctx.entry, ctx.repo_root)?;
68 let original = std::fs::read_to_string(dest)?;
69 let patched = apply_patch_pure(&original, &patch_text)
70 .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
71 std::fs::write(dest, &patched)?;
72
73 rebase_single_patch(ctx, source, &patched)
75}
76
77fn apply_dir_patches(
80 ctx: &PatchCtx<'_>,
81 installed_files: &HashMap<String, PathBuf>,
82 source_dir: &Path,
83) -> Result<(), SkillfileError> {
84 let patches_dir = patches_root(ctx.repo_root)
85 .join(ctx.entry.entity_type.dir_name())
86 .join(&ctx.entry.name);
87 if !patches_dir.is_dir() {
88 return Ok(());
89 }
90
91 let patch_files: Vec<PathBuf> = walkdir(&patches_dir)
92 .into_iter()
93 .filter(|p| p.extension().is_some_and(|e| e == "patch"))
94 .collect();
95
96 for patch_file in patch_files {
97 let Some(rel) = patch_file
98 .strip_prefix(&patches_dir)
99 .ok()
100 .and_then(|p| p.to_str())
101 .and_then(|s| s.strip_suffix(".patch"))
102 .map(str::to_string)
103 else {
104 continue;
105 };
106
107 let Some(target) = installed_files.get(&rel).filter(|p| p.exists()) else {
108 continue;
109 };
110
111 let patch_text = std::fs::read_to_string(&patch_file)?;
112 let original = std::fs::read_to_string(target)?;
113 let patched = apply_patch_pure(&original, &patch_text)
114 .map_err(|e| to_patch_conflict(&e, &ctx.entry.name))?;
115 std::fs::write(target, &patched)?;
116
117 let cache_file = source_dir.join(&rel);
119 if !cache_file.exists() {
120 continue;
121 }
122 let cache_text = std::fs::read_to_string(&cache_file)?;
123 let new_patch = generate_patch(&cache_text, &patched, &rel);
124 if new_patch.is_empty() {
125 std::fs::remove_file(&patch_file)?;
126 } else {
127 write_dir_patch(&dir_patch_path(ctx.entry, &rel, ctx.repo_root), &new_patch)?;
128 }
129 }
130 Ok(())
131}
132
133fn patch_already_covers(patch_text: &str, cache_text: &str, installed_text: &str) -> bool {
144 match apply_patch_pure(cache_text, patch_text) {
145 Ok(expected) if installed_text == expected => true, Err(_) => true, Ok(_) => false, }
149}
150
151fn should_skip_pin(ctx: &PatchCtx<'_>, cache_text: &str, installed_text: &str) -> bool {
154 if !has_patch(ctx.entry, ctx.repo_root) {
155 return false;
156 }
157 let Ok(pt) = read_patch(ctx.entry, ctx.repo_root) else {
158 return false;
159 };
160 patch_already_covers(&pt, cache_text, installed_text)
161}
162
163fn auto_pin_entry(entry: &Entry, manifest: &Manifest, repo_root: &Path) {
165 if entry.source_type() == "local" {
166 return;
167 }
168
169 let Ok(locked) = read_lock(repo_root) else {
170 return;
171 };
172 let key = lock_key(entry);
173 if !locked.contains_key(&key) {
174 return;
175 }
176
177 let vdir = vendor_dir_for(entry, repo_root);
178
179 if is_dir_entry(entry) {
180 auto_pin_dir_entry(entry, manifest, repo_root);
181 return;
182 }
183
184 let cf = content_file(entry);
185 if cf.is_empty() {
186 return;
187 }
188 let cache_file = vdir.join(&cf);
189 if !cache_file.exists() {
190 return;
191 }
192
193 let Ok(dest) = installed_path(entry, manifest, repo_root) else {
194 return;
195 };
196 if !dest.exists() {
197 return;
198 }
199
200 let Ok(cache_text) = std::fs::read_to_string(&cache_file) else {
201 return;
202 };
203 let Ok(installed_text) = std::fs::read_to_string(&dest) else {
204 return;
205 };
206
207 let ctx = PatchCtx { entry, repo_root };
209 if should_skip_pin(&ctx, &cache_text, &installed_text) {
210 return;
211 }
212
213 let patch_text = generate_patch(&cache_text, &installed_text, &format!("{}.md", entry.name));
214 if !patch_text.is_empty() && write_patch(entry, &patch_text, repo_root).is_ok() {
215 progress!(
216 " {}: local changes auto-saved to .skillfile/patches/",
217 entry.name
218 );
219 }
220}
221
222struct AutoPinCtx<'a> {
226 vdir: &'a Path,
227 entry: &'a Entry,
228 installed: &'a HashMap<String, PathBuf>,
229 repo_root: &'a Path,
230}
231
232fn dir_patch_already_matches(patch_path: &Path, cache_text: &str, installed_text: &str) -> bool {
235 if !patch_path.exists() {
236 return false;
237 }
238 let Ok(pt) = std::fs::read_to_string(patch_path) else {
239 return false;
240 };
241 patch_already_covers(&pt, cache_text, installed_text)
242}
243
244fn try_auto_pin_file(cache_file: &Path, ctx: &AutoPinCtx<'_>) -> Option<String> {
245 if cache_file.file_name().is_some_and(|n| n == ".meta") {
246 return None;
247 }
248 let filename = cache_file
249 .strip_prefix(ctx.vdir)
250 .ok()?
251 .to_str()?
252 .to_string();
253 let inst_path = match ctx.installed.get(&filename) {
254 Some(p) if p.exists() => p,
255 _ => return None,
256 };
257
258 let cache_text = std::fs::read_to_string(cache_file).ok()?;
259 let installed_text = std::fs::read_to_string(inst_path).ok()?;
260
261 let p = dir_patch_path(ctx.entry, &filename, ctx.repo_root);
263 if dir_patch_already_matches(&p, &cache_text, &installed_text) {
264 return None;
265 }
266
267 let patch_text = generate_patch(&cache_text, &installed_text, &filename);
268 if !patch_text.is_empty()
269 && write_dir_patch(
270 &dir_patch_path(ctx.entry, &filename, ctx.repo_root),
271 &patch_text,
272 )
273 .is_ok()
274 {
275 Some(filename)
276 } else {
277 None
278 }
279}
280
281fn auto_pin_dir_entry(entry: &Entry, manifest: &Manifest, repo_root: &Path) {
283 let vdir = &vendor_dir_for(entry, repo_root);
284 if !vdir.is_dir() {
285 return;
286 }
287 let Ok(installed) = installed_dir_files(entry, manifest, repo_root) else {
288 return;
289 };
290 if installed.is_empty() {
291 return;
292 }
293
294 let ctx = AutoPinCtx {
295 vdir,
296 entry,
297 installed: &installed,
298 repo_root,
299 };
300 let pinned: Vec<String> = walkdir(vdir)
301 .into_iter()
302 .filter_map(|f| try_auto_pin_file(&f, &ctx))
303 .collect();
304
305 if !pinned.is_empty() {
306 progress!(
307 " {}: local changes auto-saved to .skillfile/patches/ ({})",
308 entry.name,
309 pinned.join(", ")
310 );
311 }
312}
313
314pub struct InstallCtx<'a> {
320 pub repo_root: &'a Path,
321 pub opts: Option<&'a InstallOptions>,
322}
323
324pub fn install_entry(
332 entry: &Entry,
333 target: &InstallTarget,
334 ctx: &InstallCtx<'_>,
335) -> Result<(), SkillfileError> {
336 let default_opts = InstallOptions::default();
337 let opts = ctx.opts.unwrap_or(&default_opts);
338
339 let all_adapters = adapters();
340 let Some(adapter) = all_adapters.get(&target.adapter) else {
341 return Ok(());
342 };
343
344 if !adapter.supports(entry.entity_type) {
345 return Ok(());
346 }
347
348 let source = match source_path(entry, ctx.repo_root) {
349 Some(p) if p.exists() => p,
350 _ => {
351 eprintln!(" warning: source missing for {}, skipping", entry.name);
352 return Ok(());
353 }
354 };
355
356 let is_dir = is_dir_entry(entry) || source.is_dir();
357 let installed = adapter.deploy_entry(&DeployRequest {
358 entry,
359 source: &source,
360 scope: target.scope,
361 repo_root: ctx.repo_root,
362 opts,
363 });
364
365 if installed.is_empty() || opts.dry_run {
366 return Ok(());
367 }
368
369 let patch_ctx = PatchCtx {
370 entry,
371 repo_root: ctx.repo_root,
372 };
373 if is_dir {
374 apply_dir_patches(&patch_ctx, &installed, &source)?;
375 } else {
376 let key = format!("{}.md", entry.name);
377 if let Some(dest) = installed.get(&key) {
378 apply_single_file_patch(&patch_ctx, dest, &source)?;
379 }
380 }
381
382 Ok(())
383}
384
385fn check_preconditions(manifest: &Manifest, repo_root: &Path) -> Result<(), SkillfileError> {
390 if manifest.install_targets.is_empty() {
391 return Err(SkillfileError::Manifest(
392 "No install targets configured. Run `skillfile init` first.".into(),
393 ));
394 }
395
396 if let Some(conflict) = read_conflict(repo_root)? {
397 return Err(SkillfileError::Install(format!(
398 "pending conflict for '{}' — \
399 run `skillfile diff {}` to review, \
400 or `skillfile resolve {}` to merge",
401 conflict.entry, conflict.entry, conflict.entry
402 )));
403 }
404
405 Ok(())
406}
407
408fn sha_transition_hint(old_sha: &str, new_sha: &str) -> String {
414 if !old_sha.is_empty() && !new_sha.is_empty() && old_sha != new_sha {
415 format!(
416 "\n upstream: {} \u{2192} {}",
417 short_sha(old_sha),
418 short_sha(new_sha)
419 )
420 } else {
421 String::new()
422 }
423}
424
425struct LockMaps<'a> {
427 locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
428 old_locked: &'a std::collections::BTreeMap<String, skillfile_core::models::LockEntry>,
429}
430
431struct DeployCtx<'a> {
433 repo_root: &'a Path,
434 opts: &'a InstallOptions,
435 maps: LockMaps<'a>,
436}
437
438fn handle_patch_conflict(
440 entry: &Entry,
441 entry_name: &str,
442 ctx: &DeployCtx<'_>,
443) -> Result<(), SkillfileError> {
444 let key = lock_key(entry);
445 let old_sha = ctx
446 .maps
447 .old_locked
448 .get(&key)
449 .map(|l| l.sha.clone())
450 .unwrap_or_default();
451 let new_sha = ctx
452 .maps
453 .locked
454 .get(&key)
455 .map_or_else(|| old_sha.clone(), |l| l.sha.clone());
456
457 write_conflict(
458 ctx.repo_root,
459 &ConflictState {
460 entry: entry_name.to_string(),
461 entity_type: entry.entity_type,
462 old_sha: old_sha.clone(),
463 new_sha: new_sha.clone(),
464 },
465 )?;
466
467 let sha_info = sha_transition_hint(&old_sha, &new_sha);
468 Err(SkillfileError::Install(format!(
469 "upstream changes to '{entry_name}' conflict with your customisations.{sha_info}\n\
470 Your pinned edits could not be applied to the new upstream version.\n\
471 Run `skillfile diff {entry_name}` to review what changed upstream.\n\
472 Run `skillfile resolve {entry_name}` when ready to merge.\n\
473 Run `skillfile resolve --abort` to discard the conflict and keep the old version."
474 )))
475}
476
477fn install_entry_or_conflict(
479 entry: &Entry,
480 target: &InstallTarget,
481 ctx: &DeployCtx<'_>,
482) -> Result<(), SkillfileError> {
483 let install_ctx = InstallCtx {
484 repo_root: ctx.repo_root,
485 opts: Some(ctx.opts),
486 };
487 match install_entry(entry, target, &install_ctx) {
488 Ok(()) => Ok(()),
489 Err(SkillfileError::PatchConflict { entry_name, .. }) => {
490 handle_patch_conflict(entry, &entry_name, ctx)
491 }
492 Err(e) => Err(e),
493 }
494}
495
496fn deploy_all(manifest: &Manifest, ctx: &DeployCtx<'_>) -> Result<(), SkillfileError> {
497 let mode = if ctx.opts.dry_run { " [dry-run]" } else { "" };
498 let all_adapters = adapters();
499
500 for target in &manifest.install_targets {
501 if !all_adapters.contains(&target.adapter) {
502 eprintln!("warning: unknown platform '{}', skipping", target.adapter);
503 continue;
504 }
505 progress!(
506 "Installing for {} ({}){mode}...",
507 target.adapter,
508 target.scope
509 );
510 for entry in &manifest.entries {
511 install_entry_or_conflict(entry, target, ctx)?;
512 }
513 }
514
515 Ok(())
516}
517
518fn apply_extra_targets(manifest: &mut Manifest, extra_targets: Option<&[InstallTarget]>) {
525 let Some(targets) = extra_targets else {
526 return;
527 };
528 if !targets.is_empty() {
529 progress!("Using platform targets from personal config (Skillfile has no install lines).");
530 }
531 manifest.install_targets = targets.to_vec();
532}
533
534fn load_manifest(
538 repo_root: &Path,
539 extra_targets: Option<&[InstallTarget]>,
540) -> Result<Manifest, SkillfileError> {
541 let manifest_path = repo_root.join(MANIFEST_NAME);
542 if !manifest_path.exists() {
543 return Err(SkillfileError::Manifest(format!(
544 "{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
545 repo_root.display()
546 )));
547 }
548
549 let result = parse_manifest(&manifest_path)?;
550 for w in &result.warnings {
551 eprintln!("{w}");
552 }
553 let mut manifest = result.manifest;
554
555 if manifest.install_targets.is_empty() {
558 apply_extra_targets(&mut manifest, extra_targets);
559 }
560
561 Ok(manifest)
562}
563
564fn auto_pin_all(manifest: &Manifest, repo_root: &Path) {
566 for entry in &manifest.entries {
567 auto_pin_entry(entry, manifest, repo_root);
568 }
569}
570
571fn print_first_install_hint(manifest: &Manifest) {
573 let platforms: Vec<String> = manifest
574 .install_targets
575 .iter()
576 .map(|t| format!("{} ({})", t.adapter, t.scope))
577 .collect();
578 progress!(" Configured platforms: {}", platforms.join(", "));
579 progress!(" Run `skillfile init` to add or change platforms.");
580}
581
582pub struct CmdInstallOpts<'a> {
584 pub dry_run: bool,
585 pub update: bool,
586 pub extra_targets: Option<&'a [InstallTarget]>,
587}
588
589pub fn cmd_install(repo_root: &Path, opts: &CmdInstallOpts<'_>) -> Result<(), SkillfileError> {
590 let manifest = load_manifest(repo_root, opts.extra_targets)?;
591
592 check_preconditions(&manifest, repo_root)?;
593
594 let cache_dir = repo_root.join(".skillfile").join("cache");
596 let first_install = !cache_dir.exists();
597
598 let old_locked = read_lock(repo_root).unwrap_or_default();
600
601 if opts.update && !opts.dry_run {
603 auto_pin_all(&manifest, repo_root);
604 }
605
606 if !opts.dry_run {
608 std::fs::create_dir_all(&cache_dir)?;
609 }
610
611 cmd_sync(&skillfile_sources::sync::SyncCmdOpts {
613 repo_root,
614 dry_run: opts.dry_run,
615 entry_filter: None,
616 update: opts.update,
617 })?;
618
619 let locked = read_lock(repo_root).unwrap_or_default();
621
622 let install_opts = InstallOptions {
624 dry_run: opts.dry_run,
625 overwrite: opts.update,
626 };
627 let deploy_ctx = DeployCtx {
628 repo_root,
629 opts: &install_opts,
630 maps: LockMaps {
631 locked: &locked,
632 old_locked: &old_locked,
633 },
634 };
635 deploy_all(&manifest, &deploy_ctx)?;
636
637 if !opts.dry_run {
638 progress!("Done.");
639
640 if first_install {
644 print_first_install_hint(&manifest);
645 }
646 }
647
648 Ok(())
649}
650
651#[cfg(test)]
656mod tests {
657 use super::*;
658 use skillfile_core::models::{
659 EntityType, Entry, InstallTarget, LockEntry, Scope, SourceFields,
660 };
661 use std::collections::BTreeMap;
662 use std::path::{Path, PathBuf};
663
664 fn patch_fixture_path(dir: &Path, entry: &Entry) -> PathBuf {
671 dir.join(".skillfile/patches")
672 .join(entry.entity_type.dir_name())
673 .join(format!("{}.patch", entry.name))
674 }
675
676 fn dir_patch_fixture_path(dir: &Path, entry: &Entry, rel: &str) -> PathBuf {
679 dir.join(".skillfile/patches")
680 .join(entry.entity_type.dir_name())
681 .join(&entry.name)
682 .join(format!("{rel}.patch"))
683 }
684
685 fn write_patch_fixture(dir: &Path, entry: &Entry, text: &str) {
687 let p = patch_fixture_path(dir, entry);
688 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
689 std::fs::write(p, text).unwrap();
690 }
691
692 fn write_lock_fixture(dir: &Path, locked: &BTreeMap<String, LockEntry>) {
694 let json = serde_json::to_string_pretty(locked).unwrap();
695 std::fs::write(dir.join("Skillfile.lock"), format!("{json}\n")).unwrap();
696 }
697
698 fn write_conflict_fixture(dir: &Path, state: &ConflictState) {
700 let p = dir.join(".skillfile/conflict");
701 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
702 let json = serde_json::to_string_pretty(state).unwrap();
703 std::fs::write(p, format!("{json}\n")).unwrap();
704 }
705
706 fn has_dir_patch_fixture(dir: &Path, entry: &Entry) -> bool {
708 let d = dir
709 .join(".skillfile/patches")
710 .join(entry.entity_type.dir_name())
711 .join(&entry.name);
712 if !d.is_dir() {
713 return false;
714 }
715 std::fs::read_dir(&d)
716 .map(|rd| {
717 rd.filter_map(std::result::Result::ok)
718 .any(|e| e.path().extension().is_some_and(|x| x == "patch"))
719 })
720 .unwrap_or(false)
721 }
722
723 fn make_agent_entry(name: &str) -> Entry {
728 Entry {
729 entity_type: EntityType::Agent,
730 name: name.into(),
731 source: SourceFields::Github {
732 owner_repo: "owner/repo".into(),
733 path_in_repo: "agents/agent.md".into(),
734 ref_: "main".into(),
735 },
736 }
737 }
738
739 fn make_local_entry(name: &str, path: &str) -> Entry {
740 Entry {
741 entity_type: EntityType::Skill,
742 name: name.into(),
743 source: SourceFields::Local { path: path.into() },
744 }
745 }
746
747 fn make_target(adapter: &str, scope: Scope) -> InstallTarget {
748 InstallTarget {
749 adapter: adapter.into(),
750 scope,
751 }
752 }
753
754 #[test]
757 fn install_local_entry_copy() {
758 let dir = tempfile::tempdir().unwrap();
759 let source_file = dir.path().join("skills/my-skill.md");
760 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
761 std::fs::write(&source_file, "# My Skill").unwrap();
762
763 let entry = make_local_entry("my-skill", "skills/my-skill.md");
764 let target = make_target("claude-code", Scope::Local);
765 install_entry(
766 &entry,
767 &target,
768 &InstallCtx {
769 repo_root: dir.path(),
770 opts: None,
771 },
772 )
773 .unwrap();
774
775 let dest = dir.path().join(".claude/skills/my-skill.md");
776 assert!(dest.exists());
777 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
778 }
779
780 #[test]
781 fn install_local_dir_entry_copy() {
782 let dir = tempfile::tempdir().unwrap();
783 let source_dir = dir.path().join("skills/python-testing");
785 std::fs::create_dir_all(&source_dir).unwrap();
786 std::fs::write(source_dir.join("SKILL.md"), "# Python Testing").unwrap();
787 std::fs::write(source_dir.join("examples.md"), "# Examples").unwrap();
788
789 let entry = make_local_entry("python-testing", "skills/python-testing");
790 let target = make_target("claude-code", Scope::Local);
791 install_entry(
792 &entry,
793 &target,
794 &InstallCtx {
795 repo_root: dir.path(),
796 opts: None,
797 },
798 )
799 .unwrap();
800
801 let dest = dir.path().join(".claude/skills/python-testing");
803 assert!(dest.is_dir(), "local dir entry must deploy as directory");
804 assert_eq!(
805 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
806 "# Python Testing"
807 );
808 assert_eq!(
809 std::fs::read_to_string(dest.join("examples.md")).unwrap(),
810 "# Examples"
811 );
812 assert!(
814 !dir.path().join(".claude/skills/python-testing.md").exists(),
815 "should not create python-testing.md for a dir source"
816 );
817 }
818
819 #[test]
820 fn install_entry_dry_run_no_write() {
821 let dir = tempfile::tempdir().unwrap();
822 let source_file = dir.path().join("skills/my-skill.md");
823 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
824 std::fs::write(&source_file, "# My Skill").unwrap();
825
826 let entry = make_local_entry("my-skill", "skills/my-skill.md");
827 let target = make_target("claude-code", Scope::Local);
828 let opts = InstallOptions {
829 dry_run: true,
830 ..Default::default()
831 };
832 install_entry(
833 &entry,
834 &target,
835 &InstallCtx {
836 repo_root: dir.path(),
837 opts: Some(&opts),
838 },
839 )
840 .unwrap();
841
842 let dest = dir.path().join(".claude/skills/my-skill.md");
843 assert!(!dest.exists());
844 }
845
846 #[test]
847 fn install_entry_overwrites_existing() {
848 let dir = tempfile::tempdir().unwrap();
849 let source_file = dir.path().join("skills/my-skill.md");
850 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
851 std::fs::write(&source_file, "# New content").unwrap();
852
853 let dest_dir = dir.path().join(".claude/skills");
854 std::fs::create_dir_all(&dest_dir).unwrap();
855 let dest = dest_dir.join("my-skill.md");
856 std::fs::write(&dest, "# Old content").unwrap();
857
858 let entry = make_local_entry("my-skill", "skills/my-skill.md");
859 let target = make_target("claude-code", Scope::Local);
860 install_entry(
861 &entry,
862 &target,
863 &InstallCtx {
864 repo_root: dir.path(),
865 opts: None,
866 },
867 )
868 .unwrap();
869
870 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# New content");
871 }
872
873 #[test]
876 fn install_github_entry_copy() {
877 let dir = tempfile::tempdir().unwrap();
878 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
879 std::fs::create_dir_all(&vdir).unwrap();
880 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
881
882 let entry = make_agent_entry("my-agent");
883 let target = make_target("claude-code", Scope::Local);
884 install_entry(
885 &entry,
886 &target,
887 &InstallCtx {
888 repo_root: dir.path(),
889 opts: None,
890 },
891 )
892 .unwrap();
893
894 let dest = dir.path().join(".claude/agents/my-agent.md");
895 assert!(dest.exists());
896 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
897 }
898
899 #[test]
900 fn install_github_dir_entry_copy() {
901 let dir = tempfile::tempdir().unwrap();
902 let vdir = dir.path().join(".skillfile/cache/skills/python-pro");
903 std::fs::create_dir_all(&vdir).unwrap();
904 std::fs::write(vdir.join("SKILL.md"), "# Python Pro").unwrap();
905 std::fs::write(vdir.join("examples.md"), "# Examples").unwrap();
906
907 let entry = Entry {
908 entity_type: EntityType::Skill,
909 name: "python-pro".into(),
910 source: SourceFields::Github {
911 owner_repo: "owner/repo".into(),
912 path_in_repo: "skills/python-pro".into(),
913 ref_: "main".into(),
914 },
915 };
916 let target = make_target("claude-code", Scope::Local);
917 install_entry(
918 &entry,
919 &target,
920 &InstallCtx {
921 repo_root: dir.path(),
922 opts: None,
923 },
924 )
925 .unwrap();
926
927 let dest = dir.path().join(".claude/skills/python-pro");
928 assert!(dest.is_dir());
929 assert_eq!(
930 std::fs::read_to_string(dest.join("SKILL.md")).unwrap(),
931 "# Python Pro"
932 );
933 }
934
935 #[test]
936 fn install_agent_dir_entry_explodes_to_individual_files() {
937 let dir = tempfile::tempdir().unwrap();
938 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
939 std::fs::create_dir_all(&vdir).unwrap();
940 std::fs::write(vdir.join("backend-developer.md"), "# Backend").unwrap();
941 std::fs::write(vdir.join("frontend-developer.md"), "# Frontend").unwrap();
942 std::fs::write(vdir.join(".meta"), "{}").unwrap();
943
944 let entry = Entry {
945 entity_type: EntityType::Agent,
946 name: "core-dev".into(),
947 source: SourceFields::Github {
948 owner_repo: "owner/repo".into(),
949 path_in_repo: "categories/core-dev".into(),
950 ref_: "main".into(),
951 },
952 };
953 let target = make_target("claude-code", Scope::Local);
954 install_entry(
955 &entry,
956 &target,
957 &InstallCtx {
958 repo_root: dir.path(),
959 opts: None,
960 },
961 )
962 .unwrap();
963
964 let agents_dir = dir.path().join(".claude/agents");
965 assert_eq!(
966 std::fs::read_to_string(agents_dir.join("backend-developer.md")).unwrap(),
967 "# Backend"
968 );
969 assert_eq!(
970 std::fs::read_to_string(agents_dir.join("frontend-developer.md")).unwrap(),
971 "# Frontend"
972 );
973 assert!(!agents_dir.join("core-dev").exists());
975 }
976
977 #[test]
978 fn install_entry_missing_source_warns() {
979 let dir = tempfile::tempdir().unwrap();
980 let entry = make_agent_entry("my-agent");
981 let target = make_target("claude-code", Scope::Local);
982
983 install_entry(
985 &entry,
986 &target,
987 &InstallCtx {
988 repo_root: dir.path(),
989 opts: None,
990 },
991 )
992 .unwrap();
993 }
994
995 #[test]
998 fn install_applies_existing_patch() {
999 let dir = tempfile::tempdir().unwrap();
1000
1001 let vdir = dir.path().join(".skillfile/cache/skills/test");
1003 std::fs::create_dir_all(&vdir).unwrap();
1004 std::fs::write(vdir.join("test.md"), "# Test\n\nOriginal.\n").unwrap();
1005
1006 let entry = Entry {
1008 entity_type: EntityType::Skill,
1009 name: "test".into(),
1010 source: SourceFields::Github {
1011 owner_repo: "owner/repo".into(),
1012 path_in_repo: "skills/test.md".into(),
1013 ref_: "main".into(),
1014 },
1015 };
1016 let patch_text =
1018 "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Test\n \n-Original.\n+Modified.\n";
1019 write_patch_fixture(dir.path(), &entry, patch_text);
1020
1021 let target = make_target("claude-code", Scope::Local);
1022 install_entry(
1023 &entry,
1024 &target,
1025 &InstallCtx {
1026 repo_root: dir.path(),
1027 opts: None,
1028 },
1029 )
1030 .unwrap();
1031
1032 let dest = dir.path().join(".claude/skills/test.md");
1033 assert_eq!(
1034 std::fs::read_to_string(&dest).unwrap(),
1035 "# Test\n\nModified.\n"
1036 );
1037 }
1038
1039 #[test]
1040 fn install_patch_conflict_returns_error() {
1041 let dir = tempfile::tempdir().unwrap();
1042
1043 let vdir = dir.path().join(".skillfile/cache/skills/test");
1044 std::fs::create_dir_all(&vdir).unwrap();
1045 std::fs::write(vdir.join("test.md"), "totally different\ncontent\n").unwrap();
1047
1048 let entry = Entry {
1049 entity_type: EntityType::Skill,
1050 name: "test".into(),
1051 source: SourceFields::Github {
1052 owner_repo: "owner/repo".into(),
1053 path_in_repo: "skills/test.md".into(),
1054 ref_: "main".into(),
1055 },
1056 };
1057 let bad_patch =
1059 "--- a/test.md\n+++ b/test.md\n@@ -1 +1 @@\n-expected_original_line\n+modified\n";
1060 write_patch_fixture(dir.path(), &entry, bad_patch);
1061
1062 let installed_dir = dir.path().join(".claude/skills");
1064 std::fs::create_dir_all(&installed_dir).unwrap();
1065 std::fs::write(
1066 installed_dir.join("test.md"),
1067 "totally different\ncontent\n",
1068 )
1069 .unwrap();
1070
1071 let target = make_target("claude-code", Scope::Local);
1072 let result = install_entry(
1073 &entry,
1074 &target,
1075 &InstallCtx {
1076 repo_root: dir.path(),
1077 opts: None,
1078 },
1079 );
1080 assert!(result.is_err());
1081 matches!(result.unwrap_err(), SkillfileError::PatchConflict { .. });
1083 }
1084
1085 #[test]
1088 fn install_local_skill_gemini_cli() {
1089 let dir = tempfile::tempdir().unwrap();
1090 let source_file = dir.path().join("skills/my-skill.md");
1091 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1092 std::fs::write(&source_file, "# My Skill").unwrap();
1093
1094 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1095 let target = make_target("gemini-cli", Scope::Local);
1096 install_entry(
1097 &entry,
1098 &target,
1099 &InstallCtx {
1100 repo_root: dir.path(),
1101 opts: None,
1102 },
1103 )
1104 .unwrap();
1105
1106 let dest = dir.path().join(".gemini/skills/my-skill.md");
1107 assert!(dest.exists());
1108 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
1109 }
1110
1111 #[test]
1112 fn install_local_skill_codex() {
1113 let dir = tempfile::tempdir().unwrap();
1114 let source_file = dir.path().join("skills/my-skill.md");
1115 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1116 std::fs::write(&source_file, "# My Skill").unwrap();
1117
1118 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1119 let target = make_target("codex", Scope::Local);
1120 install_entry(
1121 &entry,
1122 &target,
1123 &InstallCtx {
1124 repo_root: dir.path(),
1125 opts: None,
1126 },
1127 )
1128 .unwrap();
1129
1130 let dest = dir.path().join(".codex/skills/my-skill.md");
1131 assert!(dest.exists());
1132 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# My Skill");
1133 }
1134
1135 #[test]
1136 fn codex_skips_agent_entries() {
1137 let dir = tempfile::tempdir().unwrap();
1138 let entry = make_agent_entry("my-agent");
1139 let target = make_target("codex", Scope::Local);
1140 install_entry(
1141 &entry,
1142 &target,
1143 &InstallCtx {
1144 repo_root: dir.path(),
1145 opts: None,
1146 },
1147 )
1148 .unwrap();
1149
1150 assert!(!dir.path().join(".codex").exists());
1151 }
1152
1153 #[test]
1154 fn install_github_agent_gemini_cli() {
1155 let dir = tempfile::tempdir().unwrap();
1156 let vdir = dir.path().join(".skillfile/cache/agents/my-agent");
1157 std::fs::create_dir_all(&vdir).unwrap();
1158 std::fs::write(vdir.join("agent.md"), "# Agent").unwrap();
1159
1160 let entry = make_agent_entry("my-agent");
1161 let target = make_target("gemini-cli", Scope::Local);
1162 install_entry(
1163 &entry,
1164 &target,
1165 &InstallCtx {
1166 repo_root: dir.path(),
1167 opts: Some(&InstallOptions::default()),
1168 },
1169 )
1170 .unwrap();
1171
1172 let dest = dir.path().join(".gemini/agents/my-agent.md");
1173 assert!(dest.exists());
1174 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Agent");
1175 }
1176
1177 #[test]
1178 fn install_skill_multi_adapter() {
1179 for adapter in &["claude-code", "gemini-cli", "codex"] {
1180 let dir = tempfile::tempdir().unwrap();
1181 let source_file = dir.path().join("skills/my-skill.md");
1182 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1183 std::fs::write(&source_file, "# Multi Skill").unwrap();
1184
1185 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1186 let target = make_target(adapter, Scope::Local);
1187 install_entry(
1188 &entry,
1189 &target,
1190 &InstallCtx {
1191 repo_root: dir.path(),
1192 opts: None,
1193 },
1194 )
1195 .unwrap();
1196
1197 let prefix = match *adapter {
1198 "claude-code" => ".claude",
1199 "gemini-cli" => ".gemini",
1200 "codex" => ".codex",
1201 _ => unreachable!(),
1202 };
1203 let dest = dir.path().join(format!("{prefix}/skills/my-skill.md"));
1204 assert!(dest.exists(), "Failed for adapter {adapter}");
1205 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
1206 }
1207 }
1208
1209 #[test]
1212 fn cmd_install_no_manifest() {
1213 let dir = tempfile::tempdir().unwrap();
1214 let result = cmd_install(
1215 dir.path(),
1216 &CmdInstallOpts {
1217 dry_run: false,
1218 update: false,
1219 extra_targets: None,
1220 },
1221 );
1222 assert!(result.is_err());
1223 assert!(result.unwrap_err().to_string().contains("not found"));
1224 }
1225
1226 #[test]
1227 fn cmd_install_no_install_targets() {
1228 let dir = tempfile::tempdir().unwrap();
1229 std::fs::write(
1230 dir.path().join("Skillfile"),
1231 "local skill foo skills/foo.md\n",
1232 )
1233 .unwrap();
1234
1235 let result = cmd_install(
1236 dir.path(),
1237 &CmdInstallOpts {
1238 dry_run: false,
1239 update: false,
1240 extra_targets: None,
1241 },
1242 );
1243 assert!(result.is_err());
1244 assert!(result
1245 .unwrap_err()
1246 .to_string()
1247 .contains("No install targets"));
1248 }
1249
1250 #[test]
1251 fn cmd_install_extra_targets_fallback() {
1252 let dir = tempfile::tempdir().unwrap();
1253 std::fs::write(
1255 dir.path().join("Skillfile"),
1256 "local skill foo skills/foo.md\n",
1257 )
1258 .unwrap();
1259 let source_file = dir.path().join("skills/foo.md");
1260 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1261 std::fs::write(&source_file, "# Foo").unwrap();
1262
1263 let targets = vec![make_target("claude-code", Scope::Local)];
1265 cmd_install(
1266 dir.path(),
1267 &CmdInstallOpts {
1268 dry_run: false,
1269 update: false,
1270 extra_targets: Some(&targets),
1271 },
1272 )
1273 .unwrap();
1274
1275 let dest = dir.path().join(".claude/skills/foo.md");
1276 assert!(
1277 dest.exists(),
1278 "extra_targets must be used when Skillfile has none"
1279 );
1280 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Foo");
1281 }
1282
1283 #[test]
1284 fn cmd_install_skillfile_targets_win_over_extra() {
1285 let dir = tempfile::tempdir().unwrap();
1286 std::fs::write(
1288 dir.path().join("Skillfile"),
1289 "install claude-code local\nlocal skill foo skills/foo.md\n",
1290 )
1291 .unwrap();
1292 let source_file = dir.path().join("skills/foo.md");
1293 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1294 std::fs::write(&source_file, "# Foo").unwrap();
1295
1296 let targets = vec![make_target("gemini-cli", Scope::Local)];
1298 cmd_install(
1299 dir.path(),
1300 &CmdInstallOpts {
1301 dry_run: false,
1302 update: false,
1303 extra_targets: Some(&targets),
1304 },
1305 )
1306 .unwrap();
1307
1308 assert!(dir.path().join(".claude/skills/foo.md").exists());
1310 assert!(!dir.path().join(".gemini").exists());
1312 }
1313
1314 #[test]
1315 fn cmd_install_dry_run_no_files() {
1316 let dir = tempfile::tempdir().unwrap();
1317 std::fs::write(
1318 dir.path().join("Skillfile"),
1319 "install claude-code local\nlocal skill foo skills/foo.md\n",
1320 )
1321 .unwrap();
1322 let source_file = dir.path().join("skills/foo.md");
1323 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1324 std::fs::write(&source_file, "# Foo").unwrap();
1325
1326 cmd_install(
1327 dir.path(),
1328 &CmdInstallOpts {
1329 dry_run: true,
1330 update: false,
1331 extra_targets: None,
1332 },
1333 )
1334 .unwrap();
1335
1336 assert!(!dir.path().join(".claude").exists());
1337 }
1338
1339 #[test]
1340 fn cmd_install_deploys_to_multiple_adapters() {
1341 let dir = tempfile::tempdir().unwrap();
1342 std::fs::write(
1343 dir.path().join("Skillfile"),
1344 "install claude-code local\n\
1345 install gemini-cli local\n\
1346 install codex local\n\
1347 local skill foo skills/foo.md\n\
1348 local agent bar agents/bar.md\n",
1349 )
1350 .unwrap();
1351 std::fs::create_dir_all(dir.path().join("skills")).unwrap();
1352 std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
1353 std::fs::create_dir_all(dir.path().join("agents")).unwrap();
1354 std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
1355
1356 cmd_install(
1357 dir.path(),
1358 &CmdInstallOpts {
1359 dry_run: false,
1360 update: false,
1361 extra_targets: None,
1362 },
1363 )
1364 .unwrap();
1365
1366 assert!(dir.path().join(".claude/skills/foo.md").exists());
1368 assert!(dir.path().join(".gemini/skills/foo.md").exists());
1369 assert!(dir.path().join(".codex/skills/foo.md").exists());
1370
1371 assert!(dir.path().join(".claude/agents/bar.md").exists());
1373 assert!(dir.path().join(".gemini/agents/bar.md").exists());
1374 assert!(!dir.path().join(".codex/agents").exists());
1375 }
1376
1377 #[test]
1378 fn cmd_install_pending_conflict_blocks() {
1379 let dir = tempfile::tempdir().unwrap();
1380 std::fs::write(
1381 dir.path().join("Skillfile"),
1382 "install claude-code local\nlocal skill foo skills/foo.md\n",
1383 )
1384 .unwrap();
1385
1386 write_conflict_fixture(
1387 dir.path(),
1388 &ConflictState {
1389 entry: "foo".into(),
1390 entity_type: EntityType::Skill,
1391 old_sha: "aaa".into(),
1392 new_sha: "bbb".into(),
1393 },
1394 );
1395
1396 let result = cmd_install(
1397 dir.path(),
1398 &CmdInstallOpts {
1399 dry_run: false,
1400 update: false,
1401 extra_targets: None,
1402 },
1403 );
1404 assert!(result.is_err());
1405 assert!(result.unwrap_err().to_string().contains("pending conflict"));
1406 }
1407
1408 fn make_skill_entry(name: &str) -> Entry {
1414 Entry {
1415 entity_type: EntityType::Skill,
1416 name: name.into(),
1417 source: SourceFields::Github {
1418 owner_repo: "owner/repo".into(),
1419 path_in_repo: format!("skills/{name}.md"),
1420 ref_: "main".into(),
1421 },
1422 }
1423 }
1424
1425 fn make_dir_skill_entry(name: &str) -> Entry {
1427 Entry {
1428 entity_type: EntityType::Skill,
1429 name: name.into(),
1430 source: SourceFields::Github {
1431 owner_repo: "owner/repo".into(),
1432 path_in_repo: format!("skills/{name}"),
1433 ref_: "main".into(),
1434 },
1435 }
1436 }
1437
1438 fn setup_github_skill_repo(dir: &Path, name: &str, cache_content: &str) {
1440 std::fs::write(
1442 dir.join("Skillfile"),
1443 format!(
1444 "install claude-code local\ngithub skill {name} owner/repo skills/{name}.md\n"
1445 ),
1446 )
1447 .unwrap();
1448
1449 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1451 locked.insert(
1452 format!("github/skill/{name}"),
1453 LockEntry {
1454 sha: "abc123def456abc123def456abc123def456abc123".into(),
1455 raw_url: format!(
1456 "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
1457 ),
1458 },
1459 );
1460 write_lock_fixture(dir, &locked);
1461
1462 let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
1464 std::fs::create_dir_all(&vdir).unwrap();
1465 std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
1466 }
1467
1468 #[test]
1473 fn auto_pin_entry_local_is_skipped() {
1474 let dir = tempfile::tempdir().unwrap();
1475
1476 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1478 let manifest = Manifest {
1479 entries: vec![entry.clone()],
1480 install_targets: vec![make_target("claude-code", Scope::Local)],
1481 };
1482
1483 let skills_dir = dir.path().join("skills");
1485 std::fs::create_dir_all(&skills_dir).unwrap();
1486 std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
1487
1488 auto_pin_entry(&entry, &manifest, dir.path());
1489
1490 assert!(
1492 !patch_fixture_path(dir.path(), &entry).exists(),
1493 "local entry must never be pinned"
1494 );
1495 }
1496
1497 #[test]
1498 fn auto_pin_entry_missing_lock_is_skipped() {
1499 let dir = tempfile::tempdir().unwrap();
1500
1501 let entry = make_skill_entry("test");
1502 let manifest = Manifest {
1503 entries: vec![entry.clone()],
1504 install_targets: vec![make_target("claude-code", Scope::Local)],
1505 };
1506
1507 auto_pin_entry(&entry, &manifest, dir.path());
1509
1510 assert!(!patch_fixture_path(dir.path(), &entry).exists());
1511 }
1512
1513 #[test]
1514 fn auto_pin_entry_missing_lock_key_is_skipped() {
1515 let dir = tempfile::tempdir().unwrap();
1516
1517 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1519 locked.insert(
1520 "github/skill/other".into(),
1521 LockEntry {
1522 sha: "aabbcc".into(),
1523 raw_url: "https://example.com/other.md".into(),
1524 },
1525 );
1526 write_lock_fixture(dir.path(), &locked);
1527
1528 let entry = make_skill_entry("test");
1529 let manifest = Manifest {
1530 entries: vec![entry.clone()],
1531 install_targets: vec![make_target("claude-code", Scope::Local)],
1532 };
1533
1534 auto_pin_entry(&entry, &manifest, dir.path());
1535
1536 assert!(!patch_fixture_path(dir.path(), &entry).exists());
1537 }
1538
1539 #[test]
1540 fn auto_pin_entry_writes_patch_when_installed_differs() {
1541 let dir = tempfile::tempdir().unwrap();
1542 let name = "my-skill";
1543
1544 let cache_content = "# My Skill\n\nOriginal content.\n";
1545 let installed_content = "# My Skill\n\nUser-modified content.\n";
1546
1547 setup_github_skill_repo(dir.path(), name, cache_content);
1548
1549 let installed_dir = dir.path().join(".claude/skills");
1551 std::fs::create_dir_all(&installed_dir).unwrap();
1552 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1553
1554 let entry = make_skill_entry(name);
1555 let manifest = Manifest {
1556 entries: vec![entry.clone()],
1557 install_targets: vec![make_target("claude-code", Scope::Local)],
1558 };
1559
1560 auto_pin_entry(&entry, &manifest, dir.path());
1561
1562 assert!(
1563 patch_fixture_path(dir.path(), &entry).exists(),
1564 "patch should be written when installed differs from cache"
1565 );
1566
1567 std::fs::write(installed_dir.join(format!("{name}.md")), cache_content).unwrap();
1570 let target = make_target("claude-code", Scope::Local);
1571 install_entry(
1572 &entry,
1573 &target,
1574 &InstallCtx {
1575 repo_root: dir.path(),
1576 opts: None,
1577 },
1578 )
1579 .unwrap();
1580 assert_eq!(
1581 std::fs::read_to_string(installed_dir.join(format!("{name}.md"))).unwrap(),
1582 installed_content,
1583 );
1584 }
1585
1586 #[test]
1587 fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
1588 let dir = tempfile::tempdir().unwrap();
1589 let name = "my-skill";
1590
1591 let cache_content = "# My Skill\n\nOriginal.\n";
1592 let installed_content = "# My Skill\n\nModified.\n";
1593
1594 setup_github_skill_repo(dir.path(), name, cache_content);
1595
1596 let entry = make_skill_entry(name);
1597 let manifest = Manifest {
1598 entries: vec![entry.clone()],
1599 install_targets: vec![make_target("claude-code", Scope::Local)],
1600 };
1601
1602 let patch_text = "--- a/my-skill.md\n+++ b/my-skill.md\n@@ -1,3 +1,3 @@\n # My Skill\n \n-Original.\n+Modified.\n";
1605 write_patch_fixture(dir.path(), &entry, patch_text);
1606
1607 let installed_dir = dir.path().join(".claude/skills");
1609 std::fs::create_dir_all(&installed_dir).unwrap();
1610 std::fs::write(installed_dir.join(format!("{name}.md")), installed_content).unwrap();
1611
1612 let patch_path = patch_fixture_path(dir.path(), &entry);
1614 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1615
1616 std::thread::sleep(std::time::Duration::from_millis(20));
1618
1619 auto_pin_entry(&entry, &manifest, dir.path());
1620
1621 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1622
1623 assert_eq!(
1624 mtime_before, mtime_after,
1625 "patch must not be rewritten when already up to date"
1626 );
1627 }
1628
1629 #[test]
1630 fn auto_pin_entry_repins_when_installed_has_additional_edits() {
1631 let dir = tempfile::tempdir().unwrap();
1632 let name = "my-skill";
1633
1634 let cache_content = "# My Skill\n\nOriginal.\n";
1635 let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
1636
1637 setup_github_skill_repo(dir.path(), name, cache_content);
1638
1639 let entry = make_skill_entry(name);
1640 let manifest = Manifest {
1641 entries: vec![entry.clone()],
1642 install_targets: vec![make_target("claude-code", Scope::Local)],
1643 };
1644
1645 let old_patch = "--- a/my-skill.md\n+++ b/my-skill.md\n@@ -1,3 +1,3 @@\n # My Skill\n \n-Original.\n+First edit.\n";
1647 write_patch_fixture(dir.path(), &entry, old_patch);
1648
1649 let installed_dir = dir.path().join(".claude/skills");
1651 std::fs::create_dir_all(&installed_dir).unwrap();
1652 std::fs::write(installed_dir.join(format!("{name}.md")), new_installed).unwrap();
1653
1654 auto_pin_entry(&entry, &manifest, dir.path());
1655
1656 std::fs::write(installed_dir.join(format!("{name}.md")), cache_content).unwrap();
1659 let target = make_target("claude-code", Scope::Local);
1660 install_entry(
1661 &entry,
1662 &target,
1663 &InstallCtx {
1664 repo_root: dir.path(),
1665 opts: None,
1666 },
1667 )
1668 .unwrap();
1669 assert_eq!(
1670 std::fs::read_to_string(installed_dir.join(format!("{name}.md"))).unwrap(),
1671 new_installed,
1672 "updated patch must describe the latest installed content"
1673 );
1674 }
1675
1676 #[test]
1681 fn auto_pin_dir_entry_writes_per_file_patches() {
1682 let dir = tempfile::tempdir().unwrap();
1683 let name = "lang-pro";
1684
1685 std::fs::write(
1687 dir.path().join("Skillfile"),
1688 format!(
1689 "install claude-code local\ngithub skill {name} owner/repo skills/{name}\n"
1690 ),
1691 )
1692 .unwrap();
1693 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1694 locked.insert(
1695 format!("github/skill/{name}"),
1696 LockEntry {
1697 sha: "deadbeefdeadbeefdeadbeef".into(),
1698 raw_url: format!("https://example.com/{name}"),
1699 },
1700 );
1701 write_lock_fixture(dir.path(), &locked);
1702
1703 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1705 std::fs::create_dir_all(&vdir).unwrap();
1706 std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
1707 std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1708
1709 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1711 std::fs::create_dir_all(&inst_dir).unwrap();
1712 std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
1713 std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1714
1715 let entry = make_dir_skill_entry(name);
1716 let manifest = Manifest {
1717 entries: vec![entry.clone()],
1718 install_targets: vec![make_target("claude-code", Scope::Local)],
1719 };
1720
1721 auto_pin_entry(&entry, &manifest, dir.path());
1722
1723 let skill_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1725 assert!(skill_patch.exists(), "patch for SKILL.md must be written");
1726
1727 let examples_patch = dir_patch_fixture_path(dir.path(), &entry, "examples.md");
1729 assert!(
1730 !examples_patch.exists(),
1731 "patch for examples.md must not be written (content unchanged)"
1732 );
1733 }
1734
1735 #[test]
1736 fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
1737 let dir = tempfile::tempdir().unwrap();
1738 let name = "lang-pro";
1739
1740 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1742 locked.insert(
1743 format!("github/skill/{name}"),
1744 LockEntry {
1745 sha: "abc".into(),
1746 raw_url: "https://example.com".into(),
1747 },
1748 );
1749 write_lock_fixture(dir.path(), &locked);
1750
1751 let entry = make_dir_skill_entry(name);
1752 let manifest = Manifest {
1753 entries: vec![entry.clone()],
1754 install_targets: vec![make_target("claude-code", Scope::Local)],
1755 };
1756
1757 auto_pin_entry(&entry, &manifest, dir.path());
1759
1760 assert!(!has_dir_patch_fixture(dir.path(), &entry));
1761 }
1762
1763 #[test]
1764 fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
1765 let dir = tempfile::tempdir().unwrap();
1766 let name = "lang-pro";
1767
1768 let cache_content = "# Lang Pro\n\nOriginal.\n";
1769 let modified = "# Lang Pro\n\nModified.\n";
1770
1771 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1773 locked.insert(
1774 format!("github/skill/{name}"),
1775 LockEntry {
1776 sha: "abc".into(),
1777 raw_url: "https://example.com".into(),
1778 },
1779 );
1780 write_lock_fixture(dir.path(), &locked);
1781
1782 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1784 std::fs::create_dir_all(&vdir).unwrap();
1785 std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
1786
1787 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1789 std::fs::create_dir_all(&inst_dir).unwrap();
1790 std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
1791
1792 let entry = make_dir_skill_entry(name);
1793 let manifest = Manifest {
1794 entries: vec![entry.clone()],
1795 install_targets: vec![make_target("claude-code", Scope::Local)],
1796 };
1797
1798 let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Lang Pro\n \n-Original.\n+Modified.\n";
1800 let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1801 std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1802 std::fs::write(&dp, patch_text).unwrap();
1803
1804 let patch_path = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1805 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1806
1807 std::thread::sleep(std::time::Duration::from_millis(20));
1808
1809 auto_pin_entry(&entry, &manifest, dir.path());
1810
1811 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1812
1813 assert_eq!(
1814 mtime_before, mtime_after,
1815 "dir patch must not be rewritten when already up to date"
1816 );
1817 }
1818
1819 #[test]
1824 fn apply_dir_patches_applies_patch_and_rebases() {
1825 let dir = tempfile::tempdir().unwrap();
1826
1827 let cache_content = "# Skill\n\nOriginal.\n";
1829 let installed_content = "# Skill\n\nModified.\n";
1830 let new_cache_content = "# Skill\n\nOriginal v2.\n";
1832 let expected_rebased_to_new_cache = installed_content;
1835
1836 let entry = make_dir_skill_entry("lang-pro");
1837
1838 let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1840 let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1841 std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1842 std::fs::write(&dp, patch_text).unwrap();
1843
1844 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1846 std::fs::create_dir_all(&inst_dir).unwrap();
1847 std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
1848
1849 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1851 std::fs::create_dir_all(&new_cache_dir).unwrap();
1852 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
1853
1854 let mut installed_files = std::collections::HashMap::new();
1856 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1857
1858 apply_dir_patches(
1859 &PatchCtx {
1860 entry: &entry,
1861 repo_root: dir.path(),
1862 },
1863 &installed_files,
1864 &new_cache_dir,
1865 )
1866 .unwrap();
1867
1868 let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
1870 assert_eq!(installed_after, installed_content);
1871
1872 std::fs::write(inst_dir.join("SKILL.md"), new_cache_content).unwrap();
1876 let mut reinstall_files = std::collections::HashMap::new();
1877 reinstall_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1878 apply_dir_patches(
1879 &PatchCtx {
1880 entry: &entry,
1881 repo_root: dir.path(),
1882 },
1883 &reinstall_files,
1884 &new_cache_dir,
1885 )
1886 .unwrap();
1887 assert_eq!(
1888 std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap(),
1889 expected_rebased_to_new_cache,
1890 "rebased patch applied to new_cache must reproduce installed_content"
1891 );
1892 }
1893
1894 #[test]
1895 fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
1896 let dir = tempfile::tempdir().unwrap();
1897
1898 let original = "# Skill\n\nOriginal.\n";
1900 let modified = "# Skill\n\nModified.\n";
1901 let new_cache = modified; let entry = make_dir_skill_entry("lang-pro");
1905
1906 let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1908 let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1909 std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1910 std::fs::write(&dp, patch_text).unwrap();
1911
1912 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1914 std::fs::create_dir_all(&inst_dir).unwrap();
1915 std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
1916
1917 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1918 std::fs::create_dir_all(&new_cache_dir).unwrap();
1919 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
1920
1921 let mut installed_files = std::collections::HashMap::new();
1922 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1923
1924 apply_dir_patches(
1925 &PatchCtx {
1926 entry: &entry,
1927 repo_root: dir.path(),
1928 },
1929 &installed_files,
1930 &new_cache_dir,
1931 )
1932 .unwrap();
1933
1934 let removed_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1936 assert!(
1937 !removed_patch.exists(),
1938 "patch file must be removed when rebase yields empty diff"
1939 );
1940 }
1941
1942 #[test]
1943 fn apply_dir_patches_no_op_when_no_patches_dir() {
1944 let dir = tempfile::tempdir().unwrap();
1945
1946 let entry = make_dir_skill_entry("lang-pro");
1948 let installed_files = std::collections::HashMap::new();
1949 let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1950 std::fs::create_dir_all(&source_dir).unwrap();
1951
1952 apply_dir_patches(
1954 &PatchCtx {
1955 entry: &entry,
1956 repo_root: dir.path(),
1957 },
1958 &installed_files,
1959 &source_dir,
1960 )
1961 .unwrap();
1962 }
1963
1964 #[test]
1969 fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
1970 let dir = tempfile::tempdir().unwrap();
1971
1972 let original = "# Skill\n\nOriginal.\n";
1973 let modified = "# Skill\n\nModified.\n";
1974 let new_cache = modified;
1976
1977 let entry = make_skill_entry("test");
1978
1979 let patch_text =
1981 "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1982 write_patch_fixture(dir.path(), &entry, patch_text);
1983
1984 let vdir = dir.path().join(".skillfile/cache/skills/test");
1986 std::fs::create_dir_all(&vdir).unwrap();
1987 let source = vdir.join("test.md");
1988 std::fs::write(&source, new_cache).unwrap();
1989
1990 let installed_dir = dir.path().join(".claude/skills");
1992 std::fs::create_dir_all(&installed_dir).unwrap();
1993 let dest = installed_dir.join("test.md");
1994 std::fs::write(&dest, original).unwrap();
1995
1996 apply_single_file_patch(
1997 &PatchCtx {
1998 entry: &entry,
1999 repo_root: dir.path(),
2000 },
2001 &dest,
2002 &source,
2003 )
2004 .unwrap();
2005
2006 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
2008
2009 assert!(
2011 !patch_fixture_path(dir.path(), &entry).exists(),
2012 "patch must be removed when new cache already matches patched content"
2013 );
2014 }
2015
2016 #[test]
2017 fn apply_single_file_patch_rewrites_patch_after_rebase() {
2018 let dir = tempfile::tempdir().unwrap();
2019
2020 let original = "# Skill\n\nOriginal.\n";
2022 let modified = "# Skill\n\nModified.\n";
2023 let new_cache = "# Skill\n\nOriginal v2.\n";
2024 let expected_rebased_result = modified;
2027
2028 let entry = make_skill_entry("test");
2029
2030 let patch_text =
2032 "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
2033 write_patch_fixture(dir.path(), &entry, patch_text);
2034
2035 let vdir = dir.path().join(".skillfile/cache/skills/test");
2037 std::fs::create_dir_all(&vdir).unwrap();
2038 let source = vdir.join("test.md");
2039 std::fs::write(&source, new_cache).unwrap();
2040
2041 let installed_dir = dir.path().join(".claude/skills");
2043 std::fs::create_dir_all(&installed_dir).unwrap();
2044 let dest = installed_dir.join("test.md");
2045 std::fs::write(&dest, original).unwrap();
2046
2047 apply_single_file_patch(
2048 &PatchCtx {
2049 entry: &entry,
2050 repo_root: dir.path(),
2051 },
2052 &dest,
2053 &source,
2054 )
2055 .unwrap();
2056
2057 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
2059
2060 assert!(
2062 patch_fixture_path(dir.path(), &entry).exists(),
2063 "rebased patch must still exist (new_cache != modified)"
2064 );
2065 std::fs::write(&dest, new_cache).unwrap();
2068 std::fs::write(&source, new_cache).unwrap();
2069 apply_single_file_patch(
2070 &PatchCtx {
2071 entry: &entry,
2072 repo_root: dir.path(),
2073 },
2074 &dest,
2075 &source,
2076 )
2077 .unwrap();
2078 assert_eq!(
2079 std::fs::read_to_string(&dest).unwrap(),
2080 expected_rebased_result,
2081 "rebased patch applied to new_cache must reproduce installed content"
2082 );
2083 }
2084
2085 #[test]
2090 fn check_preconditions_no_targets_returns_error() {
2091 let dir = tempfile::tempdir().unwrap();
2092 let manifest = Manifest {
2093 entries: vec![],
2094 install_targets: vec![],
2095 };
2096 let result = check_preconditions(&manifest, dir.path());
2097 assert!(result.is_err());
2098 assert!(result
2099 .unwrap_err()
2100 .to_string()
2101 .contains("No install targets"));
2102 }
2103
2104 #[test]
2105 fn check_preconditions_pending_conflict_returns_error() {
2106 let dir = tempfile::tempdir().unwrap();
2107 let manifest = Manifest {
2108 entries: vec![],
2109 install_targets: vec![make_target("claude-code", Scope::Local)],
2110 };
2111
2112 write_conflict_fixture(
2113 dir.path(),
2114 &ConflictState {
2115 entry: "my-skill".into(),
2116 entity_type: EntityType::Skill,
2117 old_sha: "aaa".into(),
2118 new_sha: "bbb".into(),
2119 },
2120 );
2121
2122 let result = check_preconditions(&manifest, dir.path());
2123 assert!(result.is_err());
2124 assert!(result.unwrap_err().to_string().contains("pending conflict"));
2125 }
2126
2127 #[test]
2128 fn check_preconditions_ok_with_target_and_no_conflict() {
2129 let dir = tempfile::tempdir().unwrap();
2130 let manifest = Manifest {
2131 entries: vec![],
2132 install_targets: vec![make_target("claude-code", Scope::Local)],
2133 };
2134 check_preconditions(&manifest, dir.path()).unwrap();
2135 }
2136}