use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
const DEFAULT_INDEX_URL: &str =
"https://raw.githubusercontent.com/avala-ai/agent-code-skills/main/index.json";
const CACHE_MAX_AGE_SECS: u64 = 3600;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteSkill {
pub name: String,
pub description: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub author: String,
pub url: String,
}
pub async fn fetch_index(index_url: Option<&str>) -> Result<Vec<RemoteSkill>, String> {
let url = index_url.unwrap_or(DEFAULT_INDEX_URL);
match fetch_index_from_url(url).await {
Ok(skills) => {
if let Err(e) = save_cached_index(&skills) {
warn!("Failed to cache skill index: {e}");
}
Ok(skills)
}
Err(net_err) => {
debug!("Network fetch failed: {net_err}, trying cache");
match load_cached_index() {
Some(skills) => {
debug!("Using cached skill index ({} entries)", skills.len());
Ok(skills)
}
None => Err(format!("Failed to fetch skill index: {net_err}")),
}
}
}
}
async fn fetch_index_from_url(url: &str) -> Result<Vec<RemoteSkill>, String> {
let client = reqwest::Client::new();
let response = client
.get(url)
.timeout(std::time::Duration::from_secs(10))
.header("User-Agent", "agent-code")
.send()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {e}"))?;
serde_json::from_str(&body).map_err(|e| format!("Invalid index JSON: {e}"))
}
pub async fn install_skill(name: &str, index_url: Option<&str>) -> Result<PathBuf, String> {
let index = fetch_index(index_url).await?;
let skill = index
.iter()
.find(|s| s.name == name)
.ok_or_else(|| format!("Skill '{name}' not found in index"))?;
let content = download_skill(&skill.url).await?;
let dest = user_skills_dir()?;
std::fs::create_dir_all(&dest).map_err(|e| format!("Failed to create skills dir: {e}"))?;
let file_path = dest.join(format!("{name}.md"));
std::fs::write(&file_path, &content).map_err(|e| format!("Failed to write skill file: {e}"))?;
debug!("Installed skill '{name}' to {}", file_path.display());
Ok(file_path)
}
pub fn uninstall_skill(name: &str) -> Result<(), String> {
let dir = user_skills_dir()?;
let file_path = dir.join(format!("{name}.md"));
if !file_path.exists() {
return Err(format!("Skill '{name}' is not installed"));
}
std::fs::remove_file(&file_path).map_err(|e| format!("Failed to remove skill: {e}"))?;
Ok(())
}
pub fn list_installed() -> Vec<String> {
let dir = match user_skills_dir() {
Ok(d) => d,
Err(_) => return Vec::new(),
};
if !dir.is_dir() {
return Vec::new();
}
std::fs::read_dir(dir)
.ok()
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let path = e.path();
if path.extension().is_some_and(|ext| ext == "md") {
path.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
} else {
None
}
})
.collect()
}
async fn download_skill(url: &str) -> Result<String, String> {
let client = reqwest::Client::new();
let response = client
.get(url)
.timeout(std::time::Duration::from_secs(15))
.header("User-Agent", "agent-code")
.send()
.await
.map_err(|e| format!("Download failed: {e}"))?;
if !response.status().is_success() {
return Err(format!("Download failed: HTTP {}", response.status()));
}
response
.text()
.await
.map_err(|e| format!("Failed to read download: {e}"))
}
fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("agent-code").join("skill-index.json"))
}
fn save_cached_index(skills: &[RemoteSkill]) -> Result<(), String> {
let path = cache_path().ok_or("No cache directory")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(skills).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| e.to_string())?;
Ok(())
}
fn load_cached_index() -> Option<Vec<RemoteSkill>> {
let path = cache_path()?;
if !path.exists() {
return None;
}
if let Ok(metadata) = path.metadata()
&& let Ok(modified) = metadata.modified()
&& let Ok(age) = modified.elapsed()
&& age.as_secs() > CACHE_MAX_AGE_SECS * 24
{
debug!(
"Skill index cache is stale ({:.0}h old)",
age.as_secs_f64() / 3600.0
);
}
let content = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
fn user_skills_dir() -> Result<PathBuf, String> {
dirs::config_dir()
.map(|d| d.join("agent-code").join("skills"))
.ok_or_else(|| "Cannot determine user config directory".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_remote_skill_deserialize() {
let json = r#"[
{
"name": "deploy",
"description": "Deploy to production",
"version": "1.0.0",
"author": "test",
"url": "https://example.com/deploy.md"
}
]"#;
let skills: Vec<RemoteSkill> = serde_json::from_str(json).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "deploy");
assert_eq!(skills[0].version, "1.0.0");
}
#[test]
fn test_remote_skill_optional_fields() {
let json = r#"[{"name":"test","description":"A test","url":"https://example.com/t.md"}]"#;
let skills: Vec<RemoteSkill> = serde_json::from_str(json).unwrap();
assert_eq!(skills[0].version, "");
assert_eq!(skills[0].author, "");
}
#[test]
fn test_list_installed_empty() {
let result = list_installed();
assert!(result.is_empty() || !result.is_empty()); }
#[test]
fn test_uninstall_nonexistent() {
let result = uninstall_skill("nonexistent-skill-xyz-test");
assert!(result.is_err());
}
}