use std::fs;
use std::path::{Path, PathBuf};
use crate::core::agents::{Env, canonical_skills_dir, disabled_skills_dir, universal_agents};
use crate::core::discover::{Skill, parse_skill_md};
use crate::error::Result;
#[derive(Debug)]
pub struct InstallResult {
pub success: bool,
pub canonical_path: PathBuf,
pub skipped: bool,
pub error: Option<String>,
}
pub fn sanitize_name(name: &str) -> String {
let mut s: String = name
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' {
c
} else {
'-'
}
})
.collect();
let folded = {
let mut out = String::new();
let mut prev_dash = false;
for c in s.chars() {
if c == '-' {
if !prev_dash {
out.push(c);
}
prev_dash = true;
} else {
out.push(c);
prev_dash = false;
}
}
out
};
s = folded;
let trimmed = s.trim_matches(|c: char| c == '.' || c == '-').to_string();
let mut result: String = trimmed.chars().take(255).collect();
if result.is_empty() {
result = "unnamed-skill".to_string();
}
result
}
pub fn get_canonical_path(name: &str, global: bool, env: &Env) -> PathBuf {
canonical_skills_dir(global, env).join(sanitize_name(name))
}
fn canonicalize_lenient(p: &Path) -> PathBuf {
let mut tail = PathBuf::new();
let mut cur = p.to_path_buf();
loop {
if let Ok(resolved) = cur.canonicalize() {
return resolved.join(&tail);
}
match (cur.parent(), cur.file_name()) {
(Some(parent), Some(name)) => {
tail = PathBuf::from(name).join(&tail);
cur = parent.to_path_buf();
}
_ => return p.to_path_buf(),
}
}
}
fn path_safe(base: &Path, target: &Path) -> bool {
let base_abs = canonicalize_lenient(base);
let target_abs = canonicalize_lenient(target);
target_abs == base_abs || target_abs.starts_with(&base_abs)
}
fn paths_overlap(a: &Path, b: &Path) -> bool {
path_safe(a, b) || path_safe(b, a)
}
fn clean_and_create(dir: &Path) -> Result<()> {
let _ = fs::remove_dir_all(dir);
fs::create_dir_all(dir)?;
Ok(())
}
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 name = entry.file_name().to_string_lossy().into_owned();
let src_path = entry.path();
let dest_path = dest.join(&name);
let meta = entry.metadata()?;
if meta.is_dir() {
if name == ".git" || name == "__pycache__" || name == "__pypackages__" {
continue;
}
copy_directory(&src_path, &dest_path)?;
} else if meta.is_file() {
if name == "metadata.json" {
continue;
}
fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
pub fn install_skill(skill: &Skill, global: bool, env: &Env) -> InstallResult {
let skill_name = sanitize_name(&skill.name);
let canonical_base = canonical_skills_dir(global, env);
let canonical_dir = canonical_base.join(&skill_name);
if !path_safe(&canonical_base, &canonical_dir) {
return InstallResult {
success: false,
canonical_path: canonical_dir,
skipped: false,
error: Some("Invalid skill name: potential path traversal detected".to_string()),
};
}
if paths_overlap(&skill.dir, &canonical_dir) {
return InstallResult {
success: true,
canonical_path: canonical_dir,
skipped: true,
error: None,
};
}
if let Err(e) =
clean_and_create(&canonical_dir).and_then(|_| copy_directory(&skill.dir, &canonical_dir))
{
return InstallResult {
success: false,
canonical_path: canonical_dir,
skipped: false,
error: Some(e.to_string()),
};
}
InstallResult {
success: true,
canonical_path: canonical_dir,
skipped: false,
error: None,
}
}
#[derive(Debug)]
pub struct InstalledSkill {
pub name: String,
pub canonical_path: PathBuf,
pub scope: String,
pub agents: Vec<String>,
}
pub fn list_installed_skills(
env: &Env,
global: bool,
agent_filter: &[String],
) -> Vec<InstalledSkill> {
let scope = if global { "global" } else { "project" };
let canonical = canonical_skills_dir(global, env);
let mut out: Vec<InstalledSkill> = Vec::new();
let entries = match fs::read_dir(&canonical) {
Ok(e) => e,
Err(_) => return out,
};
for entry in entries.flatten() {
let skill_dir = entry.path();
let skill_md = skill_dir.join("SKILL.md");
if !skill_md.is_file() {
continue;
}
let Some(skill) = parse_skill_md(&skill_md) else {
continue;
};
let mut agents: Vec<String> = Vec::new();
for agent in universal_agents() {
if !agent_filter.is_empty() && !agent_filter.iter().any(|a| a == &agent.name) {
continue;
}
agents.push(agent.name.to_string());
}
for agent in crate::core::agents::AGENTS
.iter()
.filter(|a| !a.is_universal())
{
if !agent_filter.is_empty() && !agent_filter.iter().any(|a| a == &agent.name) {
continue;
}
let Some(base) = crate::core::agents::agent_skills_dir(agent, global, env) else {
continue;
};
let candidate = base.join(sanitize_name(&skill.name));
if candidate.exists() || base.join(entry.file_name()).exists() {
agents.push(agent.name.to_string());
}
}
out.push(InstalledSkill {
name: skill.name,
canonical_path: skill_dir,
scope: scope.to_string(),
agents,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn scan_installed(env: &Env, global: bool) -> Vec<String> {
let canonical = canonical_skills_dir(global, env);
let mut v: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&canonical) {
for entry in entries.flatten() {
if entry.path().is_dir() {
v.push(entry.file_name().to_string_lossy().into_owned());
}
}
}
v.sort();
v
}
pub fn scan_disabled(env: &Env, global: bool) -> Vec<String> {
let disabled = disabled_skills_dir(global, env);
let mut v: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&disabled) {
for entry in entries.flatten() {
if entry.path().is_dir() {
v.push(entry.file_name().to_string_lossy().into_owned());
}
}
}
v.sort();
v
}
pub fn list_disabled_skills(env: &Env, global: bool) -> Vec<InstalledSkill> {
let scope = if global { "global" } else { "project" };
let disabled = disabled_skills_dir(global, env);
let mut out: Vec<InstalledSkill> = Vec::new();
let entries = match fs::read_dir(&disabled) {
Ok(e) => e,
Err(_) => return out,
};
for entry in entries.flatten() {
let skill_dir = entry.path();
let skill_md = skill_dir.join("SKILL.md");
if !skill_md.is_file() {
continue;
}
let Some(skill) = parse_skill_md(&skill_md) else {
continue;
};
out.push(InstalledSkill {
name: skill.name,
canonical_path: skill_dir,
scope: scope.to_string(),
agents: Vec::new(),
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn move_skill(name: &str, global: bool, to_enabled: bool, env: &Env) -> Result<()> {
let canonical = canonical_skills_dir(global, env).join(sanitize_name(name));
let disabled = disabled_skills_dir(global, env).join(sanitize_name(name));
let (from, to) = if to_enabled {
(&disabled, &canonical)
} else {
(&canonical, &disabled)
};
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(from, to)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::link::{LinkOutcome, link_agent};
use crate::core::test_utils::{env_at, write_and_parse_skill};
fn write_skill(dir: &Path, name: &str) -> Skill {
write_and_parse_skill(dir, name)
}
#[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(" "), "unnamed-skill");
assert_eq!(sanitize_name("A.B_c"), "a.b_c");
assert_eq!(sanitize_name("-leading-trailing-"), "leading-trailing");
}
#[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_safe(&base, &existing));
assert!(path_safe(&base, &base.join("beta")));
assert!(path_safe(
&tmp.path().join("a/b"),
&tmp.path().join("a/b/c")
));
assert!(!path_safe(&base, &tmp.path().join("elsewhere")));
}
#[test]
fn canonical_dir_project_and_global() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
assert_eq!(
canonical_skills_dir(false, &env),
tmp.path().join(".agents/skills")
);
assert_eq!(
canonical_skills_dir(true, &env),
tmp.path().join(".agents/skills")
);
}
#[test]
fn install_skill_writes_canonical_only() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("src-skill");
let skill = write_skill(&src, "pdf");
let r = install_skill(&skill, false, &env);
assert!(r.success, "err={:?}", r.error);
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, false, &env);
assert!(r.success);
assert!(r.skipped);
assert!(src.join("SKILL.md").exists());
}
#[test]
fn copy_directory_excludes_ignored() {
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").exists());
assert!(!dest.join("__pycache__").exists());
}
#[test]
fn list_installed_skills_finds_canonical() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("src-skill");
let skill = write_skill(&src, "pdf");
install_skill(&skill, false, &env);
let installed = list_installed_skills(&env, false, &[]);
assert_eq!(installed.len(), 1);
assert_eq!(installed[0].name, "pdf");
assert_eq!(installed[0].scope, "project");
}
#[test]
fn list_installed_skills_reports_dir_linked_agents() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("src-skill");
let skill = write_skill(&src, "pdf");
install_skill(&skill, false, &env);
fs::create_dir_all(tmp.path().join(".windsurf")).unwrap();
assert!(matches!(
link_agent(
crate::core::agents::get_agent("windsurf").unwrap(),
false,
&env,
false
),
LinkOutcome::Linked { .. }
));
let installed = list_installed_skills(&env, false, &[]);
assert_eq!(installed.len(), 1);
assert!(installed[0].agents.contains(&"windsurf".to_string()));
}
#[test]
fn scan_installed_lists_canonical_only() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("src-skill");
let skill = write_skill(&src, "pdf");
install_skill(&skill, false, &env);
let names = scan_installed(&env, false);
assert_eq!(names, vec!["pdf".to_string()]);
}
#[test]
fn move_skill_disables_then_enables_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("src-skill");
let skill = write_skill(&src, "pdf");
install_skill(&skill, false, &env);
move_skill("pdf", false, 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, false).is_empty());
assert_eq!(scan_disabled(&env, false), vec!["pdf".to_string()]);
move_skill("pdf", false, true, &env).unwrap();
assert!(tmp.path().join(".agents/skills/pdf/SKILL.md").exists());
assert!(scan_disabled(&env, false).is_empty());
}
#[test]
fn list_disabled_skills_reports_hidden_skills() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("src-skill");
let skill = write_skill(&src, "pdf");
install_skill(&skill, false, &env);
move_skill("pdf", false, false, &env).unwrap();
let disabled = list_disabled_skills(&env, false);
assert_eq!(disabled.len(), 1);
assert_eq!(disabled[0].name, "pdf");
assert!(disabled[0].agents.is_empty());
assert_eq!(disabled[0].scope, "project");
}
}