mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
251de1f540 | ||
|
|
123f60fa93 | ||
|
|
643faf71c8 | ||
|
|
a2c46fbb5d | ||
|
|
f51e0a087d | ||
|
|
d9108b0948 | ||
|
|
d7a017e1c3 | ||
|
|
fffdf82540 | ||
|
|
a16e624766 | ||
|
|
decce1d35c | ||
|
|
468832af3f | ||
|
|
54c793a660 | ||
|
|
5d9a89a885 | ||
|
|
31e9a57678 | ||
|
|
7680cc4ce8 | ||
|
|
de56255b95 | ||
|
|
eaaa5e4423 | ||
|
|
49e9c3c071 | ||
|
|
ef96fbee84 | ||
|
|
9fc032216e | ||
|
|
236058b1b5 | ||
|
|
595bd2cf05 | ||
|
|
918b5528df | ||
|
|
2f6f11af3f | ||
|
|
c90f92dbc7 | ||
|
|
8db8c89206 | ||
|
|
00c31dcdd4 | ||
|
|
47646937b6 | ||
|
|
10d250ea32 | ||
|
|
e7fa7afdf7 | ||
|
|
aa97727be8 | ||
|
|
d375496c17 | ||
|
|
e108789ab1 |
@@ -15,10 +15,12 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Peer deps
|
||||
run: bun run check:peers
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
@@ -36,4 +38,3 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Web: show published skills on user profiles (thanks @njoylab, #20).
|
||||
|
||||
### Fixed
|
||||
- Registry: drop missing skills during search hydration (thanks @aaronn, #28).
|
||||
- CLI: use path-based skill metadata lookup for updates (thanks @daveonkels, #22).
|
||||
- Search: keep highlighted-only filtering and clamp vector candidates to Convex limits (thanks @aaronn, #30).
|
||||
|
||||
## 0.3.0 - 2026-01-19
|
||||
|
||||
### Added
|
||||
- CLI: add `explore` command for latest updates, with limit clamping + tests/docs (thanks @jdrhyne, #14).
|
||||
- CLI: `explore --json` output + new sorts (`installs`, `installsAllTime`, `trending`) and limit up to 200.
|
||||
- API: `/api/v1/skills` supports installs + trending sorts (7-day installs).
|
||||
- API: idempotent `POST/DELETE /api/v1/stars/{slug}` endpoints.
|
||||
- Registry: trending leaderboard + daily stats backfill for installs-based sorts.
|
||||
|
||||
### Fixed
|
||||
- Web: keep search mode navigation and state in sync (thanks @NACC96, #12).
|
||||
|
||||
## 0.2.0 - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "clawdhub",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.3",
|
||||
"@auth/core": "^0.37.4",
|
||||
"@convex-dev/auth": "^0.0.90",
|
||||
"@fontsource/bricolage-grotesque": "^5.2.10",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
@@ -15,14 +16,15 @@
|
||||
"@resvg/resvg-wasm": "^2.6.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "^0.9.2",
|
||||
"@tanstack/react-router": "^1.151.3",
|
||||
"@tanstack/react-router-devtools": "^1.151.3",
|
||||
"@tanstack/react-start": "^1.151.5",
|
||||
"@tanstack/router-plugin": "^1.151.5",
|
||||
"@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",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"clawdhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.31.5",
|
||||
"convex-helpers": "^0.1.111",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.8",
|
||||
"lucide-react": "^0.562.0",
|
||||
@@ -60,7 +62,7 @@
|
||||
},
|
||||
"packages/clawdhub": {
|
||||
"name": "clawdhub",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
},
|
||||
@@ -75,6 +77,7 @@
|
||||
"ora": "^9.0.0",
|
||||
"p-retry": "^7.1.1",
|
||||
"semver": "^7.7.3",
|
||||
"undici": "^7.16.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.9",
|
||||
@@ -85,10 +88,10 @@
|
||||
"name": "clawdhub-schema",
|
||||
"version": "0.0.2",
|
||||
"dependencies": {
|
||||
"arktype": "latest",
|
||||
"arktype": "^2.1.29",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "latest",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -105,7 +108,7 @@
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="],
|
||||
"@auth/core": ["@auth/core@0.37.4", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^5.9.6", "oauth4webapi": "^3.1.1", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
@@ -551,37 +554,37 @@
|
||||
|
||||
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.2", "", { "dependencies": { "@tanstack/devtools": "0.10.3" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-JNXvBO3jgq16GzTVm7p65n5zHNfMhnqF6Bm7CawjoqZrjEakxbM6Yvy63aKSIpbrdf+Wun2Xn8P0qD+vp56e1g=="],
|
||||
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.151.3", "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-Xb8444OgVpmwCl9hpUA+N/X0dsik/WWYWdldIYNAcmBAl1ZYYXm8kKnfjgHS2aMZzPnqN1BjKocKgc5jg3lK9Q=="],
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.151.6", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.151.6", "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-KDbz7kacZCOoDrUwYljz4I/qjqVGq+bgUhpi/CWubi7by0GZ3JEECwFl/+k+4V6ATinJDjTNmCGwFcdwqjQDtA=="],
|
||||
|
||||
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.151.3", "", { "dependencies": { "@tanstack/router-devtools-core": "1.151.3" }, "peerDependencies": { "@tanstack/react-router": "^1.151.3", "@tanstack/router-core": "^1.151.3", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-WQ8cA4BkDBIglieexdHFvHbhvZOdtgCWO5BDR58MQoz+ZQwsWo9R0m6t6yVOQzkPi69t6Og9s1NogE4BF0/NYA=="],
|
||||
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.151.6", "", { "dependencies": { "@tanstack/router-devtools-core": "1.151.6" }, "peerDependencies": { "@tanstack/react-router": "^1.151.6", "@tanstack/router-core": "^1.151.6", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-mRRFzIAIOAWYcZrEr0FYy/1FmM51iWwUdK0J3nWuXjAIeEb7uizS0HkeNbzX5yxfGZgkplk23eCXIUmJcDuVRQ=="],
|
||||
|
||||
"@tanstack/react-start": ["@tanstack/react-start@1.151.5", "", { "dependencies": { "@tanstack/react-router": "1.151.3", "@tanstack/react-start-client": "1.151.3", "@tanstack/react-start-server": "1.151.3", "@tanstack/router-utils": "^1.143.11", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-plugin-core": "1.151.5", "@tanstack/start-server-core": "1.151.3", "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-V3di0M9L6Y/dGCTJrqjTWvxHPxXkSnT2+Dt1ffmVSixlO0HiUDrc1BfEZb3avERPkJ0SL8zxSz6xFYsQZew/yg=="],
|
||||
"@tanstack/react-start": ["@tanstack/react-start@1.152.0", "", { "dependencies": { "@tanstack/react-router": "1.151.6", "@tanstack/react-start-client": "1.152.0", "@tanstack/react-start-server": "1.152.0", "@tanstack/router-utils": "^1.143.11", "@tanstack/start-client-core": "1.152.0", "@tanstack/start-plugin-core": "1.152.0", "@tanstack/start-server-core": "1.152.0", "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-btRNNIJGnXVEmD9yuiyNRvMITX5aocVJVdv/LQHxca4EcxntPEn1+HboPXFr6SDl9UNekH/6NZqv3LPz9tDm7A=="],
|
||||
|
||||
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.151.3", "", { "dependencies": { "@tanstack/react-router": "1.151.3", "@tanstack/router-core": "1.151.3", "@tanstack/start-client-core": "1.151.3", "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-oW6hyCnetWu5dpPROUUxoX3Rpvc1Y7bEwqj3zfHCzGBQ0CsQqryjvtWifd9IBIVYW6W4qKD9ScLyzoLl7uw+ww=="],
|
||||
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.152.0", "", { "dependencies": { "@tanstack/react-router": "1.151.6", "@tanstack/router-core": "1.151.6", "@tanstack/start-client-core": "1.152.0", "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-YI2VVdCLk86QP5Q0oMXZWdk551haIisuF5AIr+S9ZAF415s4AgDpKRlXX251aqiHXYvv4rFnV1c9o7w02031Cw=="],
|
||||
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-router": "1.151.3", "@tanstack/router-core": "1.151.3", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-server-core": "1.151.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Zdlw5gyNkffd52E6u8IFCNu/xwUFfuLnQId3DIkGzPBFi9rg5JfcRXANbjUL0SrnM3+nksyhnbnluYTlCrEmvg=="],
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.152.0", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-router": "1.151.6", "@tanstack/router-core": "1.151.6", "@tanstack/start-client-core": "1.152.0", "@tanstack/start-server-core": "1.152.0" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-oz3J3Ipj04IUAfVSE1DD41x8+7DDWHZez+fCqLnk8sw63ct013CeKwIvW9v1n0OtU6axekuVHHYBOsF5wO5/lg=="],
|
||||
|
||||
"@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.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-JVaNgR/4xl6/5BhVVZCnaZrN+VgpIaHVIWnMiC8dgpZZAI+FfIUOXoTQ+95167cp1igjR8pKDAKEX+gDgD3yKw=="],
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.151.6", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eyqWx6vhKffkINWLujDF2sxAG9GE/XUdi3HrlD94ddJO9MBi/90a1HJaTYFSV8LmngjcRv8A3tt7OvFdv/UqhA=="],
|
||||
|
||||
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.151.3", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.151.3", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-C4ZjhYz3ASkromu0PkXvr6MVwHWsipfQ3fttPODMgjQ1Crvulixmd6yaeZjPoqYXWWTFw75qfiY1Fmh89mD+oQ=="],
|
||||
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.151.6", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.151.6", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-OHGGvEtnANEbEwjYCChbvCyCLk/3Cqh9G5bhM5DVqrZ+b9wfeu46IdEsbSi1JfuK2sCHNMS5MrJaE2HZPsFx6Q=="],
|
||||
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.151.5", "", { "dependencies": { "@tanstack/router-core": "1.151.3", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-YxXCVil0hMBwSYHYL1kGY9ENI0mi3FUDTqVty8XudddT3Jvx21lW94BAUU56VZAp7kuDIVpn8nsyk++ogDJgOg=="],
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.151.6", "", { "dependencies": { "@tanstack/router-core": "1.151.6", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-IS4tkrkLIwI2EViGlUXCVgnKJ4EhWMM6w75XoJqd0X4t6K0/OiHkr3AQ0f2qZXbNciqLGxS88GLe5UiSAOS5Vw=="],
|
||||
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.151.5", "", { "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.151.3", "@tanstack/router-generator": "1.151.5", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "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.151.3", "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-l+4jmNACXHDamjbP/bFcOLheqzzqGCpEMjMS/w+BqBEYIu0W+Jqwj8wOwMaEBcuk/Vhnvmkp+WN1RYdM6+GVLg=="],
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.151.6", "", { "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.151.6", "@tanstack/router-generator": "1.151.6", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "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.151.6", "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-Kz9wmAgcylung1KoXvEEVTW91PNh4U65MgVwSmz5fYQP7UqNHPvHqBFWKEu17a45SfZ4LufsNW3LTFT35tWGXg=="],
|
||||
|
||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.143.11", "", { "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-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA=="],
|
||||
|
||||
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.151.3", "", { "dependencies": { "@tanstack/router-core": "1.151.3", "@tanstack/start-fn-stubs": "1.151.3", "@tanstack/start-storage-context": "1.151.3", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-44i4lPsY7rmQYY9kZYicc+x+/zDXkDGEn6qNVOA0Q4O6evNXr92eXPVBAYFly1y7m+wlFgeFWddBXbW08gUA7g=="],
|
||||
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.152.0", "", { "dependencies": { "@tanstack/router-core": "1.151.6", "@tanstack/start-fn-stubs": "1.151.3", "@tanstack/start-storage-context": "1.151.6", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-G88urpJImiGZttawtciSj46Ko57TXO2pd11Zef6Yw1VLD6fJs+RF0q9N0P98SGs9Jm0oUjLusi3Ti550fkQ2qw=="],
|
||||
|
||||
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.151.3", "", {}, "sha512-/zWBnfsOwact936Bn0CxigudU1QRZdiNTsK7ME/LMXXA66XsDxkryX5+5FeGwU5ETNPfLAx6pRUet1mtUKnLCg=="],
|
||||
|
||||
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.151.5", "", { "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.151.3", "@tanstack/router-generator": "1.151.5", "@tanstack/router-plugin": "1.151.5", "@tanstack/router-utils": "1.143.11", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-server-core": "1.151.3", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.0", "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-IA3XJ5jzJu2JcLFCTDgEqsR8SgsX1drJKZbvVgoYk6RS3scBtIs7PaBlJ/hn61TvYUx14UTQsJzFzxU6DRH1fQ=="],
|
||||
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.152.0", "", { "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.151.6", "@tanstack/router-generator": "1.151.6", "@tanstack/router-plugin": "1.151.6", "@tanstack/router-utils": "1.143.11", "@tanstack/start-client-core": "1.152.0", "@tanstack/start-server-core": "1.152.0", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.0", "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-XT+ECwpmi6cQj6gb9QHo1XOOw+7zGXOvwnrijg3HSzNKFDlawHycgOotnViTOjtVjhHLpW1/yGyivWX5OC8wTw=="],
|
||||
|
||||
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/router-core": "1.151.3", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-storage-context": "1.151.3", "h3-v2": "npm:h3@2.0.1-rc.7", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3" } }, "sha512-Hg+VwS2PKkx7+EW9MGZeT3arY7K+JUKvjhuQ/Ugs05f8x/dks3cn91aK5DYkPN0YRMpTNbDwZJX/DePBSnse8w=="],
|
||||
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.152.0", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/router-core": "1.151.6", "@tanstack/start-client-core": "1.152.0", "@tanstack/start-storage-context": "1.151.6", "h3-v2": "npm:h3@2.0.1-rc.7", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3" } }, "sha512-bMmc4tIhR3FClb1iWLw0zuhvY71b9k2iz8Jw0tSK/KKi++gl1bGyJjuPtx8kDbx5Ix1SoXp4QNdXuo7dv5INfQ=="],
|
||||
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.151.3", "", { "dependencies": { "@tanstack/router-core": "1.151.3" } }, "sha512-3gUiBFfcV/OYbdwex6Dl4BWY2oTHy46fDF9CzOzTpujDcz3hI7pqWs68sxatChdyoQVMZR2Xf8os4VRLMgF3ig=="],
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.151.6", "", { "dependencies": { "@tanstack/router-core": "1.151.6" } }, "sha512-MvTcT40qnqatIpKjWSfMRxFzTkprGBxhX2c+em58iZLEsGksitMUWbprknD6AIUqjHty8V3LuhULks/o6tSugQ=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
|
||||
|
||||
@@ -605,8 +608,6 @@
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
@@ -741,7 +742,9 @@
|
||||
|
||||
"convex": ["convex@1.31.5", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-E1IuJKFwMCHDToNGukBPs6c7RFaarR3t8chLF9n98TM5/Tgmj8lM6l7sKM1aJ3VwqGaB4wbeUAPY8osbCOXBhQ=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
"convex-helpers": ["convex-helpers@0.1.111", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-0O59Ohi8HVc3+KULxSC6JHsw8cQJyc8gZ7OAfNRVX7T5Wy6LhPx3l8veYN9avKg7UiPlO7m1eBiQMHKclIyXyQ=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
|
||||
|
||||
@@ -1075,7 +1078,7 @@
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="],
|
||||
"oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
@@ -1119,9 +1122,9 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="],
|
||||
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
||||
|
||||
"preact-render-to-string": ["preact-render-to-string@5.2.3", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA=="],
|
||||
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
|
||||
|
||||
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
|
||||
|
||||
@@ -1345,10 +1348,6 @@
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@convex-dev/auth/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"@convex-dev/auth/oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-ryJnSmj4UhrGLZZPJ6PKVb4wNPAIkW6iyLy+0TRwazd3L1u0wzMe8RfqevAh2HbcSkoeLiSYnOVDOys4JSGYyg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Z82FDl1ByxqPEPrAYYeTQVlx2FSHPe1qwX465c+96IRS3fTdSYRoJcRxg3g2fEG5I69z1dSEWQlNRRr0/677mg=="],
|
||||
@@ -1385,8 +1384,6 @@
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"preact-render-to-string/pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
Vendored
+12
@@ -12,6 +12,7 @@ import type * as auth from "../auth.js";
|
||||
import type * as comments from "../comments.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as devSeed from "../devSeed.js";
|
||||
import type * as devSeedExtra from "../devSeedExtra.js";
|
||||
import type * as downloads from "../downloads.js";
|
||||
import type * as githubBackups from "../githubBackups.js";
|
||||
import type * as githubBackupsNode from "../githubBackupsNode.js";
|
||||
@@ -21,6 +22,7 @@ import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as httpApi from "../httpApi.js";
|
||||
import type * as httpApiV1 from "../httpApiV1.js";
|
||||
import type * as leaderboards from "../leaderboards.js";
|
||||
import type * as lib_access from "../lib/access.js";
|
||||
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
|
||||
import type * as lib_changelog from "../lib/changelog.js";
|
||||
@@ -28,9 +30,11 @@ import type * as lib_embeddings from "../lib/embeddings.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";
|
||||
import type * as lib_leaderboards from "../lib/leaderboards.js";
|
||||
import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillPublish from "../lib/skillPublish.js";
|
||||
import type * as lib_skillStats from "../lib/skillStats.js";
|
||||
import type * as lib_skills from "../lib/skills.js";
|
||||
import type * as lib_soulChangelog from "../lib/soulChangelog.js";
|
||||
import type * as lib_soulPublish from "../lib/soulPublish.js";
|
||||
@@ -41,12 +45,14 @@ import type * as rateLimits from "../rateLimits.js";
|
||||
import type * as search from "../search.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as seedSouls from "../seedSouls.js";
|
||||
import type * as skillStatEvents from "../skillStatEvents.js";
|
||||
import type * as skills from "../skills.js";
|
||||
import type * as soulComments from "../soulComments.js";
|
||||
import type * as soulDownloads from "../soulDownloads.js";
|
||||
import type * as soulStars from "../soulStars.js";
|
||||
import type * as souls from "../souls.js";
|
||||
import type * as stars from "../stars.js";
|
||||
import type * as statsMaintenance from "../statsMaintenance.js";
|
||||
import type * as telemetry from "../telemetry.js";
|
||||
import type * as tokens from "../tokens.js";
|
||||
import type * as uploads from "../uploads.js";
|
||||
@@ -64,6 +70,7 @@ declare const fullApi: ApiFromModules<{
|
||||
comments: typeof comments;
|
||||
crons: typeof crons;
|
||||
devSeed: typeof devSeed;
|
||||
devSeedExtra: typeof devSeedExtra;
|
||||
downloads: typeof downloads;
|
||||
githubBackups: typeof githubBackups;
|
||||
githubBackupsNode: typeof githubBackupsNode;
|
||||
@@ -73,6 +80,7 @@ declare const fullApi: ApiFromModules<{
|
||||
http: typeof http;
|
||||
httpApi: typeof httpApi;
|
||||
httpApiV1: typeof httpApiV1;
|
||||
leaderboards: typeof leaderboards;
|
||||
"lib/access": typeof lib_access;
|
||||
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
|
||||
"lib/changelog": typeof lib_changelog;
|
||||
@@ -80,9 +88,11 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
"lib/leaderboards": typeof lib_leaderboards;
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillPublish": typeof lib_skillPublish;
|
||||
"lib/skillStats": typeof lib_skillStats;
|
||||
"lib/skills": typeof lib_skills;
|
||||
"lib/soulChangelog": typeof lib_soulChangelog;
|
||||
"lib/soulPublish": typeof lib_soulPublish;
|
||||
@@ -93,12 +103,14 @@ declare const fullApi: ApiFromModules<{
|
||||
search: typeof search;
|
||||
seed: typeof seed;
|
||||
seedSouls: typeof seedSouls;
|
||||
skillStatEvents: typeof skillStatEvents;
|
||||
skills: typeof skills;
|
||||
soulComments: typeof soulComments;
|
||||
soulDownloads: typeof soulDownloads;
|
||||
soulStars: typeof soulStars;
|
||||
souls: typeof souls;
|
||||
stars: typeof stars;
|
||||
statsMaintenance: typeof statsMaintenance;
|
||||
telemetry: typeof telemetry;
|
||||
tokens: typeof tokens;
|
||||
uploads: typeof uploads;
|
||||
|
||||
@@ -10,4 +10,25 @@ crons.interval(
|
||||
{ batchSize: 50, maxBatches: 5 },
|
||||
)
|
||||
|
||||
crons.interval(
|
||||
'trending-leaderboard',
|
||||
{ minutes: 60 },
|
||||
internal.leaderboards.rebuildTrendingLeaderboardInternal,
|
||||
{ limit: 200 },
|
||||
)
|
||||
|
||||
crons.interval(
|
||||
'skill-stats-backfill',
|
||||
{ minutes: 10 },
|
||||
internal.statsMaintenance.runSkillStatBackfillInternal,
|
||||
{ batchSize: 200, maxBatches: 5 },
|
||||
)
|
||||
|
||||
crons.interval(
|
||||
'skill-stat-events',
|
||||
{ minutes: 5 },
|
||||
internal.skillStatEvents.processSkillStatEventsInternal,
|
||||
{ batchSize: 100 },
|
||||
)
|
||||
|
||||
export default crons
|
||||
|
||||
+71
-41
@@ -1,5 +1,6 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { internalAction, internalMutation } from './_generated/server'
|
||||
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
|
||||
import { parseClawdisMetadata, parseFrontmatter } from './lib/skills'
|
||||
@@ -13,6 +14,17 @@ type SeedSkillSpec = {
|
||||
rawSkillMd: string
|
||||
}
|
||||
|
||||
type SeedActionArgs = {
|
||||
reset?: boolean
|
||||
}
|
||||
|
||||
type SeedActionResult = {
|
||||
ok: true
|
||||
results: Array<Record<string, unknown> & { slug: string }>
|
||||
}
|
||||
|
||||
type SeedMutationResult = Record<string, unknown>
|
||||
|
||||
const SEED_SKILLS: SeedSkillSpec[] = [
|
||||
{
|
||||
slug: 'padel',
|
||||
@@ -237,53 +249,19 @@ function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
|
||||
)}${rawSkillMd.slice(frontmatterEnd)}`
|
||||
}
|
||||
|
||||
export const seedNixSkills = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const results = []
|
||||
|
||||
for (const spec of SEED_SKILLS) {
|
||||
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
|
||||
const frontmatter = parseFrontmatter(skillMd)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
|
||||
|
||||
const result = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
reset: args.reset,
|
||||
storageId,
|
||||
metadata: spec.metadata,
|
||||
frontmatter,
|
||||
clawdis,
|
||||
skillMd,
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
})
|
||||
|
||||
results.push({ slug: spec.slug, ...result })
|
||||
}
|
||||
|
||||
return { ok: true, results }
|
||||
},
|
||||
})
|
||||
|
||||
export const seedPadelSkill = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
|
||||
if (!spec) throw new Error('padel seed spec missing')
|
||||
async function seedNixSkillsHandler(
|
||||
ctx: ActionCtx,
|
||||
args: SeedActionArgs,
|
||||
): Promise<SeedActionResult> {
|
||||
const results: Array<Record<string, unknown> & { slug: string }> = []
|
||||
|
||||
for (const spec of SEED_SKILLS) {
|
||||
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
|
||||
const frontmatter = parseFrontmatter(skillMd)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
|
||||
|
||||
return ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
const result: SeedMutationResult = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
reset: args.reset,
|
||||
storageId,
|
||||
metadata: spec.metadata,
|
||||
@@ -295,7 +273,51 @@ export const seedPadelSkill = internalAction({
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
})
|
||||
|
||||
results.push({ slug: spec.slug, ...result })
|
||||
}
|
||||
|
||||
return { ok: true, results }
|
||||
}
|
||||
|
||||
export const seedNixSkills: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: seedNixSkillsHandler,
|
||||
})
|
||||
|
||||
async function seedPadelSkillHandler(
|
||||
ctx: ActionCtx,
|
||||
args: SeedActionArgs,
|
||||
): Promise<SeedMutationResult> {
|
||||
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
|
||||
if (!spec) throw new Error('padel seed spec missing')
|
||||
|
||||
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
|
||||
const frontmatter = parseFrontmatter(skillMd)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
|
||||
|
||||
return (await ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
reset: args.reset,
|
||||
storageId,
|
||||
metadata: spec.metadata,
|
||||
frontmatter,
|
||||
clawdis,
|
||||
skillMd,
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
})) as SeedMutationResult
|
||||
}
|
||||
|
||||
export const seedPadelSkill: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: seedPadelSkillHandler,
|
||||
})
|
||||
|
||||
export const seedSkillMutation = internalMutation({
|
||||
@@ -364,6 +386,10 @@ export const seedSkillMutation = internalMutation({
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
badges: { redactionApproved: undefined },
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
@@ -413,6 +439,10 @@ export const seedSkillMutation = internalMutation({
|
||||
await ctx.db.patch(skillId, {
|
||||
latestVersionId: versionId,
|
||||
tags: { latest: versionId },
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* Extra seed skills for pagination testing.
|
||||
*
|
||||
* This file contains 50 placeholder skills to test pagination behavior.
|
||||
* Run with: bunx convex run internal.devSeedExtra.seedExtraSkillsInternal
|
||||
* Or with reset: bunx convex run internal.devSeedExtra.seedExtraSkillsInternal '{"reset": true}'
|
||||
*/
|
||||
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Id } from './_generated/dataModel'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { internalAction, internalMutation } from './_generated/server'
|
||||
import { parseClawdisMetadata, parseFrontmatter } from './lib/skills'
|
||||
|
||||
type SeedSkillSpec = {
|
||||
slug: string
|
||||
displayName: string
|
||||
summary: string
|
||||
version: string
|
||||
metadata: Record<string, unknown>
|
||||
rawSkillMd: string
|
||||
}
|
||||
|
||||
function makeSkill(
|
||||
slug: string,
|
||||
displayName: string,
|
||||
summary: string,
|
||||
envVars: string[] = [],
|
||||
commands: string[] = ['help', 'status', 'run'],
|
||||
): SeedSkillSpec {
|
||||
const cliHelp = `${slug} - ${summary}
|
||||
|
||||
Usage:
|
||||
${slug} [command]
|
||||
|
||||
Commands:
|
||||
${commands.map((cmd) => ` ${cmd.padEnd(12)} Run ${cmd} operation`).join('\n')}
|
||||
|
||||
Flags:
|
||||
-h, --help Show help
|
||||
--json Output as JSON
|
||||
`
|
||||
|
||||
const rawSkillMd = `---
|
||||
name: ${slug}
|
||||
description: ${summary}
|
||||
---
|
||||
|
||||
# ${displayName}
|
||||
|
||||
## CLI
|
||||
|
||||
\`\`\`bash
|
||||
${commands.map((cmd) => `${slug} ${cmd}`).join('\n')}
|
||||
\`\`\`
|
||||
|
||||
## Usage
|
||||
|
||||
Use this skill to ${summary.toLowerCase()}.
|
||||
`
|
||||
|
||||
return {
|
||||
slug,
|
||||
displayName,
|
||||
summary,
|
||||
version: '0.1.0',
|
||||
metadata: {
|
||||
clawdbot: {
|
||||
nix: {
|
||||
plugin: `github:example/${slug}`,
|
||||
systems: ['aarch64-darwin', 'x86_64-linux'],
|
||||
},
|
||||
config: {
|
||||
requiredEnv: envVars,
|
||||
},
|
||||
cliHelp,
|
||||
},
|
||||
},
|
||||
rawSkillMd,
|
||||
}
|
||||
}
|
||||
|
||||
// 50 placeholder skills for pagination testing
|
||||
const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [
|
||||
// DevOps & Infrastructure (10)
|
||||
makeSkill(
|
||||
'kubectl-helper',
|
||||
'Kubectl Helper',
|
||||
'Simplified kubectl commands for common Kubernetes operations.',
|
||||
['KUBECONFIG'],
|
||||
['pods', 'logs', 'exec', 'describe', 'apply'],
|
||||
),
|
||||
makeSkill(
|
||||
'terraform-runner',
|
||||
'Terraform Runner',
|
||||
'Execute Terraform plans and applies with safety checks.',
|
||||
['TF_VAR_region', 'AWS_PROFILE'],
|
||||
['plan', 'apply', 'destroy', 'output', 'state'],
|
||||
),
|
||||
makeSkill(
|
||||
'ansible-exec',
|
||||
'Ansible Exec',
|
||||
'Run Ansible playbooks and ad-hoc commands.',
|
||||
['ANSIBLE_INVENTORY'],
|
||||
['playbook', 'adhoc', 'inventory', 'facts', 'vault'],
|
||||
),
|
||||
makeSkill(
|
||||
'docker-compose-mgr',
|
||||
'Docker Compose Manager',
|
||||
'Manage Docker Compose stacks and services.',
|
||||
['DOCKER_HOST'],
|
||||
['up', 'down', 'logs', 'ps', 'restart'],
|
||||
),
|
||||
makeSkill(
|
||||
'k9s-wrapper',
|
||||
'K9s Wrapper',
|
||||
'Interactive Kubernetes cluster management via K9s.',
|
||||
['KUBECONFIG'],
|
||||
['launch', 'contexts', 'namespaces', 'pods', 'logs'],
|
||||
),
|
||||
makeSkill(
|
||||
'helm-charts',
|
||||
'Helm Charts',
|
||||
'Manage Helm chart deployments and releases.',
|
||||
['KUBECONFIG', 'HELM_REPO'],
|
||||
['install', 'upgrade', 'rollback', 'list', 'search'],
|
||||
),
|
||||
makeSkill(
|
||||
'prometheus-alerts',
|
||||
'Prometheus Alerts',
|
||||
'Query Prometheus metrics and manage alerting rules.',
|
||||
['PROMETHEUS_URL'],
|
||||
['query', 'alerts', 'rules', 'targets', 'status'],
|
||||
),
|
||||
makeSkill(
|
||||
'grafana-dash',
|
||||
'Grafana Dashboards',
|
||||
'Create and manage Grafana dashboards programmatically.',
|
||||
['GRAFANA_URL', 'GRAFANA_API_KEY'],
|
||||
['list', 'export', 'import', 'create', 'delete'],
|
||||
),
|
||||
makeSkill(
|
||||
'nginx-config',
|
||||
'Nginx Config',
|
||||
'Generate and validate Nginx configuration files.',
|
||||
['NGINX_CONF_DIR'],
|
||||
['generate', 'validate', 'reload', 'test', 'sites'],
|
||||
),
|
||||
makeSkill(
|
||||
'jenkins-jobs',
|
||||
'Jenkins Jobs',
|
||||
'Manage Jenkins jobs and pipelines.',
|
||||
['JENKINS_URL', 'JENKINS_TOKEN'],
|
||||
['list', 'build', 'status', 'logs', 'config'],
|
||||
),
|
||||
|
||||
// Productivity (8)
|
||||
makeSkill(
|
||||
'todoist-sync',
|
||||
'Todoist Sync',
|
||||
'Sync and manage Todoist tasks from the command line.',
|
||||
['TODOIST_API_TOKEN'],
|
||||
['list', 'add', 'complete', 'projects', 'labels'],
|
||||
),
|
||||
makeSkill(
|
||||
'notion-backup',
|
||||
'Notion Backup',
|
||||
'Export and backup Notion workspaces.',
|
||||
['NOTION_TOKEN'],
|
||||
['export', 'backup', 'restore', 'pages', 'databases'],
|
||||
),
|
||||
makeSkill(
|
||||
'gcal-manager',
|
||||
'Google Calendar Manager',
|
||||
'Manage Google Calendar events and schedules.',
|
||||
['GOOGLE_CREDENTIALS_FILE'],
|
||||
['events', 'create', 'delete', 'calendars', 'reminders'],
|
||||
),
|
||||
makeSkill(
|
||||
'time-tracker',
|
||||
'Time Tracker',
|
||||
'Track time spent on projects and tasks.',
|
||||
['TIMETRACK_DB'],
|
||||
['start', 'stop', 'status', 'report', 'projects'],
|
||||
),
|
||||
makeSkill(
|
||||
'email-digest',
|
||||
'Email Digest',
|
||||
'Generate email digests and summaries.',
|
||||
['IMAP_SERVER', 'IMAP_USER'],
|
||||
['fetch', 'digest', 'search', 'folders', 'unread'],
|
||||
),
|
||||
makeSkill(
|
||||
'habit-tracker',
|
||||
'Habit Tracker',
|
||||
'Track daily habits and streaks.',
|
||||
['HABITS_DB'],
|
||||
['log', 'streak', 'stats', 'habits', 'remind'],
|
||||
),
|
||||
makeSkill(
|
||||
'bookmark-sync',
|
||||
'Bookmark Sync',
|
||||
'Sync bookmarks across browsers and devices.',
|
||||
['BOOKMARKS_DIR'],
|
||||
['sync', 'export', 'import', 'search', 'tags'],
|
||||
),
|
||||
makeSkill(
|
||||
'notes-export',
|
||||
'Notes Export',
|
||||
'Export notes to various formats.',
|
||||
['NOTES_DIR'],
|
||||
['export', 'convert', 'search', 'list', 'tags'],
|
||||
),
|
||||
|
||||
// Media & Entertainment (6)
|
||||
makeSkill(
|
||||
'spotify-ctl',
|
||||
'Spotify Control',
|
||||
'Control Spotify playback from the terminal.',
|
||||
['SPOTIFY_CLIENT_ID', 'SPOTIFY_CLIENT_SECRET'],
|
||||
['play', 'pause', 'next', 'prev', 'search'],
|
||||
),
|
||||
makeSkill(
|
||||
'plex-manager',
|
||||
'Plex Manager',
|
||||
'Manage Plex media libraries and playback.',
|
||||
['PLEX_URL', 'PLEX_TOKEN'],
|
||||
['libraries', 'scan', 'search', 'play', 'sessions'],
|
||||
),
|
||||
makeSkill(
|
||||
'ytdl-wrapper',
|
||||
'YouTube Downloader',
|
||||
'Download videos from YouTube and other platforms.',
|
||||
['YTDL_OUTPUT_DIR'],
|
||||
['download', 'info', 'playlist', 'audio', 'formats'],
|
||||
),
|
||||
makeSkill(
|
||||
'podcast-dl',
|
||||
'Podcast Downloader',
|
||||
'Download and manage podcast episodes.',
|
||||
['PODCAST_DIR'],
|
||||
['subscribe', 'download', 'list', 'play', 'search'],
|
||||
),
|
||||
makeSkill(
|
||||
'audiobook-player',
|
||||
'Audiobook Player',
|
||||
'Manage and play audiobook collections.',
|
||||
['AUDIOBOOK_DIR'],
|
||||
['play', 'pause', 'bookmark', 'list', 'progress'],
|
||||
),
|
||||
makeSkill(
|
||||
'music-lib',
|
||||
'Music Library',
|
||||
'Organize and query local music libraries.',
|
||||
['MUSIC_DIR'],
|
||||
['scan', 'search', 'play', 'playlist', 'stats'],
|
||||
),
|
||||
|
||||
// Smart Home (8)
|
||||
makeSkill(
|
||||
'hass-control',
|
||||
'Home Assistant Control',
|
||||
'Control Home Assistant entities and automations.',
|
||||
['HASS_URL', 'HASS_TOKEN'],
|
||||
['entities', 'services', 'automations', 'scenes', 'history'],
|
||||
),
|
||||
makeSkill(
|
||||
'zigbee-mqtt',
|
||||
'Zigbee2MQTT',
|
||||
'Manage Zigbee devices via MQTT.',
|
||||
['MQTT_BROKER', 'ZIGBEE_TOPIC'],
|
||||
['devices', 'pair', 'remove', 'rename', 'groups'],
|
||||
),
|
||||
makeSkill(
|
||||
'tasmota-ctl',
|
||||
'Tasmota Control',
|
||||
'Control Tasmota-flashed devices.',
|
||||
['TASMOTA_HOSTS'],
|
||||
['status', 'power', 'config', 'update', 'backup'],
|
||||
),
|
||||
makeSkill(
|
||||
'esphome-mgr',
|
||||
'ESPHome Manager',
|
||||
'Manage ESPHome device configurations.',
|
||||
['ESPHOME_DIR'],
|
||||
['compile', 'upload', 'logs', 'dashboard', 'config'],
|
||||
),
|
||||
makeSkill(
|
||||
'mqtt-broker',
|
||||
'MQTT Broker',
|
||||
'Interact with MQTT brokers for IoT messaging.',
|
||||
['MQTT_BROKER', 'MQTT_USER'],
|
||||
['pub', 'sub', 'topics', 'clients', 'stats'],
|
||||
),
|
||||
makeSkill(
|
||||
'hue-lights',
|
||||
'Philips Hue',
|
||||
'Control Philips Hue lights and scenes.',
|
||||
['HUE_BRIDGE_IP', 'HUE_API_KEY'],
|
||||
['lights', 'scenes', 'groups', 'schedules', 'sensors'],
|
||||
),
|
||||
makeSkill(
|
||||
'smart-thermo',
|
||||
'Smart Thermostat',
|
||||
'Control smart thermostats and HVAC systems.',
|
||||
['THERMOSTAT_API_KEY'],
|
||||
['status', 'set', 'schedule', 'history', 'zones'],
|
||||
),
|
||||
makeSkill(
|
||||
'cam-viewer',
|
||||
'Camera Viewer',
|
||||
'View and manage security camera feeds.',
|
||||
['CAMERA_URLS'],
|
||||
['list', 'snapshot', 'stream', 'record', 'events'],
|
||||
),
|
||||
|
||||
// Finance (5)
|
||||
makeSkill(
|
||||
'budget-track',
|
||||
'Budget Tracker',
|
||||
'Track budgets and spending across categories.',
|
||||
['BUDGET_DB'],
|
||||
['summary', 'add', 'categories', 'report', 'goals'],
|
||||
),
|
||||
makeSkill(
|
||||
'crypto-watch',
|
||||
'Crypto Watcher',
|
||||
'Monitor cryptocurrency prices and portfolios.',
|
||||
['CRYPTO_API_KEY'],
|
||||
['prices', 'portfolio', 'alerts', 'history', 'convert'],
|
||||
),
|
||||
makeSkill(
|
||||
'stock-alerts',
|
||||
'Stock Alerts',
|
||||
'Set up stock price alerts and notifications.',
|
||||
['STOCK_API_KEY'],
|
||||
['quote', 'watch', 'alerts', 'portfolio', 'news'],
|
||||
),
|
||||
makeSkill(
|
||||
'expense-cat',
|
||||
'Expense Categorizer',
|
||||
'Automatically categorize expenses.',
|
||||
['EXPENSE_DB'],
|
||||
['import', 'categorize', 'report', 'rules', 'export'],
|
||||
),
|
||||
makeSkill(
|
||||
'invoice-gen',
|
||||
'Invoice Generator',
|
||||
'Generate and manage invoices.',
|
||||
['INVOICE_DIR', 'COMPANY_INFO'],
|
||||
['create', 'list', 'send', 'paid', 'overdue'],
|
||||
),
|
||||
|
||||
// Communication (5)
|
||||
makeSkill(
|
||||
'slack-bot',
|
||||
'Slack Bot',
|
||||
'Interact with Slack channels and messages.',
|
||||
['SLACK_TOKEN'],
|
||||
['send', 'channels', 'users', 'search', 'files'],
|
||||
),
|
||||
makeSkill(
|
||||
'discord-mgr',
|
||||
'Discord Manager',
|
||||
'Manage Discord servers and messages.',
|
||||
['DISCORD_TOKEN'],
|
||||
['send', 'servers', 'channels', 'members', 'roles'],
|
||||
),
|
||||
makeSkill(
|
||||
'telegram-bot',
|
||||
'Telegram Bot',
|
||||
'Send and receive Telegram messages.',
|
||||
['TELEGRAM_BOT_TOKEN'],
|
||||
['send', 'receive', 'chats', 'files', 'inline'],
|
||||
),
|
||||
makeSkill(
|
||||
'matrix-cli',
|
||||
'Matrix CLI',
|
||||
'Interact with Matrix chat rooms.',
|
||||
['MATRIX_HOMESERVER', 'MATRIX_TOKEN'],
|
||||
['send', 'rooms', 'join', 'leave', 'sync'],
|
||||
),
|
||||
makeSkill(
|
||||
'irc-bridge',
|
||||
'IRC Bridge',
|
||||
'Bridge IRC channels to other platforms.',
|
||||
['IRC_SERVER', 'IRC_NICK'],
|
||||
['connect', 'join', 'send', 'channels', 'users'],
|
||||
),
|
||||
|
||||
// Data & Analytics (5)
|
||||
makeSkill(
|
||||
'pg-queries',
|
||||
'PostgreSQL Queries',
|
||||
'Execute PostgreSQL queries and manage databases.',
|
||||
['DATABASE_URL'],
|
||||
['query', 'tables', 'schema', 'backup', 'restore'],
|
||||
),
|
||||
makeSkill(
|
||||
'clickhouse-ql',
|
||||
'ClickHouse Queries',
|
||||
'Run ClickHouse analytics queries.',
|
||||
['CLICKHOUSE_URL'],
|
||||
['query', 'tables', 'insert', 'system', 'optimize'],
|
||||
),
|
||||
makeSkill(
|
||||
'redis-cli',
|
||||
'Redis CLI',
|
||||
'Interact with Redis cache and data structures.',
|
||||
['REDIS_URL'],
|
||||
['get', 'set', 'keys', 'info', 'flush'],
|
||||
),
|
||||
makeSkill(
|
||||
'elastic-search',
|
||||
'Elasticsearch',
|
||||
'Search and manage Elasticsearch indices.',
|
||||
['ELASTICSEARCH_URL'],
|
||||
['search', 'index', 'mapping', 'cluster', 'aliases'],
|
||||
),
|
||||
makeSkill(
|
||||
'mongo-shell',
|
||||
'MongoDB Shell',
|
||||
'Query and manage MongoDB collections.',
|
||||
['MONGODB_URI'],
|
||||
['find', 'insert', 'update', 'delete', 'aggregate'],
|
||||
),
|
||||
|
||||
// Security (3)
|
||||
makeSkill(
|
||||
'vault-secrets',
|
||||
'Vault Secrets',
|
||||
'Manage secrets in HashiCorp Vault.',
|
||||
['VAULT_ADDR', 'VAULT_TOKEN'],
|
||||
['read', 'write', 'list', 'delete', 'seal'],
|
||||
),
|
||||
makeSkill(
|
||||
'gpg-keys',
|
||||
'GPG Keys',
|
||||
'Manage GPG keys and encryption.',
|
||||
['GNUPGHOME'],
|
||||
['list', 'generate', 'export', 'import', 'encrypt'],
|
||||
),
|
||||
makeSkill(
|
||||
'ssh-rotate',
|
||||
'SSH Key Rotator',
|
||||
'Rotate and manage SSH keys.',
|
||||
['SSH_KEY_DIR'],
|
||||
['generate', 'rotate', 'deploy', 'list', 'revoke'],
|
||||
),
|
||||
]
|
||||
|
||||
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
|
||||
const frontmatterEnd = rawSkillMd.indexOf('\n---', 3)
|
||||
if (frontmatterEnd === -1) return rawSkillMd
|
||||
return `${rawSkillMd.slice(0, frontmatterEnd)}\nmetadata: ${JSON.stringify(
|
||||
metadata,
|
||||
)}${rawSkillMd.slice(frontmatterEnd)}`
|
||||
}
|
||||
|
||||
function randomStats() {
|
||||
return {
|
||||
downloads: Math.floor(Math.random() * 5000),
|
||||
stars: Math.floor(Math.random() * 500),
|
||||
installsCurrent: Math.floor(Math.random() * 200),
|
||||
installsAllTime: Math.floor(Math.random() * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
export const applyRandomStats = internalMutation({
|
||||
args: {
|
||||
skillId: v.id('skills'),
|
||||
stats: v.object({
|
||||
downloads: v.number(),
|
||||
stars: v.number(),
|
||||
installsCurrent: v.number(),
|
||||
installsAllTime: v.number(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.skillId, {
|
||||
statsDownloads: args.stats.downloads,
|
||||
statsStars: args.stats.stars,
|
||||
statsInstallsCurrent: args.stats.installsCurrent,
|
||||
statsInstallsAllTime: args.stats.installsAllTime,
|
||||
stats: {
|
||||
downloads: args.stats.downloads,
|
||||
stars: args.stats.stars,
|
||||
installsCurrent: args.stats.installsCurrent,
|
||||
installsAllTime: args.stats.installsAllTime,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const seedExtraSkillsInternal = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx: ActionCtx, args) => {
|
||||
const results: Array<{ slug: string; ok: boolean; skipped?: boolean }> = []
|
||||
|
||||
for (const spec of EXTRA_SEED_SKILLS) {
|
||||
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
|
||||
const frontmatter = parseFrontmatter(skillMd)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
|
||||
|
||||
const result = (await ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
reset: args.reset,
|
||||
storageId,
|
||||
metadata: spec.metadata,
|
||||
frontmatter,
|
||||
clawdis,
|
||||
skillMd,
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
})) as { ok: boolean; skipped?: boolean; skillId?: string }
|
||||
|
||||
// Apply random stats after creation (only if not skipped)
|
||||
if (result.skillId && !result.skipped) {
|
||||
const stats = randomStats()
|
||||
await ctx.runMutation(internal.devSeedExtra.applyRandomStats, {
|
||||
skillId: result.skillId as Id<'skills'>,
|
||||
stats,
|
||||
})
|
||||
}
|
||||
|
||||
results.push({ slug: spec.slug, ok: result.ok, skipped: result.skipped })
|
||||
}
|
||||
|
||||
const created = results.filter((r) => !r.skipped).length
|
||||
const skipped = results.filter((r) => r.skipped).length
|
||||
|
||||
return { ok: true, total: results.length, created, skipped }
|
||||
},
|
||||
})
|
||||
+4
-3
@@ -2,6 +2,7 @@ import { v } from 'convex/values'
|
||||
import { zipSync } from 'fflate'
|
||||
import { api } from './_generated/api'
|
||||
import { httpAction, mutation } from './_generated/server'
|
||||
import { insertStatEvent } from './skillStatEvents'
|
||||
|
||||
export const downloadZip = httpAction(async (ctx, request) => {
|
||||
const url = new URL(request.url)
|
||||
@@ -69,9 +70,9 @@ export const increment = mutation({
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) return
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, downloads: skill.stats.downloads + 1 },
|
||||
updatedAt: Date.now(),
|
||||
await insertStatEvent(ctx, {
|
||||
skillId: skill._id,
|
||||
kind: 'download',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
soulsDeleteRouterV1Http,
|
||||
soulsGetRouterV1Http,
|
||||
soulsPostRouterV1Http,
|
||||
starsDeleteRouterV1Http,
|
||||
starsPostRouterV1Http,
|
||||
whoamiV1Http,
|
||||
} from './httpApiV1'
|
||||
|
||||
@@ -81,6 +83,18 @@ http.route({
|
||||
handler: skillsDeleteRouterV1Http,
|
||||
})
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.stars}/`,
|
||||
method: 'POST',
|
||||
handler: starsPostRouterV1Http,
|
||||
})
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.stars}/`,
|
||||
method: 'DELETE',
|
||||
handler: starsDeleteRouterV1Http,
|
||||
})
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.whoami,
|
||||
method: 'GET',
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('./skills', () => ({
|
||||
|
||||
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
|
||||
const { publishVersionForUser } = await import('./skills')
|
||||
const { __handlers, cliSkillDeleteHttp, cliSkillUndeleteHttp } = await import('./httpApi')
|
||||
const { __handlers } = await import('./httpApi')
|
||||
const { hashSkillFiles } = await import('./lib/skills')
|
||||
|
||||
function makeCtx(partial: Record<string, unknown>) {
|
||||
@@ -33,7 +33,7 @@ describe('httpApi handlers', () => {
|
||||
expect(await response.json()).toEqual({ results: [] })
|
||||
})
|
||||
|
||||
it('searchSkillsHttp forwards args', async () => {
|
||||
it('searchSkillsHttp forwards args (approvedOnly alias)', async () => {
|
||||
const runAction = vi.fn().mockResolvedValue([
|
||||
{
|
||||
score: 1,
|
||||
@@ -48,14 +48,27 @@ describe('httpApi handlers', () => {
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
query: 'test',
|
||||
limit: 5,
|
||||
approvedOnly: true,
|
||||
highlightedOnly: true,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.results[0].slug).toBe('a')
|
||||
})
|
||||
|
||||
it('searchSkillsHttp omits approvedOnly when false', async () => {
|
||||
it('searchSkillsHttp forwards highlightedOnly', async () => {
|
||||
const runAction = vi.fn().mockResolvedValue([])
|
||||
await __handlers.searchSkillsHandler(
|
||||
makeCtx({ runAction }),
|
||||
new Request('https://example.com/api/search?q=test&highlightedOnly=true'),
|
||||
)
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
query: 'test',
|
||||
limit: undefined,
|
||||
highlightedOnly: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('searchSkillsHttp omits highlightedOnly when approvedOnly is false', async () => {
|
||||
const runAction = vi.fn().mockResolvedValue([])
|
||||
await __handlers.searchSkillsHandler(
|
||||
makeCtx({ runAction }),
|
||||
@@ -64,7 +77,7 @@ describe('httpApi handlers', () => {
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
query: 'test',
|
||||
limit: undefined,
|
||||
approvedOnly: undefined,
|
||||
highlightedOnly: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -416,13 +429,14 @@ describe('httpApi handlers', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
|
||||
const runMutation = vi.fn().mockResolvedValue({ ok: true })
|
||||
const response = await cliSkillUndeleteHttp(
|
||||
const response = await __handlers.cliSkillDeleteHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request('https://x/api/cli/skill/undelete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: 'demo' }),
|
||||
}),
|
||||
false,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
@@ -437,13 +451,14 @@ describe('httpApi handlers', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
|
||||
const runMutation = vi.fn().mockResolvedValue({ ok: true })
|
||||
const response = await cliSkillDeleteHttp(
|
||||
const response = await __handlers.cliSkillDeleteHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request('https://x/api/cli/skill/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: 'demo' }),
|
||||
}),
|
||||
true,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
|
||||
+2
-1
@@ -44,13 +44,14 @@ async function searchSkillsHandler(ctx: ActionCtx, request: Request) {
|
||||
const query = url.searchParams.get('q')?.trim() ?? ''
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const approvedOnly = url.searchParams.get('approvedOnly') === 'true'
|
||||
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true' || approvedOnly
|
||||
|
||||
if (!query) return json({ results: [] })
|
||||
|
||||
const results = (await ctx.runAction(api.search.searchSkills, {
|
||||
query,
|
||||
limit,
|
||||
approvedOnly: approvedOnly || undefined,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
})) as SearchSkillEntry[]
|
||||
|
||||
return json({
|
||||
|
||||
@@ -158,6 +158,31 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(json.items[0].tags.latest).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('lists skills supports sort aliases', async () => {
|
||||
const checks: Array<[string, string]> = [
|
||||
['rating', 'stars'],
|
||||
['installs', 'installsCurrent'],
|
||||
['installs-all-time', 'installsAllTime'],
|
||||
['trending', 'trending'],
|
||||
]
|
||||
|
||||
for (const [input, expected] of checks) {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('sort' in args || 'cursor' in args || 'limit' in args) {
|
||||
expect(args.sort).toBe(expected)
|
||||
return { items: [], nextCursor: null }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.listSkillsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request(`https://example.com/api/v1/skills?sort=${input}`),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('get skill returns 404 when missing', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue(null)
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
@@ -498,4 +523,62 @@ describe('httpApiV1 handlers', () => {
|
||||
)
|
||||
expect(response2.status).toBe(200)
|
||||
})
|
||||
|
||||
it('stars require auth', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.starsPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request('https://example.com/api/v1/stars/demo', { method: 'POST' }),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('stars add succeeds', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runQuery = vi.fn().mockResolvedValue({ _id: 'skills:1' })
|
||||
const runMutation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce({ ok: true, starred: true, alreadyStarred: false })
|
||||
const response = await __handlers.starsPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/stars/demo', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.ok).toBe(true)
|
||||
expect(json.starred).toBe(true)
|
||||
})
|
||||
|
||||
it('stars delete succeeds', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runQuery = vi.fn().mockResolvedValue({ _id: 'skills:1' })
|
||||
const runMutation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce({ ok: true, unstarred: true, alreadyUnstarred: false })
|
||||
const response = await __handlers.starsDeleteRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/stars/demo', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.ok).toBe(true)
|
||||
expect(json.unstarred).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+90
-5
@@ -44,6 +44,9 @@ type ListSkillsResult = {
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
type SkillFile = Doc<'skillVersions'>['files'][number]
|
||||
type SoulFile = Doc<'soulVersions'>['files'][number]
|
||||
|
||||
type GetBySlugResult = {
|
||||
skill: {
|
||||
_id: Id<'skills'>
|
||||
@@ -191,11 +194,14 @@ async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
|
||||
const url = new URL(request.url)
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const cursor = url.searchParams.get('cursor')?.trim() || undefined
|
||||
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
|
||||
const sort = parseListSort(url.searchParams.get('sort'))
|
||||
const cursor = sort === 'updated' ? rawCursor : undefined
|
||||
|
||||
const result = (await ctx.runQuery(api.skills.listPublicPage, {
|
||||
limit,
|
||||
cursor,
|
||||
sort,
|
||||
})) as ListSkillsResult
|
||||
|
||||
const items = await Promise.all(
|
||||
@@ -315,7 +321,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource ?? null,
|
||||
files: version.files.map((file) => ({
|
||||
files: version.files.map((file: SkillFile) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
@@ -753,9 +759,36 @@ function toOptionalNumber(value: string | null) {
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
type SkillListSort =
|
||||
| 'updated'
|
||||
| 'downloads'
|
||||
| 'stars'
|
||||
| 'installsCurrent'
|
||||
| 'installsAllTime'
|
||||
| 'trending'
|
||||
|
||||
function parseListSort(value: string | null): SkillListSort {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
if (normalized === 'downloads') return 'downloads'
|
||||
if (normalized === 'stars' || normalized === 'rating') return 'stars'
|
||||
if (
|
||||
normalized === 'installs' ||
|
||||
normalized === 'install' ||
|
||||
normalized === 'installscurrent' ||
|
||||
normalized === 'installs-current'
|
||||
) {
|
||||
return 'installsCurrent'
|
||||
}
|
||||
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
|
||||
return 'installsAllTime'
|
||||
}
|
||||
if (normalized === 'trending') return 'trending'
|
||||
return 'updated'
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const data = new Uint8Array(bytes)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return toHex(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
@@ -895,7 +928,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource ?? null,
|
||||
files: version.files.map((file) => ({
|
||||
files: version.files.map((file: SoulFile) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
@@ -1047,6 +1080,56 @@ async function soulsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
}
|
||||
|
||||
export const soulsDeleteRouterV1Http = httpAction(soulsDeleteRouterV1Handler)
|
||||
|
||||
async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/stars/')
|
||||
if (segments.length !== 1) return text('Not found', 404, rate.headers)
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
|
||||
if (!skill) return text('Skill not found', 404, rate.headers)
|
||||
|
||||
const result = await ctx.runMutation(internal.stars.addStarInternal, {
|
||||
userId,
|
||||
skillId: skill._id,
|
||||
})
|
||||
return json(result, 200, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
export const starsPostRouterV1Http = httpAction(starsPostRouterV1Handler)
|
||||
|
||||
async function starsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/stars/')
|
||||
if (segments.length !== 1) return text('Not found', 404, rate.headers)
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
|
||||
if (!skill) return text('Skill not found', 404, rate.headers)
|
||||
|
||||
const result = await ctx.runMutation(internal.stars.removeStarInternal, {
|
||||
userId,
|
||||
skillId: skill._id,
|
||||
})
|
||||
return json(result, 200, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
export const starsDeleteRouterV1Http = httpAction(starsDeleteRouterV1Handler)
|
||||
export const __handlers = {
|
||||
searchSkillsV1Handler,
|
||||
resolveSkillVersionV1Handler,
|
||||
@@ -1060,5 +1143,7 @@ export const __handlers = {
|
||||
publishSoulV1Handler,
|
||||
soulsPostRouterV1Handler,
|
||||
soulsDeleteRouterV1Handler,
|
||||
starsPostRouterV1Handler,
|
||||
starsDeleteRouterV1Handler,
|
||||
whoamiV1Handler,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internalMutation } from './_generated/server'
|
||||
import { buildTrendingLeaderboard } from './lib/leaderboards'
|
||||
|
||||
const MAX_TRENDING_LIMIT = 200
|
||||
const KEEP_LEADERBOARD_ENTRIES = 3
|
||||
|
||||
export const rebuildTrendingLeaderboardInternal = internalMutation({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT)
|
||||
const now = Date.now()
|
||||
const { startDay, endDay, items } = await buildTrendingLeaderboard(ctx, { limit, now })
|
||||
|
||||
await ctx.db.insert('skillLeaderboards', {
|
||||
kind: 'trending',
|
||||
generatedAt: now,
|
||||
rangeStartDay: startDay,
|
||||
rangeEndDay: endDay,
|
||||
items,
|
||||
})
|
||||
|
||||
const recent = await ctx.db
|
||||
.query('skillLeaderboards')
|
||||
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
|
||||
.order('desc')
|
||||
.take(KEEP_LEADERBOARD_ENTRIES + 5)
|
||||
|
||||
for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) {
|
||||
await ctx.db.delete(entry._id)
|
||||
}
|
||||
|
||||
return { ok: true as const, count: items.length }
|
||||
},
|
||||
})
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Id } from '../_generated/dataModel'
|
||||
import type { MutationCtx, QueryCtx } from '../_generated/server'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
export const TRENDING_DAYS = 7
|
||||
|
||||
type LeaderboardEntry = {
|
||||
skillId: Id<'skills'>
|
||||
score: number
|
||||
installs: number
|
||||
downloads: number
|
||||
}
|
||||
|
||||
export function toDayKey(timestamp: number) {
|
||||
return Math.floor(timestamp / DAY_MS)
|
||||
}
|
||||
|
||||
export function getTrendingRange(now: number) {
|
||||
const endDay = toDayKey(now)
|
||||
const startDay = endDay - (TRENDING_DAYS - 1)
|
||||
return { startDay, endDay }
|
||||
}
|
||||
|
||||
export async function buildTrendingLeaderboard(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
params: { limit: number; now?: number },
|
||||
) {
|
||||
const now = params.now ?? Date.now()
|
||||
const { startDay, endDay } = getTrendingRange(now)
|
||||
const rows = await ctx.db
|
||||
.query('skillDailyStats')
|
||||
.withIndex('by_day', (q) => q.gte('day', startDay).lte('day', endDay))
|
||||
.collect()
|
||||
|
||||
const totals = new Map<Id<'skills'>, { installs: number; downloads: number }>()
|
||||
for (const row of rows) {
|
||||
const current = totals.get(row.skillId) ?? { installs: 0, downloads: 0 }
|
||||
current.installs += row.installs
|
||||
current.downloads += row.downloads
|
||||
totals.set(row.skillId, current)
|
||||
}
|
||||
|
||||
const entries = Array.from(totals, ([skillId, totalsEntry]) => ({
|
||||
skillId,
|
||||
installs: totalsEntry.installs,
|
||||
downloads: totalsEntry.downloads,
|
||||
score: totalsEntry.installs,
|
||||
}))
|
||||
|
||||
const items = topN(entries, params.limit, compareTrendingEntries).sort((a, b) =>
|
||||
compareTrendingEntries(b, a),
|
||||
)
|
||||
|
||||
return { startDay, endDay, items }
|
||||
}
|
||||
|
||||
function compareTrendingEntries(a: LeaderboardEntry, b: LeaderboardEntry) {
|
||||
if (a.score !== b.score) return a.score - b.score
|
||||
if (a.downloads !== b.downloads) return a.downloads - b.downloads
|
||||
return 0
|
||||
}
|
||||
|
||||
function topN<T>(entries: T[], limit: number, compare: (a: T, b: T) => number) {
|
||||
if (entries.length <= limit) return entries.slice()
|
||||
|
||||
const heap: T[] = []
|
||||
for (const entry of entries) {
|
||||
if (heap.length < limit) {
|
||||
heap.push(entry)
|
||||
siftUp(heap, heap.length - 1, compare)
|
||||
continue
|
||||
}
|
||||
if (compare(entry, heap[0]) <= 0) continue
|
||||
heap[0] = entry
|
||||
siftDown(heap, 0, compare)
|
||||
}
|
||||
return heap
|
||||
}
|
||||
|
||||
function siftUp<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
|
||||
let current = index
|
||||
while (current > 0) {
|
||||
const parent = Math.floor((current - 1) / 2)
|
||||
if (compare(heap[current], heap[parent]) >= 0) break
|
||||
;[heap[current], heap[parent]] = [heap[parent], heap[current]]
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
function siftDown<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
|
||||
let current = index
|
||||
const length = heap.length
|
||||
while (true) {
|
||||
const left = current * 2 + 1
|
||||
const right = current * 2 + 2
|
||||
let smallest = current
|
||||
if (left < length && compare(heap[left], heap[smallest]) < 0) smallest = left
|
||||
if (right < length && compare(heap[right], heap[smallest]) < 0) smallest = right
|
||||
if (smallest === current) break
|
||||
;[heap[current], heap[smallest]] = [heap[smallest], heap[current]]
|
||||
current = smallest
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,26 @@ describe('searchText', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('matchesExactTokens requires all query tokens', () => {
|
||||
it('matchesExactTokens requires at least one query token to prefix-match', () => {
|
||||
const queryTokens = tokenize('Remind Me')
|
||||
expect(matchesExactTokens(queryTokens, ['Remind Me', '/remind-me', 'Short summary'])).toBe(true)
|
||||
// "Reminder" starts with "remind", so it matches with prefix matching
|
||||
expect(matchesExactTokens(queryTokens, ['Reminder tool', '/reminder', 'Short summary'])).toBe(
|
||||
false,
|
||||
true,
|
||||
)
|
||||
// Matches because "remind" token is present
|
||||
expect(matchesExactTokens(queryTokens, ['Remind tool', '/remind', 'Short summary'])).toBe(true)
|
||||
// No matching tokens at all
|
||||
expect(matchesExactTokens(queryTokens, ['Other tool', '/other', 'Short summary'])).toBe(false)
|
||||
})
|
||||
|
||||
it('matchesExactTokens supports prefix matching for partial queries', () => {
|
||||
// "go" should match "gohome" because "gohome" starts with "go"
|
||||
expect(matchesExactTokens(['go'], ['GoHome', '/gohome', 'Navigate home'])).toBe(true)
|
||||
// "pad" should match "padel"
|
||||
expect(matchesExactTokens(['pad'], ['Padel', '/padel', 'Tennis-like sport'])).toBe(true)
|
||||
// "xyz" should not match anything
|
||||
expect(matchesExactTokens(['xyz'], ['GoHome', '/gohome', 'Navigate home'])).toBe(false)
|
||||
})
|
||||
|
||||
it('matchesExactTokens ignores empty inputs', () => {
|
||||
|
||||
@@ -18,8 +18,10 @@ export function matchesExactTokens(
|
||||
if (!text) return false
|
||||
const textTokens = tokenize(text)
|
||||
if (textTokens.length === 0) return false
|
||||
const textSet = new Set(textTokens)
|
||||
return queryTokens.every((token) => textSet.has(token))
|
||||
// Require at least one token to prefix-match, allowing vector similarity to determine relevance
|
||||
return queryTokens.some((queryToken) =>
|
||||
textTokens.some((textToken) => textToken.includes(queryToken)),
|
||||
)
|
||||
}
|
||||
|
||||
export const __test = { normalize, tokenize, matchesExactTokens }
|
||||
|
||||
@@ -75,16 +75,20 @@ export async function publishVersionForUser(
|
||||
if (sanitizedFiles.some((file) => !file.path)) {
|
||||
throw new ConvexError('Invalid file paths')
|
||||
}
|
||||
if (sanitizedFiles.some((file) => !isTextFile(file.path ?? '', file.contentType ?? undefined))) {
|
||||
const safeFiles = sanitizedFiles.map((file) => ({
|
||||
...file,
|
||||
path: file.path as string,
|
||||
}))
|
||||
if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
|
||||
throw new ConvexError('Only text-based files are allowed')
|
||||
}
|
||||
|
||||
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
throw new ConvexError('Skill bundle exceeds 50MB limit')
|
||||
}
|
||||
|
||||
const readmeFile = sanitizedFiles.find(
|
||||
const readmeFile = safeFiles.find(
|
||||
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
|
||||
)
|
||||
if (!readmeFile) throw new ConvexError('SKILL.md is required')
|
||||
@@ -95,7 +99,7 @@ export async function publishVersionForUser(
|
||||
const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
|
||||
|
||||
const otherFiles = [] as Array<{ path: string; content: string }>
|
||||
for (const file of sanitizedFiles) {
|
||||
for (const file of safeFiles) {
|
||||
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
|
||||
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
|
||||
const content = await fetchText(ctx, file.storageId)
|
||||
@@ -110,7 +114,7 @@ export async function publishVersionForUser(
|
||||
})
|
||||
|
||||
const fingerprintPromise = hashSkillFiles(
|
||||
sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
|
||||
safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
)
|
||||
|
||||
const changelogPromise =
|
||||
@@ -120,7 +124,7 @@ export async function publishVersionForUser(
|
||||
slug,
|
||||
version,
|
||||
readmeText,
|
||||
files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
|
||||
files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
})
|
||||
|
||||
const embeddingPromise = generateEmbedding(embeddingText)
|
||||
@@ -148,9 +152,9 @@ export async function publishVersionForUser(
|
||||
version: args.forkOf.version?.trim() || undefined,
|
||||
}
|
||||
: undefined,
|
||||
files: sanitizedFiles.map((file) => ({
|
||||
files: safeFiles.map((file) => ({
|
||||
...file,
|
||||
path: file.path ?? '',
|
||||
path: file.path,
|
||||
})),
|
||||
parsed: {
|
||||
frontmatter,
|
||||
@@ -169,7 +173,7 @@ export async function publishVersionForUser(
|
||||
version,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
files: sanitizedFiles,
|
||||
files: safeFiles,
|
||||
publishedAt: Date.now(),
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { MutationCtx } from '../_generated/server'
|
||||
import { toDayKey } from './leaderboards'
|
||||
|
||||
type SkillStatDeltas = {
|
||||
downloads?: number
|
||||
stars?: number
|
||||
installsCurrent?: number
|
||||
installsAllTime?: number
|
||||
}
|
||||
|
||||
export function applySkillStatDeltas(skill: Doc<'skills'>, deltas: SkillStatDeltas) {
|
||||
const currentDownloads =
|
||||
typeof skill.statsDownloads === 'number' ? skill.statsDownloads : skill.stats.downloads
|
||||
const currentStars = typeof skill.statsStars === 'number' ? skill.statsStars : skill.stats.stars
|
||||
const currentInstallsCurrent =
|
||||
typeof skill.statsInstallsCurrent === 'number'
|
||||
? skill.statsInstallsCurrent
|
||||
: (skill.stats.installsCurrent ?? 0)
|
||||
const currentInstallsAllTime =
|
||||
typeof skill.statsInstallsAllTime === 'number'
|
||||
? skill.statsInstallsAllTime
|
||||
: (skill.stats.installsAllTime ?? 0)
|
||||
|
||||
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0))
|
||||
const nextStars = Math.max(0, currentStars + (deltas.stars ?? 0))
|
||||
const nextInstallsCurrent = Math.max(0, currentInstallsCurrent + (deltas.installsCurrent ?? 0))
|
||||
const nextInstallsAllTime = Math.max(0, currentInstallsAllTime + (deltas.installsAllTime ?? 0))
|
||||
|
||||
return {
|
||||
statsDownloads: nextDownloads,
|
||||
statsStars: nextStars,
|
||||
statsInstallsCurrent: nextInstallsCurrent,
|
||||
statsInstallsAllTime: nextInstallsAllTime,
|
||||
stats: {
|
||||
...skill.stats,
|
||||
downloads: nextDownloads,
|
||||
stars: nextStars,
|
||||
installsCurrent: nextInstallsCurrent,
|
||||
installsAllTime: nextInstallsAllTime,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function bumpDailySkillStats(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
skillId: Id<'skills'>
|
||||
now: number
|
||||
downloads?: number
|
||||
installs?: number
|
||||
},
|
||||
) {
|
||||
const downloads = params.downloads ?? 0
|
||||
const installs = params.installs ?? 0
|
||||
if (downloads === 0 && installs === 0) return
|
||||
|
||||
const day = toDayKey(params.now)
|
||||
const existing = await ctx.db
|
||||
.query('skillDailyStats')
|
||||
.withIndex('by_skill_day', (q) => q.eq('skillId', params.skillId).eq('day', day))
|
||||
.unique()
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
downloads: Math.max(0, existing.downloads + downloads),
|
||||
installs: Math.max(0, existing.installs + installs),
|
||||
updatedAt: params.now,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.db.insert('skillDailyStats', {
|
||||
skillId: params.skillId,
|
||||
day,
|
||||
downloads: Math.max(0, downloads),
|
||||
installs: Math.max(0, installs),
|
||||
updatedAt: params.now,
|
||||
})
|
||||
}
|
||||
@@ -49,6 +49,10 @@ const skills = defineTable({
|
||||
),
|
||||
}),
|
||||
batch: v.optional(v.string()),
|
||||
statsDownloads: v.optional(v.number()),
|
||||
statsStars: v.optional(v.number()),
|
||||
statsInstallsCurrent: v.optional(v.number()),
|
||||
statsInstallsAllTime: v.optional(v.number()),
|
||||
stats: v.object({
|
||||
downloads: v.number(),
|
||||
installsCurrent: v.optional(v.number()),
|
||||
@@ -63,7 +67,12 @@ const skills = defineTable({
|
||||
.index('by_slug', ['slug'])
|
||||
.index('by_owner', ['ownerUserId'])
|
||||
.index('by_updated', ['updatedAt'])
|
||||
.index('by_stats_downloads', ['statsDownloads', 'updatedAt'])
|
||||
.index('by_stats_stars', ['statsStars', 'updatedAt'])
|
||||
.index('by_stats_installs_current', ['statsInstallsCurrent', 'updatedAt'])
|
||||
.index('by_stats_installs_all_time', ['statsInstallsAllTime', 'updatedAt'])
|
||||
.index('by_batch', ['batch'])
|
||||
.index('by_active_updated', ['softDeletedAt', 'updatedAt'])
|
||||
|
||||
const souls = defineTable({
|
||||
slug: v.string(),
|
||||
@@ -177,6 +186,61 @@ const skillEmbeddings = defineTable({
|
||||
filterFields: ['visibility'],
|
||||
})
|
||||
|
||||
const skillDailyStats = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
day: v.number(),
|
||||
downloads: v.number(),
|
||||
installs: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_skill_day', ['skillId', 'day'])
|
||||
.index('by_day', ['day'])
|
||||
|
||||
const skillLeaderboards = defineTable({
|
||||
kind: v.string(),
|
||||
generatedAt: v.number(),
|
||||
rangeStartDay: v.number(),
|
||||
rangeEndDay: v.number(),
|
||||
items: v.array(
|
||||
v.object({
|
||||
skillId: v.id('skills'),
|
||||
score: v.number(),
|
||||
installs: v.number(),
|
||||
downloads: v.number(),
|
||||
}),
|
||||
),
|
||||
}).index('by_kind', ['kind', 'generatedAt'])
|
||||
|
||||
const skillStatBackfillState = defineTable({
|
||||
key: v.string(),
|
||||
cursor: v.optional(v.string()),
|
||||
doneAt: v.optional(v.number()),
|
||||
updatedAt: v.number(),
|
||||
}).index('by_key', ['key'])
|
||||
|
||||
const skillStatEvents = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
kind: v.union(
|
||||
v.literal('download'),
|
||||
v.literal('star'),
|
||||
v.literal('unstar'),
|
||||
v.literal('install_new'),
|
||||
v.literal('install_reactivate'),
|
||||
v.literal('install_deactivate'),
|
||||
v.literal('install_clear'),
|
||||
),
|
||||
delta: v.optional(
|
||||
v.object({
|
||||
allTime: v.number(),
|
||||
current: v.number(),
|
||||
}),
|
||||
),
|
||||
occurredAt: v.number(),
|
||||
processedAt: v.optional(v.number()),
|
||||
})
|
||||
.index('by_unprocessed', ['processedAt'])
|
||||
.index('by_skill', ['skillId'])
|
||||
|
||||
const soulEmbeddings = defineTable({
|
||||
soulId: v.id('souls'),
|
||||
versionId: v.id('soulVersions'),
|
||||
@@ -323,6 +387,10 @@ export default defineSchema({
|
||||
soulVersionFingerprints,
|
||||
skillEmbeddings,
|
||||
soulEmbeddings,
|
||||
skillDailyStats,
|
||||
skillLeaderboards,
|
||||
skillStatBackfillState,
|
||||
skillStatEvents,
|
||||
comments,
|
||||
soulComments,
|
||||
stars,
|
||||
|
||||
+46
-16
@@ -7,8 +7,9 @@ import { matchesExactTokens, tokenize } from './lib/searchText'
|
||||
|
||||
type HydratedEntry = {
|
||||
embeddingId: Id<'skillEmbeddings'>
|
||||
skill: Doc<'skills'> | null
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
ownerHandle: string | null
|
||||
}
|
||||
|
||||
type SearchResult = HydratedEntry & { score: number }
|
||||
@@ -29,10 +30,17 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
if (!query) return []
|
||||
const queryTokens = tokenize(query)
|
||||
if (queryTokens.length === 0) return []
|
||||
const vector = await generateEmbedding(query)
|
||||
let vector: number[]
|
||||
try {
|
||||
vector = await generateEmbedding(query)
|
||||
} catch (error) {
|
||||
console.warn('Search embedding generation failed', error)
|
||||
return []
|
||||
}
|
||||
const limit = args.limit ?? 10
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
|
||||
let candidateLimit = Math.max(limit * 3, 50)
|
||||
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
|
||||
let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
|
||||
let hydrated: HydratedEntry[] = []
|
||||
let scoreById = new Map<Id<'skillEmbeddings'>, number>()
|
||||
let exactMatches: HydratedEntry[] = []
|
||||
@@ -86,18 +94,33 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
export const hydrateResults = internalQuery({
|
||||
args: { embeddingIds: v.array(v.id('skillEmbeddings')) },
|
||||
handler: async (ctx, args): Promise<HydratedEntry[]> => {
|
||||
const entries: HydratedEntry[] = []
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
|
||||
for (const embeddingId of args.embeddingIds) {
|
||||
const embedding = await ctx.db.get(embeddingId)
|
||||
if (!embedding) continue
|
||||
const skill = await ctx.db.get(embedding.skillId)
|
||||
if (skill?.softDeletedAt) continue
|
||||
const version = await ctx.db.get(embedding.versionId)
|
||||
entries.push({ embeddingId, skill, version })
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = ctx.db
|
||||
.get(ownerUserId)
|
||||
.then((owner) => owner?.handle ?? owner?._id ?? null)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
}
|
||||
|
||||
return entries
|
||||
const entries = await Promise.all(
|
||||
args.embeddingIds.map(async (embeddingId) => {
|
||||
const embedding = await ctx.db.get(embeddingId)
|
||||
if (!embedding) return null
|
||||
const skill = await ctx.db.get(embedding.skillId)
|
||||
if (!skill || skill.softDeletedAt) return null
|
||||
const [version, ownerHandle] = await Promise.all([
|
||||
ctx.db.get(embedding.versionId),
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
])
|
||||
return { embeddingId, skill, version, ownerHandle }
|
||||
}),
|
||||
)
|
||||
|
||||
return entries.filter((entry): entry is HydratedEntry => entry !== null)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -119,10 +142,17 @@ export const searchSouls: ReturnType<typeof action> = action({
|
||||
if (!query) return []
|
||||
const queryTokens = tokenize(query)
|
||||
if (queryTokens.length === 0) return []
|
||||
const vector = await generateEmbedding(query)
|
||||
let vector: number[]
|
||||
try {
|
||||
vector = await generateEmbedding(query)
|
||||
} catch (error) {
|
||||
console.warn('Search embedding generation failed', error)
|
||||
return []
|
||||
}
|
||||
const limit = args.limit ?? 10
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
|
||||
let candidateLimit = Math.max(limit * 3, 50)
|
||||
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
|
||||
let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
|
||||
let hydrated: HydratedSoulEntry[] = []
|
||||
let scoreById = new Map<Id<'soulEmbeddings'>, number>()
|
||||
let exactMatches: HydratedSoulEntry[] = []
|
||||
|
||||
+2
-1
@@ -242,7 +242,8 @@ export const ensureSeedUserInternal = internalMutation({
|
||||
})
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
const data = new Uint8Array(bytes)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return toHex(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Skill Stat Events - Event-sourced stats processing for skills
|
||||
*
|
||||
* Instead of updating skill stats synchronously in the hot path (which can cause
|
||||
* contention when multiple users download/star/install the same skill), we insert
|
||||
* lightweight event records and process them in batches via a cron job.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User action (download, star, install) → insertStatEvent() writes to skillStatEvents table
|
||||
* 2. Cron job runs every 5 minutes → processSkillStatEventsInternal() processes batches
|
||||
* 3. Events are aggregated per-skill to minimize database operations
|
||||
* 4. Stats are applied to skill documents and daily stats tables
|
||||
* 5. Events are marked as processed (kept forever for auditing)
|
||||
*/
|
||||
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { MutationCtx } from './_generated/server'
|
||||
import { internalMutation } from './_generated/server'
|
||||
import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
|
||||
|
||||
/**
|
||||
* Event types that affect skill stats:
|
||||
*
|
||||
* - download: User downloaded skill as zip (+1 downloads)
|
||||
* - star: User starred the skill (+1 stars)
|
||||
* - unstar: User removed their star (-1 stars)
|
||||
* - install_new: First time this user installed this skill (+1 installsAllTime, +1 installsCurrent)
|
||||
* - install_reactivate: User re-added skill after removing it (+1 installsCurrent only)
|
||||
* - install_deactivate: User removed skill from all projects (-1 installsCurrent)
|
||||
* - install_clear: User cleared all telemetry data (custom delta for both allTime and current)
|
||||
*/
|
||||
export type StatEventKind =
|
||||
| 'download'
|
||||
| 'star'
|
||||
| 'unstar'
|
||||
| 'install_new'
|
||||
| 'install_reactivate'
|
||||
| 'install_deactivate'
|
||||
| 'install_clear'
|
||||
|
||||
/**
|
||||
* Insert a stat event to be processed later by the cron job.
|
||||
*
|
||||
* This is called from the hot path (downloads, stars, telemetry) instead of
|
||||
* directly updating skill stats. It's a single insert with no read-modify-write
|
||||
* cycle, so it's fast and doesn't contend with other operations on the same skill.
|
||||
*
|
||||
* @param ctx - Mutation context
|
||||
* @param params.skillId - The skill being affected
|
||||
* @param params.kind - Type of event (download, star, install_new, etc.)
|
||||
* @param params.occurredAt - When the event happened (defaults to now). Important for
|
||||
* daily stats bucketing - we want downloads at 11:55 PM Monday
|
||||
* to count toward Monday's stats even if processed on Tuesday.
|
||||
* @param params.delta - Only used for install_clear events, specifies exact delta amounts
|
||||
*/
|
||||
export async function insertStatEvent(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
skillId: Id<'skills'>
|
||||
kind: StatEventKind
|
||||
occurredAt?: number
|
||||
delta?: { allTime: number; current: number }
|
||||
},
|
||||
) {
|
||||
await ctx.db.insert('skillStatEvents', {
|
||||
skillId: params.skillId,
|
||||
kind: params.kind,
|
||||
delta: params.delta,
|
||||
occurredAt: params.occurredAt ?? Date.now(),
|
||||
processedAt: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated deltas for a single skill after processing multiple events.
|
||||
*
|
||||
* When we process a batch of 100 events, many might be for the same skill.
|
||||
* Instead of updating the skill document once per event, we aggregate all
|
||||
* events for each skill and apply a single update.
|
||||
*
|
||||
* The downloadEvents and installNewEvents arrays store the original timestamps
|
||||
* so we can update daily stats with the correct day bucket for each event.
|
||||
*/
|
||||
type AggregatedDeltas = {
|
||||
downloads: number
|
||||
stars: number
|
||||
installsAllTime: number
|
||||
installsCurrent: number
|
||||
/** Original timestamps for each download event (for daily stats bucketing) */
|
||||
downloadEvents: number[]
|
||||
/** Original timestamps for each new install event (for daily stats bucketing) */
|
||||
installNewEvents: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate multiple events for a single skill into net deltas.
|
||||
*
|
||||
* Example: If a skill has these events in the batch:
|
||||
* - download (Mon 11pm)
|
||||
* - download (Tue 1am)
|
||||
* - star
|
||||
* - unstar
|
||||
* - star
|
||||
*
|
||||
* The result would be:
|
||||
* - downloads: 2
|
||||
* - stars: 1 (net: +1 -1 +1 = +1)
|
||||
* - downloadEvents: [<Mon 11pm timestamp>, <Tue 1am timestamp>]
|
||||
*
|
||||
* This aggregation reduces the number of database operations from N events
|
||||
* to 1 skill update + N daily stat updates (which themselves may coalesce
|
||||
* if multiple events fall on the same day).
|
||||
*/
|
||||
function aggregateEvents(events: Doc<'skillStatEvents'>[]): AggregatedDeltas {
|
||||
const result: AggregatedDeltas = {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
installsAllTime: 0,
|
||||
installsCurrent: 0,
|
||||
downloadEvents: [],
|
||||
installNewEvents: [],
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.kind) {
|
||||
case 'download':
|
||||
result.downloads += 1
|
||||
result.downloadEvents.push(event.occurredAt)
|
||||
break
|
||||
case 'star':
|
||||
result.stars += 1
|
||||
break
|
||||
case 'unstar':
|
||||
result.stars -= 1
|
||||
break
|
||||
case 'install_new':
|
||||
// New user installing for the first time: count toward both lifetime and current
|
||||
result.installsAllTime += 1
|
||||
result.installsCurrent += 1
|
||||
result.installNewEvents.push(event.occurredAt)
|
||||
break
|
||||
case 'install_reactivate':
|
||||
// User re-added skill after removing: only affects current count
|
||||
result.installsCurrent += 1
|
||||
break
|
||||
case 'install_deactivate':
|
||||
// User removed skill from all projects: only affects current count
|
||||
result.installsCurrent -= 1
|
||||
break
|
||||
case 'install_clear':
|
||||
// User cleared telemetry: uses custom delta values (typically negative)
|
||||
if (event.delta) {
|
||||
result.installsAllTime += event.delta.allTime
|
||||
result.installsCurrent += event.delta.current
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of unprocessed stat events.
|
||||
*
|
||||
* Called by cron every 5 minutes. Processes up to batchSize events (default 100).
|
||||
* If the batch is full, schedules an immediate follow-up run to drain the queue.
|
||||
*
|
||||
* Processing steps:
|
||||
* 1. Query unprocessed events (processedAt is undefined)
|
||||
* 2. Group events by skillId to minimize skill document fetches
|
||||
* 3. For each skill:
|
||||
* a. Fetch the skill document once
|
||||
* b. Aggregate all events for this skill into net deltas
|
||||
* c. Apply deltas to skill stats (downloads, stars, installs)
|
||||
* d. Update daily stats for trending (using original event timestamps)
|
||||
* e. Mark all events as processed
|
||||
* 4. If batch was full, schedule another run immediately
|
||||
*
|
||||
* Aggregation levels:
|
||||
* - Level 1: Batch of 100 events from the queue
|
||||
* - Level 2: Group by skillId (e.g., 100 events → 30 unique skills)
|
||||
* - Level 3: Aggregate events per skill (e.g., 5 events → 1 skill update)
|
||||
* - Level 4: Daily stats may coalesce (e.g., 3 downloads same day → 1 upsert)
|
||||
*/
|
||||
export const processSkillStatEventsInternal = internalMutation({
|
||||
args: { batchSize: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = args.batchSize ?? 100
|
||||
const now = Date.now()
|
||||
|
||||
// Level 1: Fetch a batch of unprocessed events
|
||||
const events = await ctx.db
|
||||
.query('skillStatEvents')
|
||||
.withIndex('by_unprocessed', (q) => q.eq('processedAt', undefined))
|
||||
.take(batchSize)
|
||||
|
||||
if (events.length === 0) {
|
||||
return { processed: 0 }
|
||||
}
|
||||
|
||||
// Level 2: Group events by skillId to minimize database reads
|
||||
// Instead of fetching the same skill document multiple times,
|
||||
// we fetch it once and process all its events together
|
||||
const eventsBySkill = new Map<Id<'skills'>, Doc<'skillStatEvents'>[]>()
|
||||
for (const event of events) {
|
||||
const existing = eventsBySkill.get(event.skillId) ?? []
|
||||
existing.push(event)
|
||||
eventsBySkill.set(event.skillId, existing)
|
||||
}
|
||||
|
||||
// Process each skill's events
|
||||
for (const [skillId, skillEvents] of eventsBySkill) {
|
||||
const skill = await ctx.db.get(skillId)
|
||||
|
||||
// Skill was deleted - just mark events as processed
|
||||
if (!skill) {
|
||||
for (const event of skillEvents) {
|
||||
await ctx.db.patch(event._id, { processedAt: now })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Level 3: Aggregate all events for this skill into net deltas
|
||||
// e.g., 3 downloads + 2 stars - 1 unstar → { downloads: 3, stars: 1 }
|
||||
const deltas = aggregateEvents(skillEvents)
|
||||
|
||||
// Apply aggregated deltas to skill stats (single update per skill)
|
||||
if (
|
||||
deltas.downloads !== 0 ||
|
||||
deltas.stars !== 0 ||
|
||||
deltas.installsAllTime !== 0 ||
|
||||
deltas.installsCurrent !== 0
|
||||
) {
|
||||
const patch = applySkillStatDeltas(skill, {
|
||||
downloads: deltas.downloads,
|
||||
stars: deltas.stars,
|
||||
installsAllTime: deltas.installsAllTime,
|
||||
installsCurrent: deltas.installsCurrent,
|
||||
})
|
||||
await ctx.db.patch(skill._id, {
|
||||
...patch,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Update daily stats for trending/leaderboards
|
||||
// We use the ORIGINAL event timestamp (occurredAt) so that:
|
||||
// - A download at Mon 11:55 PM counts toward Monday's stats
|
||||
// - Even if the cron processes it on Tuesday
|
||||
//
|
||||
// Level 4: bumpDailySkillStats does its own coalescing - multiple
|
||||
// events on the same day will update the same daily record
|
||||
for (const occurredAt of deltas.downloadEvents) {
|
||||
await bumpDailySkillStats(ctx, { skillId, now: occurredAt, downloads: 1 })
|
||||
}
|
||||
for (const occurredAt of deltas.installNewEvents) {
|
||||
await bumpDailySkillStats(ctx, { skillId, now: occurredAt, installs: 1 })
|
||||
}
|
||||
|
||||
// Mark all events for this skill as processed
|
||||
for (const event of skillEvents) {
|
||||
await ctx.db.patch(event._id, { processedAt: now })
|
||||
}
|
||||
}
|
||||
|
||||
// If we hit the batch limit, there may be more events waiting.
|
||||
// Schedule an immediate follow-up run to drain the queue.
|
||||
// This ensures high-volume periods don't create a backlog.
|
||||
if (events.length === batchSize) {
|
||||
await ctx.scheduler.runAfter(0, internal.skillStatEvents.processSkillStatEventsInternal, {
|
||||
batchSize,
|
||||
})
|
||||
}
|
||||
|
||||
return { processed: events.length }
|
||||
},
|
||||
})
|
||||
+161
-17
@@ -1,10 +1,13 @@
|
||||
import { paginationOptsValidator } from 'convex/server'
|
||||
import { ConvexError, v } from 'convex/values'
|
||||
import { paginator } from 'convex-helpers/server/pagination'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { MutationCtx } from './_generated/server'
|
||||
import type { MutationCtx, QueryCtx } from './_generated/server'
|
||||
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
|
||||
import { assertRole, requireUser, requireUserFromAction } from './lib/access'
|
||||
import { generateChangelogPreview as buildChangelogPreview } from './lib/changelog'
|
||||
import { buildTrendingLeaderboard } from './lib/leaderboards'
|
||||
import {
|
||||
fetchText,
|
||||
type PublishResult,
|
||||
@@ -12,6 +15,7 @@ import {
|
||||
queueHighlightedWebhook,
|
||||
} from './lib/skillPublish'
|
||||
import { getFrontmatterValue, hashSkillFiles } from './lib/skills'
|
||||
import schema from './schema'
|
||||
|
||||
export { publishVersionForUser } from './lib/skillPublish'
|
||||
|
||||
@@ -20,9 +24,43 @@ type FileTextResult = { path: string; text: string; size: number; sha256: string
|
||||
|
||||
const MAX_DIFF_FILE_BYTES = 200 * 1024
|
||||
const MAX_LIST_LIMIT = 50
|
||||
const MAX_PUBLIC_LIST_LIMIT = 200
|
||||
const MAX_LIST_BULK_LIMIT = 200
|
||||
const MAX_LIST_TAKE = 1000
|
||||
|
||||
async function resolveOwnerHandle(ctx: QueryCtx, ownerUserId: Id<'users'>) {
|
||||
const owner = await ctx.db.get(ownerUserId)
|
||||
return owner?.handle ?? owner?._id ?? null
|
||||
}
|
||||
|
||||
type PublicSkillEntry = {
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
ownerHandle: string | null
|
||||
}
|
||||
|
||||
async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = resolveOwnerHandle(ctx, ownerUserId)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
skills.map(async (skill) => {
|
||||
const [latestVersion, ownerHandle] = await Promise.all([
|
||||
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
])
|
||||
return { skill, latestVersion, ownerHandle }
|
||||
}),
|
||||
) satisfies Promise<PublicSkillEntry[]>
|
||||
}
|
||||
|
||||
export const getBySlug = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -130,9 +168,10 @@ export const listWithLatest = query({
|
||||
.order('desc')
|
||||
.take(takeLimit)
|
||||
} else if (args.ownerUserId) {
|
||||
const ownerUserId = args.ownerUserId
|
||||
entries = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', ownerUserId))
|
||||
.order('desc')
|
||||
.take(takeLimit)
|
||||
} else {
|
||||
@@ -150,34 +189,135 @@ export const listWithLatest = query({
|
||||
},
|
||||
})
|
||||
|
||||
// TODO: Delete listPublicPage once all clients have migrated to listPublicPageV2
|
||||
export const listPublicPage = query({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
limit: v.optional(v.number()),
|
||||
sort: v.optional(
|
||||
v.union(
|
||||
v.literal('updated'),
|
||||
v.literal('downloads'),
|
||||
v.literal('stars'),
|
||||
v.literal('installsCurrent'),
|
||||
v.literal('installsAllTime'),
|
||||
v.literal('trending'),
|
||||
),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 24, 1, MAX_LIST_LIMIT)
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_updated', (q) => q)
|
||||
.order('desc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: limit })
|
||||
const sort = args.sort ?? 'updated'
|
||||
const limit = clampInt(args.limit ?? 24, 1, MAX_PUBLIC_LIST_LIMIT)
|
||||
|
||||
const items: Array<{
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
}> = []
|
||||
if (sort === 'updated') {
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_updated', (q) => q)
|
||||
.order('desc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: limit })
|
||||
|
||||
for (const skill of page) {
|
||||
if (skill.softDeletedAt) continue
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
items.push({ skill, latestVersion })
|
||||
const skills = page.filter((skill) => !skill.softDeletedAt)
|
||||
const items = await buildPublicSkillEntries(ctx, skills)
|
||||
|
||||
return { items, nextCursor: isDone ? null : continueCursor }
|
||||
}
|
||||
|
||||
return { items, nextCursor: isDone ? null : continueCursor }
|
||||
if (sort === 'trending') {
|
||||
const entries = await getTrendingEntries(ctx, limit)
|
||||
const skills: Doc<'skills'>[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const skill = await ctx.db.get(entry.skillId)
|
||||
if (!skill || skill.softDeletedAt) continue
|
||||
skills.push(skill)
|
||||
if (skills.length >= limit) break
|
||||
}
|
||||
|
||||
const items = await buildPublicSkillEntries(ctx, skills)
|
||||
return { items, nextCursor: null }
|
||||
}
|
||||
|
||||
const index = sortToIndex(sort)
|
||||
const page = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex(index, (q) => q)
|
||||
.order('desc')
|
||||
.take(Math.min(limit * 5, MAX_LIST_TAKE))
|
||||
|
||||
const filtered = page.filter((skill) => !skill.softDeletedAt).slice(0, limit)
|
||||
const items = await buildPublicSkillEntries(ctx, filtered)
|
||||
return { items, nextCursor: null }
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* V2 of listPublicPage using convex-helpers paginator for better cache behavior.
|
||||
*
|
||||
* Key differences from V1:
|
||||
* - Uses `paginator` from convex-helpers (doesn't track end-cursor internally, better caching)
|
||||
* - Uses `by_active_updated` index to filter soft-deleted skills at query level
|
||||
* - Returns standard pagination shape compatible with usePaginatedQuery
|
||||
*/
|
||||
export const listPublicPageV2 = query({
|
||||
args: {
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
// Use the new index to filter out soft-deleted skills at query time.
|
||||
// softDeletedAt === undefined means active (non-deleted) skills only.
|
||||
const result = await paginator(ctx.db, schema)
|
||||
.query('skills')
|
||||
.withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
|
||||
.order('desc')
|
||||
.paginate(args.paginationOpts)
|
||||
|
||||
// Build the public skill entries (fetch latestVersion + ownerHandle)
|
||||
const items = await buildPublicSkillEntries(ctx, result.page)
|
||||
|
||||
return {
|
||||
...result,
|
||||
page: items,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function sortToIndex(
|
||||
sort: 'downloads' | 'stars' | 'installsCurrent' | 'installsAllTime',
|
||||
):
|
||||
| 'by_stats_downloads'
|
||||
| 'by_stats_stars'
|
||||
| 'by_stats_installs_current'
|
||||
| 'by_stats_installs_all_time' {
|
||||
switch (sort) {
|
||||
case 'downloads':
|
||||
return 'by_stats_downloads'
|
||||
case 'stars':
|
||||
return 'by_stats_stars'
|
||||
case 'installsCurrent':
|
||||
return 'by_stats_installs_current'
|
||||
case 'installsAllTime':
|
||||
return 'by_stats_installs_all_time'
|
||||
}
|
||||
}
|
||||
|
||||
async function getTrendingEntries(ctx: QueryCtx, limit: number) {
|
||||
// Use the pre-computed leaderboard from the hourly cron job.
|
||||
// Avoid Date.now() here to keep the query deterministic and cacheable.
|
||||
const latest = await ctx.db
|
||||
.query('skillLeaderboards')
|
||||
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
|
||||
.order('desc')
|
||||
.take(1)
|
||||
|
||||
if (latest[0]) {
|
||||
return latest[0].items.slice(0, limit)
|
||||
}
|
||||
|
||||
// No leaderboard exists yet (cold start) - compute on the fly
|
||||
const fallback = await buildTrendingLeaderboard(ctx, { limit, now: Date.now() })
|
||||
return fallback.items
|
||||
}
|
||||
|
||||
export const listVersions = query({
|
||||
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -587,6 +727,10 @@ export const insertVersion = internalMutation({
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
badges: { redactionApproved: undefined },
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
|
||||
+1
-1
@@ -377,7 +377,7 @@ export const insertVersion = internalMutation({
|
||||
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
|
||||
.order('desc')
|
||||
.take(2)
|
||||
let soul = soulMatches[0] ?? null
|
||||
let soul: Doc<'souls'> | null = soulMatches[0] ?? null
|
||||
|
||||
if (soul && soul.ownerUserId !== userId) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
|
||||
+45
-9
@@ -1,7 +1,8 @@
|
||||
import { v } from 'convex/values'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { internalMutation, mutation, query } from './_generated/server'
|
||||
import { requireUser } from './lib/access'
|
||||
import { insertStatEvent } from './skillStatEvents'
|
||||
|
||||
export const isStarred = query({
|
||||
args: { skillId: v.id('skills') },
|
||||
@@ -29,10 +30,7 @@ export const toggle = mutation({
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.delete(existing._id)
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, stars: Math.max(0, skill.stats.stars - 1) },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
await insertStatEvent(ctx, { skillId: skill._id, kind: 'unstar' })
|
||||
return { starred: false }
|
||||
}
|
||||
|
||||
@@ -42,10 +40,7 @@ export const toggle = mutation({
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, stars: skill.stats.stars + 1 },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
await insertStatEvent(ctx, { skillId: skill._id, kind: 'star' })
|
||||
|
||||
return { starred: true }
|
||||
},
|
||||
@@ -68,3 +63,44 @@ export const listByUser = query({
|
||||
return skills
|
||||
},
|
||||
})
|
||||
|
||||
export const addStarInternal = internalMutation({
|
||||
args: { userId: v.id('users'), skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
const existing = await ctx.db
|
||||
.query('stars')
|
||||
.withIndex('by_skill_user', (q) => q.eq('skillId', args.skillId).eq('userId', args.userId))
|
||||
.unique()
|
||||
if (existing) return { ok: true as const, starred: true, alreadyStarred: true }
|
||||
|
||||
await ctx.db.insert('stars', {
|
||||
skillId: args.skillId,
|
||||
userId: args.userId,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
await insertStatEvent(ctx, { skillId: skill._id, kind: 'star' })
|
||||
|
||||
return { ok: true as const, starred: true, alreadyStarred: false }
|
||||
},
|
||||
})
|
||||
|
||||
export const removeStarInternal = internalMutation({
|
||||
args: { userId: v.id('users'), skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
const existing = await ctx.db
|
||||
.query('stars')
|
||||
.withIndex('by_skill_user', (q) => q.eq('skillId', args.skillId).eq('userId', args.userId))
|
||||
.unique()
|
||||
if (!existing) return { ok: true as const, unstarred: false, alreadyUnstarred: true }
|
||||
|
||||
await ctx.db.delete(existing._id)
|
||||
await insertStatEvent(ctx, { skillId: skill._id, kind: 'unstar' })
|
||||
|
||||
return { ok: true as const, unstarred: true, alreadyUnstarred: false }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { internalAction, internalMutation, internalQuery } from './_generated/server'
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 200
|
||||
const MAX_BATCH_SIZE = 1000
|
||||
const DEFAULT_MAX_BATCHES = 5
|
||||
const MAX_MAX_BATCHES = 50
|
||||
const BACKFILL_STATE_KEY = 'default'
|
||||
|
||||
export const backfillSkillStatFieldsInternal = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('skills')
|
||||
.order('asc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
|
||||
|
||||
let patched = 0
|
||||
for (const skill of page) {
|
||||
const next = buildSkillStatPatch(skill)
|
||||
if (!next) continue
|
||||
await ctx.db.patch(skill._id, next)
|
||||
patched += 1
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
scanned: page.length,
|
||||
patched,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type BackfillState = {
|
||||
cursor: string | null
|
||||
doneAt?: number
|
||||
}
|
||||
|
||||
type BackfillActionArgs = {
|
||||
batchSize?: number
|
||||
maxBatches?: number
|
||||
resetCursor?: boolean
|
||||
}
|
||||
|
||||
type BackfillStats = {
|
||||
scanned: number
|
||||
patched: number
|
||||
batches: number
|
||||
}
|
||||
|
||||
type BackfillActionResult = {
|
||||
ok: true
|
||||
isDone: boolean
|
||||
cursor: string | null
|
||||
stats: BackfillStats
|
||||
}
|
||||
|
||||
export const getSkillStatBackfillStateInternal = internalQuery({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<BackfillState> => {
|
||||
const state = await ctx.db
|
||||
.query('skillStatBackfillState')
|
||||
.withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
|
||||
.unique()
|
||||
return { cursor: state?.cursor ?? null, doneAt: state?.doneAt }
|
||||
},
|
||||
})
|
||||
|
||||
export const setSkillStatBackfillStateInternal = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
doneAt: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now()
|
||||
const state = await ctx.db
|
||||
.query('skillStatBackfillState')
|
||||
.withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
|
||||
.unique()
|
||||
|
||||
if (!state) {
|
||||
await ctx.db.insert('skillStatBackfillState', {
|
||||
key: BACKFILL_STATE_KEY,
|
||||
cursor: args.cursor,
|
||||
doneAt: args.doneAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { ok: true as const }
|
||||
}
|
||||
|
||||
await ctx.db.patch(state._id, {
|
||||
cursor: args.cursor,
|
||||
doneAt: args.doneAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
|
||||
async function runSkillStatBackfillInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: BackfillActionArgs,
|
||||
): Promise<BackfillActionResult> {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
|
||||
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
|
||||
|
||||
if (args.resetCursor) {
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: undefined,
|
||||
doneAt: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const state = (await ctx.runQuery(
|
||||
internal.statsMaintenance.getSkillStatBackfillStateInternal,
|
||||
{},
|
||||
)) as BackfillState
|
||||
if (state.doneAt && !args.resetCursor) {
|
||||
return {
|
||||
ok: true,
|
||||
isDone: true,
|
||||
cursor: null,
|
||||
stats: { scanned: 0, patched: 0, batches: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
let cursor: string | null = state.cursor ?? null
|
||||
const stats: BackfillStats = { scanned: 0, patched: 0, batches: 0 }
|
||||
|
||||
for (let i = 0; i < maxBatches; i += 1) {
|
||||
const result = (await ctx.runMutation(
|
||||
internal.statsMaintenance.backfillSkillStatFieldsInternal,
|
||||
{
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
},
|
||||
)) as { scanned: number; patched: number; cursor: string | null; isDone: boolean }
|
||||
stats.scanned += result.scanned
|
||||
stats.patched += result.patched
|
||||
stats.batches += 1
|
||||
cursor = result.cursor
|
||||
|
||||
if (result.isDone) {
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: undefined,
|
||||
doneAt: Date.now(),
|
||||
})
|
||||
return { ok: true, isDone: true, cursor: null, stats }
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: cursor ?? undefined,
|
||||
doneAt: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, isDone: false, cursor, stats }
|
||||
}
|
||||
|
||||
export const runSkillStatBackfillInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
resetCursor: v.optional(v.boolean()),
|
||||
},
|
||||
handler: runSkillStatBackfillInternalHandler,
|
||||
})
|
||||
|
||||
function buildSkillStatPatch(skill: Doc<'skills'>) {
|
||||
const stats = skill.stats
|
||||
const nextDownloads = stats.downloads
|
||||
const nextStars = stats.stars
|
||||
const nextInstallsCurrent = stats.installsCurrent ?? 0
|
||||
const nextInstallsAllTime = stats.installsAllTime ?? 0
|
||||
|
||||
if (
|
||||
skill.statsDownloads === nextDownloads &&
|
||||
skill.statsStars === nextStars &&
|
||||
skill.statsInstallsCurrent === nextInstallsCurrent &&
|
||||
skill.statsInstallsAllTime === nextInstallsAllTime
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
statsDownloads: nextDownloads,
|
||||
statsStars: nextStars,
|
||||
statsInstallsCurrent: nextInstallsCurrent,
|
||||
statsInstallsAllTime: nextInstallsAllTime,
|
||||
}
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
+13
-35
@@ -4,6 +4,7 @@ import type { Id } from './_generated/dataModel'
|
||||
import type { MutationCtx, QueryCtx } from './_generated/server'
|
||||
import { internalMutation, mutation, query } from './_generated/server'
|
||||
import { requireUser } from './lib/access'
|
||||
import { insertStatEvent } from './skillStatEvents'
|
||||
|
||||
const TELEMETRY_STALE_MS = 120 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -157,24 +158,13 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<'use
|
||||
await ctx.db.delete(entry._id)
|
||||
continue
|
||||
}
|
||||
const stats = skill.stats as {
|
||||
downloads: number
|
||||
installsCurrent?: number
|
||||
installsAllTime?: number
|
||||
stars: number
|
||||
versions: number
|
||||
comments: number
|
||||
}
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: {
|
||||
...stats,
|
||||
installsCurrent: Math.max(
|
||||
0,
|
||||
(stats.installsCurrent ?? 0) - (entry.activeRoots > 0 ? 1 : 0),
|
||||
),
|
||||
installsAllTime: Math.max(0, (stats.installsAllTime ?? 0) - 1),
|
||||
await insertStatEvent(ctx, {
|
||||
skillId: skill._id,
|
||||
kind: 'install_clear',
|
||||
delta: {
|
||||
allTime: -1,
|
||||
current: entry.activeRoots > 0 ? -1 : 0,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
await ctx.db.delete(entry._id)
|
||||
}
|
||||
@@ -381,25 +371,13 @@ async function bumpSkillInstallCounts(
|
||||
ctx: MutationCtx,
|
||||
params: { skillId: Id<'skills'>; deltaAllTime: number; deltaCurrent: number },
|
||||
) {
|
||||
const skill = await ctx.db.get(params.skillId)
|
||||
if (!skill) return
|
||||
const stats = skill.stats as {
|
||||
downloads: number
|
||||
installsCurrent?: number
|
||||
installsAllTime?: number
|
||||
stars: number
|
||||
versions: number
|
||||
comments: number
|
||||
if (params.deltaAllTime === 1 && params.deltaCurrent === 1) {
|
||||
await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_new' })
|
||||
} else if (params.deltaAllTime === 0 && params.deltaCurrent === 1) {
|
||||
await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_reactivate' })
|
||||
} else if (params.deltaAllTime === 0 && params.deltaCurrent === -1) {
|
||||
await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_deactivate' })
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: {
|
||||
...stats,
|
||||
installsAllTime: Math.max(0, (stats.installsAllTime ?? 0) + params.deltaAllTime),
|
||||
installsCurrent: Math.max(0, (stats.installsCurrent ?? 0) + params.deltaCurrent),
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
async function expireStaleRoots(
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Ret
|
||||
Public read:
|
||||
|
||||
- `GET /api/v1/search?q=...`
|
||||
- `GET /api/v1/skills?limit=&cursor=`
|
||||
- `GET /api/v1/skills?limit=&cursor=&sort=`
|
||||
- `sort`: `updated` (default), `downloads`, `stars` (`rating`), `installsCurrent` (`installs`), `installsAllTime`, `trending`
|
||||
- `GET /api/v1/skills/{slug}`
|
||||
- `GET /api/v1/skills/{slug}/versions?limit=&cursor=`
|
||||
- `GET /api/v1/skills/{slug}/versions/{version}`
|
||||
|
||||
+15
@@ -47,10 +47,25 @@ Stores your API token + cached registry URL.
|
||||
|
||||
- Verifies the stored token via `/api/v1/whoami`.
|
||||
|
||||
### `star <slug>` / `unstar <slug>`
|
||||
|
||||
- Adds/removes a skill from your highlights.
|
||||
- Calls `POST /api/v1/stars/<slug>` and `DELETE /api/v1/stars/<slug>`.
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `search <query...>`
|
||||
|
||||
- Calls `/api/v1/search?q=...`.
|
||||
|
||||
### `explore`
|
||||
|
||||
- Lists latest updated skills via `/api/v1/skills?limit=...` (sorted by `updatedAt` desc).
|
||||
- Flags:
|
||||
- `--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).
|
||||
|
||||
### `install <slug>`
|
||||
|
||||
- Resolves latest version via `/api/v1/skills/<slug>`.
|
||||
|
||||
+21
-2
@@ -44,8 +44,13 @@ Response:
|
||||
|
||||
Query params:
|
||||
|
||||
- `limit` (optional): integer
|
||||
- `cursor` (optional): pagination cursor
|
||||
- `limit` (optional): integer (1–200)
|
||||
- `cursor` (optional): pagination cursor (only for `sort=updated`)
|
||||
- `sort` (optional): `updated` (default), `downloads`, `stars` (alias: `rating`), `installsCurrent` (alias: `installs`), `installsAllTime`, `trending`
|
||||
|
||||
Notes:
|
||||
|
||||
- `trending` ranks by installs in the last 7 days (telemetry-based).
|
||||
|
||||
Response:
|
||||
|
||||
@@ -140,6 +145,20 @@ Publishes a new version.
|
||||
|
||||
Soft-delete / restore a skill (owner/admin only).
|
||||
|
||||
### `POST /api/v1/stars/{slug}` / `DELETE /api/v1/stars/{slug}`
|
||||
|
||||
Add/remove a star (highlights). Both endpoints are idempotent.
|
||||
|
||||
Responses:
|
||||
|
||||
```json
|
||||
{ "ok": true, "starred": true, "alreadyStarred": false }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "ok": true, "unstarred": true, "alreadyUnstarred": false }
|
||||
```
|
||||
|
||||
## Legacy CLI endpoints (deprecated)
|
||||
|
||||
Still supported for older CLI versions:
|
||||
|
||||
+7
-5
@@ -10,6 +10,7 @@
|
||||
"build": "bun --bun vite build",
|
||||
"preview": "bun --bun vite preview",
|
||||
"docs:list": "bun scripts/docs-list.ts",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "vitest run -c vitest.e2e.config.ts",
|
||||
@@ -23,7 +24,7 @@
|
||||
"format": "biome format --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.3",
|
||||
"@auth/core": "^0.37.4",
|
||||
"@convex-dev/auth": "^0.0.90",
|
||||
"@fontsource/bricolage-grotesque": "^5.2.10",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
@@ -34,14 +35,15 @@
|
||||
"@resvg/resvg-wasm": "^2.6.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "^0.9.2",
|
||||
"@tanstack/react-router": "^1.151.3",
|
||||
"@tanstack/react-router-devtools": "^1.151.3",
|
||||
"@tanstack/react-start": "^1.151.5",
|
||||
"@tanstack/router-plugin": "^1.151.5",
|
||||
"@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",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"clawdhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.31.5",
|
||||
"convex-helpers": "^0.1.111",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.8",
|
||||
"lucide-react": "^0.562.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clawdhub",
|
||||
"version": "0.2.0",
|
||||
"description": "ClawdHub CLI \u2014 install, update, search, and publish agent skills.",
|
||||
"version": "0.3.0",
|
||||
"description": "ClawdHub CLI \\u2014 install, update, search, and publish agent skills.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -28,7 +28,8 @@
|
||||
"mime": "^4.1.0",
|
||||
"ora": "^9.0.0",
|
||||
"p-retry": "^7.1.1",
|
||||
"semver": "^7.7.3"
|
||||
"semver": "^7.7.3",
|
||||
"undici": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.9",
|
||||
|
||||
@@ -7,8 +7,10 @@ import { resolveClawdbotDefaultWorkspace } from './cli/clawdbotConfig.js'
|
||||
import { cmdLoginFlow, cmdLogout, cmdWhoami } from './cli/commands/auth.js'
|
||||
import { cmdDeleteSkill, cmdUndeleteSkill } from './cli/commands/delete.js'
|
||||
import { cmdPublish } from './cli/commands/publish.js'
|
||||
import { cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
|
||||
import { cmdExplore, cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
|
||||
import { cmdStarSkill } from './cli/commands/star.js'
|
||||
import { cmdSync } from './cli/commands/sync.js'
|
||||
import { cmdUnstarSkill } from './cli/commands/unstar.js'
|
||||
import { configureCommanderHelp, styleEnvBlock, styleTitle } from './cli/helpStyle.js'
|
||||
import { DEFAULT_REGISTRY, DEFAULT_SITE } from './cli/registry.js'
|
||||
import type { GlobalOpts } from './cli/types.js'
|
||||
@@ -183,6 +185,28 @@ program
|
||||
await cmdList(opts)
|
||||
})
|
||||
|
||||
program
|
||||
.command('explore')
|
||||
.description('Browse latest updated skills from the registry')
|
||||
.option(
|
||||
'--limit <n>',
|
||||
'Number of skills to show (max 200)',
|
||||
(value) => Number.parseInt(value, 10),
|
||||
25,
|
||||
)
|
||||
.option(
|
||||
'--sort <order>',
|
||||
'Sort by newest, downloads, rating, installs, installsAllTime, or trending',
|
||||
'newest',
|
||||
)
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
const limit =
|
||||
typeof options.limit === 'number' && Number.isFinite(options.limit) ? options.limit : 25
|
||||
await cmdExplore(opts, { limit, sort: options.sort, json: options.json })
|
||||
})
|
||||
|
||||
program
|
||||
.command('publish')
|
||||
.description('Publish skill from folder')
|
||||
@@ -218,6 +242,26 @@ program
|
||||
await cmdUndeleteSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('star')
|
||||
.description('Add a skill to your highlights')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdStarSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('unstar')
|
||||
.description('Remove a skill from your highlights')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdUnstarSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('sync')
|
||||
.description('Scan local skills and publish new/updated ones')
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* @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 mockDownloadZip = vi.fn()
|
||||
vi.mock('../../http.js', () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
downloadZip: (...args: unknown[]) => mockDownloadZip(...args),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => 'https://clawdhub.com')
|
||||
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)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
vi.mock('../../skills.js', () => ({
|
||||
extractZipToDir: vi.fn(),
|
||||
hashSkillFiles: vi.fn(),
|
||||
listTextFiles: vi.fn(),
|
||||
readLockfile: vi.fn(),
|
||||
readSkillOrigin: vi.fn(),
|
||||
writeLockfile: vi.fn(),
|
||||
writeSkillOrigin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}))
|
||||
|
||||
const { clampLimit, cmdExplore, cmdUpdate, formatExploreLine } = await import('./skills')
|
||||
const {
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
listTextFiles,
|
||||
readLockfile,
|
||||
readSkillOrigin,
|
||||
writeLockfile,
|
||||
writeSkillOrigin,
|
||||
} = await import('../../skills.js')
|
||||
const { rm, stat } = await import('node:fs/promises')
|
||||
|
||||
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: '/work',
|
||||
dir: '/work/skills',
|
||||
site: 'https://clawdhub.com',
|
||||
registry: 'https://clawdhub.com',
|
||||
registrySource: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('explore helpers', () => {
|
||||
it('clamps explore limits and handles non-finite values', () => {
|
||||
expect(clampLimit(-5)).toBe(1)
|
||||
expect(clampLimit(0)).toBe(1)
|
||||
expect(clampLimit(1)).toBe(1)
|
||||
expect(clampLimit(50)).toBe(50)
|
||||
expect(clampLimit(99)).toBe(99)
|
||||
expect(clampLimit(200)).toBe(200)
|
||||
expect(clampLimit(250)).toBe(200)
|
||||
expect(clampLimit(Number.NaN)).toBe(25)
|
||||
expect(clampLimit(Number.POSITIVE_INFINITY)).toBe(25)
|
||||
expect(clampLimit(Number.NaN, 10)).toBe(10)
|
||||
})
|
||||
|
||||
it('formats explore lines with relative time and truncation', () => {
|
||||
const now = 4 * 60 * 60 * 1000
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const summary = 'a'.repeat(60)
|
||||
const line = formatExploreLine({
|
||||
slug: 'weather',
|
||||
summary,
|
||||
updatedAt: now - 2 * 60 * 60 * 1000,
|
||||
latestVersion: null,
|
||||
})
|
||||
expect(line).toBe(`weather v? 2h ago ${'a'.repeat(49)}…`)
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdExplore', () => {
|
||||
it('clamps limit and handles empty results', async () => {
|
||||
mockApiRequest.mockResolvedValue({ items: [] })
|
||||
|
||||
await cmdExplore(makeOpts(), { limit: 0 })
|
||||
|
||||
const [, args] = mockApiRequest.mock.calls[0] ?? []
|
||||
const url = new URL(String(args?.url))
|
||||
expect(url.searchParams.get('limit')).toBe('1')
|
||||
expect(mockLog).toHaveBeenCalledWith('No skills found.')
|
||||
})
|
||||
|
||||
it('prints formatted results', async () => {
|
||||
const now = 10 * 60 * 1000
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const item = {
|
||||
slug: 'gog',
|
||||
summary: 'Google Workspace CLI for Gmail, Calendar, Drive and more.',
|
||||
updatedAt: now - 90 * 1000,
|
||||
latestVersion: { version: '1.2.3' },
|
||||
}
|
||||
mockApiRequest.mockResolvedValue({ items: [item] })
|
||||
|
||||
await cmdExplore(makeOpts(), { limit: 250 })
|
||||
|
||||
const [, args] = mockApiRequest.mock.calls[0] ?? []
|
||||
const url = new URL(String(args?.url))
|
||||
expect(url.searchParams.get('limit')).toBe('200')
|
||||
expect(mockLog).toHaveBeenCalledWith(formatExploreLine(item))
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('supports sort and json output', async () => {
|
||||
const payload = { items: [], nextCursor: null }
|
||||
mockApiRequest.mockResolvedValue(payload)
|
||||
|
||||
await cmdExplore(makeOpts(), { limit: 10, sort: 'installs', json: true })
|
||||
|
||||
const [, args] = mockApiRequest.mock.calls[0] ?? []
|
||||
const url = new URL(String(args?.url))
|
||||
expect(url.searchParams.get('limit')).toBe('10')
|
||||
expect(url.searchParams.get('sort')).toBe('installsCurrent')
|
||||
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(payload, null, 2))
|
||||
})
|
||||
|
||||
it('supports all-time installs and trending sorts', async () => {
|
||||
mockApiRequest.mockResolvedValue({ items: [], nextCursor: null })
|
||||
|
||||
await cmdExplore(makeOpts(), { limit: 5, sort: 'installsAllTime' })
|
||||
await cmdExplore(makeOpts(), { limit: 5, sort: 'trending' })
|
||||
|
||||
const first = new URL(String(mockApiRequest.mock.calls[0]?.[1]?.url))
|
||||
const second = new URL(String(mockApiRequest.mock.calls[1]?.[1]?.url))
|
||||
expect(first.searchParams.get('sort')).toBe('installsAllTime')
|
||||
expect(second.searchParams.get('sort')).toBe('trending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdUpdate', () => {
|
||||
it('uses path-based skill lookup when no local fingerprint is available', async () => {
|
||||
mockApiRequest.mockResolvedValue({ latestVersion: { version: '1.0.0' } })
|
||||
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]))
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { demo: { version: '0.1.0', installedAt: 123 } },
|
||||
})
|
||||
vi.mocked(writeLockfile).mockResolvedValue()
|
||||
vi.mocked(readSkillOrigin).mockResolvedValue(null)
|
||||
vi.mocked(writeSkillOrigin).mockResolvedValue()
|
||||
vi.mocked(extractZipToDir).mockResolvedValue()
|
||||
vi.mocked(listTextFiles).mockResolvedValue([])
|
||||
vi.mocked(hashSkillFiles).mockReturnValue({ fingerprint: 'hash', files: [] })
|
||||
vi.mocked(stat).mockRejectedValue(new Error('missing'))
|
||||
vi.mocked(rm).mockResolvedValue()
|
||||
|
||||
await cmdUpdate(makeOpts(), 'demo', {}, false)
|
||||
|
||||
const [, args] = mockApiRequest.mock.calls[0] ?? []
|
||||
expect(args?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent('demo')}`)
|
||||
expect(args?.url).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { apiRequest, downloadZip } from '../../http.js'
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SearchResponseSchema,
|
||||
ApiV1SkillListResponseSchema,
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
ApiV1SkillResponseSchema,
|
||||
} from '../../schema/index.js'
|
||||
@@ -152,7 +153,7 @@ export async function cmdUpdate(
|
||||
} else {
|
||||
const meta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
resolveResult = { match: null, latestVersion: meta.latestVersion ?? null }
|
||||
@@ -241,6 +242,123 @@ export async function cmdList(opts: GlobalOpts) {
|
||||
}
|
||||
}
|
||||
|
||||
type ExploreSort = 'newest' | 'downloads' | 'rating' | 'installs' | 'installsAllTime' | 'trending'
|
||||
type ApiExploreSort =
|
||||
| 'updated'
|
||||
| 'downloads'
|
||||
| 'stars'
|
||||
| 'installsCurrent'
|
||||
| 'installsAllTime'
|
||||
| 'trending'
|
||||
|
||||
export async function cmdExplore(
|
||||
opts: GlobalOpts,
|
||||
options: { limit?: number; sort?: string; json?: boolean } = {},
|
||||
) {
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner('Fetching latest skills')
|
||||
try {
|
||||
const url = new URL(ApiRoutes.skills, registry)
|
||||
const boundedLimit = clampLimit(options.limit ?? 25)
|
||||
const { apiSort } = resolveExploreSort(options.sort)
|
||||
url.searchParams.set('limit', String(boundedLimit))
|
||||
if (apiSort !== 'updated') url.searchParams.set('sort', apiSort)
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
ApiV1SkillListResponseSchema,
|
||||
)
|
||||
|
||||
spinner.stop()
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
return
|
||||
}
|
||||
if (result.items.length === 0) {
|
||||
console.log('No skills found.')
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of result.items) {
|
||||
console.log(formatExploreLine(item))
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function formatExploreLine(item: {
|
||||
slug: string
|
||||
summary?: string | null
|
||||
updatedAt: number
|
||||
latestVersion?: { version: string } | null
|
||||
}) {
|
||||
const version = item.latestVersion?.version ?? '?'
|
||||
const age = formatRelativeTime(item.updatedAt)
|
||||
const summary = item.summary ? ` ${truncate(item.summary, 50)}` : ''
|
||||
return `${item.slug} v${version} ${age}${summary}`
|
||||
}
|
||||
|
||||
export function clampLimit(limit: number, fallback = 25) {
|
||||
if (!Number.isFinite(limit)) return fallback
|
||||
return Math.min(Math.max(1, limit), 200)
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 30) {
|
||||
const months = Math.floor(days / 30)
|
||||
return `${months}mo ago`
|
||||
}
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str
|
||||
return `${str.slice(0, maxLen - 1)}…`
|
||||
}
|
||||
|
||||
function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExploreSort } {
|
||||
const normalized = raw?.trim().toLowerCase()
|
||||
if (!normalized || normalized === 'newest' || normalized === 'updated') {
|
||||
return { sort: 'newest', apiSort: 'updated' }
|
||||
}
|
||||
if (normalized === 'downloads' || normalized === 'download') {
|
||||
return { sort: 'downloads', apiSort: 'downloads' }
|
||||
}
|
||||
if (normalized === 'rating' || normalized === 'stars' || normalized === 'star') {
|
||||
return { sort: 'rating', apiSort: 'stars' }
|
||||
}
|
||||
if (
|
||||
normalized === 'installs' ||
|
||||
normalized === 'install' ||
|
||||
normalized === 'installscurrent' ||
|
||||
normalized === 'installs-current' ||
|
||||
normalized === 'current'
|
||||
) {
|
||||
return { sort: 'installs', apiSort: 'installsCurrent' }
|
||||
}
|
||||
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
|
||||
return { sort: 'installsAllTime', apiSort: 'installsAllTime' }
|
||||
}
|
||||
if (normalized === 'trending') {
|
||||
return { sort: 'trending', apiSort: 'trending' }
|
||||
}
|
||||
fail(
|
||||
`Invalid sort "${raw}". Use newest, downloads, rating, installs, installsAllTime, or trending.`,
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
|
||||
const url = new URL(ApiRoutes.resolve, registry)
|
||||
url.searchParams.set('slug', slug)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import { ApiRoutes, ApiV1StarResponseSchema } 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: clawdhub login')
|
||||
return token
|
||||
}
|
||||
|
||||
export async function cmdStarSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase()
|
||||
if (!slug) fail('Slug required')
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const ok = await promptConfirm(`Star ${slug}?`)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`Starring ${slug}`)
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'POST', path: `${ApiRoutes.stars}/${encodeURIComponent(slug)}`, token },
|
||||
ApiV1StarResponseSchema,
|
||||
)
|
||||
spinner.succeed(result.alreadyStarred ? `OK. ${slug} already starred.` : `OK. Starred ${slug}`)
|
||||
return result
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import { ApiRoutes, ApiV1UnstarResponseSchema } 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: clawdhub login')
|
||||
return token
|
||||
}
|
||||
|
||||
export async function cmdUnstarSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase()
|
||||
if (!slug) fail('Slug required')
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const ok = await promptConfirm(`Unstar ${slug}?`)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`Unstarring ${slug}`)
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'DELETE', path: `${ApiRoutes.stars}/${encodeURIComponent(slug)}`, token },
|
||||
ApiV1UnstarResponseSchema,
|
||||
)
|
||||
spinner.succeed(
|
||||
result.alreadyUnstarred ? `OK. ${slug} already unstarred.` : `OK. Unstarred ${slug}`,
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const ApiRoutes = {
|
||||
resolve: '/api/v1/resolve',
|
||||
download: '/api/v1/download',
|
||||
skills: '/api/v1/skills',
|
||||
stars: '/api/v1/stars',
|
||||
souls: '/api/v1/souls',
|
||||
whoami: '/api/v1/whoami',
|
||||
} as const
|
||||
|
||||
@@ -215,6 +215,18 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
})
|
||||
|
||||
export const ApiV1StarResponseSchema = type({
|
||||
ok: 'true',
|
||||
starred: 'boolean',
|
||||
alreadyStarred: 'boolean',
|
||||
})
|
||||
|
||||
export const ApiV1UnstarResponseSchema = type({
|
||||
ok: 'true',
|
||||
unstarred: 'boolean',
|
||||
alreadyUnstarred: 'boolean',
|
||||
})
|
||||
|
||||
export const SkillInstallSpecSchema = type({
|
||||
id: 'string?',
|
||||
kind: '"brew"|"node"|"go"|"uv"',
|
||||
|
||||
Vendored
+1
@@ -15,6 +15,7 @@ export declare const ApiRoutes: {
|
||||
readonly resolve: "/api/v1/resolve";
|
||||
readonly download: "/api/v1/download";
|
||||
readonly skills: "/api/v1/skills";
|
||||
readonly stars: "/api/v1/stars";
|
||||
readonly souls: "/api/v1/souls";
|
||||
readonly whoami: "/api/v1/whoami";
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -15,6 +15,7 @@ export const ApiRoutes = {
|
||||
resolve: '/api/v1/resolve',
|
||||
download: '/api/v1/download',
|
||||
skills: '/api/v1/skills',
|
||||
stars: '/api/v1/stars',
|
||||
souls: '/api/v1/souls',
|
||||
whoami: '/api/v1/whoami',
|
||||
};
|
||||
|
||||
Vendored
+1
-1
@@ -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,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,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
|
||||
Vendored
+10
@@ -221,6 +221,16 @@ export declare const ApiV1PublishResponseSchema: import("arktype/internal/varian
|
||||
export declare const ApiV1DeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
}, {}>;
|
||||
export declare const ApiV1StarResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
starred: boolean;
|
||||
alreadyStarred: boolean;
|
||||
}, {}>;
|
||||
export declare const ApiV1UnstarResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
unstarred: boolean;
|
||||
alreadyUnstarred: boolean;
|
||||
}, {}>;
|
||||
export declare const SkillInstallSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
kind: "brew" | "node" | "go" | "uv";
|
||||
id?: string | undefined;
|
||||
|
||||
Vendored
+11
-1
@@ -192,6 +192,16 @@ export const ApiV1PublishResponseSchema = type({
|
||||
export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
});
|
||||
export const ApiV1StarResponseSchema = type({
|
||||
ok: 'true',
|
||||
starred: 'boolean',
|
||||
alreadyStarred: 'boolean',
|
||||
});
|
||||
export const ApiV1UnstarResponseSchema = type({
|
||||
ok: 'true',
|
||||
unstarred: 'boolean',
|
||||
alreadyUnstarred: 'boolean',
|
||||
});
|
||||
export const SkillInstallSpecSchema = type({
|
||||
id: 'string?',
|
||||
kind: '"brew"|"node"|"go"|"uv"',
|
||||
@@ -230,4 +240,4 @@ export const ClawdisSkillMetadataSchema = type({
|
||||
nix: NixPluginSpecSchema.optional(),
|
||||
config: ClawdbotConfigSpecSchema.optional(),
|
||||
});
|
||||
//# sourceMappingURL=schemas.js.map
|
||||
//# sourceMappingURL=schemas.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@ export const ApiRoutes = {
|
||||
resolve: '/api/v1/resolve',
|
||||
download: '/api/v1/download',
|
||||
skills: '/api/v1/skills',
|
||||
stars: '/api/v1/stars',
|
||||
souls: '/api/v1/souls',
|
||||
whoami: '/api/v1/whoami',
|
||||
} as const
|
||||
|
||||
@@ -226,6 +226,18 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
})
|
||||
|
||||
export const ApiV1StarResponseSchema = type({
|
||||
ok: 'true',
|
||||
starred: 'boolean',
|
||||
alreadyStarred: 'boolean',
|
||||
})
|
||||
|
||||
export const ApiV1UnstarResponseSchema = type({
|
||||
ok: 'true',
|
||||
unstarred: 'boolean',
|
||||
alreadyUnstarred: 'boolean',
|
||||
})
|
||||
|
||||
export const SkillInstallSpecSchema = type({
|
||||
id: 'string?',
|
||||
kind: '"brew"|"node"|"go"|"uv"',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import semver from 'semver'
|
||||
|
||||
type PackageJson = {
|
||||
name?: string
|
||||
version?: string
|
||||
dependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
async function readJson(path: string): Promise<PackageJson> {
|
||||
const raw = await readFile(path, 'utf8')
|
||||
return JSON.parse(raw) as PackageJson
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = process.cwd()
|
||||
const rootPkgPath = join(root, 'package.json')
|
||||
const authPkgPath = join(root, 'node_modules', '@convex-dev', 'auth', 'package.json')
|
||||
const corePkgPath = join(root, 'node_modules', '@auth', 'core', 'package.json')
|
||||
|
||||
const rootPkg = await readJson(rootPkgPath)
|
||||
const authPkg = await readJson(authPkgPath)
|
||||
const corePkg = await readJson(corePkgPath)
|
||||
|
||||
const peerRange = authPkg.peerDependencies?.['@auth/core']
|
||||
const declaredRange = rootPkg.dependencies?.['@auth/core']
|
||||
const installedVersion = corePkg.version
|
||||
|
||||
if (!peerRange) {
|
||||
throw new Error('Missing @auth/core peer range in @convex-dev/auth package.json')
|
||||
}
|
||||
if (!declaredRange) {
|
||||
throw new Error('Missing @auth/core dependency in root package.json')
|
||||
}
|
||||
if (!installedVersion) {
|
||||
throw new Error('Missing @auth/core version in node_modules')
|
||||
}
|
||||
|
||||
if (!semver.intersects(declaredRange, peerRange, { includePrerelease: true })) {
|
||||
throw new Error(
|
||||
`@auth/core range mismatch: package.json declares "${declaredRange}" but @convex-dev/auth requires "${peerRange}"`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!semver.satisfies(installedVersion, peerRange, { includePrerelease: true })) {
|
||||
throw new Error(
|
||||
`@auth/core version mismatch: installed "${installedVersion}" does not satisfy @convex-dev/auth peer "${peerRange}"`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`peer ok: @auth/core ${installedVersion} satisfies @convex-dev/auth (${peerRange})`)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
|
||||
redirect: (options: unknown) => ({ redirect: options }),
|
||||
}))
|
||||
|
||||
import { Route } from '../routes/search'
|
||||
|
||||
describe('search route', () => {
|
||||
it('redirects to the skills index', () => {
|
||||
const route = Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: { search: { q?: string; highlighted?: boolean } }) => void
|
||||
}
|
||||
}
|
||||
const beforeLoad = route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
}) => void
|
||||
let thrown: unknown
|
||||
|
||||
try {
|
||||
beforeLoad({ search: { q: 'crab', highlighted: true } })
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).toEqual({
|
||||
redirect: {
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: 'crab',
|
||||
highlighted: true,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to the skills index without query', () => {
|
||||
const route = Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: { search: { q?: string; highlighted?: boolean } }) => void
|
||||
}
|
||||
}
|
||||
const beforeLoad = route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
}) => void
|
||||
let thrown: unknown
|
||||
|
||||
try {
|
||||
beforeLoad({ search: {} })
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).toEqual({
|
||||
redirect: {
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: undefined,
|
||||
highlighted: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { vi } from 'vitest'
|
||||
import { SkillDetailPage } from '../components/SkillDetailPage'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const useAuthStatusMock = vi.fn()
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
@@ -13,18 +14,27 @@ const useQueryMock = vi.fn()
|
||||
const getReadmeMock = vi.fn()
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useConvexAuth: () => ({ isAuthenticated: false }),
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => vi.fn(),
|
||||
useAction: () => getReadmeMock,
|
||||
}))
|
||||
|
||||
vi.mock('../lib/useAuthStatus', () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}))
|
||||
|
||||
describe('SkillDetailPage', () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset()
|
||||
getReadmeMock.mockReset()
|
||||
navigateMock.mockReset()
|
||||
useAuthStatusMock.mockReset()
|
||||
getReadmeMock.mockResolvedValue({ text: '' })
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
})
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
return undefined
|
||||
@@ -32,9 +42,7 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
|
||||
it('shows a loading indicator while loading', () => {
|
||||
useQueryMock
|
||||
.mockImplementationOnce(() => null) // me
|
||||
.mockImplementationOnce(() => undefined) // getBySlug
|
||||
useQueryMock.mockImplementationOnce(() => undefined) // getBySlug
|
||||
|
||||
render(<SkillDetailPage slug="weather" />)
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
|
||||
@@ -42,30 +50,26 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
|
||||
it('shows not found when skill query resolves to null', async () => {
|
||||
useQueryMock
|
||||
.mockImplementationOnce(() => null) // me
|
||||
.mockImplementationOnce(() => null) // getBySlug
|
||||
useQueryMock.mockImplementationOnce(() => null) // getBySlug
|
||||
|
||||
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(() => null) // me
|
||||
.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.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: {} },
|
||||
}))
|
||||
|
||||
render(<SkillDetailPage slug="weather" redirectToCanonical />)
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SkillsIndex } from '../routes/skills/index'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const useQueryMock = vi.fn()
|
||||
const useActionMock = vi.fn()
|
||||
const usePaginatedQueryMock = vi.fn()
|
||||
let searchMock: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
@@ -14,22 +15,30 @@ vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
Link: (props: { children: unknown }) => <a href="/">{props.children}</a>,
|
||||
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
|
||||
}))
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useAction: (...args: unknown[]) => useActionMock(...args),
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('convex-helpers/react', () => ({
|
||||
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
|
||||
}))
|
||||
|
||||
describe('SkillsIndex', () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset()
|
||||
usePaginatedQueryMock.mockReset()
|
||||
useActionMock.mockReset()
|
||||
navigateMock.mockReset()
|
||||
searchMock = {}
|
||||
useActionMock.mockReturnValue(() => Promise.resolve([]))
|
||||
useQueryMock.mockReturnValue({ items: [], nextCursor: null })
|
||||
// Default: return empty results with Exhausted status
|
||||
usePaginatedQueryMock.mockReturnValue({
|
||||
results: [],
|
||||
status: 'Exhausted',
|
||||
loadMore: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -39,10 +48,12 @@ describe('SkillsIndex', () => {
|
||||
|
||||
it('requests the first skills page', () => {
|
||||
render(<SkillsIndex />)
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), {
|
||||
cursor: undefined,
|
||||
limit: 50,
|
||||
})
|
||||
// usePaginatedQuery should be called with the API endpoint and empty args
|
||||
expect(usePaginatedQueryMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{},
|
||||
{ initialNumItems: 25 },
|
||||
)
|
||||
})
|
||||
|
||||
it('renders an empty state when no skills are returned', () => {
|
||||
@@ -54,19 +65,29 @@ describe('SkillsIndex', () => {
|
||||
searchMock = { q: 'remind' }
|
||||
const actionFn = vi.fn().mockResolvedValue([])
|
||||
useActionMock.mockReturnValue(actionFn)
|
||||
useQueryMock.mockReturnValue(undefined)
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<SkillsIndex />)
|
||||
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip')
|
||||
// usePaginatedQuery should be called with 'skip' when there's a search query
|
||||
expect(usePaginatedQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip', {
|
||||
initialNumItems: 25,
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
expect(actionFn).toHaveBeenCalledWith({
|
||||
query: 'remind',
|
||||
highlightedOnly: false,
|
||||
limit: 50,
|
||||
limit: 25,
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
expect(actionFn).toHaveBeenCalledWith({
|
||||
query: 'remind',
|
||||
highlightedOnly: false,
|
||||
limit: 25,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,10 +96,9 @@ describe('SkillsIndex', () => {
|
||||
vi.stubGlobal('IntersectionObserver', undefined)
|
||||
const actionFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makeSearchResults(25))
|
||||
.mockResolvedValueOnce(makeSearchResults(50))
|
||||
.mockResolvedValueOnce(makeSearchResults(100))
|
||||
useActionMock.mockReturnValue(actionFn)
|
||||
useQueryMock.mockReturnValue(undefined)
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<SkillsIndex />)
|
||||
@@ -95,7 +115,7 @@ describe('SkillsIndex', () => {
|
||||
expect(actionFn).toHaveBeenLastCalledWith({
|
||||
query: 'remind',
|
||||
highlightedOnly: false,
|
||||
limit: 100,
|
||||
limit: 50,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,10 +15,10 @@ const publishVersion = vi.fn()
|
||||
const generateChangelogPreview = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
const useQueryMock = vi.fn()
|
||||
const useAuthStatusMock = vi.fn()
|
||||
let useActionCallCount = 0
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useConvexAuth: () => ({ isAuthenticated: true }),
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => generateUploadUrl,
|
||||
useAction: () => {
|
||||
@@ -27,6 +27,10 @@ vi.mock('convex/react', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../lib/useAuthStatus', () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}))
|
||||
|
||||
describe('Upload route', () => {
|
||||
beforeEach(() => {
|
||||
generateUploadUrl.mockReset()
|
||||
@@ -34,7 +38,13 @@ describe('Upload route', () => {
|
||||
generateChangelogPreview.mockReset()
|
||||
fetchMock.mockReset()
|
||||
useQueryMock.mockReset()
|
||||
useAuthStatusMock.mockReset()
|
||||
useActionCallCount = 0
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: 'users:1' },
|
||||
})
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
return null
|
||||
|
||||
@@ -3,15 +3,13 @@ import { convex } from '../convex/client'
|
||||
import { UserBootstrap } from './UserBootstrap'
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
if (typeof window === 'undefined') {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<ConvexAuthProvider
|
||||
client={convex}
|
||||
replaceURL={(relativeUrl) => {
|
||||
window.history.replaceState(null, '', relativeUrl)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.replaceState(null, '', relativeUrl)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UserBootstrap />
|
||||
|
||||
+51
-39
@@ -1,13 +1,12 @@
|
||||
import { useAuthActions } from '@convex-dev/auth/react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useConvexAuth, useQuery } from 'convex/react'
|
||||
import { Menu, Monitor, Moon, Sun } from 'lucide-react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import { gravatarUrl } from '../lib/gravatar'
|
||||
import { getClawdHubSiteUrl, getSiteMode, getSiteName } from '../lib/site'
|
||||
import { applyTheme, useThemeMode } from '../lib/theme'
|
||||
import { startThemeTransition } from '../lib/theme-transition'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -18,9 +17,8 @@ import {
|
||||
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group'
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth()
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus()
|
||||
const { signIn, signOut } = useAuthActions()
|
||||
const me = useQuery(api.users.me)
|
||||
const { mode, setMode } = useThemeMode()
|
||||
const toggleRef = useRef<HTMLDivElement | null>(null)
|
||||
const siteMode = getSiteMode()
|
||||
@@ -49,7 +47,11 @@ export default function Header() {
|
||||
return (
|
||||
<header className="navbar">
|
||||
<div className="navbar-inner">
|
||||
<Link to="/" search={{ q: undefined, highlighted: undefined }} className="brand">
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: undefined }}
|
||||
className="brand"
|
||||
>
|
||||
<span className="brand-mark">
|
||||
<img src="/clawd-logo.png" alt="" aria-hidden="true" />
|
||||
</span>
|
||||
@@ -57,27 +59,32 @@ export default function Header() {
|
||||
</Link>
|
||||
<nav className="nav-links">
|
||||
{isSoulMode ? <a href={clawdHubUrl}>ClawdHub</a> : null}
|
||||
<Link
|
||||
to={isSoulMode ? '/souls' : '/skills'}
|
||||
search={
|
||||
isSoulMode
|
||||
? undefined
|
||||
: {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
{isSoulMode ? 'Souls' : 'Skills'}
|
||||
</Link>
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/upload" search={{ updateSlug: undefined }}>
|
||||
Upload
|
||||
</Link>
|
||||
{isSoulMode ? null : <Link to="/import">Import</Link>}
|
||||
<Link to="/search" search={{ q: undefined, highlighted: undefined }}>
|
||||
<Link to="/" search={{ q: undefined, highlighted: undefined, search: true }}>
|
||||
Search
|
||||
</Link>
|
||||
{me ? <Link to="/stars">Stars</Link> : null}
|
||||
@@ -98,22 +105,27 @@ export default function Header() {
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to={isSoulMode ? '/souls' : '/skills'}
|
||||
search={
|
||||
isSoulMode
|
||||
? undefined
|
||||
: {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
{isSoulMode ? 'Souls' : 'Skills'}
|
||||
</Link>
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }}>
|
||||
@@ -126,7 +138,7 @@ export default function Header() {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/search" search={{ q: undefined, highlighted: undefined }}>
|
||||
<Link to="/" search={{ q: undefined, highlighted: undefined, search: true }}>
|
||||
Search
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -8,11 +8,14 @@ type SkillCardProps = {
|
||||
chip?: string
|
||||
summaryFallback: string
|
||||
meta: ReactNode
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function SkillCard({ skill, badge, chip, summaryFallback, meta }: SkillCardProps) {
|
||||
export function SkillCard({ skill, badge, chip, summaryFallback, meta, href }: SkillCardProps) {
|
||||
const link = href ?? `/skills/${skill.slug}`
|
||||
|
||||
return (
|
||||
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="card skill-card">
|
||||
<Link to={link} className="card skill-card">
|
||||
{badge || chip ? (
|
||||
<div className="skill-card-tags">
|
||||
{badge ? <div className="tag">{badge}</div> : null}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import type { ClawdisSkillMetadata, SkillInstallSpec } from 'clawdhub-schema'
|
||||
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
|
||||
import { useAction, useMutation, useQuery } from 'convex/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Doc, Id } from '../../convex/_generated/dataModel'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
import { SkillDiffCard } from './SkillDiffCard'
|
||||
|
||||
type SkillDetailPageProps = {
|
||||
@@ -14,14 +15,15 @@ type SkillDetailPageProps = {
|
||||
redirectToCanonical?: boolean
|
||||
}
|
||||
|
||||
type SkillFile = Doc<'skillVersions'>['files'][number]
|
||||
|
||||
export function SkillDetailPage({
|
||||
slug,
|
||||
canonicalOwner,
|
||||
redirectToCanonical,
|
||||
}: SkillDetailPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const { isAuthenticated } = useConvexAuth()
|
||||
const me = useQuery(api.users.me)
|
||||
const { isAuthenticated, me } = useAuthStatus()
|
||||
const result = useQuery(api.skills.getBySlug, { slug })
|
||||
const toggleStar = useMutation(api.stars.toggle)
|
||||
const addComment = useMutation(api.comments.add)
|
||||
@@ -122,7 +124,7 @@ export function SkillDetailPage({
|
||||
if (!readme) return null
|
||||
return stripFrontmatter(readme)
|
||||
}, [readme])
|
||||
const latestFiles = latestVersion?.files ?? []
|
||||
const latestFiles: SkillFile[] = latestVersion?.files ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestVersion) return
|
||||
|
||||
@@ -418,7 +418,7 @@ function buildDiffOptions(viewMode: 'split' | 'inline'): DiffEditorProps['option
|
||||
|
||||
function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
const surface = styles.getPropertyValue('--surface').trim() || '#ffffff'
|
||||
const surface = normalizeHex(styles.getPropertyValue('--surface').trim() || '#ffffff')
|
||||
const surfaceMuted = styles.getPropertyValue('--surface-muted').trim() || '#f6f1ec'
|
||||
const ink = styles.getPropertyValue('--ink').trim() || '#1d1a17'
|
||||
const inkSoft = styles.getPropertyValue('--ink-soft').trim() || '#4c463f'
|
||||
@@ -468,7 +468,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
|
||||
}
|
||||
|
||||
function normalizeHex(value: string) {
|
||||
if (!value.startsWith('#')) return '#000000'
|
||||
if (!value.startsWith('#')) return value
|
||||
if (value.length === 4) {
|
||||
return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
|
||||
import { useAction, useMutation, useQuery } from 'convex/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Doc } from '../../convex/_generated/dataModel'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
|
||||
type SoulDetailPageProps = {
|
||||
slug: string
|
||||
}
|
||||
|
||||
export function SoulDetailPage({ slug }: SoulDetailPageProps) {
|
||||
const { isAuthenticated } = useConvexAuth()
|
||||
const me = useQuery(api.users.me)
|
||||
const { isAuthenticated, me } = useAuthStatus()
|
||||
const result = useQuery(api.souls.getBySlug, { slug })
|
||||
const toggleStar = useMutation(api.soulStars.toggle)
|
||||
const addComment = useMutation(api.soulComments.add)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useConvexAuth, useMutation } from 'convex/react'
|
||||
import { useMutation } from 'convex/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
|
||||
export function UserBootstrap() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth()
|
||||
const { isAuthenticated, isLoading } = useAuthStatus()
|
||||
const ensureUser = useMutation(api.users.ensure)
|
||||
const didRun = useRef(false)
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from 'convex/react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
|
||||
export function useAuthStatus() {
|
||||
const me = useQuery(api.users.me)
|
||||
return {
|
||||
me,
|
||||
isLoading: me === undefined,
|
||||
isAuthenticated: Boolean(me),
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useAuthActions } from '@convex-dev/auth/react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useConvexAuth, useMutation, useQuery } from 'convex/react'
|
||||
import { useMutation } from 'convex/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../../../convex/_generated/api'
|
||||
import { useAuthStatus } from '../../lib/useAuthStatus'
|
||||
|
||||
export const Route = createFileRoute('/cli/auth')({
|
||||
component: CliAuth,
|
||||
})
|
||||
|
||||
function CliAuth() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth()
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus()
|
||||
const { signIn } = useAuthActions()
|
||||
const me = useQuery(api.users.me)
|
||||
const createToken = useMutation(api.tokens.create)
|
||||
|
||||
const search = Route.useSearch() as {
|
||||
|
||||
@@ -10,7 +10,10 @@ export const Route = createFileRoute('/dashboard')({
|
||||
|
||||
function Dashboard() {
|
||||
const me = useQuery(api.users.me)
|
||||
const mySkills = useQuery(api.skills.list, me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip')
|
||||
const mySkills = useQuery(
|
||||
api.skills.list,
|
||||
me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip',
|
||||
) as Doc<'skills'>[] | undefined
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
@@ -29,7 +32,7 @@ function Dashboard() {
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
My Skills
|
||||
</h1>
|
||||
<Link to="/upload" className="btn btn-primary">
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Upload New Skill
|
||||
</Link>
|
||||
@@ -40,7 +43,7 @@ function Dashboard() {
|
||||
<Package className="dashboard-empty-icon" aria-hidden="true" />
|
||||
<h2>No skills yet</h2>
|
||||
<p>Upload your first skill to share it with the community.</p>
|
||||
<Link to="/upload" className="btn btn-primary">
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Upload a Skill
|
||||
</Link>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useAction, useConvexAuth } from 'convex/react'
|
||||
import { useAction } from 'convex/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import { formatBytes } from '../lib/uploadUtils'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
|
||||
export const Route = createFileRoute('/import')({
|
||||
component: ImportGitHub,
|
||||
@@ -37,7 +38,7 @@ type CandidatePreview = {
|
||||
}
|
||||
|
||||
function ImportGitHub() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth()
|
||||
const { isAuthenticated, isLoading } = useAuthStatus()
|
||||
const previewImport = useAction(api.githubImport.previewGitHubImport)
|
||||
const previewCandidate = useAction(api.githubImport.previewGitHubImportCandidate)
|
||||
const importSkill = useAction(api.githubImport.importGitHubSkill)
|
||||
|
||||
+96
-214
@@ -11,7 +11,14 @@ import { getSiteMode } from '../lib/site'
|
||||
export const Route = createFileRoute('/')({
|
||||
validateSearch: (search) => ({
|
||||
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
|
||||
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
|
||||
highlighted:
|
||||
search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true
|
||||
? true
|
||||
: undefined,
|
||||
search:
|
||||
search.search === '1' || search.search === 'true' || search.search === true
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
component: Home,
|
||||
})
|
||||
@@ -22,75 +29,13 @@ function Home() {
|
||||
}
|
||||
|
||||
function SkillsHome() {
|
||||
const navigate = Route.useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const searchSkills = useAction(api.search.searchSkills)
|
||||
const highlighted =
|
||||
(useQuery(api.skills.list, { batch: 'highlighted', limit: 6 }) as Doc<'skills'>[]) ?? []
|
||||
const latest = (useQuery(api.skills.list, { limit: 12 }) as Doc<'skills'>[]) ?? []
|
||||
const [query, setQuery] = useState(search.q ?? '')
|
||||
const [highlightedOnly, setHighlightedOnly] = useState(search.highlighted ?? false)
|
||||
const [results, setResults] = useState<
|
||||
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
|
||||
>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchMode, setSearchMode] = useState(Boolean(search.q || search.highlighted))
|
||||
const searchRequest = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const trimmedQuery = useMemo(() => query.trim(), [query])
|
||||
const hasQuery = trimmedQuery.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? '')
|
||||
setHighlightedOnly(search.highlighted ?? false)
|
||||
if (search.q || search.highlighted) {
|
||||
setSearchMode(true)
|
||||
}
|
||||
}, [search.highlighted, search.q])
|
||||
|
||||
useEffect(() => {
|
||||
void navigate({
|
||||
search: () => ({
|
||||
q: trimmedQuery || undefined,
|
||||
highlighted: highlightedOnly ? true : undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
}, [highlightedOnly, navigate, trimmedQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trimmedQuery) {
|
||||
setResults([])
|
||||
setIsSearching(false)
|
||||
return
|
||||
}
|
||||
searchRequest.current += 1
|
||||
const requestId = searchRequest.current
|
||||
setIsSearching(true)
|
||||
const handle = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const data = (await searchSkills({ query: trimmedQuery, highlightedOnly })) as Array<{
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
}>
|
||||
if (requestId === searchRequest.current) {
|
||||
setResults(data)
|
||||
}
|
||||
} finally {
|
||||
if (requestId === searchRequest.current) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, 220)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [highlightedOnly, searchSkills, trimmedQuery])
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className={`hero${searchMode ? ' search-mode' : ''}`}>
|
||||
<section className="hero">
|
||||
<div className="hero-inner">
|
||||
<div className="hero-copy fade-up" data-delay="1">
|
||||
<span className="hero-badge">Lobster-light. Agent-right.</span>
|
||||
@@ -103,148 +48,6 @@ function SkillsHome() {
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish a skill
|
||||
</Link>
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchMode(true)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
Explore search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-card hero-search-card fade-up" data-delay="2">
|
||||
<form
|
||||
className="search-bar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!searchMode) setSearchMode(true)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<span className="mono">/</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="search-input"
|
||||
placeholder="Search skills, tags, or capabilities"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onFocus={() => setSearchMode(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !trimmedQuery) {
|
||||
setSearchMode(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="search-filter-button"
|
||||
type="button"
|
||||
aria-pressed={highlightedOnly}
|
||||
onClick={() => {
|
||||
setHighlightedOnly((value) => !value)
|
||||
setSearchMode(true)
|
||||
}}
|
||||
>
|
||||
Highlighted
|
||||
</button>
|
||||
</form>
|
||||
{!searchMode ? (
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="stat">Search skills. Versioned, rollback-ready.</div>
|
||||
<InstallSwitcher exampleSlug="sonoscli" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{searchMode ? (
|
||||
<section className="section">
|
||||
<h2 className="section-title">Search results</h2>
|
||||
<p className="section-subtitle">
|
||||
{isSearching ? 'Searching now.' : 'Instant results as you type.'}
|
||||
</p>
|
||||
<div className="grid">
|
||||
{!hasQuery ? (
|
||||
<div className="card">Start typing to search.</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="card">No results yet. Try a different prompt.</div>
|
||||
) : (
|
||||
results.map((result) => (
|
||||
<Link
|
||||
key={result.skill._id}
|
||||
to="/skills/$slug"
|
||||
params={{ slug: result.skill.slug }}
|
||||
className="card"
|
||||
>
|
||||
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
|
||||
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
{result.skill.displayName}
|
||||
</h3>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
{result.skill.summary ?? 'Skill pack'}
|
||||
</p>
|
||||
{result.skill.batch === 'highlighted' ? (
|
||||
<div className="tag">Highlighted</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="section">
|
||||
<h2 className="section-title">Highlighted batch</h2>
|
||||
<p className="section-subtitle">Curated signal — highlighted for quick trust.</p>
|
||||
<div className="grid">
|
||||
{highlighted.length === 0 ? (
|
||||
<div className="card">No highlighted skills yet.</div>
|
||||
) : (
|
||||
highlighted.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge="Highlighted"
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title">Latest drops</h2>
|
||||
<p className="section-subtitle">Newest uploads across the registry.</p>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<div className="card">No skills yet. Be the first.</div>
|
||||
) : (
|
||||
latest.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
{skill.stats.versions} versions · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="section-cta">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
@@ -256,12 +59,82 @@ function SkillsHome() {
|
||||
}}
|
||||
className="btn"
|
||||
>
|
||||
See all skills
|
||||
Browse skills
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="hero-card hero-search-card fade-up" data-delay="2">
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="stat">Search skills. Versioned, rollback-ready.</div>
|
||||
<InstallSwitcher exampleSlug="sonoscli" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title">Highlighted batch</h2>
|
||||
<p className="section-subtitle">Curated signal — highlighted for quick trust.</p>
|
||||
<div className="grid">
|
||||
{highlighted.length === 0 ? (
|
||||
<div className="card">No highlighted skills yet.</div>
|
||||
) : (
|
||||
highlighted.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge="Highlighted"
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title">Latest drops</h2>
|
||||
<p className="section-subtitle">Newest uploads across the registry.</p>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<div className="card">No skills yet. Be the first.</div>
|
||||
) : (
|
||||
latest.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
{skill.stats.versions} versions · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="section-cta">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}}
|
||||
className="btn"
|
||||
>
|
||||
See all skills
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -277,7 +150,7 @@ function OnlyCrabsHome() {
|
||||
Array<{ soul: Doc<'souls'>; version: Doc<'soulVersions'> | null; score: number }>
|
||||
>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchMode, setSearchMode] = useState(Boolean(search.q))
|
||||
const [searchMode, setSearchMode] = useState(Boolean(search.q || search.search))
|
||||
const searchRequest = useRef(0)
|
||||
const seedEnsuredRef = useRef(false)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -286,10 +159,12 @@ function OnlyCrabsHome() {
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? '')
|
||||
if (search.q) {
|
||||
if (search.q || search.search) {
|
||||
setSearchMode(true)
|
||||
} else {
|
||||
setSearchMode(false)
|
||||
}
|
||||
}, [search.q])
|
||||
}, [search.q, search.search])
|
||||
|
||||
useEffect(() => {
|
||||
if (seedEnsuredRef.current) return
|
||||
@@ -302,10 +177,17 @@ function OnlyCrabsHome() {
|
||||
search: () => ({
|
||||
q: trimmedQuery || undefined,
|
||||
highlighted: undefined,
|
||||
search: searchMode && !trimmedQuery ? true : undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
}, [navigate, trimmedQuery])
|
||||
}, [navigate, searchMode, trimmedQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchMode && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [searchMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trimmedQuery) {
|
||||
|
||||
+12
-20
@@ -1,29 +1,21 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/search')({
|
||||
validateSearch: (search) => ({
|
||||
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
|
||||
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
|
||||
}),
|
||||
component: SearchRedirect,
|
||||
})
|
||||
|
||||
function SearchRedirect() {
|
||||
const navigate = Route.useNavigate()
|
||||
const search = Route.useSearch()
|
||||
|
||||
useEffect(() => {
|
||||
void navigate({
|
||||
to: '/',
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: search.q || undefined,
|
||||
highlighted: search.highlighted ? true : undefined,
|
||||
}),
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
view: undefined,
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
}, [navigate, search.highlighted, search.q])
|
||||
|
||||
return null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
+11
-1
@@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Id } from '../../convex/_generated/dataModel'
|
||||
import { gravatarUrl } from '../lib/gravatar'
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
@@ -12,7 +13,16 @@ function Settings() {
|
||||
const me = useQuery(api.users.me)
|
||||
const updateProfile = useMutation(api.users.updateProfile)
|
||||
const deleteAccount = useMutation(api.users.deleteAccount)
|
||||
const tokens = useQuery(api.tokens.listMine)
|
||||
const tokens = useQuery(api.tokens.listMine) as
|
||||
| Array<{
|
||||
_id: Id<'apiTokens'>
|
||||
label: string
|
||||
prefix: string
|
||||
createdAt: number
|
||||
lastUsedAt?: number
|
||||
revokedAt?: number
|
||||
}>
|
||||
| undefined
|
||||
const createToken = useMutation(api.tokens.create)
|
||||
const revokeToken = useMutation(api.tokens.revoke)
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
|
||||
+54
-54
@@ -1,12 +1,13 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useAction, useQuery } from 'convex/react'
|
||||
import { useAction } from 'convex/react'
|
||||
import { usePaginatedQuery } from 'convex-helpers/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../../../convex/_generated/api'
|
||||
import type { Doc } from '../../../convex/_generated/dataModel'
|
||||
import { SkillCard } from '../../components/SkillCard'
|
||||
|
||||
const sortKeys = ['newest', 'downloads', 'installs', 'stars', 'name', 'updated'] as const
|
||||
const pageSize = 50
|
||||
const pageSize = 25
|
||||
type SortKey = (typeof sortKeys)[number]
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
@@ -21,13 +22,34 @@ function parseDir(value: unknown, sort: SortKey): SortDir {
|
||||
return sort === 'name' ? 'asc' : 'desc'
|
||||
}
|
||||
|
||||
type SkillListEntry = {
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
ownerHandle?: string | null
|
||||
}
|
||||
|
||||
type SkillSearchEntry = {
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
ownerHandle?: string | null
|
||||
}
|
||||
|
||||
function buildSkillHref(skill: Doc<'skills'>, ownerHandle?: string | null) {
|
||||
const owner = ownerHandle ?? 'unknown'
|
||||
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/skills/')({
|
||||
validateSearch: (search) => {
|
||||
return {
|
||||
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
|
||||
sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
|
||||
dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
|
||||
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
|
||||
highlighted:
|
||||
search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true
|
||||
? true
|
||||
: undefined,
|
||||
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
|
||||
}
|
||||
},
|
||||
@@ -43,14 +65,7 @@ export function SkillsIndex() {
|
||||
const highlightedOnly = search.highlighted ?? false
|
||||
const [query, setQuery] = useState(search.q ?? '')
|
||||
const searchSkills = useAction(api.search.searchSkills)
|
||||
const [pages, setPages] = useState<
|
||||
Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
|
||||
>([])
|
||||
const [cursor, setCursor] = useState<string | null>(null)
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
|
||||
>([])
|
||||
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([])
|
||||
const [searchLimit, setSearchLimit] = useState(pageSize)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const searchRequest = useRef(0)
|
||||
@@ -60,34 +75,25 @@ export function SkillsIndex() {
|
||||
const hasQuery = trimmedQuery.length > 0
|
||||
const searchKey = trimmedQuery ? `${trimmedQuery}::${highlightedOnly ? '1' : '0'}` : ''
|
||||
|
||||
const listPage = useQuery(
|
||||
api.skills.listPublicPage,
|
||||
hasQuery ? 'skip' : { cursor: cursor ?? undefined, limit: pageSize },
|
||||
) as
|
||||
| {
|
||||
items: Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
|
||||
nextCursor: string | null
|
||||
}
|
||||
| undefined
|
||||
const isLoadingList = !hasQuery && pages.length === 0 && listPage === undefined
|
||||
// Use convex-helpers usePaginatedQuery for better cache behavior
|
||||
const {
|
||||
results: paginatedResults,
|
||||
status: paginationStatus,
|
||||
loadMore: loadMorePaginated,
|
||||
} = usePaginatedQuery(api.skills.listPublicPageV2, hasQuery ? 'skip' : {}, {
|
||||
initialNumItems: pageSize,
|
||||
})
|
||||
|
||||
// Derive loading states from pagination status
|
||||
// status: 'LoadingFirstPage' | 'CanLoadMore' | 'LoadingMore' | 'Exhausted'
|
||||
const isLoadingList = paginationStatus === 'LoadingFirstPage'
|
||||
const canLoadMoreList = paginationStatus === 'CanLoadMore'
|
||||
const isLoadingMoreList = paginationStatus === 'LoadingMore'
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? '')
|
||||
}, [search.q])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasQuery) return
|
||||
setPages([])
|
||||
setCursor(null)
|
||||
setNextCursor(null)
|
||||
}, [hasQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasQuery || !listPage) return
|
||||
setNextCursor(listPage.nextCursor)
|
||||
setPages((prev) => (cursor ? [...prev, ...listPage.items] : listPage.items))
|
||||
}, [cursor, hasQuery, listPage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchKey) {
|
||||
setSearchResults([])
|
||||
@@ -110,11 +116,7 @@ export function SkillsIndex() {
|
||||
query: trimmedQuery,
|
||||
highlightedOnly,
|
||||
limit: searchLimit,
|
||||
})) as Array<{
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
}>
|
||||
})) as Array<SkillSearchEntry>
|
||||
if (requestId === searchRequest.current) {
|
||||
setSearchResults(data)
|
||||
}
|
||||
@@ -133,10 +135,12 @@ export function SkillsIndex() {
|
||||
return searchResults.map((entry) => ({
|
||||
skill: entry.skill,
|
||||
latestVersion: entry.version,
|
||||
ownerHandle: entry.ownerHandle ?? null,
|
||||
}))
|
||||
}
|
||||
return pages
|
||||
}, [hasQuery, pages, searchResults])
|
||||
// paginatedResults is an array of page items from usePaginatedQuery
|
||||
return paginatedResults as Array<SkillListEntry>
|
||||
}, [hasQuery, paginatedResults, searchResults])
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
@@ -175,20 +179,18 @@ export function SkillsIndex() {
|
||||
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList
|
||||
const canLoadMore = hasQuery
|
||||
? !isSearching && searchResults.length === searchLimit && searchResults.length > 0
|
||||
: nextCursor !== null
|
||||
const isLoadingMore = hasQuery
|
||||
? isSearching && searchResults.length > 0
|
||||
: listPage === undefined && pages.length > 0
|
||||
: canLoadMoreList
|
||||
const isLoadingMore = hasQuery ? isSearching && searchResults.length > 0 : isLoadingMoreList
|
||||
const canAutoLoad = typeof IntersectionObserver !== 'undefined'
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (isLoadingMore || !canLoadMore) return
|
||||
if (hasQuery) {
|
||||
setSearchLimit((value) => value + pageSize)
|
||||
} else if (nextCursor) {
|
||||
setCursor(nextCursor)
|
||||
} else {
|
||||
loadMorePaginated(pageSize)
|
||||
}
|
||||
}, [canLoadMore, hasQuery, isLoadingMore, nextCursor])
|
||||
}, [canLoadMore, hasQuery, isLoadingMore, loadMorePaginated])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canLoadMore || typeof IntersectionObserver === 'undefined') return
|
||||
@@ -322,10 +324,12 @@ export function SkillsIndex() {
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill
|
||||
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
|
||||
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
||||
return (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
href={skillHref}
|
||||
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
|
||||
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
@@ -344,13 +348,9 @@ export function SkillsIndex() {
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill
|
||||
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
|
||||
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
||||
return (
|
||||
<Link
|
||||
key={skill._id}
|
||||
className="skills-row"
|
||||
to="/skills/$slug"
|
||||
params={{ slug: skill.slug }}
|
||||
>
|
||||
<Link key={skill._id} className="skills-row" to={skillHref}>
|
||||
<div className="skills-row-main">
|
||||
<div className="skills-row-title">
|
||||
<span>{skill.displayName}</span>
|
||||
|
||||
+26
-7
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from 'convex/react'
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Doc } from '../../convex/_generated/dataModel'
|
||||
|
||||
@@ -14,6 +14,8 @@ function Stars() {
|
||||
| Doc<'skills'>[]
|
||||
| undefined) ?? []
|
||||
|
||||
const toggleStar = useMutation(api.stars.toggle)
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<main className="section">
|
||||
@@ -31,12 +33,29 @@ function Stars() {
|
||||
<div className="card">No stars yet.</div>
|
||||
) : (
|
||||
skills.map((skill) => (
|
||||
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
|
||||
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<div className="stat">⭐ {skill.stats.stars}</div>
|
||||
</Link>
|
||||
<div key={skill._id} className="card skill-card">
|
||||
<Link to="/skills/$slug" params={{ slug: skill.slug }}>
|
||||
<h3 className="skill-card-title">{skill.displayName}</h3>
|
||||
</Link>
|
||||
<div className="skill-card-footer skill-card-footer-inline">
|
||||
<span className="stat">⭐ {skill.stats.stars}</span>
|
||||
<button
|
||||
className="star-toggle is-active"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await toggleStar({ skillId: skill._id })
|
||||
} catch (error) {
|
||||
console.error('Failed to unstar skill:', error)
|
||||
window.alert('Unable to unstar this skill. Please try again.')
|
||||
}
|
||||
}}
|
||||
aria-label={`Unstar ${skill.displayName}`}
|
||||
>
|
||||
<span aria-hidden="true">★</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,10 @@ function UserProfile() {
|
||||
const { handle } = Route.useParams()
|
||||
const me = useQuery(api.users.me)
|
||||
const user = useQuery(api.users.getByHandle, { handle }) as Doc<'users'> | null | undefined
|
||||
const publishedSkills = useQuery(
|
||||
api.skills.list,
|
||||
user ? { ownerUserId: user._id, limit: 50 } : 'skip',
|
||||
) as Doc<'skills'>[] | undefined
|
||||
const starredSkills = useQuery(
|
||||
api.stars.listByUser,
|
||||
user ? { userId: user._id, limit: 50 } : 'skip',
|
||||
@@ -54,6 +58,8 @@ function UserProfile() {
|
||||
const initial = displayName.charAt(0).toUpperCase()
|
||||
const isLoadingSkills = starredSkills === undefined
|
||||
const skills = starredSkills ?? []
|
||||
const isLoadingPublished = publishedSkills === undefined
|
||||
const published = publishedSkills ?? []
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
@@ -98,6 +104,34 @@ function UserProfile() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="section-title" style={{ fontSize: '1.3rem' }}>
|
||||
Published
|
||||
</h2>
|
||||
<p className="section-subtitle">Skills published by this user.</p>
|
||||
|
||||
{isLoadingPublished ? (
|
||||
<div className="card">
|
||||
<div className="loading-indicator">Loading published skills…</div>
|
||||
</div>
|
||||
) : published.length > 0 ? (
|
||||
<div className="grid" style={{ marginBottom: 18 }}>
|
||||
{published.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h2 className="section-title" style={{ fontSize: '1.3rem' }}>
|
||||
Stars
|
||||
</h2>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
|
||||
import { useAction, useMutation, useQuery } from 'convex/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import semver from 'semver'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import { getSiteMode } from '../lib/site'
|
||||
import { expandFiles } from '../lib/uploadFiles'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
import {
|
||||
formatBytes,
|
||||
formatPublishError,
|
||||
@@ -24,7 +25,7 @@ export const Route = createFileRoute('/upload')({
|
||||
})
|
||||
|
||||
export function Upload() {
|
||||
const { isAuthenticated } = useConvexAuth()
|
||||
const { isAuthenticated } = useAuthStatus()
|
||||
const { updateSlug } = useSearch({ from: '/upload' })
|
||||
const siteMode = getSiteMode()
|
||||
const isSoulMode = siteMode === 'souls'
|
||||
|
||||
@@ -1002,6 +1002,13 @@ code {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.skill-card-footer-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-install {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
+20
-13
@@ -1,4 +1,5 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { devtools } from '@tanstack/devtools-vite'
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
@@ -8,25 +9,31 @@ import { defineConfig } from 'vite'
|
||||
import viteTsConfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const resvgWasmPath = require.resolve('@resvg/resvg-wasm/index_bg.wasm')
|
||||
const bricolageBoldPath = require.resolve(
|
||||
'@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2',
|
||||
)
|
||||
const bricolageTextPath = require.resolve(
|
||||
'@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2',
|
||||
)
|
||||
const plexMonoPath = require.resolve(
|
||||
'@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2',
|
||||
)
|
||||
|
||||
const convexEntry = require.resolve('convex')
|
||||
const convexRoot = dirname(dirname(dirname(convexEntry)))
|
||||
const convexReactPath = join(convexRoot, 'dist/esm/react/index.js')
|
||||
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')
|
||||
|
||||
const config = defineConfig({
|
||||
resolve: {
|
||||
dedupe: ['convex', '@convex-dev/auth', 'react', 'react-dom'],
|
||||
alias: {
|
||||
'convex/react': convexReactPath,
|
||||
'convex/browser': convexBrowserPath,
|
||||
'convex/values': convexValuesPath,
|
||||
'@convex-dev/auth/react': convexAuthReactPath,
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['convex/react', 'convex/browser'],
|
||||
},
|
||||
plugins: [
|
||||
devtools(),
|
||||
nitro({
|
||||
serverDir: 'server',
|
||||
externals: {
|
||||
traceInclude: [resvgWasmPath, bricolageBoldPath, bricolageTextPath, plexMonoPath],
|
||||
},
|
||||
}),
|
||||
// this is the plugin that enables path aliases
|
||||
viteTsConfigPaths({
|
||||
|
||||
Reference in New Issue
Block a user