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 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 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 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 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#[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}