1use anyhow::{Context, Result};
7use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10#[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
43pub struct SkillConfig {
45 pub name: String,
47 pub content: String,
49 pub version: String,
51 pub path_resolver: Box<dyn Fn(&str) -> PathBuf + Send + Sync>,
53}
54
55impl SkillConfig {
56 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 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 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 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 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 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 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 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 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#[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}