1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::sync::OnceLock;
5
6use skillfile_core::models::{EntityType, Entry, InstallOptions, Scope};
7use skillfile_core::patch::walkdir;
8use skillfile_core::progress;
9use skillfile_sources::strategy::is_dir_entry;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DirInstallMode {
21 Flat,
22 Nested,
23}
24
25pub type DeployResult = HashMap<String, PathBuf>;
32
33pub struct AdapterScope<'a> {
34 pub scope: Scope,
35 pub repo_root: &'a Path,
36}
37
38pub struct DeployRequest<'a> {
39 pub entry: &'a Entry,
40 pub source: &'a Path,
41 pub scope: Scope,
42 pub repo_root: &'a Path,
43 pub opts: &'a InstallOptions,
44}
45
46pub trait PlatformAdapter: Send + Sync + fmt::Debug {
54 fn name(&self) -> &str;
55
56 fn supports(&self, entity_type: EntityType) -> bool;
57
58 fn target_dir(&self, entity_type: EntityType, ctx: &AdapterScope<'_>) -> PathBuf;
59
60 fn dir_mode(&self, entity_type: EntityType) -> Option<DirInstallMode>;
61
62 fn deploy_entry(&self, req: &DeployRequest<'_>) -> DeployResult;
65
66 fn installed_path(&self, entry: &Entry, ctx: &AdapterScope<'_>) -> PathBuf;
67
68 fn installed_dir_files(
69 &self,
70 entry: &Entry,
71 ctx: &AdapterScope<'_>,
72 ) -> HashMap<String, PathBuf>;
73}
74
75#[derive(Debug, Clone)]
80pub struct EntityConfig {
81 pub global_path: String,
82 pub local_path: String,
83 pub dir_mode: DirInstallMode,
84}
85
86#[derive(Debug, Clone)]
95pub struct FileSystemAdapter {
96 name: String,
97 entities: HashMap<EntityType, EntityConfig>,
98}
99
100impl FileSystemAdapter {
101 pub fn new(name: &str, entities: HashMap<EntityType, EntityConfig>) -> Self {
102 Self {
103 name: name.to_string(),
104 entities,
105 }
106 }
107
108 fn is_flat_mode(&self, entity_type: EntityType) -> bool {
110 self.entities
111 .get(&entity_type)
112 .is_some_and(|c| c.dir_mode == DirInstallMode::Flat)
113 }
114
115 fn single_file_install_path(&self, entry: &Entry, target_dir: &Path) -> PathBuf {
116 if self.is_flat_mode(entry.entity_type) {
117 target_dir.join(format!("{}.md", entry.name))
118 } else {
119 target_dir.join(&entry.name).join("SKILL.md")
120 }
121 }
122}
123
124fn preferred_home_dir_from(
125 home_override: Option<std::ffi::OsString>,
126 fallback: Option<PathBuf>,
127) -> PathBuf {
128 home_override
129 .filter(|path| !path.is_empty())
130 .map(PathBuf::from)
131 .or(fallback)
132 .unwrap_or_else(|| PathBuf::from("/"))
133}
134
135fn preferred_home_dir() -> PathBuf {
136 preferred_home_dir_from(std::env::var_os("HOME"), dirs::home_dir())
137}
138
139impl PlatformAdapter for FileSystemAdapter {
140 fn name(&self) -> &str {
141 &self.name
142 }
143
144 fn supports(&self, entity_type: EntityType) -> bool {
145 self.entities.contains_key(&entity_type)
146 }
147
148 fn target_dir(&self, entity_type: EntityType, ctx: &AdapterScope<'_>) -> PathBuf {
149 let config = self.entities.get(&entity_type).unwrap_or_else(|| {
150 panic!(
151 "BUG: target_dir called for unsupported entity type '{entity_type}' on adapter '{}'. \
152 Call supports() first.",
153 self.name
154 )
155 });
156 let raw = match ctx.scope {
157 Scope::Global => &config.global_path,
158 Scope::Local => &config.local_path,
159 };
160 if raw.starts_with('~') {
161 let home = preferred_home_dir();
162 home.join(raw.strip_prefix("~/").unwrap_or(raw))
163 } else {
164 ctx.repo_root.join(raw)
165 }
166 }
167
168 fn dir_mode(&self, entity_type: EntityType) -> Option<DirInstallMode> {
169 self.entities.get(&entity_type).map(|c| c.dir_mode)
170 }
171
172 fn deploy_entry(&self, req: &DeployRequest<'_>) -> DeployResult {
173 let ctx = AdapterScope {
174 scope: req.scope,
175 repo_root: req.repo_root,
176 };
177 let target_dir = self.target_dir(req.entry.entity_type, &ctx);
178 let is_dir = is_dir_entry(req.entry) || req.source.is_dir();
180
181 if is_dir
182 && self
183 .entities
184 .get(&req.entry.entity_type)
185 .is_some_and(|c| c.dir_mode == DirInstallMode::Flat)
186 {
187 return deploy_flat(req.source, &target_dir, req.opts);
188 }
189
190 let dest = if is_dir {
191 target_dir.join(&req.entry.name)
192 } else {
193 self.single_file_install_path(req.entry, &target_dir)
194 };
195
196 if !place_file(
197 &PlaceOp {
198 source: req.source,
199 dest: &dest,
200 is_dir,
201 },
202 req.opts,
203 ) || req.opts.dry_run
204 {
205 return HashMap::new();
206 }
207
208 if !self.is_flat_mode(req.entry.entity_type) {
211 remove_orphan_flat_file(&req.entry.name, &target_dir);
212 }
213
214 if is_dir {
215 collect_dir_deploy_result(req.source, &dest)
216 } else {
217 let entry_name = &req.entry.name;
218 HashMap::from([(format!("{entry_name}.md"), dest)])
219 }
220 }
221
222 fn installed_path(&self, entry: &Entry, ctx: &AdapterScope<'_>) -> PathBuf {
223 let target_dir = self.target_dir(entry.entity_type, ctx);
224 self.single_file_install_path(entry, &target_dir)
225 }
226
227 fn installed_dir_files(
228 &self,
229 entry: &Entry,
230 ctx: &AdapterScope<'_>,
231 ) -> HashMap<String, PathBuf> {
232 let target_dir = self.target_dir(entry.entity_type, ctx);
233 let mode = self
234 .entities
235 .get(&entry.entity_type)
236 .map_or(DirInstallMode::Nested, |c| c.dir_mode);
237
238 if mode == DirInstallMode::Nested {
239 collect_nested_installed(entry, &target_dir)
240 } else {
241 let vdir = skillfile_sources::sync::vendor_dir_for(entry, ctx.repo_root);
243 collect_flat_installed_checked(&vdir, &target_dir)
244 }
245 }
246}
247
248fn remove_orphan_flat_file(entry_name: &str, target_dir: &Path) {
255 let orphan = target_dir.join(format!("{entry_name}.md"));
256 if orphan.is_file() && std::fs::remove_file(&orphan).is_err() {
257 eprintln!("warning: failed to remove {}", orphan.display());
258 }
259}
260
261fn forward_slash(path: &Path) -> String {
266 path.to_string_lossy().replace('\\', "/")
267}
268
269fn collect_dir_deploy_result(source: &Path, dest: &Path) -> DeployResult {
270 let mut result = HashMap::new();
271 for file in walkdir(source) {
272 if file.file_name().is_none_or(|n| n == ".meta") {
273 continue;
274 }
275 let Ok(rel) = file.strip_prefix(source) else {
276 continue;
277 };
278 result.insert(forward_slash(rel), dest.join(rel));
279 }
280 result
281}
282
283fn collect_nested_installed(entry: &Entry, target_dir: &Path) -> HashMap<String, PathBuf> {
285 let installed_dir = target_dir.join(&entry.name);
286 if !installed_dir.is_dir() {
287 return HashMap::new();
288 }
289 collect_walkdir_relative(&installed_dir)
290}
291
292fn collect_flat_installed_checked(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
294 if !vdir.is_dir() {
295 return HashMap::new();
296 }
297 collect_flat_installed(vdir, target_dir)
298}
299
300fn collect_walkdir_relative(base: &Path) -> HashMap<String, PathBuf> {
301 let mut result = HashMap::new();
302 for file in walkdir(base) {
303 let Ok(rel) = file.strip_prefix(base) else {
304 continue;
305 };
306 result.insert(forward_slash(rel), file);
307 }
308 result
309}
310
311fn collect_flat_installed(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
312 let mut result = HashMap::new();
313 for file in walkdir(vdir) {
314 if file
315 .extension()
316 .is_none_or(|ext| ext.to_string_lossy() != "md")
317 {
318 continue;
319 }
320 let Ok(rel) = file.strip_prefix(vdir) else {
321 continue;
322 };
323 let dest = target_dir.join(file.file_name().unwrap_or_default());
324 if dest.exists() {
325 result.insert(forward_slash(rel), dest);
326 }
327 }
328 result
329}
330
331fn deploy_flat(source_dir: &Path, target_dir: &Path, opts: &InstallOptions) -> DeployResult {
332 let mut md_files: Vec<PathBuf> = walkdir(source_dir)
333 .into_iter()
334 .filter(|f| f.extension().is_some_and(|ext| ext == "md"))
335 .collect();
336 md_files.sort();
337
338 if opts.dry_run {
339 for src in md_files.iter().filter(|s| s.file_name().is_some()) {
340 let name = src.file_name().unwrap_or_default();
341 progress!(
342 " {} -> {} [copy, dry-run]",
343 name.to_string_lossy(),
344 target_dir.join(name).display()
345 );
346 }
347 return HashMap::new();
348 }
349
350 if std::fs::create_dir_all(target_dir).is_err() {
351 return HashMap::new();
352 }
353 let mut result = HashMap::new();
354 for src in &md_files {
355 let Some(name) = src.file_name() else {
356 continue;
357 };
358 let dest = target_dir.join(name);
359 if !place_file(
360 &PlaceOp {
361 source: src,
362 dest: &dest,
363 is_dir: false,
364 },
365 opts,
366 ) {
367 continue;
368 }
369 if let Ok(rel) = src.strip_prefix(source_dir) {
370 result.insert(forward_slash(rel), dest);
371 }
372 }
373 result
374}
375
376struct PlaceOp<'a> {
377 source: &'a Path,
378 dest: &'a Path,
379 is_dir: bool,
380}
381
382fn remove_existing_path(path: &Path) -> std::io::Result<()> {
383 if !(path.exists() || path.is_symlink()) {
384 return Ok(());
385 }
386 if path.is_dir() {
387 std::fs::remove_dir_all(path)
388 } else {
389 std::fs::remove_file(path)
390 }
391}
392
393fn cleanup_failed_path(path: &Path) {
394 let _ = remove_existing_path(path);
395}
396
397fn copy_to_destination(op: &PlaceOp<'_>) -> std::io::Result<()> {
398 if let Some(parent) = op.dest.parent() {
399 std::fs::create_dir_all(parent)?;
400 }
401
402 remove_existing_path(op.dest)?;
403 if op.is_dir {
404 copy_dir_recursive(op.source, op.dest)
405 } else {
406 std::fs::copy(op.source, op.dest).map(|_| ())
407 }
408}
409
410fn place_file(op: &PlaceOp<'_>, opts: &InstallOptions) -> bool {
411 if !opts.overwrite && !opts.dry_run && (op.dest.exists() || op.dest.is_symlink()) {
412 return false;
413 }
414
415 let label = format!(
416 " {} -> {}",
417 op.source.file_name().unwrap_or_default().to_string_lossy(),
418 op.dest.display()
419 );
420
421 if opts.dry_run {
422 progress!("{label} [copy, dry-run]");
423 return true;
424 }
425
426 if copy_to_destination(op).is_err() {
427 cleanup_failed_path(op.dest);
428 return false;
429 }
430
431 progress!("{label}");
432 true
433}
434
435#[allow(clippy::cognitive_complexity)]
439fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
440 std::fs::create_dir_all(dst)?;
441 for entry in std::fs::read_dir(src)? {
442 let entry = entry?;
443 let ty = entry.file_type()?;
444 let dest_path = dst.join(entry.file_name());
445 if ty.is_dir() {
446 copy_dir_recursive(&entry.path(), &dest_path)?;
447 } else {
448 std::fs::copy(entry.path(), &dest_path)?;
449 }
450 }
451 Ok(())
452}
453
454pub struct AdapterRegistry {
464 adapters: HashMap<String, Box<dyn PlatformAdapter>>,
465}
466
467impl AdapterRegistry {
468 pub fn new(adapters: Vec<Box<dyn PlatformAdapter>>) -> Self {
469 let map = adapters
470 .into_iter()
471 .map(|a| (a.name().to_string(), a))
472 .collect();
473 Self { adapters: map }
474 }
475
476 pub fn builtin() -> Self {
477 Self::new(
478 BUILTIN_ADAPTERS
479 .iter()
480 .map(|spec| Box::new(build_adapter(spec)) as Box<dyn PlatformAdapter>)
481 .collect(),
482 )
483 }
484
485 pub fn get(&self, name: &str) -> Option<&dyn PlatformAdapter> {
486 self.adapters.get(name).map(|b| &**b)
487 }
488
489 pub fn contains(&self, name: &str) -> bool {
490 self.adapters.contains_key(name)
491 }
492
493 pub fn names(&self) -> Vec<&str> {
494 let mut names: Vec<&str> = self.adapters.keys().map(String::as_str).collect();
495 names.sort_unstable();
496 names
497 }
498}
499
500impl fmt::Debug for AdapterRegistry {
501 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
502 f.debug_struct("AdapterRegistry")
503 .field("adapters", &self.names())
504 .finish()
505 }
506}
507
508struct EntitySpec {
513 entity_type: EntityType,
514 global_path: &'static str,
515 local_path: &'static str,
516 dir_mode: DirInstallMode,
517}
518
519struct AdapterSpec {
521 name: &'static str,
522 entities: &'static [EntitySpec],
523}
524
525const BUILTIN_ADAPTERS: &[AdapterSpec] = &[
540 AdapterSpec {
541 name: "claude-code",
542 entities: &[
543 EntitySpec {
544 entity_type: EntityType::Skill,
545 global_path: "~/.claude/skills",
546 local_path: ".claude/skills",
547 dir_mode: DirInstallMode::Nested,
548 },
549 EntitySpec {
550 entity_type: EntityType::Agent,
551 global_path: "~/.claude/agents",
552 local_path: ".claude/agents",
553 dir_mode: DirInstallMode::Flat,
554 },
555 ],
556 },
557 AdapterSpec {
558 name: "factory",
559 entities: &[
560 EntitySpec {
561 entity_type: EntityType::Skill,
562 global_path: "~/.factory/skills",
563 local_path: ".factory/skills",
564 dir_mode: DirInstallMode::Nested,
565 },
566 EntitySpec {
567 entity_type: EntityType::Agent,
568 global_path: "~/.factory/droids",
569 local_path: ".factory/droids",
570 dir_mode: DirInstallMode::Flat,
571 },
572 ],
573 },
574 AdapterSpec {
575 name: "gemini-cli",
576 entities: &[
577 EntitySpec {
578 entity_type: EntityType::Skill,
579 global_path: "~/.gemini/skills",
580 local_path: ".gemini/skills",
581 dir_mode: DirInstallMode::Nested,
582 },
583 EntitySpec {
584 entity_type: EntityType::Agent,
585 global_path: "~/.gemini/agents",
586 local_path: ".gemini/agents",
587 dir_mode: DirInstallMode::Flat,
588 },
589 ],
590 },
591 AdapterSpec {
592 name: "codex",
593 entities: &[EntitySpec {
594 entity_type: EntityType::Skill,
595 global_path: "~/.codex/skills",
596 local_path: ".codex/skills",
597 dir_mode: DirInstallMode::Nested,
598 }],
599 },
600 AdapterSpec {
601 name: "cursor",
602 entities: &[
603 EntitySpec {
604 entity_type: EntityType::Skill,
605 global_path: "~/.cursor/skills",
606 local_path: ".cursor/skills",
607 dir_mode: DirInstallMode::Nested,
608 },
609 EntitySpec {
610 entity_type: EntityType::Agent,
611 global_path: "~/.cursor/agents",
612 local_path: ".cursor/agents",
613 dir_mode: DirInstallMode::Flat,
614 },
615 ],
616 },
617 AdapterSpec {
618 name: "windsurf",
619 entities: &[EntitySpec {
620 entity_type: EntityType::Skill,
621 global_path: "~/.codeium/windsurf/skills",
622 local_path: ".windsurf/skills",
623 dir_mode: DirInstallMode::Nested,
624 }],
625 },
626 AdapterSpec {
627 name: "opencode",
628 entities: &[
629 EntitySpec {
630 entity_type: EntityType::Skill,
631 global_path: "~/.config/opencode/skills",
632 local_path: ".opencode/skills",
633 dir_mode: DirInstallMode::Nested,
634 },
635 EntitySpec {
636 entity_type: EntityType::Agent,
637 global_path: "~/.config/opencode/agents",
638 local_path: ".opencode/agents",
639 dir_mode: DirInstallMode::Flat,
640 },
641 ],
642 },
643 AdapterSpec {
644 name: "copilot",
645 entities: &[
646 EntitySpec {
647 entity_type: EntityType::Skill,
648 global_path: "~/.copilot/skills",
649 local_path: ".github/skills",
650 dir_mode: DirInstallMode::Nested,
651 },
652 EntitySpec {
653 entity_type: EntityType::Agent,
654 global_path: "~/.copilot/agents",
655 local_path: ".github/agents",
656 dir_mode: DirInstallMode::Flat,
657 },
658 ],
659 },
660 AdapterSpec {
661 name: "junie",
662 entities: &[
663 EntitySpec {
664 entity_type: EntityType::Skill,
665 global_path: "~/.junie/skills",
666 local_path: ".junie/skills",
667 dir_mode: DirInstallMode::Nested,
668 },
669 EntitySpec {
670 entity_type: EntityType::Agent,
671 global_path: "~/.junie/agents",
672 local_path: ".junie/agents",
673 dir_mode: DirInstallMode::Flat,
674 },
675 ],
676 },
677 AdapterSpec {
678 name: "antigravity",
679 entities: &[EntitySpec {
680 entity_type: EntityType::Skill,
681 global_path: "~/.gemini/antigravity/skills",
682 local_path: ".agents/skills",
683 dir_mode: DirInstallMode::Nested,
684 }],
685 },
686];
687
688fn build_adapter(spec: &AdapterSpec) -> FileSystemAdapter {
689 let entities = spec
690 .entities
691 .iter()
692 .map(|e| {
693 (
694 e.entity_type,
695 EntityConfig {
696 global_path: e.global_path.into(),
697 local_path: e.local_path.into(),
698 dir_mode: e.dir_mode,
699 },
700 )
701 })
702 .collect();
703 FileSystemAdapter::new(spec.name, entities)
704}
705
706#[must_use]
711pub fn adapters() -> &'static AdapterRegistry {
712 static REGISTRY: OnceLock<AdapterRegistry> = OnceLock::new();
713 REGISTRY.get_or_init(AdapterRegistry::builtin)
714}
715
716#[must_use]
717pub fn known_adapters() -> Vec<&'static str> {
718 adapters().names()
719}
720
721#[cfg(test)]
726mod tests {
727 use super::*;
728
729 fn local(root: &Path) -> AdapterScope<'_> {
730 AdapterScope {
731 scope: Scope::Local,
732 repo_root: root,
733 }
734 }
735
736 fn global(root: &Path) -> AdapterScope<'_> {
737 AdapterScope {
738 scope: Scope::Global,
739 repo_root: root,
740 }
741 }
742
743 #[test]
746 fn all_builtin_adapters_in_registry() {
747 let reg = adapters();
748 for spec in BUILTIN_ADAPTERS {
749 assert!(reg.contains(spec.name), "missing adapter: {}", spec.name);
750 }
751 }
752
753 #[test]
754 fn known_adapters_contains_all() {
755 let names = known_adapters();
756 for spec in BUILTIN_ADAPTERS {
757 assert!(names.contains(&spec.name), "missing adapter: {}", spec.name);
758 }
759 assert_eq!(names.len(), BUILTIN_ADAPTERS.len());
760 }
761
762 #[test]
763 fn adapter_name_matches_registry_key() {
764 let reg = adapters();
765 for name in reg.names() {
766 let adapter = reg.get(name).unwrap();
767 assert_eq!(adapter.name(), name);
768 }
769 }
770
771 #[test]
772 fn registry_get_unknown_returns_none() {
773 assert!(adapters().get("unknown-tool").is_none());
774 }
775
776 #[test]
779 fn claude_code_supports_agent_and_skill() {
780 let a = adapters().get("claude-code").unwrap();
781 assert!(a.supports(EntityType::Agent));
782 assert!(a.supports(EntityType::Skill));
783 }
785
786 #[test]
787 fn factory_supports_agent_and_skill() {
788 let a = adapters().get("factory").unwrap();
789 assert!(a.supports(EntityType::Agent));
790 assert!(a.supports(EntityType::Skill));
791 }
792
793 #[test]
794 fn gemini_cli_supports_agent_and_skill() {
795 let a = adapters().get("gemini-cli").unwrap();
796 assert!(a.supports(EntityType::Agent));
797 assert!(a.supports(EntityType::Skill));
798 }
799
800 #[test]
801 fn codex_supports_skill_not_agent() {
802 let a = adapters().get("codex").unwrap();
803 assert!(a.supports(EntityType::Skill));
804 assert!(!a.supports(EntityType::Agent));
805 }
806
807 #[test]
810 fn local_target_dir_claude_code() {
811 let tmp = PathBuf::from("/tmp/test");
812 let a = adapters().get("claude-code").unwrap();
813 assert_eq!(
814 a.target_dir(EntityType::Agent, &local(&tmp)),
815 tmp.join(".claude/agents")
816 );
817 assert_eq!(
818 a.target_dir(EntityType::Skill, &local(&tmp)),
819 tmp.join(".claude/skills")
820 );
821 }
822
823 #[test]
824 fn local_target_dir_factory() {
825 let tmp = PathBuf::from("/tmp/test");
826 let a = adapters().get("factory").unwrap();
827 assert_eq!(
828 a.target_dir(EntityType::Agent, &local(&tmp)),
829 tmp.join(".factory/droids")
830 );
831 assert_eq!(
832 a.target_dir(EntityType::Skill, &local(&tmp)),
833 tmp.join(".factory/skills")
834 );
835 }
836
837 #[test]
838 fn local_target_dir_gemini_cli() {
839 let tmp = PathBuf::from("/tmp/test");
840 let a = adapters().get("gemini-cli").unwrap();
841 assert_eq!(
842 a.target_dir(EntityType::Agent, &local(&tmp)),
843 tmp.join(".gemini/agents")
844 );
845 assert_eq!(
846 a.target_dir(EntityType::Skill, &local(&tmp)),
847 tmp.join(".gemini/skills")
848 );
849 }
850
851 #[test]
852 fn local_target_dir_codex() {
853 let tmp = PathBuf::from("/tmp/test");
854 let a = adapters().get("codex").unwrap();
855 assert_eq!(
856 a.target_dir(EntityType::Skill, &local(&tmp)),
857 tmp.join(".codex/skills")
858 );
859 }
860
861 #[test]
862 fn global_target_dir_is_absolute() {
863 let a = adapters().get("claude-code").unwrap();
864 let result = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
865 assert!(result.is_absolute());
866 assert!(result.to_string_lossy().ends_with(".claude/agents"));
867 }
868
869 #[test]
870 fn global_target_dir_gemini_cli_skill() {
871 let a = adapters().get("gemini-cli").unwrap();
872 let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
873 assert!(result.is_absolute());
874 assert!(result.to_string_lossy().ends_with(".gemini/skills"));
875 }
876
877 #[test]
878 fn global_target_dir_codex_skill() {
879 let a = adapters().get("codex").unwrap();
880 let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
881 assert!(result.is_absolute());
882 assert!(result.to_string_lossy().ends_with(".codex/skills"));
883 }
884
885 #[test]
888 fn cursor_supports_agent_and_skill() {
889 let a = adapters().get("cursor").unwrap();
890 assert!(a.supports(EntityType::Agent));
891 assert!(a.supports(EntityType::Skill));
892 }
894
895 #[test]
896 fn windsurf_supports_skill_not_agent() {
897 let a = adapters().get("windsurf").unwrap();
898 assert!(a.supports(EntityType::Skill));
899 assert!(!a.supports(EntityType::Agent));
900 }
901
902 #[test]
903 fn opencode_supports_agent_and_skill() {
904 let a = adapters().get("opencode").unwrap();
905 assert!(a.supports(EntityType::Agent));
906 assert!(a.supports(EntityType::Skill));
907 }
909
910 #[test]
911 fn copilot_supports_agent_and_skill() {
912 let a = adapters().get("copilot").unwrap();
913 assert!(a.supports(EntityType::Agent));
914 assert!(a.supports(EntityType::Skill));
915 }
917
918 #[test]
921 fn local_target_dir_cursor() {
922 let tmp = PathBuf::from("/tmp/test");
923 let a = adapters().get("cursor").unwrap();
924 assert_eq!(
925 a.target_dir(EntityType::Agent, &local(&tmp)),
926 tmp.join(".cursor/agents")
927 );
928 assert_eq!(
929 a.target_dir(EntityType::Skill, &local(&tmp)),
930 tmp.join(".cursor/skills")
931 );
932 }
933
934 #[test]
935 fn local_target_dir_windsurf() {
936 let tmp = PathBuf::from("/tmp/test");
937 let a = adapters().get("windsurf").unwrap();
938 assert_eq!(
939 a.target_dir(EntityType::Skill, &local(&tmp)),
940 tmp.join(".windsurf/skills")
941 );
942 }
943
944 #[test]
945 fn local_target_dir_opencode() {
946 let tmp = PathBuf::from("/tmp/test");
947 let a = adapters().get("opencode").unwrap();
948 assert_eq!(
949 a.target_dir(EntityType::Agent, &local(&tmp)),
950 tmp.join(".opencode/agents")
951 );
952 assert_eq!(
953 a.target_dir(EntityType::Skill, &local(&tmp)),
954 tmp.join(".opencode/skills")
955 );
956 }
957
958 #[test]
959 fn local_target_dir_copilot() {
960 let tmp = PathBuf::from("/tmp/test");
961 let a = adapters().get("copilot").unwrap();
962 assert_eq!(
963 a.target_dir(EntityType::Agent, &local(&tmp)),
964 tmp.join(".github/agents")
965 );
966 assert_eq!(
967 a.target_dir(EntityType::Skill, &local(&tmp)),
968 tmp.join(".github/skills")
969 );
970 }
971
972 #[test]
973 fn global_target_dir_cursor() {
974 let a = adapters().get("cursor").unwrap();
975 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
976 assert!(skill.is_absolute());
977 assert!(skill.to_string_lossy().ends_with(".cursor/skills"));
978 let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
979 assert!(agent.is_absolute());
980 assert!(agent.to_string_lossy().ends_with(".cursor/agents"));
981 }
982
983 #[test]
984 fn global_target_dir_windsurf() {
985 let a = adapters().get("windsurf").unwrap();
986 let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
987 assert!(result.is_absolute());
988 assert!(
989 result.to_string_lossy().ends_with("windsurf/skills"),
990 "unexpected: {result:?}"
991 );
992 }
993
994 #[test]
995 fn global_target_dir_opencode() {
996 let a = adapters().get("opencode").unwrap();
997 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
998 assert!(skill.is_absolute());
999 assert!(
1000 skill.to_string_lossy().ends_with("opencode/skills"),
1001 "unexpected: {skill:?}"
1002 );
1003 let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
1004 assert!(agent.is_absolute());
1005 assert!(
1006 agent.to_string_lossy().ends_with("opencode/agents"),
1007 "unexpected: {agent:?}"
1008 );
1009 }
1010
1011 #[test]
1012 fn global_target_dir_copilot() {
1013 let a = adapters().get("copilot").unwrap();
1014 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
1015 assert!(skill.is_absolute());
1016 assert!(skill.to_string_lossy().ends_with(".copilot/skills"));
1017 let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
1018 assert!(agent.is_absolute());
1019 assert!(agent.to_string_lossy().ends_with(".copilot/agents"));
1020 }
1021
1022 #[test]
1025 fn cursor_dir_modes() {
1026 let a = adapters().get("cursor").unwrap();
1027 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1028 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1029 }
1030
1031 #[test]
1032 fn windsurf_dir_mode() {
1033 let a = adapters().get("windsurf").unwrap();
1034 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1035 assert_eq!(a.dir_mode(EntityType::Agent), None);
1036 }
1037
1038 #[test]
1039 fn opencode_dir_modes() {
1040 let a = adapters().get("opencode").unwrap();
1041 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1042 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1043 }
1044
1045 #[test]
1046 fn copilot_dir_modes() {
1047 let a = adapters().get("copilot").unwrap();
1048 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1049 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1050 }
1051
1052 #[test]
1055 fn claude_code_dir_modes() {
1056 let a = adapters().get("claude-code").unwrap();
1057 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1058 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1059 }
1060
1061 #[test]
1062 fn gemini_cli_dir_modes() {
1063 let a = adapters().get("gemini-cli").unwrap();
1064 assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
1065 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1066 }
1067
1068 #[test]
1069 fn codex_dir_mode() {
1070 let a = adapters().get("codex").unwrap();
1071 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1072 }
1073
1074 #[test]
1077 fn custom_adapter_via_registry() {
1078 let custom = FileSystemAdapter::new(
1079 "my-tool",
1080 HashMap::from([(
1081 EntityType::Skill,
1082 EntityConfig {
1083 global_path: "~/.my-tool/skills".into(),
1084 local_path: ".my-tool/skills".into(),
1085 dir_mode: DirInstallMode::Nested,
1086 },
1087 )]),
1088 );
1089 let registry = AdapterRegistry::new(vec![Box::new(custom)]);
1090 let a = registry.get("my-tool").unwrap();
1091 assert!(a.supports(EntityType::Skill));
1092 assert!(!a.supports(EntityType::Agent));
1093 assert_eq!(registry.names(), vec!["my-tool"]);
1094 }
1095
1096 #[test]
1099 fn deploy_entry_single_file_key_matches_patch_convention() {
1100 use skillfile_core::models::{EntityType, SourceFields};
1101
1102 let dir = tempfile::tempdir().unwrap();
1103 let source_dir = dir.path().join(".skillfile/cache/agents/test");
1104 std::fs::create_dir_all(&source_dir).unwrap();
1105 std::fs::write(source_dir.join("agent.md"), "# Agent\n").unwrap();
1106 let source = source_dir.join("agent.md");
1107
1108 let entry = Entry {
1109 entity_type: EntityType::Agent,
1110 name: "test".into(),
1111 source: SourceFields::Github {
1112 owner_repo: "o/r".into(),
1113 path_in_repo: "agents/agent.md".into(),
1114 ref_: "main".into(),
1115 },
1116 };
1117 let a = adapters().get("claude-code").unwrap();
1118 let result = a.deploy_entry(&DeployRequest {
1119 entry: &entry,
1120 source: &source,
1121 scope: Scope::Local,
1122 repo_root: dir.path(),
1123 opts: &InstallOptions::default(),
1124 });
1125 assert!(
1126 result.contains_key("test.md"),
1127 "Single-file key must be 'test.md', got {:?}",
1128 result.keys().collect::<Vec<_>>()
1129 );
1130 }
1131
1132 #[test]
1135 fn deploy_flat_copies_md_files_to_target_dir() {
1136 use skillfile_core::models::{EntityType, SourceFields};
1137
1138 let dir = tempfile::tempdir().unwrap();
1139 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1141 std::fs::create_dir_all(&source_dir).unwrap();
1142 std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1143 std::fs::write(source_dir.join("frontend.md"), "# Frontend").unwrap();
1144 std::fs::write(source_dir.join(".meta"), "{}").unwrap();
1145
1146 let entry = Entry {
1147 entity_type: EntityType::Agent,
1148 name: "core-dev".into(),
1149 source: SourceFields::Github {
1150 owner_repo: "o/r".into(),
1151 path_in_repo: "agents/core-dev".into(),
1152 ref_: "main".into(),
1153 },
1154 };
1155 let a = adapters().get("claude-code").unwrap();
1156 let result = a.deploy_entry(&DeployRequest {
1157 entry: &entry,
1158 source: &source_dir,
1159 scope: Scope::Local,
1160 repo_root: dir.path(),
1161 opts: &InstallOptions {
1162 dry_run: false,
1163 overwrite: true,
1164 },
1165 });
1166 assert!(result.contains_key("backend.md"));
1168 assert!(result.contains_key("frontend.md"));
1169 assert!(!result.contains_key(".meta"));
1170 let target = dir.path().join(".claude/agents");
1172 assert!(target.join("backend.md").exists());
1173 assert!(target.join("frontend.md").exists());
1174 }
1175
1176 #[test]
1177 fn deploy_flat_dry_run_returns_empty() {
1178 use skillfile_core::models::{EntityType, SourceFields};
1179
1180 let dir = tempfile::tempdir().unwrap();
1181 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1182 std::fs::create_dir_all(&source_dir).unwrap();
1183 std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1184
1185 let entry = Entry {
1186 entity_type: EntityType::Agent,
1187 name: "core-dev".into(),
1188 source: SourceFields::Github {
1189 owner_repo: "o/r".into(),
1190 path_in_repo: "agents/core-dev".into(),
1191 ref_: "main".into(),
1192 },
1193 };
1194 let a = adapters().get("claude-code").unwrap();
1195 let result = a.deploy_entry(&DeployRequest {
1196 entry: &entry,
1197 source: &source_dir,
1198 scope: Scope::Local,
1199 repo_root: dir.path(),
1200 opts: &InstallOptions {
1201 dry_run: true,
1202 overwrite: false,
1203 },
1204 });
1205 assert!(result.is_empty());
1206 assert!(!dir.path().join(".claude/agents/backend.md").exists());
1207 }
1208
1209 #[test]
1210 fn deploy_flat_skips_existing_when_no_overwrite() {
1211 use skillfile_core::models::{EntityType, SourceFields};
1212
1213 let dir = tempfile::tempdir().unwrap();
1214 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1215 std::fs::create_dir_all(&source_dir).unwrap();
1216 std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1217
1218 let target = dir.path().join(".claude/agents");
1220 std::fs::create_dir_all(&target).unwrap();
1221 std::fs::write(target.join("backend.md"), "# Old").unwrap();
1222
1223 let entry = Entry {
1224 entity_type: EntityType::Agent,
1225 name: "core-dev".into(),
1226 source: SourceFields::Github {
1227 owner_repo: "o/r".into(),
1228 path_in_repo: "agents/core-dev".into(),
1229 ref_: "main".into(),
1230 },
1231 };
1232 let a = adapters().get("claude-code").unwrap();
1233 let result = a.deploy_entry(&DeployRequest {
1234 entry: &entry,
1235 source: &source_dir,
1236 scope: Scope::Local,
1237 repo_root: dir.path(),
1238 opts: &InstallOptions {
1239 dry_run: false,
1240 overwrite: false,
1241 },
1242 });
1243 assert!(result.is_empty());
1245 assert_eq!(
1247 std::fs::read_to_string(target.join("backend.md")).unwrap(),
1248 "# Old"
1249 );
1250 }
1251
1252 #[test]
1253 fn deploy_flat_overwrites_existing_when_overwrite_true() {
1254 use skillfile_core::models::{EntityType, SourceFields};
1255
1256 let dir = tempfile::tempdir().unwrap();
1257 let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1258 std::fs::create_dir_all(&source_dir).unwrap();
1259 std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1260
1261 let target = dir.path().join(".claude/agents");
1262 std::fs::create_dir_all(&target).unwrap();
1263 std::fs::write(target.join("backend.md"), "# Old").unwrap();
1264
1265 let entry = Entry {
1266 entity_type: EntityType::Agent,
1267 name: "core-dev".into(),
1268 source: SourceFields::Github {
1269 owner_repo: "o/r".into(),
1270 path_in_repo: "agents/core-dev".into(),
1271 ref_: "main".into(),
1272 },
1273 };
1274 let a = adapters().get("claude-code").unwrap();
1275 let result = a.deploy_entry(&DeployRequest {
1276 entry: &entry,
1277 source: &source_dir,
1278 scope: Scope::Local,
1279 repo_root: dir.path(),
1280 opts: &InstallOptions {
1281 dry_run: false,
1282 overwrite: true,
1283 },
1284 });
1285 assert!(result.contains_key("backend.md"));
1286 assert_eq!(
1287 std::fs::read_to_string(target.join("backend.md")).unwrap(),
1288 "# New"
1289 );
1290 }
1291
1292 #[test]
1295 fn place_file_skips_existing_dir_when_no_overwrite() {
1296 use skillfile_core::models::{EntityType, SourceFields};
1297
1298 let dir = tempfile::tempdir().unwrap();
1299 let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1300 std::fs::create_dir_all(&source_dir).unwrap();
1301 std::fs::write(source_dir.join("SKILL.md"), "# Skill").unwrap();
1302
1303 let dest = dir.path().join(".claude/skills/my-skill");
1305 std::fs::create_dir_all(&dest).unwrap();
1306 std::fs::write(dest.join("OLD.md"), "# Old").unwrap();
1307
1308 let entry = Entry {
1309 entity_type: EntityType::Skill,
1310 name: "my-skill".into(),
1311 source: SourceFields::Github {
1312 owner_repo: "o/r".into(),
1313 path_in_repo: "skills/my-skill".into(),
1314 ref_: "main".into(),
1315 },
1316 };
1317 let a = adapters().get("claude-code").unwrap();
1318 let result = a.deploy_entry(&DeployRequest {
1319 entry: &entry,
1320 source: &source_dir,
1321 scope: Scope::Local,
1322 repo_root: dir.path(),
1323 opts: &InstallOptions {
1324 dry_run: false,
1325 overwrite: false,
1326 },
1327 });
1328 assert!(result.is_empty());
1330 assert!(dest.join("OLD.md").exists());
1332 }
1333
1334 #[test]
1335 fn place_file_skips_existing_single_file_when_no_overwrite() {
1336 use skillfile_core::models::{EntityType, SourceFields};
1337
1338 let dir = tempfile::tempdir().unwrap();
1339 let source_file = dir.path().join("skills/my-skill.md");
1340 std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1341 std::fs::write(&source_file, "# New").unwrap();
1342
1343 let dest = dir.path().join(".claude/skills/my-skill/SKILL.md");
1344 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1345 std::fs::write(&dest, "# Old").unwrap();
1346
1347 let entry = Entry {
1348 entity_type: EntityType::Skill,
1349 name: "my-skill".into(),
1350 source: SourceFields::Local {
1351 path: "skills/my-skill.md".into(),
1352 },
1353 };
1354 let a = adapters().get("claude-code").unwrap();
1355 let result = a.deploy_entry(&DeployRequest {
1356 entry: &entry,
1357 source: &source_file,
1358 scope: Scope::Local,
1359 repo_root: dir.path(),
1360 opts: &InstallOptions {
1361 dry_run: false,
1362 overwrite: false,
1363 },
1364 });
1365 assert!(result.is_empty());
1366 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old");
1367 }
1368
1369 #[test]
1372 fn installed_dir_files_flat_mode_returns_deployed_files() {
1373 use skillfile_core::models::{EntityType, SourceFields};
1374
1375 let dir = tempfile::tempdir().unwrap();
1376 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1378 std::fs::create_dir_all(&vdir).unwrap();
1379 std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1380 std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1381 std::fs::write(vdir.join(".meta"), "{}").unwrap();
1382
1383 let target = dir.path().join(".claude/agents");
1385 std::fs::create_dir_all(&target).unwrap();
1386 std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1387 std::fs::write(target.join("frontend.md"), "# Frontend").unwrap();
1388
1389 let entry = Entry {
1390 entity_type: EntityType::Agent,
1391 name: "core-dev".into(),
1392 source: SourceFields::Github {
1393 owner_repo: "o/r".into(),
1394 path_in_repo: "agents/core-dev".into(),
1395 ref_: "main".into(),
1396 },
1397 };
1398 let a = adapters().get("claude-code").unwrap();
1399 let files = a.installed_dir_files(&entry, &local(dir.path()));
1400 assert!(files.contains_key("backend.md"));
1401 assert!(files.contains_key("frontend.md"));
1402 assert!(!files.contains_key(".meta"));
1403 }
1404
1405 #[test]
1406 fn installed_dir_files_flat_mode_no_vdir_returns_empty() {
1407 use skillfile_core::models::{EntityType, SourceFields};
1408
1409 let dir = tempfile::tempdir().unwrap();
1410 let entry = Entry {
1412 entity_type: EntityType::Agent,
1413 name: "core-dev".into(),
1414 source: SourceFields::Github {
1415 owner_repo: "o/r".into(),
1416 path_in_repo: "agents/core-dev".into(),
1417 ref_: "main".into(),
1418 },
1419 };
1420 let a = adapters().get("claude-code").unwrap();
1421 let files = a.installed_dir_files(&entry, &local(dir.path()));
1422 assert!(files.is_empty());
1423 }
1424
1425 #[test]
1426 fn installed_dir_files_flat_mode_skips_non_deployed_files() {
1427 use skillfile_core::models::{EntityType, SourceFields};
1428
1429 let dir = tempfile::tempdir().unwrap();
1430 let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1431 std::fs::create_dir_all(&vdir).unwrap();
1432 std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1433 std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1434
1435 let target = dir.path().join(".claude/agents");
1437 std::fs::create_dir_all(&target).unwrap();
1438 std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1439 let entry = Entry {
1442 entity_type: EntityType::Agent,
1443 name: "core-dev".into(),
1444 source: SourceFields::Github {
1445 owner_repo: "o/r".into(),
1446 path_in_repo: "agents/core-dev".into(),
1447 ref_: "main".into(),
1448 },
1449 };
1450 let a = adapters().get("claude-code").unwrap();
1451 let files = a.installed_dir_files(&entry, &local(dir.path()));
1452 assert!(files.contains_key("backend.md"));
1453 assert!(!files.contains_key("frontend.md"));
1454 }
1455
1456 #[test]
1457 fn forward_slash_converts_backslashes() {
1458 assert_eq!(forward_slash(Path::new("a/b/c")), "a/b/c");
1459 assert_eq!(forward_slash(Path::new("simple.md")), "simple.md");
1460 }
1461
1462 #[cfg(windows)]
1463 #[test]
1464 fn forward_slash_converts_windows_separators() {
1465 assert_eq!(forward_slash(Path::new(r"a\b\c.md")), "a/b/c.md");
1466 }
1467
1468 #[test]
1469 fn deploy_entry_dir_keys_match_source_relative_paths() {
1470 use skillfile_core::models::{EntityType, SourceFields};
1471
1472 let dir = tempfile::tempdir().unwrap();
1473 let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1474 std::fs::create_dir_all(&source_dir).unwrap();
1475 std::fs::write(source_dir.join("SKILL.md"), "# Skill\n").unwrap();
1476 std::fs::write(source_dir.join("examples.md"), "# Examples\n").unwrap();
1477
1478 let entry = Entry {
1479 entity_type: EntityType::Skill,
1480 name: "my-skill".into(),
1481 source: SourceFields::Github {
1482 owner_repo: "o/r".into(),
1483 path_in_repo: "skills/my-skill".into(),
1484 ref_: "main".into(),
1485 },
1486 };
1487 let a = adapters().get("claude-code").unwrap();
1488 let result = a.deploy_entry(&DeployRequest {
1489 entry: &entry,
1490 source: &source_dir,
1491 scope: Scope::Local,
1492 repo_root: dir.path(),
1493 opts: &InstallOptions::default(),
1494 });
1495 assert!(result.contains_key("SKILL.md"));
1496 assert!(result.contains_key("examples.md"));
1497 }
1498
1499 #[test]
1500 fn deploy_entry_nested_mode_removes_legacy_flat_file_for_directory_sources() {
1501 use skillfile_core::models::{EntityType, SourceFields};
1502
1503 let dir = tempfile::tempdir().unwrap();
1504 let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1505 std::fs::create_dir_all(&source_dir).unwrap();
1506 std::fs::write(source_dir.join("SKILL.md"), "# Skill\n").unwrap();
1507
1508 let target_dir = dir.path().join(".claude/skills");
1509 std::fs::create_dir_all(&target_dir).unwrap();
1510 std::fs::write(target_dir.join("my-skill.md"), "# Legacy flat\n").unwrap();
1511
1512 let entry = Entry {
1513 entity_type: EntityType::Skill,
1514 name: "my-skill".into(),
1515 source: SourceFields::Github {
1516 owner_repo: "o/r".into(),
1517 path_in_repo: "skills/my-skill".into(),
1518 ref_: "main".into(),
1519 },
1520 };
1521 let a = adapters().get("claude-code").unwrap();
1522 let result = a.deploy_entry(&DeployRequest {
1523 entry: &entry,
1524 source: &source_dir,
1525 scope: Scope::Local,
1526 repo_root: dir.path(),
1527 opts: &InstallOptions::default(),
1528 });
1529
1530 assert!(result.contains_key("SKILL.md"));
1531 assert!(target_dir.join("my-skill/SKILL.md").exists());
1532 assert!(!target_dir.join("my-skill.md").exists());
1533 }
1534
1535 #[test]
1538 fn antigravity_supports_skill_not_agent() {
1539 let a = adapters().get("antigravity").unwrap();
1540 assert!(a.supports(EntityType::Skill));
1541 assert!(!a.supports(EntityType::Agent));
1542 }
1543
1544 #[test]
1545 fn local_target_dir_antigravity() {
1546 let tmp = PathBuf::from("/tmp/test");
1547 let a = adapters().get("antigravity").unwrap();
1548 assert_eq!(
1549 a.target_dir(EntityType::Skill, &local(&tmp)),
1550 tmp.join(".agents/skills")
1551 );
1552 }
1553
1554 #[test]
1555 fn global_target_dir_antigravity() {
1556 let a = adapters().get("antigravity").unwrap();
1557 let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
1558 assert!(skill.is_absolute());
1559 assert!(
1560 skill.to_string_lossy().ends_with("antigravity/skills"),
1561 "unexpected: {skill:?}"
1562 );
1563 }
1564
1565 #[test]
1566 fn global_target_dir_prefers_home_env_override() {
1567 let dir = tempfile::tempdir().unwrap();
1568 let home = preferred_home_dir_from(Some(dir.path().as_os_str().to_owned()), None);
1569 assert_eq!(home, dir.path());
1570 }
1571
1572 #[test]
1573 fn antigravity_dir_mode() {
1574 let a = adapters().get("antigravity").unwrap();
1575 assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
1576 assert_eq!(a.dir_mode(EntityType::Agent), None);
1577 }
1578}