Skip to main content

opendev_repl/
skills.rs

1//! Skill loading from remote URLs and local cache.
2//!
3//! Skills are markdown files that define agent behaviors. This module adds
4//! support for loading skills from HTTPS URLs with local caching in
5//! `~/.opendev/skills/`.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use sha2::{Digest, Sha256};
11use thiserror::Error;
12use tracing::debug;
13
14/// Errors that can occur when loading skills.
15#[derive(Debug, Error)]
16pub enum SkillError {
17    #[error("invalid URL: {0}")]
18    InvalidUrl(String),
19    #[error("network error: {0}")]
20    NetworkError(String),
21    #[error("I/O error: {0}")]
22    IoError(#[from] std::io::Error),
23    #[error("skill parse error: {0}")]
24    ParseError(String),
25    #[error("URL scheme must be https: {0}")]
26    InsecureUrl(String),
27}
28
29/// A parsed skill definition loaded from a markdown file.
30#[derive(Debug, Clone)]
31pub struct Skill {
32    /// Skill name (derived from the first heading or filename).
33    pub name: String,
34    /// Raw markdown content.
35    pub content: String,
36    /// Source URL (if loaded from a remote URL).
37    pub source_url: Option<String>,
38    /// Local cache path.
39    pub cache_path: Option<PathBuf>,
40    /// Extracted sections from the markdown.
41    pub sections: HashMap<String, String>,
42}
43
44/// Default cache directory for downloaded skills.
45fn default_skills_dir() -> PathBuf {
46    dirs::home_dir()
47        .unwrap_or_else(|| PathBuf::from("/tmp"))
48        .join(".opendev")
49        .join("skills")
50}
51
52/// Generate a cache filename from a URL.
53fn url_to_cache_filename(url: &str) -> String {
54    let mut hasher = Sha256::new();
55    hasher.update(url.as_bytes());
56    let hash = hasher.finalize();
57    let hex: String = hash.iter().take(8).map(|b| format!("{b:02x}")).collect();
58    format!("{hex}.md")
59}
60
61/// Parse a markdown string into a `Skill`.
62///
63/// Extracts the skill name from the first `#` heading, and splits the
64/// document into sections keyed by heading text.
65pub fn parse_skill(content: &str, fallback_name: &str) -> Result<Skill, SkillError> {
66    if content.trim().is_empty() {
67        return Err(SkillError::ParseError("skill content is empty".to_string()));
68    }
69
70    let mut name = fallback_name.to_string();
71    let mut sections: HashMap<String, String> = HashMap::new();
72    let mut current_section = String::new();
73    let mut current_body = String::new();
74
75    for line in content.lines() {
76        if let Some(heading) = line.strip_prefix("# ") {
77            // First heading becomes the skill name
78            if name == fallback_name && !heading.trim().is_empty() {
79                name = heading.trim().to_string();
80            }
81            // Save previous section
82            if !current_section.is_empty() {
83                sections.insert(current_section.clone(), current_body.trim().to_string());
84            }
85            current_section = heading.trim().to_string();
86            current_body.clear();
87        } else if let Some(heading) = line.strip_prefix("## ") {
88            if !current_section.is_empty() {
89                sections.insert(current_section.clone(), current_body.trim().to_string());
90            }
91            current_section = heading.trim().to_string();
92            current_body.clear();
93        } else {
94            current_body.push_str(line);
95            current_body.push('\n');
96        }
97    }
98
99    // Save last section
100    if !current_section.is_empty() {
101        sections.insert(current_section, current_body.trim().to_string());
102    }
103
104    Ok(Skill {
105        name,
106        content: content.to_string(),
107        source_url: None,
108        cache_path: None,
109        sections,
110    })
111}
112
113/// Load a skill from an HTTPS URL.
114///
115/// The skill markdown is fetched, cached locally in `~/.opendev/skills/`,
116/// and parsed into a [`Skill`]. Subsequent calls for the same URL will
117/// return the cached version unless `force_refresh` is true.
118pub fn load_skill_from_url(url: &str) -> Result<Skill, SkillError> {
119    load_skill_from_url_with_options(url, None, false)
120}
121
122/// Load a skill from a URL with options for cache directory and force refresh.
123pub fn load_skill_from_url_with_options(
124    url: &str,
125    cache_dir: Option<&Path>,
126    force_refresh: bool,
127) -> Result<Skill, SkillError> {
128    // Validate URL
129    if !url.starts_with("https://") {
130        return Err(SkillError::InsecureUrl(url.to_string()));
131    }
132
133    if !url.contains('.') || url.len() < 12 {
134        return Err(SkillError::InvalidUrl(url.to_string()));
135    }
136
137    let skills_dir = cache_dir
138        .map(PathBuf::from)
139        .unwrap_or_else(default_skills_dir);
140    let cache_filename = url_to_cache_filename(url);
141    let cache_path = skills_dir.join(&cache_filename);
142
143    // Try cache first (unless force refresh)
144    if !force_refresh && cache_path.exists() {
145        debug!("Loading cached skill from {:?}", cache_path);
146        let content = std::fs::read_to_string(&cache_path)?;
147        let fallback_name = extract_name_from_url(url);
148        let mut skill = parse_skill(&content, &fallback_name)?;
149        skill.source_url = Some(url.to_string());
150        skill.cache_path = Some(cache_path);
151        return Ok(skill);
152    }
153
154    // Fetch from URL
155    debug!("Fetching skill from {}", url);
156    let content = fetch_url_content(url)?;
157
158    // Cache locally
159    std::fs::create_dir_all(&skills_dir)?;
160    std::fs::write(&cache_path, &content)?;
161    debug!("Cached skill to {:?}", cache_path);
162
163    let fallback_name = extract_name_from_url(url);
164    let mut skill = parse_skill(&content, &fallback_name)?;
165    skill.source_url = Some(url.to_string());
166    skill.cache_path = Some(cache_path);
167
168    Ok(skill)
169}
170
171/// Extract a reasonable name from a URL path.
172fn extract_name_from_url(url: &str) -> String {
173    url.rsplit('/')
174        .next()
175        .unwrap_or("remote-skill")
176        .trim_end_matches(".md")
177        .replace(['-', '_'], " ")
178}
179
180/// Fetch content from a URL (sync, uses a temporary tokio runtime).
181fn fetch_url_content(url: &str) -> Result<String, SkillError> {
182    let url_owned = url.to_string();
183
184    let fetch = async move {
185        let client = reqwest::Client::builder()
186            .timeout(std::time::Duration::from_secs(15))
187            .user_agent("opendev-rust/0.1.0")
188            .build()
189            .map_err(|e| SkillError::NetworkError(e.to_string()))?;
190
191        let resp = client
192            .get(&url_owned)
193            .send()
194            .await
195            .map_err(|e| SkillError::NetworkError(e.to_string()))?;
196
197        if !resp.status().is_success() {
198            return Err(SkillError::NetworkError(format!(
199                "HTTP {} for {}",
200                resp.status(),
201                url_owned
202            )));
203        }
204
205        resp.text()
206            .await
207            .map_err(|e| SkillError::NetworkError(e.to_string()))
208    };
209
210    // Try to use existing runtime, fall back to creating one
211    match tokio::runtime::Handle::try_current() {
212        Ok(_handle) => std::thread::scope(|s| {
213            s.spawn(|| {
214                tokio::runtime::Builder::new_current_thread()
215                    .enable_all()
216                    .build()
217                    .map_err(|e| SkillError::NetworkError(e.to_string()))
218                    .and_then(|rt| rt.block_on(fetch))
219            })
220            .join()
221            .unwrap_or_else(|_| Err(SkillError::NetworkError("thread join failed".to_string())))
222        }),
223        Err(_) => tokio::runtime::Builder::new_current_thread()
224            .enable_all()
225            .build()
226            .map_err(|e| SkillError::NetworkError(e.to_string()))
227            .and_then(|rt| rt.block_on(fetch)),
228    }
229}
230
231/// Load a skill from a local file path.
232pub fn load_skill_from_file(path: &Path) -> Result<Skill, SkillError> {
233    let content = std::fs::read_to_string(path)?;
234    let fallback_name = path
235        .file_stem()
236        .and_then(|s| s.to_str())
237        .unwrap_or("local-skill")
238        .to_string();
239    let mut skill = parse_skill(&content, &fallback_name)?;
240    skill.cache_path = Some(path.to_path_buf());
241    Ok(skill)
242}
243
244/// List all cached skills in the skills directory.
245pub fn list_cached_skills(cache_dir: Option<&Path>) -> Vec<PathBuf> {
246    let skills_dir = cache_dir
247        .map(PathBuf::from)
248        .unwrap_or_else(default_skills_dir);
249
250    if !skills_dir.exists() {
251        return Vec::new();
252    }
253
254    let mut paths = Vec::new();
255    if let Ok(entries) = std::fs::read_dir(&skills_dir) {
256        for entry in entries.flatten() {
257            let path = entry.path();
258            if path.extension().and_then(|e| e.to_str()) == Some("md") {
259                paths.push(path);
260            }
261        }
262    }
263    paths.sort();
264    paths
265}
266
267#[cfg(test)]
268#[path = "skills_tests.rs"]
269mod tests;