Skip to main content

microclaw_engine/internal/clawhub/
client.rs

1use crate::internal::clawhub::types::*;
2use microclaw_core::error::MicroClawError;
3
4pub struct ClawHubClient {
5    base_url: String,
6    token: Option<String>,
7    client: reqwest::Client,
8}
9
10impl ClawHubClient {
11    pub fn new(base_url: &str, token: Option<String>) -> Self {
12        Self {
13            base_url: base_url.to_string(),
14            token,
15            client: reqwest::Client::new(),
16        }
17    }
18
19    /// Search skills by query
20    pub async fn search(
21        &self,
22        query: &str,
23        limit: usize,
24        _sort: &str,
25    ) -> Result<Vec<SearchResult>, MicroClawError> {
26        // Use the dedicated search endpoint that actually filters by query
27        let url = format!(
28            "{}/api/v1/search?q={}&limit={}",
29            self.base_url, query, limit
30        );
31        let mut req = self.client.get(&url);
32        if let Some(ref token) = self.token {
33            req = req.header("Authorization", format!("Bearer {}", token));
34        }
35        let resp = req
36            .send()
37            .await
38            .map_err(|e| MicroClawError::Config(format!("ClawHub request failed: {}", e)))?;
39        let search_response: ApiSearchResponse = resp.json().await.map_err(|e| {
40            MicroClawError::Config(format!("Failed to parse search results: {}", e))
41        })?;
42        // Convert API response items to internal SearchResult type
43        Ok(search_response
44            .results
45            .into_iter()
46            .take(limit)
47            .map(SearchResult::from)
48            .collect())
49    }
50
51    /// Get skill metadata by slug
52    pub async fn get_skill(&self, slug: &str) -> Result<SkillMeta, MicroClawError> {
53        let url = format!("{}/api/v1/skills/{}", self.base_url, slug);
54        let mut req = self.client.get(&url);
55        if let Some(ref token) = self.token {
56            req = req.header("Authorization", format!("Bearer {}", token));
57        }
58        let resp = req
59            .send()
60            .await
61            .map_err(|e| MicroClawError::Config(format!("ClawHub request failed: {}", e)))?;
62        let get_response: GetSkillResponse = resp.json().await.map_err(|e| {
63            MicroClawError::Config(format!("Failed to parse skill metadata: {}", e))
64        })?;
65        // Convert API response to internal SkillMeta type
66        Ok(SkillMeta::from(get_response))
67    }
68
69    /// Download skill as ZIP bytes
70    pub async fn download_skill(
71        &self,
72        slug: &str,
73        version: &str,
74    ) -> Result<Vec<u8>, MicroClawError> {
75        // Prefer the configured registry domain first.
76        let mut candidate_urls = vec![
77            format!(
78                "{}/api/v1/download?slug={}&version={}",
79                self.base_url, slug, version
80            ),
81            format!(
82                "{}/api/v1/skills/{}/download?version={}",
83                self.base_url, slug, version
84            ),
85        ];
86
87        // Backward-compatible fallback used by the hosted registry.
88        if self.base_url.contains("clawhub.ai") {
89            candidate_urls.push(format!(
90                "https://wry-manatee-359.convex.site/api/v1/download?slug={}&version={}",
91                slug, version
92            ));
93        }
94
95        let mut last_error: Option<MicroClawError> = None;
96        for url in candidate_urls {
97            let mut req = self.client.get(&url);
98            if let Some(ref token) = self.token {
99                req = req.header("Authorization", format!("Bearer {}", token));
100            }
101
102            let resp = match req.send().await {
103                Ok(resp) => resp,
104                Err(e) => {
105                    last_error = Some(MicroClawError::Config(format!(
106                        "ClawHub download failed at {}: {}",
107                        url, e
108                    )));
109                    continue;
110                }
111            };
112
113            let resp = match resp.error_for_status() {
114                Ok(resp) => resp,
115                Err(e) => {
116                    last_error = Some(MicroClawError::Config(format!(
117                        "ClawHub download HTTP error at {}: {}",
118                        url, e
119                    )));
120                    continue;
121                }
122            };
123
124            let bytes = resp.bytes().await.map_err(|e| {
125                MicroClawError::Config(format!("Failed to read download from {}: {}", url, e))
126            })?;
127            return Ok(bytes.to_vec());
128        }
129
130        Err(last_error.unwrap_or_else(|| {
131            MicroClawError::Config("ClawHub download failed: no usable endpoint".into())
132        }))
133    }
134
135    /// List versions for a skill
136    pub async fn get_versions(&self, slug: &str) -> Result<Vec<SkillVersion>, MicroClawError> {
137        let url = format!("{}/api/v1/skills/{}/versions", self.base_url, slug);
138        let mut req = self.client.get(&url);
139        if let Some(ref token) = self.token {
140            req = req.header("Authorization", format!("Bearer {}", token));
141        }
142        let resp = req
143            .send()
144            .await
145            .map_err(|e| MicroClawError::Config(format!("ClawHub request failed: {}", e)))?;
146        let versions: Vec<SkillVersion> = resp
147            .json()
148            .await
149            .map_err(|e| MicroClawError::Config(format!("Failed to parse versions: {}", e)))?;
150        Ok(versions)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_client_construction() {
160        let client = ClawHubClient::new("https://clawhub.ai", None);
161        assert_eq!(client.base_url, "https://clawhub.ai");
162        assert!(client.token.is_none());
163    }
164
165    #[test]
166    fn test_client_with_token() {
167        let client = ClawHubClient::new("https://clawhub.ai", Some("test-token".into()));
168        assert!(client.token.is_some());
169    }
170}