#![allow(missing_docs)]
use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
use super::client::{ClawHubClient, DownloadedArchive};
use super::types::{ClawHubLockEntry, ClawHubLockfile, ClawHubOrigin};
#[derive(Debug, Clone, Serialize)]
pub struct InstallResult {
pub ok: bool,
pub slug: String,
pub version: String,
pub target_dir: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub changelog: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateResult {
pub ok: bool,
pub slug: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_version: Option<String>,
pub version: String,
pub changed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateAvailable {
pub slug: String,
pub current_version: String,
pub latest_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub changelog: Option<String>,
}
pub struct ClawHubInstaller {
client: ClawHubClient,
skills_dir: PathBuf,
workspace_dir: PathBuf,
}
impl ClawHubInstaller {
pub fn new(skills_dir: PathBuf, workspace_dir: PathBuf, base_url: Option<String>) -> Self {
Self {
client: ClawHubClient::new(base_url).expect("valid ClawHub base URL"),
skills_dir,
workspace_dir,
}
}
pub async fn install(&self, slug: &str, version: Option<&str>) -> Result<InstallResult> {
let version = match version {
Some(v) => v.to_string(),
None => {
let detail = self.client.get_skill(slug).await?;
detail
.latest_version
.as_ref()
.map(|v| v.version.clone())
.unwrap_or_else(|| "latest".to_string())
}
};
let archive = self.client.download_skill(slug, Some(&version)).await?;
let target_dir = self.skills_dir.join(slug);
if target_dir.exists() {
anyhow::bail!("skill already installed: {slug} (use update to reinstall)");
}
fs::create_dir_all(&target_dir).context("create skills_dir")?;
self.extract_archive(&archive, &target_dir)?;
let origin = ClawHubOrigin {
version: 1,
registry: self.client.base_url().to_string(),
slug: slug.to_string(),
installed_version: version.clone(),
installed_at: chrono::Utc::now().to_rfc3339(),
sha256: Some(archive.sha256.clone()),
};
let origin_path = target_dir.join(".clawhub").join("origin.json");
fs::create_dir_all(origin_path.parent().unwrap())?;
fs::write(
&origin_path,
serde_json::to_string_pretty(&origin).context("serialize origin")?,
)?;
self.update_lockfile(slug, &version)?;
let changelog = self
.client
.get_skill(slug)
.await?
.latest_version
.as_ref()
.and_then(|v| v.changelog.clone());
Ok(InstallResult {
ok: true,
slug: slug.to_string(),
version,
target_dir,
changelog,
})
}
pub async fn update(&self, slug: &str) -> Result<UpdateResult> {
let current = self.get_installed_version(slug).ok();
let detail = self.client.get_skill(slug).await?;
let latest = detail
.latest_version
.as_ref()
.map(|v| v.version.clone())
.unwrap_or_else(|| "latest".to_string());
if current.as_deref() == Some(&latest) {
return Ok(UpdateResult {
ok: true,
slug: slug.to_string(),
previous_version: current,
version: latest,
changed: false,
error: None,
});
}
let archive = self.client.download_skill(slug, Some(&latest)).await?;
let target_dir = self.skills_dir.join(slug);
if target_dir.exists() {
fs::remove_dir_all(&target_dir).context("remove old skill dir")?;
}
fs::create_dir_all(&target_dir).context("create skills_dir")?;
self.extract_archive(&archive, &target_dir)?;
if let Ok(prev) = self.get_installed_version(slug) {
let prev_origin_path = self.skills_dir.join(slug).join(".clawhub/origin.json");
if let Ok(buf) = std::fs::read_to_string(&prev_origin_path)
&& let Ok(prev_origin) = serde_json::from_str::<ClawHubOrigin>(&buf)
{
tracing::info!(
slug = %slug,
previous_version = %prev,
previous_sha256 = ?prev_origin.sha256,
"Updating ClawHub skill (replacing stored hash)"
);
}
}
let origin = ClawHubOrigin {
version: 1,
registry: self.client.base_url().to_string(),
slug: slug.to_string(),
installed_version: latest.clone(),
installed_at: chrono::Utc::now().to_rfc3339(),
sha256: Some(archive.sha256.clone()),
};
let origin_path = target_dir.join(".clawhub").join("origin.json");
fs::create_dir_all(origin_path.parent().unwrap())?;
fs::write(
&origin_path,
serde_json::to_string_pretty(&origin).context("serialize origin")?,
)?;
self.update_lockfile(slug, &latest)?;
Ok(UpdateResult {
ok: true,
slug: slug.to_string(),
previous_version: current,
version: latest,
changed: true,
error: None,
})
}
pub async fn verify_skill_integrity(&self, slug: &str) -> Result<bool> {
let origin_path = self.skills_dir.join(slug).join(".clawhub/origin.json");
if !origin_path.exists() {
anyhow::bail!("skill '{slug}' is not installed (no origin.json)");
}
let buf = std::fs::read_to_string(&origin_path).context("read origin.json")?;
let origin: ClawHubOrigin = serde_json::from_str(&buf).context("parse origin.json")?;
let expected = origin.sha256.as_deref().unwrap_or("");
if expected.is_empty() {
anyhow::bail!("no stored sha256 for skill '{slug}' (legacy install)");
}
let archive = self
.client
.download_skill(slug, Some(&origin.installed_version))
.await?;
let actual = &archive.sha256;
let _ = std::fs::remove_file(&archive.path);
if actual == expected {
tracing::info!(
slug = %slug,
version = %origin.installed_version,
"ClawHub skill integrity verified"
);
Ok(true)
} else {
tracing::warn!(
slug = %slug,
version = %origin.installed_version,
expected = %expected,
actual = %actual,
"ClawHub skill hash mismatch — possible registry update, tampering, or corrupted install"
);
Ok(false)
}
}
pub async fn update_all(&self) -> Result<Vec<UpdateResult>> {
let lock = self.read_lockfile()?;
let mut results = Vec::with_capacity(lock.skills.len());
for (slug, entry) in lock.skills {
let result = match self.update(&slug).await {
Ok(r) => r,
Err(e) => UpdateResult {
ok: false,
slug,
previous_version: Some(entry.version),
version: String::new(),
changed: false,
error: Some(e.to_string()),
},
};
results.push(result);
}
Ok(results)
}
pub async fn check_updates(&self) -> Result<Vec<UpdateAvailable>> {
let lock = self.read_lockfile()?;
let skills: Vec<(String, ClawHubLockEntry)> = lock.skills.into_iter().collect();
let futures: Vec<_> = skills
.into_iter()
.map(|(slug, entry)| {
let client = self.client.clone();
async move {
let detail = client.get_skill(&slug).await.ok()?;
let latest = detail.latest_version.as_ref()?;
if latest.version != entry.version {
Some(UpdateAvailable {
slug,
current_version: entry.version,
latest_version: latest.version.clone(),
changelog: latest.changelog.clone(),
})
} else {
None
}
}
})
.collect();
let updates: Vec<UpdateAvailable> = futures::future::join_all(futures)
.await
.into_iter()
.flatten()
.collect();
Ok(updates)
}
fn read_lockfile(&self) -> Result<ClawHubLockfile> {
let path = self.lockfile_path();
if !path.exists() {
return Ok(ClawHubLockfile {
version: 1,
skills: HashMap::new(),
});
}
let mut file = fs::File::open(&path).context("open lockfile")?;
let mut buf = String::new();
file.read_to_string(&mut buf)
.context("read lockfile content")?;
serde_json::from_str(&buf).context("parse lockfile JSON")
}
fn write_lockfile(&self, lock: &ClawHubLockfile) -> Result<()> {
let path = self.lockfile_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).context("create .clawhub dir")?;
}
let json = serde_json::to_string_pretty(lock).context("serialize lockfile to JSON")?;
fs::write(&path, json).context("write lockfile")?;
Ok(())
}
fn lockfile_path(&self) -> PathBuf {
self.workspace_dir.join(".clawhub").join("lock.json")
}
fn extract_archive(&self, archive: &DownloadedArchive, target: &Path) -> Result<()> {
let file = fs::File::open(&archive.path).context("open downloaded zip")?;
let mut zip = zip::ZipArchive::new(file)?;
crate::skill::archive::extract_skill_zip(&mut zip, target)
}
fn update_lockfile(&self, slug: &str, version: &str) -> Result<()> {
let mut lock = self.read_lockfile()?;
lock.skills.insert(
slug.to_string(),
ClawHubLockEntry {
version: version.to_string(),
installed_at: chrono::Utc::now().to_rfc3339(),
},
);
self.write_lockfile(&lock)
}
fn get_installed_version(&self, slug: &str) -> Result<String> {
let origin_path = self
.skills_dir
.join(slug)
.join(".clawhub")
.join("origin.json");
let mut file = fs::File::open(&origin_path).context("open origin.json")?;
let mut buf = String::new();
file.read_to_string(&mut buf)
.context("read origin.json content")?;
let origin: ClawHubOrigin = serde_json::from_str(&buf).context("parse origin.json")?;
Ok(origin.installed_version)
}
pub fn client(&self) -> &ClawHubClient {
&self.client
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_install_result_serialize() {
let res = InstallResult {
ok: true,
slug: "test".to_string(),
version: "1.0.0".to_string(),
target_dir: PathBuf::from("/tmp/test"),
changelog: Some("fixes".to_string()),
};
let json = serde_json::to_string_pretty(&res).unwrap();
assert!(json.contains("\"ok\": true"));
assert!(json.contains("\"slug\": \"test\""));
}
#[test]
fn test_update_result_serialize() {
let res = UpdateResult {
ok: true,
slug: "test".to_string(),
previous_version: Some("1.0.0".to_string()),
version: "2.0.0".to_string(),
changed: true,
error: None,
};
let json = serde_json::to_string_pretty(&res).unwrap();
assert!(json.contains("\"version\": \"2.0.0\""));
assert!(json.contains("\"changed\": true"));
}
}