use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
use super::client::SkillsShClient;
#[derive(Debug, Clone, Serialize)]
pub struct SkillsShInstallResult {
pub ok: bool,
pub slug: String,
pub source: String,
pub skill_id: String,
pub target_dir: PathBuf,
pub file_count: usize,
pub installs: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct SkillsShOrigin {
pub version: u32,
pub registry: String,
#[serde(rename = "skillId")]
pub skill_id: String,
pub slug: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
#[serde(rename = "installedAt")]
pub installed_at: String,
}
pub struct SkillsShInstaller {
client: SkillsShClient,
skills_dir: PathBuf,
}
impl SkillsShInstaller {
pub fn new(skills_dir: PathBuf, base_url: Option<String>, api_key: Option<String>) -> Self {
Self {
client: SkillsShClient::new(base_url, api_key).expect("valid skills.sh base URL"),
skills_dir,
}
}
pub async fn install(&self, skill_id: &str) -> Result<SkillsShInstallResult> {
let detail = self.client.get_skill(skill_id).await?;
let files = detail.files.as_ref().ok_or_else(|| {
anyhow::anyhow!("skill {skill_id} has no files available (no snapshot)")
})?;
if files.is_empty() {
anyhow::bail!("skill {skill_id} has no files");
}
let target_dir = self.skills_dir.join(&detail.slug);
if target_dir.exists() {
let origin_path = target_dir.join(".skills_sh").join("origin.json");
if origin_path.exists() {
anyhow::bail!(
"skill already installed: {} (use update to reinstall)",
detail.slug
);
}
tracing::warn!(
"skill directory {} exists but not from skills.sh, overwriting",
detail.slug
);
fs::remove_dir_all(&target_dir).context("remove existing skill dir")?;
}
fs::create_dir_all(&target_dir).context("create skill directory")?;
let mut file_count = 0usize;
for file in files {
let file_path = target_dir.join(&file.path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).context("create parent dir for skill file")?;
}
fs::write(&file_path, &file.contents).context("write skill file")?;
file_count += 1;
}
let origin = SkillsShOrigin {
version: 1,
registry: self.client.base_url().to_string(),
skill_id: detail.id.clone(),
slug: detail.slug.clone(),
source: detail.source.clone(),
hash: detail.hash.clone(),
installed_at: chrono::Utc::now().to_rfc3339(),
};
let origin_dir = target_dir.join(".skills_sh");
fs::create_dir_all(&origin_dir).context("create .skills_sh dir")?;
fs::write(
origin_dir.join("origin.json"),
serde_json::to_string_pretty(&origin).context("serialize origin")?,
)
.context("write origin.json")?;
Ok(SkillsShInstallResult {
ok: true,
slug: detail.slug,
source: detail.source,
skill_id: detail.id,
target_dir,
file_count,
installs: detail.installs,
hash: detail.hash,
})
}
pub async fn update(&self, skill_id: &str) -> Result<SkillsShInstallResult> {
let detail = self.client.get_skill(skill_id).await?;
let target_dir = self.skills_dir.join(&detail.slug);
if target_dir.exists() {
let old_hash = self.read_installed_hash(&detail.slug);
let new_hash = detail.hash.as_deref();
if old_hash.as_deref() == new_hash {
return Ok(SkillsShInstallResult {
ok: true,
slug: detail.slug,
source: detail.source,
skill_id: detail.id,
target_dir,
file_count: 0,
installs: detail.installs,
hash: detail.hash,
});
}
fs::remove_dir_all(&target_dir).context("remove old skill dir")?;
}
self.install(skill_id).await
}
pub fn is_installed(&self, slug: &str) -> bool {
self.skills_dir
.join(slug)
.join(".skills_sh")
.join("origin.json")
.exists()
}
fn read_installed_hash(&self, slug: &str) -> Option<String> {
let origin_path = self
.skills_dir
.join(slug)
.join(".skills_sh")
.join("origin.json");
let content = fs::read_to_string(&origin_path).ok()?;
let origin: SkillsShOrigin = serde_json::from_str(&content).ok()?;
origin.hash
}
pub fn get_installed_skill_id(&self, slug: &str) -> Option<String> {
let origin_path = self
.skills_dir
.join(slug)
.join(".skills_sh")
.join("origin.json");
let content = fs::read_to_string(&origin_path).ok()?;
let origin: SkillsShOrigin = serde_json::from_str(&content).ok()?;
Some(origin.skill_id)
}
pub fn client(&self) -> &SkillsShClient {
&self.client
}
pub fn skills_dir(&self) -> &Path {
&self.skills_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_origin_serialize() {
let origin = SkillsShOrigin {
version: 1,
registry: "https://skills.sh/".to_string(),
skill_id: "vercel-labs/agent-skills/frontend-design".to_string(),
slug: "frontend-design".to_string(),
source: "vercel-labs/agent-skills".to_string(),
hash: Some("abc123".to_string()),
installed_at: "2026-06-01T00:00:00Z".to_string(),
};
let json = serde_json::to_string_pretty(&origin).unwrap();
assert!(json.contains("\"skillId\":"));
assert!(json.contains("\"frontend-design\""));
}
}