Skip to main content

agent_code_lib/skills/
remote.rs

1//! Remote skill discovery and installation.
2//!
3//! Fetches a skill index from a configurable URL, caches it locally,
4//! and installs skills to the user's skills directory. The index is
5//! a JSON array of skill entries with name, description, and download URL.
6//!
7//! # Index Format
8//!
9//! ```json
10//! [
11//!   {
12//!     "name": "deploy",
13//!     "description": "Deploy to production with safety checks",
14//!     "version": "1.0.0",
15//!     "author": "avala-ai",
16//!     "url": "https://raw.githubusercontent.com/avala-ai/agent-code-skills/main/deploy.md"
17//!   }
18//! ]
19//! ```
20
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24use tracing::{debug, warn};
25
26/// Default skill index URL.
27const DEFAULT_INDEX_URL: &str =
28    "https://raw.githubusercontent.com/avala-ai/agent-code-skills/main/index.json";
29
30/// How long to cache the index before re-fetching (1 hour).
31const CACHE_MAX_AGE_SECS: u64 = 3600;
32
33/// A skill entry in the remote index.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RemoteSkill {
36    /// Skill name (used as filename).
37    pub name: String,
38    /// What this skill does.
39    pub description: String,
40    /// Semantic version.
41    #[serde(default)]
42    pub version: String,
43    /// Author or organization.
44    #[serde(default)]
45    pub author: String,
46    /// URL to download the skill markdown file.
47    pub url: String,
48}
49
50/// Fetch the remote skill index, falling back to the local cache.
51pub async fn fetch_index(index_url: Option<&str>) -> Result<Vec<RemoteSkill>, String> {
52    let url = index_url.unwrap_or(DEFAULT_INDEX_URL);
53
54    // Try fetching from the network first.
55    match fetch_index_from_url(url).await {
56        Ok(skills) => {
57            // Cache the result for offline use.
58            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            // Fall back to cached index.
66            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
77/// Fetch the index from a URL.
78async 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
100/// Install a skill by name from the remote index.
101pub 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    // Download the skill file.
110    let content = download_skill(&skill.url).await?;
111
112    // Save to user skills directory.
113    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
123/// Remove an installed skill by name.
124pub 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
136/// List installed skills (in user skills directory).
137pub 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
165/// Download a skill file from a URL.
166async 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
186// ── Cache ────────────────────────────────────────────────────────
187
188fn 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    // Check cache age.
209    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        // Cache is older than 24 hours — still usable as fallback
215        // but log a warning.
216        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        // Should not panic even if the directory doesn't exist.
264        let result = list_installed();
265        assert!(result.is_empty() || !result.is_empty()); // Just shouldn't panic.
266    }
267
268    #[test]
269    fn test_uninstall_nonexistent() {
270        let result = uninstall_skill("nonexistent-skill-xyz-test");
271        assert!(result.is_err());
272    }
273}