Skip to main content

oxios_kernel/skill/skills_sh/
client.rs

1//! Skills.sh API client.
2//!
3//! HTTP client for the skills.sh REST API (v1). Supports search, listing,
4//! skill detail, curated skills, and security audits.
5//!
6//! # Authentication
7//!
8//! All endpoints require a Bearer token (`SKILLS_SH_TOKEN` env var or config).
9//! Request an API key by emailing `skills-api@vercel.com`.
10
11use anyhow::{Context, Result};
12use url::Url;
13
14use super::types::{
15    SkillsShAuditResponse, SkillsShCuratedResponse, SkillsShListResponse, SkillsShSearchResponse,
16    SkillsShSkillDetail,
17};
18
19const DEFAULT_BASE_URL: &str = "https://skills.sh";
20
21/// Skills.sh API client.
22#[derive(Clone)]
23pub struct SkillsShClient {
24    base_url: Url,
25    client: reqwest::Client,
26    api_key: Option<String>,
27}
28
29impl SkillsShClient {
30    /// Create a new client.
31    ///
32    /// - `base_url`: Override the default `https://skills.sh`.
33    /// - `api_key`: Bearer token for authentication. If `None`, falls back to
34    ///   the `SKILLS_SH_TOKEN` environment variable.
35    pub fn new(base_url: Option<String>, api_key: Option<String>) -> Result<Self> {
36        let base = base_url
37            .map(|s| Url::parse(&s))
38            .unwrap_or_else(|| Url::parse(DEFAULT_BASE_URL))?;
39        let base = base
40            .join("/")
41            .map_err(|e| anyhow::anyhow!("invalid base URL: {e}"))?;
42
43        let api_key = api_key.or_else(|| std::env::var("SKILLS_SH_TOKEN").ok());
44
45        Ok(Self {
46            base_url: base,
47            client: reqwest::Client::new(),
48            api_key,
49        })
50    }
51
52    /// Returns the base URL.
53    pub fn base_url(&self) -> &Url {
54        &self.base_url
55    }
56
57    /// Whether an API key is configured.
58    pub fn has_api_key(&self) -> bool {
59        self.api_key.is_some()
60    }
61
62    /// Search for skills by name, source, or description.
63    ///
64    /// Single-word queries use fuzzy matching. Multi-word queries use semantic search.
65    pub async fn search(
66        &self,
67        query: &str,
68        limit: Option<usize>,
69    ) -> Result<SkillsShSearchResponse> {
70        let mut url = self.base_url.join("/api/v1/skills/search")?;
71        url.query_pairs_mut()
72            .append_pair("q", query)
73            .append_pair("limit", &limit.unwrap_or(50).to_string());
74
75        let resp = self.get_response(url).await?;
76        let status = resp.status();
77        if !status.is_success() {
78            let body = resp.text().await.unwrap_or_default();
79            anyhow::bail!("skills.sh search failed ({status}): {body}");
80        }
81
82        resp.json().await.context("parse skills.sh search response")
83    }
84
85    /// Paginated leaderboard of all skills.
86    ///
87    /// - `view`: `"all-time"` (default), `"trending"`, or `"hot"`.
88    /// - `page`: 0-indexed page number.
89    /// - `per_page`: 1–500 results per page.
90    pub async fn list(
91        &self,
92        view: Option<&str>,
93        page: Option<i64>,
94        per_page: Option<i64>,
95    ) -> Result<SkillsShListResponse> {
96        let mut url = self.base_url.join("/api/v1/skills")?;
97        {
98            let mut qp = url.query_pairs_mut();
99            qp.append_pair("view", view.unwrap_or("all-time"));
100            if let Some(p) = page {
101                qp.append_pair("page", &p.to_string());
102            }
103            if let Some(pp) = per_page {
104                qp.append_pair("per_page", &pp.to_string());
105            }
106        }
107
108        let resp = self.get_response(url).await?;
109        let status = resp.status();
110        if !status.is_success() {
111            let body = resp.text().await.unwrap_or_default();
112            anyhow::bail!("skills.sh list failed ({status}): {body}");
113        }
114
115        resp.json().await.context("parse skills.sh list response")
116    }
117
118    /// Get detailed information about a single skill including file contents.
119    ///
120    /// The `id` parameter is the stable `"{source}/{slug}"` identifier,
121    /// e.g. `"vercel-labs/agent-skills/next-js-development"`.
122    pub async fn get_skill(&self, id: &str) -> Result<SkillsShSkillDetail> {
123        // The id is used directly as the path: /api/v1/skills/{id}
124        let url = self
125            .base_url
126            .join(&format!("/api/v1/skills/{id}"))
127            .context("construct skill detail URL")?;
128
129        let resp = self.get_response(url).await?;
130        let status = resp.status();
131        if !status.is_success() {
132            let body = resp.text().await.unwrap_or_default();
133            anyhow::bail!("skills.sh get_skill {id} failed ({status}): {body}");
134        }
135
136        resp.json()
137            .await
138            .context("parse skills.sh skill detail response")
139    }
140
141    /// Get the official curated set of first-party skills.
142    pub async fn curated(&self) -> Result<SkillsShCuratedResponse> {
143        let url = self.base_url.join("/api/v1/skills/curated")?;
144
145        let resp = self.get_response(url).await?;
146        let status = resp.status();
147        if !status.is_success() {
148            let body = resp.text().await.unwrap_or_default();
149            anyhow::bail!("skills.sh curated failed ({status}): {body}");
150        }
151
152        resp.json()
153            .await
154            .context("parse skills.sh curated response")
155    }
156
157    /// Get security audit results for a skill.
158    pub async fn audit(&self, id: &str) -> Result<SkillsShAuditResponse> {
159        let url = self
160            .base_url
161            .join(&format!("/api/v1/skills/audit/{id}"))
162            .context("construct audit URL")?;
163
164        let resp = self.get_response(url).await?;
165        let status = resp.status();
166        if status == reqwest::StatusCode::NOT_FOUND {
167            anyhow::bail!("no audits found for skill {id}");
168        }
169        if !status.is_success() {
170            let body = resp.text().await.unwrap_or_default();
171            anyhow::bail!("skills.sh audit {id} failed ({status}): {body}");
172        }
173
174        resp.json().await.context("parse skills.sh audit response")
175    }
176
177    // ─── Internal ────────────────────────────────────────────────────────
178
179    /// Build an authenticated GET request and send it.
180    async fn get_response(&self, url: Url) -> Result<reqwest::Response> {
181        let mut req = self.client.get(url);
182        if let Some(ref key) = self.api_key {
183            req = req.header("Authorization", format!("Bearer {key}"));
184        }
185        let resp = req.send().await?;
186        Ok(resp)
187    }
188}
189
190// ─── Tests ─────────────────────────────────────────────────────────────────
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_client_new_default() {
198        let client = SkillsShClient::new(None, None).unwrap();
199        assert_eq!(client.base_url.as_str(), "https://skills.sh/");
200    }
201
202    #[test]
203    fn test_client_new_custom_url() {
204        let client =
205            SkillsShClient::new(Some("https://staging.skills.sh".to_string()), None).unwrap();
206        assert_eq!(client.base_url.as_str(), "https://staging.skills.sh/");
207    }
208
209    #[test]
210    fn test_client_api_key_from_param() {
211        let client = SkillsShClient::new(None, Some("sk_test_123".to_string())).unwrap();
212        assert!(client.has_api_key());
213    }
214}