From 5b59aee0160cae57d8b13afcf615de827f308bf0 Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Tue, 3 Mar 2026 16:19:19 -0500 Subject: [PATCH] fix(skills): broaden ClawhHub URL detection for installer --- src/skills/mod.rs | 49 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 1982f7e91..4cce8ce96 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -1759,19 +1759,32 @@ fn validate_artifact_url( // Zip contents follow the OpenClaw convention: `_meta.json` + `SKILL.md` + scripts. const CLAWHUB_DOMAIN: &str = "clawhub.ai"; +const CLAWHUB_WWW_DOMAIN: &str = "www.clawhub.ai"; const CLAWHUB_DOWNLOAD_API: &str = "https://clawhub.ai/api/v1/download"; +fn is_clawhub_host(host: &str) -> bool { + host.eq_ignore_ascii_case(CLAWHUB_DOMAIN) || host.eq_ignore_ascii_case(CLAWHUB_WWW_DOMAIN) +} + +fn parse_clawhub_url(source: &str) -> Option { + let parsed = reqwest::Url::parse(source).ok()?; + match parsed.scheme() { + "https" | "http" => {} + _ => return None, + } + if !parsed.host_str().is_some_and(is_clawhub_host) { + return None; + } + Some(parsed) +} + /// Returns true if `source` is a ClawhHub skill reference. fn is_clawhub_source(source: &str) -> bool { if source.starts_with("clawhub:") { return true; } - // Auto-detect from domain: https://clawhub.ai/... - if let Some(rest) = source.strip_prefix("https://") { - let host = rest.split('/').next().unwrap_or(""); - return host == CLAWHUB_DOMAIN; - } - false + // Auto-detect from URL host, supporting both clawhub.ai and www.clawhub.ai. + parse_clawhub_url(source).is_some() } /// Convert a ClawhHub source string into the zip download URL. @@ -1794,14 +1807,16 @@ fn clawhub_download_url(source: &str) -> Result { } return Ok(format!("{CLAWHUB_DOWNLOAD_API}?slug={slug}")); } - // Profile URL: https://clawhub.ai// or https://clawhub.ai/ + // Profile URL: https://clawhub.ai// or https://www.clawhub.ai/. // Forward the full path as the slug so the API can resolve owner-namespaced skills. - if let Some(rest) = source.strip_prefix("https://") { - let path = rest - .strip_prefix(CLAWHUB_DOMAIN) - .unwrap_or("") - .trim_start_matches('/'); - let path = path.trim_end_matches('/'); + if let Some(parsed) = parse_clawhub_url(source) { + let path = parsed + .path_segments() + .into_iter() + .flatten() + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("/"); if path.is_empty() { anyhow::bail!("could not extract slug from ClawhHub URL: {source}"); } @@ -3230,6 +3245,8 @@ description = "Bare minimum" assert!(is_clawhub_source("https://clawhub.ai/steipete/gog")); assert!(is_clawhub_source("https://clawhub.ai/gog")); assert!(is_clawhub_source("https://clawhub.ai/user/my-skill")); + assert!(is_clawhub_source("https://www.clawhub.ai/steipete/gog")); + assert!(is_clawhub_source("http://clawhub.ai/steipete/gog")); } #[test] @@ -3252,6 +3269,12 @@ description = "Bare minimum" assert_eq!(url, "https://clawhub.ai/api/v1/download?slug=steipete/gog"); } + #[test] + fn clawhub_download_url_from_www_profile_url() { + let url = clawhub_download_url("https://www.clawhub.ai/steipete/gog").unwrap(); + assert_eq!(url, "https://clawhub.ai/api/v1/download?slug=steipete/gog"); + } + #[test] fn clawhub_download_url_from_single_path_url() { // Single-segment URL: path is just the skill name