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        // Audit F-12: read+log the old origin hash before overwriting so the
174        // previous integrity value is not silently lost. (Use
175        // `verify_skill_integrity` to actually re-check it against a fresh
176        // download.)
177        if let Ok(prev) = self.get_installed_version(slug) {
178            let prev_origin_path = self.skills_dir.join(slug).join(".clawhub/origin.json");
179            if let Ok(buf) = std::fs::read_to_string(&prev_origin_path)
180                && let Ok(prev_origin) = serde_json::from_str::<ClawHubOrigin>(&buf)
181            {
182                tracing::info!(
183                    slug = %slug,
184                    previous_version = %prev,
185                    previous_sha256 = ?prev_origin.sha256,
186                    "Updating ClawHub skill (replacing stored hash)"
187                );
188            }
189        }
190        let origin = ClawHubOrigin {
191            version: 1,
192            registry: self.client.base_url().to_string(),
193            slug: slug.to_string(),
194            installed_version: latest.clone(),
195            installed_at: chrono::Utc::now().to_rfc3339(),
196            sha256: Some(archive.sha256.clone()),
197        };
198        let origin_path = target_dir.join(".clawhub").join("origin.json");
199        fs::create_dir_all(origin_path.parent().unwrap())?;
200
201        fs::write(
202            &origin_path,
203            serde_json::to_string_pretty(&origin).context("serialize origin")?,
204        )?;
205        self.update_lockfile(slug, &latest)?;
206
207        Ok(UpdateResult {
208            ok: true,
209            slug: slug.to_string(),
210            previous_version: current,
211            version: latest,
212            changed: true,
213            error: None,
214        })
215    }
216
217    /// Verify a ClawHub skill's stored integrity hash by re-downloading the
218    /// same version and comparing the computed SHA-256 to the one stored in
219    /// the skill's `origin.json` (audit F-12 — the hash was previously
220    /// write-only: stored at install/update but never re-checked).
221    ///
222    /// Returns `Ok(true)` if the hashes match (integrity verified),
223    /// `Ok(false)` on mismatch (possible registry update or tampering),
224    /// or `Err` if the skill is not installed or the download fails.
225    pub async fn verify_skill_integrity(&self, slug: &str) -> Result<bool> {
226        let origin_path = self.skills_dir.join(slug).join(".clawhub/origin.json");
227        if !origin_path.exists() {
228            anyhow::bail!("skill '{slug}' is not installed (no origin.json)");
229        }
230        let buf = std::fs::read_to_string(&origin_path).context("read origin.json")?;
231        let origin: ClawHubOrigin = serde_json::from_str(&buf).context("parse origin.json")?;
232
233        let expected = origin.sha256.as_deref().unwrap_or("");
234        if expected.is_empty() {
235            anyhow::bail!("no stored sha256 for skill '{slug}' (legacy install)");
236        }
237
238        // Re-download the exact same version and compare.
239        let archive = self
240            .client
241            .download_skill(slug, Some(&origin.installed_version))
242            .await?;
243        let actual = &archive.sha256;
244        // Clean up the temp archive (client keeps it by default for install).
245        let _ = std::fs::remove_file(&archive.path);
246
247        if actual == expected {
248            tracing::info!(
249                slug = %slug,
250                version = %origin.installed_version,
251                "ClawHub skill integrity verified"
252            );
253            Ok(true)
254        } else {
255            tracing::warn!(
256                slug = %slug,
257                version = %origin.installed_version,
258                expected = %expected,
259                actual = %actual,
260                "ClawHub skill hash mismatch — possible registry update, tampering, or corrupted install"
261            );
262            Ok(false)
263        }
264    }
265
266    /// Update all installed ClawHub skills.
267    pub async fn update_all(&self) -> Result<Vec<UpdateResult>> {
268        let lock = self.read_lockfile()?;
269        let mut results = Vec::with_capacity(lock.skills.len());
270
271        for (slug, entry) in lock.skills {
272            let result = match self.update(&slug).await {
273                Ok(r) => r,
274                Err(e) => UpdateResult {
275                    ok: false,
276                    slug,
277                    previous_version: Some(entry.version),
278                    version: String::new(),
279                    changed: false,
280                    error: Some(e.to_string()),
281                },
282            };
283            results.push(result);
284        }
285
286        Ok(results)
287    }
288
289    /// Check which installed skills have updates available.
290    ///
291    /// Fetches skill details concurrently for lower latency.
292    pub async fn check_updates(&self) -> Result<Vec<UpdateAvailable>> {
293        let lock = self.read_lockfile()?;
294        let skills: Vec<(String, ClawHubLockEntry)> = lock.skills.into_iter().collect();
295
296        let futures: Vec<_> = skills
297            .into_iter()
298            .map(|(slug, entry)| {
299                let client = self.client.clone();
300                async move {
301                    let detail = client.get_skill(&slug).await.ok()?;
302                    let latest = detail.latest_version.as_ref()?;
303                    if latest.version != entry.version {
304                        Some(UpdateAvailable {
305                            slug,
306                            current_version: entry.version,
307                            latest_version: latest.version.clone(),
308                            changelog: latest.changelog.clone(),
309                        })
310                    } else {
311                        None
312                    }
313                }
314            })
315            .collect();
316
317        let updates: Vec<UpdateAvailable> = futures::future::join_all(futures)
318            .await
319            .into_iter()
320            .flatten()
321            .collect();
322
323        Ok(updates)
324    }
325
326    /// Read the lockfile from `{workspace_dir}/.clawhub/lock.json`.
327    fn read_lockfile(&self) -> Result<ClawHubLockfile> {
328        let path = self.lockfile_path();
329        if !path.exists() {
330            return Ok(ClawHubLockfile {
331                version: 1,
332                skills: HashMap::new(),
333            });
334        }
335        let mut file = fs::File::open(&path).context("open lockfile")?;
336        let mut buf = String::new();
337        file.read_to_string(&mut buf)
338            .context("read lockfile content")?;
339        serde_json::from_str(&buf).context("parse lockfile JSON")
340    }
341
342    /// Write the lockfile to `{workspace_dir}/.clawhub/lock.json`.
343    fn write_lockfile(&self, lock: &ClawHubLockfile) -> Result<()> {
344        let path = self.lockfile_path();
345        if let Some(parent) = path.parent() {
346            fs::create_dir_all(parent).context("create .clawhub dir")?;
347        }
348        let json = serde_json::to_string_pretty(lock).context("serialize lockfile to JSON")?;
349        fs::write(&path, json).context("write lockfile")?;
350        Ok(())
351    }
352
353    /// Path to the lockfile.
354    fn lockfile_path(&self) -> PathBuf {
355        self.workspace_dir.join(".clawhub").join("lock.json")
356    }
357
358    /// Extract a downloaded skill zip into the target directory.
359    ///
360    /// Delegates to [`crate::skill::archive::extract_skill_zip`] so the
361    /// Zip-Slip defense and `SKILL.md` marker detection stay in one place,
362    /// shared with the user-driven `.skill` file import.
363    fn extract_archive(&self, archive: &DownloadedArchive, target: &Path) -> Result<()> {
364        let file = fs::File::open(&archive.path).context("open downloaded zip")?;
365        let mut zip = zip::ZipArchive::new(file)?;
366        crate::skill::archive::extract_skill_zip(&mut zip, target)
367    }
368
369    /// Update (or insert) a lockfile entry for the given slug.
370    fn update_lockfile(&self, slug: &str, version: &str) -> Result<()> {
371        let mut lock = self.read_lockfile()?;
372        lock.skills.insert(
373            slug.to_string(),
374            ClawHubLockEntry {
375                version: version.to_string(),
376                installed_at: chrono::Utc::now().to_rfc3339(),
377            },
378        );
379        self.write_lockfile(&lock)
380    }
381
382    /// Read the installed version for a slug from its origin file.
383    fn get_installed_version(&self, slug: &str) -> Result<String> {
384        let origin_path = self
385            .skills_dir
386            .join(slug)
387            .join(".clawhub")
388            .join("origin.json");
389        let mut file = fs::File::open(&origin_path).context("open origin.json")?;
390        let mut buf = String::new();
391        file.read_to_string(&mut buf)
392            .context("read origin.json content")?;
393        let origin: ClawHubOrigin = serde_json::from_str(&buf).context("parse origin.json")?;
394        Ok(origin.installed_version)
395    }
396
397    /// Access the underlying ClawHub client.
398    pub fn client(&self) -> &ClawHubClient {
399        &self.client
400    }
401}
402
403// ─── Tests ─────────────────────────────────────────────────────────────────
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_install_result_serialize() {
411        let res = InstallResult {
412            ok: true,
413            slug: "test".to_string(),
414            version: "1.0.0".to_string(),
415            target_dir: PathBuf::from("/tmp/test"),
416            changelog: Some("fixes".to_string()),
417        };
418        let json = serde_json::to_string_pretty(&res).unwrap();
419        assert!(json.contains("\"ok\": true"));
420        assert!(json.contains("\"slug\": \"test\""));
421    }
422
423    #[test]
424    fn test_update_result_serialize() {
425        let res = UpdateResult {
426            ok: true,
427            slug: "test".to_string(),
428            previous_version: Some("1.0.0".to_string()),
429            version: "2.0.0".to_string(),
430            changed: true,
431            error: None,
432        };
433        let json = serde_json::to_string_pretty(&res).unwrap();
434        assert!(json.contains("\"version\": \"2.0.0\""));
435        assert!(json.contains("\"changed\": true"));
436    }
437}