agent_code_lib/skills/
remote.rs1use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24use tracing::{debug, warn};
25
26const DEFAULT_INDEX_URL: &str =
28 "https://raw.githubusercontent.com/avala-ai/agent-code-skills/main/index.json";
29
30const CACHE_MAX_AGE_SECS: u64 = 3600;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RemoteSkill {
36 pub name: String,
38 pub description: String,
40 #[serde(default)]
42 pub version: String,
43 #[serde(default)]
45 pub author: String,
46 pub url: String,
48}
49
50pub async fn fetch_index(index_url: Option<&str>) -> Result<Vec<RemoteSkill>, String> {
52 let url = index_url.unwrap_or(DEFAULT_INDEX_URL);
53
54 match fetch_index_from_url(url).await {
56 Ok(skills) => {
57 if let Err(e) = save_cached_index(&skills) {
59 warn!("Failed to cache skill index: {e}");
60 }
61 Ok(skills)
62 }
63 Err(net_err) => {
64 debug!("Network fetch failed: {net_err}, trying cache");
65 match load_cached_index() {
67 Some(skills) => {
68 debug!("Using cached skill index ({} entries)", skills.len());
69 Ok(skills)
70 }
71 None => Err(format!("Failed to fetch skill index: {net_err}")),
72 }
73 }
74 }
75}
76
77async fn fetch_index_from_url(url: &str) -> Result<Vec<RemoteSkill>, String> {
79 let client = reqwest::Client::new();
80 let response = client
81 .get(url)
82 .timeout(std::time::Duration::from_secs(10))
83 .header("User-Agent", "agent-code")
84 .send()
85 .await
86 .map_err(|e| format!("HTTP request failed: {e}"))?;
87
88 if !response.status().is_success() {
89 return Err(format!("HTTP {}", response.status()));
90 }
91
92 let body = response
93 .text()
94 .await
95 .map_err(|e| format!("Failed to read response: {e}"))?;
96
97 serde_json::from_str(&body).map_err(|e| format!("Invalid index JSON: {e}"))
98}
99
100pub async fn install_skill(name: &str, index_url: Option<&str>) -> Result<PathBuf, String> {
102 let index = fetch_index(index_url).await?;
103
104 let skill = index
105 .iter()
106 .find(|s| s.name == name)
107 .ok_or_else(|| format!("Skill '{name}' not found in index"))?;
108
109 let content = download_skill(&skill.url).await?;
111
112 let dest = user_skills_dir()?;
114 std::fs::create_dir_all(&dest).map_err(|e| format!("Failed to create skills dir: {e}"))?;
115
116 let file_path = dest.join(format!("{name}.md"));
117 std::fs::write(&file_path, &content).map_err(|e| format!("Failed to write skill file: {e}"))?;
118
119 debug!("Installed skill '{name}' to {}", file_path.display());
120 Ok(file_path)
121}
122
123pub fn uninstall_skill(name: &str) -> Result<(), String> {
125 let dir = user_skills_dir()?;
126 let file_path = dir.join(format!("{name}.md"));
127
128 if !file_path.exists() {
129 return Err(format!("Skill '{name}' is not installed"));
130 }
131
132 std::fs::remove_file(&file_path).map_err(|e| format!("Failed to remove skill: {e}"))?;
133 Ok(())
134}
135
136pub fn list_installed() -> Vec<String> {
138 let dir = match user_skills_dir() {
139 Ok(d) => d,
140 Err(_) => return Vec::new(),
141 };
142
143 if !dir.is_dir() {
144 return Vec::new();
145 }
146
147 std::fs::read_dir(dir)
148 .ok()
149 .into_iter()
150 .flatten()
151 .flatten()
152 .filter_map(|e| {
153 let path = e.path();
154 if path.extension().is_some_and(|ext| ext == "md") {
155 path.file_stem()
156 .and_then(|s| s.to_str())
157 .map(|s| s.to_string())
158 } else {
159 None
160 }
161 })
162 .collect()
163}
164
165async fn download_skill(url: &str) -> Result<String, String> {
167 let client = reqwest::Client::new();
168 let response = client
169 .get(url)
170 .timeout(std::time::Duration::from_secs(15))
171 .header("User-Agent", "agent-code")
172 .send()
173 .await
174 .map_err(|e| format!("Download failed: {e}"))?;
175
176 if !response.status().is_success() {
177 return Err(format!("Download failed: HTTP {}", response.status()));
178 }
179
180 response
181 .text()
182 .await
183 .map_err(|e| format!("Failed to read download: {e}"))
184}
185
186fn cache_path() -> Option<PathBuf> {
189 dirs::cache_dir().map(|d| d.join("agent-code").join("skill-index.json"))
190}
191
192fn save_cached_index(skills: &[RemoteSkill]) -> Result<(), String> {
193 let path = cache_path().ok_or("No cache directory")?;
194 if let Some(parent) = path.parent() {
195 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
196 }
197 let json = serde_json::to_string_pretty(skills).map_err(|e| e.to_string())?;
198 std::fs::write(&path, json).map_err(|e| e.to_string())?;
199 Ok(())
200}
201
202fn load_cached_index() -> Option<Vec<RemoteSkill>> {
203 let path = cache_path()?;
204 if !path.exists() {
205 return None;
206 }
207
208 if let Ok(metadata) = path.metadata()
210 && let Ok(modified) = metadata.modified()
211 && let Ok(age) = modified.elapsed()
212 && age.as_secs() > CACHE_MAX_AGE_SECS * 24
213 {
214 debug!(
217 "Skill index cache is stale ({:.0}h old)",
218 age.as_secs_f64() / 3600.0
219 );
220 }
221
222 let content = std::fs::read_to_string(&path).ok()?;
223 serde_json::from_str(&content).ok()
224}
225
226fn user_skills_dir() -> Result<PathBuf, String> {
227 dirs::config_dir()
228 .map(|d| d.join("agent-code").join("skills"))
229 .ok_or_else(|| "Cannot determine user config directory".to_string())
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn test_remote_skill_deserialize() {
238 let json = r#"[
239 {
240 "name": "deploy",
241 "description": "Deploy to production",
242 "version": "1.0.0",
243 "author": "test",
244 "url": "https://example.com/deploy.md"
245 }
246 ]"#;
247 let skills: Vec<RemoteSkill> = serde_json::from_str(json).unwrap();
248 assert_eq!(skills.len(), 1);
249 assert_eq!(skills[0].name, "deploy");
250 assert_eq!(skills[0].version, "1.0.0");
251 }
252
253 #[test]
254 fn test_remote_skill_optional_fields() {
255 let json = r#"[{"name":"test","description":"A test","url":"https://example.com/t.md"}]"#;
256 let skills: Vec<RemoteSkill> = serde_json::from_str(json).unwrap();
257 assert_eq!(skills[0].version, "");
258 assert_eq!(skills[0].author, "");
259 }
260
261 #[test]
262 fn test_list_installed_empty() {
263 let result = list_installed();
265 assert!(result.is_empty() || !result.is_empty()); }
267
268 #[test]
269 fn test_uninstall_nonexistent() {
270 let result = uninstall_skill("nonexistent-skill-xyz-test");
271 assert!(result.is_err());
272 }
273}