1use std::fs;
12use std::path::{Path, PathBuf};
13
14use chrono::Utc;
15
16use crate::error::{Result, SkillError};
17use crate::manifest::{
18 HistoricalHashes, InstallState, InstalledFile, InstalledSkill, MANIFEST_FILE, Manifest,
19 classify_path, sha256_hex,
20};
21use crate::skill::Skill;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Agent {
30 Claude,
33 Codex,
36 Cursor,
39 Kimi,
41}
42
43impl Agent {
44 pub fn as_str(&self) -> &'static str {
46 match self {
47 Self::Claude => "claude",
48 Self::Codex => "codex",
49 Self::Cursor => "cursor",
50 Self::Kimi => "kimi",
51 }
52 }
53
54 pub fn all() -> &'static [Agent] {
56 &[Self::Claude, Self::Codex, Self::Cursor, Self::Kimi]
57 }
58
59 pub fn parse(s: &str) -> Option<Self> {
61 match s {
62 "claude" => Some(Self::Claude),
63 "codex" => Some(Self::Codex),
64 "cursor" => Some(Self::Cursor),
65 "kimi" => Some(Self::Kimi),
66 _ => None,
67 }
68 }
69
70 pub fn dir_name(&self) -> &'static str {
73 match self {
74 Self::Claude => ".claude",
75 Self::Codex => ".codex",
76 Self::Cursor => ".cursor",
77 Self::Kimi => ".kimi",
78 }
79 }
80}
81
82pub fn detect_installed_agents(home: &Path) -> Vec<Agent> {
85 Agent::all()
86 .iter()
87 .copied()
88 .filter(|a| home.join(a.dir_name()).is_dir())
89 .collect()
90}
91
92#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct InstallSpec {
99 pub global: bool,
101 pub local: bool,
103 pub agents: Vec<Agent>,
106 pub include_vendor_neutral_global: bool,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct InstallTarget {
115 pub label: String,
117 pub skills_dir: PathBuf,
122}
123
124#[derive(Debug, Clone)]
127pub struct Environment {
128 pub cwd: PathBuf,
129 pub home: PathBuf,
131 pub repo_root: Option<PathBuf>,
133}
134
135impl Environment {
136 pub fn detect() -> Result<Self> {
145 let cwd = std::env::current_dir().map_err(|source| SkillError::Io {
146 path: PathBuf::from("."),
147 source,
148 })?;
149 let home = match std::env::var_os("DEVBOY_HOME_OVERRIDE") {
150 Some(p) if !p.is_empty() => PathBuf::from(p),
151 _ => dirs::home_dir().ok_or_else(|| SkillError::Io {
152 path: PathBuf::from("~"),
153 source: std::io::Error::other("home directory is not set"),
154 })?,
155 };
156 let repo_root = locate_repo_root(&cwd);
157 Ok(Self {
158 cwd,
159 home,
160 repo_root,
161 })
162 }
163}
164
165fn locate_repo_root(start: &Path) -> Option<PathBuf> {
166 let mut cur = start;
167 loop {
168 if cur.join(".git").exists() || cur.join(".devboy.toml").exists() {
169 return Some(cur.to_path_buf());
170 }
171 cur = cur.parent()?;
172 }
173}
174
175pub fn resolve_targets(env: &Environment, spec: &InstallSpec) -> Result<Vec<InstallTarget>> {
195 let mut targets: Vec<InstallTarget> = Vec::new();
196
197 if spec.global {
198 targets.push(InstallTarget {
199 label: "global (~/.agents/skills)".into(),
200 skills_dir: env.home.join(".agents").join("skills"),
201 });
202 }
203
204 if !spec.agents.is_empty() {
205 for agent in &spec.agents {
206 let (label, root) = if spec.local {
207 let repo =
208 env.repo_root
209 .as_ref()
210 .ok_or_else(|| SkillError::MissingRequiredField {
211 skill: "<install-target>".into(),
212 field: "repository root",
213 })?;
214 (
215 format!(
216 "{} (local: <repo>/{}/skills)",
217 agent.as_str(),
218 agent.dir_name()
219 ),
220 repo.join(agent.dir_name()).join("skills"),
221 )
222 } else {
223 (
224 format!("{} (~/{}/skills)", agent.as_str(), agent.dir_name()),
225 env.home.join(agent.dir_name()).join("skills"),
226 )
227 };
228 targets.push(InstallTarget {
229 label,
230 skills_dir: root,
231 });
232 }
233 if spec.include_vendor_neutral_global
234 && !targets
235 .iter()
236 .any(|t| t.skills_dir == env.home.join(".agents").join("skills"))
237 {
238 targets.push(InstallTarget {
239 label: "global (~/.agents/skills)".into(),
240 skills_dir: env.home.join(".agents").join("skills"),
241 });
242 }
243 }
244
245 if !spec.global && spec.agents.is_empty() {
246 let repo = env
247 .repo_root
248 .as_ref()
249 .ok_or_else(|| SkillError::MissingRequiredField {
250 skill: "<install-target>".into(),
251 field: "repository root (pass --global or --agent to install outside a repo)",
252 })?;
253 targets.push(InstallTarget {
254 label: "repo-local (<repo>/.agents/skills)".into(),
255 skills_dir: repo.join(".agents").join("skills"),
256 });
257 }
258
259 Ok(targets)
260}
261
262#[derive(Debug, Clone, Default)]
268pub struct InstallOptions {
269 pub force: bool,
271 pub dry_run: bool,
273 pub installed_from: Option<String>,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum InstallOutcome {
281 Installed,
283 Unchanged,
285 Upgraded {
287 from_version: Option<u32>,
289 },
290 SkippedUserModified,
292 OverwrittenWithForce,
294 SkippedUnknown,
298}
299
300#[derive(Debug, Clone, Default)]
302pub struct InstallReport {
303 pub outcomes: std::collections::BTreeMap<String, InstallOutcome>,
305}
306
307impl InstallReport {
308 pub fn count(&self, outcome: &InstallOutcome) -> usize {
310 self.outcomes.values().filter(|o| o == &outcome).count()
311 }
312
313 pub fn is_all_noop(&self) -> bool {
315 self.outcomes.values().all(|o| {
316 matches!(
317 o,
318 InstallOutcome::Unchanged
319 | InstallOutcome::SkippedUserModified
320 | InstallOutcome::SkippedUnknown
321 )
322 })
323 }
324}
325
326pub fn install_skills_to_target(
328 target: &InstallTarget,
329 skills: &[Skill],
330 history: &HistoricalHashes,
331 options: &InstallOptions,
332) -> Result<InstallReport> {
333 if !options.dry_run {
334 fs::create_dir_all(&target.skills_dir).map_err(|source| SkillError::Io {
335 path: target.skills_dir.clone(),
336 source,
337 })?;
338 }
339
340 let manifest_path = target.skills_dir.join(MANIFEST_FILE);
341 let mut manifest = Manifest::load(&manifest_path)?;
345 manifest.installed_from = options.installed_from.clone().or(manifest.installed_from);
346
347 let mut report = InstallReport::default();
348
349 for skill in skills {
350 let skill_dir = target.skills_dir.join(&skill.frontmatter.name);
351 let skill_path = skill_dir.join("SKILL.md");
352 let body = render_skill_file(skill);
353
354 let mut state = classify_path(history, &skill.frontmatter.name, &skill_path)?
356 .unwrap_or(InstallState::Unknown);
357
358 if matches!(state, InstallState::Unknown)
364 && let Some(recorded) = manifest.get(&skill.frontmatter.name)
365 && let Some(file_entry) = recorded.files.get("SKILL.md")
366 && let Some(actual_sha) = hash_file_if_exists(&skill_path)?
367 {
368 state = if actual_sha.eq_ignore_ascii_case(&file_entry.sha256) {
369 InstallState::Unchanged
370 } else {
371 InstallState::UserModified
372 };
373 }
374
375 let previously_installed = skill_path.is_file();
376
377 let outcome = match (state, previously_installed, options.force) {
378 (_, false, _) => {
379 write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
380 if !options.dry_run {
381 manifest.record(
382 &skill.frontmatter.name,
383 record_for(skill, body.as_bytes(), options, "embedded"),
384 );
385 }
386 InstallOutcome::Installed
387 }
388 (InstallState::Unchanged, true, _) => InstallOutcome::Unchanged,
389 (InstallState::HistoricalSafe, true, _) => {
390 let prev_version = manifest.get(&skill.frontmatter.name).map(|m| m.version);
391 write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
392 if !options.dry_run {
393 manifest.record(
394 &skill.frontmatter.name,
395 record_for(skill, body.as_bytes(), options, "embedded"),
396 );
397 }
398 InstallOutcome::Upgraded {
399 from_version: prev_version,
400 }
401 }
402 (InstallState::UserModified, true, true) => {
403 write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
404 if !options.dry_run {
405 manifest.record(
406 &skill.frontmatter.name,
407 record_for(skill, body.as_bytes(), options, "embedded"),
408 );
409 }
410 InstallOutcome::OverwrittenWithForce
411 }
412 (InstallState::UserModified, true, false) => InstallOutcome::SkippedUserModified,
413 (InstallState::Unknown, true, true) => {
414 write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
415 if !options.dry_run {
416 manifest.record(
417 &skill.frontmatter.name,
418 record_for(skill, body.as_bytes(), options, "embedded"),
419 );
420 }
421 InstallOutcome::OverwrittenWithForce
422 }
423 (InstallState::Unknown, true, false) => InstallOutcome::SkippedUnknown,
424 };
425
426 report
427 .outcomes
428 .insert(skill.frontmatter.name.clone(), outcome);
429 }
430
431 if !options.dry_run {
432 manifest.save(&manifest_path)?;
433 }
434
435 Ok(report)
436}
437
438pub fn remove_skills_from_target(
441 target: &InstallTarget,
442 names: &[String],
443 strict: bool,
444 dry_run: bool,
445) -> Result<Vec<String>> {
446 let manifest_path = target.skills_dir.join(MANIFEST_FILE);
447 let mut manifest = Manifest::load(&manifest_path)?;
450 let mut removed = Vec::new();
451
452 for name in names {
453 let skill_dir = target.skills_dir.join(name);
454 let dir_present = skill_dir.exists();
455 let in_manifest = manifest.get(name).is_some();
456
457 if !dir_present && !in_manifest {
458 if strict {
459 return Err(SkillError::NotFound {
460 name: name.clone(),
461 source_name: "install-target",
462 });
463 }
464 continue;
465 }
466
467 if dir_present && !dry_run {
468 fs::remove_dir_all(&skill_dir).map_err(|source| SkillError::Io {
469 path: skill_dir.clone(),
470 source,
471 })?;
472 }
473 if !dry_run {
478 manifest.forget(name);
479 }
480 removed.push(name.clone());
481 }
482
483 if !dry_run {
484 manifest.save(&manifest_path)?;
485 }
486 Ok(removed)
487}
488
489#[derive(Debug, Clone, PartialEq, Eq)]
495pub struct LegacySkill {
496 pub legacy_name: String,
498 pub canonical_name: String,
500 pub canonical_present: bool,
504 pub path: PathBuf,
506}
507
508pub fn scan_legacy_skills_at_target(target: &InstallTarget) -> Result<Vec<LegacySkill>> {
513 let mut out = Vec::new();
514 let entries = match fs::read_dir(&target.skills_dir) {
515 Ok(e) => e,
516 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
517 Err(source) => {
518 return Err(SkillError::Io {
519 path: target.skills_dir.clone(),
520 source,
521 });
522 }
523 };
524 for entry in entries.flatten() {
525 let name = entry.file_name().to_string_lossy().into_owned();
526 let Some(canonical) = name.strip_prefix("devboy-").map(str::to_owned) else {
527 continue;
528 };
529 let path = entry.path();
531 if !path.is_dir() {
532 continue;
533 }
534 let canonical_path = target.skills_dir.join(&canonical);
535 out.push(LegacySkill {
536 legacy_name: name,
537 canonical_name: canonical,
538 canonical_present: canonical_path.exists(),
539 path,
540 });
541 }
542 out.sort_by(|a, b| a.legacy_name.cmp(&b.legacy_name));
543 Ok(out)
544}
545
546pub fn migrate_legacy_skills_at_target(
554 target: &InstallTarget,
555 dry_run: bool,
556) -> Result<Vec<String>> {
557 let scan = scan_legacy_skills_at_target(target)?;
558 let safe: Vec<String> = scan
559 .iter()
560 .filter(|s| s.canonical_present)
561 .map(|s| s.legacy_name.clone())
562 .collect();
563 if safe.is_empty() {
564 return Ok(safe);
565 }
566 remove_skills_from_target(target, &safe, false, dry_run)
567}
568
569fn hash_file_if_exists(path: &Path) -> Result<Option<String>> {
574 match fs::read(path) {
575 Ok(bytes) => Ok(Some(sha256_hex(&bytes))),
576 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
577 Err(source) => Err(SkillError::Io {
578 path: path.to_path_buf(),
579 source,
580 }),
581 }
582}
583
584fn render_skill_file(skill: &Skill) -> String {
585 let yaml =
588 serde_yaml::to_string(&skill.frontmatter).expect("frontmatter should always serialise");
589 let mut out = String::with_capacity(yaml.len() + skill.body.len() + 16);
590 out.push_str("---\n");
591 out.push_str(&yaml);
592 if !yaml.ends_with('\n') {
593 out.push('\n');
594 }
595 out.push_str("---\n");
596 out.push_str(&skill.body);
597 out
598}
599
600fn write_skill(skill_dir: &Path, file_path: &Path, bytes: &[u8], dry_run: bool) -> Result<()> {
601 if dry_run {
602 return Ok(());
603 }
604 fs::create_dir_all(skill_dir).map_err(|source| SkillError::Io {
605 path: skill_dir.to_path_buf(),
606 source,
607 })?;
608 fs::write(file_path, bytes).map_err(|source| SkillError::Io {
609 path: file_path.to_path_buf(),
610 source,
611 })?;
612 Ok(())
613}
614
615fn record_for(
616 skill: &Skill,
617 body: &[u8],
618 _options: &InstallOptions,
619 source: &str,
620) -> InstalledSkill {
621 let mut files = std::collections::BTreeMap::new();
622 files.insert(
623 "SKILL.md".to_string(),
624 InstalledFile {
625 sha256: sha256_hex(body),
626 size: body.len() as u64,
627 },
628 );
629 InstalledSkill {
635 version: skill.frontmatter.version,
636 installed_at: Utc::now(),
637 source: source.to_string(),
638 files,
639 }
640}
641
642#[cfg(test)]
647mod tests {
648 use super::*;
649 use tempfile::tempdir;
650
651 fn test_env(home: &Path, cwd: &Path, repo_root: Option<PathBuf>) -> Environment {
652 Environment {
653 cwd: cwd.to_path_buf(),
654 home: home.to_path_buf(),
655 repo_root,
656 }
657 }
658
659 #[test]
660 fn resolver_default_is_repo_local() {
661 let home = tempdir().unwrap();
662 let repo = tempdir().unwrap();
663 let env = test_env(home.path(), repo.path(), Some(repo.path().to_path_buf()));
664 let spec = InstallSpec::default();
665 let targets = resolve_targets(&env, &spec).unwrap();
666 assert_eq!(targets.len(), 1);
667 assert_eq!(
668 targets[0].skills_dir,
669 repo.path().join(".agents").join("skills")
670 );
671 }
672
673 #[test]
674 fn resolver_fails_without_repo_and_flags() {
675 let home = tempdir().unwrap();
676 let env = test_env(home.path(), home.path(), None);
677 let spec = InstallSpec::default();
678 let err = resolve_targets(&env, &spec).unwrap_err();
679 assert!(
680 matches!(err, SkillError::MissingRequiredField { .. }),
681 "expected MissingRequiredField, got {err:?}"
682 );
683 }
684
685 #[test]
686 fn resolver_global() {
687 let home = tempdir().unwrap();
688 let env = test_env(home.path(), home.path(), None);
689 let spec = InstallSpec {
690 global: true,
691 ..Default::default()
692 };
693 let targets = resolve_targets(&env, &spec).unwrap();
694 assert_eq!(targets.len(), 1);
695 assert_eq!(
696 targets[0].skills_dir,
697 home.path().join(".agents").join("skills")
698 );
699 }
700
701 #[test]
702 fn resolver_agent_maps_to_home() {
703 let home = tempdir().unwrap();
704 let env = test_env(home.path(), home.path(), None);
705 let spec = InstallSpec {
706 agents: vec![Agent::Claude],
707 ..Default::default()
708 };
709 let targets = resolve_targets(&env, &spec).unwrap();
710 assert_eq!(targets.len(), 1);
711 assert_eq!(
712 targets[0].skills_dir,
713 home.path().join(".claude").join("skills")
714 );
715 }
716
717 #[test]
718 fn resolver_agent_local_maps_to_repo() {
719 let home = tempdir().unwrap();
720 let repo = tempdir().unwrap();
721 let env = test_env(home.path(), repo.path(), Some(repo.path().to_path_buf()));
722 let spec = InstallSpec {
723 agents: vec![Agent::Codex],
724 local: true,
725 ..Default::default()
726 };
727 let targets = resolve_targets(&env, &spec).unwrap();
728 assert_eq!(targets.len(), 1);
729 assert_eq!(
730 targets[0].skills_dir,
731 repo.path().join(".codex").join("skills")
732 );
733 }
734
735 #[test]
736 fn resolver_agent_all_expands_and_includes_vendor_neutral() {
737 let home = tempdir().unwrap();
738 let env = test_env(home.path(), home.path(), None);
739 let spec = InstallSpec {
740 agents: vec![Agent::Claude, Agent::Codex],
741 include_vendor_neutral_global: true,
742 ..Default::default()
743 };
744 let targets = resolve_targets(&env, &spec).unwrap();
745 assert_eq!(targets.len(), 3);
746 let paths: Vec<_> = targets.iter().map(|t| t.skills_dir.clone()).collect();
747 assert!(paths.contains(&home.path().join(".claude").join("skills")));
748 assert!(paths.contains(&home.path().join(".codex").join("skills")));
749 assert!(paths.contains(&home.path().join(".agents").join("skills")));
750 }
751
752 #[test]
753 fn detect_installed_agents_filters_by_dir_presence() {
754 let home = tempdir().unwrap();
755 fs::create_dir(home.path().join(".claude")).unwrap();
756 fs::create_dir(home.path().join(".cursor")).unwrap();
757 let detected = detect_installed_agents(home.path());
758 assert!(detected.contains(&Agent::Claude));
759 assert!(detected.contains(&Agent::Cursor));
760 assert!(!detected.contains(&Agent::Codex));
761 assert!(!detected.contains(&Agent::Kimi));
762 }
763
764 #[test]
765 fn install_writes_new_skill_and_skips_unchanged() {
766 let dir = tempdir().unwrap();
767 let target = InstallTarget {
768 label: "test".into(),
769 skills_dir: dir.path().to_path_buf(),
770 };
771 let skill = crate::skill::Skill::parse(
772 "setup",
773 r#"---
774name: setup
775description: test
776category: self-bootstrap
777version: 1
778---
779body
780"#,
781 )
782 .unwrap();
783
784 let body = render_skill_file(&skill);
786 let mut history = HistoricalHashes::default();
787 history.by_skill.insert(
788 "setup".into(),
789 crate::manifest::SkillHistory {
790 current: crate::manifest::HistoricalVersion {
791 version: 1,
792 sha256: sha256_hex(body.as_bytes()),
793 },
794 history: vec![],
795 },
796 );
797
798 let options = InstallOptions::default();
799 let report =
800 install_skills_to_target(&target, std::slice::from_ref(&skill), &history, &options)
801 .unwrap();
802 assert_eq!(
803 report.outcomes.get("setup"),
804 Some(&InstallOutcome::Installed)
805 );
806
807 let report = install_skills_to_target(&target, &[skill], &history, &options).unwrap();
809 assert_eq!(
810 report.outcomes.get("setup"),
811 Some(&InstallOutcome::Unchanged)
812 );
813 }
814
815 #[test]
816 fn install_refuses_user_modification_without_force() {
817 let dir = tempdir().unwrap();
818 let target = InstallTarget {
819 label: "test".into(),
820 skills_dir: dir.path().to_path_buf(),
821 };
822 let skill = crate::skill::Skill::parse(
823 "setup",
824 r#"---
825name: setup
826description: test
827category: self-bootstrap
828version: 1
829---
830body
831"#,
832 )
833 .unwrap();
834
835 let skill_dir = dir.path().join("setup");
837 fs::create_dir_all(&skill_dir).unwrap();
838 fs::write(skill_dir.join("SKILL.md"), b"user version").unwrap();
839
840 let mut history = HistoricalHashes::default();
843 history.by_skill.insert(
844 "setup".into(),
845 crate::manifest::SkillHistory {
846 current: crate::manifest::HistoricalVersion {
847 version: 1,
848 sha256: sha256_hex(b"current shipped body"),
849 },
850 history: vec![],
851 },
852 );
853
854 let report = install_skills_to_target(
855 &target,
856 std::slice::from_ref(&skill),
857 &history,
858 &InstallOptions::default(),
859 )
860 .unwrap();
861 assert_eq!(
862 report.outcomes.get("setup"),
863 Some(&InstallOutcome::SkippedUserModified)
864 );
865
866 let report = install_skills_to_target(
868 &target,
869 &[skill],
870 &history,
871 &InstallOptions {
872 force: true,
873 ..Default::default()
874 },
875 )
876 .unwrap();
877 assert_eq!(
878 report.outcomes.get("setup"),
879 Some(&InstallOutcome::OverwrittenWithForce)
880 );
881 }
882
883 #[test]
884 fn remove_deletes_skill_and_manifest_entry() {
885 let dir = tempdir().unwrap();
886 let target = InstallTarget {
887 label: "test".into(),
888 skills_dir: dir.path().to_path_buf(),
889 };
890 let skill_dir = dir.path().join("setup");
891 fs::create_dir_all(&skill_dir).unwrap();
892 fs::write(skill_dir.join("SKILL.md"), b"hi").unwrap();
893
894 let removed = remove_skills_from_target(&target, &["setup".into()], false, false).unwrap();
895 assert_eq!(removed, vec!["setup".to_string()]);
896 assert!(!skill_dir.exists());
897 }
898
899 #[test]
904 fn agent_parse_and_as_str_round_trip() {
905 for agent in Agent::all() {
906 let parsed = Agent::parse(agent.as_str()).unwrap();
907 assert_eq!(parsed, *agent);
908 assert!(!agent.dir_name().is_empty());
909 assert!(agent.dir_name().starts_with('.'));
910 }
911 assert!(Agent::parse("not-an-agent").is_none());
912 assert_eq!(Agent::all().len(), 4);
916 }
917
918 #[test]
919 fn detect_installed_agents_filters_on_disk() {
920 let home = tempdir().unwrap();
921 assert!(detect_installed_agents(home.path()).is_empty());
923
924 fs::create_dir_all(home.path().join(".claude")).unwrap();
926 fs::create_dir_all(home.path().join(".kimi")).unwrap();
927 let detected = detect_installed_agents(home.path());
928 assert!(detected.contains(&Agent::Claude));
929 assert!(detected.contains(&Agent::Kimi));
930 assert!(!detected.contains(&Agent::Codex));
931 }
932
933 #[test]
938 fn render_skill_file_produces_round_trippable_markdown() {
939 let original = r#"---
940name: setup
941description: Walk the user through initial devboy configuration.
942category: self-bootstrap
943version: 3
944compatibility: devboy-tools >= 0.18
945activation:
946 - "setup devboy"
947tools:
948 - doctor
949---
950
951# setup
952
953Body stays intact across a render round-trip.
954"#;
955 let skill = Skill::parse("setup", original).unwrap();
956 let rendered = render_skill_file(&skill);
957 assert!(rendered.starts_with("---\n"));
958 assert!(rendered.contains("\n---\n"));
959 assert!(rendered.contains("Body stays intact"));
960
961 let reparsed = Skill::parse("setup", &rendered).unwrap();
964 assert_eq!(reparsed.name(), skill.name());
965 assert_eq!(reparsed.version(), skill.version());
966 assert_eq!(reparsed.category(), skill.category());
967 assert_eq!(reparsed.body.trim_end(), skill.body.trim_end());
968 }
969
970 fn plant_skill(target: &InstallTarget, name: &str) {
975 let dir = target.skills_dir.join(name);
976 fs::create_dir_all(&dir).unwrap();
977 fs::write(
978 dir.join("SKILL.md"),
979 format!(
980 "---\nname: {n}\ndescription: x\ncategory: self-bootstrap\nversion: 1\n---\nbody\n",
981 n = name
982 ),
983 )
984 .unwrap();
985 }
986
987 #[test]
988 fn scan_finds_legacy_dirs_and_reports_canonical_presence() {
989 let dir = tempdir().unwrap();
990 let target = InstallTarget {
991 label: "test".into(),
992 skills_dir: dir.path().to_path_buf(),
993 };
994 plant_skill(&target, "devboy-setup");
996 plant_skill(&target, "setup");
997 plant_skill(&target, "devboy-orphan");
999 plant_skill(&target, "review-mr");
1001 fs::write(dir.path().join("devboy-readme.txt"), "hi").unwrap();
1004
1005 let scan = scan_legacy_skills_at_target(&target).unwrap();
1006 let names: Vec<&str> = scan.iter().map(|s| s.legacy_name.as_str()).collect();
1007 assert_eq!(names, vec!["devboy-orphan", "devboy-setup"]);
1008 let setup = scan
1009 .iter()
1010 .find(|s| s.legacy_name == "devboy-setup")
1011 .unwrap();
1012 assert!(setup.canonical_present);
1013 assert_eq!(setup.canonical_name, "setup");
1014 let orphan = scan
1015 .iter()
1016 .find(|s| s.legacy_name == "devboy-orphan")
1017 .unwrap();
1018 assert!(!orphan.canonical_present);
1019 }
1020
1021 #[test]
1022 fn scan_returns_empty_when_target_dir_missing() {
1023 let target = InstallTarget {
1024 label: "test".into(),
1025 skills_dir: PathBuf::from("/definitely/does/not/exist"),
1026 };
1027 let scan = scan_legacy_skills_at_target(&target).unwrap();
1028 assert!(scan.is_empty());
1029 }
1030
1031 #[test]
1032 fn migrate_removes_safe_duplicates_only() {
1033 let dir = tempdir().unwrap();
1034 let target = InstallTarget {
1035 label: "test".into(),
1036 skills_dir: dir.path().to_path_buf(),
1037 };
1038 plant_skill(&target, "devboy-setup");
1039 plant_skill(&target, "setup");
1040 plant_skill(&target, "devboy-orphan");
1041
1042 let removed = migrate_legacy_skills_at_target(&target, false).unwrap();
1043 assert_eq!(removed, vec!["devboy-setup".to_string()]);
1044
1045 assert!(!dir.path().join("devboy-setup").exists());
1047 assert!(dir.path().join("setup").exists());
1048 assert!(dir.path().join("devboy-orphan").exists());
1049 }
1050
1051 #[test]
1052 fn migrate_dry_run_does_not_touch_filesystem() {
1053 let dir = tempdir().unwrap();
1054 let target = InstallTarget {
1055 label: "test".into(),
1056 skills_dir: dir.path().to_path_buf(),
1057 };
1058 plant_skill(&target, "devboy-setup");
1059 plant_skill(&target, "setup");
1060
1061 let would_remove = migrate_legacy_skills_at_target(&target, true).unwrap();
1062 assert_eq!(would_remove, vec!["devboy-setup".to_string()]);
1063 assert!(dir.path().join("devboy-setup").exists());
1064 assert!(dir.path().join("setup").exists());
1065 }
1066
1067 #[test]
1068 fn migrate_is_noop_when_nothing_legacy() {
1069 let dir = tempdir().unwrap();
1070 let target = InstallTarget {
1071 label: "test".into(),
1072 skills_dir: dir.path().to_path_buf(),
1073 };
1074 plant_skill(&target, "setup");
1075 plant_skill(&target, "review-mr");
1076 let removed = migrate_legacy_skills_at_target(&target, false).unwrap();
1077 assert!(removed.is_empty());
1078 assert!(dir.path().join("setup").exists());
1079 assert!(dir.path().join("review-mr").exists());
1080 }
1081}