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 match cur.parent() {
172 Some(p) => cur = p,
173 None => return None,
174 }
175 }
176}
177
178pub fn resolve_targets(env: &Environment, spec: &InstallSpec) -> Result<Vec<InstallTarget>> {
198 let mut targets: Vec<InstallTarget> = Vec::new();
199
200 if spec.global {
201 targets.push(InstallTarget {
202 label: "global (~/.agents/skills)".into(),
203 skills_dir: env.home.join(".agents").join("skills"),
204 });
205 }
206
207 if !spec.agents.is_empty() {
208 for agent in &spec.agents {
209 let (label, root) = if spec.local {
210 let repo =
211 env.repo_root
212 .as_ref()
213 .ok_or_else(|| SkillError::MissingRequiredField {
214 skill: "<install-target>".into(),
215 field: "repository root",
216 })?;
217 (
218 format!(
219 "{} (local: <repo>/{}/skills)",
220 agent.as_str(),
221 agent.dir_name()
222 ),
223 repo.join(agent.dir_name()).join("skills"),
224 )
225 } else {
226 (
227 format!("{} (~/{}/skills)", agent.as_str(), agent.dir_name()),
228 env.home.join(agent.dir_name()).join("skills"),
229 )
230 };
231 targets.push(InstallTarget {
232 label,
233 skills_dir: root,
234 });
235 }
236 if spec.include_vendor_neutral_global
237 && !targets
238 .iter()
239 .any(|t| t.skills_dir == env.home.join(".agents").join("skills"))
240 {
241 targets.push(InstallTarget {
242 label: "global (~/.agents/skills)".into(),
243 skills_dir: env.home.join(".agents").join("skills"),
244 });
245 }
246 }
247
248 if !spec.global && spec.agents.is_empty() {
249 let repo = env
250 .repo_root
251 .as_ref()
252 .ok_or_else(|| SkillError::MissingRequiredField {
253 skill: "<install-target>".into(),
254 field: "repository root (pass --global or --agent to install outside a repo)",
255 })?;
256 targets.push(InstallTarget {
257 label: "repo-local (<repo>/.agents/skills)".into(),
258 skills_dir: repo.join(".agents").join("skills"),
259 });
260 }
261
262 Ok(targets)
263}
264
265#[derive(Debug, Clone, Default)]
271pub struct InstallOptions {
272 pub force: bool,
274 pub dry_run: bool,
276 pub installed_from: Option<String>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum InstallOutcome {
284 Installed,
286 Unchanged,
288 Upgraded {
290 from_version: Option<u32>,
292 },
293 SkippedUserModified,
295 OverwrittenWithForce,
297 SkippedUnknown,
301}
302
303#[derive(Debug, Clone, Default)]
305pub struct InstallReport {
306 pub outcomes: std::collections::BTreeMap<String, InstallOutcome>,
308}
309
310impl InstallReport {
311 pub fn count(&self, outcome: &InstallOutcome) -> usize {
313 self.outcomes.values().filter(|o| o == &outcome).count()
314 }
315
316 pub fn is_all_noop(&self) -> bool {
318 self.outcomes.values().all(|o| {
319 matches!(
320 o,
321 InstallOutcome::Unchanged
322 | InstallOutcome::SkippedUserModified
323 | InstallOutcome::SkippedUnknown
324 )
325 })
326 }
327}
328
329pub fn install_skills_to_target(
331 target: &InstallTarget,
332 skills: &[Skill],
333 history: &HistoricalHashes,
334 options: &InstallOptions,
335) -> Result<InstallReport> {
336 if !options.dry_run {
337 fs::create_dir_all(&target.skills_dir).map_err(|source| SkillError::Io {
338 path: target.skills_dir.clone(),
339 source,
340 })?;
341 }
342
343 let manifest_path = target.skills_dir.join(MANIFEST_FILE);
344 let mut manifest = Manifest::load(&manifest_path)?;
348 manifest.installed_from = options.installed_from.clone().or(manifest.installed_from);
349
350 let mut report = InstallReport::default();
351
352 for skill in skills {
353 let skill_dir = target.skills_dir.join(&skill.frontmatter.name);
354 let skill_path = skill_dir.join("SKILL.md");
355 let body = render_skill_file(skill);
356
357 let mut state = classify_path(history, &skill.frontmatter.name, &skill_path)?
359 .unwrap_or(InstallState::Unknown);
360
361 if matches!(state, InstallState::Unknown)
367 && let Some(recorded) = manifest.get(&skill.frontmatter.name)
368 && let Some(file_entry) = recorded.files.get("SKILL.md")
369 && let Some(actual_sha) = hash_file_if_exists(&skill_path)?
370 {
371 state = if actual_sha.eq_ignore_ascii_case(&file_entry.sha256) {
372 InstallState::Unchanged
373 } else {
374 InstallState::UserModified
375 };
376 }
377
378 let previously_installed = skill_path.is_file();
379
380 let outcome = match (state, previously_installed, options.force) {
381 (_, false, _) => {
382 write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
383 if !options.dry_run {
384 manifest.record(
385 &skill.frontmatter.name,
386 record_for(skill, body.as_bytes(), options, "embedded"),
387 );
388 }
389 InstallOutcome::Installed
390 }
391 (InstallState::Unchanged, true, _) => InstallOutcome::Unchanged,
392 (InstallState::HistoricalSafe, true, _) => {
393 let prev_version = manifest.get(&skill.frontmatter.name).map(|m| m.version);
394 write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
395 if !options.dry_run {
396 manifest.record(
397 &skill.frontmatter.name,
398 record_for(skill, body.as_bytes(), options, "embedded"),
399 );
400 }
401 InstallOutcome::Upgraded {
402 from_version: prev_version,
403 }
404 }
405 (InstallState::UserModified, true, true) => {
406 write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
407 if !options.dry_run {
408 manifest.record(
409 &skill.frontmatter.name,
410 record_for(skill, body.as_bytes(), options, "embedded"),
411 );
412 }
413 InstallOutcome::OverwrittenWithForce
414 }
415 (InstallState::UserModified, true, false) => InstallOutcome::SkippedUserModified,
416 (InstallState::Unknown, true, true) => {
417 write_skill(&skill_dir, &skill_path, body.as_bytes(), options.dry_run)?;
418 if !options.dry_run {
419 manifest.record(
420 &skill.frontmatter.name,
421 record_for(skill, body.as_bytes(), options, "embedded"),
422 );
423 }
424 InstallOutcome::OverwrittenWithForce
425 }
426 (InstallState::Unknown, true, false) => InstallOutcome::SkippedUnknown,
427 };
428
429 report
430 .outcomes
431 .insert(skill.frontmatter.name.clone(), outcome);
432 }
433
434 if !options.dry_run {
435 manifest.save(&manifest_path)?;
436 }
437
438 Ok(report)
439}
440
441pub fn remove_skills_from_target(
444 target: &InstallTarget,
445 names: &[String],
446 strict: bool,
447 dry_run: bool,
448) -> Result<Vec<String>> {
449 let manifest_path = target.skills_dir.join(MANIFEST_FILE);
450 let mut manifest = Manifest::load(&manifest_path)?;
453 let mut removed = Vec::new();
454
455 for name in names {
456 let skill_dir = target.skills_dir.join(name);
457 let dir_present = skill_dir.exists();
458 let in_manifest = manifest.get(name).is_some();
459
460 if !dir_present && !in_manifest {
461 if strict {
462 return Err(SkillError::NotFound {
463 name: name.clone(),
464 source_name: "install-target",
465 });
466 }
467 continue;
468 }
469
470 if dir_present && !dry_run {
471 fs::remove_dir_all(&skill_dir).map_err(|source| SkillError::Io {
472 path: skill_dir.clone(),
473 source,
474 })?;
475 }
476 if !dry_run {
481 manifest.forget(name);
482 }
483 removed.push(name.clone());
484 }
485
486 if !dry_run {
487 manifest.save(&manifest_path)?;
488 }
489 Ok(removed)
490}
491
492#[derive(Debug, Clone, PartialEq, Eq)]
498pub struct LegacySkill {
499 pub legacy_name: String,
501 pub canonical_name: String,
503 pub canonical_present: bool,
507 pub path: PathBuf,
509}
510
511pub fn scan_legacy_skills_at_target(target: &InstallTarget) -> Result<Vec<LegacySkill>> {
516 let mut out = Vec::new();
517 let entries = match fs::read_dir(&target.skills_dir) {
518 Ok(e) => e,
519 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
520 Err(source) => {
521 return Err(SkillError::Io {
522 path: target.skills_dir.clone(),
523 source,
524 });
525 }
526 };
527 for entry in entries.flatten() {
528 let name = entry.file_name().to_string_lossy().into_owned();
529 let Some(canonical) = name.strip_prefix("devboy-").map(str::to_owned) else {
530 continue;
531 };
532 let path = entry.path();
534 if !path.is_dir() {
535 continue;
536 }
537 let canonical_path = target.skills_dir.join(&canonical);
538 out.push(LegacySkill {
539 legacy_name: name,
540 canonical_name: canonical,
541 canonical_present: canonical_path.exists(),
542 path,
543 });
544 }
545 out.sort_by(|a, b| a.legacy_name.cmp(&b.legacy_name));
546 Ok(out)
547}
548
549pub fn migrate_legacy_skills_at_target(
557 target: &InstallTarget,
558 dry_run: bool,
559) -> Result<Vec<String>> {
560 let scan = scan_legacy_skills_at_target(target)?;
561 let safe: Vec<String> = scan
562 .iter()
563 .filter(|s| s.canonical_present)
564 .map(|s| s.legacy_name.clone())
565 .collect();
566 if safe.is_empty() {
567 return Ok(safe);
568 }
569 remove_skills_from_target(target, &safe, false, dry_run)
570}
571
572fn hash_file_if_exists(path: &Path) -> Result<Option<String>> {
577 match fs::read(path) {
578 Ok(bytes) => Ok(Some(sha256_hex(&bytes))),
579 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
580 Err(source) => Err(SkillError::Io {
581 path: path.to_path_buf(),
582 source,
583 }),
584 }
585}
586
587fn render_skill_file(skill: &Skill) -> String {
588 let yaml =
591 serde_yaml::to_string(&skill.frontmatter).expect("frontmatter should always serialise");
592 let mut out = String::with_capacity(yaml.len() + skill.body.len() + 16);
593 out.push_str("---\n");
594 out.push_str(&yaml);
595 if !yaml.ends_with('\n') {
596 out.push('\n');
597 }
598 out.push_str("---\n");
599 out.push_str(&skill.body);
600 out
601}
602
603fn write_skill(skill_dir: &Path, file_path: &Path, bytes: &[u8], dry_run: bool) -> Result<()> {
604 if dry_run {
605 return Ok(());
606 }
607 fs::create_dir_all(skill_dir).map_err(|source| SkillError::Io {
608 path: skill_dir.to_path_buf(),
609 source,
610 })?;
611 fs::write(file_path, bytes).map_err(|source| SkillError::Io {
612 path: file_path.to_path_buf(),
613 source,
614 })?;
615 Ok(())
616}
617
618fn record_for(
619 skill: &Skill,
620 body: &[u8],
621 _options: &InstallOptions,
622 source: &str,
623) -> InstalledSkill {
624 let mut files = std::collections::BTreeMap::new();
625 files.insert(
626 "SKILL.md".to_string(),
627 InstalledFile {
628 sha256: sha256_hex(body),
629 size: body.len() as u64,
630 },
631 );
632 InstalledSkill {
638 version: skill.frontmatter.version,
639 installed_at: Utc::now(),
640 source: source.to_string(),
641 files,
642 }
643}
644
645#[cfg(test)]
650mod tests {
651 use super::*;
652 use tempfile::tempdir;
653
654 fn test_env(home: &Path, cwd: &Path, repo_root: Option<PathBuf>) -> Environment {
655 Environment {
656 cwd: cwd.to_path_buf(),
657 home: home.to_path_buf(),
658 repo_root,
659 }
660 }
661
662 #[test]
663 fn resolver_default_is_repo_local() {
664 let home = tempdir().unwrap();
665 let repo = tempdir().unwrap();
666 let env = test_env(home.path(), repo.path(), Some(repo.path().to_path_buf()));
667 let spec = InstallSpec::default();
668 let targets = resolve_targets(&env, &spec).unwrap();
669 assert_eq!(targets.len(), 1);
670 assert_eq!(
671 targets[0].skills_dir,
672 repo.path().join(".agents").join("skills")
673 );
674 }
675
676 #[test]
677 fn resolver_fails_without_repo_and_flags() {
678 let home = tempdir().unwrap();
679 let env = test_env(home.path(), home.path(), None);
680 let spec = InstallSpec::default();
681 let err = resolve_targets(&env, &spec).unwrap_err();
682 assert!(
683 matches!(err, SkillError::MissingRequiredField { .. }),
684 "expected MissingRequiredField, got {err:?}"
685 );
686 }
687
688 #[test]
689 fn resolver_global() {
690 let home = tempdir().unwrap();
691 let env = test_env(home.path(), home.path(), None);
692 let spec = InstallSpec {
693 global: true,
694 ..Default::default()
695 };
696 let targets = resolve_targets(&env, &spec).unwrap();
697 assert_eq!(targets.len(), 1);
698 assert_eq!(
699 targets[0].skills_dir,
700 home.path().join(".agents").join("skills")
701 );
702 }
703
704 #[test]
705 fn resolver_agent_maps_to_home() {
706 let home = tempdir().unwrap();
707 let env = test_env(home.path(), home.path(), None);
708 let spec = InstallSpec {
709 agents: vec![Agent::Claude],
710 ..Default::default()
711 };
712 let targets = resolve_targets(&env, &spec).unwrap();
713 assert_eq!(targets.len(), 1);
714 assert_eq!(
715 targets[0].skills_dir,
716 home.path().join(".claude").join("skills")
717 );
718 }
719
720 #[test]
721 fn resolver_agent_local_maps_to_repo() {
722 let home = tempdir().unwrap();
723 let repo = tempdir().unwrap();
724 let env = test_env(home.path(), repo.path(), Some(repo.path().to_path_buf()));
725 let spec = InstallSpec {
726 agents: vec![Agent::Codex],
727 local: true,
728 ..Default::default()
729 };
730 let targets = resolve_targets(&env, &spec).unwrap();
731 assert_eq!(targets.len(), 1);
732 assert_eq!(
733 targets[0].skills_dir,
734 repo.path().join(".codex").join("skills")
735 );
736 }
737
738 #[test]
739 fn resolver_agent_all_expands_and_includes_vendor_neutral() {
740 let home = tempdir().unwrap();
741 let env = test_env(home.path(), home.path(), None);
742 let spec = InstallSpec {
743 agents: vec![Agent::Claude, Agent::Codex],
744 include_vendor_neutral_global: true,
745 ..Default::default()
746 };
747 let targets = resolve_targets(&env, &spec).unwrap();
748 assert_eq!(targets.len(), 3);
749 let paths: Vec<_> = targets.iter().map(|t| t.skills_dir.clone()).collect();
750 assert!(paths.contains(&home.path().join(".claude").join("skills")));
751 assert!(paths.contains(&home.path().join(".codex").join("skills")));
752 assert!(paths.contains(&home.path().join(".agents").join("skills")));
753 }
754
755 #[test]
756 fn detect_installed_agents_filters_by_dir_presence() {
757 let home = tempdir().unwrap();
758 fs::create_dir(home.path().join(".claude")).unwrap();
759 fs::create_dir(home.path().join(".cursor")).unwrap();
760 let detected = detect_installed_agents(home.path());
761 assert!(detected.contains(&Agent::Claude));
762 assert!(detected.contains(&Agent::Cursor));
763 assert!(!detected.contains(&Agent::Codex));
764 assert!(!detected.contains(&Agent::Kimi));
765 }
766
767 #[test]
768 fn install_writes_new_skill_and_skips_unchanged() {
769 let dir = tempdir().unwrap();
770 let target = InstallTarget {
771 label: "test".into(),
772 skills_dir: dir.path().to_path_buf(),
773 };
774 let skill = crate::skill::Skill::parse(
775 "setup",
776 r#"---
777name: setup
778description: test
779category: self-bootstrap
780version: 1
781---
782body
783"#,
784 )
785 .unwrap();
786
787 let body = render_skill_file(&skill);
789 let mut history = HistoricalHashes::default();
790 history.by_skill.insert(
791 "setup".into(),
792 crate::manifest::SkillHistory {
793 current: crate::manifest::HistoricalVersion {
794 version: 1,
795 sha256: sha256_hex(body.as_bytes()),
796 },
797 history: vec![],
798 },
799 );
800
801 let options = InstallOptions::default();
802 let report =
803 install_skills_to_target(&target, std::slice::from_ref(&skill), &history, &options)
804 .unwrap();
805 assert_eq!(
806 report.outcomes.get("setup"),
807 Some(&InstallOutcome::Installed)
808 );
809
810 let report = install_skills_to_target(&target, &[skill], &history, &options).unwrap();
812 assert_eq!(
813 report.outcomes.get("setup"),
814 Some(&InstallOutcome::Unchanged)
815 );
816 }
817
818 #[test]
819 fn install_refuses_user_modification_without_force() {
820 let dir = tempdir().unwrap();
821 let target = InstallTarget {
822 label: "test".into(),
823 skills_dir: dir.path().to_path_buf(),
824 };
825 let skill = crate::skill::Skill::parse(
826 "setup",
827 r#"---
828name: setup
829description: test
830category: self-bootstrap
831version: 1
832---
833body
834"#,
835 )
836 .unwrap();
837
838 let skill_dir = dir.path().join("setup");
840 fs::create_dir_all(&skill_dir).unwrap();
841 fs::write(skill_dir.join("SKILL.md"), b"user version").unwrap();
842
843 let mut history = HistoricalHashes::default();
846 history.by_skill.insert(
847 "setup".into(),
848 crate::manifest::SkillHistory {
849 current: crate::manifest::HistoricalVersion {
850 version: 1,
851 sha256: sha256_hex(b"current shipped body"),
852 },
853 history: vec![],
854 },
855 );
856
857 let report = install_skills_to_target(
858 &target,
859 std::slice::from_ref(&skill),
860 &history,
861 &InstallOptions::default(),
862 )
863 .unwrap();
864 assert_eq!(
865 report.outcomes.get("setup"),
866 Some(&InstallOutcome::SkippedUserModified)
867 );
868
869 let report = install_skills_to_target(
871 &target,
872 &[skill],
873 &history,
874 &InstallOptions {
875 force: true,
876 ..Default::default()
877 },
878 )
879 .unwrap();
880 assert_eq!(
881 report.outcomes.get("setup"),
882 Some(&InstallOutcome::OverwrittenWithForce)
883 );
884 }
885
886 #[test]
887 fn remove_deletes_skill_and_manifest_entry() {
888 let dir = tempdir().unwrap();
889 let target = InstallTarget {
890 label: "test".into(),
891 skills_dir: dir.path().to_path_buf(),
892 };
893 let skill_dir = dir.path().join("setup");
894 fs::create_dir_all(&skill_dir).unwrap();
895 fs::write(skill_dir.join("SKILL.md"), b"hi").unwrap();
896
897 let removed = remove_skills_from_target(&target, &["setup".into()], false, false).unwrap();
898 assert_eq!(removed, vec!["setup".to_string()]);
899 assert!(!skill_dir.exists());
900 }
901
902 #[test]
907 fn agent_parse_and_as_str_round_trip() {
908 for agent in Agent::all() {
909 let parsed = Agent::parse(agent.as_str()).unwrap();
910 assert_eq!(parsed, *agent);
911 assert!(!agent.dir_name().is_empty());
912 assert!(agent.dir_name().starts_with('.'));
913 }
914 assert!(Agent::parse("not-an-agent").is_none());
915 assert_eq!(Agent::all().len(), 4);
919 }
920
921 #[test]
922 fn detect_installed_agents_filters_on_disk() {
923 let home = tempdir().unwrap();
924 assert!(detect_installed_agents(home.path()).is_empty());
926
927 fs::create_dir_all(home.path().join(".claude")).unwrap();
929 fs::create_dir_all(home.path().join(".kimi")).unwrap();
930 let detected = detect_installed_agents(home.path());
931 assert!(detected.contains(&Agent::Claude));
932 assert!(detected.contains(&Agent::Kimi));
933 assert!(!detected.contains(&Agent::Codex));
934 }
935
936 #[test]
941 fn render_skill_file_produces_round_trippable_markdown() {
942 let original = r#"---
943name: setup
944description: Walk the user through initial devboy configuration.
945category: self-bootstrap
946version: 3
947compatibility: devboy-tools >= 0.18
948activation:
949 - "setup devboy"
950tools:
951 - doctor
952---
953
954# setup
955
956Body stays intact across a render round-trip.
957"#;
958 let skill = Skill::parse("setup", original).unwrap();
959 let rendered = render_skill_file(&skill);
960 assert!(rendered.starts_with("---\n"));
961 assert!(rendered.contains("\n---\n"));
962 assert!(rendered.contains("Body stays intact"));
963
964 let reparsed = Skill::parse("setup", &rendered).unwrap();
967 assert_eq!(reparsed.name(), skill.name());
968 assert_eq!(reparsed.version(), skill.version());
969 assert_eq!(reparsed.category(), skill.category());
970 assert_eq!(reparsed.body.trim_end(), skill.body.trim_end());
971 }
972
973 fn plant_skill(target: &InstallTarget, name: &str) {
978 let dir = target.skills_dir.join(name);
979 fs::create_dir_all(&dir).unwrap();
980 fs::write(
981 dir.join("SKILL.md"),
982 format!(
983 "---\nname: {n}\ndescription: x\ncategory: self-bootstrap\nversion: 1\n---\nbody\n",
984 n = name
985 ),
986 )
987 .unwrap();
988 }
989
990 #[test]
991 fn scan_finds_legacy_dirs_and_reports_canonical_presence() {
992 let dir = tempdir().unwrap();
993 let target = InstallTarget {
994 label: "test".into(),
995 skills_dir: dir.path().to_path_buf(),
996 };
997 plant_skill(&target, "devboy-setup");
999 plant_skill(&target, "setup");
1000 plant_skill(&target, "devboy-orphan");
1002 plant_skill(&target, "review-mr");
1004 fs::write(dir.path().join("devboy-readme.txt"), "hi").unwrap();
1007
1008 let scan = scan_legacy_skills_at_target(&target).unwrap();
1009 let names: Vec<&str> = scan.iter().map(|s| s.legacy_name.as_str()).collect();
1010 assert_eq!(names, vec!["devboy-orphan", "devboy-setup"]);
1011 let setup = scan
1012 .iter()
1013 .find(|s| s.legacy_name == "devboy-setup")
1014 .unwrap();
1015 assert!(setup.canonical_present);
1016 assert_eq!(setup.canonical_name, "setup");
1017 let orphan = scan
1018 .iter()
1019 .find(|s| s.legacy_name == "devboy-orphan")
1020 .unwrap();
1021 assert!(!orphan.canonical_present);
1022 }
1023
1024 #[test]
1025 fn scan_returns_empty_when_target_dir_missing() {
1026 let target = InstallTarget {
1027 label: "test".into(),
1028 skills_dir: PathBuf::from("/definitely/does/not/exist"),
1029 };
1030 let scan = scan_legacy_skills_at_target(&target).unwrap();
1031 assert!(scan.is_empty());
1032 }
1033
1034 #[test]
1035 fn migrate_removes_safe_duplicates_only() {
1036 let dir = tempdir().unwrap();
1037 let target = InstallTarget {
1038 label: "test".into(),
1039 skills_dir: dir.path().to_path_buf(),
1040 };
1041 plant_skill(&target, "devboy-setup");
1042 plant_skill(&target, "setup");
1043 plant_skill(&target, "devboy-orphan");
1044
1045 let removed = migrate_legacy_skills_at_target(&target, false).unwrap();
1046 assert_eq!(removed, vec!["devboy-setup".to_string()]);
1047
1048 assert!(!dir.path().join("devboy-setup").exists());
1050 assert!(dir.path().join("setup").exists());
1051 assert!(dir.path().join("devboy-orphan").exists());
1052 }
1053
1054 #[test]
1055 fn migrate_dry_run_does_not_touch_filesystem() {
1056 let dir = tempdir().unwrap();
1057 let target = InstallTarget {
1058 label: "test".into(),
1059 skills_dir: dir.path().to_path_buf(),
1060 };
1061 plant_skill(&target, "devboy-setup");
1062 plant_skill(&target, "setup");
1063
1064 let would_remove = migrate_legacy_skills_at_target(&target, true).unwrap();
1065 assert_eq!(would_remove, vec!["devboy-setup".to_string()]);
1066 assert!(dir.path().join("devboy-setup").exists());
1067 assert!(dir.path().join("setup").exists());
1068 }
1069
1070 #[test]
1071 fn migrate_is_noop_when_nothing_legacy() {
1072 let dir = tempdir().unwrap();
1073 let target = InstallTarget {
1074 label: "test".into(),
1075 skills_dir: dir.path().to_path_buf(),
1076 };
1077 plant_skill(&target, "setup");
1078 plant_skill(&target, "review-mr");
1079 let removed = migrate_legacy_skills_at_target(&target, false).unwrap();
1080 assert!(removed.is_empty());
1081 assert!(dir.path().join("setup").exists());
1082 assert!(dir.path().join("review-mr").exists());
1083 }
1084}