use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::SkillError;
use crate::loader::{load_skill_meta, validate_path_within};
use crate::trust::{SkillSource, compute_skill_hash};
pub struct SkillManager {
managed_dir: PathBuf,
}
#[derive(Debug)]
pub struct InstallResult {
pub name: String,
pub blake3_hash: String,
pub source: SkillSource,
}
#[derive(Debug)]
pub struct InstalledSkill {
pub name: String,
pub description: String,
pub skill_dir: PathBuf,
pub requires_secrets: Vec<String>,
}
#[derive(Debug)]
pub struct VerifyResult {
pub name: String,
pub current_hash: String,
pub stored_hash_matches: Option<bool>,
}
impl SkillManager {
#[must_use]
pub fn new(managed_dir: PathBuf) -> Self {
Self { managed_dir }
}
pub fn install_from_url(&self, url: &str) -> Result<InstallResult, SkillError> {
if !(url.starts_with("https://") || url.starts_with("http://") || url.starts_with("git@")) {
return Err(SkillError::GitCloneFailed(format!(
"unsupported URL scheme: {url}"
)));
}
if url.chars().any(char::is_whitespace) {
return Err(SkillError::GitCloneFailed(
"URL must not contain whitespace".to_owned(),
));
}
std::fs::create_dir_all(&self.managed_dir).map_err(SkillError::Io)?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp_name = format!("__tmp_{}_{}", nanos, std::process::id());
let tmp_dir = self.managed_dir.join(&tmp_name);
let status = Command::new("git")
.args(["clone", "--depth=1", url, tmp_dir.to_str().unwrap_or("")])
.status()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
SkillError::GitCloneFailed(
"git binary not found on PATH; install git to use skill install from URL"
.to_owned(),
)
} else {
SkillError::GitCloneFailed(format!("failed to run git: {e}"))
}
})?;
if !status.success() {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(SkillError::GitCloneFailed(format!(
"git clone failed with exit code: {}",
status.code().unwrap_or(-1)
)));
}
let skill_md = tmp_dir.join("SKILL.md");
let meta = load_skill_meta(&skill_md).inspect_err(|_| {
let _ = std::fs::remove_dir_all(&tmp_dir);
})?;
let name = meta.name.clone();
let dest_dir = self.managed_dir.join(&name);
if dest_dir.exists() {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(SkillError::AlreadyExists(name));
}
std::fs::rename(&tmp_dir, &dest_dir).map_err(|e| {
let _ = std::fs::remove_dir_all(&tmp_dir);
SkillError::Io(e)
})?;
validate_path_within(&dest_dir, &self.managed_dir)?;
strip_bundled_markers(&dest_dir).map_err(|e| {
let _ = std::fs::remove_dir_all(&dest_dir);
SkillError::Io(e)
})?;
let hash = compute_skill_hash(&dest_dir)?;
Ok(InstallResult {
name,
blake3_hash: hash,
source: SkillSource::Hub {
url: url.to_owned(),
},
})
}
pub fn install_from_path(&self, source: &Path) -> Result<InstallResult, SkillError> {
std::fs::create_dir_all(&self.managed_dir).map_err(SkillError::Io)?;
let skill_md = source.join("SKILL.md");
let meta = load_skill_meta(&skill_md)?;
let name = meta.name.clone();
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(SkillError::Invalid(format!("invalid skill name: {name}")));
}
let dest_dir = self.managed_dir.join(&name);
if dest_dir.exists() {
return Err(SkillError::AlreadyExists(name));
}
copy_dir_recursive(source, &dest_dir).map_err(|e| {
SkillError::CopyFailed(format!("failed to copy {}: {e}", source.display()))
})?;
validate_path_within(&dest_dir, &self.managed_dir)?;
strip_bundled_markers(&dest_dir).map_err(|e| {
let _ = std::fs::remove_dir_all(&dest_dir);
SkillError::Io(e)
})?;
let hash = compute_skill_hash(&dest_dir)?;
Ok(InstallResult {
name: name.clone(),
blake3_hash: hash,
source: SkillSource::File {
path: source.to_owned(),
},
})
}
pub fn remove(&self, name: &str) -> Result<(), SkillError> {
let skill_dir = self.managed_dir.join(name);
if !skill_dir.exists() {
return Err(SkillError::NotFound(name.to_owned()));
}
validate_path_within(&skill_dir, &self.managed_dir)?;
std::fs::remove_dir_all(&skill_dir).map_err(SkillError::Io)?;
Ok(())
}
pub fn list_installed(&self) -> Result<Vec<InstalledSkill>, SkillError> {
if !self.managed_dir.exists() {
return Ok(Vec::new());
}
let canonical_base = self.managed_dir.canonicalize().map_err(|e| {
SkillError::Other(format!(
"failed to canonicalize managed dir {}: {e}",
self.managed_dir.display()
))
})?;
let mut result = Vec::new();
let entries = std::fs::read_dir(&self.managed_dir).map_err(SkillError::Io)?;
for entry in entries.flatten() {
let skill_dir = entry.path();
let skill_md = skill_dir.join("SKILL.md");
if !skill_md.is_file() {
continue;
}
if validate_path_within(&skill_md, &canonical_base).is_err() {
continue;
}
match load_skill_meta(&skill_md) {
Ok(meta) => result.push(InstalledSkill {
name: meta.name,
description: meta.description,
skill_dir,
requires_secrets: meta.requires_secrets,
}),
Err(e) => tracing::warn!("skipping {}: {e:#}", skill_md.display()),
}
}
Ok(result)
}
pub fn verify(&self, name: &str) -> Result<String, SkillError> {
let skill_dir = self.managed_dir.join(name);
if !skill_dir.exists() {
return Err(SkillError::NotFound(name.to_owned()));
}
validate_path_within(&skill_dir, &self.managed_dir)?;
compute_skill_hash(&skill_dir).map_err(SkillError::Io)
}
pub fn verify_all(
&self,
stored_hashes: &std::collections::HashMap<String, String>,
) -> Result<Vec<VerifyResult>, SkillError> {
let installed = self.list_installed()?;
let mut results = Vec::new();
for skill in installed {
match compute_skill_hash(&skill.skill_dir) {
Ok(current_hash) => {
let stored_hash_matches = stored_hashes
.get(&skill.name)
.map(|stored| stored == ¤t_hash);
results.push(VerifyResult {
name: skill.name,
current_hash,
stored_hash_matches,
});
}
Err(e) => {
tracing::warn!("failed to hash skill '{}': {e:#}", skill.name);
}
}
}
Ok(results)
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_symlink() {
tracing::warn!(
path = %src_path.display(),
"skipping symlink in skill source directory"
);
continue;
}
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn strip_bundled_markers(dir: &Path) -> std::io::Result<u64> {
strip_bundled_markers_recursive(dir)
}
fn strip_bundled_markers_recursive(dir: &Path) -> std::io::Result<u64> {
let mut removed = 0u64;
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
removed += strip_bundled_markers_recursive(&path)?;
} else if file_type.is_file() && entry.file_name() == ".bundled" {
tracing::warn!(
path = %path.display(),
"stripped forged .bundled marker from installed skill package"
);
std::fs::remove_file(&path)?;
removed += 1;
}
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_skill_dir(dir: &Path, name: &str) {
let skill_dir = dir.join(name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: A test skill.\n---\n# Body\nHello"),
)
.unwrap();
}
#[test]
fn install_from_url_rejects_bad_scheme() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr.install_from_url("ftp://example.com/skill").unwrap_err();
assert!(matches!(err, SkillError::GitCloneFailed(_)));
assert!(format!("{err}").contains("unsupported URL scheme"));
}
#[test]
fn install_from_url_rejects_whitespace() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr
.install_from_url("https://example.com/skill name")
.unwrap_err();
assert!(matches!(err, SkillError::GitCloneFailed(_)));
assert!(format!("{err}").contains("whitespace"));
}
#[test]
fn install_from_path_success() {
let src = tempfile::tempdir().unwrap();
let managed = tempfile::tempdir().unwrap();
make_skill_dir(src.path(), "my-skill");
let mgr = SkillManager::new(managed.path().to_path_buf());
let result = mgr.install_from_path(&src.path().join("my-skill")).unwrap();
assert_eq!(result.name, "my-skill");
assert_eq!(result.blake3_hash.len(), 64);
assert!(matches!(result.source, SkillSource::File { .. }));
assert!(managed.path().join("my-skill").join("SKILL.md").exists());
}
#[test]
fn install_from_path_already_exists() {
let src = tempfile::tempdir().unwrap();
let managed = tempfile::tempdir().unwrap();
make_skill_dir(src.path(), "dup-skill");
make_skill_dir(managed.path(), "dup-skill");
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr
.install_from_path(&src.path().join("dup-skill"))
.unwrap_err();
assert!(matches!(err, SkillError::AlreadyExists(_)));
}
#[test]
fn install_from_path_invalid_skill() {
let src = tempfile::tempdir().unwrap();
let managed = tempfile::tempdir().unwrap();
let bad_dir = src.path().join("bad-skill");
std::fs::create_dir_all(&bad_dir).unwrap();
std::fs::write(bad_dir.join("SKILL.md"), "no frontmatter").unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr.install_from_path(&bad_dir).unwrap_err();
assert!(
format!("{err}").contains("missing frontmatter")
|| format!("{err}").contains("invalid")
);
}
#[test]
fn remove_skill_success() {
let managed = tempfile::tempdir().unwrap();
make_skill_dir(managed.path(), "to-remove");
let mgr = SkillManager::new(managed.path().to_path_buf());
mgr.remove("to-remove").unwrap();
assert!(!managed.path().join("to-remove").exists());
}
#[test]
fn remove_skill_not_found() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr.remove("nonexistent").unwrap_err();
assert!(matches!(err, SkillError::NotFound(_)));
}
#[test]
fn list_installed_empty_dir() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let list = mgr.list_installed().unwrap();
assert!(list.is_empty());
}
#[test]
fn list_installed_nonexistent_dir() {
let mgr = SkillManager::new(PathBuf::from("/nonexistent/managed/dir"));
let list = mgr.list_installed().unwrap();
assert!(list.is_empty());
}
#[test]
fn list_installed_with_skills() {
let managed = tempfile::tempdir().unwrap();
make_skill_dir(managed.path(), "skill-a");
make_skill_dir(managed.path(), "skill-b");
let mgr = SkillManager::new(managed.path().to_path_buf());
let mut list = mgr.list_installed().unwrap();
list.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "skill-a");
assert_eq!(list[1].name, "skill-b");
}
#[test]
fn verify_skill_success() {
let managed = tempfile::tempdir().unwrap();
make_skill_dir(managed.path(), "verify-me");
let mgr = SkillManager::new(managed.path().to_path_buf());
let hash = mgr.verify("verify-me").unwrap();
assert_eq!(hash.len(), 64);
}
#[test]
fn verify_skill_not_found() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr.verify("nope").unwrap_err();
assert!(matches!(err, SkillError::NotFound(_)));
}
#[test]
fn verify_all_with_matching_hash() {
let managed = tempfile::tempdir().unwrap();
make_skill_dir(managed.path(), "hash-skill");
let mgr = SkillManager::new(managed.path().to_path_buf());
let hash = mgr.verify("hash-skill").unwrap();
let mut stored = std::collections::HashMap::new();
stored.insert("hash-skill".to_owned(), hash);
let results = mgr.verify_all(&stored).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].stored_hash_matches, Some(true));
}
#[test]
fn verify_all_with_mismatched_hash() {
let managed = tempfile::tempdir().unwrap();
make_skill_dir(managed.path(), "tampered-skill");
let mgr = SkillManager::new(managed.path().to_path_buf());
let mut stored = std::collections::HashMap::new();
stored.insert("tampered-skill".to_owned(), "wrong_hash".to_owned());
let results = mgr.verify_all(&stored).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].stored_hash_matches, Some(false));
}
#[test]
fn verify_all_no_stored_hash() {
let managed = tempfile::tempdir().unwrap();
make_skill_dir(managed.path(), "unknown-skill");
let mgr = SkillManager::new(managed.path().to_path_buf());
let results = mgr.verify_all(&std::collections::HashMap::new()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].stored_hash_matches, None);
}
#[test]
fn install_from_url_accepts_git_at_scheme() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr
.install_from_url("git@github.com:example/skill.git")
.unwrap_err();
let msg = format!("{err}");
assert!(
!msg.contains("unsupported URL scheme"),
"git@ scheme should pass URL check: {msg}"
);
assert!(matches!(err, SkillError::GitCloneFailed(_)));
}
#[test]
fn install_from_url_rejects_empty_string() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr.install_from_url("").unwrap_err();
assert!(matches!(err, SkillError::GitCloneFailed(_)));
assert!(format!("{err}").contains("unsupported URL scheme"));
}
#[test]
fn install_from_path_missing_source_dir() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr
.install_from_path(Path::new("/nonexistent/skill/path"))
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("No such file")
|| msg.contains("cannot find")
|| msg.contains("invalid")
|| msg.contains("missing"),
"unexpected error: {msg}"
);
}
#[test]
fn install_from_path_missing_skill_md() {
let src = tempfile::tempdir().unwrap();
let managed = tempfile::tempdir().unwrap();
std::fs::create_dir_all(src.path().join("skill-no-md")).unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr
.install_from_path(&src.path().join("skill-no-md"))
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("No such file")
|| msg.contains("cannot find")
|| msg.contains("invalid")
|| msg.contains("missing"),
"unexpected error: {msg}"
);
}
#[test]
fn list_installed_skips_dirs_without_skill_md() {
let managed = tempfile::tempdir().unwrap();
make_skill_dir(managed.path(), "valid-skill");
std::fs::create_dir_all(managed.path().join("no-md-dir")).unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let list = mgr.list_installed().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "valid-skill");
}
#[test]
fn verify_all_empty_dir_returns_empty() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let results = mgr.verify_all(&std::collections::HashMap::new()).unwrap();
assert!(results.is_empty());
}
#[test]
fn verify_all_multiple_skills() {
let managed = tempfile::tempdir().unwrap();
make_skill_dir(managed.path(), "skill-one");
make_skill_dir(managed.path(), "skill-two");
let mgr = SkillManager::new(managed.path().to_path_buf());
let hash_one = mgr.verify("skill-one").unwrap();
let mut stored = std::collections::HashMap::new();
stored.insert("skill-one".to_owned(), hash_one);
stored.insert("skill-two".to_owned(), "stale-hash".to_owned());
let mut results = mgr.verify_all(&stored).unwrap();
results.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(results.len(), 2);
assert_eq!(results[0].stored_hash_matches, Some(true));
assert_eq!(results[1].stored_hash_matches, Some(false));
}
#[test]
fn remove_skill_path_traversal_rejected() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr.remove("../evil").unwrap_err();
assert!(
matches!(
err,
SkillError::NotFound(_) | SkillError::Invalid(_) | SkillError::Other(_)
),
"unexpected error: {err}"
);
}
#[test]
fn install_from_url_rejects_tab_in_url() {
let managed = tempfile::tempdir().unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let err = mgr
.install_from_url("https://example.com/skill\ttab")
.unwrap_err();
assert!(matches!(err, SkillError::GitCloneFailed(_)));
assert!(format!("{err}").contains("whitespace"));
}
#[test]
fn list_installed_populates_requires_secrets() {
let managed = tempfile::tempdir().unwrap();
let skill_dir = managed.path().join("api-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: api-skill\ndescription: Needs secrets.\nx-requires-secrets: github_token, slack_webhook\n---\n# Body\nHello",
)
.unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let list = mgr.list_installed().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "api-skill");
assert_eq!(
list[0].requires_secrets,
vec!["github_token".to_owned(), "slack_webhook".to_owned()]
);
}
#[test]
fn new_manager_stores_path() {
let dir = PathBuf::from("/some/path");
let mgr = SkillManager::new(dir.clone());
let result = mgr.list_installed();
assert!(result.is_ok());
}
#[test]
fn install_from_path_strips_bundled_marker() {
let src = tempfile::tempdir().unwrap();
let managed = tempfile::tempdir().unwrap();
let skill_src = src.path().join("sec-skill");
std::fs::create_dir_all(&skill_src).unwrap();
std::fs::write(
skill_src.join("SKILL.md"),
"---\nname: sec-skill\ndescription: Security test.\n---\n# Body\nHello",
)
.unwrap();
std::fs::write(skill_src.join(".bundled"), "0.1.0").unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let result = mgr.install_from_path(&skill_src).unwrap();
assert_eq!(result.name, "sec-skill");
let installed = managed.path().join("sec-skill");
assert!(
installed.join("SKILL.md").exists(),
"SKILL.md must be present"
);
assert!(
!installed.join(".bundled").exists(),
".bundled must be stripped after install"
);
}
#[test]
fn install_from_path_strips_nested_bundled_marker() {
let src = tempfile::tempdir().unwrap();
let managed = tempfile::tempdir().unwrap();
let skill_src = src.path().join("nested-skill");
let subdir = skill_src.join("scripts");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(
skill_src.join("SKILL.md"),
"---\nname: nested-skill\ndescription: Nested test.\n---\n# Body\nHello",
)
.unwrap();
std::fs::write(skill_src.join(".bundled"), "0.1.0").unwrap();
std::fs::write(subdir.join(".bundled"), "0.1.0").unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
mgr.install_from_path(&skill_src).unwrap();
let installed = managed.path().join("nested-skill");
assert!(
!installed.join(".bundled").exists(),
"root .bundled must be stripped"
);
assert!(
!installed.join("scripts").join(".bundled").exists(),
"nested .bundled must be stripped"
);
}
#[test]
fn install_from_path_forged_bundled_stays_quarantined() {
let src = tempfile::tempdir().unwrap();
let managed = tempfile::tempdir().unwrap();
let skill_src = src.path().join("q-skill");
std::fs::create_dir_all(&skill_src).unwrap();
std::fs::write(
skill_src.join("SKILL.md"),
"---\nname: q-skill\ndescription: Quarantine test.\n---\n# Body\nHello",
)
.unwrap();
std::fs::write(skill_src.join(".bundled"), "forged").unwrap();
let mgr = SkillManager::new(managed.path().to_path_buf());
let result = mgr.install_from_path(&skill_src).unwrap();
assert!(
matches!(result.source, SkillSource::File { .. }),
"source must be File for path install"
);
assert!(
!managed.path().join("q-skill").join(".bundled").exists(),
".bundled must not exist after install"
);
}
#[test]
fn strip_bundled_markers_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let count = strip_bundled_markers(dir.path()).unwrap();
assert_eq!(count, 0);
}
#[test]
fn strip_bundled_markers_preserves_other_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("SKILL.md"), "content").unwrap();
std::fs::write(dir.path().join("script.sh"), "#!/bin/sh").unwrap();
std::fs::write(dir.path().join(".bundled"), "0.1.0").unwrap();
strip_bundled_markers(dir.path()).unwrap();
assert!(
dir.path().join("SKILL.md").exists(),
"SKILL.md must be preserved"
);
assert!(
dir.path().join("script.sh").exists(),
"script.sh must be preserved"
);
assert!(
!dir.path().join(".bundled").exists(),
".bundled must be removed"
);
}
#[test]
fn strip_bundled_markers_removes_at_multiple_levels() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(dir.path().join(".bundled"), "0.1.0").unwrap();
std::fs::write(sub.join(".bundled"), "0.1.0").unwrap();
std::fs::write(sub.join("keep.txt"), "data").unwrap();
let count = strip_bundled_markers(dir.path()).unwrap();
assert_eq!(count, 2, "both .bundled files must be removed");
assert!(!dir.path().join(".bundled").exists());
assert!(!sub.join(".bundled").exists());
assert!(sub.join("keep.txt").exists(), "keep.txt must survive");
}
}