oxios_kernel/skill/clawhub/
installer.rs1#![allow(missing_docs)]
2use 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#[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#[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#[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
52pub struct ClawHubInstaller {
54 client: ClawHubClient,
55 skills_dir: PathBuf,
57 workspace_dir: PathBuf,
59}
60
61impl ClawHubInstaller {
62 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 pub async fn install(&self, slug: &str, version: Option<&str>) -> Result<InstallResult> {
78 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 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 fs::create_dir_all(&target_dir).context("create skills_dir")?;
101 self.extract_archive(&archive, &target_dir)?;
102
103 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 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 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 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 let archive = self.client.download_skill(slug, Some(&latest)).await?;
164 let target_dir = self.skills_dir.join(slug);
165
166 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 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 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 let archive = self
240 .client
241 .download_skill(slug, Some(&origin.installed_version))
242 .await?;
243 let actual = &archive.sha256;
244 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 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 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 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 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 fn lockfile_path(&self) -> PathBuf {
355 self.workspace_dir.join(".clawhub").join("lock.json")
356 }
357
358 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 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 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 pub fn client(&self) -> &ClawHubClient {
399 &self.client
400 }
401}
402
403#[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}