Skip to main content

oxios_kernel/skill/clawhub/
installer.rs

1#![allow(missing_docs)]
2//! ClawHub skill installer.
3//!
4//! Handles install, update, and update-all workflows for ClawHub skills,
5//! including origin tracking and lockfile management.
6
7use std::collections::HashMap;
8use std::fs;
9use std::io::Read;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use serde::Serialize;
14
15use super::client::{ClawHubClient, DownloadedArchive};
16use super::types::{ClawHubLockEntry, ClawHubLockfile, ClawHubOrigin};
17
18/// Installation result returned to callers.
19#[derive(Debug, Clone, Serialize)]
20pub struct InstallResult {
21    pub ok: bool,
22    pub slug: String,
23    pub version: String,
24    pub target_dir: PathBuf,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub changelog: Option<String>,
27}
28
29/// Update result for a single skill.
30#[derive(Debug, Clone, Serialize)]
31pub struct UpdateResult {
32    pub ok: bool,
33    pub slug: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub previous_version: Option<String>,
36    pub version: String,
37    pub changed: bool,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub error: Option<String>,
40}
41
42/// Summary of an available update.
43#[derive(Debug, Clone, Serialize)]
44pub struct UpdateAvailable {
45    pub slug: String,
46    pub current_version: String,
47    pub latest_version: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub changelog: Option<String>,
50}
51
52/// ClawHub skill installer.
53pub struct ClawHubInstaller {
54    client: ClawHubClient,
55    /// Directory where skills are installed (e.g. ~/.oxios/skills/).
56    skills_dir: PathBuf,
57    /// Workspace root directory — lockfile lives at `{workspace_dir}/.clawhub/lock.json`.
58    workspace_dir: PathBuf,
59}
60
61impl ClawHubInstaller {
62    /// Create a new installer.
63    ///
64    /// - `skills_dir` — parent directory holding each skill subdirectory.
65    /// - `workspace_dir` — root of the workspace; `.clawhub/` is created here for the lockfile.
66    pub fn new(skills_dir: PathBuf, workspace_dir: PathBuf, base_url: Option<String>) -> Self {
67        Self {
68            client: ClawHubClient::new(base_url).expect("valid ClawHub base URL"),
69            skills_dir,
70            workspace_dir,
71        }
72    }
73
74    /// Install a skill from ClawHub.
75    ///
76    /// If `version` is `None`, the latest version is resolved from the API.
77    pub async fn install(&self, slug: &str, version: Option<&str>) -> Result<InstallResult> {
78        // Resolve version from API if not specified
79        let version = match version {
80            Some(v) => v.to_string(),
81            None => {
82                let detail = self.client.get_skill(slug).await?;
83                detail
84                    .latest_version
85                    .as_ref()
86                    .map(|v| v.version.clone())
87                    .unwrap_or_else(|| "latest".to_string())
88            }
89        };
90
91        // Download
92        let archive = self.client.download_skill(slug, Some(&version)).await?;
93        let target_dir = self.skills_dir.join(slug);
94
95        if target_dir.exists() {
96            anyhow::bail!("skill already installed: {slug} (use update to reinstall)");
97        }
98
99        // Extract
100        fs::create_dir_all(&target_dir).context("create skills_dir")?;
101        self.extract_archive(&archive, &target_dir)?;
102
103        // Origin file
104        let origin = ClawHubOrigin {
105            version: 1,
106            registry: self.client.base_url().to_string(),
107            slug: slug.to_string(),
108            installed_version: version.clone(),
109            installed_at: chrono::Utc::now().to_rfc3339(),
110            sha256: Some(archive.sha256.clone()),
111        };
112        let origin_path = target_dir.join(".clawhub").join("origin.json");
113        fs::create_dir_all(origin_path.parent().unwrap())?;
114        fs::write(
115            &origin_path,
116            serde_json::to_string_pretty(&origin).context("serialize origin")?,
117        )?;
118
119        // Update lockfile
120        self.update_lockfile(slug, &version)?;
121
122        let changelog = self
123            .client
124            .get_skill(slug)
125            .await?
126            .latest_version
127            .as_ref()
128            .and_then(|v| v.changelog.clone());
129
130        Ok(InstallResult {
131            ok: true,
132            slug: slug.to_string(),
133            version,
134            target_dir,
135            changelog,
136        })
137    }
138
139    /// Update a specific installed skill to the latest version.
140    pub async fn update(&self, slug: &str) -> Result<UpdateResult> {
141        let current = self.get_installed_version(slug).ok();
142
143        let detail = self.client.get_skill(slug).await?;
144        let latest = detail
145            .latest_version
146            .as_ref()
147            .map(|v| v.version.clone())
148            .unwrap_or_else(|| "latest".to_string());
149
150        // No-op if already at latest
151        if current.as_deref() == Some(&latest) {
152            return Ok(UpdateResult {
153                ok: true,
154                slug: slug.to_string(),
155                previous_version: current,
156                version: latest,
157                changed: false,
158                error: None,
159            });
160        }
161
162        // Download and extract
163        let archive = self.client.download_skill(slug, Some(&latest)).await?;
164        let target_dir = self.skills_dir.join(slug);
165
166        // Remove old directory and re-extract
167        if target_dir.exists() {
168            fs::remove_dir_all(&target_dir).context("remove old skill dir")?;
169        }
170        fs::create_dir_all(&target_dir).context("create skills_dir")?;
171        self.extract_archive(&archive, &target_dir)?;
172
173        // Update origin file
174        let origin = ClawHubOrigin {
175            version: 1,
176            registry: self.client.base_url().to_string(),
177            slug: slug.to_string(),
178            installed_version: latest.clone(),
179            installed_at: chrono::Utc::now().to_rfc3339(),
180            sha256: Some(archive.sha256.clone()),
181        };
182        let origin_path = target_dir.join(".clawhub").join("origin.json");
183        fs::create_dir_all(origin_path.parent().unwrap())?;
184        fs::write(
185            &origin_path,
186            serde_json::to_string_pretty(&origin).context("serialize origin")?,
187        )?;
188        self.update_lockfile(slug, &latest)?;
189
190        Ok(UpdateResult {
191            ok: true,
192            slug: slug.to_string(),
193            previous_version: current,
194            version: latest,
195            changed: true,
196            error: None,
197        })
198    }
199
200    /// Update all installed ClawHub skills.
201    pub async fn update_all(&self) -> Result<Vec<UpdateResult>> {
202        let lock = self.read_lockfile()?;
203        let mut results = Vec::with_capacity(lock.skills.len());
204
205        for (slug, entry) in lock.skills {
206            let result = match self.update(&slug).await {
207                Ok(r) => r,
208                Err(e) => UpdateResult {
209                    ok: false,
210                    slug,
211                    previous_version: Some(entry.version),
212                    version: String::new(),
213                    changed: false,
214                    error: Some(e.to_string()),
215                },
216            };
217            results.push(result);
218        }
219
220        Ok(results)
221    }
222
223    /// Check which installed skills have updates available.
224    ///
225    /// Fetches skill details concurrently for lower latency.
226    pub async fn check_updates(&self) -> Result<Vec<UpdateAvailable>> {
227        let lock = self.read_lockfile()?;
228        let skills: Vec<(String, ClawHubLockEntry)> = lock.skills.into_iter().collect();
229
230        let futures: Vec<_> = skills
231            .into_iter()
232            .map(|(slug, entry)| {
233                let client = self.client.clone();
234                async move {
235                    let detail = client.get_skill(&slug).await.ok()?;
236                    let latest = detail.latest_version.as_ref()?;
237                    if latest.version != entry.version {
238                        Some(UpdateAvailable {
239                            slug,
240                            current_version: entry.version,
241                            latest_version: latest.version.clone(),
242                            changelog: latest.changelog.clone(),
243                        })
244                    } else {
245                        None
246                    }
247                }
248            })
249            .collect();
250
251        let updates: Vec<UpdateAvailable> = futures::future::join_all(futures)
252            .await
253            .into_iter()
254            .flatten()
255            .collect();
256
257        Ok(updates)
258    }
259
260    /// Read the lockfile from `{workspace_dir}/.clawhub/lock.json`.
261    fn read_lockfile(&self) -> Result<ClawHubLockfile> {
262        let path = self.lockfile_path();
263        if !path.exists() {
264            return Ok(ClawHubLockfile {
265                version: 1,
266                skills: HashMap::new(),
267            });
268        }
269        let mut file = fs::File::open(&path).context("open lockfile")?;
270        let mut buf = String::new();
271        file.read_to_string(&mut buf)
272            .context("read lockfile content")?;
273        serde_json::from_str(&buf).context("parse lockfile JSON")
274    }
275
276    /// Write the lockfile to `{workspace_dir}/.clawhub/lock.json`.
277    fn write_lockfile(&self, lock: &ClawHubLockfile) -> Result<()> {
278        let path = self.lockfile_path();
279        if let Some(parent) = path.parent() {
280            fs::create_dir_all(parent).context("create .clawhub dir")?;
281        }
282        let json = serde_json::to_string_pretty(lock).context("serialize lockfile to JSON")?;
283        fs::write(&path, json).context("write lockfile")?;
284        Ok(())
285    }
286
287    /// Path to the lockfile.
288    fn lockfile_path(&self) -> PathBuf {
289        self.workspace_dir.join(".clawhub").join("lock.json")
290    }
291
292    /// Extract a zip archive into the target directory, finding the skill root
293    /// that contains `SKILL.md` / `skill.md` / `skills.md`.
294    fn extract_archive(&self, archive: &DownloadedArchive, target: &Path) -> Result<()> {
295        let file = fs::File::open(&archive.path).context("open downloaded zip")?;
296        let mut zip = zip::ZipArchive::new(file)?;
297
298        // Find the root directory inside the zip that contains a SKILL.md marker.
299        // Some archives zip the skill directory directly, others zip the contents.
300        let root_prefix = self
301            .find_skill_root(&mut zip)
302            .context("parse zip archive")?;
303
304        // Extract all entries, stripping the root prefix so contents land in `target`.
305        for i in 0..zip.len() {
306            let mut file = zip.by_index(i).context("read zip entry")?;
307            let name = file.name();
308
309            // Strip the detected root prefix
310            let relative = if let Some(rest) = name.strip_prefix(&root_prefix) {
311                rest.to_string()
312            } else {
313                // Entry outside the root — skip
314                continue;
315            };
316
317            // Normalize separators
318            let relative = relative.replace('\\', "/");
319            if relative.is_empty() || relative == "/" {
320                continue;
321            }
322
323            let out_path = target.join(&relative);
324
325            if file.is_dir() {
326                fs::create_dir_all(&out_path).context("create extracted dir")?;
327            } else {
328                if let Some(parent) = out_path.parent() {
329                    fs::create_dir_all(parent).context("create parent dir")?;
330                }
331                let mut dst = fs::File::create(&out_path).context("create output file")?;
332                std::io::copy(&mut file, &mut dst).context("copy zip entry")?;
333            }
334        }
335
336        Ok(())
337    }
338
339    /// Find the directory prefix inside the zip that contains a SKILL.md marker.
340    fn find_skill_root<R: std::io::Read + std::io::Seek>(
341        &self,
342        zip: &mut zip::ZipArchive<R>,
343    ) -> Result<String> {
344        // MARKER_FILES in order of preference
345        const MARKERS: &[&str] = &["SKILL.md", "skill.md", "skills.md"];
346
347        for i in 0..zip.len() {
348            let name = zip.by_index(i).unwrap().name().to_string();
349            let name_lower = name.to_lowercase();
350            if MARKERS.iter().any(|m| {
351                name_lower.ends_with(&format!("/{}", m.to_lowercase()))
352                    || name_lower == m.to_lowercase()
353            }) {
354                // Return everything up to and including the directory component
355                if let Some(slash) = name
356                    .strip_prefix('/')
357                    .and_then(|s| s.rfind('/'))
358                    .map(|p| p + 1)
359                {
360                    return Ok(name[..slash].to_string());
361                }
362                // File is at root level
363                if let Some(last_slash) = name.rfind('/') {
364                    return Ok(name[..=last_slash].to_string());
365                }
366                // No slash — the root is the archive root (empty prefix)
367                return Ok(String::new());
368            }
369        }
370
371        // Fallback: no marker found — extract everything at root
372        tracing::warn!("no SKILL.md marker found in archive, extracting all entries");
373        Ok(String::new())
374    }
375
376    /// Update (or insert) a lockfile entry for the given slug.
377    fn update_lockfile(&self, slug: &str, version: &str) -> Result<()> {
378        let mut lock = self.read_lockfile()?;
379        lock.skills.insert(
380            slug.to_string(),
381            ClawHubLockEntry {
382                version: version.to_string(),
383                installed_at: chrono::Utc::now().to_rfc3339(),
384            },
385        );
386        self.write_lockfile(&lock)
387    }
388
389    /// Read the installed version for a slug from its origin file.
390    fn get_installed_version(&self, slug: &str) -> Result<String> {
391        let origin_path = self
392            .skills_dir
393            .join(slug)
394            .join(".clawhub")
395            .join("origin.json");
396        let mut file = fs::File::open(&origin_path).context("open origin.json")?;
397        let mut buf = String::new();
398        file.read_to_string(&mut buf)
399            .context("read origin.json content")?;
400        let origin: ClawHubOrigin = serde_json::from_str(&buf).context("parse origin.json")?;
401        Ok(origin.installed_version)
402    }
403
404    /// Access the underlying ClawHub client.
405    pub fn client(&self) -> &ClawHubClient {
406        &self.client
407    }
408}
409
410// ─── Tests ─────────────────────────────────────────────────────────────────
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn test_find_skill_root() {
418        use std::io::Write;
419        // Build a zip with SKILL.md nested inside a skill directory
420        let mut buf = Vec::new();
421        {
422            let mut zipw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
423            zipw.start_file(
424                "code-review/SKILL.md",
425                zip::write::SimpleFileOptions::default(),
426            )
427            .unwrap();
428            zipw.write_all(b"# Code Review\n").unwrap();
429            zipw.finish().unwrap();
430        }
431
432        let cursor = std::io::Cursor::new(buf);
433        let mut arch = zip::ZipArchive::new(cursor).unwrap();
434
435        let installer = ClawHubInstaller::new(
436            PathBuf::from("/tmp/skills"),
437            PathBuf::from("/tmp/workspace"),
438            None,
439        );
440        let prefix = installer.find_skill_root(&mut arch).unwrap();
441        assert_eq!(prefix, "code-review/");
442    }
443
444    #[test]
445    fn test_find_skill_root_skips_root_level() {
446        use std::io::Write;
447        // Some archives may have SKILL.md at root level (no subdirectory)
448        let mut buf = Vec::new();
449        {
450            let mut zipw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
451            zipw.start_file("SKILL.md", zip::write::SimpleFileOptions::default())
452                .unwrap();
453            zipw.write_all(b"# Skill\n").unwrap();
454            zipw.finish().unwrap();
455        }
456        let cursor = std::io::Cursor::new(buf);
457        let mut arch = zip::ZipArchive::new(cursor).unwrap();
458        let installer = ClawHubInstaller::new(
459            PathBuf::from("/tmp/skills"),
460            PathBuf::from("/tmp/workspace"),
461            None,
462        );
463        // SKILL.md at root → prefix is empty (extract everything as-is)
464        let prefix = installer.find_skill_root(&mut arch).unwrap();
465        assert_eq!(prefix, "");
466    }
467
468    #[test]
469    fn test_install_result_serialize() {
470        let res = InstallResult {
471            ok: true,
472            slug: "test".to_string(),
473            version: "1.0.0".to_string(),
474            target_dir: PathBuf::from("/tmp/test"),
475            changelog: Some("fixes".to_string()),
476        };
477        let json = serde_json::to_string_pretty(&res).unwrap();
478        assert!(json.contains("\"ok\": true"));
479        assert!(json.contains("\"slug\": \"test\""));
480    }
481
482    #[test]
483    fn test_update_result_serialize() {
484        let res = UpdateResult {
485            ok: true,
486            slug: "test".to_string(),
487            previous_version: Some("1.0.0".to_string()),
488            version: "2.0.0".to_string(),
489            changed: true,
490            error: None,
491        };
492        let json = serde_json::to_string_pretty(&res).unwrap();
493        assert!(json.contains("\"version\": \"2.0.0\""));
494        assert!(json.contains("\"changed\": true"));
495    }
496}