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/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/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/my-skill");
825 std::fs::create_dir_all(&dest_dir).unwrap();
826 let dest = dest_dir.join("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/SKILL.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/test");
1035 std::fs::create_dir_all(&installed_dir).unwrap();
1036 std::fs::write(
1037 installed_dir.join("SKILL.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/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/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
1175 .path()
1176 .join(format!("{prefix}/skills/my-skill/SKILL.md"));
1177 assert!(dest.exists(), "Failed for adapter {adapter}");
1178 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Multi Skill");
1179 }
1180 }
1181
1182 #[test]
1185 fn cmd_install_no_manifest() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let result = cmd_install(
1188 dir.path(),
1189 &CmdInstallOpts {
1190 dry_run: false,
1191 update: false,
1192 extra_targets: None,
1193 },
1194 );
1195 assert!(result.is_err());
1196 assert!(result.unwrap_err().to_string().contains("not found"));
1197 }
1198
1199 #[test]
1200 fn cmd_install_no_install_targets() {
1201 let dir = tempfile::tempdir().unwrap();
1202 std::fs::write(
1203 dir.path().join("Skillfile"),
1204 "local skill foo skills/foo.md\n",
1205 )
1206 .unwrap();
1207
1208 let result = cmd_install(
1209 dir.path(),
1210 &CmdInstallOpts {
1211 dry_run: false,
1212 update: false,
1213 extra_targets: None,
1214 },
1215 );
1216 assert!(result.is_err());
1217 assert!(result
1218 .unwrap_err()
1219 .to_string()
1220 .contains("No install targets"));
1221 }
1222
1223 #[test]
1224 fn cmd_install_extra_targets_fallback() {
1225 let dir = tempfile::tempdir().unwrap();
1226 std::fs::write(
1228 dir.path().join("Skillfile"),
1229 "local skill foo skills/foo.md\n",
1230 )
1231 .unwrap();
1232 let source_file = dir.path().join("skills/foo.md");
1233 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1234 std::fs::write(&source_file, "# Foo").unwrap();
1235
1236 let targets = vec![make_target("claude-code", Scope::Local)];
1238 cmd_install(
1239 dir.path(),
1240 &CmdInstallOpts {
1241 dry_run: false,
1242 update: false,
1243 extra_targets: Some(&targets),
1244 },
1245 )
1246 .unwrap();
1247
1248 let dest = dir.path().join(".claude/skills/foo/SKILL.md");
1249 assert!(
1250 dest.exists(),
1251 "extra_targets must be used when Skillfile has none"
1252 );
1253 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Foo");
1254 }
1255
1256 #[test]
1257 fn cmd_install_skillfile_targets_win_over_extra() {
1258 let dir = tempfile::tempdir().unwrap();
1259 std::fs::write(
1261 dir.path().join("Skillfile"),
1262 "install claude-code local\nlocal skill foo skills/foo.md\n",
1263 )
1264 .unwrap();
1265 let source_file = dir.path().join("skills/foo.md");
1266 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1267 std::fs::write(&source_file, "# Foo").unwrap();
1268
1269 let targets = vec![make_target("gemini-cli", Scope::Local)];
1271 cmd_install(
1272 dir.path(),
1273 &CmdInstallOpts {
1274 dry_run: false,
1275 update: false,
1276 extra_targets: Some(&targets),
1277 },
1278 )
1279 .unwrap();
1280
1281 assert!(dir.path().join(".claude/skills/foo/SKILL.md").exists());
1283 assert!(!dir.path().join(".gemini").exists());
1285 }
1286
1287 #[test]
1288 fn cmd_install_dry_run_no_files() {
1289 let dir = tempfile::tempdir().unwrap();
1290 std::fs::write(
1291 dir.path().join("Skillfile"),
1292 "install claude-code local\nlocal skill foo skills/foo.md\n",
1293 )
1294 .unwrap();
1295 let source_file = dir.path().join("skills/foo.md");
1296 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1297 std::fs::write(&source_file, "# Foo").unwrap();
1298
1299 cmd_install(
1300 dir.path(),
1301 &CmdInstallOpts {
1302 dry_run: true,
1303 update: false,
1304 extra_targets: None,
1305 },
1306 )
1307 .unwrap();
1308
1309 assert!(!dir.path().join(".claude").exists());
1310 }
1311
1312 #[test]
1313 fn cmd_install_deploys_to_multiple_adapters() {
1314 let dir = tempfile::tempdir().unwrap();
1315 std::fs::write(
1316 dir.path().join("Skillfile"),
1317 "install claude-code local\n\
1318 install gemini-cli local\n\
1319 install codex local\n\
1320 local skill foo skills/foo.md\n\
1321 local agent bar agents/bar.md\n",
1322 )
1323 .unwrap();
1324 std::fs::create_dir_all(dir.path().join("skills")).unwrap();
1325 std::fs::write(dir.path().join("skills/foo.md"), "# Foo").unwrap();
1326 std::fs::create_dir_all(dir.path().join("agents")).unwrap();
1327 std::fs::write(dir.path().join("agents/bar.md"), "# Bar").unwrap();
1328
1329 cmd_install(
1330 dir.path(),
1331 &CmdInstallOpts {
1332 dry_run: false,
1333 update: false,
1334 extra_targets: None,
1335 },
1336 )
1337 .unwrap();
1338
1339 assert!(dir.path().join(".claude/skills/foo/SKILL.md").exists());
1341 assert!(dir.path().join(".gemini/skills/foo/SKILL.md").exists());
1342 assert!(dir.path().join(".codex/skills/foo/SKILL.md").exists());
1343
1344 assert!(dir.path().join(".claude/agents/bar.md").exists());
1346 assert!(dir.path().join(".gemini/agents/bar.md").exists());
1347 assert!(!dir.path().join(".codex/agents").exists());
1348 }
1349
1350 #[test]
1351 fn cmd_install_pending_conflict_blocks() {
1352 let dir = tempfile::tempdir().unwrap();
1353 std::fs::write(
1354 dir.path().join("Skillfile"),
1355 "install claude-code local\nlocal skill foo skills/foo.md\n",
1356 )
1357 .unwrap();
1358
1359 write_conflict_fixture(
1360 dir.path(),
1361 &ConflictState {
1362 entry: "foo".into(),
1363 entity_type: EntityType::Skill,
1364 old_sha: "aaa".into(),
1365 new_sha: "bbb".into(),
1366 },
1367 );
1368
1369 let result = cmd_install(
1370 dir.path(),
1371 &CmdInstallOpts {
1372 dry_run: false,
1373 update: false,
1374 extra_targets: None,
1375 },
1376 );
1377 assert!(result.is_err());
1378 assert!(result.unwrap_err().to_string().contains("pending conflict"));
1379 }
1380
1381 fn make_skill_entry(name: &str) -> Entry {
1387 Entry {
1388 entity_type: EntityType::Skill,
1389 name: name.into(),
1390 source: SourceFields::Github {
1391 owner_repo: "owner/repo".into(),
1392 path_in_repo: format!("skills/{name}.md"),
1393 ref_: "main".into(),
1394 },
1395 }
1396 }
1397
1398 fn make_dir_skill_entry(name: &str) -> Entry {
1400 Entry {
1401 entity_type: EntityType::Skill,
1402 name: name.into(),
1403 source: SourceFields::Github {
1404 owner_repo: "owner/repo".into(),
1405 path_in_repo: format!("skills/{name}"),
1406 ref_: "main".into(),
1407 },
1408 }
1409 }
1410
1411 fn setup_github_skill_repo(dir: &Path, name: &str, cache_content: &str) {
1413 std::fs::write(
1415 dir.join("Skillfile"),
1416 format!(
1417 "install claude-code local\ngithub skill {name} owner/repo skills/{name}.md\n"
1418 ),
1419 )
1420 .unwrap();
1421
1422 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1424 locked.insert(
1425 format!("github/skill/{name}"),
1426 LockEntry {
1427 sha: "abc123def456abc123def456abc123def456abc123".into(),
1428 raw_url: format!(
1429 "https://raw.githubusercontent.com/owner/repo/abc123def456/skills/{name}.md"
1430 ),
1431 },
1432 );
1433 write_lock_fixture(dir, &locked);
1434
1435 let vdir = dir.join(format!(".skillfile/cache/skills/{name}"));
1437 std::fs::create_dir_all(&vdir).unwrap();
1438 std::fs::write(vdir.join(format!("{name}.md")), cache_content).unwrap();
1439 }
1440
1441 #[test]
1446 fn auto_pin_entry_local_is_skipped() {
1447 let dir = tempfile::tempdir().unwrap();
1448
1449 let entry = make_local_entry("my-skill", "skills/my-skill.md");
1451 let manifest = Manifest {
1452 entries: vec![entry.clone()],
1453 install_targets: vec![make_target("claude-code", Scope::Local)],
1454 };
1455
1456 let skills_dir = dir.path().join("skills");
1458 std::fs::create_dir_all(&skills_dir).unwrap();
1459 std::fs::write(skills_dir.join("my-skill.md"), "# Original\n").unwrap();
1460
1461 auto_pin_entry(&entry, &manifest, dir.path());
1462
1463 assert!(
1465 !patch_fixture_path(dir.path(), &entry).exists(),
1466 "local entry must never be pinned"
1467 );
1468 }
1469
1470 #[test]
1471 fn auto_pin_entry_missing_lock_is_skipped() {
1472 let dir = tempfile::tempdir().unwrap();
1473
1474 let entry = make_skill_entry("test");
1475 let manifest = Manifest {
1476 entries: vec![entry.clone()],
1477 install_targets: vec![make_target("claude-code", Scope::Local)],
1478 };
1479
1480 auto_pin_entry(&entry, &manifest, dir.path());
1482
1483 assert!(!patch_fixture_path(dir.path(), &entry).exists());
1484 }
1485
1486 #[test]
1487 fn auto_pin_entry_missing_lock_key_is_skipped() {
1488 let dir = tempfile::tempdir().unwrap();
1489
1490 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1492 locked.insert(
1493 "github/skill/other".into(),
1494 LockEntry {
1495 sha: "aabbcc".into(),
1496 raw_url: "https://example.com/other.md".into(),
1497 },
1498 );
1499 write_lock_fixture(dir.path(), &locked);
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());
1508
1509 assert!(!patch_fixture_path(dir.path(), &entry).exists());
1510 }
1511
1512 #[test]
1513 fn auto_pin_entry_writes_patch_when_installed_differs() {
1514 let dir = tempfile::tempdir().unwrap();
1515 let name = "my-skill";
1516
1517 let cache_content = "# My Skill\n\nOriginal content.\n";
1518 let installed_content = "# My Skill\n\nUser-modified content.\n";
1519
1520 setup_github_skill_repo(dir.path(), name, cache_content);
1521
1522 let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
1524 std::fs::create_dir_all(&installed_dir).unwrap();
1525 std::fs::write(installed_dir.join("SKILL.md"), installed_content).unwrap();
1526
1527 let entry = make_skill_entry(name);
1528 let manifest = Manifest {
1529 entries: vec![entry.clone()],
1530 install_targets: vec![make_target("claude-code", Scope::Local)],
1531 };
1532
1533 auto_pin_entry(&entry, &manifest, dir.path());
1534
1535 assert!(
1536 patch_fixture_path(dir.path(), &entry).exists(),
1537 "patch should be written when installed differs from cache"
1538 );
1539
1540 std::fs::write(installed_dir.join("SKILL.md"), cache_content).unwrap();
1543 let target = make_target("claude-code", Scope::Local);
1544 install_entry(
1545 &entry,
1546 &target,
1547 &InstallCtx {
1548 repo_root: dir.path(),
1549 opts: None,
1550 },
1551 )
1552 .unwrap();
1553 assert_eq!(
1554 std::fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
1555 installed_content,
1556 );
1557 }
1558
1559 #[test]
1560 fn auto_pin_entry_no_repin_when_patch_already_describes_installed() {
1561 let dir = tempfile::tempdir().unwrap();
1562 let name = "my-skill";
1563
1564 let cache_content = "# My Skill\n\nOriginal.\n";
1565 let installed_content = "# My Skill\n\nModified.\n";
1566
1567 setup_github_skill_repo(dir.path(), name, cache_content);
1568
1569 let entry = make_skill_entry(name);
1570 let manifest = Manifest {
1571 entries: vec![entry.clone()],
1572 install_targets: vec![make_target("claude-code", Scope::Local)],
1573 };
1574
1575 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";
1578 write_patch_fixture(dir.path(), &entry, patch_text);
1579
1580 let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
1582 std::fs::create_dir_all(&installed_dir).unwrap();
1583 std::fs::write(installed_dir.join("SKILL.md"), installed_content).unwrap();
1584
1585 let patch_path = patch_fixture_path(dir.path(), &entry);
1587 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1588
1589 std::thread::sleep(std::time::Duration::from_millis(20));
1591
1592 auto_pin_entry(&entry, &manifest, dir.path());
1593
1594 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1595
1596 assert_eq!(
1597 mtime_before, mtime_after,
1598 "patch must not be rewritten when already up to date"
1599 );
1600 }
1601
1602 #[test]
1603 fn auto_pin_entry_repins_when_installed_has_additional_edits() {
1604 let dir = tempfile::tempdir().unwrap();
1605 let name = "my-skill";
1606
1607 let cache_content = "# My Skill\n\nOriginal.\n";
1608 let new_installed = "# My Skill\n\nFirst edit.\n\nSecond edit.\n";
1609
1610 setup_github_skill_repo(dir.path(), name, cache_content);
1611
1612 let entry = make_skill_entry(name);
1613 let manifest = Manifest {
1614 entries: vec![entry.clone()],
1615 install_targets: vec![make_target("claude-code", Scope::Local)],
1616 };
1617
1618 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";
1620 write_patch_fixture(dir.path(), &entry, old_patch);
1621
1622 let installed_dir = dir.path().join(format!(".claude/skills/{name}"));
1624 std::fs::create_dir_all(&installed_dir).unwrap();
1625 std::fs::write(installed_dir.join("SKILL.md"), new_installed).unwrap();
1626
1627 auto_pin_entry(&entry, &manifest, dir.path());
1628
1629 std::fs::write(installed_dir.join("SKILL.md"), cache_content).unwrap();
1632 let target = make_target("claude-code", Scope::Local);
1633 install_entry(
1634 &entry,
1635 &target,
1636 &InstallCtx {
1637 repo_root: dir.path(),
1638 opts: None,
1639 },
1640 )
1641 .unwrap();
1642 assert_eq!(
1643 std::fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
1644 new_installed,
1645 "updated patch must describe the latest installed content"
1646 );
1647 }
1648
1649 #[test]
1654 fn auto_pin_dir_entry_writes_per_file_patches() {
1655 let dir = tempfile::tempdir().unwrap();
1656 let name = "lang-pro";
1657
1658 std::fs::write(
1660 dir.path().join("Skillfile"),
1661 format!(
1662 "install claude-code local\ngithub skill {name} owner/repo skills/{name}\n"
1663 ),
1664 )
1665 .unwrap();
1666 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1667 locked.insert(
1668 format!("github/skill/{name}"),
1669 LockEntry {
1670 sha: "deadbeefdeadbeefdeadbeef".into(),
1671 raw_url: format!("https://example.com/{name}"),
1672 },
1673 );
1674 write_lock_fixture(dir.path(), &locked);
1675
1676 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1678 std::fs::create_dir_all(&vdir).unwrap();
1679 std::fs::write(vdir.join("SKILL.md"), "# Lang Pro\n\nOriginal.\n").unwrap();
1680 std::fs::write(vdir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1681
1682 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1684 std::fs::create_dir_all(&inst_dir).unwrap();
1685 std::fs::write(inst_dir.join("SKILL.md"), "# Lang Pro\n\nModified.\n").unwrap();
1686 std::fs::write(inst_dir.join("examples.md"), "# Examples\n\nOriginal.\n").unwrap();
1687
1688 let entry = make_dir_skill_entry(name);
1689 let manifest = Manifest {
1690 entries: vec![entry.clone()],
1691 install_targets: vec![make_target("claude-code", Scope::Local)],
1692 };
1693
1694 auto_pin_entry(&entry, &manifest, dir.path());
1695
1696 let skill_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1698 assert!(skill_patch.exists(), "patch for SKILL.md must be written");
1699
1700 let examples_patch = dir_patch_fixture_path(dir.path(), &entry, "examples.md");
1702 assert!(
1703 !examples_patch.exists(),
1704 "patch for examples.md must not be written (content unchanged)"
1705 );
1706 }
1707
1708 #[test]
1709 fn auto_pin_dir_entry_skips_when_vendor_dir_missing() {
1710 let dir = tempfile::tempdir().unwrap();
1711 let name = "lang-pro";
1712
1713 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1715 locked.insert(
1716 format!("github/skill/{name}"),
1717 LockEntry {
1718 sha: "abc".into(),
1719 raw_url: "https://example.com".into(),
1720 },
1721 );
1722 write_lock_fixture(dir.path(), &locked);
1723
1724 let entry = make_dir_skill_entry(name);
1725 let manifest = Manifest {
1726 entries: vec![entry.clone()],
1727 install_targets: vec![make_target("claude-code", Scope::Local)],
1728 };
1729
1730 auto_pin_entry(&entry, &manifest, dir.path());
1732
1733 assert!(!has_dir_patch_fixture(dir.path(), &entry));
1734 }
1735
1736 #[test]
1737 fn auto_pin_dir_entry_no_repin_when_patch_already_matches() {
1738 let dir = tempfile::tempdir().unwrap();
1739 let name = "lang-pro";
1740
1741 let cache_content = "# Lang Pro\n\nOriginal.\n";
1742 let modified = "# Lang Pro\n\nModified.\n";
1743
1744 let mut locked: BTreeMap<String, LockEntry> = BTreeMap::new();
1746 locked.insert(
1747 format!("github/skill/{name}"),
1748 LockEntry {
1749 sha: "abc".into(),
1750 raw_url: "https://example.com".into(),
1751 },
1752 );
1753 write_lock_fixture(dir.path(), &locked);
1754
1755 let vdir = dir.path().join(format!(".skillfile/cache/skills/{name}"));
1757 std::fs::create_dir_all(&vdir).unwrap();
1758 std::fs::write(vdir.join("SKILL.md"), cache_content).unwrap();
1759
1760 let inst_dir = dir.path().join(format!(".claude/skills/{name}"));
1762 std::fs::create_dir_all(&inst_dir).unwrap();
1763 std::fs::write(inst_dir.join("SKILL.md"), modified).unwrap();
1764
1765 let entry = make_dir_skill_entry(name);
1766 let manifest = Manifest {
1767 entries: vec![entry.clone()],
1768 install_targets: vec![make_target("claude-code", Scope::Local)],
1769 };
1770
1771 let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Lang Pro\n \n-Original.\n+Modified.\n";
1773 let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1774 std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1775 std::fs::write(&dp, patch_text).unwrap();
1776
1777 let patch_path = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1778 let mtime_before = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1779
1780 std::thread::sleep(std::time::Duration::from_millis(20));
1781
1782 auto_pin_entry(&entry, &manifest, dir.path());
1783
1784 let mtime_after = std::fs::metadata(&patch_path).unwrap().modified().unwrap();
1785
1786 assert_eq!(
1787 mtime_before, mtime_after,
1788 "dir patch must not be rewritten when already up to date"
1789 );
1790 }
1791
1792 #[test]
1797 fn apply_dir_patches_applies_patch_and_rebases() {
1798 let dir = tempfile::tempdir().unwrap();
1799
1800 let cache_content = "# Skill\n\nOriginal.\n";
1802 let installed_content = "# Skill\n\nModified.\n";
1803 let new_cache_content = "# Skill\n\nOriginal v2.\n";
1805 let expected_rebased_to_new_cache = installed_content;
1808
1809 let entry = make_dir_skill_entry("lang-pro");
1810
1811 let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1813 let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1814 std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1815 std::fs::write(&dp, patch_text).unwrap();
1816
1817 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1819 std::fs::create_dir_all(&inst_dir).unwrap();
1820 std::fs::write(inst_dir.join("SKILL.md"), cache_content).unwrap();
1821
1822 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1824 std::fs::create_dir_all(&new_cache_dir).unwrap();
1825 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache_content).unwrap();
1826
1827 let mut installed_files = std::collections::HashMap::new();
1829 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1830
1831 apply_dir_patches(
1832 &PatchCtx {
1833 entry: &entry,
1834 repo_root: dir.path(),
1835 },
1836 &installed_files,
1837 &new_cache_dir,
1838 )
1839 .unwrap();
1840
1841 let installed_after = std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap();
1843 assert_eq!(installed_after, installed_content);
1844
1845 std::fs::write(inst_dir.join("SKILL.md"), new_cache_content).unwrap();
1849 let mut reinstall_files = std::collections::HashMap::new();
1850 reinstall_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1851 apply_dir_patches(
1852 &PatchCtx {
1853 entry: &entry,
1854 repo_root: dir.path(),
1855 },
1856 &reinstall_files,
1857 &new_cache_dir,
1858 )
1859 .unwrap();
1860 assert_eq!(
1861 std::fs::read_to_string(inst_dir.join("SKILL.md")).unwrap(),
1862 expected_rebased_to_new_cache,
1863 "rebased patch applied to new_cache must reproduce installed_content"
1864 );
1865 }
1866
1867 #[test]
1868 fn apply_dir_patches_removes_patch_when_rebase_yields_empty_diff() {
1869 let dir = tempfile::tempdir().unwrap();
1870
1871 let original = "# Skill\n\nOriginal.\n";
1873 let modified = "# Skill\n\nModified.\n";
1874 let new_cache = modified; let entry = make_dir_skill_entry("lang-pro");
1878
1879 let patch_text = "--- a/SKILL.md\n+++ b/SKILL.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1881 let dp = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1882 std::fs::create_dir_all(dp.parent().unwrap()).unwrap();
1883 std::fs::write(&dp, patch_text).unwrap();
1884
1885 let inst_dir = dir.path().join(".claude/skills/lang-pro");
1887 std::fs::create_dir_all(&inst_dir).unwrap();
1888 std::fs::write(inst_dir.join("SKILL.md"), original).unwrap();
1889
1890 let new_cache_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1891 std::fs::create_dir_all(&new_cache_dir).unwrap();
1892 std::fs::write(new_cache_dir.join("SKILL.md"), new_cache).unwrap();
1893
1894 let mut installed_files = std::collections::HashMap::new();
1895 installed_files.insert("SKILL.md".to_string(), inst_dir.join("SKILL.md"));
1896
1897 apply_dir_patches(
1898 &PatchCtx {
1899 entry: &entry,
1900 repo_root: dir.path(),
1901 },
1902 &installed_files,
1903 &new_cache_dir,
1904 )
1905 .unwrap();
1906
1907 let removed_patch = dir_patch_fixture_path(dir.path(), &entry, "SKILL.md");
1909 assert!(
1910 !removed_patch.exists(),
1911 "patch file must be removed when rebase yields empty diff"
1912 );
1913 }
1914
1915 #[test]
1916 fn apply_dir_patches_no_op_when_no_patches_dir() {
1917 let dir = tempfile::tempdir().unwrap();
1918
1919 let entry = make_dir_skill_entry("lang-pro");
1921 let installed_files = std::collections::HashMap::new();
1922 let source_dir = dir.path().join(".skillfile/cache/skills/lang-pro");
1923 std::fs::create_dir_all(&source_dir).unwrap();
1924
1925 apply_dir_patches(
1927 &PatchCtx {
1928 entry: &entry,
1929 repo_root: dir.path(),
1930 },
1931 &installed_files,
1932 &source_dir,
1933 )
1934 .unwrap();
1935 }
1936
1937 #[test]
1942 fn apply_single_file_patch_removes_patch_when_rebase_is_empty() {
1943 let dir = tempfile::tempdir().unwrap();
1944
1945 let original = "# Skill\n\nOriginal.\n";
1946 let modified = "# Skill\n\nModified.\n";
1947 let new_cache = modified;
1949
1950 let entry = make_skill_entry("test");
1951
1952 let patch_text =
1954 "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
1955 write_patch_fixture(dir.path(), &entry, patch_text);
1956
1957 let vdir = dir.path().join(".skillfile/cache/skills/test");
1959 std::fs::create_dir_all(&vdir).unwrap();
1960 let source = vdir.join("test.md");
1961 std::fs::write(&source, new_cache).unwrap();
1962
1963 let installed_dir = dir.path().join(".claude/skills");
1965 std::fs::create_dir_all(&installed_dir).unwrap();
1966 let dest = installed_dir.join("test.md");
1967 std::fs::write(&dest, original).unwrap();
1968
1969 apply_single_file_patch(
1970 &PatchCtx {
1971 entry: &entry,
1972 repo_root: dir.path(),
1973 },
1974 &dest,
1975 &source,
1976 )
1977 .unwrap();
1978
1979 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
1981
1982 assert!(
1984 !patch_fixture_path(dir.path(), &entry).exists(),
1985 "patch must be removed when new cache already matches patched content"
1986 );
1987 }
1988
1989 #[test]
1990 fn apply_single_file_patch_rewrites_patch_after_rebase() {
1991 let dir = tempfile::tempdir().unwrap();
1992
1993 let original = "# Skill\n\nOriginal.\n";
1995 let modified = "# Skill\n\nModified.\n";
1996 let new_cache = "# Skill\n\nOriginal v2.\n";
1997 let expected_rebased_result = modified;
2000
2001 let entry = make_skill_entry("test");
2002
2003 let patch_text =
2005 "--- a/test.md\n+++ b/test.md\n@@ -1,3 +1,3 @@\n # Skill\n \n-Original.\n+Modified.\n";
2006 write_patch_fixture(dir.path(), &entry, patch_text);
2007
2008 let vdir = dir.path().join(".skillfile/cache/skills/test");
2010 std::fs::create_dir_all(&vdir).unwrap();
2011 let source = vdir.join("test.md");
2012 std::fs::write(&source, new_cache).unwrap();
2013
2014 let installed_dir = dir.path().join(".claude/skills");
2016 std::fs::create_dir_all(&installed_dir).unwrap();
2017 let dest = installed_dir.join("test.md");
2018 std::fs::write(&dest, original).unwrap();
2019
2020 apply_single_file_patch(
2021 &PatchCtx {
2022 entry: &entry,
2023 repo_root: dir.path(),
2024 },
2025 &dest,
2026 &source,
2027 )
2028 .unwrap();
2029
2030 assert_eq!(std::fs::read_to_string(&dest).unwrap(), modified);
2032
2033 assert!(
2035 patch_fixture_path(dir.path(), &entry).exists(),
2036 "rebased patch must still exist (new_cache != modified)"
2037 );
2038 std::fs::write(&dest, new_cache).unwrap();
2041 std::fs::write(&source, new_cache).unwrap();
2042 apply_single_file_patch(
2043 &PatchCtx {
2044 entry: &entry,
2045 repo_root: dir.path(),
2046 },
2047 &dest,
2048 &source,
2049 )
2050 .unwrap();
2051 assert_eq!(
2052 std::fs::read_to_string(&dest).unwrap(),
2053 expected_rebased_result,
2054 "rebased patch applied to new_cache must reproduce installed content"
2055 );
2056 }
2057
2058 #[test]
2063 fn check_preconditions_no_targets_returns_error() {
2064 let dir = tempfile::tempdir().unwrap();
2065 let manifest = Manifest {
2066 entries: vec![],
2067 install_targets: vec![],
2068 };
2069 let result = check_preconditions(&manifest, dir.path());
2070 assert!(result.is_err());
2071 assert!(result
2072 .unwrap_err()
2073 .to_string()
2074 .contains("No install targets"));
2075 }
2076
2077 #[test]
2078 fn check_preconditions_pending_conflict_returns_error() {
2079 let dir = tempfile::tempdir().unwrap();
2080 let manifest = Manifest {
2081 entries: vec![],
2082 install_targets: vec![make_target("claude-code", Scope::Local)],
2083 };
2084
2085 write_conflict_fixture(
2086 dir.path(),
2087 &ConflictState {
2088 entry: "my-skill".into(),
2089 entity_type: EntityType::Skill,
2090 old_sha: "aaa".into(),
2091 new_sha: "bbb".into(),
2092 },
2093 );
2094
2095 let result = check_preconditions(&manifest, dir.path());
2096 assert!(result.is_err());
2097 assert!(result.unwrap_err().to_string().contains("pending conflict"));
2098 }
2099
2100 #[test]
2101 fn check_preconditions_ok_with_target_and_no_conflict() {
2102 let dir = tempfile::tempdir().unwrap();
2103 let manifest = Manifest {
2104 entries: vec![],
2105 install_targets: vec![make_target("claude-code", Scope::Local)],
2106 };
2107 check_preconditions(&manifest, dir.path()).unwrap();
2108 }
2109}