use reqwest::Client;
use tracing::debug;
#[derive(Debug, Clone)]
pub struct SearchResult {
pub slug: String,
pub version: Option<String>,
pub description: Option<String>,
pub downloads: Option<u64>,
pub installs: Option<u64>,
pub stars: Option<u64>,
pub registry: String,
}
pub enum Registry {
Clawhub {
client: Client,
api_base: String,
token: Option<String>,
},
Skillhub {
client: Client,
search_url: String,
index_url: String,
},
Skillsh {
client: Client,
},
Iwencai {
client: Client,
list_url: String,
},
}
impl Registry {
pub fn name(&self) -> &str {
match self {
Registry::Clawhub { .. } => "clawhub.ai",
Registry::Skillhub { .. } => "skillhub",
Registry::Skillsh { .. } => "skills.sh",
Registry::Iwencai { .. } => "iwencai",
}
}
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
match self {
Registry::Clawhub { client, api_base, token } => {
search_clawhub(client, api_base, token.as_deref(), query).await
}
Registry::Skillhub { client, search_url, index_url } => {
search_skillhub(client, search_url, index_url, query).await
}
Registry::Skillsh { client } => {
search_skillsh(client, query).await
}
Registry::Iwencai { client, list_url } => {
search_iwencai(client, list_url, query).await
}
}
}
}
pub async fn search_concurrent(registries: &[Registry], query: &str) -> Vec<SearchResult> {
let futures: Vec<_> = registries.iter().map(|r| r.search(query)).collect();
let all_results: Vec<Vec<SearchResult>> = futures::future::join_all(futures).await;
debug!(
registries = registries.iter().map(|r| r.name()).collect::<Vec<_>>().join(", "),
counts = all_results.iter().map(|v| v.len().to_string()).collect::<Vec<_>>().join("+"),
"concurrent search complete"
);
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut merged: Vec<SearchResult> = Vec::new();
for result in all_results.into_iter().flatten() {
let key = normalize_slug(&result.slug);
if let Some(&idx) = seen.get(&key) {
let existing = &mut merged[idx];
if result.installs.unwrap_or(0) > existing.installs.unwrap_or(0) {
existing.installs = result.installs;
}
if existing.description.is_none() {
if let Some(desc) = result.description {
existing.description = Some(if result.registry != existing.registry {
format!("[{}] {}", result.registry, desc)
} else {
desc
});
}
}
if existing.version.is_none() {
existing.version = result.version;
}
} else {
seen.insert(key, merged.len());
merged.push(result);
}
}
merged.sort_by(|a, b| popularity_score(b).cmp(&popularity_score(a)));
merged
}
async fn search_clawhub(
client: &Client,
api_base: &str,
token: Option<&str>,
query: &str,
) -> Vec<SearchResult> {
let url = format!("{}/v1/search?q={}", api_base, url_encode(query));
let mut req = client.get(&url);
if let Some(t) = token {
req = req.bearer_auth(t);
}
let Ok(resp) = req.send().await else { return vec![] };
if !resp.status().is_success() { return vec![]; }
let Ok(body) = resp.json::<serde_json::Value>().await else { return vec![] };
parse_standard_response(&body, "clawhub.ai")
}
async fn search_skillhub(
client: &Client,
search_url: &str,
_index_url: &str,
query: &str,
) -> Vec<SearchResult> {
let url = format!(
"{}?keyword={}&page=1&pageSize=20",
search_url,
url_encode(query)
);
let Ok(resp) = client.get(&url).send().await else { return vec![] };
if !resp.status().is_success() { return vec![]; }
let Ok(body) = resp.json::<serde_json::Value>().await else { return vec![] };
let arr = body
.get("data")
.and_then(|d| d.get("skills"))
.and_then(|v| v.as_array());
let Some(arr) = arr else { return vec![] };
arr.iter()
.map(|item| {
let desc = item["description_zh"]
.as_str()
.filter(|s| !s.is_empty())
.or_else(|| item["description"].as_str())
.map(|s| s.to_owned());
SearchResult {
slug: item["slug"].as_str().unwrap_or("unknown").to_owned(),
version: item["version"].as_str().map(|s| s.to_owned()),
description: desc,
downloads: item["downloads"].as_u64(),
installs: item["installs"].as_u64(),
stars: item["stars"].as_u64(),
registry: "skillhub".to_owned(),
}
})
.collect()
}
async fn search_iwencai(client: &Client, list_url: &str, query: &str) -> Vec<SearchResult> {
let url = if list_url.contains('?') {
format!("{list_url}&size=100&page=1")
} else {
format!("{list_url}?size=100&page=1")
};
let Ok(resp) = client.get(&url).send().await else { return vec![] };
if !resp.status().is_success() { return vec![]; }
let Ok(body) = resp.json::<serde_json::Value>().await else { return vec![] };
let q = query.trim().to_lowercase();
let arr = body
.get("data")
.and_then(|d| d.get("records"))
.and_then(|v| v.as_array());
let Some(arr) = arr else { return vec![] };
arr.iter()
.filter(|item| {
let name = item["name"].as_str().unwrap_or("");
if !name.starts_with("hithink-") { return false; }
if q.is_empty() { return true; }
let q_lc = q.as_str();
let cn_name = item["cn_name"].as_str().unwrap_or("").to_lowercase();
let desc = item["description"].as_str().unwrap_or("").to_lowercase();
name.to_lowercase().contains(q_lc) || cn_name.contains(q_lc) || desc.contains(q_lc)
})
.map(|item| {
let raw_desc = item["description"].as_str().unwrap_or("");
let cn_name = item["cn_name"].as_str().unwrap_or("");
let desc = if !cn_name.is_empty() {
format!("{cn_name} — {raw_desc}")
} else {
raw_desc.to_owned()
};
SearchResult {
slug: item["name"].as_str().unwrap_or("unknown").to_owned(),
version: item["version"].as_str().map(|s| s.to_owned()),
description: if desc.is_empty() { None } else { Some(desc) },
downloads: item["download_count"].as_u64(),
installs: item["download_success_count"].as_u64(),
stars: item["star_count"].as_u64(),
registry: "iwencai".to_owned(),
}
})
.collect()
}
async fn search_skillsh(client: &Client, query: &str) -> Vec<SearchResult> {
let url = format!("https://skills.sh/api/search?q={}&limit=20", url_encode(query));
let Ok(resp) = client.get(&url).send().await else { return vec![] };
if !resp.status().is_success() { return vec![]; }
let Ok(body) = resp.json::<serde_json::Value>().await else { return vec![] };
body.get("skills")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|item| {
let source = item["source"].as_str().unwrap_or("");
let skill_id = item["skillId"].as_str()
.or_else(|| item["name"].as_str())
.unwrap_or("unknown");
let slug = if source.is_empty() {
skill_id.to_owned()
} else {
format!("{source}@{skill_id}")
};
SearchResult {
slug,
version: None,
description: item["description"].as_str()
.or_else(|| item["summary"].as_str())
.map(|s| s.to_owned()),
downloads: None,
installs: item["installs"].as_u64(),
stars: item["stars"].as_u64(),
registry: "skills.sh".to_owned(),
}
})
.collect()
})
.unwrap_or_default()
}
fn parse_standard_response(body: &serde_json::Value, registry: &str) -> Vec<SearchResult> {
body.get("skills")
.or_else(|| body.get("results"))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(|item| to_result(item, registry)).collect())
.unwrap_or_default()
}
fn to_result(item: &serde_json::Value, registry: &str) -> SearchResult {
SearchResult {
slug: item["slug"].as_str()
.or_else(|| item["name"].as_str())
.unwrap_or("unknown")
.to_owned(),
version: item["version"].as_str().map(|s| s.to_owned()),
description: item["summary"].as_str()
.or_else(|| item["description"].as_str())
.map(|s| s.to_owned()),
downloads: item["downloads"].as_u64()
.or_else(|| item["download_count"].as_u64()),
installs: item["installs"].as_u64()
.or_else(|| item["install_count"].as_u64()),
stars: item["stars"].as_u64()
.or_else(|| item["favorites"].as_u64())
.or_else(|| item["star_count"].as_u64()),
registry: registry.to_owned(),
}
}
fn popularity_score(r: &SearchResult) -> u64 {
let installs = r.installs.unwrap_or(0);
let downloads = r.downloads.unwrap_or(0) / 2;
let stars = r.stars.unwrap_or(0).saturating_mul(10);
installs.saturating_add(downloads).saturating_add(stars)
}
pub fn normalize_slug(slug: &str) -> String {
if let Some((_, after)) = slug.rsplit_once('@') {
return after.to_lowercase();
}
slug.rsplit('/').next().unwrap_or(slug).to_lowercase()
}
pub(crate) fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
out.push('%');
out.push(char::from(b"0123456789ABCDEF"[(byte >> 4) as usize]));
out.push(char::from(b"0123456789ABCDEF"[(byte & 0xf) as usize]));
}
}
}
out
}