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