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 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 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 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 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 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 fn lockfile_path(&self) -> PathBuf {
289 self.workspace_dir.join(".clawhub").join("lock.json")
290 }
291
292 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 let root_prefix = self
301 .find_skill_root(&mut zip)
302 .context("parse zip archive")?;
303
304 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 let relative = if let Some(rest) = name.strip_prefix(&root_prefix) {
311 rest.to_string()
312 } else {
313 continue;
315 };
316
317 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 fn find_skill_root<R: std::io::Read + std::io::Seek>(
341 &self,
342 zip: &mut zip::ZipArchive<R>,
343 ) -> Result<String> {
344 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 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 if let Some(last_slash) = name.rfind('/') {
364 return Ok(name[..=last_slash].to_string());
365 }
366 return Ok(String::new());
368 }
369 }
370
371 tracing::warn!("no SKILL.md marker found in archive, extracting all entries");
373 Ok(String::new())
374 }
375
376 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 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 pub fn client(&self) -> &ClawHubClient {
406 &self.client
407 }
408}
409
410#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn test_find_skill_root() {
418 use std::io::Write;
419 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 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 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}