Skip to main content

skill_harness/
manage.rs

1//! Skill management — install/check/uninstall SKILL.md files for agent environments.
2//!
3//! CLI tools bundle a SKILL.md via `include_str!` and use this module to install
4//! it to the appropriate location for the active agent environment.
5
6use anyhow::{Context, Result};
7use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10/// Explicit harness target for deterministic install/check/uninstall behavior.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum HarnessTarget {
13    ClaudeCode,
14    Codex,
15    OpenCode,
16    Cursor,
17    Generic,
18}
19
20impl HarnessTarget {
21    pub fn parse(name: &str) -> Option<Self> {
22        match name.to_ascii_lowercase().as_str() {
23            "claude" | "claude-code" | "claudecode" => Some(Self::ClaudeCode),
24            "codex" => Some(Self::Codex),
25            "opencode" | "open-code" => Some(Self::OpenCode),
26            "cursor" => Some(Self::Cursor),
27            "generic" | "agent" => Some(Self::Generic),
28            _ => None,
29        }
30    }
31
32    pub fn skill_rel_path(&self, name: &str) -> PathBuf {
33        match self {
34            Self::ClaudeCode => PathBuf::from(format!(".claude/skills/{name}/SKILL.md")),
35            Self::Codex => PathBuf::from(format!(".codex/skills/{name}/SKILL.md")),
36            Self::OpenCode => PathBuf::from(format!(".opencode/skills/{name}/SKILL.md")),
37            Self::Cursor => PathBuf::from(format!(".cursor/rules/{name}.md")),
38            Self::Generic => PathBuf::from(format!(".agent/skills/{name}/SKILL.md")),
39        }
40    }
41}
42
43/// Configuration for a skill to be managed.
44pub struct SkillConfig {
45    /// The tool name (e.g., "agent-doc", "webmaster").
46    pub name: String,
47    /// The bundled SKILL.md content (typically from `include_str!`).
48    pub content: String,
49    /// The tool version (typically from `env!("CARGO_PKG_VERSION")`).
50    pub version: String,
51    /// The relative path resolver for the target environment.
52    pub path_resolver: Box<dyn Fn(&str) -> PathBuf + Send + Sync>,
53}
54
55impl SkillConfig {
56    /// Create a new skill config with a custom path resolver.
57    pub fn new(
58        name: impl Into<String>,
59        content: impl Into<String>,
60        version: impl Into<String>,
61        path_resolver: impl Fn(&str) -> PathBuf + Send + Sync + 'static,
62    ) -> Self {
63        Self {
64            name: name.into(),
65            content: content.into(),
66            version: version.into(),
67            path_resolver: Box::new(path_resolver),
68        }
69    }
70
71    /// Create a skill config that installs to `.agent/skills/<name>/SKILL.md`.
72    pub fn generic(
73        name: impl Into<String>,
74        content: impl Into<String>,
75        version: impl Into<String>,
76    ) -> Self {
77        Self::for_harness(name, content, version, HarnessTarget::Generic)
78    }
79
80    /// Create a skill config for a specific harness target.
81    pub fn for_harness(
82        name: impl Into<String>,
83        content: impl Into<String>,
84        version: impl Into<String>,
85        target: HarnessTarget,
86    ) -> Self {
87        Self::new(name, content, version, move |name| {
88            target.skill_rel_path(name)
89        })
90    }
91
92    /// Resolve the skill file path under the given root (or CWD if None).
93    pub fn skill_path(&self, root: Option<&Path>) -> PathBuf {
94        let rel = (self.path_resolver)(&self.name);
95        match root {
96            Some(r) => r.join(rel),
97            None => rel,
98        }
99    }
100
101    /// Install the bundled SKILL.md to the project.
102    pub fn install(&self, root: Option<&Path>) -> Result<()> {
103        let path = self.skill_path(root);
104
105        if path.exists() {
106            let existing = std::fs::read_to_string(&path)
107                .with_context(|| format!("failed to read {}", path.display()))?;
108            if existing == self.content {
109                eprintln!("Skill already up to date (v{}).", self.version);
110                return Ok(());
111            }
112        }
113
114        if let Some(parent) = path.parent() {
115            std::fs::create_dir_all(parent)
116                .with_context(|| format!("failed to create {}", parent.display()))?;
117        }
118
119        std::fs::write(&path, &self.content)
120            .with_context(|| format!("failed to write {}", path.display()))?;
121        eprintln!("Installed skill v{} → {}", self.version, path.display());
122
123        Ok(())
124    }
125
126    /// Install every file from a portable skill directory into the target skill directory.
127    pub fn install_directory(&self, source_dir: &Path, root: Option<&Path>) -> Result<()> {
128        let source_skill = source_dir.join("SKILL.md");
129        if !source_skill.is_file() {
130            anyhow::bail!(
131                "source skill directory must contain SKILL.md: {}",
132                source_dir.display()
133            );
134        }
135        crate::okf::validate_skill_directory_okf(source_dir)?;
136
137        let target_skill = self.skill_path(root);
138        let target_dir = target_skill
139            .parent()
140            .context("target skill path has no parent directory")?;
141
142        sync_directory(source_dir, target_dir)?;
143        eprintln!(
144            "Installed skill directory v{} → {}",
145            self.version,
146            target_dir.display()
147        );
148        Ok(())
149    }
150
151    /// Check if the installed skill matches the bundled version.
152    pub fn check(&self, root: Option<&Path>) -> Result<bool> {
153        let path = self.skill_path(root);
154
155        if !path.exists() {
156            eprintln!(
157                "Not installed. Run `{} skill install` to install.",
158                self.name
159            );
160            return Ok(false);
161        }
162
163        let existing = std::fs::read_to_string(&path)
164            .with_context(|| format!("failed to read {}", path.display()))?;
165
166        if existing == self.content {
167            eprintln!("Up to date (v{}).", self.version);
168            Ok(true)
169        } else {
170            eprintln!(
171                "Outdated. Run `{} skill install` to update to v{}.",
172                self.name, self.version
173            );
174            Ok(false)
175        }
176    }
177
178    /// Check if the installed skill directory matches the source directory.
179    pub fn check_directory(&self, source_dir: &Path, root: Option<&Path>) -> Result<bool> {
180        let source_skill = source_dir.join("SKILL.md");
181        if !source_skill.is_file() {
182            anyhow::bail!(
183                "source skill directory must contain SKILL.md: {}",
184                source_dir.display()
185            );
186        }
187        crate::okf::validate_skill_directory_okf(source_dir)?;
188
189        let target_skill = self.skill_path(root);
190        let target_dir = target_skill
191            .parent()
192            .context("target skill path has no parent directory")?;
193
194        if !target_dir.exists() {
195            eprintln!("Not installed. Run `{} install-dir` to install.", self.name);
196            return Ok(false);
197        }
198
199        let report = compare_directories(source_dir, target_dir)?;
200        if report.is_empty() {
201            eprintln!("Directory up to date (v{}).", self.version);
202            Ok(true)
203        } else {
204            for message in report.messages() {
205                eprintln!("{message}");
206            }
207            eprintln!(
208                "Outdated. Run `{} install-dir {}` to sync to v{}.",
209                self.name, self.name, self.version
210            );
211            Ok(false)
212        }
213    }
214
215    /// Uninstall the skill file and its parent directory (if empty).
216    pub fn uninstall(&self, root: Option<&Path>) -> Result<()> {
217        let path = self.skill_path(root);
218
219        if !path.exists() {
220            eprintln!("Skill not installed.");
221            return Ok(());
222        }
223
224        std::fs::remove_file(&path)
225            .with_context(|| format!("failed to remove {}", path.display()))?;
226
227        if let Some(parent) = path.parent()
228            && parent.read_dir().is_ok_and(|mut d| d.next().is_none())
229        {
230            let _ = std::fs::remove_dir(parent);
231        }
232
233        eprintln!("Uninstalled skill from {}", path.display());
234        Ok(())
235    }
236}
237
238fn sync_directory(source_dir: &Path, target_dir: &Path) -> Result<()> {
239    copy_directory(source_dir, target_dir)?;
240    remove_files_not_in_source(source_dir, target_dir)?;
241    remove_empty_dirs_not_in_source(source_dir, target_dir)?;
242    Ok(())
243}
244
245fn copy_directory(source_dir: &Path, target_dir: &Path) -> Result<()> {
246    std::fs::create_dir_all(target_dir)
247        .with_context(|| format!("failed to create {}", target_dir.display()))?;
248
249    for entry in std::fs::read_dir(source_dir)
250        .with_context(|| format!("failed to read {}", source_dir.display()))?
251    {
252        let entry = entry?;
253        let source_path = entry.path();
254        let target_path = target_dir.join(entry.file_name());
255        let file_type = entry.file_type()?;
256
257        if file_type.is_dir() {
258            copy_directory(&source_path, &target_path)?;
259        } else if file_type.is_file() {
260            if let Some(parent) = target_path.parent() {
261                std::fs::create_dir_all(parent)
262                    .with_context(|| format!("failed to create {}", parent.display()))?;
263            }
264            std::fs::copy(&source_path, &target_path).with_context(|| {
265                format!(
266                    "failed to copy {} to {}",
267                    source_path.display(),
268                    target_path.display()
269                )
270            })?;
271        }
272    }
273    Ok(())
274}
275
276fn remove_files_not_in_source(source_dir: &Path, target_dir: &Path) -> Result<()> {
277    let source_files = collect_relative_files(source_dir)?;
278    let target_files = collect_relative_files(target_dir)?;
279    for rel in target_files {
280        if !source_files.contains(&rel) {
281            let path = target_dir.join(rel);
282            std::fs::remove_file(&path)
283                .with_context(|| format!("failed to remove {}", path.display()))?;
284        }
285    }
286    Ok(())
287}
288
289fn remove_empty_dirs_not_in_source(source_dir: &Path, target_dir: &Path) -> Result<()> {
290    let source_dirs = collect_relative_dirs(source_dir)?;
291    let mut target_dirs: Vec<PathBuf> = collect_relative_dirs(target_dir)?.into_iter().collect();
292    target_dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
293
294    for rel in target_dirs {
295        if source_dirs.contains(&rel) {
296            continue;
297        }
298
299        let path = target_dir.join(rel);
300        if path
301            .read_dir()
302            .is_ok_and(|mut entries| entries.next().is_none())
303        {
304            std::fs::remove_dir(&path)
305                .with_context(|| format!("failed to remove {}", path.display()))?;
306        }
307    }
308
309    Ok(())
310}
311
312#[derive(Debug, Default, PartialEq, Eq)]
313struct DirectoryDiff {
314    missing: BTreeSet<PathBuf>,
315    changed: BTreeSet<PathBuf>,
316    extra: BTreeSet<PathBuf>,
317}
318
319impl DirectoryDiff {
320    fn is_empty(&self) -> bool {
321        self.missing.is_empty() && self.changed.is_empty() && self.extra.is_empty()
322    }
323
324    fn messages(&self) -> Vec<String> {
325        let mut messages = Vec::new();
326        messages.extend(
327            self.missing
328                .iter()
329                .map(|path| format!("Missing installed file: {}", path.display())),
330        );
331        messages.extend(
332            self.changed
333                .iter()
334                .map(|path| format!("Outdated installed file: {}", path.display())),
335        );
336        messages.extend(
337            self.extra
338                .iter()
339                .map(|path| format!("Extra installed file: {}", path.display())),
340        );
341        messages
342    }
343}
344
345fn compare_directories(source_dir: &Path, target_dir: &Path) -> Result<DirectoryDiff> {
346    let source_files = collect_relative_files(source_dir)?;
347    let target_files = collect_relative_files(target_dir)?;
348    let mut diff = DirectoryDiff::default();
349
350    for rel in &source_files {
351        let source_path = source_dir.join(rel);
352        let target_path = target_dir.join(rel);
353        if !target_path.exists() {
354            diff.missing.insert(rel.clone());
355            continue;
356        }
357
358        let source_bytes = std::fs::read(&source_path)
359            .with_context(|| format!("failed to read {}", source_path.display()))?;
360        let target_bytes = std::fs::read(&target_path)
361            .with_context(|| format!("failed to read {}", target_path.display()))?;
362        if source_bytes != target_bytes {
363            diff.changed.insert(rel.clone());
364        }
365    }
366
367    for rel in &target_files {
368        if !source_files.contains(rel) {
369            diff.extra.insert(rel.clone());
370        }
371    }
372
373    Ok(diff)
374}
375
376fn collect_relative_files(root: &Path) -> Result<BTreeSet<PathBuf>> {
377    let mut files = BTreeSet::new();
378    collect_relative_files_inner(root, root, &mut files)?;
379    Ok(files)
380}
381
382fn collect_relative_dirs(root: &Path) -> Result<BTreeSet<PathBuf>> {
383    let mut dirs = BTreeSet::new();
384    collect_relative_dirs_inner(root, root, &mut dirs)?;
385    Ok(dirs)
386}
387
388fn collect_relative_dirs_inner(
389    root: &Path,
390    current: &Path,
391    dirs: &mut BTreeSet<PathBuf>,
392) -> Result<()> {
393    for entry in std::fs::read_dir(current)
394        .with_context(|| format!("failed to read {}", current.display()))?
395    {
396        let entry = entry?;
397        if !entry.file_type()?.is_dir() {
398            continue;
399        }
400
401        let path = entry.path();
402        dirs.insert(
403            path.strip_prefix(root)
404                .with_context(|| {
405                    format!("failed to strip {} from {}", root.display(), path.display())
406                })?
407                .to_path_buf(),
408        );
409        collect_relative_dirs_inner(root, &path, dirs)?;
410    }
411    Ok(())
412}
413
414fn collect_relative_files_inner(
415    root: &Path,
416    current: &Path,
417    files: &mut BTreeSet<PathBuf>,
418) -> Result<()> {
419    for entry in std::fs::read_dir(current)
420        .with_context(|| format!("failed to read {}", current.display()))?
421    {
422        let entry = entry?;
423        let path = entry.path();
424        let file_type = entry.file_type()?;
425        if file_type.is_dir() {
426            collect_relative_files_inner(root, &path, files)?;
427        } else if file_type.is_file() {
428            files.insert(
429                path.strip_prefix(root)
430                    .with_context(|| {
431                        format!("failed to strip {} from {}", root.display(), path.display())
432                    })?
433                    .to_path_buf(),
434            );
435        }
436    }
437    Ok(())
438}
439
440/// Create a SkillConfig that uses agent-kit Environment for path resolution.
441#[cfg(feature = "detect")]
442pub fn skill_for_environment(
443    name: impl Into<String>,
444    content: impl Into<String>,
445    version: impl Into<String>,
446) -> SkillConfig {
447    let env = agent_kit::detect::Environment::detect();
448    let name_str = name.into();
449    let name_clone = name_str.clone();
450    SkillConfig {
451        name: name_str,
452        content: content.into(),
453        version: version.into(),
454        path_resolver: Box::new(move |_| env.skill_rel_path(&name_clone)),
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    fn test_config() -> SkillConfig {
463        SkillConfig::for_harness(
464            "test-tool",
465            "# Test Skill\n\nSome content.\n",
466            "1.0.0",
467            HarnessTarget::ClaudeCode,
468        )
469    }
470
471    #[test]
472    fn skill_path_with_root() {
473        let config = test_config();
474        let path = config.skill_path(Some(Path::new("/project")));
475        assert_eq!(
476            path,
477            PathBuf::from("/project/.claude/skills/test-tool/SKILL.md")
478        );
479    }
480
481    #[test]
482    fn skill_path_without_root() {
483        let config = test_config();
484        let path = config.skill_path(None);
485        assert_eq!(path, PathBuf::from(".claude/skills/test-tool/SKILL.md"));
486    }
487
488    #[test]
489    fn generic_skill_path() {
490        let config = SkillConfig::generic("my-tool", "content", "1.0.0");
491        let path = config.skill_path(None);
492        assert_eq!(path, PathBuf::from(".agent/skills/my-tool/SKILL.md"));
493    }
494
495    #[test]
496    fn claude_code_skill_path() {
497        let config = SkillConfig::for_harness(
498            "compose-skills",
499            "content",
500            "1.0.0",
501            HarnessTarget::ClaudeCode,
502        );
503        assert_eq!(
504            config.skill_path(None),
505            PathBuf::from(".claude/skills/compose-skills/SKILL.md")
506        );
507    }
508
509    #[test]
510    fn codex_skill_path() {
511        let config =
512            SkillConfig::for_harness("compose-skills", "content", "1.0.0", HarnessTarget::Codex);
513        assert_eq!(
514            config.skill_path(None),
515            PathBuf::from(".codex/skills/compose-skills/SKILL.md")
516        );
517    }
518
519    #[test]
520    fn opencode_skill_path() {
521        let config = SkillConfig::for_harness(
522            "compose-skills",
523            "content",
524            "1.0.0",
525            HarnessTarget::OpenCode,
526        );
527        assert_eq!(
528            config.skill_path(None),
529            PathBuf::from(".opencode/skills/compose-skills/SKILL.md")
530        );
531    }
532
533    #[test]
534    fn install_creates_file() {
535        let dir = tempfile::tempdir().unwrap();
536        let config = test_config();
537        config.install(Some(dir.path())).unwrap();
538
539        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
540        assert!(path.exists());
541        let content = std::fs::read_to_string(&path).unwrap();
542        assert_eq!(content, config.content);
543    }
544
545    #[test]
546    fn install_idempotent() {
547        let dir = tempfile::tempdir().unwrap();
548        let config = test_config();
549        config.install(Some(dir.path())).unwrap();
550        config.install(Some(dir.path())).unwrap();
551
552        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
553        let content = std::fs::read_to_string(&path).unwrap();
554        assert_eq!(content, config.content);
555    }
556
557    #[test]
558    fn install_directory_copies_claude_skill_resources() {
559        let source = tempfile::tempdir().unwrap();
560        std::fs::write(source.path().join("SKILL.md"), "# Compose Skills\n").unwrap();
561        std::fs::create_dir_all(source.path().join("references")).unwrap();
562        std::fs::write(source.path().join("references/example.md"), "example").unwrap();
563
564        let project = tempfile::tempdir().unwrap();
565        let config = SkillConfig::for_harness(
566            "compose-skills",
567            "# Compose Skills\n",
568            "1.0.0",
569            HarnessTarget::ClaudeCode,
570        );
571        config
572            .install_directory(source.path(), Some(project.path()))
573            .unwrap();
574
575        assert!(
576            project
577                .path()
578                .join(".claude/skills/compose-skills/SKILL.md")
579                .is_file()
580        );
581        assert!(
582            project
583                .path()
584                .join(".claude/skills/compose-skills/references/example.md")
585                .is_file()
586        );
587    }
588
589    #[test]
590    fn install_directory_copies_codex_skill_resources() {
591        let source = tempfile::tempdir().unwrap();
592        std::fs::write(source.path().join("SKILL.md"), "# Compose Skills\n").unwrap();
593        std::fs::create_dir_all(source.path().join("references")).unwrap();
594        std::fs::write(source.path().join("references/example.md"), "example").unwrap();
595
596        let project = tempfile::tempdir().unwrap();
597        let config = SkillConfig::for_harness(
598            "compose-skills",
599            "# Compose Skills\n",
600            "1.0.0",
601            HarnessTarget::Codex,
602        );
603        config
604            .install_directory(source.path(), Some(project.path()))
605            .unwrap();
606
607        assert!(
608            project
609                .path()
610                .join(".codex/skills/compose-skills/SKILL.md")
611                .is_file()
612        );
613        assert!(
614            project
615                .path()
616                .join(".codex/skills/compose-skills/references/example.md")
617                .is_file()
618        );
619    }
620
621    #[test]
622    fn install_directory_copies_opencode_skill_resources() {
623        let source = tempfile::tempdir().unwrap();
624        std::fs::write(source.path().join("SKILL.md"), "# Compose Skills\n").unwrap();
625        std::fs::create_dir_all(source.path().join("references")).unwrap();
626        std::fs::write(source.path().join("references/example.md"), "example").unwrap();
627
628        let project = tempfile::tempdir().unwrap();
629        let config = SkillConfig::for_harness(
630            "compose-skills",
631            "# Compose Skills\n",
632            "1.0.0",
633            HarnessTarget::OpenCode,
634        );
635        config
636            .install_directory(source.path(), Some(project.path()))
637            .unwrap();
638
639        assert!(
640            project
641                .path()
642                .join(".opencode/skills/compose-skills/SKILL.md")
643                .is_file()
644        );
645        assert!(
646            project
647                .path()
648                .join(".opencode/skills/compose-skills/references/example.md")
649                .is_file()
650        );
651    }
652
653    #[test]
654    fn install_directory_copies_generic_skill_resources() {
655        let source = tempfile::tempdir().unwrap();
656        std::fs::write(source.path().join("SKILL.md"), "# Compose Skills\n").unwrap();
657        std::fs::create_dir_all(source.path().join("references")).unwrap();
658        std::fs::write(source.path().join("references/example.md"), "example").unwrap();
659
660        let project = tempfile::tempdir().unwrap();
661        let config = SkillConfig::for_harness(
662            "compose-skills",
663            "# Compose Skills\n",
664            "1.0.0",
665            HarnessTarget::Generic,
666        );
667        config
668            .install_directory(source.path(), Some(project.path()))
669            .unwrap();
670
671        assert!(
672            project
673                .path()
674                .join(".agent/skills/compose-skills/SKILL.md")
675                .is_file()
676        );
677        assert!(
678            project
679                .path()
680                .join(".agent/skills/compose-skills/references/example.md")
681                .is_file()
682        );
683    }
684
685    #[test]
686    fn bundled_compose_skills_install_check_matrix_covers_supported_harnesses() {
687        let source = tempfile::tempdir().unwrap();
688        std::fs::write(source.path().join("SKILL.md"), "# Compose Skills\n").unwrap();
689        std::fs::write(source.path().join("SPEC.md"), "# Compose Skills Spec\n").unwrap();
690        std::fs::create_dir_all(source.path().join("references/fixtures")).unwrap();
691        std::fs::write(
692            source.path().join("references/fixtures/example.md"),
693            "fixture",
694        )
695        .unwrap();
696
697        let cases = [
698            (
699                HarnessTarget::ClaudeCode,
700                PathBuf::from(".claude/skills/compose-skills"),
701            ),
702            (
703                HarnessTarget::Codex,
704                PathBuf::from(".codex/skills/compose-skills"),
705            ),
706            (
707                HarnessTarget::OpenCode,
708                PathBuf::from(".opencode/skills/compose-skills"),
709            ),
710            (
711                HarnessTarget::Generic,
712                PathBuf::from(".agent/skills/compose-skills"),
713            ),
714        ];
715
716        for (target, expected_rel_dir) in cases {
717            let project = tempfile::tempdir().unwrap();
718            let config =
719                SkillConfig::for_harness("compose-skills", "# Compose Skills\n", "1.0.0", target);
720
721            config
722                .install_directory(source.path(), Some(project.path()))
723                .unwrap();
724
725            let target_dir = project.path().join(expected_rel_dir);
726            assert!(target_dir.join("SKILL.md").is_file());
727            assert!(target_dir.join("SPEC.md").is_file());
728            assert!(target_dir.join("references/fixtures/example.md").is_file());
729            assert!(
730                config
731                    .check_directory(source.path(), Some(project.path()))
732                    .unwrap(),
733                "installed directory should check clean for {target:?}"
734            );
735        }
736    }
737
738    #[test]
739    fn check_directory_accepts_identical_tree() {
740        let source = tempfile::tempdir().unwrap();
741        std::fs::write(source.path().join("SKILL.md"), "# Compose Skills\n").unwrap();
742        std::fs::create_dir_all(source.path().join("references")).unwrap();
743        std::fs::write(source.path().join("references/example.md"), "example").unwrap();
744
745        let project = tempfile::tempdir().unwrap();
746        let config = SkillConfig::for_harness(
747            "compose-skills",
748            "# Compose Skills\n",
749            "1.0.0",
750            HarnessTarget::Codex,
751        );
752        config
753            .install_directory(source.path(), Some(project.path()))
754            .unwrap();
755
756        assert!(
757            config
758                .check_directory(source.path(), Some(project.path()))
759                .unwrap()
760        );
761    }
762
763    #[test]
764    fn check_directory_reports_missing_changed_and_extra_files() {
765        let source = tempfile::tempdir().unwrap();
766        std::fs::write(source.path().join("SKILL.md"), "# Compose Skills\n").unwrap();
767        std::fs::create_dir_all(source.path().join("references")).unwrap();
768        std::fs::write(source.path().join("references/example.md"), "example").unwrap();
769
770        let target = tempfile::tempdir().unwrap();
771        std::fs::write(target.path().join("SKILL.md"), "# Old\n").unwrap();
772        std::fs::write(target.path().join("extra.md"), "extra").unwrap();
773
774        let diff = compare_directories(source.path(), target.path()).unwrap();
775        assert_eq!(diff.changed, BTreeSet::from([PathBuf::from("SKILL.md")]));
776        assert_eq!(
777            diff.missing,
778            BTreeSet::from([PathBuf::from("references/example.md")])
779        );
780        assert_eq!(diff.extra, BTreeSet::from([PathBuf::from("extra.md")]));
781    }
782
783    #[test]
784    fn install_directory_removes_stale_installed_files() {
785        let source = tempfile::tempdir().unwrap();
786        std::fs::write(source.path().join("SKILL.md"), "# Compose Skills\n").unwrap();
787        std::fs::create_dir_all(source.path().join("references")).unwrap();
788        std::fs::write(source.path().join("references/example.md"), "example").unwrap();
789
790        let project = tempfile::tempdir().unwrap();
791        let config = SkillConfig::for_harness(
792            "compose-skills",
793            "# Compose Skills\n",
794            "1.0.0",
795            HarnessTarget::Codex,
796        );
797        config
798            .install_directory(source.path(), Some(project.path()))
799            .unwrap();
800
801        let target_dir = project.path().join(".codex/skills/compose-skills");
802        std::fs::create_dir_all(target_dir.join("stale-dir")).unwrap();
803        std::fs::write(target_dir.join("stale-dir/old.md"), "old").unwrap();
804        std::fs::write(target_dir.join("old.md"), "old").unwrap();
805
806        config
807            .install_directory(source.path(), Some(project.path()))
808            .unwrap();
809
810        assert!(!target_dir.join("old.md").exists());
811        assert!(!target_dir.join("stale-dir").exists());
812        assert!(
813            config
814                .check_directory(source.path(), Some(project.path()))
815                .unwrap()
816        );
817    }
818
819    #[test]
820    fn check_not_installed() {
821        let dir = tempfile::tempdir().unwrap();
822        let config = test_config();
823        assert!(!config.check(Some(dir.path())).unwrap());
824    }
825
826    #[test]
827    fn check_up_to_date() {
828        let dir = tempfile::tempdir().unwrap();
829        let config = test_config();
830        config.install(Some(dir.path())).unwrap();
831        assert!(config.check(Some(dir.path())).unwrap());
832    }
833
834    #[test]
835    fn uninstall_removes_file() {
836        let dir = tempfile::tempdir().unwrap();
837        let config = test_config();
838        config.install(Some(dir.path())).unwrap();
839        config.uninstall(Some(dir.path())).unwrap();
840
841        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
842        assert!(!path.exists());
843    }
844
845    #[test]
846    fn uninstall_not_installed() {
847        let dir = tempfile::tempdir().unwrap();
848        let config = test_config();
849        config.uninstall(Some(dir.path())).unwrap();
850    }
851}