use std::fs;
use std::path::{Path, PathBuf};
use crate::core::agents::{Env, canonical_skills_dir, disabled_skills_dir};
use crate::core::discover::{Skill, read_skill};
use crate::core::path_util::path_contains;
use crate::error::{Result, SkillsError};
#[derive(Debug)]
pub struct InstallOutcome {
pub canonical_path: PathBuf,
pub skipped: bool,
}
const UNSAFE_CHARS: [char; 9] = ['/', '\\', '<', '>', ':', '"', '|', '?', '*'];
const MAX_SLOT_BYTES: usize = 255;
pub fn sanitize_name(name: &str) -> String {
let mut folded = String::new();
let mut prev_dash = false;
for c in name.to_lowercase().chars() {
if c == '-' || c.is_control() || c.is_whitespace() || UNSAFE_CHARS.contains(&c) {
if !prev_dash {
folded.push('-');
prev_dash = true;
}
continue;
}
folded.push(c);
prev_dash = false;
}
let folded = folded.trim_matches(|c: char| c == '.' || c == '-');
let mut slot = truncate_bytes(&windows_safe_slot(folded), MAX_SLOT_BYTES);
if slot.is_empty() {
slot = format!("skill-{}", short_digest(name));
}
slot
}
fn truncate_bytes(name: &str, max: usize) -> String {
let mut out = String::new();
for c in name.chars() {
if out.len() + c.len_utf8() > max {
break;
}
out.push(c);
}
out
}
fn short_digest(name: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in name.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{:08x}", hash as u32)
}
const WINDOWS_RESERVED: [&str; 22] = [
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
"com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];
fn windows_safe_slot(name: &str) -> String {
let trimmed = name.trim_end_matches(['.', ' ']);
let base = trimmed.split('.').next().unwrap_or(trimmed);
if WINDOWS_RESERVED.contains(&base.to_ascii_lowercase().as_str()) {
format!("{trimmed}_")
} else {
trimmed.to_owned()
}
}
pub fn slugify(name: &str) -> String {
name.to_lowercase()
.chars()
.filter_map(|c| match c {
' ' => Some('-'),
'/' => None,
c => Some(c),
})
.collect()
}
fn same_skill_entries(dir: &Path, name: &str) -> Vec<PathBuf> {
let key = sanitize_name(name);
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter(|entry| {
let raw = entry.file_name().to_string_lossy().into_owned();
!raw.starts_with('.') && sanitize_name(&raw) == key && entry.path().is_dir()
})
.map(|entry| entry.path())
.collect()
}
fn holds_skill(dir: &Path, name: &str) -> bool {
dir.join(sanitize_name(name)).symlink_metadata().is_ok()
|| !same_skill_entries(dir, name).is_empty()
}
fn paths_overlap(a: &Path, b: &Path) -> bool {
path_contains(a, b) || path_contains(b, a)
}
pub fn copy_directory(src: &Path, dest: &Path) -> Result<()> {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
let meta = entry.metadata()?;
if meta.is_dir() {
copy_directory(&src_path, &dest_path)?;
} else if meta.is_file() {
fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
pub fn install_skill(skill: &Skill, force: bool, env: &Env) -> Result<InstallOutcome> {
let slug = slugify(&skill.name);
let canonical_base = canonical_skills_dir(env);
let slot = windows_safe_slot(skill.dir.file_name().and_then(|n| n.to_str()).ok_or_else(
|| {
SkillsError::msg(format!(
"Cannot determine an install directory name from \"{}\".",
skill.dir.display()
))
},
)?);
if slot.len() > MAX_SLOT_BYTES {
return Err(SkillsError::msg(format!(
"Install directory name \"{slot}\" exceeds the {MAX_SLOT_BYTES}-byte \
filesystem limit; rename the source directory."
)));
}
let canonical_dir = canonical_base.join(&slot);
if paths_overlap(&skill.dir, &canonical_dir) {
return Ok(InstallOutcome {
canonical_path: canonical_dir,
skipped: true,
});
}
let disabled_base = disabled_skills_dir(env);
let already_installed = holds_skill(&canonical_base, &slug)
|| holds_skill(&disabled_base, &slug)
|| canonical_dir.symlink_metadata().is_ok();
if already_installed && !force {
return Ok(InstallOutcome {
canonical_path: canonical_dir,
skipped: true,
});
}
let staging = canonical_base.join(format!(".incoming-{slot}-{}", unique_suffix()));
let install = (|| -> Result<()> {
fs::create_dir_all(&staging)?;
copy_directory(&skill.dir, &staging)?;
if force && already_installed {
remove_skill(&slug, env);
remove_path(&canonical_dir);
}
fs::rename(&staging, &canonical_dir)?;
Ok(())
})();
if let Err(e) = install {
remove_path(&staging);
return Err(e);
}
Ok(InstallOutcome {
canonical_path: canonical_dir,
skipped: false,
})
}
fn unique_suffix() -> u128 {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
nanos ^ (std::process::id() as u128)
}
fn remove_path(p: &Path) {
let Ok(meta) = p.symlink_metadata() else {
return;
};
if meta.is_symlink() {
let _ = crate::core::link::remove_link(p);
} else if meta.is_dir() {
let _ = fs::remove_dir_all(p);
} else {
let _ = fs::remove_file(p);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ScannedSkill {
pub name: String,
pub display_name: String,
pub description: String,
pub internal: bool,
pub path: PathBuf,
pub installed_at: Option<u64>,
pub dir_name: String,
}
fn scan_skills_in(dir: &Path) -> Vec<ScannedSkill> {
let mut out: Vec<ScannedSkill> = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return out,
};
for entry in entries.flatten() {
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
if !entry.path().is_dir() {
continue;
}
let Some(skill) = read_skill(&entry.path()) else {
continue;
};
out.push(ScannedSkill {
name: slugify(&skill.name),
display_name: skill.name,
description: one_line(&skill.description),
internal: skill.internal,
path: entry.path(),
installed_at: dir_created_secs(&entry.path()),
dir_name: entry.file_name().to_string_lossy().into_owned(),
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn dir_created_secs(dir: &Path) -> Option<u64> {
fs::metadata(dir)
.and_then(|m| m.created())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
}
pub fn scan_installed(env: &Env) -> Vec<ScannedSkill> {
scan_skills_in(&canonical_skills_dir(env))
}
pub fn scan_disabled(env: &Env) -> Vec<ScannedSkill> {
scan_skills_in(&disabled_skills_dir(env))
}
pub fn move_skill(name: &str, to_enabled: bool, env: &Env) -> Result<()> {
let canonical = canonical_skills_dir(env).join(name);
let disabled = disabled_skills_dir(env).join(name);
let (from, to) = if to_enabled {
(&disabled, &canonical)
} else {
(&canonical, &disabled)
};
fs::symlink_metadata(from)?;
let Some(parent) = to.parent() else {
return Ok(());
};
fs::create_dir_all(parent)?;
for stale in same_skill_entries(parent, name) {
remove_path(&stale);
}
if to.symlink_metadata().is_ok() {
remove_path(to);
}
fs::rename(from, to)?;
Ok(())
}
pub fn remove_skill(name: &str, env: &Env) -> bool {
let mut removed = false;
for dir in [canonical_skills_dir(env), disabled_skills_dir(env)] {
let mut slots = same_skill_entries(&dir, name);
slots.push(dir.join(name));
for path in slots {
if path.symlink_metadata().is_err() {
continue;
}
remove_path(&path);
removed |= path.symlink_metadata().is_err();
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::test_utils::{env_at, skill_frontmatter, write_and_parse_skill};
fn write_skill(dir: &Path, name: &str) -> Skill {
write_and_parse_skill(dir, name)
}
fn install_skill(skill: &Skill, env: &Env) -> Result<InstallOutcome> {
super::install_skill(skill, false, env)
}
#[test]
fn install_skill_force_overwrites_existing() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("src");
let skill = write_skill(&src, "pdf");
let r1 = super::install_skill(&skill, false, &env).unwrap();
assert!(!r1.skipped);
let r2 = super::install_skill(&skill, false, &env).unwrap();
assert!(r2.skipped);
let r3 = super::install_skill(&skill, true, &env).unwrap();
assert!(!r3.skipped);
}
#[test]
fn sanitize_name_basic() {
assert_eq!(sanitize_name("PDF Master"), "pdf-master");
assert_eq!(
sanitize_name("Git Review Before Commit"),
"git-review-before-commit"
);
assert_eq!(sanitize_name("../evil"), "evil");
assert_eq!(sanitize_name("A.B_c"), "a.b_c");
assert_eq!(sanitize_name("-leading-trailing-"), "leading-trailing");
}
#[test]
fn sanitize_name_keeps_non_ascii_and_legal_punctuation() {
assert_eq!(sanitize_name("中文技能"), "中文技能");
assert_ne!(sanitize_name("中文技能"), sanitize_name("另一技能"));
assert_eq!(sanitize_name("C#"), "c#");
assert_eq!(sanitize_name("C++"), "c++");
assert_ne!(sanitize_name("C#"), sanitize_name("C++"));
assert_eq!(sanitize_name("a/b\\c"), "a-b-c");
assert_eq!(sanitize_name("a:b*c?d"), "a-b-c-d");
}
#[test]
fn sanitize_name_falls_back_to_a_deterministic_digest() {
let blank = sanitize_name(" ");
assert!(blank.starts_with("skill-"), "got {blank}");
assert_eq!(blank, sanitize_name(" "));
assert_ne!(blank, sanitize_name("***"));
assert_ne!(blank, sanitize_name(""));
}
#[test]
fn sanitize_name_avoids_windows_reserved_device_names() {
assert_eq!(sanitize_name("AUX"), "aux_");
assert_eq!(sanitize_name("con"), "con_");
assert_eq!(sanitize_name("aux.txt"), "aux.txt_");
assert_eq!(sanitize_name("com10"), "com10");
assert_eq!(sanitize_name("consult"), "consult");
}
#[test]
fn install_slot_avoids_windows_reserved_names() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("aux");
let skill = write_skill(&src, "aux");
install_skill(&skill, &env).unwrap();
assert!(tmp.path().join(".agents/skills/aux_/SKILL.md").exists());
}
#[test]
fn sanitize_name_truncates_long_non_ascii_names_by_bytes() {
let long: String = "技".repeat(300);
let slot = sanitize_name(&long);
assert!(slot.len() <= MAX_SLOT_BYTES, "{} bytes", slot.len());
assert_eq!(slot.chars().count(), 85); assert!(slot.chars().all(|c| c == '技'));
}
#[test]
fn path_safe_resolves_not_yet_created_targets_consistently() {
let tmp = tempfile::TempDir::new().unwrap();
let base = tmp.path().join("skills");
std::fs::create_dir_all(&base).unwrap();
let existing = base.join("alpha");
std::fs::create_dir_all(&existing).unwrap();
assert!(path_contains(&base, &existing));
assert!(path_contains(&base, &base.join("beta")));
assert!(path_contains(
&tmp.path().join("a/b"),
&tmp.path().join("a/b/c")
));
assert!(!path_contains(&base, &tmp.path().join("elsewhere")));
}
#[test]
fn install_skill_writes_canonical_only() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("pdf");
let skill = write_skill(&src, "pdf");
let r = install_skill(&skill, &env).unwrap();
assert!(!r.skipped);
assert!(tmp.path().join(".agents/skills/pdf/SKILL.md").exists());
assert!(!tmp.path().join(".claude").exists());
assert!(!tmp.path().join(".windsurf").exists());
}
#[test]
fn install_skill_skips_when_source_overlaps_canonical() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join(".agents/skills/pdf");
let skill = write_skill(&src, "pdf");
let r = install_skill(&skill, &env).unwrap();
assert!(r.skipped);
assert!(src.join("SKILL.md").exists());
}
#[test]
fn install_skill_skips_when_already_installed() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let canonical_base = tmp.path().join(".agents/skills");
let src = tmp.path().join("pdf");
let skill = write_skill(&src, "pdf");
assert!(install_skill(&skill, &env).is_ok());
fs::write(src.join("SKILL.md"), "v2").unwrap();
let r = install_skill(&write_skill(&src, "pdf"), &env).unwrap();
assert!(r.skipped);
assert_eq!(
fs::read_to_string(canonical_base.join("pdf/SKILL.md")).unwrap(),
skill_frontmatter("pdf")
);
let leftovers: Vec<_> = fs::read_dir(&canonical_base)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with(".incoming-"))
.collect();
assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
}
#[test]
fn install_skill_skips_when_disabled() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("pdf");
let skill = write_skill(&src, "pdf");
assert!(install_skill(&skill, &env).is_ok());
move_skill("pdf", false, &env).unwrap();
let r = install_skill(&skill, &env).unwrap();
assert!(r.skipped);
assert!(!tmp.path().join(".agents/skills/pdf").exists());
assert!(
tmp.path()
.join(".agents/disabled-skills/pdf/SKILL.md")
.exists()
);
}
#[cfg(unix)]
#[test]
fn install_skill_failure_leaves_no_trace() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let canonical_base = tmp.path().join(".agents/skills");
let src = tmp.path().join("pdf");
let skill = write_skill(&src, "pdf");
let bad = src.join("bad.txt");
fs::write(&bad, "x").unwrap();
fs::set_permissions(&bad, fs::Permissions::from_mode(0o000)).unwrap();
assert!(
install_skill(&skill, &env).is_err(),
"copy should have failed"
);
assert!(!canonical_base.join("pdf").exists());
let leftovers: Vec<_> = fs::read_dir(&canonical_base)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with(".incoming-"))
.collect();
assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
}
#[test]
fn move_skill_keeps_unnormalized_dir_name() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let dir = tmp.path().join(".agents/skills/PDF Master");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), "x").unwrap();
move_skill("PDF Master", false, &env).unwrap();
assert!(!dir.exists());
let parked = tmp
.path()
.join(".agents/disabled-skills/PDF Master/SKILL.md");
assert!(parked.exists());
move_skill("PDF Master", true, &env).unwrap();
assert!(dir.join("SKILL.md").exists());
assert!(
!tmp.path()
.join(".agents/disabled-skills/PDF Master")
.exists()
);
}
#[test]
fn move_skill_discards_the_copy_occupying_the_target_slot() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let canonical = tmp.path().join(".agents/skills/pdf");
let parked = tmp.path().join(".agents/disabled-skills/pdf");
for (dir, body) in [(&parked, "parked"), (&canonical, "reinstalled")] {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join("SKILL.md"), body).unwrap();
}
move_skill("pdf", true, &env).unwrap();
assert_eq!(
fs::read_to_string(canonical.join("SKILL.md")).unwrap(),
"parked"
);
assert!(!parked.exists());
}
#[test]
fn move_skill_discards_a_differently_named_copy_of_the_same_skill() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let adopted = tmp.path().join(".agents/skills/PDF Master");
let parked = tmp.path().join(".agents/disabled-skills/pdf-master");
for (dir, body) in [(&adopted, "adopted"), (&parked, "parked")] {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join("SKILL.md"), body).unwrap();
}
move_skill("pdf-master", true, &env).unwrap();
assert!(!adopted.exists(), "the stale canonical copy must be gone");
assert_eq!(
fs::read_to_string(tmp.path().join(".agents/skills/pdf-master/SKILL.md")).unwrap(),
"parked"
);
assert!(!parked.exists());
}
#[test]
fn move_skill_keeps_the_target_when_the_source_is_gone() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let canonical = tmp.path().join(".agents/skills/pdf");
fs::create_dir_all(&canonical).unwrap();
fs::write(canonical.join("SKILL.md"), "enabled").unwrap();
assert!(move_skill("pdf", true, &env).is_err());
assert_eq!(
fs::read_to_string(canonical.join("SKILL.md")).unwrap(),
"enabled"
);
}
#[test]
fn install_skill_skips_a_disabled_copy_under_an_unnormalized_name() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let parked = tmp.path().join(".agents/disabled-skills/PDF Master");
fs::create_dir_all(&parked).unwrap();
fs::write(parked.join("SKILL.md"), "parked").unwrap();
let src = tmp.path().join("PDF Master");
let r = install_skill(&write_skill(&src, "PDF Master"), &env).unwrap();
assert!(r.skipped);
assert!(!tmp.path().join(".agents/skills/pdf-master").exists());
}
#[test]
fn install_skill_keeps_non_ascii_names_on_separate_slots() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let first = write_skill(&tmp.path().join("中文技能"), "中文技能");
let second = write_skill(&tmp.path().join("另一技能"), "另一技能");
install_skill(&first, &env).unwrap();
let r = install_skill(&second, &env).unwrap();
assert!(!r.skipped, "the second Chinese-named skill must install");
let base = tmp.path().join(".agents/skills");
assert!(base.join("中文技能/SKILL.md").exists());
assert!(base.join("另一技能/SKILL.md").exists());
}
#[test]
fn remove_skill_deletes_both_copies_under_either_name() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let canonical = tmp.path().join(".agents/skills/pdf-master");
let parked = tmp.path().join(".agents/disabled-skills/PDF Master");
for dir in [&canonical, &parked] {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join("SKILL.md"), "x").unwrap();
}
assert!(remove_skill("pdf-master", &env));
assert!(!canonical.exists());
assert!(!parked.exists());
assert!(!remove_skill("pdf-master", &env));
}
#[test]
fn copy_directory_copies_everything_verbatim() {
let tmp = tempfile::TempDir::new().unwrap();
let src = tmp.path().join("src");
fs::create_dir_all(src.join(".git")).unwrap();
fs::create_dir_all(src.join("__pycache__")).unwrap();
fs::write(src.join("SKILL.md"), "x").unwrap();
fs::write(src.join("metadata.json"), "x").unwrap();
fs::write(src.join(".git").join("HEAD"), "x").unwrap();
let dest = tmp.path().join("dest");
copy_directory(&src, &dest).unwrap();
assert!(dest.join("SKILL.md").exists());
assert!(dest.join("metadata.json").exists());
assert!(dest.join(".git/HEAD").exists());
assert!(dest.join("__pycache__").exists());
}
#[test]
fn scan_installed_finds_canonical() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("pdf");
let skill = write_skill(&src, "pdf");
install_skill(&skill, &env).unwrap();
let installed = scan_installed(&env);
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].name, "pdf");
assert_eq!(installed[0].path, tmp.path().join(".agents/skills/pdf"));
}
#[test]
fn scan_reports_the_frontmatter_name_and_the_real_path() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let dir = tmp.path().join(".agents/skills/PDF Master");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
"---\nname: pdf-master\ndescription: d\n---\nbody",
)
.unwrap();
let installed = scan_installed(&env);
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].name, "pdf-master");
assert_eq!(installed[0].dir_name, "PDF Master");
assert_eq!(installed[0].path, dir);
let dir = tmp.path().join(".agents/skills/acrobat");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), "---\ndescription: d\n---\nbody").unwrap();
fs::create_dir_all(tmp.path().join(".agents/skills/empty")).unwrap();
let installed = scan_installed(&env);
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].name, "pdf-master");
}
#[test]
fn move_skill_disables_then_enables_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("pdf");
let skill = write_skill(&src, "pdf");
install_skill(&skill, &env).unwrap();
move_skill("pdf", false, &env).unwrap();
assert!(!tmp.path().join(".agents/skills/pdf").exists());
assert!(
tmp.path()
.join(".agents/disabled-skills/pdf/SKILL.md")
.exists()
);
assert!(scan_installed(&env).is_empty());
let disabled = scan_disabled(&env);
assert_eq!(disabled.len(), 1);
assert_eq!(disabled[0].name, "pdf");
move_skill("pdf", true, &env).unwrap();
assert!(tmp.path().join(".agents/skills/pdf/SKILL.md").exists());
assert!(scan_disabled(&env).is_empty());
}
#[test]
fn scan_disabled_reports_hidden_skills() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("pdf");
let skill = write_skill(&src, "pdf");
install_skill(&skill, &env).unwrap();
move_skill("pdf", false, &env).unwrap();
let disabled = scan_disabled(&env);
assert_eq!(disabled.len(), 1);
assert_eq!(disabled[0].name, "pdf");
}
}