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