Compare commits

...
14 Commits
65 changed files with 2343 additions and 379 deletions
+11 -2
View File
@@ -3,20 +3,29 @@
## Unreleased
### Added
- Admin: ban users and delete owned skills from management console.
- Moderation: auto-hide skills after 4 unique reports; per-user report cap; moderators can ban users.
- Uploads: require GitHub accounts to be at least 7 days old for skill + soul publish/import.
- CLI: add `inspect` to fetch skill metadata/files without installing.
- CLI: add moderation commands for hide/unhide/delete and ban users.
- Management: add filters for reported skills and users.
### Changed
- Deps: update dependencies to latest available versions.
- Reporting: require reasons, show them in management console, warn about abuse bans.
### Fixed
- Bans: batch hard-delete cleanup to avoid Convex read limits on large skills.
## 0.4.0 - 2026-01-30
### Added
- Web: show published skills on user profiles (thanks @njoylab, #20).
- CLI: include OpenClaw + Moltbot fallback skill roots for sync scans.
- CLI: include ClawHub + Moltbot fallback skill roots for sync scans.
- CLI: support OpenClaw configuration files (`OPENCLAW_CONFIG_PATH` / `OPENCLAW_STATE_DIR`).
### Changed
- Brand: rebrand to OpenClaw and publish CLI as `clawhub` (legacy `clawdhub` supported).
- Brand: rebrand to ClawHub and publish CLI as `clawhub` (legacy `clawdhub` supported).
- Domain: default site/registry now `https://clawhub.ai`; `.well-known/clawhub.json` preferred.
- Theme: persist theme under `clawhub-theme` (legacy key still read).
+6 -5
View File
@@ -1,4 +1,4 @@
# OpenClaw
# ClawHub
<p align="center">
<a href="https://github.com/openclaw/clawhub/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/openclaw/clawhub/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
@@ -6,7 +6,7 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
OpenClaw is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
ClawHub is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
Its designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same way you publish skills.
@@ -27,7 +27,7 @@ onlycrabs.ai: `https://onlycrabs.ai`
- Entry point is host-based: `onlycrabs.ai`.
- On the onlycrabs.ai host, the home page and nav default to souls.
- On OpenClaw, souls live under `/souls`.
- On ClawHub, souls live under `/souls`.
- Soul bundles only accept `SOUL.md` for now (no extra files).
## How it works (high level)
@@ -37,9 +37,10 @@ onlycrabs.ai: `https://onlycrabs.ai`
- Search: OpenAI embeddings (`text-embedding-3-small`) + Convex vector search.
- API schema + routes: `packages/schema` (`clawhub-schema`).
## Telemetry
OpenClaw tracks minimal **install telemetry** (to compute install counts) when you run `clawhub sync` while logged in.
ClawHub tracks minimal **install telemetry** (to compute install counts) when you run `clawhub sync` while logged in.
Disable via:
```bash
@@ -95,7 +96,7 @@ This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints values for y
## Nix plugins (nixmode skills)
OpenClaw can store a nix-clawdbot plugin pointer in SKILL frontmatter so the registry knows which
ClawHub can store a nix-clawdbot plugin pointer in SKILL frontmatter so the registry knows which
Nix package bundle to install. A nix plugin is different from a regular skill pack: it bundles the
skill pack, the CLI binary, and its config flags/requirements together.
+63 -61
View File
@@ -15,50 +15,50 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-router": "^1.151.6",
"@tanstack/react-router-devtools": "^1.151.6",
"@tanstack/react-start": "^1.152.0",
"@tanstack/router-plugin": "^1.151.6",
"@tanstack/react-devtools": "^0.9.4",
"@tanstack/react-router": "^1.157.18",
"@tanstack/react-router-devtools": "^1.157.18",
"@tanstack/react-start": "^1.157.18",
"@tanstack/router-plugin": "^1.157.18",
"@vercel/analytics": "^1.6.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.31.6",
"convex": "^1.31.7",
"convex-helpers": "^0.1.111",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.8",
"lucide-react": "^0.562.0",
"h3": "2.0.1-rc.11",
"lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1",
"nitro": "^3.0.1-alpha.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"nitro": "^3.0.1-alpha.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^6.0.4",
"vite-tsconfig-paths": "^6.0.5",
"yaml": "^2.8.2",
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@playwright/test": "^1.57.0",
"@tanstack/devtools-vite": "^0.4.1",
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.1",
"@tanstack/devtools-vite": "^0.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^25.0.9",
"@types/react": "^19.2.8",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.4.0",
"only-allow": "^1.2.1",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.0.0",
"only-allow": "^1.2.2",
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.11.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.17",
"vitest": "^4.0.18",
},
},
"packages/clawdhub": {
@@ -252,7 +252,7 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
"@exodus/bytes": ["@exodus/bytes@1.11.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
@@ -380,17 +380,17 @@
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.110.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QROrowwlrApI1fEScMknGWKM6GTM/Z2xwMnDqvSaEmzNazBsDUlE08Jasw610hFEsYAVU2K5sp/YaCa9ORdP4A=="],
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.11.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FU4e+w09D+2rkCVdL7I7zMuQOJ2tuapVhBGPGY66VAct2FUwFDVmgU+rNJ2hHIdc9uHg24v+FD8PcfFYpask8Q=="],
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IhdhiC183s5wdFDZSQC8PaFFq1QROiVT5ahz7ysgEKVnkNDjy82ieM7ZKiUfm2ncXNX2RcFGSSZrQO6plR+VAQ=="],
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.11.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-7sm1d920HfFsC3hIP7SJVm11WhYufA8qnLQQVk7odTpSzVUAT1jtG8LdfFigzgb38zHszQbsqJ7OjAgIW/OgmA=="],
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-KJmBg10Z1uGpJqxDzETXOytYyeVrKUepo8rCXeVkRlZ2QzZqMElgalFN4BI3ccgIPkQpzzu4SVzWNFz7yiKavQ=="],
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.11.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-eoJfdmHcpG9k8fufb8yL3rC3HC6QELoTEfs56lmGaRIHHmd1aj4MWDbGCqdRqPEp7oC5fVvFxi7wDkA1MDf99Q=="],
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-P6I3dSSpoEnjFzTMlrbcBHNbErSxceZmcVUslBxrrIUH1NSVS1XfSz6S75vT2Gay7Jv6LI7zTTVAk4cSqkfe+w=="],
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.11.3", "", { "os": "linux", "cpu": "x64" }, "sha512-t7jGK0vBApuAGvOnCPTxsdX+1e9nMdvqU3zHCJWQ7yUDaJxki0bCy4zbKfUgVo8ePeVRgIKWwqLFBOVTXQ5AMQ=="],
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-G0eAW3S7cp/vP7Kx6e7+Ze7WfNgSt1tc/rOexfLKnnIi+9BelyOa2wF9bWFPpxk3n3AdkBwKttU1/adDZlD87Q=="],
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.11.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-6ellG0zcWnj2b6Mr7fl19x+nlFIWGWoKCBlYnqNZ4CaziRYGpYx7PLwHhPJq331w7zzRRSnYqhyTrVluYjZADQ=="],
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-prgQEBiwp4TAxarh6dYbVOKw6riRJ6hB49vDD6DxQlOZQky7xHQ9qTec5/rf0JTUZ16YaJ9YfHycbJS3QVpTYw=="],
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.11.3", "", { "os": "win32", "cpu": "x64" }, "sha512-rzvfaRJPK9eRYVWMXCt8JtvOsVFAsqScgsFhnXzsipU6W1Te0g+b4q068o7hZ3NRTjJxNgFJj8ayOkZ6NbX0tA=="],
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-5xXTzZIT/1meWMmS60Q+FYWvWncc6iTfC8tyQt7GDfPUoqQvE5WVgHm1QjDSJvxTD+6AHphpCqdhXq/KtxagRw=="],
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.42.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ui5CdAcDsXPQwZQEXOOSWsilJWhgj9jqHCvYBm2tDE8zfwZZuF9q58+hGKH1x5y0SV4sRlyobB2Quq6uU6EgeA=="],
@@ -410,7 +410,7 @@
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@playwright/test": ["@playwright/test@1.58.0", "", { "dependencies": { "playwright": "1.58.0" }, "bin": { "playwright": "cli.js" } }, "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg=="],
"@playwright/test": ["@playwright/test@1.58.1", "", { "dependencies": { "playwright": "1.58.1" }, "bin": { "playwright": "cli.js" } }, "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -566,7 +566,7 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/devtools": ["@tanstack/devtools@0.10.4", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-GR/HMWe+eAZgSm/mOeuWMs/cXy3pEcrdMBU+OH0c6Qv1IXYv/xqru4aCSJPe+2/eJXng5ioqCsoVt9MztyU1mg=="],
"@tanstack/devtools": ["@tanstack/devtools@0.10.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-aptV4sMcdEn/zB8zqNqKSKi8pLzfB7BhdP2MuVmyfWgBDYNchqJjhviaxEXW3tJTolbWwc30o+jszwqxOIcIaA=="],
"@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.5", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0" } }, "sha512-hsNDE3iu4frt9cC2ppn1mNRnLKo2uc1/1hXAyY9z4UYb+o40M2clFAhiFoo4HngjfGJDV3x18KVVIq7W4Un+zA=="],
@@ -576,43 +576,43 @@
"@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.4.4", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-5xHXFyX3nom0UaNfiOM92o6ziaHjGo3mcSGe2HD5Xs8dWRZNpdZ0Smd0B9ddEhy0oB+gXyMzZgUJb9DmrZV0Mg=="],
"@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.4.1", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-PkMOomcWnl/pUkCqIjqL/csjPHtkMVBirDpJVOZR7XJZDxo5CuD7B+3KsujFCF4Dsn6QYlae97gCZvxi/CB76Q=="],
"@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.5.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-Ew+ZdTnmTlVjm4q+/XY/dolx/E1BWMYpiRDyU/MXqHf5epri4MLl5C4UZJaO+ZuUCsKPpsW+ufoM99E2Z4rhug=="],
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.3", "", { "dependencies": { "@tanstack/devtools": "0.10.4" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-SJTYWXWZkbWznwUwZ11awinPGB5StVIVyJXT0BFM1zUgjuajRwT8xRHl1oXVzVqqjJP5kfj89jkbFrcQPpq7Ng=="],
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.4", "", { "dependencies": { "@tanstack/devtools": "0.10.5" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-6wQf8gVKDks1VL+LI5SS4XWK8dQLIjcDF3iMZfidyesWJNmodWbWlRkdgCmK5SpDSbcygjbp3p+LG2nE/SZ1bQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.16", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg=="],
"@tanstack/react-router": ["@tanstack/react-router@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.18", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-qs//HcVhEZ0K2/Sqejol0vOWaFIh4EoYTQQix9FhHOyWvdUpGoTJS0+g/qxEnZZm7r9QNOrnyrYZ5CDAqnII6g=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.16", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.16" }, "peerDependencies": { "@tanstack/react-router": "^1.157.16", "@tanstack/router-core": "^1.157.16", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-g6ekyzumfLBX6T5e+Vu2r37Z2CFJKrWRFqIy3vZ6A3x7OcuPV8uXNjyrLSiT/IsGTiF8YzwI4nWJa4fyd7NlCw=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.18", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.18" }, "peerDependencies": { "@tanstack/react-router": "^1.157.18", "@tanstack/router-core": "^1.157.18", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-JHSOnwj8wkim1AppgPT1Jp+NtmiiJ4yLZ//Vo0sfrQSgOzlZgdFg4OdQP+9rYzuj3BNz+XoDdokXQhJxiSqSww=="],
"@tanstack/react-start": ["@tanstack/react-start@1.157.16", "", { "dependencies": { "@tanstack/react-router": "1.157.16", "@tanstack/react-start-client": "1.157.16", "@tanstack/react-start-server": "1.157.16", "@tanstack/router-utils": "^1.154.7", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-plugin-core": "1.157.16", "@tanstack/start-server-core": "1.157.16", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-FO6UYjsZyNaC0ickSSvClqfVZemp9/HWnbRJQU2dOKYQsI+wnznhLp9IkgG90iFBLcuMAWhcNHMiIuz603GJBg=="],
"@tanstack/react-start": ["@tanstack/react-start@1.157.18", "", { "dependencies": { "@tanstack/react-router": "1.157.18", "@tanstack/react-start-client": "1.157.18", "@tanstack/react-start-server": "1.157.18", "@tanstack/router-utils": "^1.154.7", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-plugin-core": "1.157.18", "@tanstack/start-server-core": "1.157.18", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-ytKblzB34SHmm/7euujl2rZvEjzIsvICFzzKHd7DETi11FSih/WIMU4RKErMtIIc8R2NETh9MPGFLb+XFDsU6A=="],
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.157.16", "", { "dependencies": { "@tanstack/react-router": "1.157.16", "@tanstack/router-core": "1.157.16", "@tanstack/start-client-core": "1.157.16", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-r3XTxYPJXZ/szhbloxqT6CQtsoEjw8DjbnZh/3ZsQv2PLKTOl925cy7YVdQc2cWZyXtn5e19Ig78R+8tsoTpig=="],
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.157.18", "", { "dependencies": { "@tanstack/react-router": "1.157.18", "@tanstack/router-core": "1.157.18", "@tanstack/start-client-core": "1.157.18", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-8bk6x7skZp62LnEC2PKegmTo4RrOMrah0RY60S3ZUDXAJGs+CF9a+0moidpawUQbWvoO6T413TVOnNFHyPixJQ=="],
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-router": "1.157.16", "@tanstack/router-core": "1.157.16", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-server-core": "1.157.16" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-1YkBss4SUQ+HqVC1yGN/j7VNwjvdHHd3K58fASe0bz+uf7GrkGJlRXPkMJdxJkkmefYHQfyBL+q7o723N4CMYA=="],
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-router": "1.157.18", "@tanstack/router-core": "1.157.18", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-server-core": "1.157.18" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-LQg9FjwXJpt2yS1EdEP9r67KE9Qeg/9fWhdso+wl7XqGc8It/IEYh4D9qci3o+TVKS2b+MhIRklM0tVJ/nh1jw=="],
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
"@tanstack/router-core": ["@tanstack/router-core@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA=="],
"@tanstack/router-core": ["@tanstack/router-core@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jGkyA3EEE01Sf6d4goi//poxQNb/Odc/GzpjZSW2zwG+wcXm9hEzcI6vU2IxhAU0dvvwQyQgtU1HXTcXQ/Xg4A=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.16", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.16", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-XBJTs/kMZYK6J2zhbGucHNuypwDB1t2vi8K5To+V6dUnLGBEyfQTf01fegiF4rpL1yXgomdGnP6aTiOFgldbVg=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.18", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.18", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-+eh3XzBUuoGxJr8b9kCLdyJN+zPsAxtNggEvCal7iI8WE6q3ujjUPYiqHNI+MS4thtxaeUdAXlEjak/+fdBPdg=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Ae2M00VTFjjED7glSCi/mMLENRzhEym6NgjoOx7UVNbCC/rLU/5ASDe5VIlDa8QLEqP5Pj088Gi51gjmRuICvQ=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.157.18", "", { "dependencies": { "@tanstack/router-core": "1.157.18", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-t6nZdaX+pYWaudwg5Yasu/o8IAK8FPc4Jwq+rZpyaCgeZn895Vc407hxoRss40/hK1jk03b8x349+b1JekiSqA=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.16", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.16", "@tanstack/router-generator": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.16", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-YQg7L06xyCJAYyrEJNZGAnDL8oChILU+G/eSDIwEfcWn5iLk+47x1Gcdxr82++47PWmOPhzuTo8edDQXWs7kAA=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.18", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.18", "@tanstack/router-generator": "1.157.18", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.18", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-1UrRnIhD4Ar0PpXwzIkxD8nfjzmO7oYRh4CkSUO+Xc6aD5poNB62aUWPp3vS5jnXDNSk0vr+N4QAPebjPKw0Hw=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.154.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA=="],
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16", "@tanstack/start-fn-stubs": "1.154.7", "@tanstack/start-storage-context": "1.157.16", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-O+7H133MWQTkOxmXJNhrLXiOhDcBlxvpEcCd/N25Ga6eyZ7/P5vvFzNkSSxeQNkZV+RiPWnA5B75gT+U+buz3w=="],
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.157.18", "", { "dependencies": { "@tanstack/router-core": "1.157.18", "@tanstack/start-fn-stubs": "1.154.7", "@tanstack/start-storage-context": "1.157.18", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-DehC8ONA3QTBbaB95sL8ID+lK284ETP8/k9RCifseXOzr5xWKNNGbe3+Fy8OYV1MtHIuOmCMqozzltPp5MTANg=="],
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.154.7", "", {}, "sha512-D69B78L6pcFN5X5PHaydv7CScQcKLzJeEYqs7jpuyyqGQHSUIZUjS955j+Sir8cHhuDIovCe2LmsYHeZfWf3dQ=="],
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.157.16", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.157.16", "@tanstack/router-generator": "1.157.16", "@tanstack/router-plugin": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-server-core": "1.157.16", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.1", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-VmRXuvP5flryUAHeBM4Xb06n544qLtyA2cwmlQLRTUYtQiQEAdd9CvCGy8CPAly3f7eeXKqC7aX0v3MwWkLR8w=="],
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.157.18", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.157.18", "@tanstack/router-generator": "1.157.18", "@tanstack/router-plugin": "1.157.18", "@tanstack/router-utils": "1.154.7", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-server-core": "1.157.18", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.1", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-qUVfdEoLf/WYUB1WASR1hcxmlGvIBKiLEIFnpG/Kd/E01BwtHhDjv6WsEusg0n2WrWnYT2/6tuJRDPyXObV7IA=="],
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/router-core": "1.157.16", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-storage-context": "1.157.16", "h3-v2": "npm:h3@2.0.1-rc.11", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3" } }, "sha512-PEltFleYfiqz6+KcmzNXxc1lXgT7VDNKP6G6i1TirdHBDbRJ9CIY+ASLPlhrRwqwA2PL9PpFjXZl8u5bH/+Q9A=="],
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/router-core": "1.157.18", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-storage-context": "1.157.18", "h3-v2": "npm:h3@2.0.1-rc.11", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3" } }, "sha512-0ixErUvQsVM9SwOOpjyUOpS9KZBDRv1aoM2+qnSGR3DxZUricy/XbCaDAMxReN/0aJQzo47Y5gjpSWaGWlImWw=="],
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16" } }, "sha512-56izE0oihAw2YRwYUEds2H+uO5dyT2CahXCgWX62+l+FHou09M9mSep68n1lBKPdphC2ZU3cPV7wnvgeraJWHg=="],
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.157.18", "", { "dependencies": { "@tanstack/router-core": "1.157.18" } }, "sha512-OqueMS78bULFTDw37uUR8s3yWdX9JVzW4uh/y8V9Iv3oEa6yAgGC2cfOy4My70UkkggAUhoVNopQZamPtL+EBQ=="],
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
@@ -650,7 +650,7 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
@@ -788,7 +788,7 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
@@ -872,7 +872,7 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"h3": ["h3@2.0.1-rc.8", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.0" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-IIMQG7qnXx1Ls75suuMHH4xtcvTFxsUguDIZB+dgdYr1RftLj59FkeWF1dOr+jnejDs8Eo+ZKV1CMqogFeqGRQ=="],
"h3": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
"h3-v2": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
@@ -942,7 +942,7 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="],
"jsdom": ["jsdom@28.0.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.11.0", "cssstyle": "^5.3.7", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.20.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -984,7 +984,7 @@
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
@@ -1126,7 +1126,7 @@
"oxlint": ["oxlint@1.42.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.42.0", "@oxlint/darwin-x64": "1.42.0", "@oxlint/linux-arm64-gnu": "1.42.0", "@oxlint/linux-arm64-musl": "1.42.0", "@oxlint/linux-x64-gnu": "1.42.0", "@oxlint/linux-x64-musl": "1.42.0", "@oxlint/win32-arm64": "1.42.0", "@oxlint/win32-x64": "1.42.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnspC/lrp8FgKNaONLLn14dm+W5t0SSlus6V5NJpgI2YNT1tkFYZt4fBf14ESxf9AAh98WBASnW5f0gtw462Lg=="],
"oxlint-tsgolint": ["oxlint-tsgolint@0.11.3", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.11.3", "@oxlint-tsgolint/darwin-x64": "0.11.3", "@oxlint-tsgolint/linux-arm64": "0.11.3", "@oxlint-tsgolint/linux-x64": "0.11.3", "@oxlint-tsgolint/win32-arm64": "0.11.3", "@oxlint-tsgolint/win32-x64": "0.11.3" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-zkuGXJzE5WIoGQ6CHG3GbxncPNrvUG9giTKdXMqKrlieCRxa9hGMvMJM+7DFxKSaryVAEFrTQJNrGJHpeMmFPg=="],
"oxlint-tsgolint": ["oxlint-tsgolint@0.11.4", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.11.4", "@oxlint-tsgolint/darwin-x64": "0.11.4", "@oxlint-tsgolint/linux-arm64": "0.11.4", "@oxlint-tsgolint/linux-x64": "0.11.4", "@oxlint-tsgolint/win32-arm64": "0.11.4", "@oxlint-tsgolint/win32-x64": "0.11.4" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-VyQc+69TxQwUdsEPiVFN7vNZdDVO/FHaEcHltnWs3O6rvwxv67uADlknQQO714sbRdEahOjgO5dFf+K9ili0gg=="],
"p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="],
@@ -1146,9 +1146,9 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"playwright": ["playwright@1.58.0", "", { "dependencies": { "playwright-core": "1.58.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ=="],
"playwright": ["playwright@1.58.1", "", { "dependencies": { "playwright-core": "1.58.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ=="],
"playwright-core": ["playwright-core@1.58.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw=="],
"playwright-core": ["playwright-core@1.58.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
@@ -1348,9 +1348,9 @@
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="],
"whatwg-url": ["whatwg-url@16.0.0", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ=="],
"which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
@@ -1402,15 +1402,17 @@
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"html-encoding-sniffer/@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"nitro/h3": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
"jsdom/undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+2
View File
@@ -28,6 +28,7 @@ import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
import type * as lib_badges from "../lib/badges.js";
import type * as lib_changelog from "../lib/changelog.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
import type * as lib_githubBackup from "../lib/githubBackup.js";
import type * as lib_githubImport from "../lib/githubImport.js";
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
@@ -89,6 +90,7 @@ declare const fullApi: ApiFromModules<{
"lib/badges": typeof lib_badges;
"lib/changelog": typeof lib_changelog;
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
"lib/githubBackup": typeof lib_githubBackup;
"lib/githubImport": typeof lib_githubImport;
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
+7
View File
@@ -28,6 +28,7 @@ import {
soulsPostRouterV1Http,
starsDeleteRouterV1Http,
starsPostRouterV1Http,
usersPostRouterV1Http,
whoamiV1Http,
} from './httpApiV1'
@@ -101,6 +102,12 @@ http.route({
handler: whoamiV1Http,
})
http.route({
pathPrefix: `${ApiRoutes.users}/`,
method: 'POST',
handler: usersPostRouterV1Http,
})
http.route({
path: ApiRoutes.souls,
method: 'GET',
+37
View File
@@ -524,6 +524,43 @@ describe('httpApiV1 handlers', () => {
expect(response2.status).toBe(200)
})
it('ban user requires auth', async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runMutation }),
new Request('https://example.com/api/v1/users/ban', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ handle: 'demo' }),
}),
)
expect(response.status).toBe(401)
})
it('ban user succeeds with handle', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:1',
user: { handle: 'p' },
} as never)
const runQuery = vi.fn().mockResolvedValue({ _id: 'users:2' })
const runMutation = vi
.fn()
.mockResolvedValueOnce(okRate())
.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 2 })
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/users/ban', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ handle: 'demo' }),
}),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.deletedSkills).toBe(2)
})
it('stars require auth', async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
const runMutation = vi.fn().mockResolvedValue(okRate())
+59
View File
@@ -526,6 +526,64 @@ async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
export const whoamiV1Http = httpAction(whoamiV1Handler)
async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/users/')
if (segments.length !== 1 || segments[0] !== 'ban') {
return text('Not found', 404, rate.headers)
}
let payload: Record<string, unknown>
try {
payload = (await request.json()) as Record<string, unknown>
} catch {
return text('Invalid JSON', 400, rate.headers)
}
const handleRaw = typeof payload.handle === 'string' ? payload.handle.trim() : ''
const userIdRaw = typeof payload.userId === 'string' ? payload.userId.trim() : ''
if (!handleRaw && !userIdRaw) {
return text('Missing userId or handle', 400, rate.headers)
}
let actorUserId: Id<'users'>
try {
const auth = await requireApiTokenUser(ctx, request)
actorUserId = auth.userId
} catch {
return text('Unauthorized', 401, rate.headers)
}
let targetUserId: Id<'users'> | null = userIdRaw ? (userIdRaw as Id<'users'>) : null
if (!targetUserId) {
const handle = handleRaw.toLowerCase()
const user = await ctx.runQuery(api.users.getByHandle, { handle })
if (!user?._id) return text('User not found', 404, rate.headers)
targetUserId = user._id
}
try {
const result = await ctx.runMutation(internal.users.banUserInternal, {
actorUserId,
targetUserId,
})
return json(result, 200, rate.headers)
} catch (error) {
const message = error instanceof Error ? error.message : 'Ban failed'
if (message.toLowerCase().includes('forbidden')) {
return text('Forbidden', 403, rate.headers)
}
if (message.toLowerCase().includes('not found')) {
return text(message, 404, rate.headers)
}
return text(message, 400, rate.headers)
}
}
export const usersPostRouterV1Http = httpAction(usersPostRouterV1Handler)
async function parseMultipartPublish(
ctx: ActionCtx,
request: Request,
@@ -1169,4 +1227,5 @@ export const __handlers = {
starsPostRouterV1Handler,
starsDeleteRouterV1Handler,
whoamiV1Handler,
usersPostRouterV1Handler,
}
+115
View File
@@ -0,0 +1,115 @@
/* @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { internal } from '../_generated/api'
import { requireGitHubAccountAge } from './githubAccount'
vi.mock('../_generated/api', () => ({
internal: {
users: {
getByIdInternal: Symbol('getByIdInternal'),
updateGithubMetaInternal: Symbol('updateGithubMetaInternal'),
},
},
}))
const ONE_DAY_MS = 24 * 60 * 60 * 1000
describe('requireGitHubAccountAge', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('uses cached githubCreatedAt when fresh', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: now.getTime() - 10 * ONE_DAY_MS,
githubFetchedAt: now.getTime() - ONE_DAY_MS + 1000,
})
const runMutation = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
expect(fetchMock).not.toHaveBeenCalled()
expect(runMutation).not.toHaveBeenCalled()
expect(runQuery).toHaveBeenCalledWith(internal.users.getByIdInternal, { userId: 'users:1' })
vi.useRealTimers()
})
it('rejects accounts younger than 7 days', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'newbie',
githubCreatedAt: now.getTime() - 2 * ONE_DAY_MS,
githubFetchedAt: now.getTime() - ONE_DAY_MS / 2,
})
const runMutation = vi.fn()
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/GitHub account must be at least 7 days old/i)
vi.useRealTimers()
})
it('refreshes githubCreatedAt when cache is stale', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: undefined,
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
})
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
created_at: '2020-01-01T00:00:00Z',
}),
})
vi.stubGlobal('fetch', fetchMock)
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
expect(fetchMock).toHaveBeenCalledWith(
'https://api.github.com/users/steipete',
expect.objectContaining({ headers: { 'User-Agent': 'clawhub' } }),
)
expect(runMutation).toHaveBeenCalledWith(internal.users.updateGithubMetaInternal, {
userId: 'users:1',
githubCreatedAt: Date.parse('2020-01-01T00:00:00Z'),
githubFetchedAt: now.getTime(),
})
vi.useRealTimers()
})
it('throws when GitHub lookup fails', async () => {
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({ ok: false })
vi.stubGlobal('fetch', fetchMock)
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/GitHub account lookup failed/i)
})
})
+56
View File
@@ -0,0 +1,56 @@
import { ConvexError } from 'convex/values'
import { internal } from '../_generated/api'
import type { Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
const GITHUB_API = 'https://api.github.com'
const MIN_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000
const FETCH_TTL_MS = 24 * 60 * 60 * 1000
type GitHubUser = {
created_at?: string
}
export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'>) {
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
if (!user || user.deletedAt) throw new ConvexError('User not found')
const handle = user.handle?.trim()
if (!handle) throw new ConvexError('GitHub handle required')
const now = Date.now()
let createdAt = user.githubCreatedAt ?? null
const fetchedAt = user.githubFetchedAt ?? 0
const stale = !createdAt || now - fetchedAt > FETCH_TTL_MS
if (stale) {
const response = await fetch(`${GITHUB_API}/users/${encodeURIComponent(handle)}`, {
headers: { 'User-Agent': 'clawhub' },
})
if (!response.ok) throw new ConvexError('GitHub account lookup failed')
const payload = (await response.json()) as GitHubUser
const parsed = payload.created_at ? Date.parse(payload.created_at) : Number.NaN
if (!Number.isFinite(parsed)) throw new ConvexError('GitHub account lookup failed')
createdAt = parsed
await ctx.runMutation(internal.users.updateGithubMetaInternal, {
userId,
githubCreatedAt: createdAt,
githubFetchedAt: now,
})
}
if (!createdAt) throw new ConvexError('GitHub account lookup failed')
const ageMs = now - createdAt
if (ageMs < MIN_ACCOUNT_AGE_MS) {
const remainingMs = MIN_ACCOUNT_AGE_MS - ageMs
const remainingDays = Math.max(1, Math.ceil(remainingMs / (24 * 60 * 60 * 1000)))
throw new ConvexError(
`GitHub account must be at least 7 days old to upload skills. Try again in ${remainingDays} day${
remainingDays === 1 ? '' : 's'
}.`,
)
}
}
+4
View File
@@ -6,6 +6,7 @@ import type { ActionCtx, MutationCtx } from '../_generated/server'
import { getSkillBadgeMap, isSkillHighlighted } from './badges'
import { generateChangelogForPublish } from './changelog'
import { generateEmbedding } from './embeddings'
import { requireGitHubAccountAge } from './githubAccount'
import type { PublicUser } from './public'
import {
buildEmbeddingText,
@@ -67,6 +68,9 @@ export async function publishVersionForUser(
if (!semver.valid(version)) {
throw new ConvexError('Version must be valid semver')
}
await requireGitHubAccountAge(ctx, userId)
const suppliedChangelog = args.changelog.trim()
const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
+4
View File
@@ -4,6 +4,7 @@ import { internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { generateEmbedding } from './embeddings'
import { requireGitHubAccountAge } from './githubAccount'
import {
buildEmbeddingText,
getFrontmatterMetadata,
@@ -90,6 +91,9 @@ export async function publishSoulVersionForUser(
if (!semver.valid(version)) {
throw new ConvexError('Version must be valid semver')
}
await requireGitHubAccountAge(ctx, userId)
const suppliedChangelog = args.changelog.trim()
const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
+4 -4
View File
@@ -72,7 +72,7 @@ export function buildDiscordPayload(
},
],
footer: {
text: 'OpenClaw',
text: 'ClawHub',
},
timestamp: new Date().toISOString(),
},
@@ -89,9 +89,9 @@ export function buildSkillUrl(skill: WebhookSkillPayload, siteUrl: string) {
function buildDescription(event: WebhookEvent, skill: WebhookSkillPayload) {
const summary = (skill.summary ?? '').trim()
if (summary) return truncate(summary, 200)
if (event === 'skill.highlighted') return 'Newly highlighted skill on OpenClaw.'
if (skill.version) return `New version v${skill.version} published on OpenClaw.`
return 'New skill published on OpenClaw.'
if (event === 'skill.highlighted') return 'Newly highlighted skill on ClawHub.'
if (skill.version) return `New version v${skill.version} published on ClawHub.`
return 'New skill published on ClawHub.'
}
function parseBoolean(value?: string) {
+34 -26
View File
@@ -17,6 +17,8 @@ const users = defineTable({
displayName: v.optional(v.string()),
bio: v.optional(v.string()),
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
githubCreatedAt: v.optional(v.number()),
githubFetchedAt: v.optional(v.number()),
deletedAt: v.optional(v.number()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
@@ -29,6 +31,7 @@ const skills = defineTable({
slug: v.string(),
displayName: v.string(),
summary: v.optional(v.string()),
resourceId: v.optional(v.string()),
ownerUserId: v.id('users'),
canonicalSkillId: v.optional(v.id('skills')),
forkOf: v.optional(
@@ -42,32 +45,34 @@ const skills = defineTable({
latestVersionId: v.optional(v.id('skillVersions')),
tags: v.record(v.string(), v.id('skillVersions')),
softDeletedAt: v.optional(v.number()),
badges: v.object({
redactionApproved: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
highlighted: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
official: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
deprecated: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
}),
badges: v.optional(
v.object({
redactionApproved: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
highlighted: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
official: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
deprecated: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
}),
),
moderationStatus: v.optional(
v.union(v.literal('active'), v.literal('hidden'), v.literal('removed')),
),
@@ -104,6 +109,8 @@ const skills = defineTable({
.index('by_stats_installs_all_time', ['statsInstallsAllTime', 'updatedAt'])
.index('by_batch', ['batch'])
.index('by_active_updated', ['softDeletedAt', 'updatedAt'])
.index('by_canonical', ['canonicalSkillId'])
.index('by_fork_of', ['forkOf.skillId'])
const souls = defineTable({
slug: v.string(),
@@ -332,6 +339,7 @@ const skillReports = defineTable({
createdAt: v.number(),
})
.index('by_skill', ['skillId'])
.index('by_skill_createdAt', ['skillId', 'createdAt'])
.index('by_user', ['userId'])
.index('by_skill_user', ['skillId', 'userId'])
+1 -1
View File
@@ -7,7 +7,7 @@ export type SoulSeed = {
}
export const SOUL_SEED_HANDLE = 'seed'
export const SOUL_SEED_DISPLAY_NAME = 'OpenClaw Seed'
export const SOUL_SEED_DISPLAY_NAME = 'ClawHub Seed'
export const SOUL_SEED_KEY = 'seed:souls-v1'
// biome-ignore format: seed payload
+496 -143
View File
@@ -30,12 +30,317 @@ const MAX_LIST_LIMIT = 50
const MAX_PUBLIC_LIST_LIMIT = 200
const MAX_LIST_BULK_LIMIT = 200
const MAX_LIST_TAKE = 1000
const HARD_DELETE_BATCH_SIZE = 100
const HARD_DELETE_VERSION_BATCH_SIZE = 10
const HARD_DELETE_LEADERBOARD_BATCH_SIZE = 25
const MAX_ACTIVE_REPORTS_PER_USER = 20
const AUTO_HIDE_REPORT_THRESHOLD = 3
const MAX_REPORT_REASON_SAMPLE = 5
function isSkillVersionId(
value: Id<'skillVersions'> | null | undefined,
): value is Id<'skillVersions'> {
return typeof value === 'string' && value.startsWith('skillVersions:')
}
function isUserId(value: Id<'users'> | null | undefined): value is Id<'users'> {
return typeof value === 'string' && value.startsWith('users:')
}
async function resolveOwnerHandle(ctx: QueryCtx, ownerUserId: Id<'users'>) {
const owner = await ctx.db.get(ownerUserId)
return owner?.handle ?? owner?._id ?? null
}
const HARD_DELETE_PHASES = [
'versions',
'fingerprints',
'embeddings',
'comments',
'reports',
'stars',
'badges',
'dailyStats',
'statEvents',
'installs',
'rootInstalls',
'leaderboards',
'canonical',
'forks',
'finalize',
] as const
type HardDeletePhase = (typeof HARD_DELETE_PHASES)[number]
function isHardDeletePhase(value: string | undefined): value is HardDeletePhase {
return Boolean(value) && (HARD_DELETE_PHASES as readonly string[]).includes(value)
}
async function scheduleHardDelete(
ctx: MutationCtx,
skillId: Id<'skills'>,
actorUserId: Id<'users'>,
phase: HardDeletePhase,
) {
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
skillId,
actorUserId,
phase,
})
}
async function hardDeleteSkillStep(
ctx: MutationCtx,
skill: Doc<'skills'>,
actorUserId: Id<'users'>,
phase: HardDeletePhase,
) {
const now = Date.now()
const patch: Partial<Doc<'skills'>> = {}
if (!skill.softDeletedAt) patch.softDeletedAt = now
if (skill.moderationStatus !== 'removed') patch.moderationStatus = 'removed'
if (!skill.hiddenAt) patch.hiddenAt = now
if (!skill.hiddenBy) patch.hiddenBy = actorUserId
if (Object.keys(patch).length) {
patch.lastReviewedAt = now
patch.updatedAt = now
await ctx.db.patch(skill._id, patch)
}
switch (phase) {
case 'versions': {
const versions = await ctx.db
.query('skillVersions')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_VERSION_BATCH_SIZE)
for (const version of versions) {
await ctx.db.delete(version._id)
}
if (versions.length === HARD_DELETE_VERSION_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'versions')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'fingerprints')
return
}
case 'fingerprints': {
const fingerprints = await ctx.db
.query('skillVersionFingerprints')
.withIndex('by_skill_fingerprint', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const fingerprint of fingerprints) {
await ctx.db.delete(fingerprint._id)
}
if (fingerprints.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'fingerprints')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'embeddings')
return
}
case 'embeddings': {
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const embedding of embeddings) {
await ctx.db.delete(embedding._id)
}
if (embeddings.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'embeddings')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'comments')
return
}
case 'comments': {
const comments = await ctx.db
.query('comments')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const comment of comments) {
await ctx.db.delete(comment._id)
}
if (comments.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'comments')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'reports')
return
}
case 'reports': {
const reports = await ctx.db
.query('skillReports')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const report of reports) {
await ctx.db.delete(report._id)
}
if (reports.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'reports')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'stars')
return
}
case 'stars': {
const stars = await ctx.db
.query('stars')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const star of stars) {
await ctx.db.delete(star._id)
}
if (stars.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'stars')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'badges')
return
}
case 'badges': {
const badges = await ctx.db
.query('skillBadges')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const badge of badges) {
await ctx.db.delete(badge._id)
}
if (badges.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'badges')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'dailyStats')
return
}
case 'dailyStats': {
const dailyStats = await ctx.db
.query('skillDailyStats')
.withIndex('by_skill_day', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const stat of dailyStats) {
await ctx.db.delete(stat._id)
}
if (dailyStats.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'dailyStats')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'statEvents')
return
}
case 'statEvents': {
const statEvents = await ctx.db
.query('skillStatEvents')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const statEvent of statEvents) {
await ctx.db.delete(statEvent._id)
}
if (statEvents.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'statEvents')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'installs')
return
}
case 'installs': {
const installs = await ctx.db
.query('userSkillInstalls')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const install of installs) {
await ctx.db.delete(install._id)
}
if (installs.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'installs')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'rootInstalls')
return
}
case 'rootInstalls': {
const rootInstalls = await ctx.db
.query('userSkillRootInstalls')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const rootInstall of rootInstalls) {
await ctx.db.delete(rootInstall._id)
}
if (rootInstalls.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'rootInstalls')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'leaderboards')
return
}
case 'leaderboards': {
const leaderboards = await ctx.db
.query('skillLeaderboards')
.take(HARD_DELETE_LEADERBOARD_BATCH_SIZE)
for (const leaderboard of leaderboards) {
const items = leaderboard.items.filter((item) => item.skillId !== skill._id)
if (items.length !== leaderboard.items.length) {
await ctx.db.patch(leaderboard._id, { items })
}
}
if (leaderboards.length === HARD_DELETE_LEADERBOARD_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'leaderboards')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'canonical')
return
}
case 'canonical': {
const canonicalRefs = await ctx.db
.query('skills')
.withIndex('by_canonical', (q) => q.eq('canonicalSkillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const related of canonicalRefs) {
await ctx.db.patch(related._id, {
canonicalSkillId: undefined,
updatedAt: now,
})
}
if (canonicalRefs.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'canonical')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'forks')
return
}
case 'forks': {
const forkRefs = await ctx.db
.query('skills')
.withIndex('by_fork_of', (q) => q.eq('forkOf.skillId', skill._id))
.take(HARD_DELETE_BATCH_SIZE)
for (const related of forkRefs) {
await ctx.db.patch(related._id, {
forkOf: undefined,
updatedAt: now,
})
}
if (forkRefs.length === HARD_DELETE_BATCH_SIZE) {
await scheduleHardDelete(ctx, skill._id, actorUserId, 'forks')
return
}
await scheduleHardDelete(ctx, skill._id, actorUserId, 'finalize')
return
}
case 'finalize': {
await ctx.db.delete(skill._id)
await ctx.db.insert('auditLogs', {
actorUserId,
action: 'skill.hard_delete',
targetType: 'skill',
targetId: skill._id,
metadata: { slug: skill.slug },
createdAt: now,
})
return
}
}
}
type PublicSkillEntry = {
skill: NonNullable<ReturnType<typeof toPublicSkill>>
latestVersion: Doc<'skillVersions'> | null
@@ -179,7 +484,7 @@ export const getBySlug = query({
.unique()
if (!skill || skill.softDeletedAt) return null
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId))
const owner = await ctx.db.get(skill.ownerUserId)
const badges = await getSkillBadgeMap(ctx, skill._id)
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null
@@ -225,6 +530,62 @@ export const getBySlug = query({
},
})
export const getBySlugForStaff = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertModerator(user)
const skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (!skill) return null
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId))
const badges = await getSkillBadgeMap(ctx, skill._id)
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null
const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null
const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null
const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null
return {
skill: { ...skill, badges },
latestVersion,
owner,
forkOf: forkOfSkill
? {
kind: skill.forkOf?.kind ?? 'fork',
version: skill.forkOf?.version ?? null,
skill: {
slug: forkOfSkill.slug,
displayName: forkOfSkill.displayName,
},
owner: {
handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
userId: forkOfOwner?._id ?? null,
},
}
: null,
canonical: canonicalSkill
? {
skill: {
slug: canonicalSkill.slug,
displayName: canonicalSkill.displayName,
},
owner: {
handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
userId: canonicalOwner?._id ?? null,
},
}
: null,
}
},
})
export const getSkillBySlugInternal = internalQuery({
args: { slug: v.string() },
handler: async (ctx, args) => {
@@ -404,7 +765,39 @@ export const listReportedSkills = query({
.filter((skill) => (skill.reportCount ?? 0) > 0)
.sort((a, b) => (b.lastReportedAt ?? 0) - (a.lastReportedAt ?? 0))
.slice(0, limit)
return buildManagementSkillEntries(ctx, reported)
const managementEntries = await buildManagementSkillEntries(ctx, reported)
const reporterCache = new Map<Id<'users'>, Promise<Doc<'users'> | null>>()
const getReporter = (reporterId: Id<'users'>) => {
const cached = reporterCache.get(reporterId)
if (cached) return cached
const reporterPromise = ctx.db.get(reporterId)
reporterCache.set(reporterId, reporterPromise)
return reporterPromise
}
return Promise.all(
managementEntries.map(async (entry) => {
const reports = await ctx.db
.query('skillReports')
.withIndex('by_skill_createdAt', (q) => q.eq('skillId', entry.skill._id))
.order('desc')
.take(MAX_REPORT_REASON_SAMPLE)
const reportEntries = await Promise.all(
reports.map(async (report) => {
const reporter = await getReporter(report.userId)
const reason = report.reason?.trim()
return {
reason: reason && reason.length > 0 ? reason : 'No reason provided.',
createdAt: report.createdAt,
reporterHandle: reporter?.handle ?? reporter?.name ?? null,
reporterId: report.userId,
}
}),
)
return { ...entry, reports: reportEntries }
}),
)
},
})
@@ -427,14 +820,22 @@ export const listDuplicateCandidates = query({
}> = []
for (const skill of entries) {
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
const latestVersion = isSkillVersionId(skill.latestVersionId)
? await ctx.db.get(skill.latestVersionId)
: null
const fingerprint = latestVersion?.fingerprint ?? null
if (!fingerprint) continue
const matchedFingerprints = await ctx.db
.query('skillVersionFingerprints')
.withIndex('by_fingerprint', (q) => q.eq('fingerprint', fingerprint))
.take(10)
let matchedFingerprints: Doc<'skillVersionFingerprints'>[] = []
try {
matchedFingerprints = await ctx.db
.query('skillVersionFingerprints')
.withIndex('by_fingerprint', (q) => q.eq('fingerprint', fingerprint))
.take(10)
} catch (error) {
console.error('listDuplicateCandidates: fingerprint lookup failed', error)
continue
}
const matchEntries: Array<{ skill: Doc<'skills'>; owner: Doc<'users'> | null }> = []
for (const match of matchedFingerprints) {
@@ -447,7 +848,7 @@ export const listDuplicateCandidates = query({
if (matchEntries.length === 0) continue
const owner = await ctx.db.get(skill.ownerUserId)
const owner = isUserId(skill.ownerUserId) ? await ctx.db.get(skill.ownerUserId) : null
results.push({
skill,
latestVersion,
@@ -461,12 +862,39 @@ export const listDuplicateCandidates = query({
},
})
async function countActiveReportsForUser(ctx: MutationCtx, userId: Id<'users'>) {
const reports = await ctx.db
.query('skillReports')
.withIndex('by_user', (q) => q.eq('userId', userId))
.collect()
let count = 0
for (const report of reports) {
const skill = await ctx.db.get(report.skillId)
if (!skill) continue
if (skill.softDeletedAt) continue
if (skill.moderationStatus === 'removed') continue
const owner = await ctx.db.get(skill.ownerUserId)
if (!owner || owner.deletedAt) continue
count += 1
if (count >= MAX_ACTIVE_REPORTS_PER_USER) break
}
return count
}
export const report = mutation({
args: { skillId: v.id('skills'), reason: v.optional(v.string()) },
args: { skillId: v.id('skills'), reason: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const skill = await ctx.db.get(args.skillId)
if (!skill || skill.softDeletedAt) throw new Error('Skill not found')
if (!skill || skill.softDeletedAt || skill.moderationStatus === 'removed') {
throw new Error('Skill not found')
}
const reason = args.reason.trim()
if (!reason) {
throw new Error('Report reason required.')
}
const existing = await ctx.db
.query('skillReports')
@@ -474,20 +902,60 @@ export const report = mutation({
.unique()
if (existing) return { ok: true as const, reported: false, alreadyReported: true }
const activeReports = await countActiveReportsForUser(ctx, userId)
if (activeReports >= MAX_ACTIVE_REPORTS_PER_USER) {
throw new Error('Report limit reached. Please wait for moderation before reporting more.')
}
const now = Date.now()
const reason = args.reason?.trim()
await ctx.db.insert('skillReports', {
skillId: args.skillId,
userId,
reason: reason ? reason.slice(0, 500) : undefined,
reason: reason.slice(0, 500),
createdAt: now,
})
await ctx.db.patch(skill._id, {
reportCount: (skill.reportCount ?? 0) + 1,
const nextReportCount = (skill.reportCount ?? 0) + 1
const shouldAutoHide = nextReportCount > AUTO_HIDE_REPORT_THRESHOLD && !skill.softDeletedAt
const updates: Partial<Doc<'skills'>> = {
reportCount: nextReportCount,
lastReportedAt: now,
updatedAt: now,
})
}
if (shouldAutoHide) {
Object.assign(updates, {
softDeletedAt: now,
moderationStatus: 'hidden',
moderationReason: 'auto.reports',
moderationNotes: 'Auto-hidden after 4 unique reports.',
hiddenAt: now,
lastReviewedAt: now,
})
}
await ctx.db.patch(skill._id, updates)
if (shouldAutoHide) {
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
visibility: 'deleted',
updatedAt: now,
})
}
await ctx.db.insert('auditLogs', {
actorUserId: userId,
action: 'skill.auto_hide',
targetType: 'skill',
targetId: skill._id,
metadata: { reportCount: nextReportCount },
createdAt: now,
})
}
return { ok: true as const, reported: true, alreadyReported: false }
},
@@ -1164,135 +1632,20 @@ export const hardDelete = mutation({
assertAdmin(user)
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await hardDeleteSkillStep(ctx, skill, user._id, 'versions')
},
})
const versions = await ctx.db
.query('skillVersions')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const version of versions) {
const versionFingerprints = await ctx.db
.query('skillVersionFingerprints')
.withIndex('by_version', (q) => q.eq('versionId', version._id))
.collect()
for (const fingerprint of versionFingerprints) {
await ctx.db.delete(fingerprint._id)
}
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_version', (q) => q.eq('versionId', version._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.delete(embedding._id)
}
await ctx.db.delete(version._id)
}
const remainingFingerprints = await ctx.db
.query('skillVersionFingerprints')
.withIndex('by_skill_fingerprint', (q) => q.eq('skillId', skill._id))
.collect()
for (const fingerprint of remainingFingerprints) {
await ctx.db.delete(fingerprint._id)
}
const remainingEmbeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of remainingEmbeddings) {
await ctx.db.delete(embedding._id)
}
const comments = await ctx.db
.query('comments')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const comment of comments) {
await ctx.db.delete(comment._id)
}
const stars = await ctx.db
.query('stars')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const star of stars) {
await ctx.db.delete(star._id)
}
const badges = await ctx.db
.query('skillBadges')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const badge of badges) {
await ctx.db.delete(badge._id)
}
const dailyStats = await ctx.db
.query('skillDailyStats')
.withIndex('by_skill_day', (q) => q.eq('skillId', skill._id))
.collect()
for (const stat of dailyStats) {
await ctx.db.delete(stat._id)
}
const statEvents = await ctx.db
.query('skillStatEvents')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const statEvent of statEvents) {
await ctx.db.delete(statEvent._id)
}
const installs = await ctx.db
.query('userSkillInstalls')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const install of installs) {
await ctx.db.delete(install._id)
}
const rootInstalls = await ctx.db
.query('userSkillRootInstalls')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const rootInstall of rootInstalls) {
await ctx.db.delete(rootInstall._id)
}
const leaderboards = await ctx.db.query('skillLeaderboards').collect()
for (const leaderboard of leaderboards) {
const items = leaderboard.items.filter((item) => item.skillId !== skill._id)
if (items.length !== leaderboard.items.length) {
await ctx.db.patch(leaderboard._id, { items })
}
}
const relatedSkills = await ctx.db.query('skills').collect()
for (const related of relatedSkills) {
if (related._id === skill._id) continue
if (related.canonicalSkillId === skill._id || related.forkOf?.skillId === skill._id) {
await ctx.db.patch(related._id, {
canonicalSkillId:
related.canonicalSkillId === skill._id ? undefined : related.canonicalSkillId,
forkOf: related.forkOf?.skillId === skill._id ? undefined : related.forkOf,
updatedAt: Date.now(),
})
}
}
await ctx.db.delete(skill._id)
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'skill.hard_delete',
targetType: 'skill',
targetId: skill._id,
metadata: { slug: skill.slug },
createdAt: Date.now(),
})
export const hardDeleteInternal = internalMutation({
args: { skillId: v.id('skills'), actorUserId: v.id('users'), phase: v.optional(v.string()) },
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId)
if (!actor || actor.deletedAt) throw new Error('User not found')
assertAdmin(actor)
const skill = await ctx.db.get(args.skillId)
if (!skill) return
const phase = isHardDeletePhase(args.phase) ? args.phase : 'versions'
await hardDeleteSkillStep(ctx, skill, actor._id, phase)
},
})
+92 -2
View File
@@ -1,8 +1,10 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import { v } from 'convex/values'
import { internal } from './_generated/api'
import { internalQuery, mutation, query } from './_generated/server'
import { assertAdmin, requireUser } from './lib/access'
import type { Doc, Id } from './_generated/dataModel'
import type { MutationCtx } from './_generated/server'
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
import { assertAdmin, assertModerator, requireUser } from './lib/access'
import { toPublicUser } from './lib/public'
const DEFAULT_ROLE = 'user'
@@ -18,6 +20,21 @@ export const getByIdInternal = internalQuery({
handler: async (ctx, args) => ctx.db.get(args.userId),
})
export const updateGithubMetaInternal = internalMutation({
args: {
userId: v.id('users'),
githubCreatedAt: v.number(),
githubFetchedAt: v.number(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
githubCreatedAt: args.githubCreatedAt,
githubFetchedAt: args.githubFetchedAt,
updatedAt: args.githubFetchedAt,
})
},
})
export const me = query({
args: {},
handler: async (ctx) => {
@@ -120,3 +137,76 @@ export const setRole = mutation({
})
},
})
export const banUser = mutation({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
return banUserWithActor(ctx, user, args.userId)
},
})
export const banUserInternal = internalMutation({
args: { actorUserId: v.id('users'), targetUserId: v.id('users') },
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId)
if (!actor || actor.deletedAt) throw new Error('User not found')
return banUserWithActor(ctx, actor, args.targetUserId)
},
})
async function banUserWithActor(ctx: MutationCtx, actor: Doc<'users'>, targetUserId: Id<'users'>) {
assertModerator(actor)
if (targetUserId === actor._id) throw new Error('Cannot ban yourself')
const target = await ctx.db.get(targetUserId)
if (!target) throw new Error('User not found')
if (target.role === 'admin' && actor.role !== 'admin') {
throw new Error('Forbidden')
}
const now = Date.now()
if (target.deletedAt) {
return { ok: true as const, alreadyBanned: true, deletedSkills: 0 }
}
const skills = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', targetUserId))
.collect()
for (const skill of skills) {
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
skillId: skill._id,
actorUserId: actor._id,
})
}
const tokens = await ctx.db
.query('apiTokens')
.withIndex('by_user', (q) => q.eq('userId', targetUserId))
.collect()
for (const token of tokens) {
await ctx.db.patch(token._id, { revokedAt: now })
}
await ctx.db.patch(targetUserId, {
deletedAt: now,
role: 'user',
updatedAt: now,
})
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId: targetUserId })
await ctx.db.insert('auditLogs', {
actorUserId: actor._id,
action: 'user.ban',
targetType: 'user',
targetId: targetUserId,
metadata: { deletedSkills: skills.length },
createdAt: now,
})
return { ok: true as const, alreadyBanned: false, deletedSkills: skills.length }
}
+1
View File
@@ -22,6 +22,7 @@ Reading order (new contributor):
Feature/ops docs (already present):
- `docs/spec.md`: product + implementation spec (data model + flows).
- `docs/security.md`: moderation, reporting, bans, upload gating.
- `docs/telemetry.md`: what `clawhub sync` reports; opt-out.
- `docs/webhook.md`: Discord webhook events/payload.
- `docs/diffing.md`: version-to-version diff UI spec.
+41 -1
View File
@@ -61,11 +61,22 @@ Stores your API token + cached registry URL.
- Lists latest updated skills via `/api/v1/skills?limit=...` (sorted by `updatedAt` desc).
- Flags:
- `--limit <n>` (1200, default: 25)
- `--limit <n>` (1-200, default: 25)
- `--sort newest|downloads|rating|installs|installsAllTime|trending` (default: newest)
- `--json` (machine-readable output)
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
### `inspect <slug>`
- Fetches skill metadata and version files without installing.
- `--version <version>`: inspect a specific version (default: latest).
- `--tag <tag>`: inspect a tagged version (e.g. `latest`).
- `--versions`: list version history (first page).
- `--limit <n>`: max versions to list (1-200).
- `--files`: list files for the selected version.
- `--file <path>`: fetch raw file content (text files only; 200KB limit).
- `--json`: machine-readable output.
### `install <slug>`
- Resolves latest version via `/api/v1/skills/<slug>`.
@@ -92,6 +103,35 @@ Stores your API token + cached registry URL.
- Publishes via `POST /api/v1/skills` (multipart).
- Requires semver: `--version 1.2.3`.
### `delete <slug>`
- Soft-delete a skill (moderator/admin only).
- Calls `DELETE /api/v1/skills/{slug}`.
- `--yes` skips confirmation.
### `undelete <slug>`
- Restore a hidden skill (moderator/admin only).
- Calls `POST /api/v1/skills/{slug}/undelete`.
- `--yes` skips confirmation.
### `hide <slug>`
- Hide a skill (moderator/admin only).
- Alias for `delete`.
### `unhide <slug>`
- Unhide a skill (moderator/admin only).
- Alias for `undelete`.
### `ban-user <handleOrId>`
- Ban a user and delete owned skills (moderator/admin only).
- Calls `POST /api/v1/users/ban`.
- `--id` treats the argument as a user id instead of a handle.
- `--yes` skips confirmation.
### `sync`
- Scans for local skill folders and publishes new/changed ones.
+1 -1
View File
@@ -7,7 +7,7 @@ read_when:
# Deploy
OpenClaw is two deployables:
ClawHub is two deployables:
- Web app (TanStack Start) → typically Vercel.
- Convex backend → Convex deployment (serves `/api/...` routes).
+1 -1
View File
@@ -10,7 +10,7 @@ read_when:
## Goals
- Compare any file between two versions.
- Default compare: `latest` vs `previous` (SemVer precedence).
- UX feels native to OpenClaw (theme + typography + motion).
- UX feels native to ClawHub (theme + typography + motion).
- Inline or side-by-side toggle.
- Public access.
+23 -1
View File
@@ -143,7 +143,29 @@ Publishes a new version.
### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete`
Soft-delete / restore a skill (owner/admin only).
Soft-delete / restore a skill (moderator/admin only).
### `POST /api/v1/users/ban`
Ban a user and hard-delete owned skills (moderator/admin only).
Body:
```json
{ "handle": "user_handle" }
```
or
```json
{ "userId": "users_..." }
```
Response:
```json
{ "ok": true, "alreadyBanned": false, "deletedSkills": 3 }
```
### `POST /api/v1/stars/{slug}` / `DELETE /api/v1/stars/{slug}`
+1 -1
View File
@@ -20,7 +20,7 @@ Example (starter):
```json
{
"name": "OpenClaw",
"name": "ClawHub",
"logo": "public/logo.svg",
"navigation": [
{ "group": "Start", "pages": ["docs/README", "docs/quickstart"] },
+50
View File
@@ -0,0 +1,50 @@
---
summary: 'Security + moderation controls (reports, bans, upload gating).'
read_when:
- Working on moderation or abuse controls
- Reviewing upload restrictions
- Troubleshooting hidden/removed skills
---
# Security + Moderation
## Roles + permissions
- user: upload skills/souls (subject to GitHub age gate), report skills.
- moderator: hide/restore skills, view hidden skills, unhide, soft-delete, ban users (except admins).
- admin: all moderator actions + hard delete skills, change owners, change roles.
## Reporting + auto-hide
- Reports are unique per user + skill.
- Report reason required (trimmed, max 500 chars). Abuse of reporting may result in account bans.
- Per-user cap: 20 **active** reports.
- Active = skill exists, not soft-deleted, not `moderationStatus = removed`,
and the owner is not banned.
- Auto-hide: when unique reports exceed 3 (4th report), the skill is:
- soft-deleted (`softDeletedAt`)
- `moderationStatus = hidden`
- `moderationReason = auto.reports`
- embeddings visibility set to `deleted`
- audit log entry: `skill.auto_hide`
- Public queries hide non-active moderation statuses; staff can still access via
staff-only queries and unhide/restore/delete/ban.
## Bans
- Banning a user:
- hard-deletes all owned skills
- revokes API tokens
- sets `deletedAt` on the user
- Moderators cannot ban admins; nobody can ban themselves.
- Report counters effectively reset because deleted/banned skills are no longer
considered active in the per-user report cap.
## Upload gate (GitHub account age)
- Skill + soul publish actions require GitHub account age ≥ 7 days.
- Lookup uses GitHub `created_at` and caches on the user:
- `githubCreatedAt` (source of truth)
- `githubFetchedAt` (fetch timestamp)
- Cache TTL: 24 hours.
- Gate applies to web uploads, CLI publish, and GitHub import.
+7 -5
View File
@@ -1,12 +1,12 @@
---
summary: "OpenClaw spec: skills registry, versioning, vector search, moderation"
summary: "ClawHub spec: skills registry, versioning, vector search, moderation"
read_when:
- Bootstrapping OpenClaw
- Bootstrapping ClawHub
- Implementing schema/auth/search/versioning
- Reviewing API and upload/download flows
---
# OpenClaw — product + implementation spec (v1)
# ClawHub — product + implementation spec (v1)
## Goals
- onlycrabs.ai mode for sharing `SOUL.md` bundles (host-based entry point).
@@ -123,8 +123,9 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
## Auth + roles
- Convex Auth with GitHub OAuth App.
- Default role `user`; bootstrap `steipete` to `admin` on first login.
- Management console: moderators can hide/restore skills + mark duplicates; admins can change owners, approve badges, and hard-delete.
- Management console: moderators can hide/restore skills + mark duplicates + ban users; admins can change owners, approve badges, hard-delete skills, and ban users (deletes owned skills).
- Role changes are admin-only and audited.
- Reporting: any user can report skills; per-user cap 20 active reports; skills auto-hide after >3 unique reports (mods can review/unhide/delete/ban).
## Upload flow (50MB per version)
1) Client requests upload session.
@@ -135,9 +136,10 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- file extensions/text content
- SKILL.md exists and frontmatter parseable
- version uniqueness
- GitHub account age ≥ 7 days
5) Server stores files + metadata, sets `latest` tag, updates stats.
Soul upload flow: same as skills, but only `SOUL.md` is allowed in the bundle.
Soul upload flow: same as skills (including GitHub account age checks), but only `SOUL.md` is allowed.
Seed data lives in `convex/seed.ts` for local dev.
## Versioning + tags
+2 -2
View File
@@ -7,7 +7,7 @@ read_when:
# Telemetry
OpenClaw uses **minimal telemetry** to compute **install counts** (whats actually in use) and to power better sorting/filtering.
ClawHub uses **minimal telemetry** to compute **install counts** (whats actually in use) and to power better sorting/filtering.
This is based on the CLI `clawhub sync` command.
## When telemetry is collected
@@ -70,7 +70,7 @@ This is evaluated lazily (on the next telemetry report) to avoid background jobs
## Transparency + user controls
OpenClaw provides a private “Installed” tab on your own profile:
ClawHub provides a private “Installed” tab on your own profile:
- Shows the exact roots + installed skills we store.
- Includes a **JSON export** view.
+2 -2
View File
@@ -6,7 +6,7 @@ read_when:
# Webhooks (Discord)
OpenClaw can post Discord embeds when skills are published or highlighted.
ClawHub can post Discord embeds when skills are published or highlighted.
## Setup
@@ -44,7 +44,7 @@ Discord receives a JSON payload with a single embed:
{ "name": "Owner", "value": "@owner", "inline": true },
{ "name": "Tags", "value": "latest, discord", "inline": false }
],
"footer": { "text": "OpenClaw" }
"footer": { "text": "ClawHub" }
}
]
}
+1 -1
View File
@@ -43,7 +43,7 @@ test('header menu routes render', async ({ page }) => {
if (label === 'Search') {
await expect(page).toHaveURL(/\/?(\?|$)/)
await expect(page.locator('h1', { hasText: 'OpenClaw' })).toBeVisible()
await expect(page.locator('h1', { hasText: 'ClawHub' })).toBeVisible()
}
}
})
+24 -24
View File
@@ -35,49 +35,49 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-router": "^1.151.6",
"@tanstack/react-router-devtools": "^1.151.6",
"@tanstack/react-start": "^1.152.0",
"@tanstack/router-plugin": "^1.151.6",
"@tanstack/react-devtools": "^0.9.4",
"@tanstack/react-router": "^1.157.18",
"@tanstack/react-router-devtools": "^1.157.18",
"@tanstack/react-start": "^1.157.18",
"@tanstack/router-plugin": "^1.157.18",
"@vercel/analytics": "^1.6.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.31.6",
"convex": "^1.31.7",
"convex-helpers": "^0.1.111",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.8",
"lucide-react": "^0.562.0",
"h3": "2.0.1-rc.11",
"lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1",
"nitro": "^3.0.1-alpha.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"nitro": "^3.0.1-alpha.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^6.0.4",
"vite-tsconfig-paths": "^6.0.5",
"yaml": "^2.8.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@playwright/test": "^1.57.0",
"@tanstack/devtools-vite": "^0.4.1",
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.1",
"@tanstack/devtools-vite": "^0.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^25.0.9",
"@types/react": "^19.2.8",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.4.0",
"only-allow": "^1.2.1",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.0.0",
"only-allow": "^1.2.2",
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.11.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.17"
"vitest": "^4.0.18"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# `clawhub`
OpenClaw CLI — install, update, search, and publish agent skills as folders.
ClawHub CLI — install, update, search, and publish agent skills as folders.
## Install
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "clawhub",
"version": "0.4.0",
"description": "OpenClaw CLI \\u2014 install, update, search, and publish agent skills.",
"description": "ClawHub CLI \\u2014 install, update, search, and publish agent skills.",
"license": "MIT",
"type": "module",
"bin": {
+1 -1
View File
@@ -60,7 +60,7 @@ describe('browserAuth', () => {
const response = await fetch(server.redirectUri)
expect(response.status).toBe(200)
const text = await response.text()
expect(text).toContain('OpenClaw CLI Login')
expect(text).toContain('ClawHub CLI Login')
server.close()
})
+1 -1
View File
@@ -127,7 +127,7 @@ const CALLBACK_HTML = `<!doctype html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenClaw CLI Login</title>
<title>ClawHub CLI Login</title>
<style>
:root { color-scheme: light dark; }
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; padding: 24px; }
+58 -4
View File
@@ -5,7 +5,14 @@ import { Command } from 'commander'
import { getCliBuildLabel, getCliVersion } from './cli/buildInfo.js'
import { resolveClawdbotDefaultWorkspace } from './cli/clawdbotConfig.js'
import { cmdLoginFlow, cmdLogout, cmdWhoami } from './cli/commands/auth.js'
import { cmdDeleteSkill, cmdUndeleteSkill } from './cli/commands/delete.js'
import {
cmdDeleteSkill,
cmdHideSkill,
cmdUndeleteSkill,
cmdUnhideSkill,
} from './cli/commands/delete.js'
import { cmdInspect } from './cli/commands/inspect.js'
import { cmdBanUser } from './cli/commands/moderation.js'
import { cmdPublish } from './cli/commands/publish.js'
import { cmdExplore, cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
import { cmdStarSkill } from './cli/commands/star.js'
@@ -20,7 +27,7 @@ import { readGlobalConfig } from './config.js'
const program = new Command()
.name('clawhub')
.description(
`${styleTitle(`OpenClaw CLI ${getCliBuildLabel()}`)}\n${styleEnvBlock(
`${styleTitle(`ClawHub CLI ${getCliBuildLabel()}`)}\n${styleEnvBlock(
'install, update, search, and publish agent skills.',
)}`,
)
@@ -221,6 +228,22 @@ program
await cmdExplore(opts, { limit, sort: options.sort, json: options.json })
})
program
.command('inspect')
.description('Fetch skill metadata and files without installing')
.argument('<slug>', 'Skill slug')
.option('--version <version>', 'Version to inspect')
.option('--tag <tag>', 'Tag to inspect (default: latest)')
.option('--versions', 'List version history (first page)')
.option('--limit <n>', 'Max versions to list (1-200)', (value) => Number.parseInt(value, 10))
.option('--files', 'List files for the selected version')
.option('--file <path>', 'Fetch raw file content (text <= 200KB)')
.option('--json', 'Output JSON')
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdInspect(opts, slug, options)
})
program
.command('publish')
.description('Publish skill from folder')
@@ -238,7 +261,7 @@ program
program
.command('delete')
.description('Soft-delete a skill (owner/admin only)')
.description('Soft-delete a skill (moderator/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
@@ -246,9 +269,19 @@ program
await cmdDeleteSkill(opts, slug, options, isInputAllowed())
})
program
.command('hide')
.description('Hide a skill (moderator/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdHideSkill(opts, slug, options, isInputAllowed())
})
program
.command('undelete')
.description('Restore a soft-deleted skill (owner/admin only)')
.description('Restore a hidden skill (moderator/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
@@ -256,6 +289,27 @@ program
await cmdUndeleteSkill(opts, slug, options, isInputAllowed())
})
program
.command('unhide')
.description('Unhide a skill (moderator/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdUnhideSkill(opts, slug, options, isInputAllowed())
})
program
.command('ban-user')
.description('Ban a user and delete owned skills (moderator/admin only)')
.argument('<handleOrId>', 'User handle (default) or user id')
.option('--id', 'Treat argument as user id')
.option('--yes', 'Skip confirmation')
.action(async (handleOrId, options) => {
const opts = await resolveGlobalOpts()
await cmdBanUser(opts, handleOrId, options, isInputAllowed())
})
program
.command('star')
.description('Add a skill to your highlights')
+3 -2
View File
@@ -37,7 +37,8 @@ export async function cmdLoginFlow(
const result = await receiver.waitForResult()
const registry = result.registry?.trim() || opts.registry
await cmdLogin({ ...opts, registry }, result.token, inputAllowed)
const registrySource = result.registry?.trim() ? 'cli' : opts.registrySource
await cmdLogin({ ...opts, registry, registrySource }, result.token, inputAllowed)
}
export async function cmdLogin(
@@ -47,7 +48,7 @@ export async function cmdLogin(
) {
if (!tokenFlag && !inputAllowed) fail('Token required (use --token or remove --no-input)')
const token = tokenFlag || (await promptHidden('OpenClaw token: '))
const token = tokenFlag || (await promptHidden('ClawHub token: '))
if (!token) fail('Token required')
const registry = await getRegistry(opts, { cache: true })
@@ -29,7 +29,7 @@ vi.mock('../ui.js', () => ({
promptConfirm: vi.fn(async () => true),
}))
const { cmdDeleteSkill, cmdUndeleteSkill } = await import('./delete')
const { cmdDeleteSkill, cmdHideSkill, cmdUndeleteSkill, cmdUnhideSkill } = await import('./delete')
function makeOpts(): GlobalOpts {
return {
@@ -49,6 +49,8 @@ describe('delete/undelete', () => {
it('requires --yes when input is disabled', async () => {
await expect(cmdDeleteSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
await expect(cmdUndeleteSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
await expect(cmdHideSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
await expect(cmdUnhideSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
})
it('calls delete endpoint with --yes', async () => {
@@ -70,4 +72,20 @@ describe('delete/undelete', () => {
expect.anything(),
)
})
it('supports hide/unhide aliases', async () => {
mockApiRequest.mockResolvedValue({ ok: true })
await cmdHideSkill(makeOpts(), 'demo', { yes: true }, false)
await cmdUnhideSkill(makeOpts(), 'demo', { yes: true }, false)
expect(mockApiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: 'DELETE', path: '/api/v1/skills/demo' }),
expect.anything(),
)
expect(mockApiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: 'POST', path: '/api/v1/skills/demo/undelete' }),
expect.anything(),
)
})
})
+66 -6
View File
@@ -5,6 +5,41 @@ import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
type SkillActionLabels = {
verb: string
progress: string
past: string
promptSuffix?: string
}
const deleteLabels: SkillActionLabels = {
verb: 'Delete',
progress: 'Deleting',
past: 'Deleted',
promptSuffix: 'soft delete, requires moderator/admin',
}
const undeleteLabels: SkillActionLabels = {
verb: 'Undelete',
progress: 'Undeleting',
past: 'Undeleted',
promptSuffix: 'requires moderator/admin',
}
const hideLabels: SkillActionLabels = {
verb: 'Hide',
progress: 'Hiding',
past: 'Hidden',
promptSuffix: 'requires moderator/admin',
}
const unhideLabels: SkillActionLabels = {
verb: 'Unhide',
progress: 'Unhiding',
past: 'Unhidden',
promptSuffix: 'requires moderator/admin',
}
async function requireToken() {
const cfg = await readGlobalConfig()
const token = cfg?.token
@@ -17,6 +52,7 @@ export async function cmdDeleteSkill(
slugArg: string,
options: { yes?: boolean },
inputAllowed: boolean,
labels: SkillActionLabels = deleteLabels,
) {
const slug = slugArg.trim().toLowerCase()
if (!slug) fail('Slug required')
@@ -24,20 +60,20 @@ export async function cmdDeleteSkill(
if (!options.yes) {
if (!allowPrompt) fail('Pass --yes (no input)')
const ok = await promptConfirm(`Delete ${slug}? (soft delete)`)
const ok = await promptConfirm(formatPrompt(labels, slug))
if (!ok) return
}
const token = await requireToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`Deleting ${slug}`)
const spinner = createSpinner(`${labels.progress} ${slug}`)
try {
const result = await apiRequest(
registry,
{ method: 'DELETE', path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}`, token },
ApiV1DeleteResponseSchema,
)
spinner.succeed(`OK. Deleted ${slug}`)
spinner.succeed(`OK. ${labels.past} ${slug}`)
return parseArk(ApiV1DeleteResponseSchema, result, 'Delete response')
} catch (error) {
spinner.fail(formatError(error))
@@ -50,6 +86,7 @@ export async function cmdUndeleteSkill(
slugArg: string,
options: { yes?: boolean },
inputAllowed: boolean,
labels: SkillActionLabels = undeleteLabels,
) {
const slug = slugArg.trim().toLowerCase()
if (!slug) fail('Slug required')
@@ -57,13 +94,13 @@ export async function cmdUndeleteSkill(
if (!options.yes) {
if (!allowPrompt) fail('Pass --yes (no input)')
const ok = await promptConfirm(`Undelete ${slug}?`)
const ok = await promptConfirm(formatPrompt(labels, slug))
if (!ok) return
}
const token = await requireToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`Undeleting ${slug}`)
const spinner = createSpinner(`${labels.progress} ${slug}`)
try {
const result = await apiRequest(
registry,
@@ -74,10 +111,33 @@ export async function cmdUndeleteSkill(
},
ApiV1DeleteResponseSchema,
)
spinner.succeed(`OK. Undeleted ${slug}`)
spinner.succeed(`OK. ${labels.past} ${slug}`)
return parseArk(ApiV1DeleteResponseSchema, result, 'Undelete response')
} catch (error) {
spinner.fail(formatError(error))
throw error
}
}
export async function cmdHideSkill(
opts: GlobalOpts,
slugArg: string,
options: { yes?: boolean },
inputAllowed: boolean,
) {
return cmdDeleteSkill(opts, slugArg, options, inputAllowed, hideLabels)
}
export async function cmdUnhideSkill(
opts: GlobalOpts,
slugArg: string,
options: { yes?: boolean },
inputAllowed: boolean,
) {
return cmdUndeleteSkill(opts, slugArg, options, inputAllowed, unhideLabels)
}
function formatPrompt(labels: SkillActionLabels, slug: string) {
const suffix = labels.promptSuffix ? ` (${labels.promptSuffix})` : ''
return `${labels.verb} ${slug}?${suffix}`
}
@@ -0,0 +1,123 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiRoutes } from '../../schema/index.js'
import type { GlobalOpts } from '../types'
const mockApiRequest = vi.fn()
const mockFetchText = vi.fn()
vi.mock('../../http.js', () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
fetchText: (...args: unknown[]) => mockFetchText(...args),
}))
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const mockSpinner = {
stop: vi.fn(),
fail: vi.fn(),
start: vi.fn(),
succeed: vi.fn(),
isSpinning: false,
text: '',
}
vi.mock('../ui.js', () => ({
createSpinner: vi.fn(() => mockSpinner),
fail: (message: string) => {
throw new Error(message)
},
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}))
const { cmdInspect } = await import('./inspect')
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const mockWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
function makeOpts(): GlobalOpts {
return {
workdir: '/work',
dir: '/work/skills',
site: 'https://clawhub.ai',
registry: 'https://clawhub.ai',
registrySource: 'default',
}
}
afterEach(() => {
vi.clearAllMocks()
mockLog.mockClear()
mockWrite.mockClear()
})
describe('cmdInspect', () => {
it('fetches latest version files when --files is set', async () => {
mockApiRequest
.mockResolvedValueOnce({
skill: {
slug: 'demo',
displayName: 'Demo',
summary: null,
tags: { latest: '1.2.3' },
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '1.2.3', createdAt: 3, changelog: 'init' },
owner: null,
})
.mockResolvedValueOnce({
skill: { slug: 'demo', displayName: 'Demo' },
version: { version: '1.2.3', createdAt: 3, changelog: 'init', files: [] },
})
await cmdInspect(makeOpts(), 'demo', { files: true })
const firstArgs = mockApiRequest.mock.calls[0]?.[1]
const secondArgs = mockApiRequest.mock.calls[1]?.[1]
expect(firstArgs?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent('demo')}`)
expect(secondArgs?.path).toBe(
`${ApiRoutes.skills}/${encodeURIComponent('demo')}/versions/${encodeURIComponent('1.2.3')}`,
)
})
it('uses tag param when fetching a file', async () => {
mockApiRequest
.mockResolvedValueOnce({
skill: {
slug: 'demo',
displayName: 'Demo',
summary: null,
tags: { latest: '2.0.0' },
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'init' },
owner: null,
})
.mockResolvedValueOnce({
skill: { slug: 'demo', displayName: 'Demo' },
version: { version: '2.0.0', createdAt: 3, changelog: 'init', files: [] },
})
mockFetchText.mockResolvedValue('content')
await cmdInspect(makeOpts(), 'demo', { file: 'SKILL.md', tag: 'latest' })
const fetchArgs = mockFetchText.mock.calls[0]?.[1]
const url = new URL(String(fetchArgs?.url))
expect(url.pathname).toBe('/api/v1/skills/demo/file')
expect(url.searchParams.get('path')).toBe('SKILL.md')
expect(url.searchParams.get('tag')).toBe('latest')
expect(url.searchParams.get('version')).toBeNull()
})
it('rejects when both version and tag are provided', async () => {
await expect(
cmdInspect(makeOpts(), 'demo', { version: '1.0.0', tag: 'latest' }),
).rejects.toThrow('Use either --version or --tag')
})
})
@@ -0,0 +1,291 @@
import { apiRequest, fetchText } from '../../http.js'
import {
ApiRoutes,
ApiV1SkillResponseSchema,
ApiV1SkillVersionListResponseSchema,
ApiV1SkillVersionResponseSchema,
} from '../../schema/index.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError } from '../ui.js'
type InspectOptions = {
version?: string
tag?: string
versions?: boolean
limit?: number
files?: boolean
file?: string
json?: boolean
}
type FileEntry = {
path: string
size: number | null
sha256: string | null
contentType: string | null
}
export async function cmdInspect(opts: GlobalOpts, slug: string, options: InspectOptions = {}) {
const trimmed = slug.trim()
if (!trimmed) fail('Slug required')
if (options.version && options.tag) fail('Use either --version or --tag')
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching skill')
try {
const skillResult = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
ApiV1SkillResponseSchema,
)
if (!skillResult.skill) {
spinner.fail('Skill not found')
return
}
const skill = skillResult.skill
const tags = normalizeTags(skill.tags)
const latestVersion = skillResult.latestVersion?.version ?? tags.latest ?? null
const taggedVersion = options.tag ? (tags[options.tag] ?? null) : null
if (options.tag && !taggedVersion) {
spinner.fail(`Unknown tag "${options.tag}"`)
return
}
const requestedVersion = options.version ?? taggedVersion ?? null
let versionResult: { version: unknown; skill: unknown } | null = null
if (options.files || options.file || options.version || options.tag) {
const targetVersion = requestedVersion ?? latestVersion
if (!targetVersion) fail('Could not resolve latest version')
spinner.text = `Fetching ${trimmed}@${targetVersion}`
versionResult = await apiRequest(
registry,
{
method: 'GET',
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
targetVersion,
)}`,
},
ApiV1SkillVersionResponseSchema,
)
}
let versionsList: { items?: unknown[]; nextCursor?: string | null } | null = null
if (options.versions) {
const limit = clampLimit(options.limit ?? 25, 25)
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry)
url.searchParams.set('limit', String(limit))
spinner.text = `Fetching versions (${limit})`
versionsList = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
ApiV1SkillVersionListResponseSchema,
)
}
let fileContent: string | null = null
if (options.file) {
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry)
url.searchParams.set('path', options.file)
if (options.version) {
url.searchParams.set('version', options.version)
} else if (options.tag) {
url.searchParams.set('tag', options.tag)
} else if (latestVersion) {
url.searchParams.set('version', latestVersion)
}
spinner.text = `Fetching ${options.file}`
fileContent = await fetchText(registry, { url: url.toString() })
}
spinner.stop()
const output = {
skill: skillResult.skill,
latestVersion: skillResult.latestVersion,
owner: skillResult.owner,
version: versionResult?.version ?? null,
versions: versionsList?.items ?? null,
file: options.file ? { path: options.file, content: fileContent } : null,
}
if (options.json) {
console.log(JSON.stringify(output, null, 2))
return
}
const shouldPrintMeta = !options.file || options.files || options.versions || options.version
if (shouldPrintMeta) {
printSkillSummary({
skill,
latestVersion: skillResult.latestVersion,
owner: skillResult.owner,
})
}
if (shouldPrintMeta && versionResult?.version) {
printVersionSummary(versionResult.version)
}
if (versionsList?.items && Array.isArray(versionsList.items)) {
if (versionsList.items.length === 0) {
console.log('No versions found.')
} else {
console.log('Versions:')
for (const item of versionsList.items) {
console.log(formatVersionLine(item))
}
}
}
if (versionResult?.version) {
const files = normalizeFiles((versionResult.version as { files?: unknown }).files)
if (options.files) {
if (files.length === 0) {
console.log('No files found.')
} else {
console.log('Files:')
for (const file of files) {
console.log(formatFileLine(file))
}
}
}
}
if (options.file && fileContent !== null) {
if (shouldPrintMeta) console.log(`\n${options.file}:\n`)
process.stdout.write(fileContent)
if (!fileContent.endsWith('\n')) process.stdout.write('\n')
}
} catch (error) {
spinner.fail(formatError(error))
throw error
}
}
function printSkillSummary(result: {
skill: {
slug: string
displayName: string
summary?: string | null
tags?: unknown
stats?: unknown
createdAt: number
updatedAt: number
}
latestVersion?: { version: string; createdAt: number; changelog: string } | null
owner?: { handle?: string | null; displayName?: string | null; image?: string | null } | null
}) {
const { skill } = result
console.log(`${skill.slug} ${skill.displayName}`)
if (skill.summary) console.log(`Summary: ${skill.summary}`)
const owner = result.owner?.handle || result.owner?.displayName
if (owner) console.log(`Owner: ${owner}`)
console.log(`Created: ${formatTimestamp(skill.createdAt)}`)
console.log(`Updated: ${formatTimestamp(skill.updatedAt)}`)
if (result.latestVersion?.version) {
console.log(`Latest: ${result.latestVersion.version}`)
}
const tags = normalizeTags(skill.tags)
const tagEntries = Object.entries(tags)
if (tagEntries.length > 0) {
console.log(`Tags: ${tagEntries.map(([tag, version]) => `${tag}=${version}`).join(', ')}`)
}
}
function printVersionSummary(version: unknown) {
if (!version || typeof version !== 'object') return
const entry = version as { version?: unknown; createdAt?: unknown; changelog?: unknown }
const value = typeof entry.version === 'string' ? entry.version : null
if (!value) return
console.log(`Selected: ${value}`)
if (typeof entry.createdAt === 'number') {
console.log(`Selected At: ${formatTimestamp(entry.createdAt)}`)
}
if (typeof entry.changelog === 'string' && entry.changelog.trim()) {
console.log(`Changelog: ${truncate(entry.changelog, 120)}`)
}
}
function normalizeTags(tags: unknown): Record<string, string> {
if (!tags || typeof tags !== 'object') return {}
const entries = Object.entries(tags as Record<string, unknown>)
const resolved: Record<string, string> = {}
for (const [tag, version] of entries) {
if (typeof version === 'string') resolved[tag] = version
}
return resolved
}
function normalizeFiles(files: unknown): FileEntry[] {
if (!Array.isArray(files)) return []
return files
.map((file) => {
if (!file || typeof file !== 'object') return null
const entry = file as {
path?: unknown
size?: unknown
sha256?: unknown
contentType?: unknown
}
if (typeof entry.path !== 'string') return null
const size = typeof entry.size === 'number' ? entry.size : Number(entry.size)
const sha256 = typeof entry.sha256 === 'string' ? entry.sha256 : null
const contentType = typeof entry.contentType === 'string' ? entry.contentType : null
return {
path: entry.path,
size: Number.isFinite(size) ? size : null,
sha256,
contentType,
}
})
.filter((entry): entry is FileEntry => Boolean(entry))
}
function formatVersionLine(item: unknown) {
if (!item || typeof item !== 'object') return '-'
const entry = item as { version?: unknown; createdAt?: unknown; changelog?: unknown }
const version = typeof entry.version === 'string' ? entry.version : '?'
const createdAt =
typeof entry.createdAt === 'number' ? formatTimestamp(entry.createdAt) : 'unknown'
const changelog = typeof entry.changelog === 'string' ? entry.changelog : ''
const snippet = changelog ? ` ${truncate(changelog, 80)}` : ''
return `${version} ${createdAt}${snippet}`
}
function formatFileLine(file: FileEntry) {
const size = file.size === null ? '?' : formatBytes(file.size)
const sha = file.sha256 ?? '?'
const type = file.contentType ? ` ${file.contentType}` : ''
return `${file.path} ${size} ${sha}${type}`
}
function formatTimestamp(timestamp: number) {
if (!Number.isFinite(timestamp)) return 'unknown'
return new Date(timestamp).toISOString()
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes)) return '?'
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10
return `${rounded}${units[index]}`
}
function clampLimit(limit: number, fallback: number) {
if (!Number.isFinite(limit)) return fallback
return Math.min(Math.max(1, Math.round(limit)), 200)
}
function truncate(str: string, maxLen: number) {
if (str.length <= maxLen) return str
return `${str.slice(0, maxLen - 3)}...`
}
@@ -0,0 +1,78 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalOpts } from '../types'
vi.mock('../../config.js', () => ({
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
}))
vi.mock('../registry.js', () => ({
getRegistry: vi.fn(async () => 'https://clawhub.ai'),
}))
const mockApiRequest = vi.fn()
vi.mock('../../http.js', () => ({
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
mockApiRequest(registry, args, schema),
}))
vi.mock('../ui.js', () => ({
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn() })),
fail: (message: string) => {
throw new Error(message)
},
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
isInteractive: () => false,
promptConfirm: vi.fn(async () => true),
}))
const { cmdBanUser } = await import('./moderation')
function makeOpts(): GlobalOpts {
return {
workdir: '/work',
dir: '/work/skills',
site: 'https://clawhub.ai',
registry: 'https://clawhub.ai',
registrySource: 'default',
}
}
afterEach(() => {
vi.clearAllMocks()
})
describe('cmdBanUser', () => {
it('requires --yes when input is disabled', async () => {
await expect(cmdBanUser(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
})
it('posts handle payload', async () => {
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 1 })
await cmdBanUser(makeOpts(), 'hightower6eu', { yes: true }, false)
expect(mockApiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
path: '/api/v1/users/ban',
body: { handle: 'hightower6eu' },
}),
expect.anything(),
)
})
it('posts user id payload when --id is set', async () => {
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 })
await cmdBanUser(makeOpts(), 'user_123', { yes: true, id: true }, false)
expect(mockApiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
path: '/api/v1/users/ban',
body: { userId: 'user_123' },
}),
expect.anything(),
)
})
})
@@ -0,0 +1,70 @@
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import { ApiRoutes, ApiV1BanUserResponseSchema, parseArk } from '../../schema/index.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
async function requireToken() {
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawhub login')
return token
}
export async function cmdBanUser(
opts: GlobalOpts,
identifierArg: string,
options: { yes?: boolean; id?: boolean },
inputAllowed: boolean,
) {
const raw = identifierArg.trim()
if (!raw) fail('Handle or user id required')
const allowPrompt = isInteractive() && inputAllowed !== false
const usesId = Boolean(options.id)
const handle = usesId ? null : normalizeHandle(raw)
const label = usesId ? raw : `@${handle}`
if (!options.yes) {
if (!allowPrompt) fail('Pass --yes (no input)')
const ok = await promptConfirm(`Ban ${label}? (requires moderator/admin; deletes owned skills)`)
if (!ok) return
}
const token = await requireToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`Banning ${label}`)
try {
const result = await apiRequest(
registry,
{
method: 'POST',
path: `${ApiRoutes.users}/ban`,
token,
body: usesId ? { userId: raw } : { handle },
},
ApiV1BanUserResponseSchema,
)
const parsed = parseArk(ApiV1BanUserResponseSchema, result, 'Ban user response')
if (parsed.alreadyBanned) {
spinner.succeed(`OK. ${label} already banned`)
return parsed
}
spinner.succeed(`OK. Banned ${label} (${formatDeletedSkills(parsed.deletedSkills)})`)
return parsed
} catch (error) {
spinner.fail(formatError(error))
throw error
}
}
function normalizeHandle(value: string) {
const trimmed = value.trim()
return trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase()
}
function formatDeletedSkills(count: number) {
if (!Number.isFinite(count)) return 'deleted skills unknown'
if (count === 1) return 'deleted 1 skill'
return `deleted ${count} skills`
}
+1 -1
View File
@@ -30,7 +30,7 @@ import type { Candidate, LocalSkill, SyncOptions } from './syncTypes.js'
export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) {
const allowPrompt = isInteractive() && inputAllowed !== false
intro('OpenClaw sync')
intro('ClawHub sync')
const cfg = await readGlobalConfig()
const token = cfg?.token
+67
View File
@@ -127,6 +127,36 @@ export async function apiRequestForm<T>(
return json as T
}
type TextRequestArgs = { path: string; token?: string } | { url: string; token?: string }
export async function fetchText(registry: string, args: TextRequestArgs): Promise<string> {
const url = 'url' in args ? args.url : new URL(args.path, registry).toString()
return pRetry(
async () => {
if (isBun) {
return await fetchTextViaCurl(url, args)
}
const headers: Record<string, string> = { Accept: 'text/plain' }
if (args.token) headers.Authorization = `Bearer ${args.token}`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal })
clearTimeout(timeout)
const text = await response.text()
if (!response.ok) {
const message = text || `HTTP ${response.status}`
if (response.status === 429 || response.status >= 500) {
throw new Error(message)
}
throw new AbortError(message)
}
return text
},
{ retries: 2 },
)
}
export async function downloadZip(registry: string, args: { slug: string; version?: string }) {
const url = new URL(ApiRoutes.download, registry)
url.searchParams.set('slug', args.slug)
@@ -254,6 +284,43 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
}
}
async function fetchTextViaCurl(url: string, args: { token?: string }) {
const headers = ['-H', 'Accept: text/plain']
if (args.token) {
headers.push('-H', `Authorization: Bearer ${args.token}`)
}
const curlArgs = [
'--silent',
'--show-error',
'--location',
'--max-time',
String(REQUEST_TIMEOUT_SECONDS),
'--write-out',
'\n%{http_code}',
'-X',
'GET',
...headers,
url,
]
const result = spawnSync('curl', curlArgs, { encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(result.stderr || 'curl failed')
}
const output = result.stdout ?? ''
const splitAt = output.lastIndexOf('\n')
if (splitAt === -1) throw new Error('curl response missing status')
const body = output.slice(0, splitAt)
const status = Number(output.slice(splitAt + 1).trim())
if (!Number.isFinite(status)) throw new Error('curl response missing status')
if (status < 200 || status >= 300) {
if (status === 429 || status >= 500) {
throw new Error(body || `HTTP ${status}`)
}
throw new AbortError(body || `HTTP ${status}`)
}
return body
}
async function fetchBinaryViaCurl(url: string) {
const tempDir = await mkdtemp(join(tmpdir(), 'clawhub-download-'))
const filePath = join(tempDir, 'payload.bin')
+1
View File
@@ -18,5 +18,6 @@ export const ApiRoutes = {
skills: '/api/v1/skills',
stars: '/api/v1/stars',
souls: '/api/v1/souls',
users: '/api/v1/users',
whoami: '/api/v1/whoami',
} as const
+6
View File
@@ -215,6 +215,12 @@ export const ApiV1DeleteResponseSchema = type({
ok: 'true',
})
export const ApiV1BanUserResponseSchema = type({
ok: 'true',
alreadyBanned: 'boolean',
deletedSkills: 'number',
})
export const ApiV1StarResponseSchema = type({
ok: 'true',
starred: 'boolean',
+1 -1
View File
@@ -1,3 +1,3 @@
# clawhub-schema
Shared runtime schemas (ArkType) for OpenClaw.
Shared runtime schemas (ArkType) for ClawHub.
+1
View File
@@ -17,5 +17,6 @@ export declare const ApiRoutes: {
readonly skills: "/api/v1/skills";
readonly stars: "/api/v1/stars";
readonly souls: "/api/v1/souls";
readonly users: "/api/v1/users";
readonly whoami: "/api/v1/whoami";
};
+1
View File
@@ -17,6 +17,7 @@ export const ApiRoutes = {
skills: '/api/v1/skills',
stars: '/api/v1/stars',
souls: '/api/v1/souls',
users: '/api/v1/users',
whoami: '/api/v1/whoami',
};
//# sourceMappingURL=routes.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
+1
View File
@@ -18,5 +18,6 @@ export const ApiRoutes = {
skills: '/api/v1/skills',
stars: '/api/v1/stars',
souls: '/api/v1/souls',
users: '/api/v1/users',
whoami: '/api/v1/whoami',
} as const
+1 -1
View File
@@ -1,7 +1,7 @@
{
"openapi": "3.1.0",
"info": {
"title": "OpenClaw API",
"title": "ClawHub API",
"version": "1.0.0",
"description": "Public REST API for skills. Rate limits: read 120/min per IP + 600/min per key; write 30/min per IP + 120/min per key."
},
+1 -1
View File
@@ -76,7 +76,7 @@
fill="#F6EFE4"
font-size="92"
font-weight="700"
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">OpenClaw</text>
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">ClawHub</text>
<!-- Subtitle -->
<text x="114" y="332"

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+2 -2
View File
@@ -116,8 +116,8 @@ function wrapText(value: string, maxWidth: number, fontSize: number, maxLines: n
}
export function buildSkillOgSvg(params: SkillOgSvgParams) {
const rawTitle = params.title.trim() || 'OpenClaw Skill'
const rawDescription = params.description.trim() || 'Published on OpenClaw.'
const rawTitle = params.title.trim() || 'ClawHub Skill'
const rawDescription = params.description.trim() || 'Published on ClawHub.'
const cardX = 72
const cardY = 96
+59 -15
View File
@@ -42,7 +42,10 @@ describe('SkillDetailPage', () => {
})
it('shows a loading indicator while loading', () => {
useQueryMock.mockImplementationOnce(() => undefined) // getBySlug
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
return undefined
})
render(<SkillDetailPage slug="weather" />)
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
@@ -50,26 +53,33 @@ describe('SkillDetailPage', () => {
})
it('shows not found when skill query resolves to null', async () => {
useQueryMock.mockImplementationOnce(() => null) // getBySlug
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
return null
})
render(<SkillDetailPage slug="missing-skill" />)
expect(await screen.findByText(/Skill not found/i)).toBeTruthy()
})
it('redirects legacy routes to canonical owner/slug', async () => {
useQueryMock.mockImplementationOnce(() => ({
skill: {
_id: 'skills:1',
slug: 'weather',
displayName: 'Weather',
summary: 'Get current weather.',
ownerUserId: 'users:1',
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: { handle: 'steipete', name: 'Peter' },
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
}))
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
if (args && typeof args === 'object' && 'skillId' in args) return []
return {
skill: {
_id: 'skills:1',
slug: 'weather',
displayName: 'Weather',
summary: 'Get current weather.',
ownerUserId: 'users:1',
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: { handle: 'steipete', name: 'Peter' },
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
}
})
render(<SkillDetailPage slug="weather" redirectToCanonical />)
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
@@ -83,4 +93,38 @@ describe('SkillDetailPage', () => {
replace: true,
})
})
it('shows report abuse note for authenticated users', async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: 'users:1', role: 'user' },
})
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
if (args && typeof args === 'object' && 'skillId' in args) return []
if (args && typeof args === 'object' && 'slug' in args) {
return {
skill: {
_id: 'skills:1',
slug: 'weather',
displayName: 'Weather',
summary: 'Get current weather.',
ownerUserId: 'users:1',
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: { handle: 'steipete', name: 'Peter' },
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {}, files: [] },
}
}
return undefined
})
render(<SkillDetailPage slug="weather" />)
expect(
await screen.findByText(/Reports require a reason\. Abuse may result in a ban\./i),
).toBeTruthy()
})
})
+4 -4
View File
@@ -4,7 +4,7 @@ import { Menu, Monitor, Moon, Sun } from 'lucide-react'
import { useMemo, useRef } from 'react'
import { gravatarUrl } from '../lib/gravatar'
import { isModerator } from '../lib/roles'
import { getOpenClawSiteUrl, getSiteMode, getSiteName } from '../lib/site'
import { getClawHubSiteUrl, getSiteMode, getSiteName } from '../lib/site'
import { applyTheme, useThemeMode } from '../lib/theme'
import { startThemeTransition } from '../lib/theme-transition'
import { useAuthStatus } from '../lib/useAuthStatus'
@@ -25,7 +25,7 @@ export default function Header() {
const siteMode = getSiteMode()
const siteName = useMemo(() => getSiteName(siteMode), [siteMode])
const isSoulMode = siteMode === 'souls'
const clawdHubUrl = getOpenClawSiteUrl()
const clawHubUrl = getClawHubSiteUrl()
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined)
const handle = me?.handle ?? me?.displayName ?? 'user'
@@ -59,7 +59,7 @@ export default function Header() {
<span className="brand-name">{siteName}</span>
</Link>
<nav className="nav-links">
{isSoulMode ? <a href={clawdHubUrl}>OpenClaw</a> : null}
{isSoulMode ? <a href={clawHubUrl}>ClawHub</a> : null}
{isSoulMode ? (
<Link
to="/souls"
@@ -133,7 +133,7 @@ export default function Header() {
<DropdownMenuContent align="end">
{isSoulMode ? (
<DropdownMenuItem asChild>
<a href={clawdHubUrl}>OpenClaw</a>
<a href={clawHubUrl}>ClawHub</a>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
+79 -7
View File
@@ -19,9 +19,9 @@ type SkillDetailPageProps = {
}
type SkillBySlugResult = {
skill: PublicSkill
skill: Doc<'skills'> | PublicSkill
latestVersion: Doc<'skillVersions'> | null
owner: PublicUser | null
owner: Doc<'users'> | PublicUser | null
forkOf: {
kind: 'fork' | 'duplicate'
version: string | null
@@ -36,6 +36,32 @@ type SkillBySlugResult = {
type SkillFile = Doc<'skillVersions'>['files'][number]
function formatReportError(error: unknown) {
if (error && typeof error === 'object' && 'data' in error) {
const data = (error as { data?: unknown }).data
if (typeof data === 'string' && data.trim()) return data.trim()
if (
data &&
typeof data === 'object' &&
'message' in data &&
typeof (data as { message?: unknown }).message === 'string'
) {
const message = (data as { message?: string }).message?.trim()
if (message) return message
}
}
if (error instanceof Error) {
const cleaned = error.message
.replace(/\[CONVEX[^\]]*\]\s*/g, '')
.replace(/\[Request ID:[^\]]*\]\s*/g, '')
.replace(/^Server Error Called by client\s*/i, '')
.replace(/^ConvexError:\s*/i, '')
.trim()
if (cleaned && cleaned !== 'Server Error') return cleaned
}
return 'Unable to submit report. Please try again.'
}
export function SkillDetailPage({
slug,
canonicalOwner,
@@ -43,7 +69,14 @@ export function SkillDetailPage({
}: SkillDetailPageProps) {
const navigate = useNavigate()
const { isAuthenticated, me } = useAuthStatus()
const result = useQuery(api.skills.getBySlug, { slug }) as SkillBySlugResult | undefined
const isStaff = isModerator(me)
const staffResult = useQuery(api.skills.getBySlugForStaff, isStaff ? { slug } : 'skip') as
| SkillBySlugResult
| undefined
const publicResult = useQuery(api.skills.getBySlug, !isStaff ? { slug } : 'skip') as
| SkillBySlugResult
| undefined
const result = isStaff ? staffResult : publicResult
const toggleStar = useMutation(api.stars.toggle)
const reportSkill = useMutation(api.skills.report)
const addComment = useMutation(api.comments.add)
@@ -80,7 +113,6 @@ export function SkillDetailPage({
) as Array<{ comment: Doc<'comments'>; user: PublicUser | null }> | undefined
const canManage = canManageSkill(me, skill)
const isStaff = isModerator(me)
const ownerHandle = owner?.handle ?? owner?.name ?? null
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null)
@@ -104,6 +136,26 @@ export function SkillDetailPage({
canonical?.skill?.slug && canonical.skill.slug !== forkOf?.skill?.slug
? buildSkillHref(canonicalOwnerHandle, canonicalOwnerId, canonical.skill.slug)
: null
const staffSkill = isStaff && skill ? (skill as Doc<'skills'>) : null
const moderationStatus =
staffSkill?.moderationStatus ?? (staffSkill?.softDeletedAt ? 'hidden' : undefined)
const isHidden = moderationStatus === 'hidden' || Boolean(staffSkill?.softDeletedAt)
const isRemoved = moderationStatus === 'removed'
const isAutoHidden = isHidden && staffSkill?.moderationReason === 'auto.reports'
const staffVisibilityTag = isRemoved
? 'Removed'
: isAutoHidden
? 'Auto-hidden'
: isHidden
? 'Hidden'
: null
const staffModerationNote = staffVisibilityTag
? isAutoHidden
? 'Auto-hidden after 4+ unique reports.'
: isRemoved
? 'Removed from public view.'
: 'Hidden from public view.'
: null
useEffect(() => {
if (!wantsCanonicalRedirect || !ownerParam) return
@@ -207,6 +259,9 @@ export function SkillDetailPage({
</div>
<p className="section-subtitle">{skill.summary ?? 'No summary provided.'}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
@@ -246,6 +301,11 @@ export function SkillDetailPage({
{badge}
</div>
))}
{isStaff && staffVisibilityTag ? (
<div className={`tag${isAutoHidden || isRemoved ? ' tag-accent' : ''}`}>
{staffVisibilityTag}
</div>
) : null}
<div className="skill-actions">
{isAuthenticated ? (
<button
@@ -262,12 +322,19 @@ export function SkillDetailPage({
className="btn btn-ghost"
type="button"
onClick={async () => {
const reason = window.prompt('Report this skill? Add a reason if you want.')
const reason = window.prompt(
'Report this skill? A reason is required. Abuse may result in a ban.',
)
if (reason === null) return
const trimmedReason = reason.trim()
if (!trimmedReason) {
window.alert('Report reason required.')
return
}
try {
const result = await reportSkill({
skillId: skill._id,
reason: reason.trim() || undefined,
reason: trimmedReason,
})
if (result.reported) {
window.alert('Thanks — your report has been submitted.')
@@ -276,7 +343,7 @@ export function SkillDetailPage({
}
} catch (error) {
console.error('Failed to report skill', error)
window.alert('Unable to submit report. Please try again.')
window.alert(formatReportError(error))
}
}}
>
@@ -289,6 +356,11 @@ export function SkillDetailPage({
</Link>
) : null}
</div>
{isAuthenticated ? (
<div className="section-subtitle" style={{ margin: '6px 0 0' }}>
Reports require a reason. Abuse may result in a ban.
</div>
) : null}
</div>
<div className="skill-hero-cta">
<div className="skill-version-pill">
+3 -3
View File
@@ -14,7 +14,7 @@ describe('og helpers', () => {
summary: 'Forecasts for your area.',
version: '1.2.3',
})
expect(meta.title).toBe('Weather — OpenClaw')
expect(meta.title).toBe('Weather — ClawHub')
expect(meta.description).toBe('Forecasts for your area.')
expect(meta.url).toContain('/steipete/weather')
expect(meta.owner).toBe('steipete')
@@ -48,8 +48,8 @@ describe('og helpers', () => {
it('uses defaults when owner and summary are missing', () => {
const meta = buildSkillMeta({ slug: 'parser' })
expect(meta.title).toBe('parser — OpenClaw')
expect(meta.description).toMatch(/OpenClaw — a fast skill registry/i)
expect(meta.title).toBe('parser — ClawHub')
expect(meta.description).toMatch(/ClawHub — a fast skill registry/i)
expect(meta.url).toContain('/unknown/parser')
expect(meta.owner).toBeNull()
expect(meta.image).toContain('slug=parser')
+5 -5
View File
@@ -1,4 +1,4 @@
import { getOpenClawSiteUrl, getOnlyCrabsSiteUrl } from './site'
import { getClawHubSiteUrl, getOnlyCrabsSiteUrl } from './site'
type SkillMetaSource = {
slug: string
@@ -33,13 +33,13 @@ type SoulMeta = {
owner: string | null
}
const DEFAULT_DESCRIPTION = 'OpenClaw — a fast skill registry for agents, with vector search.'
const DEFAULT_DESCRIPTION = 'ClawHub — a fast skill registry for agents, with vector search.'
const DEFAULT_SOUL_DESCRIPTION = 'SoulHub — the home for SOUL.md bundles and personal system lore.'
const OG_SKILL_IMAGE_LAYOUT_VERSION = '5'
const OG_SOUL_IMAGE_LAYOUT_VERSION = '1'
export function getSiteUrl() {
return getOpenClawSiteUrl()
return getClawHubSiteUrl()
}
export function getSoulSiteUrl() {
@@ -103,9 +103,9 @@ export function buildSkillMeta(source: SkillMetaSource): SkillMeta {
const displayName = clean(source.displayName) || clean(source.slug)
const summary = clean(source.summary)
const version = clean(source.version)
const title = `${displayName}OpenClaw`
const title = `${displayName} — ClawHub`
const description =
summary || (owner ? `Agent skill by @${owner} on OpenClaw.` : DEFAULT_DESCRIPTION)
summary || (owner ? `Agent skill by @${owner} on ClawHub.` : DEFAULT_DESCRIPTION)
const ownerPath = owner || ownerId || 'unknown'
const url = `${siteUrl}/${ownerPath}/${source.slug}`
const imageParams = new URLSearchParams()
+5 -5
View File
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
detectSiteMode,
detectSiteModeFromUrl,
getOpenClawSiteUrl,
getClawHubSiteUrl,
getOnlyCrabsHost,
getOnlyCrabsSiteUrl,
getSiteDescription,
@@ -42,9 +42,9 @@ afterEach(() => {
describe('site helpers', () => {
it('returns default and env configured site URLs', () => {
expect(getOpenClawSiteUrl()).toBe('https://clawhub.ai')
expect(getClawHubSiteUrl()).toBe('https://clawhub.ai')
withMetaEnv({ VITE_SITE_URL: 'https://example.com' }, () => {
expect(getOpenClawSiteUrl()).toBe('https://example.com')
expect(getClawHubSiteUrl()).toBe('https://example.com')
})
})
@@ -118,10 +118,10 @@ describe('site helpers', () => {
})
it('derives site metadata from mode', () => {
expect(getSiteName('skills')).toBe('OpenClaw')
expect(getSiteName('skills')).toBe('ClawHub')
expect(getSiteName('souls')).toBe('SoulHub')
expect(getSiteDescription('skills')).toContain('OpenClaw')
expect(getSiteDescription('skills')).toContain('ClawHub')
expect(getSiteDescription('souls')).toContain('SoulHub')
expect(getSiteUrlForMode('skills')).toBe('https://clawhub.ai')
+4 -4
View File
@@ -4,7 +4,7 @@ const DEFAULT_CLAWHUB_SITE_URL = 'https://clawhub.ai'
const DEFAULT_ONLYCRABS_SITE_URL = 'https://onlycrabs.ai'
const DEFAULT_ONLYCRABS_HOST = 'onlycrabs.ai'
export function getOpenClawSiteUrl() {
export function getClawHubSiteUrl() {
return import.meta.env.VITE_SITE_URL ?? DEFAULT_CLAWHUB_SITE_URL
}
@@ -70,15 +70,15 @@ export function getSiteMode(): SiteMode {
}
export function getSiteName(mode: SiteMode = getSiteMode()) {
return mode === 'souls' ? 'SoulHub' : 'OpenClaw'
return mode === 'souls' ? 'SoulHub' : 'ClawHub'
}
export function getSiteDescription(mode: SiteMode = getSiteMode()) {
return mode === 'souls'
? 'SoulHub — the home for SOUL.md bundles and personal system lore.'
: 'OpenClaw — a fast skill registry for agents, with vector search.'
: 'ClawHub — a fast skill registry for agents, with vector search.'
}
export function getSiteUrlForMode(mode: SiteMode = getSiteMode()) {
return mode === 'souls' ? getOnlyCrabsSiteUrl() : getOpenClawSiteUrl()
return mode === 'souls' ? getOnlyCrabsSiteUrl() : getClawHubSiteUrl()
}
+1 -1
View File
@@ -32,7 +32,7 @@ function SkillsHome() {
<div className="hero-inner">
<div className="hero-copy fade-up" data-delay="1">
<span className="hero-badge">Lobster-light. Agent-right.</span>
<h1 className="hero-title">OpenClaw, the skill dock for sharp agents.</h1>
<h1 className="hero-title">ClawHub, the skill dock for sharp agents.</h1>
<p className="hero-subtitle">
Upload AgentSkills bundles, version them like npm, and make them searchable with
vectors. No gatekeeping, just signal.
+183 -27
View File
@@ -18,6 +18,17 @@ type ManagementSkillEntry = {
owner: Doc<'users'> | null
}
type ReportReasonEntry = {
reason: string
createdAt: number
reporterHandle: string | null
reporterId: Id<'users'>
}
type ReportedSkillEntry = ManagementSkillEntry & {
reports: ReportReasonEntry[]
}
type RecentVersionEntry = {
version: Doc<'skillVersions'>
skill: Doc<'skills'> | null
@@ -64,14 +75,14 @@ function Management() {
| undefined
const selectedSlug = search.skill?.trim()
const selectedSkill = useQuery(
api.skills.getBySlug,
api.skills.getBySlugForStaff,
staff && selectedSlug ? { slug: selectedSlug } : 'skip',
) as SkillBySlugResult | undefined
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : 'skip') as
| RecentVersionEntry[]
| undefined
const reportedSkills = useQuery(api.skills.listReportedSkills, staff ? { limit: 25 } : 'skip') as
| ManagementSkillEntry[]
| ReportedSkillEntry[]
| undefined
const duplicateCandidates = useQuery(
api.skills.listDuplicateCandidates,
@@ -79,6 +90,7 @@ function Management() {
) as DuplicateCandidateEntry[] | undefined
const setRole = useMutation(api.users.setRole)
const banUser = useMutation(api.users.banUser)
const setBatch = useMutation(api.skills.setBatch)
const setSoftDeleted = useMutation(api.skills.setSoftDeleted)
const hardDelete = useMutation(api.skills.hardDelete)
@@ -89,6 +101,10 @@ function Management() {
const [selectedDuplicate, setSelectedDuplicate] = useState('')
const [selectedOwner, setSelectedOwner] = useState('')
const [reportSearch, setReportSearch] = useState('')
const [reportSearchDebounced, setReportSearchDebounced] = useState('')
const [userSearch, setUserSearch] = useState('')
const [userSearchDebounced, setUserSearchDebounced] = useState('')
const selectedSkillId = selectedSkill?.skill?._id ?? null
const selectedOwnerUserId = selectedSkill?.skill?.ownerUserId ?? null
@@ -100,6 +116,16 @@ function Management() {
setSelectedOwner(String(selectedOwnerUserId))
}, [selectedCanonicalSlug, selectedOwnerUserId, selectedSkillId])
useEffect(() => {
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250)
return () => clearTimeout(handle)
}, [reportSearch])
useEffect(() => {
const handle = setTimeout(() => setUserSearchDebounced(userSearch), 250)
return () => clearTimeout(handle)
}, [userSearch])
if (!staff) {
return (
<main className="section">
@@ -116,6 +142,46 @@ function Management() {
)
}
const reportQuery = reportSearchDebounced.trim().toLowerCase()
const filteredReportedSkills = reportQuery
? reportedSkills.filter((entry) => {
const reportReasons = (entry.reports ?? []).map((report) => report.reason).join(' ')
const reporterHandles = (entry.reports ?? [])
.map((report) => report.reporterHandle)
.filter(Boolean)
.join(' ')
const haystack = [
entry.skill.displayName,
entry.skill.slug,
entry.owner?.handle,
entry.owner?.name,
reportReasons,
reporterHandles,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(reportQuery)
})
: reportedSkills
const reportCountLabel =
filteredReportedSkills.length === 0 && reportedSkills.length > 0
? 'No matching reports.'
: 'No reports yet.'
const reportSummary = `Showing ${filteredReportedSkills.length} of ${reportedSkills.length}`
const userQuery = userSearchDebounced.trim().toLowerCase()
const filteredUsers = userQuery
? (users ?? []).filter((user) => {
const haystack = [user.handle, user.name, user.role, user._id]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(userQuery)
})
: (users ?? [])
const userSummary = `Showing ${filteredUsers.length} of ${(users ?? []).length}`
return (
<main className="section">
<h1 className="section-title">Management console</h1>
@@ -125,16 +191,29 @@ function Management() {
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Reported skills
</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Filter</span>
<input
type="search"
placeholder="Search reported skills"
value={reportSearch}
onChange={(event) => setReportSearch(event.target.value)}
/>
</div>
<div className="management-count">{reportSummary}</div>
</div>
<div className="management-list">
{reportedSkills.length === 0 ? (
<div className="stat">No reports yet.</div>
{filteredReportedSkills.length === 0 ? (
<div className="stat">{reportCountLabel}</div>
) : (
reportedSkills.map((entry) => {
const { skill, latestVersion, owner } = entry
filteredReportedSkills.map((entry) => {
const { skill, latestVersion, owner, reports } = entry
const ownerParam = resolveOwnerParam(
owner?.handle ?? null,
owner?._id ?? skill.ownerUserId,
)
const reportEntries = reports ?? []
return (
<div key={skill._id} className="management-item">
<div className="management-item-main">
@@ -148,6 +227,26 @@ function Management() {
? ` · last ${formatTimestamp(skill.lastReportedAt)}`
: ''}
</div>
{reportEntries.length > 0 ? (
<div className="management-sublist">
{reportEntries.map((report) => (
<div
key={`${report.reporterId}-${report.createdAt}`}
className="management-report-item"
>
<span className="management-report-meta">
{formatTimestamp(report.createdAt)}
{report.reporterHandle ? ` · @${report.reporterHandle}` : ''}
</span>
<span>{report.reason}</span>
</div>
))}
</div>
) : (
<div className="section-subtitle" style={{ margin: 0 }}>
No report reasons yet.
</div>
)}
</div>
<div className="management-actions">
<button
@@ -211,6 +310,11 @@ function Management() {
const isOfficial = isSkillOfficial(skill)
const isDeprecated = isSkillDeprecated(skill)
const badges = getSkillBadges(skill)
const ownerUserId = skill.ownerUserId ?? selectedOwnerUserId
const ownerHandle = owner?.handle ?? owner?.name ?? 'user'
const isOwnerAdmin = owner?.role === 'admin'
const canBanOwner =
staff && ownerUserId && ownerUserId !== me?._id && (admin || !isOwnerAdmin)
return (
<div key={skill._id} className="management-item">
@@ -324,6 +428,22 @@ function Management() {
Hard delete
</button>
) : null}
{staff ? (
<button
className="btn"
type="button"
disabled={!canBanOwner}
onClick={() => {
if (!ownerUserId || ownerUserId === me?._id) return
if (!window.confirm(`Ban @${ownerHandle} and delete their skills?`)) {
return
}
void banUser({ userId: ownerUserId })
}}
>
Ban user
</button>
) : null}
{admin ? (
<>
<button
@@ -494,29 +614,65 @@ function Management() {
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Users
</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Filter</span>
<input
type="search"
placeholder="Search users"
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
/>
</div>
<div className="management-count">{userSummary}</div>
</div>
<div className="management-list">
{(users ?? []).map((user) => (
<div key={user._id} className="management-item">
<div className="management-item-main">
<span className="mono">@{user.handle ?? user.name ?? 'user'}</span>
</div>
<div className="management-actions">
<select
value={user.role ?? 'user'}
onChange={(event) => {
const value = event.target.value
if (value === 'admin' || value === 'moderator' || value === 'user') {
void setRole({ userId: user._id, role: value })
}
}}
>
<option value="user">User</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
</div>
{filteredUsers.length === 0 ? (
<div className="stat">
{(users ?? []).length === 0 ? 'No users yet.' : 'No matching users.'}
</div>
))}
) : (
filteredUsers.map((user) => (
<div key={user._id} className="management-item">
<div className="management-item-main">
<span className="mono">@{user.handle ?? user.name ?? 'user'}</span>
</div>
<div className="management-actions">
<select
value={user.role ?? 'user'}
onChange={(event) => {
const value = event.target.value
if (value === 'admin' || value === 'moderator' || value === 'user') {
void setRole({ userId: user._id, role: value })
}
}}
>
<option value="user">User</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<button
className="btn"
type="button"
disabled={user._id === me?._id}
onClick={() => {
if (user._id === me?._id) return
if (
!window.confirm(
`Ban @${user.handle ?? user.name ?? 'user'} and delete their skills?`,
)
) {
return
}
void banUser({ userId: user._id })
}}
>
Ban user
</button>
</div>
</div>
))
)}
</div>
</div>
) : null}
+1 -1
View File
@@ -13,7 +13,7 @@ import {
isTextFile,
readText,
uploadFile,
} from './upload/utils'
} from './upload/-utils'
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
+21
View File
@@ -2687,6 +2687,15 @@ html.theme-transition::view-transition-new(theme) {
min-width: 180px;
}
.management-search input {
min-width: 240px;
}
.management-count {
font-size: 0.85rem;
color: var(--ink-soft);
}
.management-sublist {
display: grid;
gap: 8px;
@@ -2703,6 +2712,18 @@ html.theme-transition::view-transition-new(theme) {
justify-content: space-between;
}
.management-report-item {
display: grid;
gap: 4px;
font-size: 0.95rem;
color: var(--ink);
}
.management-report-meta {
font-size: 0.8rem;
color: var(--ink-soft);
}
@media (max-width: 900px) {
.management-item {
grid-template-columns: 1fr;
+37
View File
@@ -17,6 +17,29 @@ const convexBrowserPath = join(convexRoot, 'dist/esm/browser/index.js')
const convexValuesPath = join(convexRoot, 'dist/esm/values/index.js')
const convexAuthReactPath = require.resolve('@convex-dev/auth/react')
function handleRollupWarning(
warning: { code?: string; message: string; id?: string },
warn: (warning: { code?: string; message: string; id?: string }) => void,
) {
if (
warning.code === 'MODULE_LEVEL_DIRECTIVE' &&
warning.id?.includes('node_modules') &&
/use client/i.test(warning.message)
) {
return
}
if (
warning.code === 'UNUSED_EXTERNAL_IMPORT' &&
/@tanstack\/start-|@tanstack\/router-core\/ssr\/(client|server)/.test(warning.message)
) {
return
}
if (warning.code === 'EMPTY_BUNDLE' || /Generated an empty chunk/i.test(warning.message)) {
return
}
warn(warning)
}
const config = defineConfig({
resolve: {
dedupe: ['convex', '@convex-dev/auth', 'react', 'react-dom'],
@@ -34,6 +57,9 @@ const config = defineConfig({
devtools(),
nitro({
serverDir: 'server',
rollupConfig: {
onwarn: handleRollupWarning,
},
}),
// this is the plugin that enables path aliases
viteTsConfigPaths({
@@ -43,6 +69,17 @@ const config = defineConfig({
tanstackStart(),
viteReact(),
],
build: {
chunkSizeWarningLimit: 900,
rollupOptions: {
onwarn: handleRollupWarning,
},
},
ssr: {
rollupOptions: {
onwarn: handleRollupWarning,
},
},
})
export default config