Skip to main content

skillfile_deploy/
adapter.rs

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// ---------------------------------------------------------------------------
12// PlatformAdapter trait — the core abstraction for tool-specific deployment
13// ---------------------------------------------------------------------------
14
15/// How a directory entry is deployed to a platform's target directory.
16///
17/// - `Flat`: each `.md` placed individually in `target_dir/` (e.g. claude-code agents)
18/// - `Nested`: directory placed as `target_dir/<name>/` (e.g. all skill adapters)
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DirInstallMode {
21    Flat,
22    Nested,
23}
24
25/// The deployment result: a map of `{patch_key: installed_path}`.
26///
27/// Keys match the relative paths used in `.skillfile/patches/` so patch lookups
28/// work correctly:
29/// - Single-file entries: key is `"{name}.md"`
30/// - Directory entries: keys are paths relative to the source directory
31pub type DeployResult = HashMap<String, PathBuf>;
32
33/// Bundles scope and repo root for adapter operations.
34pub struct AdapterScope<'a> {
35    pub scope: Scope,
36    pub repo_root: &'a Path,
37}
38
39/// All parameters needed to deploy a single entry.
40pub struct DeployRequest<'a> {
41    pub entry: &'a Entry,
42    pub source: &'a Path,
43    pub scope: Scope,
44    pub repo_root: &'a Path,
45    pub opts: &'a InstallOptions,
46}
47
48/// Contract for deploying skill/agent files to a specific AI tool's directory.
49///
50/// Each AI tool (Claude Code, Gemini CLI, Codex, etc.) has its own convention
51/// for where skills and agents live on disk. A `PlatformAdapter` encapsulates
52/// that knowledge.
53///
54/// The trait is object-safe so adapters can be stored in a heterogeneous registry.
55pub trait PlatformAdapter: Send + Sync + fmt::Debug {
56    /// The adapter identifier (e.g. `"claude-code"`, `"gemini-cli"`).
57    fn name(&self) -> &str;
58
59    /// Whether this platform supports the given entity type.
60    fn supports(&self, entity_type: EntityType) -> bool;
61
62    /// Resolve the absolute target directory for an entity type + scope.
63    fn target_dir(&self, entity_type: EntityType, ctx: &AdapterScope<'_>) -> PathBuf;
64
65    /// The install mode for directory entries of this entity type.
66    fn dir_mode(&self, entity_type: EntityType) -> Option<DirInstallMode>;
67
68    /// Deploy a single entry from `source` to its platform-specific location.
69    ///
70    /// Returns `{patch_key: installed_path}` for every file that was placed.
71    /// Returns an empty map for dry-run or when deployment is skipped.
72    fn deploy_entry(&self, req: &DeployRequest<'_>) -> DeployResult;
73
74    /// The installed path for a single-file entry.
75    fn installed_path(&self, entry: &Entry, ctx: &AdapterScope<'_>) -> PathBuf;
76
77    /// Map of `{relative_path: absolute_path}` for all installed files of a directory entry.
78    fn installed_dir_files(
79        &self,
80        entry: &Entry,
81        ctx: &AdapterScope<'_>,
82    ) -> HashMap<String, PathBuf>;
83}
84
85// ---------------------------------------------------------------------------
86// EntityConfig — per-entity-type path configuration
87// ---------------------------------------------------------------------------
88
89/// Paths and install mode for one entity type within a platform.
90#[derive(Debug, Clone)]
91pub struct EntityConfig {
92    pub global_path: String,
93    pub local_path: String,
94    pub dir_mode: DirInstallMode,
95}
96
97// ---------------------------------------------------------------------------
98// FileSystemAdapter — the concrete implementation of PlatformAdapter
99// ---------------------------------------------------------------------------
100
101/// Filesystem-based platform adapter.
102///
103/// Each instance is configured with a name and a map of `EntityConfig`s.
104/// All three built-in adapters (claude-code, gemini-cli, codex) are instances
105/// of this struct with different configurations — the `PlatformAdapter` trait
106/// allows alternative implementations if needed.
107#[derive(Debug, Clone)]
108pub struct FileSystemAdapter {
109    name: String,
110    entities: HashMap<EntityType, EntityConfig>,
111}
112
113impl FileSystemAdapter {
114    pub fn new(name: &str, entities: HashMap<EntityType, EntityConfig>) -> Self {
115        Self {
116            name: name.to_string(),
117            entities,
118        }
119    }
120}
121
122impl PlatformAdapter for FileSystemAdapter {
123    fn name(&self) -> &str {
124        &self.name
125    }
126
127    fn supports(&self, entity_type: EntityType) -> bool {
128        self.entities.contains_key(&entity_type)
129    }
130
131    fn target_dir(&self, entity_type: EntityType, ctx: &AdapterScope<'_>) -> PathBuf {
132        let config = self.entities.get(&entity_type).unwrap_or_else(|| {
133            panic!(
134                "BUG: target_dir called for unsupported entity type '{entity_type}' on adapter '{}'. \
135                 Call supports() first.",
136                self.name
137            )
138        });
139        let raw = match ctx.scope {
140            Scope::Global => &config.global_path,
141            Scope::Local => &config.local_path,
142        };
143        if raw.starts_with('~') {
144            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
145            home.join(raw.strip_prefix("~/").unwrap_or(raw))
146        } else {
147            ctx.repo_root.join(raw)
148        }
149    }
150
151    fn dir_mode(&self, entity_type: EntityType) -> Option<DirInstallMode> {
152        self.entities.get(&entity_type).map(|c| c.dir_mode)
153    }
154
155    fn deploy_entry(&self, req: &DeployRequest<'_>) -> DeployResult {
156        let ctx = AdapterScope {
157            scope: req.scope,
158            repo_root: req.repo_root,
159        };
160        let target_dir = self.target_dir(req.entry.entity_type, &ctx);
161        // Use filesystem truth: source.is_dir() catches local directory entries
162        // that is_dir_entry() misses (it only inspects GitHub path_in_repo).
163        let is_dir = is_dir_entry(req.entry) || req.source.is_dir();
164
165        if is_dir
166            && self
167                .entities
168                .get(&req.entry.entity_type)
169                .is_some_and(|c| c.dir_mode == DirInstallMode::Flat)
170        {
171            return deploy_flat(req.source, &target_dir, req.opts);
172        }
173
174        let dest = if is_dir {
175            target_dir.join(&req.entry.name)
176        } else {
177            target_dir.join(format!("{}.md", req.entry.name))
178        };
179
180        if !place_file(
181            &PlaceOp {
182                source: req.source,
183                dest: &dest,
184                is_dir,
185            },
186            req.opts,
187        ) || req.opts.dry_run
188        {
189            return HashMap::new();
190        }
191
192        if is_dir {
193            collect_dir_deploy_result(req.source, &dest)
194        } else {
195            HashMap::from([(format!("{}.md", req.entry.name), dest)])
196        }
197    }
198
199    fn installed_path(&self, entry: &Entry, ctx: &AdapterScope<'_>) -> PathBuf {
200        self.target_dir(entry.entity_type, ctx)
201            .join(format!("{}.md", entry.name))
202    }
203
204    fn installed_dir_files(
205        &self,
206        entry: &Entry,
207        ctx: &AdapterScope<'_>,
208    ) -> HashMap<String, PathBuf> {
209        let target_dir = self.target_dir(entry.entity_type, ctx);
210        let mode = self
211            .entities
212            .get(&entry.entity_type)
213            .map_or(DirInstallMode::Nested, |c| c.dir_mode);
214
215        if mode == DirInstallMode::Nested {
216            collect_nested_installed(entry, &target_dir)
217        } else {
218            // Flat: keys are relative-from-vdir so they match patch lookup keys
219            let vdir = skillfile_sources::sync::vendor_dir_for(entry, ctx.repo_root);
220            collect_flat_installed_checked(&vdir, &target_dir)
221        }
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Deployment helpers (used by FileSystemAdapter)
227// ---------------------------------------------------------------------------
228
229/// Build `{relative_path: absolute_path}` for all non-.meta files in a deployed directory.
230fn collect_dir_deploy_result(source: &Path, dest: &Path) -> DeployResult {
231    let mut result = HashMap::new();
232    for file in walkdir(source) {
233        if file.file_name().is_none_or(|n| n == ".meta") {
234            continue;
235        }
236        let Ok(rel) = file.strip_prefix(source) else {
237            continue;
238        };
239        result.insert(rel.to_string_lossy().to_string(), dest.join(rel));
240    }
241    result
242}
243
244/// Build `{relative_path: absolute_path}` for nested-mode installed dir.
245/// Returns empty map when the installed directory does not exist.
246fn collect_nested_installed(entry: &Entry, target_dir: &Path) -> HashMap<String, PathBuf> {
247    let installed_dir = target_dir.join(&entry.name);
248    if !installed_dir.is_dir() {
249        return HashMap::new();
250    }
251    collect_walkdir_relative(&installed_dir)
252}
253
254/// Build `{relative_path: target_path}` for flat-mode installed files.
255/// Returns empty map when the vendor cache directory does not exist.
256fn collect_flat_installed_checked(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
257    if !vdir.is_dir() {
258        return HashMap::new();
259    }
260    collect_flat_installed(vdir, target_dir)
261}
262
263/// Build `{relative_path: absolute_path}` from a walkdir rooted at `base`.
264fn collect_walkdir_relative(base: &Path) -> HashMap<String, PathBuf> {
265    let mut result = HashMap::new();
266    for file in walkdir(base) {
267        let Ok(rel) = file.strip_prefix(base) else {
268            continue;
269        };
270        result.insert(rel.to_string_lossy().to_string(), file);
271    }
272    result
273}
274
275/// Build `{relative_path: target_path}` for `.md` files in a flat-mode vendor dir
276/// that have corresponding deployed files in `target_dir`.
277fn collect_flat_installed(vdir: &Path, target_dir: &Path) -> HashMap<String, PathBuf> {
278    let mut result = HashMap::new();
279    for file in walkdir(vdir) {
280        if file
281            .extension()
282            .is_none_or(|ext| ext.to_string_lossy() != "md")
283        {
284            continue;
285        }
286        let Ok(rel) = file.strip_prefix(vdir) else {
287            continue;
288        };
289        let dest = target_dir.join(file.file_name().unwrap_or_default());
290        if dest.exists() {
291            result.insert(rel.to_string_lossy().to_string(), dest);
292        }
293    }
294    result
295}
296
297/// Deploy each `.md` in `source_dir` as an individual file in `target_dir` (flat mode).
298fn deploy_flat(source_dir: &Path, target_dir: &Path, opts: &InstallOptions) -> DeployResult {
299    let mut md_files: Vec<PathBuf> = walkdir(source_dir)
300        .into_iter()
301        .filter(|f| f.extension().is_some_and(|ext| ext == "md"))
302        .collect();
303    md_files.sort();
304
305    if opts.dry_run {
306        for src in md_files.iter().filter(|s| s.file_name().is_some()) {
307            let name = src.file_name().unwrap_or_default();
308            progress!(
309                "  {} -> {} [copy, dry-run]",
310                name.to_string_lossy(),
311                target_dir.join(name).display()
312            );
313        }
314        return HashMap::new();
315    }
316
317    std::fs::create_dir_all(target_dir).ok();
318    let mut result = HashMap::new();
319    for src in &md_files {
320        let Some(name) = src.file_name() else {
321            continue;
322        };
323        let dest = target_dir.join(name);
324        if !opts.overwrite && dest.is_file() {
325            continue;
326        }
327        if dest.exists() {
328            std::fs::remove_file(&dest).ok();
329        }
330        if std::fs::copy(src, &dest).is_err() {
331            continue;
332        }
333        progress!("  {} -> {}", name.to_string_lossy(), dest.display());
334        if let Ok(rel) = src.strip_prefix(source_dir) {
335            result.insert(rel.to_string_lossy().to_string(), dest);
336        }
337    }
338    result
339}
340
341struct PlaceOp<'a> {
342    source: &'a Path,
343    dest: &'a Path,
344    is_dir: bool,
345}
346
347/// Copy `source` to `dest`. Returns `true` if placed, `false` if skipped.
348fn place_file(op: &PlaceOp<'_>, opts: &InstallOptions) -> bool {
349    if !opts.overwrite && !opts.dry_run {
350        if op.is_dir && op.dest.is_dir() {
351            return false;
352        }
353        if !op.is_dir && op.dest.is_file() {
354            return false;
355        }
356    }
357
358    let label = format!(
359        "  {} -> {}",
360        op.source.file_name().unwrap_or_default().to_string_lossy(),
361        op.dest.display()
362    );
363
364    if opts.dry_run {
365        progress!("{label} [copy, dry-run]");
366        return true;
367    }
368
369    if let Some(parent) = op.dest.parent() {
370        std::fs::create_dir_all(parent).ok();
371    }
372
373    // Remove existing
374    if op.dest.exists() || op.dest.is_symlink() {
375        if op.dest.is_dir() {
376            std::fs::remove_dir_all(op.dest).ok();
377        } else {
378            std::fs::remove_file(op.dest).ok();
379        }
380    }
381
382    if op.is_dir {
383        copy_dir_recursive(op.source, op.dest).ok();
384    } else {
385        std::fs::copy(op.source, op.dest).ok();
386    }
387
388    progress!("{label}");
389    true
390}
391
392/// Recursively copy a directory tree.
393// The recursive structure naturally produces multiple `?` operators and
394// branching that triggers cognitive-complexity, but the logic is straightforward.
395#[allow(clippy::cognitive_complexity)]
396fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
397    std::fs::create_dir_all(dst)?;
398    for entry in std::fs::read_dir(src)? {
399        let entry = entry?;
400        let ty = entry.file_type()?;
401        let dest_path = dst.join(entry.file_name());
402        if ty.is_dir() {
403            copy_dir_recursive(&entry.path(), &dest_path)?;
404        } else {
405            std::fs::copy(entry.path(), &dest_path)?;
406        }
407    }
408    Ok(())
409}
410
411// ---------------------------------------------------------------------------
412// AdapterRegistry — injectable, testable collection of platform adapters
413// ---------------------------------------------------------------------------
414
415/// A collection of platform adapters, indexed by name.
416///
417/// The registry owns the adapters and provides lookup by name. It can be
418/// constructed with the built-in adapters via [`AdapterRegistry::builtin()`],
419/// or built manually for testing.
420pub struct AdapterRegistry {
421    adapters: HashMap<String, Box<dyn PlatformAdapter>>,
422}
423
424impl AdapterRegistry {
425    /// Create a registry from a vec of boxed adapters.
426    pub fn new(adapters: Vec<Box<dyn PlatformAdapter>>) -> Self {
427        let map = adapters
428            .into_iter()
429            .map(|a| (a.name().to_string(), a))
430            .collect();
431        Self { adapters: map }
432    }
433
434    /// Create the built-in registry with all known platform adapters.
435    pub fn builtin() -> Self {
436        Self::new(
437            BUILTIN_ADAPTERS
438                .iter()
439                .map(|spec| Box::new(build_adapter(spec)) as Box<dyn PlatformAdapter>)
440                .collect(),
441        )
442    }
443
444    /// Look up an adapter by name.
445    pub fn get(&self, name: &str) -> Option<&dyn PlatformAdapter> {
446        self.adapters.get(name).map(|b| &**b)
447    }
448
449    /// Check if an adapter with this name exists.
450    pub fn contains(&self, name: &str) -> bool {
451        self.adapters.contains_key(name)
452    }
453
454    /// Sorted list of all adapter names.
455    pub fn names(&self) -> Vec<&str> {
456        let mut names: Vec<&str> = self
457            .adapters
458            .keys()
459            .map(std::string::String::as_str)
460            .collect();
461        names.sort_unstable();
462        names
463    }
464}
465
466impl fmt::Debug for AdapterRegistry {
467    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
468        f.debug_struct("AdapterRegistry")
469            .field("adapters", &self.names())
470            .finish()
471    }
472}
473
474// ---------------------------------------------------------------------------
475// Built-in adapter specifications — declarative configuration table
476// ---------------------------------------------------------------------------
477
478/// Specification for one entity type within a platform adapter.
479struct EntitySpec {
480    entity_type: EntityType,
481    global_path: &'static str,
482    local_path: &'static str,
483    dir_mode: DirInstallMode,
484}
485
486/// Specification for a platform adapter. Adding a new platform is one table entry.
487struct AdapterSpec {
488    name: &'static str,
489    entities: &'static [EntitySpec],
490}
491
492/// All built-in platform adapters.
493///
494/// | Platform    | Skills | Agents (Flat) | Global prefix              | Local prefix     |
495/// |-------------|--------|---------------|----------------------------|------------------|
496/// | claude-code | yes    | yes           | `~/.claude/`               | `.claude/`       |
497/// | gemini-cli  | yes    | yes           | `~/.gemini/`               | `.gemini/`       |
498/// | codex       | yes    | no            | `~/.codex/`                | `.codex/`        |
499/// | cursor      | yes    | yes           | `~/.cursor/`               | `.cursor/`       |
500/// | windsurf    | yes    | no            | `~/.codeium/windsurf/`     | `.windsurf/`     |
501/// | opencode    | yes    | yes           | `~/.config/opencode/`      | `.opencode/`     |
502/// | copilot     | yes    | yes           | `~/.copilot/`              | `.github/`       |
503const BUILTIN_ADAPTERS: &[AdapterSpec] = &[
504    AdapterSpec {
505        name: "claude-code",
506        entities: &[
507            EntitySpec {
508                entity_type: EntityType::Skill,
509                global_path: "~/.claude/skills",
510                local_path: ".claude/skills",
511                dir_mode: DirInstallMode::Nested,
512            },
513            EntitySpec {
514                entity_type: EntityType::Agent,
515                global_path: "~/.claude/agents",
516                local_path: ".claude/agents",
517                dir_mode: DirInstallMode::Flat,
518            },
519        ],
520    },
521    AdapterSpec {
522        name: "gemini-cli",
523        entities: &[
524            EntitySpec {
525                entity_type: EntityType::Skill,
526                global_path: "~/.gemini/skills",
527                local_path: ".gemini/skills",
528                dir_mode: DirInstallMode::Nested,
529            },
530            EntitySpec {
531                entity_type: EntityType::Agent,
532                global_path: "~/.gemini/agents",
533                local_path: ".gemini/agents",
534                dir_mode: DirInstallMode::Flat,
535            },
536        ],
537    },
538    AdapterSpec {
539        name: "codex",
540        entities: &[EntitySpec {
541            entity_type: EntityType::Skill,
542            global_path: "~/.codex/skills",
543            local_path: ".codex/skills",
544            dir_mode: DirInstallMode::Nested,
545        }],
546    },
547    AdapterSpec {
548        name: "cursor",
549        entities: &[
550            EntitySpec {
551                entity_type: EntityType::Skill,
552                global_path: "~/.cursor/skills",
553                local_path: ".cursor/skills",
554                dir_mode: DirInstallMode::Nested,
555            },
556            EntitySpec {
557                entity_type: EntityType::Agent,
558                global_path: "~/.cursor/agents",
559                local_path: ".cursor/agents",
560                dir_mode: DirInstallMode::Flat,
561            },
562        ],
563    },
564    AdapterSpec {
565        name: "windsurf",
566        entities: &[EntitySpec {
567            entity_type: EntityType::Skill,
568            global_path: "~/.codeium/windsurf/skills",
569            local_path: ".windsurf/skills",
570            dir_mode: DirInstallMode::Nested,
571        }],
572    },
573    AdapterSpec {
574        name: "opencode",
575        entities: &[
576            EntitySpec {
577                entity_type: EntityType::Skill,
578                global_path: "~/.config/opencode/skills",
579                local_path: ".opencode/skills",
580                dir_mode: DirInstallMode::Nested,
581            },
582            EntitySpec {
583                entity_type: EntityType::Agent,
584                global_path: "~/.config/opencode/agents",
585                local_path: ".opencode/agents",
586                dir_mode: DirInstallMode::Flat,
587            },
588        ],
589    },
590    AdapterSpec {
591        name: "copilot",
592        entities: &[
593            EntitySpec {
594                entity_type: EntityType::Skill,
595                global_path: "~/.copilot/skills",
596                local_path: ".github/skills",
597                dir_mode: DirInstallMode::Nested,
598            },
599            EntitySpec {
600                entity_type: EntityType::Agent,
601                global_path: "~/.copilot/agents",
602                local_path: ".github/agents",
603                dir_mode: DirInstallMode::Flat,
604            },
605        ],
606    },
607];
608
609/// Construct a `FileSystemAdapter` from a declarative spec.
610fn build_adapter(spec: &AdapterSpec) -> FileSystemAdapter {
611    let entities = spec
612        .entities
613        .iter()
614        .map(|e| {
615            (
616                e.entity_type,
617                EntityConfig {
618                    global_path: e.global_path.into(),
619                    local_path: e.local_path.into(),
620                    dir_mode: e.dir_mode,
621                },
622            )
623        })
624        .collect();
625    FileSystemAdapter::new(spec.name, entities)
626}
627
628// ---------------------------------------------------------------------------
629// Global registry accessor (backward-compatible convenience)
630// ---------------------------------------------------------------------------
631
632/// Get the global adapter registry (lazily initialized).
633#[must_use]
634pub fn adapters() -> &'static AdapterRegistry {
635    static REGISTRY: OnceLock<AdapterRegistry> = OnceLock::new();
636    REGISTRY.get_or_init(AdapterRegistry::builtin)
637}
638
639/// Sorted list of known adapter names.
640#[must_use]
641pub fn known_adapters() -> Vec<&'static str> {
642    adapters().names()
643}
644
645// ---------------------------------------------------------------------------
646// Tests
647// ---------------------------------------------------------------------------
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    fn local(root: &Path) -> AdapterScope<'_> {
654        AdapterScope {
655            scope: Scope::Local,
656            repo_root: root,
657        }
658    }
659
660    fn global(root: &Path) -> AdapterScope<'_> {
661        AdapterScope {
662            scope: Scope::Global,
663            repo_root: root,
664        }
665    }
666
667    // -- Trait compliance: every registered adapter satisfies PlatformAdapter --
668
669    #[test]
670    fn all_builtin_adapters_in_registry() {
671        let reg = adapters();
672        assert!(reg.contains("claude-code"));
673        assert!(reg.contains("gemini-cli"));
674        assert!(reg.contains("codex"));
675        assert!(reg.contains("cursor"));
676        assert!(reg.contains("windsurf"));
677        assert!(reg.contains("opencode"));
678        assert!(reg.contains("copilot"));
679    }
680
681    #[test]
682    fn known_adapters_contains_all() {
683        let names = known_adapters();
684        assert!(names.contains(&"claude-code"));
685        assert!(names.contains(&"gemini-cli"));
686        assert!(names.contains(&"codex"));
687        assert!(names.contains(&"cursor"));
688        assert!(names.contains(&"windsurf"));
689        assert!(names.contains(&"opencode"));
690        assert!(names.contains(&"copilot"));
691        assert_eq!(names.len(), 7);
692    }
693
694    #[test]
695    fn adapter_name_matches_registry_key() {
696        let reg = adapters();
697        for name in reg.names() {
698            let adapter = reg.get(name).unwrap();
699            assert_eq!(adapter.name(), name);
700        }
701    }
702
703    #[test]
704    fn registry_get_unknown_returns_none() {
705        assert!(adapters().get("unknown-tool").is_none());
706    }
707
708    // -- supports() --
709
710    #[test]
711    fn claude_code_supports_agent_and_skill() {
712        let a = adapters().get("claude-code").unwrap();
713        assert!(a.supports(EntityType::Agent));
714        assert!(a.supports(EntityType::Skill));
715        // No need to test unsupported string types — `EntityType` makes invalid calls unrepresentable.
716    }
717
718    #[test]
719    fn gemini_cli_supports_agent_and_skill() {
720        let a = adapters().get("gemini-cli").unwrap();
721        assert!(a.supports(EntityType::Agent));
722        assert!(a.supports(EntityType::Skill));
723    }
724
725    #[test]
726    fn codex_supports_skill_not_agent() {
727        let a = adapters().get("codex").unwrap();
728        assert!(a.supports(EntityType::Skill));
729        assert!(!a.supports(EntityType::Agent));
730    }
731
732    // -- target_dir() --
733
734    #[test]
735    fn local_target_dir_claude_code() {
736        let tmp = PathBuf::from("/tmp/test");
737        let a = adapters().get("claude-code").unwrap();
738        assert_eq!(
739            a.target_dir(EntityType::Agent, &local(&tmp)),
740            tmp.join(".claude/agents")
741        );
742        assert_eq!(
743            a.target_dir(EntityType::Skill, &local(&tmp)),
744            tmp.join(".claude/skills")
745        );
746    }
747
748    #[test]
749    fn local_target_dir_gemini_cli() {
750        let tmp = PathBuf::from("/tmp/test");
751        let a = adapters().get("gemini-cli").unwrap();
752        assert_eq!(
753            a.target_dir(EntityType::Agent, &local(&tmp)),
754            tmp.join(".gemini/agents")
755        );
756        assert_eq!(
757            a.target_dir(EntityType::Skill, &local(&tmp)),
758            tmp.join(".gemini/skills")
759        );
760    }
761
762    #[test]
763    fn local_target_dir_codex() {
764        let tmp = PathBuf::from("/tmp/test");
765        let a = adapters().get("codex").unwrap();
766        assert_eq!(
767            a.target_dir(EntityType::Skill, &local(&tmp)),
768            tmp.join(".codex/skills")
769        );
770    }
771
772    #[test]
773    fn global_target_dir_is_absolute() {
774        let a = adapters().get("claude-code").unwrap();
775        let result = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
776        assert!(result.is_absolute());
777        assert!(result.to_string_lossy().ends_with(".claude/agents"));
778    }
779
780    #[test]
781    fn global_target_dir_gemini_cli_skill() {
782        let a = adapters().get("gemini-cli").unwrap();
783        let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
784        assert!(result.is_absolute());
785        assert!(result.to_string_lossy().ends_with(".gemini/skills"));
786    }
787
788    #[test]
789    fn global_target_dir_codex_skill() {
790        let a = adapters().get("codex").unwrap();
791        let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
792        assert!(result.is_absolute());
793        assert!(result.to_string_lossy().ends_with(".codex/skills"));
794    }
795
796    // -- supports() for new adapters --
797
798    #[test]
799    fn cursor_supports_agent_and_skill() {
800        let a = adapters().get("cursor").unwrap();
801        assert!(a.supports(EntityType::Agent));
802        assert!(a.supports(EntityType::Skill));
803        // No need to test unsupported string types — `EntityType` makes invalid calls unrepresentable.
804    }
805
806    #[test]
807    fn windsurf_supports_skill_not_agent() {
808        let a = adapters().get("windsurf").unwrap();
809        assert!(a.supports(EntityType::Skill));
810        assert!(!a.supports(EntityType::Agent));
811    }
812
813    #[test]
814    fn opencode_supports_agent_and_skill() {
815        let a = adapters().get("opencode").unwrap();
816        assert!(a.supports(EntityType::Agent));
817        assert!(a.supports(EntityType::Skill));
818        // No need to test unsupported string types — `EntityType` makes invalid calls unrepresentable.
819    }
820
821    #[test]
822    fn copilot_supports_agent_and_skill() {
823        let a = adapters().get("copilot").unwrap();
824        assert!(a.supports(EntityType::Agent));
825        assert!(a.supports(EntityType::Skill));
826        // No need to test unsupported string types — `EntityType` makes invalid calls unrepresentable.
827    }
828
829    // -- target_dir() for new adapters --
830
831    #[test]
832    fn local_target_dir_cursor() {
833        let tmp = PathBuf::from("/tmp/test");
834        let a = adapters().get("cursor").unwrap();
835        assert_eq!(
836            a.target_dir(EntityType::Agent, &local(&tmp)),
837            tmp.join(".cursor/agents")
838        );
839        assert_eq!(
840            a.target_dir(EntityType::Skill, &local(&tmp)),
841            tmp.join(".cursor/skills")
842        );
843    }
844
845    #[test]
846    fn local_target_dir_windsurf() {
847        let tmp = PathBuf::from("/tmp/test");
848        let a = adapters().get("windsurf").unwrap();
849        assert_eq!(
850            a.target_dir(EntityType::Skill, &local(&tmp)),
851            tmp.join(".windsurf/skills")
852        );
853    }
854
855    #[test]
856    fn local_target_dir_opencode() {
857        let tmp = PathBuf::from("/tmp/test");
858        let a = adapters().get("opencode").unwrap();
859        assert_eq!(
860            a.target_dir(EntityType::Agent, &local(&tmp)),
861            tmp.join(".opencode/agents")
862        );
863        assert_eq!(
864            a.target_dir(EntityType::Skill, &local(&tmp)),
865            tmp.join(".opencode/skills")
866        );
867    }
868
869    #[test]
870    fn local_target_dir_copilot() {
871        let tmp = PathBuf::from("/tmp/test");
872        let a = adapters().get("copilot").unwrap();
873        assert_eq!(
874            a.target_dir(EntityType::Agent, &local(&tmp)),
875            tmp.join(".github/agents")
876        );
877        assert_eq!(
878            a.target_dir(EntityType::Skill, &local(&tmp)),
879            tmp.join(".github/skills")
880        );
881    }
882
883    #[test]
884    fn global_target_dir_cursor() {
885        let a = adapters().get("cursor").unwrap();
886        let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
887        assert!(skill.is_absolute());
888        assert!(skill.to_string_lossy().ends_with(".cursor/skills"));
889        let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
890        assert!(agent.is_absolute());
891        assert!(agent.to_string_lossy().ends_with(".cursor/agents"));
892    }
893
894    #[test]
895    fn global_target_dir_windsurf() {
896        let a = adapters().get("windsurf").unwrap();
897        let result = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
898        assert!(result.is_absolute());
899        assert!(
900            result.to_string_lossy().ends_with("windsurf/skills"),
901            "unexpected: {result:?}"
902        );
903    }
904
905    #[test]
906    fn global_target_dir_opencode() {
907        let a = adapters().get("opencode").unwrap();
908        let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
909        assert!(skill.is_absolute());
910        assert!(
911            skill.to_string_lossy().ends_with("opencode/skills"),
912            "unexpected: {skill:?}"
913        );
914        let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
915        assert!(agent.is_absolute());
916        assert!(
917            agent.to_string_lossy().ends_with("opencode/agents"),
918            "unexpected: {agent:?}"
919        );
920    }
921
922    #[test]
923    fn global_target_dir_copilot() {
924        let a = adapters().get("copilot").unwrap();
925        let skill = a.target_dir(EntityType::Skill, &global(Path::new("/tmp")));
926        assert!(skill.is_absolute());
927        assert!(skill.to_string_lossy().ends_with(".copilot/skills"));
928        let agent = a.target_dir(EntityType::Agent, &global(Path::new("/tmp")));
929        assert!(agent.is_absolute());
930        assert!(agent.to_string_lossy().ends_with(".copilot/agents"));
931    }
932
933    // -- dir_mode for new adapters --
934
935    #[test]
936    fn cursor_dir_modes() {
937        let a = adapters().get("cursor").unwrap();
938        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
939        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
940    }
941
942    #[test]
943    fn windsurf_dir_mode() {
944        let a = adapters().get("windsurf").unwrap();
945        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
946        assert_eq!(a.dir_mode(EntityType::Agent), None);
947    }
948
949    #[test]
950    fn opencode_dir_modes() {
951        let a = adapters().get("opencode").unwrap();
952        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
953        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
954    }
955
956    #[test]
957    fn copilot_dir_modes() {
958        let a = adapters().get("copilot").unwrap();
959        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
960        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
961    }
962
963    // -- dir_mode --
964
965    #[test]
966    fn claude_code_dir_modes() {
967        let a = adapters().get("claude-code").unwrap();
968        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
969        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
970    }
971
972    #[test]
973    fn gemini_cli_dir_modes() {
974        let a = adapters().get("gemini-cli").unwrap();
975        assert_eq!(a.dir_mode(EntityType::Agent), Some(DirInstallMode::Flat));
976        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
977    }
978
979    #[test]
980    fn codex_dir_mode() {
981        let a = adapters().get("codex").unwrap();
982        assert_eq!(a.dir_mode(EntityType::Skill), Some(DirInstallMode::Nested));
983    }
984
985    // -- Custom adapter extensibility --
986
987    #[test]
988    fn custom_adapter_via_registry() {
989        let custom = FileSystemAdapter::new(
990            "my-tool",
991            HashMap::from([(
992                EntityType::Skill,
993                EntityConfig {
994                    global_path: "~/.my-tool/skills".into(),
995                    local_path: ".my-tool/skills".into(),
996                    dir_mode: DirInstallMode::Nested,
997                },
998            )]),
999        );
1000        let registry = AdapterRegistry::new(vec![Box::new(custom)]);
1001        let a = registry.get("my-tool").unwrap();
1002        assert!(a.supports(EntityType::Skill));
1003        assert!(!a.supports(EntityType::Agent));
1004        assert_eq!(registry.names(), vec!["my-tool"]);
1005    }
1006
1007    // -- deploy_entry key contract --
1008
1009    #[test]
1010    fn deploy_entry_single_file_key_matches_patch_convention() {
1011        use skillfile_core::models::{EntityType, SourceFields};
1012
1013        let dir = tempfile::tempdir().unwrap();
1014        let source_dir = dir.path().join(".skillfile/cache/agents/test");
1015        std::fs::create_dir_all(&source_dir).unwrap();
1016        std::fs::write(source_dir.join("agent.md"), "# Agent\n").unwrap();
1017        let source = source_dir.join("agent.md");
1018
1019        let entry = Entry {
1020            entity_type: EntityType::Agent,
1021            name: "test".into(),
1022            source: SourceFields::Github {
1023                owner_repo: "o/r".into(),
1024                path_in_repo: "agents/agent.md".into(),
1025                ref_: "main".into(),
1026            },
1027        };
1028        let a = adapters().get("claude-code").unwrap();
1029        let result = a.deploy_entry(&DeployRequest {
1030            entry: &entry,
1031            source: &source,
1032            scope: Scope::Local,
1033            repo_root: dir.path(),
1034            opts: &InstallOptions::default(),
1035        });
1036        assert!(
1037            result.contains_key("test.md"),
1038            "Single-file key must be 'test.md', got {:?}",
1039            result.keys().collect::<Vec<_>>()
1040        );
1041    }
1042
1043    // -- deploy_flat --
1044
1045    #[test]
1046    fn deploy_flat_copies_md_files_to_target_dir() {
1047        use skillfile_core::models::{EntityType, SourceFields};
1048
1049        let dir = tempfile::tempdir().unwrap();
1050        // Set up vendor cache dir with .md files and a .meta
1051        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1052        std::fs::create_dir_all(&source_dir).unwrap();
1053        std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1054        std::fs::write(source_dir.join("frontend.md"), "# Frontend").unwrap();
1055        std::fs::write(source_dir.join(".meta"), "{}").unwrap();
1056
1057        let entry = Entry {
1058            entity_type: EntityType::Agent,
1059            name: "core-dev".into(),
1060            source: SourceFields::Github {
1061                owner_repo: "o/r".into(),
1062                path_in_repo: "agents/core-dev".into(),
1063                ref_: "main".into(),
1064            },
1065        };
1066        let a = adapters().get("claude-code").unwrap();
1067        let result = a.deploy_entry(&DeployRequest {
1068            entry: &entry,
1069            source: &source_dir,
1070            scope: Scope::Local,
1071            repo_root: dir.path(),
1072            opts: &InstallOptions {
1073                dry_run: false,
1074                overwrite: true,
1075            },
1076        });
1077        // Flat mode: keys are relative paths from source dir
1078        assert!(result.contains_key("backend.md"));
1079        assert!(result.contains_key("frontend.md"));
1080        assert!(!result.contains_key(".meta"));
1081        // Files actually exist
1082        let target = dir.path().join(".claude/agents");
1083        assert!(target.join("backend.md").exists());
1084        assert!(target.join("frontend.md").exists());
1085    }
1086
1087    #[test]
1088    fn deploy_flat_dry_run_returns_empty() {
1089        use skillfile_core::models::{EntityType, SourceFields};
1090
1091        let dir = tempfile::tempdir().unwrap();
1092        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1093        std::fs::create_dir_all(&source_dir).unwrap();
1094        std::fs::write(source_dir.join("backend.md"), "# Backend").unwrap();
1095
1096        let entry = Entry {
1097            entity_type: EntityType::Agent,
1098            name: "core-dev".into(),
1099            source: SourceFields::Github {
1100                owner_repo: "o/r".into(),
1101                path_in_repo: "agents/core-dev".into(),
1102                ref_: "main".into(),
1103            },
1104        };
1105        let a = adapters().get("claude-code").unwrap();
1106        let result = a.deploy_entry(&DeployRequest {
1107            entry: &entry,
1108            source: &source_dir,
1109            scope: Scope::Local,
1110            repo_root: dir.path(),
1111            opts: &InstallOptions {
1112                dry_run: true,
1113                overwrite: false,
1114            },
1115        });
1116        assert!(result.is_empty());
1117        assert!(!dir.path().join(".claude/agents/backend.md").exists());
1118    }
1119
1120    #[test]
1121    fn deploy_flat_skips_existing_when_no_overwrite() {
1122        use skillfile_core::models::{EntityType, SourceFields};
1123
1124        let dir = tempfile::tempdir().unwrap();
1125        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1126        std::fs::create_dir_all(&source_dir).unwrap();
1127        std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1128
1129        // Pre-create the target file
1130        let target = dir.path().join(".claude/agents");
1131        std::fs::create_dir_all(&target).unwrap();
1132        std::fs::write(target.join("backend.md"), "# Old").unwrap();
1133
1134        let entry = Entry {
1135            entity_type: EntityType::Agent,
1136            name: "core-dev".into(),
1137            source: SourceFields::Github {
1138                owner_repo: "o/r".into(),
1139                path_in_repo: "agents/core-dev".into(),
1140                ref_: "main".into(),
1141            },
1142        };
1143        let a = adapters().get("claude-code").unwrap();
1144        let result = a.deploy_entry(&DeployRequest {
1145            entry: &entry,
1146            source: &source_dir,
1147            scope: Scope::Local,
1148            repo_root: dir.path(),
1149            opts: &InstallOptions {
1150                dry_run: false,
1151                overwrite: false,
1152            },
1153        });
1154        // Should skip the existing file
1155        assert!(result.is_empty());
1156        // Original content preserved
1157        assert_eq!(
1158            std::fs::read_to_string(target.join("backend.md")).unwrap(),
1159            "# Old"
1160        );
1161    }
1162
1163    #[test]
1164    fn deploy_flat_overwrites_existing_when_overwrite_true() {
1165        use skillfile_core::models::{EntityType, SourceFields};
1166
1167        let dir = tempfile::tempdir().unwrap();
1168        let source_dir = dir.path().join(".skillfile/cache/agents/core-dev");
1169        std::fs::create_dir_all(&source_dir).unwrap();
1170        std::fs::write(source_dir.join("backend.md"), "# New").unwrap();
1171
1172        let target = dir.path().join(".claude/agents");
1173        std::fs::create_dir_all(&target).unwrap();
1174        std::fs::write(target.join("backend.md"), "# Old").unwrap();
1175
1176        let entry = Entry {
1177            entity_type: EntityType::Agent,
1178            name: "core-dev".into(),
1179            source: SourceFields::Github {
1180                owner_repo: "o/r".into(),
1181                path_in_repo: "agents/core-dev".into(),
1182                ref_: "main".into(),
1183            },
1184        };
1185        let a = adapters().get("claude-code").unwrap();
1186        let result = a.deploy_entry(&DeployRequest {
1187            entry: &entry,
1188            source: &source_dir,
1189            scope: Scope::Local,
1190            repo_root: dir.path(),
1191            opts: &InstallOptions {
1192                dry_run: false,
1193                overwrite: true,
1194            },
1195        });
1196        assert!(result.contains_key("backend.md"));
1197        assert_eq!(
1198            std::fs::read_to_string(target.join("backend.md")).unwrap(),
1199            "# New"
1200        );
1201    }
1202
1203    // -- place_file skip logic --
1204
1205    #[test]
1206    fn place_file_skips_existing_dir_when_no_overwrite() {
1207        use skillfile_core::models::{EntityType, SourceFields};
1208
1209        let dir = tempfile::tempdir().unwrap();
1210        let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1211        std::fs::create_dir_all(&source_dir).unwrap();
1212        std::fs::write(source_dir.join("SKILL.md"), "# Skill").unwrap();
1213
1214        // Pre-create the destination dir
1215        let dest = dir.path().join(".claude/skills/my-skill");
1216        std::fs::create_dir_all(&dest).unwrap();
1217        std::fs::write(dest.join("OLD.md"), "# Old").unwrap();
1218
1219        let entry = Entry {
1220            entity_type: EntityType::Skill,
1221            name: "my-skill".into(),
1222            source: SourceFields::Github {
1223                owner_repo: "o/r".into(),
1224                path_in_repo: "skills/my-skill".into(),
1225                ref_: "main".into(),
1226            },
1227        };
1228        let a = adapters().get("claude-code").unwrap();
1229        let result = a.deploy_entry(&DeployRequest {
1230            entry: &entry,
1231            source: &source_dir,
1232            scope: Scope::Local,
1233            repo_root: dir.path(),
1234            opts: &InstallOptions {
1235                dry_run: false,
1236                overwrite: false,
1237            },
1238        });
1239        // Should skip — dir already exists
1240        assert!(result.is_empty());
1241        // Old file still there
1242        assert!(dest.join("OLD.md").exists());
1243    }
1244
1245    #[test]
1246    fn place_file_skips_existing_single_file_when_no_overwrite() {
1247        use skillfile_core::models::{EntityType, SourceFields};
1248
1249        let dir = tempfile::tempdir().unwrap();
1250        let source_file = dir.path().join("skills/my-skill.md");
1251        std::fs::create_dir_all(source_file.parent().unwrap()).unwrap();
1252        std::fs::write(&source_file, "# New").unwrap();
1253
1254        let dest = dir.path().join(".claude/skills/my-skill.md");
1255        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1256        std::fs::write(&dest, "# Old").unwrap();
1257
1258        let entry = Entry {
1259            entity_type: EntityType::Skill,
1260            name: "my-skill".into(),
1261            source: SourceFields::Local {
1262                path: "skills/my-skill.md".into(),
1263            },
1264        };
1265        let a = adapters().get("claude-code").unwrap();
1266        let result = a.deploy_entry(&DeployRequest {
1267            entry: &entry,
1268            source: &source_file,
1269            scope: Scope::Local,
1270            repo_root: dir.path(),
1271            opts: &InstallOptions {
1272                dry_run: false,
1273                overwrite: false,
1274            },
1275        });
1276        assert!(result.is_empty());
1277        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Old");
1278    }
1279
1280    // -- installed_dir_files flat mode --
1281
1282    #[test]
1283    fn installed_dir_files_flat_mode_returns_deployed_files() {
1284        use skillfile_core::models::{EntityType, SourceFields};
1285
1286        let dir = tempfile::tempdir().unwrap();
1287        // Set up vendor cache dir
1288        let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1289        std::fs::create_dir_all(&vdir).unwrap();
1290        std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1291        std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1292        std::fs::write(vdir.join(".meta"), "{}").unwrap();
1293
1294        // Set up installed flat files
1295        let target = dir.path().join(".claude/agents");
1296        std::fs::create_dir_all(&target).unwrap();
1297        std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1298        std::fs::write(target.join("frontend.md"), "# Frontend").unwrap();
1299
1300        let entry = Entry {
1301            entity_type: EntityType::Agent,
1302            name: "core-dev".into(),
1303            source: SourceFields::Github {
1304                owner_repo: "o/r".into(),
1305                path_in_repo: "agents/core-dev".into(),
1306                ref_: "main".into(),
1307            },
1308        };
1309        let a = adapters().get("claude-code").unwrap();
1310        let files = a.installed_dir_files(&entry, &local(dir.path()));
1311        assert!(files.contains_key("backend.md"));
1312        assert!(files.contains_key("frontend.md"));
1313        assert!(!files.contains_key(".meta"));
1314    }
1315
1316    #[test]
1317    fn installed_dir_files_flat_mode_no_vdir_returns_empty() {
1318        use skillfile_core::models::{EntityType, SourceFields};
1319
1320        let dir = tempfile::tempdir().unwrap();
1321        // No vendor cache dir
1322        let entry = Entry {
1323            entity_type: EntityType::Agent,
1324            name: "core-dev".into(),
1325            source: SourceFields::Github {
1326                owner_repo: "o/r".into(),
1327                path_in_repo: "agents/core-dev".into(),
1328                ref_: "main".into(),
1329            },
1330        };
1331        let a = adapters().get("claude-code").unwrap();
1332        let files = a.installed_dir_files(&entry, &local(dir.path()));
1333        assert!(files.is_empty());
1334    }
1335
1336    #[test]
1337    fn installed_dir_files_flat_mode_skips_non_deployed_files() {
1338        use skillfile_core::models::{EntityType, SourceFields};
1339
1340        let dir = tempfile::tempdir().unwrap();
1341        let vdir = dir.path().join(".skillfile/cache/agents/core-dev");
1342        std::fs::create_dir_all(&vdir).unwrap();
1343        std::fs::write(vdir.join("backend.md"), "# Backend").unwrap();
1344        std::fs::write(vdir.join("frontend.md"), "# Frontend").unwrap();
1345
1346        // Only deploy one file
1347        let target = dir.path().join(".claude/agents");
1348        std::fs::create_dir_all(&target).unwrap();
1349        std::fs::write(target.join("backend.md"), "# Backend").unwrap();
1350        // frontend.md NOT deployed
1351
1352        let entry = Entry {
1353            entity_type: EntityType::Agent,
1354            name: "core-dev".into(),
1355            source: SourceFields::Github {
1356                owner_repo: "o/r".into(),
1357                path_in_repo: "agents/core-dev".into(),
1358                ref_: "main".into(),
1359            },
1360        };
1361        let a = adapters().get("claude-code").unwrap();
1362        let files = a.installed_dir_files(&entry, &local(dir.path()));
1363        assert!(files.contains_key("backend.md"));
1364        assert!(!files.contains_key("frontend.md"));
1365    }
1366
1367    #[test]
1368    fn deploy_entry_dir_keys_match_source_relative_paths() {
1369        use skillfile_core::models::{EntityType, SourceFields};
1370
1371        let dir = tempfile::tempdir().unwrap();
1372        let source_dir = dir.path().join(".skillfile/cache/skills/my-skill");
1373        std::fs::create_dir_all(&source_dir).unwrap();
1374        std::fs::write(source_dir.join("SKILL.md"), "# Skill\n").unwrap();
1375        std::fs::write(source_dir.join("examples.md"), "# Examples\n").unwrap();
1376
1377        let entry = Entry {
1378            entity_type: EntityType::Skill,
1379            name: "my-skill".into(),
1380            source: SourceFields::Github {
1381                owner_repo: "o/r".into(),
1382                path_in_repo: "skills/my-skill".into(),
1383                ref_: "main".into(),
1384            },
1385        };
1386        let a = adapters().get("claude-code").unwrap();
1387        let result = a.deploy_entry(&DeployRequest {
1388            entry: &entry,
1389            source: &source_dir,
1390            scope: Scope::Local,
1391            repo_root: dir.path(),
1392            opts: &InstallOptions::default(),
1393        });
1394        assert!(result.contains_key("SKILL.md"));
1395        assert!(result.contains_key("examples.md"));
1396    }
1397}