Compare commits

..
Author SHA1 Message Date
Peter Steinberger ca70633b92 fix: harden explore limit + tests/docs (#14) (thanks @jdrhyne) 2026-01-18 16:25:18 +00:00
Jonathan Rhyne b8f2e4d7bf feat(cli): add explore command to browse latest updated skills
Adds a new `clawdhub explore` command that fetches the most recently
updated skills from the registry, sorted by updatedAt descending.

Usage:
  clawdhub explore           # Show latest 25 skills
  clawdhub explore --limit 10

Output includes slug, version, relative time since update, and summary.

The API endpoint already exists and returns skills sorted by updatedAt,
this just exposes it via the CLI.
2026-01-18 16:21:14 +00:00
73 changed files with 613 additions and 2108 deletions
+2 -3
View File
@@ -15,12 +15,10 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.6
bun-version: 1.3.5
- name: Install
run: bun install --frozen-lockfile
- name: Peer deps
run: bun run check:peers
- name: Lint
run: bun run lint
@@ -38,3 +36,4 @@ jobs:
- name: Build
run: bun run build
+1 -22
View File
@@ -1,32 +1,11 @@
# 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
- Web: dynamic OG image cards for skills (name, description, version).
- CLI: auto-scan Clawdbot skill roots (per-agent workspaces, shared skills, extraDirs).
- CLI: add `explore` command for latest updates, with limit clamping + tests (thanks @jdrhyne, #14).
- Web: import skills from public GitHub URLs (auto-detect `SKILL.md`, smart file selection, provenance).
- Web/API: SoulHub (SOUL.md registry) with v1 endpoints and first-run auto-seed.
+34 -28
View File
@@ -1,11 +1,10 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "clawdhub",
"dependencies": {
"@auth/core": "^0.37.4",
"@auth/core": "^0.34.3",
"@convex-dev/auth": "^0.0.90",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
@@ -16,10 +15,10 @@
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-router": "^1.151.6",
"@tanstack/react-router-devtools": "^1.151.6",
"@tanstack/react-start": "^1.152.0",
"@tanstack/router-plugin": "^1.151.6",
"@tanstack/react-router": "^1.151.3",
"@tanstack/react-router-devtools": "^1.151.3",
"@tanstack/react-start": "^1.151.5",
"@tanstack/router-plugin": "^1.151.5",
"@vercel/analytics": "^1.6.1",
"clawdhub-schema": "workspace:*",
"clsx": "^2.1.1",
@@ -61,7 +60,7 @@
},
"packages/clawdhub": {
"name": "clawdhub",
"version": "0.3.0",
"version": "0.2.0",
"bin": {
"clawdhub": "bin/clawdhub.js",
},
@@ -76,7 +75,6 @@
"ora": "^9.0.0",
"p-retry": "^7.1.1",
"semver": "^7.7.3",
"undici": "^7.16.0",
},
"devDependencies": {
"@types/node": "^25.0.9",
@@ -87,10 +85,10 @@
"name": "clawdhub-schema",
"version": "0.0.2",
"dependencies": {
"arktype": "^2.1.29",
"arktype": "latest",
},
"devDependencies": {
"typescript": "^5.9.3",
"typescript": "latest",
},
},
},
@@ -107,7 +105,7 @@
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@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=="],
"@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=="],
"@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=="],
@@ -553,37 +551,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.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": ["@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-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-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-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": ["@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-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-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-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-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-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.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-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-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-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-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-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-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-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-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.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-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-fn-stubs": ["@tanstack/start-fn-stubs@1.151.3", "", {}, "sha512-/zWBnfsOwact936Bn0CxigudU1QRZdiNTsK7ME/LMXXA66XsDxkryX5+5FeGwU5ETNPfLAx6pRUet1mtUKnLCg=="],
"@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-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-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-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-storage-context": ["@tanstack/start-storage-context@1.151.6", "", { "dependencies": { "@tanstack/router-core": "1.151.6" } }, "sha512-MvTcT40qnqatIpKjWSfMRxFzTkprGBxhX2c+em58iZLEsGksitMUWbprknD6AIUqjHty8V3LuhULks/o6tSugQ=="],
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.151.3", "", { "dependencies": { "@tanstack/router-core": "1.151.3" } }, "sha512-3gUiBFfcV/OYbdwex6Dl4BWY2oTHy46fDF9CzOzTpujDcz3hI7pqWs68sxatChdyoQVMZR2Xf8os4VRLMgF3ig=="],
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
@@ -607,6 +605,8 @@
"@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 +741,7 @@
"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@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
@@ -1075,7 +1075,7 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
"oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
@@ -1119,9 +1119,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.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="],
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"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=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
@@ -1345,6 +1345,10 @@
"@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=="],
@@ -1381,6 +1385,8 @@
"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=="],
-8
View File
@@ -21,7 +21,6 @@ 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";
@@ -29,11 +28,9 @@ 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";
@@ -50,7 +47,6 @@ 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";
@@ -77,7 +73,6 @@ 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;
@@ -85,11 +80,9 @@ 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;
@@ -106,7 +99,6 @@ declare const fullApi: ApiFromModules<{
soulStars: typeof soulStars;
souls: typeof souls;
stars: typeof stars;
statsMaintenance: typeof statsMaintenance;
telemetry: typeof telemetry;
tokens: typeof tokens;
uploads: typeof uploads;
-76
View File
@@ -1,76 +0,0 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('./lib/access', () => ({
assertRole: vi.fn(),
requireUser: vi.fn(),
}))
const { requireUser } = await import('./lib/access')
const { __test } = await import('./comments')
describe('comments mutations', () => {
afterEach(() => {
vi.mocked(requireUser).mockReset()
})
it('add updates comment count without touching updatedAt', async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: 'users:1',
user: { _id: 'users:1', role: 'user' },
} as never)
const get = vi.fn().mockResolvedValue({
_id: 'skills:1',
stats: { comments: 2 },
})
const insert = vi.fn()
const patch = vi.fn()
const ctx = { db: { get, insert, patch } } as never
await __test.addHandler(ctx, { skillId: 'skills:1', body: ' hello ' } as never)
expect(patch).toHaveBeenCalledTimes(1)
expect(patch).toHaveBeenCalledWith('skills:1', {
stats: { comments: 3 },
})
const skillPatch = vi.mocked(patch).mock.calls[0]?.[1] as Record<string, unknown>
expect(skillPatch.updatedAt).toBeUndefined()
})
it('remove updates comment count without touching updatedAt', async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: 'users:2',
user: { _id: 'users:2', role: 'moderator' },
} as never)
const comment = {
_id: 'comments:1',
skillId: 'skills:1',
userId: 'users:2',
softDeletedAt: undefined,
}
const skill = {
_id: 'skills:1',
stats: { comments: 4 },
}
const get = vi.fn(async (id: string) => {
if (id === 'comments:1') return comment
if (id === 'skills:1') return skill
return null
})
const insert = vi.fn()
const patch = vi.fn()
const ctx = { db: { get, insert, patch } } as never
await __test.removeHandler(ctx, { commentId: 'comments:1' } as never)
expect(patch).toHaveBeenCalledTimes(2)
expect(patch).toHaveBeenNthCalledWith(2, 'skills:1', {
stats: { comments: 3 },
})
const skillPatch = vi.mocked(patch).mock.calls[1]?.[1] as Record<string, unknown>
expect(skillPatch.updatedAt).toBeUndefined()
})
})
+55 -68
View File
@@ -3,67 +3,6 @@ import type { Doc } from './_generated/dataModel'
import { mutation, query } from './_generated/server'
import { assertRole, requireUser } from './lib/access'
async function addHandler(
ctx: import('./_generated/server').MutationCtx,
args: { skillId: import('./_generated/dataModel').Id<'skills'>; body: string },
) {
const { userId } = await requireUser(ctx)
const body = args.body.trim()
if (!body) throw new Error('Comment body required')
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await ctx.db.insert('comments', {
skillId: args.skillId,
userId,
body,
createdAt: Date.now(),
softDeletedAt: undefined,
deletedBy: undefined,
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
})
}
async function removeHandler(
ctx: import('./_generated/server').MutationCtx,
args: { commentId: import('./_generated/dataModel').Id<'comments'> },
) {
const { user } = await requireUser(ctx)
const comment = await ctx.db.get(args.commentId)
if (!comment) throw new Error('Comment not found')
if (comment.softDeletedAt) return
const isOwner = comment.userId === user._id
if (!isOwner) {
assertRole(user, ['admin', 'moderator'])
}
await ctx.db.patch(comment._id, {
softDeletedAt: Date.now(),
deletedBy: user._id,
})
const skill = await ctx.db.get(comment.skillId)
if (skill) {
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
})
}
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'comment.delete',
targetType: 'comment',
targetId: comment._id,
metadata: { skillId: comment.skillId },
createdAt: Date.now(),
})
}
export const listBySkill = query({
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
@@ -86,15 +25,63 @@ export const listBySkill = query({
export const add = mutation({
args: { skillId: v.id('skills'), body: v.string() },
handler: addHandler,
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const body = args.body.trim()
if (!body) throw new Error('Comment body required')
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await ctx.db.insert('comments', {
skillId: args.skillId,
userId,
body,
createdAt: Date.now(),
softDeletedAt: undefined,
deletedBy: undefined,
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
updatedAt: Date.now(),
})
},
})
export const remove = mutation({
args: { commentId: v.id('comments') },
handler: removeHandler,
})
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
const comment = await ctx.db.get(args.commentId)
if (!comment) throw new Error('Comment not found')
if (comment.softDeletedAt) return
export const __test = {
addHandler,
removeHandler,
}
const isOwner = comment.userId === user._id
if (!isOwner) {
assertRole(user, ['admin', 'moderator'])
}
await ctx.db.patch(comment._id, {
softDeletedAt: Date.now(),
deletedBy: user._id,
})
const skill = await ctx.db.get(comment.skillId)
if (skill) {
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
updatedAt: Date.now(),
})
}
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'comment.delete',
targetType: 'comment',
targetId: comment._id,
metadata: { skillId: comment.skillId },
createdAt: Date.now(),
})
},
})
-14
View File
@@ -10,18 +10,4 @@ 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 },
)
export default crons
+41 -71
View File
@@ -1,6 +1,5 @@
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'
@@ -14,17 +13,6 @@ 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',
@@ -249,19 +237,53 @@ function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
)}${rawSkillMd.slice(frontmatterEnd)}`
}
async function seedNixSkillsHandler(
ctx: ActionCtx,
args: SeedActionArgs,
): Promise<SeedActionResult> {
const results: Array<Record<string, unknown> & { slug: string }> = []
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')
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: SeedMutationResult = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
return ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
@@ -273,51 +295,7 @@ async function seedNixSkillsHandler(
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({
@@ -386,10 +364,6 @@ export const seedSkillMutation = internalMutation({
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: {
downloads: 0,
installsCurrent: 0,
@@ -439,10 +413,6 @@ 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,
+2 -6
View File
@@ -2,7 +2,6 @@ import { v } from 'convex/values'
import { zipSync } from 'fflate'
import { api } from './_generated/api'
import { httpAction, mutation } from './_generated/server'
import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
export const downloadZip = httpAction(async (ctx, request) => {
const url = new URL(request.url)
@@ -70,12 +69,9 @@ export const increment = mutation({
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId)
if (!skill) return
const now = Date.now()
const patch = applySkillStatDeltas(skill, { downloads: 1 })
await ctx.db.patch(skill._id, {
...patch,
updatedAt: now,
stats: { ...skill.stats, downloads: skill.stats.downloads + 1 },
updatedAt: Date.now(),
})
await bumpDailySkillStats(ctx, { skillId: skill._id, now, downloads: 1 })
},
})
-14
View File
@@ -26,8 +26,6 @@ import {
soulsDeleteRouterV1Http,
soulsGetRouterV1Http,
soulsPostRouterV1Http,
starsDeleteRouterV1Http,
starsPostRouterV1Http,
whoamiV1Http,
} from './httpApiV1'
@@ -83,18 +81,6 @@ 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',
+7 -22
View File
@@ -11,7 +11,7 @@ vi.mock('./skills', () => ({
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
const { publishVersionForUser } = await import('./skills')
const { __handlers } = await import('./httpApi')
const { __handlers, cliSkillDeleteHttp, cliSkillUndeleteHttp } = 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 (approvedOnly alias)', async () => {
it('searchSkillsHttp forwards args', async () => {
const runAction = vi.fn().mockResolvedValue([
{
score: 1,
@@ -48,27 +48,14 @@ describe('httpApi handlers', () => {
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: 5,
highlightedOnly: true,
approvedOnly: true,
})
expect(response.status).toBe(200)
const json = await response.json()
expect(json.results[0].slug).toBe('a')
})
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 () => {
it('searchSkillsHttp omits approvedOnly when false', async () => {
const runAction = vi.fn().mockResolvedValue([])
await __handlers.searchSkillsHandler(
makeCtx({ runAction }),
@@ -77,7 +64,7 @@ describe('httpApi handlers', () => {
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: undefined,
highlightedOnly: undefined,
approvedOnly: undefined,
})
})
@@ -429,14 +416,13 @@ 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 __handlers.cliSkillDeleteHandler(
const response = await cliSkillUndeleteHttp(
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(), {
@@ -451,14 +437,13 @@ 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 __handlers.cliSkillDeleteHandler(
const response = await cliSkillDeleteHttp(
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(), {
+1 -2
View File
@@ -44,14 +44,13 @@ 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,
highlightedOnly: highlightedOnly || undefined,
approvedOnly: approvedOnly || undefined,
})) as SearchSkillEntry[]
return json({
-83
View File
@@ -158,31 +158,6 @@ 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())
@@ -523,62 +498,4 @@ 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)
})
})
+5 -90
View File
@@ -44,9 +44,6 @@ type ListSkillsResult = {
nextCursor: string | null
}
type SkillFile = Doc<'skillVersions'>['files'][number]
type SoulFile = Doc<'soulVersions'>['files'][number]
type GetBySlugResult = {
skill: {
_id: Id<'skills'>
@@ -194,14 +191,11 @@ async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
const sort = parseListSort(url.searchParams.get('sort'))
const cursor = sort === 'updated' ? rawCursor : undefined
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const result = (await ctx.runQuery(api.skills.listPublicPage, {
limit,
cursor,
sort,
})) as ListSkillsResult
const items = await Promise.all(
@@ -321,7 +315,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file: SkillFile) => ({
files: version.files.map((file) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
@@ -759,36 +753,9 @@ 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 data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
const digest = await crypto.subtle.digest('SHA-256', buffer)
return toHex(new Uint8Array(digest))
}
@@ -928,7 +895,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file: SoulFile) => ({
files: version.files.map((file) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
@@ -1080,56 +1047,6 @@ 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,
@@ -1143,7 +1060,5 @@ export const __handlers = {
publishSoulV1Handler,
soulsPostRouterV1Handler,
soulsDeleteRouterV1Handler,
starsPostRouterV1Handler,
starsDeleteRouterV1Handler,
whoamiV1Handler,
}
-39
View File
@@ -1,39 +0,0 @@
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)
}
-103
View File
@@ -1,103 +0,0 @@
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
}
}
+2 -16
View File
@@ -13,26 +13,12 @@ describe('searchText', () => {
])
})
it('matchesExactTokens requires at least one query token to prefix-match', () => {
it('matchesExactTokens requires all query tokens', () => {
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(
true,
false,
)
// 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', () => {
+2 -4
View File
@@ -18,10 +18,8 @@ export function matchesExactTokens(
if (!text) return false
const textTokens = tokenize(text)
if (textTokens.length === 0) return false
// Require at least one token to prefix-match, allowing vector similarity to determine relevance
return queryTokens.some((queryToken) =>
textTokens.some((textToken) => textToken.includes(queryToken)),
)
const textSet = new Set(textTokens)
return queryTokens.every((token) => textSet.has(token))
}
export const __test = { normalize, tokenize, matchesExactTokens }
+9 -13
View File
@@ -75,20 +75,16 @@ export async function publishVersionForUser(
if (sanitizedFiles.some((file) => !file.path)) {
throw new ConvexError('Invalid file paths')
}
const safeFiles = sanitizedFiles.map((file) => ({
...file,
path: file.path as string,
}))
if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
if (sanitizedFiles.some((file) => !isTextFile(file.path ?? '', file.contentType ?? undefined))) {
throw new ConvexError('Only text-based files are allowed')
}
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
if (totalBytes > MAX_TOTAL_BYTES) {
throw new ConvexError('Skill bundle exceeds 50MB limit')
}
const readmeFile = safeFiles.find(
const readmeFile = sanitizedFiles.find(
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
)
if (!readmeFile) throw new ConvexError('SKILL.md is required')
@@ -99,7 +95,7 @@ export async function publishVersionForUser(
const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
const otherFiles = [] as Array<{ path: string; content: string }>
for (const file of safeFiles) {
for (const file of sanitizedFiles) {
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
const content = await fetchText(ctx, file.storageId)
@@ -114,7 +110,7 @@ export async function publishVersionForUser(
})
const fingerprintPromise = hashSkillFiles(
safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
)
const changelogPromise =
@@ -124,7 +120,7 @@ export async function publishVersionForUser(
slug,
version,
readmeText,
files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
})
const embeddingPromise = generateEmbedding(embeddingText)
@@ -152,9 +148,9 @@ export async function publishVersionForUser(
version: args.forkOf.version?.trim() || undefined,
}
: undefined,
files: safeFiles.map((file) => ({
files: sanitizedFiles.map((file) => ({
...file,
path: file.path,
path: file.path ?? '',
})),
parsed: {
frontmatter,
@@ -173,7 +169,7 @@ export async function publishVersionForUser(
version,
displayName,
ownerHandle,
files: safeFiles,
files: sanitizedFiles,
publishedAt: Date.now(),
})
.catch((error) => {
-80
View File
@@ -1,80 +0,0 @@
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,
})
}
-43
View File
@@ -49,10 +49,6 @@ 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()),
@@ -67,10 +63,6 @@ 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'])
const souls = defineTable({
@@ -185,38 +177,6 @@ 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 soulEmbeddings = defineTable({
soulId: v.id('souls'),
versionId: v.id('soulVersions'),
@@ -363,9 +323,6 @@ export default defineSchema({
soulVersionFingerprints,
skillEmbeddings,
soulEmbeddings,
skillDailyStats,
skillLeaderboards,
skillStatBackfillState,
comments,
soulComments,
stars,
+15 -45
View File
@@ -9,7 +9,6 @@ type HydratedEntry = {
embeddingId: Id<'skillEmbeddings'>
skill: Doc<'skills'> | null
version: Doc<'skillVersions'> | null
ownerHandle: string | null
}
type SearchResult = HydratedEntry & { score: number }
@@ -30,17 +29,10 @@ export const searchSkills: ReturnType<typeof action> = action({
if (!query) return []
const queryTokens = tokenize(query)
if (queryTokens.length === 0) return []
let vector: number[]
try {
vector = await generateEmbedding(query)
} catch (error) {
console.warn('Search embedding generation failed', error)
return []
}
const vector = await generateEmbedding(query)
const limit = args.limit ?? 10
// 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)
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
let hydrated: HydratedEntry[] = []
let scoreById = new Map<Id<'skillEmbeddings'>, number>()
let exactMatches: HydratedEntry[] = []
@@ -94,33 +86,18 @@ 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 ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
const entries: HydratedEntry[] = []
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
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 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)
return entries
},
})
@@ -142,17 +119,10 @@ export const searchSouls: ReturnType<typeof action> = action({
if (!query) return []
const queryTokens = tokenize(query)
if (queryTokens.length === 0) return []
let vector: number[]
try {
vector = await generateEmbedding(query)
} catch (error) {
console.warn('Search embedding generation failed', error)
return []
}
const vector = await generateEmbedding(query)
const limit = args.limit ?? 10
// 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)
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
let hydrated: HydratedSoulEntry[] = []
let scoreById = new Map<Id<'soulEmbeddings'>, number>()
let exactMatches: HydratedSoulEntry[] = []
+1 -2
View File
@@ -242,8 +242,7 @@ export const ensureSeedUserInternal = internalMutation({
})
async function sha256Hex(bytes: Uint8Array) {
const data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
const digest = await crypto.subtle.digest('SHA-256', bytes)
return toHex(new Uint8Array(digest))
}
+18 -127
View File
@@ -1,11 +1,10 @@
import { ConvexError, v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { MutationCtx, QueryCtx } from './_generated/server'
import type { MutationCtx } 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, getTrendingRange } from './lib/leaderboards'
import {
fetchText,
type PublishResult,
@@ -21,43 +20,9 @@ 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) => {
@@ -165,10 +130,9 @@ 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', ownerUserId))
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
.order('desc')
.take(takeLimit)
} else {
@@ -190,99 +154,30 @@ 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 sort = args.sort ?? 'updated'
const limit = clampInt(args.limit ?? 24, 1, MAX_PUBLIC_LIST_LIMIT)
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 })
const skills = page.filter((skill) => !skill.softDeletedAt)
const items = await buildPublicSkillEntries(ctx, skills)
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
const limit = clampInt(args.limit ?? 24, 1, MAX_LIST_LIMIT)
const { page, isDone, continueCursor } = await ctx.db
.query('skills')
.withIndex(index, (q) => q)
.withIndex('by_updated', (q) => q)
.order('desc')
.take(Math.min(limit * 5, MAX_LIST_TAKE))
.paginate({ cursor: args.cursor ?? null, numItems: limit })
const filtered = page.filter((skill) => !skill.softDeletedAt).slice(0, limit)
const items = await buildPublicSkillEntries(ctx, filtered)
return { items, nextCursor: null }
const items: Array<{
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
}> = []
for (const skill of page) {
if (skill.softDeletedAt) continue
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
items.push({ skill, latestVersion })
}
return { items, nextCursor: isDone ? null : continueCursor }
},
})
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) {
const now = Date.now()
const { startDay, endDay } = getTrendingRange(now)
const latest = await ctx.db
.query('skillLeaderboards')
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
.order('desc')
.take(1)
const leaderboard = latest[0]
if (leaderboard && leaderboard.rangeStartDay === startDay && leaderboard.rangeEndDay === endDay) {
return leaderboard.items.slice(0, limit)
}
const fallback = await buildTrendingLeaderboard(ctx, { limit, now })
return fallback.items
}
export const listVersions = query({
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
@@ -692,10 +587,6 @@ 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
View File
@@ -377,7 +377,7 @@ export const insertVersion = internalMutation({
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.order('desc')
.take(2)
let soul: Doc<'souls'> | null = soulMatches[0] ?? null
let soul = soulMatches[0] ?? null
if (soul && soul.ownerUserId !== userId) {
throw new Error('Only the owner can publish updates')
+3 -52
View File
@@ -1,8 +1,7 @@
import { v } from 'convex/values'
import type { Doc } from './_generated/dataModel'
import { internalMutation, mutation, query } from './_generated/server'
import { mutation, query } from './_generated/server'
import { requireUser } from './lib/access'
import { applySkillStatDeltas } from './lib/skillStats'
export const isStarred = query({
args: { skillId: v.id('skills') },
@@ -30,9 +29,8 @@ export const toggle = mutation({
if (existing) {
await ctx.db.delete(existing._id)
const patch = applySkillStatDeltas(skill, { stars: -1 })
await ctx.db.patch(skill._id, {
...patch,
stats: { ...skill.stats, stars: Math.max(0, skill.stats.stars - 1) },
updatedAt: Date.now(),
})
return { starred: false }
@@ -45,7 +43,7 @@ export const toggle = mutation({
})
await ctx.db.patch(skill._id, {
...applySkillStatDeltas(skill, { stars: 1 }),
stats: { ...skill.stats, stars: skill.stats.stars + 1 },
updatedAt: Date.now(),
})
@@ -70,50 +68,3 @@ 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 ctx.db.patch(skill._id, {
...applySkillStatDeltas(skill, { stars: 1 }),
updatedAt: Date.now(),
})
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 ctx.db.patch(skill._id, {
...applySkillStatDeltas(skill, { stars: -1 }),
updatedAt: Date.now(),
})
return { ok: true as const, unstarred: true, alreadyUnstarred: false }
},
})
-205
View File
@@ -1,205 +0,0 @@
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)
}
+30 -21
View File
@@ -4,7 +4,6 @@ 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 { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
const TELEMETRY_STALE_MS = 120 * 24 * 60 * 60 * 1000
@@ -158,12 +157,23 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<'use
await ctx.db.delete(entry._id)
continue
}
const patch = applySkillStatDeltas(skill, {
installsCurrent: entry.activeRoots > 0 ? -1 : 0,
installsAllTime: -1,
})
const stats = skill.stats as {
downloads: number
installsCurrent?: number
installsAllTime?: number
stars: number
versions: number
comments: number
}
await ctx.db.patch(skill._id, {
...patch,
stats: {
...stats,
installsCurrent: Math.max(
0,
(stats.installsCurrent ?? 0) - (entry.activeRoots > 0 ? 1 : 0),
),
installsAllTime: Math.max(0, (stats.installsAllTime ?? 0) - 1),
},
updatedAt: Date.now(),
})
await ctx.db.delete(entry._id)
@@ -373,24 +383,23 @@ async function bumpSkillInstallCounts(
) {
const skill = await ctx.db.get(params.skillId)
if (!skill) return
const now = Date.now()
const patch = applySkillStatDeltas(skill, {
installsAllTime: params.deltaAllTime,
installsCurrent: params.deltaCurrent,
})
const stats = skill.stats as {
downloads: number
installsCurrent?: number
installsAllTime?: number
stars: number
versions: number
comments: number
}
await ctx.db.patch(skill._id, {
...patch,
updatedAt: now,
stats: {
...stats,
installsAllTime: Math.max(0, (stats.installsAllTime ?? 0) + params.deltaAllTime),
installsCurrent: Math.max(0, (stats.installsCurrent ?? 0) + params.deltaCurrent),
},
updatedAt: Date.now(),
})
if (params.deltaAllTime > 0) {
await bumpDailySkillStats(ctx, {
skillId: params.skillId,
now,
installs: params.deltaAllTime,
})
}
}
async function expireStaleRoots(
+1 -2
View File
@@ -30,8 +30,7 @@ Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Ret
Public read:
- `GET /api/v1/search?q=...`
- `GET /api/v1/skills?limit=&cursor=&sort=`
- `sort`: `updated` (default), `downloads`, `stars` (`rating`), `installsCurrent` (`installs`), `installsAllTime`, `trending`
- `GET /api/v1/skills?limit=&cursor=`
- `GET /api/v1/skills/{slug}`
- `GET /api/v1/skills/{slug}/versions?limit=&cursor=`
- `GET /api/v1/skills/{slug}/versions/{version}`
+1 -9
View File
@@ -47,12 +47,6 @@ 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=...`.
@@ -61,9 +55,7 @@ Stores your API token + cached registry URL.
- Lists latest updated skills via `/api/v1/skills?limit=...` (sorted by `updatedAt` desc).
- Flags:
- `--limit <n>` (1200, default: 25)
- `--sort newest|downloads|rating|installs|installsAllTime|trending` (default: newest)
- `--json` (machine-readable output)
- `--limit <n>` (150, default: 25)
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
### `install <slug>`
+2 -21
View File
@@ -44,13 +44,8 @@ Response:
Query params:
- `limit` (optional): integer (1200)
- `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).
- `limit` (optional): integer
- `cursor` (optional): pagination cursor
Response:
@@ -145,20 +140,6 @@ 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:
+5 -6
View File
@@ -10,7 +10,6 @@
"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",
@@ -24,7 +23,7 @@
"format": "biome format --write ."
},
"dependencies": {
"@auth/core": "^0.37.4",
"@auth/core": "^0.34.3",
"@convex-dev/auth": "^0.0.90",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
@@ -35,10 +34,10 @@
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-router": "^1.151.6",
"@tanstack/react-router-devtools": "^1.151.6",
"@tanstack/react-start": "^1.152.0",
"@tanstack/router-plugin": "^1.151.6",
"@tanstack/react-router": "^1.151.3",
"@tanstack/react-router-devtools": "^1.151.3",
"@tanstack/react-start": "^1.151.5",
"@tanstack/router-plugin": "^1.151.5",
"@vercel/analytics": "^1.6.1",
"clawdhub-schema": "workspace:*",
"clsx": "^2.1.1",
+3 -4
View File
@@ -1,7 +1,7 @@
{
"name": "clawdhub",
"version": "0.3.0",
"description": "ClawdHub CLI \\u2014 install, update, search, and publish agent skills.",
"version": "0.2.0",
"description": "ClawdHub CLI \u2014 install, update, search, and publish agent skills.",
"license": "MIT",
"type": "module",
"bin": {
@@ -28,8 +28,7 @@
"mime": "^4.1.0",
"ora": "^9.0.0",
"p-retry": "^7.1.1",
"semver": "^7.7.3",
"undici": "^7.16.0"
"semver": "^7.7.3"
},
"devDependencies": {
"@types/node": "^25.0.9",
+2 -30
View File
@@ -8,9 +8,7 @@ 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 { 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'
@@ -190,21 +188,15 @@ program
.description('Browse latest updated skills from the registry')
.option(
'--limit <n>',
'Number of skills to show (max 200)',
'Number of skills to show (max 50)',
(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 })
await cmdExplore(opts, limit)
})
program
@@ -242,26 +234,6 @@ 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')
@@ -1,14 +1,11 @@
/* @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')
@@ -16,51 +13,13 @@ vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const mockSpinner = {
stop: vi.fn(),
fail: vi.fn(),
start: vi.fn(),
succeed: vi.fn(),
isSpinning: false,
text: '',
}
const mockSpinner = { stop: vi.fn(), fail: vi.fn() }
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 { clampLimit, cmdExplore, formatExploreLine } = await import('./skills')
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -84,9 +43,7 @@ describe('explore helpers', () => {
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(99)).toBe(50)
expect(clampLimit(Number.NaN)).toBe(25)
expect(clampLimit(Number.POSITIVE_INFINITY)).toBe(25)
expect(clampLimit(Number.NaN, 10)).toBe(10)
@@ -111,7 +68,7 @@ describe('cmdExplore', () => {
it('clamps limit and handles empty results', async () => {
mockApiRequest.mockResolvedValue({ items: [] })
await cmdExplore(makeOpts(), { limit: 0 })
await cmdExplore(makeOpts(), 0)
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
@@ -130,62 +87,12 @@ describe('cmdExplore', () => {
}
mockApiRequest.mockResolvedValue({ items: [item] })
await cmdExplore(makeOpts(), { limit: 250 })
await cmdExplore(makeOpts(), 100)
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
expect(url.searchParams.get('limit')).toBe('200')
expect(url.searchParams.get('limit')).toBe('50')
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()
})
})
+4 -53
View File
@@ -153,7 +153,7 @@ export async function cmdUpdate(
} else {
const meta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
{ method: 'GET', url: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
ApiV1SkillResponseSchema,
)
resolveResult = { match: null, latestVersion: meta.latestVersion ?? null }
@@ -242,27 +242,13 @@ 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 } = {},
) {
export async function cmdExplore(opts: GlobalOpts, limit = 25) {
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)
const boundedLimit = clampLimit(limit)
url.searchParams.set('limit', String(boundedLimit))
if (apiSort !== 'updated') url.searchParams.set('sort', apiSort)
const result = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
@@ -270,10 +256,6 @@ export async function cmdExplore(
)
spinner.stop()
if (options.json) {
console.log(JSON.stringify(result, null, 2))
return
}
if (result.items.length === 0) {
console.log('No skills found.')
return
@@ -302,7 +284,7 @@ export function formatExploreLine(item: {
export function clampLimit(limit: number, fallback = 25) {
if (!Number.isFinite(limit)) return fallback
return Math.min(Math.max(1, limit), 200)
return Math.min(Math.max(1, limit), 50)
}
function formatRelativeTime(timestamp: number): string {
@@ -328,37 +310,6 @@ function truncate(str: string, maxLen: number): string {
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)
@@ -1,46 +0,0 @@
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
}
}
@@ -1,48 +0,0 @@
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
}
}
-1
View File
@@ -16,7 +16,6 @@ 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
-12
View File
@@ -215,18 +215,6 @@ 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"',
-1
View File
@@ -15,7 +15,6 @@ 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";
};
-1
View File
@@ -15,7 +15,6 @@ 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',
};
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
-10
View File
@@ -221,16 +221,6 @@ 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;
+1 -11
View File
@@ -192,16 +192,6 @@ 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"',
@@ -240,4 +230,4 @@ export const ClawdisSkillMetadataSchema = type({
nix: NixPluginSpecSchema.optional(),
config: ClawdbotConfigSpecSchema.optional(),
});
//# sourceMappingURL=schemas.js.map
//# sourceMappingURL=schemas.js.map
File diff suppressed because one or more lines are too long
-1
View File
@@ -16,7 +16,6 @@ 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
-12
View File
@@ -226,18 +226,6 @@ 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"',
-56
View File
@@ -1,56 +0,0 @@
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()
-68
View File
@@ -1,68 +0,0 @@
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,
},
})
})
})
+22 -26
View File
@@ -4,7 +4,6 @@ import { vi } from 'vitest'
import { SkillDetailPage } from '../components/SkillDetailPage'
const navigateMock = vi.fn()
const useAuthStatusMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
@@ -14,27 +13,18 @@ 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
@@ -42,7 +32,9 @@ describe('SkillDetailPage', () => {
})
it('shows a loading indicator while loading', () => {
useQueryMock.mockImplementationOnce(() => undefined) // getBySlug
useQueryMock
.mockImplementationOnce(() => null) // me
.mockImplementationOnce(() => undefined) // getBySlug
render(<SkillDetailPage slug="weather" />)
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
@@ -50,26 +42,30 @@ describe('SkillDetailPage', () => {
})
it('shows not found when skill query resolves to null', async () => {
useQueryMock.mockImplementationOnce(() => null) // getBySlug
useQueryMock
.mockImplementationOnce(() => null) // me
.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(() => ({
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(() => 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: {} },
}))
render(<SkillDetailPage slug="weather" redirectToCanonical />)
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
+1 -2
View File
@@ -1,6 +1,5 @@
/* @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'
@@ -15,7 +14,7 @@ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
useSearch: () => searchMock,
}),
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
Link: (props: { children: unknown }) => <a href="/">{props.children}</a>,
}))
vi.mock('convex/react', () => ({
+1 -11
View File
@@ -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,10 +27,6 @@ vi.mock('convex/react', () => ({
},
}))
vi.mock('../lib/useAuthStatus', () => ({
useAuthStatus: () => useAuthStatusMock(),
}))
describe('Upload route', () => {
beforeEach(() => {
generateUploadUrl.mockReset()
@@ -38,13 +34,7 @@ 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
+5 -3
View File
@@ -3,13 +3,15 @@ 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) => {
if (typeof window !== 'undefined') {
window.history.replaceState(null, '', relativeUrl)
}
window.history.replaceState(null, '', relativeUrl)
}}
>
<UserBootstrap />
+39 -51
View File
@@ -1,12 +1,13 @@
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,
@@ -17,8 +18,9 @@ import {
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group'
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus()
const { isAuthenticated, isLoading } = useConvexAuth()
const { signIn, signOut } = useAuthActions()
const me = useQuery(api.users.me)
const { mode, setMode } = useThemeMode()
const toggleRef = useRef<HTMLDivElement | null>(null)
const siteMode = getSiteMode()
@@ -47,11 +49,7 @@ export default function Header() {
return (
<header className="navbar">
<div className="navbar-inner">
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
<Link to="/" search={{ q: undefined, highlighted: undefined }} className="brand">
<span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" />
</span>
@@ -59,32 +57,27 @@ export default function Header() {
</Link>
<nav className="nav-links">
{isSoulMode ? <a href={clawdHubUrl}>ClawdHub</a> : null}
{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={isSoulMode ? '/souls' : '/skills'}
search={
isSoulMode
? undefined
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}
}
>
{isSoulMode ? 'Souls' : 'Skills'}
</Link>
<Link to="/upload" search={{ updateSlug: undefined }}>
Upload
</Link>
{isSoulMode ? null : <Link to="/import">Import</Link>}
<Link to="/" search={{ q: undefined, highlighted: undefined, search: true }}>
<Link to="/search" search={{ q: undefined, highlighted: undefined }}>
Search
</Link>
{me ? <Link to="/stars">Stars</Link> : null}
@@ -105,27 +98,22 @@ export default function Header() {
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
{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={isSoulMode ? '/souls' : '/skills'}
search={
isSoulMode
? undefined
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}
}
>
{isSoulMode ? 'Souls' : 'Skills'}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/upload" search={{ updateSlug: undefined }}>
@@ -138,7 +126,7 @@ export default function Header() {
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/" search={{ q: undefined, highlighted: undefined, search: true }}>
<Link to="/search" search={{ q: undefined, highlighted: undefined }}>
Search
</Link>
</DropdownMenuItem>
+2 -5
View File
@@ -8,14 +8,11 @@ type SkillCardProps = {
chip?: string
summaryFallback: string
meta: ReactNode
href?: string
}
export function SkillCard({ skill, badge, chip, summaryFallback, meta, href }: SkillCardProps) {
const link = href ?? `/skills/${skill.slug}`
export function SkillCard({ skill, badge, chip, summaryFallback, meta }: SkillCardProps) {
return (
<Link to={link} className="card skill-card">
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="card skill-card">
{badge || chip ? (
<div className="skill-card-tags">
{badge ? <div className="tag">{badge}</div> : null}
+4 -6
View File
@@ -1,12 +1,11 @@
import { useNavigate } from '@tanstack/react-router'
import type { ClawdisSkillMetadata, SkillInstallSpec } from 'clawdhub-schema'
import { useAction, useMutation, useQuery } from 'convex/react'
import { useAction, useConvexAuth, 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 = {
@@ -15,15 +14,14 @@ type SkillDetailPageProps = {
redirectToCanonical?: boolean
}
type SkillFile = Doc<'skillVersions'>['files'][number]
export function SkillDetailPage({
slug,
canonicalOwner,
redirectToCanonical,
}: SkillDetailPageProps) {
const navigate = useNavigate()
const { isAuthenticated, me } = useAuthStatus()
const { isAuthenticated } = useConvexAuth()
const me = useQuery(api.users.me)
const result = useQuery(api.skills.getBySlug, { slug })
const toggleStar = useMutation(api.stars.toggle)
const addComment = useMutation(api.comments.add)
@@ -124,7 +122,7 @@ export function SkillDetailPage({
if (!readme) return null
return stripFrontmatter(readme)
}, [readme])
const latestFiles: SkillFile[] = latestVersion?.files ?? []
const latestFiles = latestVersion?.files ?? []
useEffect(() => {
if (!latestVersion) return
+2 -2
View File
@@ -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 = normalizeHex(styles.getPropertyValue('--surface').trim() || '#ffffff')
const surface = 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 value
if (!value.startsWith('#')) return '#000000'
if (value.length === 4) {
return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
}
+3 -3
View File
@@ -1,17 +1,17 @@
import { useAction, useMutation, useQuery } from 'convex/react'
import { useAction, useConvexAuth, 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, me } = useAuthStatus()
const { isAuthenticated } = useConvexAuth()
const me = useQuery(api.users.me)
const result = useQuery(api.souls.getBySlug, { slug })
const toggleStar = useMutation(api.soulStars.toggle)
const addComment = useMutation(api.soulComments.add)
+2 -3
View File
@@ -1,10 +1,9 @@
import { useMutation } from 'convex/react'
import { useConvexAuth, 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 } = useAuthStatus()
const { isAuthenticated, isLoading } = useConvexAuth()
const ensureUser = useMutation(api.users.ensure)
const didRun = useRef(false)
-11
View File
@@ -1,11 +0,0 @@
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),
}
}
+3 -3
View File
@@ -1,17 +1,17 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation } from 'convex/react'
import { useConvexAuth, useMutation, useQuery } 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, me } = useAuthStatus()
const { isAuthenticated, isLoading } = useConvexAuth()
const { signIn } = useAuthActions()
const me = useQuery(api.users.me)
const createToken = useMutation(api.tokens.create)
const search = Route.useSearch() as {
+3 -6
View File
@@ -10,10 +10,7 @@ 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',
) as Doc<'skills'>[] | undefined
const mySkills = useQuery(api.skills.list, me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip')
if (!me) {
return (
@@ -32,7 +29,7 @@ function Dashboard() {
<h1 className="section-title" style={{ margin: 0 }}>
My Skills
</h1>
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
<Link to="/upload" className="btn btn-primary">
<Plus className="h-4 w-4" aria-hidden="true" />
Upload New Skill
</Link>
@@ -43,7 +40,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" search={{ updateSlug: undefined }} className="btn btn-primary">
<Link to="/upload" className="btn btn-primary">
<Upload className="h-4 w-4" aria-hidden="true" />
Upload a Skill
</Link>
+2 -3
View File
@@ -1,9 +1,8 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useAction } from 'convex/react'
import { useAction, useConvexAuth } 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,
@@ -38,7 +37,7 @@ type CandidatePreview = {
}
function ImportGitHub() {
const { isAuthenticated, isLoading } = useAuthStatus()
const { isAuthenticated, isLoading } = useConvexAuth()
const previewImport = useAction(api.githubImport.previewGitHubImport)
const previewCandidate = useAction(api.githubImport.previewGitHubImportCandidate)
const importSkill = useAction(api.githubImport.importGitHubSkill)
+219 -81
View File
@@ -11,14 +11,7 @@ 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' || search.highlighted === true
? true
: undefined,
search:
search.search === '1' || search.search === 'true' || search.search === true
? true
: undefined,
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
}),
component: Home,
})
@@ -29,13 +22,75 @@ 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">
<section className={`hero${searchMode ? ' search-mode' : ''}`}>
<div className="hero-inner">
<div className="hero-copy fade-up" data-delay="1">
<span className="hero-badge">Lobster-light. Agent-right.</span>
@@ -48,73 +103,165 @@ function SkillsHome() {
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
Publish a skill
</Link>
<Link to="/skills" className="btn">
Browse skills
</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">
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="stat">Search skills. Versioned, rollback-ready.</div>
<InstallSwitcher exampleSlug="sonoscli" />
</div>
<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>
<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>
{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" className="btn">
See all skills
</Link>
</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>
)
}
@@ -130,7 +277,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 || search.search))
const [searchMode, setSearchMode] = useState(Boolean(search.q))
const searchRequest = useRef(0)
const seedEnsuredRef = useRef(false)
const inputRef = useRef<HTMLInputElement | null>(null)
@@ -139,12 +286,10 @@ function OnlyCrabsHome() {
useEffect(() => {
setQuery(search.q ?? '')
if (search.q || search.search) {
if (search.q) {
setSearchMode(true)
} else {
setSearchMode(false)
}
}, [search.q, search.search])
}, [search.q])
useEffect(() => {
if (seedEnsuredRef.current) return
@@ -157,17 +302,10 @@ function OnlyCrabsHome() {
search: () => ({
q: trimmedQuery || undefined,
highlighted: undefined,
search: searchMode && !trimmedQuery ? true : undefined,
}),
replace: true,
})
}, [navigate, searchMode, trimmedQuery])
useEffect(() => {
if (searchMode && inputRef.current) {
inputRef.current.focus()
}
}, [searchMode])
}, [navigate, trimmedQuery])
useEffect(() => {
if (!trimmedQuery) {
+20 -9
View File
@@ -1,18 +1,29 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useEffect } from 'react'
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,
}),
beforeLoad: ({ search }) => {
throw redirect({
to: '/skills',
search: {
component: SearchRedirect,
})
function SearchRedirect() {
const navigate = Route.useNavigate()
const search = Route.useSearch()
useEffect(() => {
void navigate({
to: '/',
search: (prev) => ({
...prev,
q: search.q || undefined,
highlighted: search.highlighted || undefined,
},
highlighted: search.highlighted ? true : undefined,
}),
replace: true,
})
},
})
}, [navigate, search.highlighted, search.q])
return null
}
+1 -11
View File
@@ -2,7 +2,6 @@ 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')({
@@ -13,16 +12,7 @@ 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) as
| Array<{
_id: Id<'apiTokens'>
label: string
prefix: string
createdAt: number
lastUsedAt?: number
revokedAt?: number
}>
| undefined
const tokens = useQuery(api.tokens.listMine)
const createToken = useMutation(api.tokens.create)
const revokeToken = useMutation(api.tokens.revoke)
const [displayName, setDisplayName] = useState('')
+19 -31
View File
@@ -21,34 +21,13 @@ 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' || search.highlighted === true
? true
: undefined,
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
}
},
@@ -64,10 +43,14 @@ 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<SkillListEntry>>([])
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<SkillSearchEntry>>([])
const [searchResults, setSearchResults] = useState<
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
>([])
const [searchLimit, setSearchLimit] = useState(pageSize)
const [isSearching, setIsSearching] = useState(false)
const searchRequest = useRef(0)
@@ -82,7 +65,7 @@ export function SkillsIndex() {
hasQuery ? 'skip' : { cursor: cursor ?? undefined, limit: pageSize },
) as
| {
items: Array<SkillListEntry>
items: Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
nextCursor: string | null
}
| undefined
@@ -127,7 +110,11 @@ export function SkillsIndex() {
query: trimmedQuery,
highlightedOnly,
limit: searchLimit,
})) as Array<SkillSearchEntry>
})) as Array<{
skill: Doc<'skills'>
version: Doc<'skillVersions'> | null
score: number
}>
if (requestId === searchRequest.current) {
setSearchResults(data)
}
@@ -146,7 +133,6 @@ export function SkillsIndex() {
return searchResults.map((entry) => ({
skill: entry.skill,
latestVersion: entry.version,
ownerHandle: entry.ownerHandle ?? null,
}))
}
return pages
@@ -336,12 +322,10 @@ 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."
@@ -360,9 +344,13 @@ 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={skillHref}>
<Link
key={skill._id}
className="skills-row"
to="/skills/$slug"
params={{ slug: skill.slug }}
>
<div className="skills-row-main">
<div className="skills-row-title">
<span>{skill.displayName}</span>
+7 -26
View File
@@ -1,5 +1,5 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery } from 'convex/react'
import { useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
@@ -14,8 +14,6 @@ function Stars() {
| Doc<'skills'>[]
| undefined) ?? []
const toggleStar = useMutation(api.stars.toggle)
if (!me) {
return (
<main className="section">
@@ -33,29 +31,12 @@ function Stars() {
<div className="card">No stars yet.</div>
) : (
skills.map((skill) => (
<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>
<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>
-34
View File
@@ -13,10 +13,6 @@ 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',
@@ -58,8 +54,6 @@ 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">
@@ -104,34 +98,6 @@ 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>
+2 -3
View File
@@ -1,11 +1,10 @@
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
import { useAction, useMutation, useQuery } from 'convex/react'
import { useAction, useConvexAuth, 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,
@@ -25,7 +24,7 @@ export const Route = createFileRoute('/upload')({
})
export function Upload() {
const { isAuthenticated } = useAuthStatus()
const { isAuthenticated } = useConvexAuth()
const { updateSlug } = useSearch({ from: '/upload' })
const siteMode = getSiteMode()
const isSoulMode = siteMode === 'souls'
-7
View File
@@ -1002,13 +1002,6 @@ 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
View File
@@ -1,5 +1,4 @@
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'
@@ -20,26 +19,7 @@ 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({