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 downloaded skill zip into the target directory.
293    ///
294    /// Delegates to [`crate::skill::archive::extract_skill_zip`] so the
295    /// Zip-Slip defense and `SKILL.md` marker detection stay in one place,
296    /// shared with the user-driven `.skill` file import.
297    fn extract_archive(&self, archive: &DownloadedArchive, target: &Path) -> Result<()> {
298        let file = fs::File::open(&archive.path).context("open downloaded zip")?;
299        let mut zip = zip::ZipArchive::new(file)?;
300        crate::skill::archive::extract_skill_zip(&mut zip, target)
301    }
302
303    /// Update (or insert) a lockfile entry for the given slug.
304    fn update_lockfile(&self, slug: &str, version: &str) -> Result<()> {
305        let mut lock = self.read_lockfile()?;
306        lock.skills.insert(
307            slug.to_string(),
308            ClawHubLockEntry {
309                version: version.to_string(),
310                installed_at: chrono::Utc::now().to_rfc3339(),
311            },
312        );
313        self.write_lockfile(&lock)
314    }
315
316    /// Read the installed version for a slug from its origin file.
317    fn get_installed_version(&self, slug: &str) -> Result<String> {
318        let origin_path = self
319            .skills_dir
320            .join(slug)
321            .join(".clawhub")
322            .join("origin.json");
323        let mut file = fs::File::open(&origin_path).context("open origin.json")?;
324        let mut buf = String::new();
325        file.read_to_string(&mut buf)
326            .context("read origin.json content")?;
327        let origin: ClawHubOrigin = serde_json::from_str(&buf).context("parse origin.json")?;
328        Ok(origin.installed_version)
329    }
330
331    /// Access the underlying ClawHub client.
332    pub fn client(&self) -> &ClawHubClient {
333        &self.client
334    }
335}
336
337// ─── Tests ─────────────────────────────────────────────────────────────────
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_install_result_serialize() {
345        let res = InstallResult {
346            ok: true,
347            slug: "test".to_string(),
348            version: "1.0.0".to_string(),
349            target_dir: PathBuf::from("/tmp/test"),
350            changelog: Some("fixes".to_string()),
351        };
352        let json = serde_json::to_string_pretty(&res).unwrap();
353        assert!(json.contains("\"ok\": true"));
354        assert!(json.contains("\"slug\": \"test\""));
355    }
356
357    #[test]
358    fn test_update_result_serialize() {
359        let res = UpdateResult {
360            ok: true,
361            slug: "test".to_string(),
362            previous_version: Some("1.0.0".to_string()),
363            version: "2.0.0".to_string(),
364            changed: true,
365            error: None,
366        };
367        let json = serde_json::to_string_pretty(&res).unwrap();
368        assert!(json.contains("\"version\": \"2.0.0\""));
369        assert!(json.contains("\"changed\": true"));
370    }
371}