From 21fdba08a31df861afb13a16eb551953c895a518 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 10 Jan 2026 01:01:47 +0200 Subject: [PATCH] feat: Integrate ClawdHub skill registry --- bun.lock | 16 +- packages/desktop/src-tauri/Cargo.lock | 238 +++++++- packages/desktop/src-tauri/Cargo.toml | 1 + .../desktop/src-tauri/src/skills_catalog.rs | 549 +++++++++++++++++- .../skills/catalog/InstallSkillDialog.tsx | 13 +- .../skills/catalog/SkillsCatalogPage.tsx | 20 +- packages/ui/src/lib/api/types.ts | 22 + packages/vscode/package.json | 6 +- packages/vscode/src/skillsCatalog.ts | 304 +++++++++- packages/web/package.json | 8 +- packages/web/server/index.js | 55 ++ .../server/lib/skills-catalog/clawdhub/api.js | 129 ++++ .../lib/skills-catalog/clawdhub/index.js | 30 + .../lib/skills-catalog/clawdhub/install.js | 200 +++++++ .../lib/skills-catalog/clawdhub/scan.js | 73 +++ .../lib/skills-catalog/curated-sources.js | 10 +- 16 files changed, 1651 insertions(+), 23 deletions(-) create mode 100644 packages/web/server/lib/skills-catalog/clawdhub/api.js create mode 100644 packages/web/server/lib/skills-catalog/clawdhub/index.js create mode 100644 packages/web/server/lib/skills-catalog/clawdhub/install.js create mode 100644 packages/web/server/lib/skills-catalog/clawdhub/scan.js diff --git a/bun.lock b/bun.lock index 14a52a89..b7e955c1 100644 --- a/bun.lock +++ b/bun.lock @@ -74,7 +74,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.4.5", + "version": "1.4.6", "dependencies": { "@openchamber/ui": "workspace:*", "@tauri-apps/plugin-notification": "^2.3.3", @@ -97,7 +97,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.4.5", + "version": "1.4.6", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -169,16 +169,18 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.4.5", + "version": "1.4.6", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.1.8", + "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", "react-dom": "^19.1.1", "yaml": "^2.8.1", }, "devDependencies": { + "@types/adm-zip": "^0.5.7", "@types/vscode": "^1.85.0", "@vitejs/plugin-react": "^5.0.0", "@vscode/vsce": "^3.2.1", @@ -190,7 +192,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.4.5", + "version": "1.4.6", "bin": { "openchamber": "./bin/cli.js", }, @@ -210,6 +212,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@types/react-syntax-highlighter": "^15.5.13", + "adm-zip": "^0.5.16", "bun-pty": "^0.4.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -236,6 +239,7 @@ "devDependencies": { "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", + "@types/adm-zip": "^0.5.7", "@types/node": "^24.3.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", @@ -811,6 +815,8 @@ "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], + "@types/adm-zip": ["@types/adm-zip@0.5.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -993,6 +999,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 096de237..68d8f599 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -537,6 +548,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -611,6 +641,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -673,6 +705,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -710,6 +752,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -775,6 +823,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -881,6 +944,12 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + [[package]] name = "deranged" version = "0.5.5" @@ -923,6 +992,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1743,6 +1813,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2038,6 +2117,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2124,6 +2212,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.82" @@ -2293,6 +2391,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -2858,7 +2977,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.4.5" +version = "1.4.6" dependencies = [ "anyhow", "axum", @@ -2897,6 +3016,7 @@ dependencies = [ "urlencoding", "uuid", "window-vibrancy 0.7.1", + "zip 2.4.2", ] [[package]] @@ -2999,6 +3119,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4210,6 +4340,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4839,7 +4980,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -6467,6 +6608,15 @@ dependencies = [ "rustix", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.1" @@ -6598,6 +6748,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] [[package]] name = "zerotrie" @@ -6632,6 +6796,36 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.12.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.17", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zip" version = "4.6.1" @@ -6644,6 +6838,46 @@ dependencies = [ "memchr", ] +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "5.8.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 7ac41e4f..4191e739 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -52,6 +52,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" base64 = "0.22.1" urlencoding = "2.1" +zip = "2.1" [build-dependencies] tauri-build = { version = "2.5.3", features = [] } diff --git a/packages/desktop/src-tauri/src/skills_catalog.rs b/packages/desktop/src-tauri/src/skills_catalog.rs index b511caf5..d873b483 100644 --- a/packages/desktop/src-tauri/src/skills_catalog.rs +++ b/packages/desktop/src-tauri/src/skills_catalog.rs @@ -44,6 +44,21 @@ pub struct SkillsCatalogInstalledBadge { pub scope: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClawdHubSkillMetadata { + pub slug: String, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub downloads: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stars: Option, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsCatalogItem { @@ -63,6 +78,8 @@ pub struct SkillsCatalogItem { #[serde(skip_serializing_if = "Option::is_none")] pub warnings: Option>, pub installed: SkillsCatalogInstalledBadge, + #[serde(skip_serializing_if = "Option::is_none")] + pub clawdhub: Option, } #[derive(Debug, Clone, Serialize)] @@ -701,6 +718,180 @@ fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<& ) } +// ============== ClawdHub API ============== + +const CLAWDHUB_API_BASE: &str = "https://clawdhub.com/api/v1"; + +fn is_clawdhub_source(source: &str) -> bool { + source.starts_with("clawdhub:") +} + +#[derive(Debug, Deserialize)] +struct ClawdHubSkillOwner { + handle: Option, +} + +#[derive(Debug, Deserialize)] +struct ClawdHubSkillStats { + downloads: Option, + stars: Option, +} + +#[derive(Debug, Deserialize)] +struct ClawdHubSkillTags { + latest: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClawdHubSkillVersion { + version: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClawdHubSkillListItem { + slug: String, + display_name: Option, + summary: Option, + tags: Option, + latest_version: Option, + stats: Option, + owner: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClawdHubSkillsResponse { + items: Vec, + next_cursor: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClawdHubSkillInfoResponse { + skill: Option, + latest_version: Option, +} + +#[derive(Debug, Deserialize)] +struct ClawdHubSkillInfoSkill { + tags: Option, +} + +async fn scan_clawdhub() -> Result> { + let client = reqwest::Client::builder() + .user_agent("OpenChamber-Desktop/1.0") + .timeout(Duration::from_secs(30)) + .build()?; + + let mut all_items = Vec::new(); + let mut cursor: Option = None; + let max_pages = 20; + + for _ in 0..max_pages { + let url = match &cursor { + Some(c) => format!("{}{}?cursor={}", CLAWDHUB_API_BASE, "/skills", urlencoding::encode(c)), + None => format!("{}/skills", CLAWDHUB_API_BASE), + }; + + let response = client.get(&url).send().await?; + if !response.status().is_success() { + return Err(anyhow!("ClawdHub API error: {}", response.status())); + } + + let data: ClawdHubSkillsResponse = response.json().await?; + + for item in data.items { + let latest_version = item + .tags + .as_ref() + .and_then(|t| t.latest.clone()) + .or_else(|| item.latest_version.as_ref().and_then(|v| v.version.clone())) + .unwrap_or_else(|| "1.0.0".to_string()); + + all_items.push(SkillsCatalogItem { + source_id: "clawdhub".to_string(), + repo_source: "clawdhub:registry".to_string(), + repo_subpath: None, + git_identity_id: None, + skill_dir: item.slug.clone(), + skill_name: item.slug.clone(), + frontmatter_name: item.display_name.clone(), + description: item.summary, + installable: true, + warnings: None, + installed: SkillsCatalogInstalledBadge { + is_installed: false, + scope: None, + }, + clawdhub: Some(ClawdHubSkillMetadata { + slug: item.slug, + version: latest_version, + display_name: item.display_name, + owner: item.owner.and_then(|o| o.handle), + downloads: item.stats.as_ref().and_then(|s| s.downloads), + stars: item.stats.as_ref().and_then(|s| s.stars), + }), + }); + } + + match data.next_cursor { + Some(c) => cursor = Some(c), + None => break, + } + + // Rate limiting + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Sort by downloads (most popular first) + all_items.sort_by(|a, b| { + let a_downloads = a.clawdhub.as_ref().and_then(|c| c.downloads).unwrap_or(0); + let b_downloads = b.clawdhub.as_ref().and_then(|c| c.downloads).unwrap_or(0); + b_downloads.cmp(&a_downloads) + }); + + Ok(all_items) +} + +async fn download_clawdhub_skill(slug: &str, version: &str) -> Result> { + let client = reqwest::Client::builder() + .user_agent("OpenChamber-Desktop/1.0") + .timeout(Duration::from_secs(60)) + .build()?; + + let url = format!( + "{}/download?slug={}&version={}", + CLAWDHUB_API_BASE, + urlencoding::encode(slug), + urlencoding::encode(version) + ); + + let response = client.get(&url).send().await?; + if !response.status().is_success() { + return Err(anyhow!("ClawdHub download error: {}", response.status())); + } + + Ok(response.bytes().await?.to_vec()) +} + +async fn fetch_clawdhub_skill_info(slug: &str) -> Result { + let client = reqwest::Client::builder() + .user_agent("OpenChamber-Desktop/1.0") + .timeout(Duration::from_secs(15)) + .build()?; + + let url = format!("{}/skills/{}", CLAWDHUB_API_BASE, urlencoding::encode(slug)); + let response = client.get(&url).send().await?; + + if !response.status().is_success() { + return Err(anyhow!("ClawdHub skill info error: {}", response.status())); + } + + Ok(response.json().await?) +} + fn load_custom_catalog_sources() -> Vec { let settings_path = dirs::home_dir().map(|mut home| { home.push(".config"); @@ -786,14 +977,24 @@ fn load_custom_catalog_sources() -> Vec { } pub async fn get_curated_sources() -> Vec { - let mut sources = vec![SkillsCatalogSource { - id: "anthropic".to_string(), - label: "Anthropic".to_string(), - description: Some("Anthropic’s public skills repository".to_string()), - source: "anthropics/skills".to_string(), - default_subpath: Some("skills".to_string()), - git_identity_id: None, - }]; + let mut sources = vec![ + SkillsCatalogSource { + id: "anthropic".to_string(), + label: "Anthropic".to_string(), + description: Some("Anthropic's public skills repository".to_string()), + source: "anthropics/skills".to_string(), + default_subpath: Some("skills".to_string()), + git_identity_id: None, + }, + SkillsCatalogSource { + id: "clawdhub".to_string(), + label: "ClawdHub".to_string(), + description: Some("Community skill registry with vector search".to_string()), + source: "clawdhub:registry".to_string(), + default_subpath: None, + git_identity_id: None, + }, + ]; sources.extend(load_custom_catalog_sources()); sources @@ -811,6 +1012,69 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal let mut items_by_source: HashMap> = HashMap::new(); for src in &sources { + // Handle ClawdHub sources separately (API-based, not git-based) + if is_clawdhub_source(&src.source) { + let key = "clawdhub:registry".to_string(); + + let maybe_cached = if refresh { + None + } else { + let cache = CATALOG_CACHE.lock().await; + cache.get(&key).cloned() + }; + + let cached_items = maybe_cached.and_then(|entry| { + if entry.created_at.elapsed() < CACHE_TTL { + Some(entry.items) + } else { + None + } + }); + + let scanned_items = if let Some(items) = cached_items { + items + } else { + let items = match scan_clawdhub().await { + Ok(items) => items, + Err(_) => { + items_by_source.insert(src.id.clone(), vec![]); + continue; + } + }; + + let mut cache = CATALOG_CACHE.lock().await; + cache.insert( + key, + CacheEntry { + created_at: Instant::now(), + items: items.clone(), + }, + ); + + items + }; + + // Update installed badges + let enriched: Vec = scanned_items + .into_iter() + .map(|mut item| { + let installed = installed_by_name.get(&item.skill_name); + item.installed = SkillsCatalogInstalledBadge { + is_installed: installed.is_some(), + scope: installed.map(|s| match s.scope { + opencode_config::Scope::User => "user".to_string(), + opencode_config::Scope::Project => "project".to_string(), + }), + }; + item + }) + .collect(); + + items_by_source.insert(src.id.clone(), enriched); + continue; + } + + // Handle GitHub sources (git clone based) let parsed = match parse_repo_source(&src.source, None) { Ok(p) => p, Err(_) => { @@ -899,6 +1163,7 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal opencode_config::Scope::Project => "project".to_string(), }), }, + clawdhub: None, }); } @@ -989,6 +1254,7 @@ pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse { is_installed: false, scope: None, }, + clawdhub: None, }); } items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name)); @@ -1019,10 +1285,18 @@ pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse { } } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClawdHubInstallMeta { + pub slug: String, + pub version: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillsInstallSelection { pub skill_dir: String, + pub clawdhub: Option, } #[derive(Debug, Deserialize)] @@ -1123,10 +1397,269 @@ async fn copy_dir_no_symlinks(src: &Path, dst: &Path) -> Result<()> { Ok(()) } +async fn install_skills_from_clawdhub( + working_directory: &Path, + req: &SkillsInstallRequest, +) -> SkillsInstallResponse { + let mut installed = vec![]; + let mut skipped = vec![]; + + let _user_dir = match user_skill_dir() { + Ok(d) => d, + Err(e) => { + return SkillsInstallResponse { + ok: false, + installed: None, + skipped: None, + error: Some(simple_error("unknown", &e.to_string())), + }; + } + }; + + // Check for conflicts first + let mut conflicts = vec![]; + for sel in &req.selections { + let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir); + if !validate_skill_name(slug) { + continue; + } + + let target = match target_skill_dir(&req.scope, working_directory, slug) { + Ok(p) => p, + Err(_) => continue, + }; + + if target.exists() { + let decision = req + .conflict_decisions + .as_ref() + .and_then(|m| m.get(slug)) + .map(|s| s.as_str()); + + let auto = req.conflict_policy.as_deref().unwrap_or("prompt"); + + if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" { + conflicts.push(SkillConflict { + skill_name: slug.to_string(), + scope: req.scope.clone(), + }); + } + } + } + + if !conflicts.is_empty() { + return SkillsInstallResponse { + ok: false, + installed: None, + skipped: None, + error: Some(conflicts_error(conflicts)), + }; + } + + for sel in &req.selections { + let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir); + let mut version = sel.clawdhub.as_ref().map(|c| c.version.as_str()).unwrap_or("latest").to_string(); + + if !validate_skill_name(slug) { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: "Invalid skill name".to_string(), + }); + continue; + } + + // Resolve 'latest' version + if version == "latest" { + if let Ok(info) = fetch_clawdhub_skill_info(slug).await { + version = info + .skill + .and_then(|s| s.tags) + .and_then(|t| t.latest) + .or_else(|| info.latest_version.and_then(|v| v.version)) + .unwrap_or_else(|| "latest".to_string()); + } + } + + let target_dir = match target_skill_dir(&req.scope, working_directory, slug) { + Ok(p) => p, + Err(e) => { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: e.to_string(), + }); + continue; + } + }; + + let exists = target_dir.exists(); + let mut decision = req + .conflict_decisions + .as_ref() + .and_then(|m| m.get(slug)) + .cloned(); + + let auto = req.conflict_policy.as_deref().unwrap_or("prompt"); + + if decision.is_none() { + if exists && auto == "skipAll" { + decision = Some("skip".to_string()); + } + if exists && auto == "overwriteAll" { + decision = Some("overwrite".to_string()); + } + if !exists { + decision = Some("overwrite".to_string()); + } + } + + if exists && decision.as_deref() == Some("skip") { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: "Already installed (skipped)".to_string(), + }); + continue; + } + + if exists && decision.as_deref() == Some("overwrite") { + let _ = tokio::fs::remove_dir_all(&target_dir).await; + } + + // Download and extract + match download_clawdhub_skill(slug, &version).await { + Ok(zip_data) => { + let temp_dir = std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4())); + let _ = tokio::fs::remove_dir_all(&temp_dir).await; + + // Extract ZIP using the zip crate + let cursor = std::io::Cursor::new(&zip_data); + let mut archive = match zip::ZipArchive::new(cursor) { + Ok(a) => a, + Err(e) => { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: format!("Failed to open ZIP: {}", e), + }); + continue; + } + }; + + if let Err(e) = std::fs::create_dir_all(&temp_dir) { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: format!("Failed to create temp dir: {}", e), + }); + continue; + } + + let mut extract_ok = true; + for i in 0..archive.len() { + let mut file = match archive.by_index(i) { + Ok(f) => f, + Err(e) => { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: format!("Failed to read ZIP entry: {}", e), + }); + extract_ok = false; + break; + } + }; + + let outpath = temp_dir.join(file.name()); + + if file.name().ends_with('/') { + let _ = std::fs::create_dir_all(&outpath); + } else { + if let Some(p) = outpath.parent() { + let _ = std::fs::create_dir_all(p); + } + let mut outfile = match std::fs::File::create(&outpath) { + Ok(f) => f, + Err(e) => { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: format!("Failed to create file: {}", e), + }); + extract_ok = false; + break; + } + }; + if let Err(e) = std::io::copy(&mut file, &mut outfile) { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: format!("Failed to write file: {}", e), + }); + extract_ok = false; + break; + } + } + } + + if !extract_ok { + let _ = std::fs::remove_dir_all(&temp_dir); + continue; + } + + // Verify SKILL.md exists + let skill_md_path = temp_dir.join("SKILL.md"); + if !skill_md_path.exists() { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: "SKILL.md not found in downloaded package".to_string(), + }); + let _ = std::fs::remove_dir_all(&temp_dir); + continue; + } + + // Move to target directory + if let Some(parent) = target_dir.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + if let Err(e) = tokio::fs::rename(&temp_dir, &target_dir).await { + // If rename fails (cross-device), try copy + if let Err(e2) = copy_dir_no_symlinks(&temp_dir, &target_dir).await { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: format!("Failed to move files: {} / {}", e, e2), + }); + let _ = tokio::fs::remove_dir_all(&temp_dir).await; + continue; + } + let _ = tokio::fs::remove_dir_all(&temp_dir).await; + } + + installed.push(InstalledSkill { + skill_name: slug.to_string(), + scope: req.scope.clone(), + }); + } + Err(e) => { + skipped.push(SkippedSkill { + skill_name: slug.to_string(), + reason: format!("Failed to download: {}", e), + }); + } + } + } + + SkillsInstallResponse { + ok: true, + installed: Some(installed), + skipped: Some(skipped), + error: None, + } +} + pub async fn install_skills( working_directory: &Path, req: SkillsInstallRequest, ) -> SkillsInstallResponse { + // Handle ClawdHub sources separately + if is_clawdhub_source(&req.source) { + return install_skills_from_clawdhub(working_directory, &req).await; + } + let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref()); let selections: Vec = req diff --git a/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx index 1ad1e078..412bf486 100644 --- a/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx @@ -55,12 +55,23 @@ export const InstallSkillDialog: React.FC = ({ open, on skillDir: string; conflictDecisions?: Record; }) => { + // Build selection with clawdhub metadata if present + const selection: { skillDir: string; clawdhub?: { slug: string; version: string } } = { + skillDir: request.skillDir, + }; + if (item?.clawdhub) { + selection.clawdhub = { + slug: item.clawdhub.slug, + version: item.clawdhub.version, + }; + } + const result = await installSkills({ source: request.source, subpath: request.subpath, gitIdentityId: item?.gitIdentityId, scope: request.scope, - selections: [{ skillDir: request.skillDir }], + selections: [selection], conflictPolicy: 'prompt', conflictDecisions: request.conflictDecisions, }); diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx index 92cfd72d..09b63e57 100644 --- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx +++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx @@ -11,7 +11,7 @@ import { SelectTrigger, } from '@/components/ui/select'; -import { RiAddLine, RiDeleteBinLine, RiRefreshLine } from '@remixicon/react'; +import { RiAddLine, RiDeleteBinLine, RiRefreshLine, RiDownloadLine, RiStarLine } from '@remixicon/react'; import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; import type { SkillsCatalogItem } from '@/lib/api/types'; @@ -254,6 +254,24 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo ) : (
No description provided
)} + {item.clawdhub ? ( +
+ {item.clawdhub.owner ? ( + by {item.clawdhub.owner} + ) : null} + + + {item.clawdhub.downloads?.toLocaleString() ?? 0} + + {(item.clawdhub.stars ?? 0) > 0 ? ( + + + {item.clawdhub.stars} + + ) : null} + v{item.clawdhub.version} +
+ ) : null} {item.warnings?.length ? (
{item.warnings.join(' · ')}
) : null} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index b4e6de03..c6edd04a 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -456,12 +456,15 @@ export type RuntimeAPISelector = (apis: RuntimeAPIs) => TValue; export type SkillsCatalogSourceId = string; +export type SkillsCatalogSourceType = 'github' | 'clawdhub'; + export interface SkillsCatalogSource { id: SkillsCatalogSourceId; label: string; description?: string; source: string; defaultSubpath?: string; + sourceType?: SkillsCatalogSourceType; } export interface SkillsCatalogItemInstalledBadge { @@ -469,6 +472,18 @@ export interface SkillsCatalogItemInstalledBadge { scope?: 'user' | 'project'; } +export interface ClawdHubSkillMetadata { + slug: string; + version: string; + displayName?: string; + owner?: string; + downloads?: number; + stars?: number; + versionsCount?: number; + createdAt?: number; + updatedAt?: number; +} + export interface SkillsCatalogItem { sourceId: SkillsCatalogSourceId; repoSource: string; @@ -481,6 +496,8 @@ export interface SkillsCatalogItem { installable: boolean; warnings?: string[]; installed?: SkillsCatalogItemInstalledBadge; + /** ClawdHub-specific metadata (present only for ClawdHub sources) */ + clawdhub?: ClawdHubSkillMetadata; } export interface SkillsCatalogResponse { @@ -511,6 +528,11 @@ export interface SkillsRepoScanResponse { export interface SkillsInstallSelection { skillDir: string; + /** ClawdHub-specific metadata for installation */ + clawdhub?: { + slug: string; + version: string; + }; } export interface SkillsInstallRequest { diff --git a/packages/vscode/package.json b/packages/vscode/package.json index eee667fa..e957a2de 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -187,17 +187,19 @@ "package": "vsce package --no-dependencies" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", "@types/vscode": "^1.85.0", "@vitejs/plugin-react": "^5.0.0", + "@vscode/vsce": "^3.2.1", "concurrently": "^9.2.1", "esbuild": "^0.24.2", "typescript": "~5.8.3", - "vite": "^7.1.2", - "@vscode/vsce": "^3.2.1" + "vite": "^7.1.2" }, "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.1.8", + "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts index 77b8cc0d..5a773585 100644 --- a/packages/vscode/src/skillsCatalog.ts +++ b/packages/vscode/src/skillsCatalog.ts @@ -4,6 +4,7 @@ import path from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; import yaml from 'yaml'; +import AdmZip from 'adm-zip'; import { discoverSkills } from './opencodeConfig'; @@ -32,6 +33,15 @@ type SkillFrontmatter = { [key: string]: unknown; }; +export type ClawdHubSkillMetadata = { + slug: string; + version: string; + displayName?: string; + owner?: string; + downloads?: number; + stars?: number; +}; + export type SkillsCatalogItem = { repoSource: string; repoSubpath?: string; @@ -41,6 +51,7 @@ export type SkillsCatalogItem = { description?: string; installable: boolean; warnings?: string[]; + clawdhub?: ClawdHubSkillMetadata; }; type SkillsCatalogItemWithBadge = SkillsCatalogItem & { @@ -68,12 +79,267 @@ export const CURATED_SOURCES: CuratedSource[] = [ { id: 'anthropic', label: 'Anthropic', - description: "Anthropic’s public skills repository", + description: "Anthropic's public skills repository", source: 'anthropics/skills', defaultSubpath: 'skills', }, + { + id: 'clawdhub', + label: 'ClawdHub', + description: 'Community skill registry with vector search', + source: 'clawdhub:registry', + }, ]; +// ============== ClawdHub API ============== + +const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1'; +const CLAWDHUB_RATE_LIMIT_MS = 100; +let clawdhubLastRequest = 0; + +function isClawdHubSource(source: string): boolean { + return typeof source === 'string' && source.startsWith('clawdhub:'); +} + +async function clawdhubFetch(url: string, options?: RequestInit): Promise { + const now = Date.now(); + const elapsed = now - clawdhubLastRequest; + if (elapsed < CLAWDHUB_RATE_LIMIT_MS) { + await new Promise((resolve) => setTimeout(resolve, CLAWDHUB_RATE_LIMIT_MS - elapsed)); + } + clawdhubLastRequest = Date.now(); + + return fetch(url, { + ...options, + headers: { + Accept: 'application/json', + 'User-Agent': 'OpenChamber-VSCode/1.0', + ...options?.headers, + }, + }); +} + +type ClawdHubSkillListItem = { + slug: string; + displayName?: string; + summary?: string; + tags?: { latest?: string }; + latestVersion?: { version?: string }; + stats?: { downloads?: number; stars?: number }; + owner?: { handle?: string }; +}; + +type ClawdHubSkillsResponse = { + items: ClawdHubSkillListItem[]; + nextCursor?: string; +}; + +async function scanClawdHub(): Promise { + try { + const allItems: SkillsCatalogItem[] = []; + let cursor: string | null = null; + const maxPages = 20; + + for (let page = 0; page < maxPages; page++) { + const url = cursor + ? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}` + : `${CLAWDHUB_API_BASE}/skills`; + + const response = await clawdhubFetch(url); + if (!response.ok) { + throw new Error(`ClawdHub API error: ${response.status}`); + } + + const data = (await response.json()) as ClawdHubSkillsResponse; + + for (const item of data.items || []) { + const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0'; + + allItems.push({ + repoSource: 'clawdhub:registry', + skillDir: item.slug, + skillName: item.slug, + frontmatterName: item.displayName || item.slug, + description: item.summary || undefined, + installable: true, + clawdhub: { + slug: item.slug, + version: latestVersion, + displayName: item.displayName, + owner: item.owner?.handle, + downloads: item.stats?.downloads || 0, + stars: item.stats?.stars || 0, + }, + }); + } + + if (!data.nextCursor) break; + cursor = data.nextCursor; + } + + // Sort by downloads (most popular first) + allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0)); + + return { ok: true, items: allItems }; + } catch (error) { + return { + ok: false, + error: { + kind: 'networkError', + message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub', + }, + }; + } +} + +async function downloadClawdHubSkill(slug: string, version: string): Promise { + const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`; + const response = await clawdhubFetch(url, { headers: { Accept: 'application/zip' } }); + + if (!response.ok) { + throw new Error(`ClawdHub download error: ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); +} + +type ClawdHubSkillInfoResponse = { + skill?: { tags?: { latest?: string } }; + latestVersion?: { version?: string }; +}; + +async function fetchClawdHubSkillInfo(slug: string): Promise { + const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`; + const response = await clawdhubFetch(url); + + if (!response.ok) { + throw new Error(`ClawdHub skill error: ${response.status}`); + } + + return response.json() as Promise; +} + +export async function installSkillsFromClawdHub(options: { + scope: SkillScope; + workingDirectory?: string; + selections: Array<{ skillDir: string; clawdhub?: { slug: string; version: string } }>; + conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; + conflictDecisions?: Record; +}): Promise { + if (options.scope === 'project' && !options.workingDirectory) { + return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } }; + } + + const userSkillDir = getUserSkillBaseDir(); + const requestedSkills = options.selections || []; + + if (requestedSkills.length === 0) { + return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } }; + } + + // Check for conflicts + const conflicts: Array<{ skillName: string; scope: SkillScope }> = []; + for (const sel of requestedSkills) { + const slug = sel.clawdhub?.slug || sel.skillDir; + if (!validateSkillName(slug)) continue; + + const targetDir = options.scope === 'user' + ? path.join(userSkillDir, slug) + : path.join(options.workingDirectory as string, '.opencode', 'skill', slug); + + if (fs.existsSync(targetDir)) { + const decision = options.conflictDecisions?.[slug]; + const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll'; + if (!decision && !hasAutoPolicy) { + conflicts.push({ skillName: slug, scope: options.scope }); + } + } + } + + if (conflicts.length > 0) { + return { ok: false, error: { kind: 'conflicts', message: 'Some skills already exist in the selected scope', conflicts } }; + } + + const installed: Array<{ skillName: string; scope: SkillScope }> = []; + const skipped: Array<{ skillName: string; reason: string }> = []; + + for (const sel of requestedSkills) { + const slug = sel.clawdhub?.slug || sel.skillDir; + let version = sel.clawdhub?.version || 'latest'; + + if (!validateSkillName(slug)) { + skipped.push({ skillName: slug, reason: 'Invalid skill name' }); + continue; + } + + try { + // Resolve 'latest' version + if (version === 'latest') { + try { + const info = await fetchClawdHubSkillInfo(slug); + version = info.skill?.tags?.latest || info.latestVersion?.version || version; + } catch { + // Fall back to 'latest' + } + } + + const targetDir = options.scope === 'user' + ? path.join(userSkillDir, slug) + : path.join(options.workingDirectory as string, '.opencode', 'skill', slug); + + const exists = fs.existsSync(targetDir); + let decision = options.conflictDecisions?.[slug] || null; + if (!decision) { + if (exists && options.conflictPolicy === 'skipAll') decision = 'skip'; + if (exists && options.conflictPolicy === 'overwriteAll') decision = 'overwrite'; + if (!exists) decision = 'overwrite'; + } + + if (exists && decision === 'skip') { + skipped.push({ skillName: slug, reason: 'Already installed (skipped)' }); + continue; + } + + if (exists && decision === 'overwrite') { + await safeRm(targetDir); + } + + // Download and extract + const zipBuffer = await downloadClawdHubSkill(slug, version); + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${slug}-`)); + + try { + const zip = new AdmZip(zipBuffer); + zip.extractAllTo(tempDir, true); + + // Verify SKILL.md exists + const skillMdPath = path.join(tempDir, 'SKILL.md'); + if (!fs.existsSync(skillMdPath)) { + skipped.push({ skillName: slug, reason: 'SKILL.md not found in downloaded package' }); + continue; + } + + // Move to target directory + await fs.promises.mkdir(path.dirname(targetDir), { recursive: true }); + await fs.promises.rename(tempDir, targetDir); + + installed.push({ skillName: slug, scope: options.scope }); + } catch (extractError) { + await safeRm(tempDir); + throw extractError; + } + } catch (error) { + skipped.push({ + skillName: slug, + reason: error instanceof Error ? error.message : 'Failed to download or extract skill', + }); + } + } + + return { ok: true, installed, skipped }; +} + function validateSkillName(skillName: string): boolean { if (skillName.length < 1 || skillName.length > 64) return false; return SKILL_NAME_PATTERN.test(skillName); @@ -544,6 +810,40 @@ export async function getSkillsCatalog( const itemsBySource: Record = {}; for (const src of sources) { + // Handle ClawdHub sources separately (API-based, not git-based) + if (isClawdHubSource(src.source)) { + const cacheKey = 'clawdhub:registry'; + let cached = !refresh ? catalogCache.get(cacheKey) : null; + if (cached && Date.now() >= cached.expiresAt) { + catalogCache.delete(cacheKey); + cached = null; + } + + let items: SkillsCatalogItem[] = []; + if (cached) { + items = cached.items; + } else { + const scanned = await scanClawdHub(); + if (!scanned.ok) { + itemsBySource[src.id] = []; + continue; + } + items = scanned.items || []; + catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items }); + } + + itemsBySource[src.id] = items.map((item) => { + const installed = installedByName.get(item.skillName); + return { + sourceId: src.id, + ...item, + installed: installed ? { isInstalled: true, scope: installed.scope } : { isInstalled: false }, + }; + }); + continue; + } + + // Handle GitHub sources (git clone based) const parsed = parseSkillRepoSource(src.source); if (!parsed.ok) { itemsBySource[src.id] = []; @@ -584,3 +884,5 @@ export async function getSkillsCatalog( return { ok: true as const, sources, itemsBySource }; } + +export { isClawdHubSource }; diff --git a/packages/web/package.json b/packages/web/package.json index eafa6cd3..7a9b0570 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,7 +22,6 @@ "start": "node bin/cli.js serve" }, "dependencies": { - "qrcode-terminal": "^0.12.0", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", @@ -38,16 +37,18 @@ "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@types/react-syntax-highlighter": "^15.5.13", - "ghostty-web": "0.3.0", + "adm-zip": "^0.5.16", + "bun-pty": "^0.4.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", + "ghostty-web": "0.3.0", "http-proxy-middleware": "^3.0.5", "jsonc-parser": "^3.3.1", "next-themes": "^0.4.6", - "bun-pty": "^0.4.5", "node-pty": "^1.1.0", + "qrcode-terminal": "^0.12.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", @@ -63,6 +64,7 @@ "devDependencies": { "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", + "@types/adm-zip": "^0.5.7", "@types/node": "^24.3.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index d507956e..7393ef0d 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -2643,6 +2643,7 @@ async function main(options = {}) { const { parseSkillRepoSource } = await import('./lib/skills-catalog/source.js'); const { scanSkillsRepository } = await import('./lib/skills-catalog/scan.js'); const { installSkillsFromRepository } = await import('./lib/skills-catalog/install.js'); + const { scanClawdHub, installSkillsFromClawdHub, isClawdHubSource } = await import('./lib/skills-catalog/clawdhub/index.js'); const { getProfiles, getProfile } = await import('./lib/git-identity-storage.js'); const listGitIdentitiesForResponse = () => { @@ -2699,6 +2700,37 @@ async function main(options = {}) { const itemsBySource = {}; for (const src of sources) { + // Handle ClawdHub sources separately (API-based, not git-based) + if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) { + const cacheKey = 'clawdhub:registry'; + let scanResult = !refresh ? getCachedScan(cacheKey) : null; + + if (!scanResult) { + const scanned = await scanClawdHub(); + if (!scanned.ok) { + itemsBySource[src.id] = []; + continue; + } + scanResult = scanned; + setCachedScan(cacheKey, scanResult); + } + + const items = (scanResult.items || []).map((item) => { + const installed = installedByName.get(item.skillName); + return { + ...item, + sourceId: src.id, + installed: installed + ? { isInstalled: true, scope: installed.scope } + : { isInstalled: false }, + }; + }); + + itemsBySource[src.id] = items; + continue; + } + + // Handle GitHub sources (git clone based) const parsed = parseSkillRepoSource(src.source); if (!parsed.ok) { itemsBySource[src.id] = []; @@ -2808,6 +2840,29 @@ async function main(options = {}) { } workingDirectory = resolved.directory; } + + // Handle ClawdHub sources (ZIP download based) + if (isClawdHubSource(source)) { + const result = await installSkillsFromClawdHub({ + scope, + workingDirectory, + userSkillDir: SKILL_DIR, + selections, + conflictPolicy, + conflictDecisions, + }); + + if (!result.ok) { + if (result.error?.kind === 'conflicts') { + return res.status(409).json({ ok: false, error: result.error }); + } + return res.status(400).json({ ok: false, error: result.error }); + } + + return res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] }); + } + + // Handle GitHub sources (git clone based) const identity = resolveGitIdentity(gitIdentityId); const result = await installSkillsFromRepository({ diff --git a/packages/web/server/lib/skills-catalog/clawdhub/api.js b/packages/web/server/lib/skills-catalog/clawdhub/api.js new file mode 100644 index 00000000..c82f996d --- /dev/null +++ b/packages/web/server/lib/skills-catalog/clawdhub/api.js @@ -0,0 +1,129 @@ +/** + * ClawdHub API client + * + * ClawdHub is a public skill registry at https://clawdhub.com + * This client provides methods to fetch skills list and download skill packages. + */ + +const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1'; + +// Rate limiting: ClawdHub allows 120 requests/minute +const RATE_LIMIT_DELAY_MS = 100; +let lastRequestTime = 0; + +async function rateLimitedFetch(url, options = {}) { + const now = Date.now(); + const elapsed = now - lastRequestTime; + if (elapsed < RATE_LIMIT_DELAY_MS) { + await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY_MS - elapsed)); + } + lastRequestTime = Date.now(); + + const response = await fetch(url, { + ...options, + headers: { + Accept: 'application/json', + 'User-Agent': 'OpenChamber/1.0', + ...options.headers, + }, + }); + + return response; +} + +/** + * Fetch paginated list of skills from ClawdHub + * @param {Object} options + * @param {string} [options.cursor] - Pagination cursor from previous response + * @returns {Promise<{ items: Array, nextCursor?: string }>} + */ +export async function fetchClawdHubSkills({ cursor } = {}) { + const url = cursor + ? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}` + : `${CLAWDHUB_API_BASE}/skills`; + + const response = await rateLimitedFetch(url); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`ClawdHub API error (${response.status}): ${text || response.statusText}`); + } + + const data = await response.json(); + return { + items: data.items || [], + nextCursor: data.nextCursor || null, + }; +} + +/** + * Fetch details for a specific skill version + * @param {string} slug - Skill slug/identifier + * @param {string} [version='latest'] - Version string or 'latest' + * @returns {Promise<{ skill: Object, version: Object }>} + */ +export async function fetchClawdHubSkillVersion(slug, version = 'latest') { + // For 'latest', we need to first get the skill metadata to find the latest version + if (version === 'latest') { + const skillResponse = await rateLimitedFetch(`${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`); + if (!skillResponse.ok) { + throw new Error(`ClawdHub skill not found: ${slug}`); + } + const skillData = await skillResponse.json(); + const latestVersion = skillData.skill?.tags?.latest || skillData.latestVersion?.version; + if (!latestVersion) { + throw new Error(`No latest version found for skill: ${slug}`); + } + version = latestVersion; + } + + const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`; + const response = await rateLimitedFetch(url); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`ClawdHub version error (${response.status}): ${text || response.statusText}`); + } + + return response.json(); +} + +/** + * Download a skill package as a ZIP buffer + * @param {string} slug - Skill slug/identifier + * @param {string} version - Specific version string + * @returns {Promise} - ZIP file contents + */ +export async function downloadClawdHubSkill(slug, version) { + const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`; + + const response = await rateLimitedFetch(url, { + headers: { + Accept: 'application/zip', + }, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`ClawdHub download error (${response.status}): ${text || response.statusText}`); + } + + return response.arrayBuffer(); +} + +/** + * Get skill metadata without version details + * @param {string} slug - Skill slug/identifier + * @returns {Promise} + */ +export async function fetchClawdHubSkillInfo(slug) { + const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`; + const response = await rateLimitedFetch(url); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`ClawdHub skill error (${response.status}): ${text || response.statusText}`); + } + + return response.json(); +} diff --git a/packages/web/server/lib/skills-catalog/clawdhub/index.js b/packages/web/server/lib/skills-catalog/clawdhub/index.js new file mode 100644 index 00000000..29b022c1 --- /dev/null +++ b/packages/web/server/lib/skills-catalog/clawdhub/index.js @@ -0,0 +1,30 @@ +/** + * ClawdHub integration module + * + * Provides skill browsing and installation from the ClawdHub registry. + * https://clawdhub.com + */ + +export { scanClawdHub } from './scan.js'; +export { installSkillsFromClawdHub } from './install.js'; +export { + fetchClawdHubSkills, + fetchClawdHubSkillVersion, + fetchClawdHubSkillInfo, + downloadClawdHubSkill, +} from './api.js'; + +/** + * Check if a source string refers to ClawdHub + * @param {string} source + * @returns {boolean} + */ +export function isClawdHubSource(source) { + return typeof source === 'string' && source.startsWith('clawdhub:'); +} + +/** + * ClawdHub source identifier used in curated sources + */ +export const CLAWDHUB_SOURCE_ID = 'clawdhub'; +export const CLAWDHUB_SOURCE_STRING = 'clawdhub:registry'; diff --git a/packages/web/server/lib/skills-catalog/clawdhub/install.js b/packages/web/server/lib/skills-catalog/clawdhub/install.js new file mode 100644 index 00000000..704c8b53 --- /dev/null +++ b/packages/web/server/lib/skills-catalog/clawdhub/install.js @@ -0,0 +1,200 @@ +/** + * ClawdHub skill installation + * + * Downloads skills from ClawdHub as ZIP files and extracts them + * to the appropriate skill directory. + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import AdmZip from 'adm-zip'; + +import { downloadClawdHubSkill, fetchClawdHubSkillInfo } from './api.js'; + +const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; + +function validateSkillName(skillName) { + if (typeof skillName !== 'string') return false; + if (skillName.length < 1 || skillName.length > 64) return false; + return SKILL_NAME_PATTERN.test(skillName); +} + +async function safeRm(dir) { + try { + await fs.promises.rm(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +async function ensureDir(dirPath) { + await fs.promises.mkdir(dirPath, { recursive: true }); +} + +function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) { + if (scope === 'user') { + return path.join(userSkillDir, skillName); + } + + if (!workingDirectory) { + throw new Error('workingDirectory is required for project installs'); + } + + return path.join(workingDirectory, '.opencode', 'skill', skillName); +} + +/** + * Install skills from ClawdHub registry + * @param {Object} options + * @param {string} options.scope - 'user' or 'project' + * @param {string} [options.workingDirectory] - Required for project scope + * @param {string} options.userSkillDir - User skills directory + * @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } } + * @param {string} [options.conflictPolicy] - 'prompt', 'skipAll', or 'overwriteAll' + * @param {Object} [options.conflictDecisions] - Per-skill conflict decisions + * @returns {Promise<{ ok: boolean, installed?: Array, skipped?: Array, error?: Object }>} + */ +export async function installSkillsFromClawdHub({ + scope, + workingDirectory, + userSkillDir, + selections, + conflictPolicy, + conflictDecisions, +} = {}) { + if (scope !== 'user' && scope !== 'project') { + return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } }; + } + + if (!userSkillDir) { + return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } }; + } + + if (scope === 'project' && !workingDirectory) { + return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } }; + } + + const requestedSkills = Array.isArray(selections) ? selections : []; + if (requestedSkills.length === 0) { + return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } }; + } + + // Build installation plans + const skillPlans = requestedSkills.map((sel) => { + const slug = sel.clawdhub?.slug || sel.skillDir; + const version = sel.clawdhub?.version || 'latest'; + return { + slug, + version, + installable: validateSkillName(slug), + }; + }); + + // Check for conflicts before downloading + const conflicts = []; + for (const plan of skillPlans) { + if (!plan.installable) { + continue; + } + + const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug }); + if (fs.existsSync(targetDir)) { + const decision = conflictDecisions?.[plan.slug]; + const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll'; + if (!decision && !hasAutoPolicy) { + conflicts.push({ skillName: plan.slug, scope }); + } + } + } + + if (conflicts.length > 0) { + return { + ok: false, + error: { + kind: 'conflicts', + message: 'Some skills already exist in the selected scope', + conflicts, + }, + }; + } + + const installed = []; + const skipped = []; + + for (const plan of skillPlans) { + if (!plan.installable) { + skipped.push({ skillName: plan.slug, reason: 'Invalid skill name' }); + continue; + } + + try { + // Resolve 'latest' version if needed + let resolvedVersion = plan.version; + if (resolvedVersion === 'latest') { + try { + const info = await fetchClawdHubSkillInfo(plan.slug); + resolvedVersion = info.skill?.tags?.latest || info.latestVersion?.version || plan.version; + } catch { + // Fall back to 'latest' tag if info fetch fails + resolvedVersion = 'latest'; + } + } + + const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug }); + const exists = fs.existsSync(targetDir); + + // Determine conflict resolution + let decision = conflictDecisions?.[plan.slug] || null; + if (!decision) { + if (exists && conflictPolicy === 'skipAll') decision = 'skip'; + if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite'; + if (!exists) decision = 'overwrite'; // No conflict, proceed + } + + if (exists && decision === 'skip') { + skipped.push({ skillName: plan.slug, reason: 'Already installed (skipped)' }); + continue; + } + + if (exists && decision === 'overwrite') { + await safeRm(targetDir); + } + + // Download the skill ZIP + const zipBuffer = await downloadClawdHubSkill(plan.slug, resolvedVersion); + + // Extract to a temp directory first for validation + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${plan.slug}-`)); + + try { + const zip = new AdmZip(Buffer.from(zipBuffer)); + zip.extractAllTo(tempDir, true); + + // Verify SKILL.md exists + const skillMdPath = path.join(tempDir, 'SKILL.md'); + if (!fs.existsSync(skillMdPath)) { + skipped.push({ skillName: plan.slug, reason: 'SKILL.md not found in downloaded package' }); + continue; + } + + // Move to target directory + await ensureDir(path.dirname(targetDir)); + await fs.promises.rename(tempDir, targetDir); + + installed.push({ skillName: plan.slug, scope }); + } catch (extractError) { + await safeRm(tempDir); + throw extractError; + } + } catch (error) { + console.error(`Failed to install ClawdHub skill "${plan.slug}":`, error); + skipped.push({ + skillName: plan.slug, + reason: error instanceof Error ? error.message : 'Failed to download or extract skill', + }); + } + } + + return { ok: true, installed, skipped }; +} diff --git a/packages/web/server/lib/skills-catalog/clawdhub/scan.js b/packages/web/server/lib/skills-catalog/clawdhub/scan.js new file mode 100644 index 00000000..4448a343 --- /dev/null +++ b/packages/web/server/lib/skills-catalog/clawdhub/scan.js @@ -0,0 +1,73 @@ +/** + * ClawdHub skill scanning + * + * Fetches all available skills from the ClawdHub registry + * and transforms them into SkillsCatalogItem format. + */ + +import { fetchClawdHubSkills } from './api.js'; + +const MAX_PAGES = 20; // Safety limit to prevent infinite loops + +/** + * Scan ClawdHub registry for all available skills + * @returns {Promise<{ ok: boolean, items?: Array, error?: Object }>} + */ +export async function scanClawdHub() { + try { + const allItems = []; + let cursor = null; + + for (let page = 0; page < MAX_PAGES; page++) { + const { items, nextCursor } = await fetchClawdHubSkills({ cursor }); + + for (const item of items) { + const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0'; + + allItems.push({ + sourceId: 'clawdhub', + repoSource: 'clawdhub:registry', + repoSubpath: null, + gitIdentityId: null, + skillDir: item.slug, + skillName: item.slug, + frontmatterName: item.displayName || item.slug, + description: item.summary || null, + installable: true, + warnings: [], + // ClawdHub-specific metadata + clawdhub: { + slug: item.slug, + version: latestVersion, + displayName: item.displayName, + owner: item.owner?.handle || null, + downloads: item.stats?.downloads || 0, + stars: item.stats?.stars || 0, + versionsCount: item.stats?.versions || 1, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }, + }); + } + + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + // Sort by downloads (most popular first) + allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0)); + + return { ok: true, items: allItems }; + } catch (error) { + console.error('ClawdHub scan error:', error); + return { + ok: false, + error: { + kind: 'networkError', + message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub', + }, + }; + } +} diff --git a/packages/web/server/lib/skills-catalog/curated-sources.js b/packages/web/server/lib/skills-catalog/curated-sources.js index ec4d9187..6f6cf9d3 100644 --- a/packages/web/server/lib/skills-catalog/curated-sources.js +++ b/packages/web/server/lib/skills-catalog/curated-sources.js @@ -2,9 +2,17 @@ export const CURATED_SKILLS_SOURCES = [ { id: 'anthropic', label: 'Anthropic', - description: "Anthropic’s public skills repository", + description: "Anthropic's public skills repository", source: 'anthropics/skills', defaultSubpath: 'skills', + sourceType: 'github', + }, + { + id: 'clawdhub', + label: 'ClawdHub', + description: 'Community skill registry with vector search', + source: 'clawdhub:registry', + sourceType: 'clawdhub', }, ];