Skip to main content

oxios_kernel/skill/clawhub/
client.rs

1//! ClawHub API client.
2//!
3//! Thin wrapper around ClawHub REST API — search, get detail, download archive.
4
5use std::path::PathBuf;
6
7use anyhow::Result;
8use sha2::Digest;
9use url::Url;
10
11use super::types::{ClawHubSearchResult, ClawHubSkillDetail, SearchResponse};
12
13const DEFAULT_BASE_URL: &str = "https://clawhub.ai";
14
15/// Result of a successful archive download.
16#[derive(Debug)]
17pub struct DownloadedArchive {
18    /// Temporary file containing the zip bytes.
19    pub path: PathBuf,
20    /// SHA-256 hex digest of the downloaded bytes.
21    pub sha256: String,
22}
23
24/// ClawHub API client.
25#[derive(Clone)]
26pub struct ClawHubClient {
27    base_url: Url,
28    client: reqwest::Client,
29}
30
31impl ClawHubClient {
32    /// Create a new client targeting the given base URL, or the public
33    /// ClawHub registry if `base_url` is `None`.
34    pub fn new(base_url: Option<String>) -> Result<Self> {
35        let base = base_url
36            .map(|s| Url::parse(&s))
37            .unwrap_or_else(|| Url::parse(DEFAULT_BASE_URL))?;
38        let base = base
39            .join("/")
40            .map_err(|e| anyhow::anyhow!("invalid base URL: {e}"))?;
41
42        let client = reqwest::Client::builder()
43            .timeout(std::time::Duration::from_secs(60))
44            .connect_timeout(std::time::Duration::from_secs(10))
45            .build()
46            .map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}"))?;
47
48        Ok(Self {
49            base_url: base,
50            client,
51        })
52    }
53
54    /// Returns the base URL of the registry this client targets.
55    pub fn base_url(&self) -> &Url {
56        &self.base_url
57    }
58
59    /// Search for skills by query string.
60    pub async fn search_skills(
61        &self,
62        query: &str,
63        limit: Option<usize>,
64    ) -> Result<Vec<ClawHubSearchResult>> {
65        let mut url = self.base_url.join("/api/v1/search")?;
66        url.query_pairs_mut()
67            .append_pair("q", query)
68            .append_pair("limit", &limit.unwrap_or(20).to_string());
69
70        let mut req = self.client.get(url);
71        if let Ok(token) = std::env::var("CLAWHUB_TOKEN")
72            && !token.is_empty()
73        {
74            req = req.header("Authorization", format!("Bearer {token}"));
75        }
76
77        let resp = req.send().await?;
78        let status = resp.status();
79        if !status.is_success() {
80            let body = resp.text().await.unwrap_or_default();
81            anyhow::bail!("ClawHub search failed ({status}): {body}");
82        }
83
84        let body: SearchResponse = resp.json().await?;
85        Ok(body.results)
86    }
87
88    /// Fetch full detail for a skill by slug.
89    pub async fn get_skill(&self, slug: &str) -> Result<ClawHubSkillDetail> {
90        let url = self.base_url.join(&format!("/api/v1/skills/{slug}"))?;
91
92        let mut req = self.client.get(url);
93        if let Ok(token) = std::env::var("CLAWHUB_TOKEN")
94            && !token.is_empty()
95        {
96            req = req.header("Authorization", format!("Bearer {token}"));
97        }
98
99        let resp = req.send().await?;
100        let status = resp.status();
101        if !status.is_success() {
102            let body = resp.text().await.unwrap_or_default();
103            anyhow::bail!("ClawHub get_skill {slug} failed ({status}): {body}");
104        }
105
106        let detail: ClawHubSkillDetail = resp.json().await?;
107        Ok(detail)
108    }
109
110    /// Download a skill archive (zip) returning the temp file path and sha256.
111    pub async fn download_skill(
112        &self,
113        slug: &str,
114        version: Option<&str>,
115    ) -> Result<DownloadedArchive> {
116        let mut url = self.base_url.join("/api/v1/download")?;
117        url.query_pairs_mut().append_pair("slug", slug);
118        if let Some(v) = version {
119            url.query_pairs_mut().append_pair("version", v);
120        }
121
122        let mut req = self.client.get(url);
123        if let Ok(token) = std::env::var("CLAWHUB_TOKEN")
124            && !token.is_empty()
125        {
126            req = req.header("Authorization", format!("Bearer {token}"));
127        }
128
129        let resp = req.send().await?;
130        let status = resp.status();
131        if !status.is_success() {
132            let body = resp.text().await.unwrap_or_default();
133            anyhow::bail!("ClawHub download {slug} failed ({status}): {body}");
134        }
135
136        let bytes = resp.bytes().await?;
137        let sha256 = sha2::Sha256::digest(&bytes)
138            .iter()
139            .map(|b| format!("{b:02x}"))
140            .collect::<String>();
141
142        // Write to a temp file (deleted when dropped)
143        let mut tmp = tempfile::Builder::new()
144            .prefix("clawhub-")
145            .suffix(".zip")
146            .tempfile()?;
147        std::io::Write::write_all(&mut tmp, &bytes)?;
148
149        let path = tmp
150            .into_temp_path()
151            .keep()
152            .map_err(|e| anyhow::anyhow!("failed to persist temp file: {e}"))?;
153
154        Ok(DownloadedArchive { path, sha256 })
155    }
156}
157
158// ─── Tests ─────────────────────────────────────────────────────────────────
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_client_new_default() {
166        let client = ClawHubClient::new(None).unwrap();
167        assert_eq!(client.base_url.as_str(), "https://clawhub.ai/");
168    }
169
170    #[test]
171    fn test_client_new_custom_url() {
172        let client = ClawHubClient::new(Some("https://staging.clawhub.ai".to_string())).unwrap();
173        assert_eq!(client.base_url.as_str(), "https://staging.clawhub.ai/");
174    }
175
176    #[test]
177    fn test_client_new_invalid_url() {
178        assert!(ClawHubClient::new(Some("not-a-url".to_string())).is_err());
179    }
180}