oxios_kernel/skill/clawhub/
client.rs1use 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#[derive(Debug)]
17pub struct DownloadedArchive {
18 pub path: PathBuf,
20 pub sha256: String,
22}
23
24#[derive(Clone)]
26pub struct ClawHubClient {
27 base_url: Url,
28 client: reqwest::Client,
29}
30
31impl ClawHubClient {
32 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 pub fn base_url(&self) -> &Url {
56 &self.base_url
57 }
58
59 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 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 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 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#[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}