Compare commits

..
Author SHA1 Message Date
Peter Steinberger f2434dcb95 Revert "chore: rename molthub branding"
This reverts commit 8d68b55333.
2026-01-29 18:20:36 +01:00
Vignesh 5ef00e8c6a chore(security): harden file endpoints CSP + XFO + svg detection (#67) 2026-01-28 23:21:16 -06:00
Jamieson O'Reillyandtheonejvo c5e5e657dd fix: add CSP headers and Content-Disposition to prevent SVG XSS (#61)
Co-authored-by: theonejvo <theonejvo@users.noreply.github.com>
2026-01-28 22:21:39 -06:00
Jamie Turner 69e1e5c507 A few fixes for search. (#64) 2026-01-28 22:21:26 -06:00
Josh Palmer 481f1b9188 Merge pull request #66 from moltbot/fix/ci-lint
fix: restore lint compliance
2026-01-28 21:27:21 +01:00
Josh Palmer d4f5832554 🤖 chore: merge main into fix/ci-lint
- resolve conflicts in search/skill publish files
- apply Biome formatting updates from main

Tests: bun run lint:biome; bun run lint:oxlint
2026-01-28 21:23:50 +01:00
Josh Palmer ca3275ba92 🤖 fix: restore lint compliance
- apply Biome formatting and import ordering across linted files
- fix management useEffect dependencies flagged by Biome

Tests: bun run lint:biome; bun run lint:oxlint
2026-01-28 21:18:19 +01:00
Shadow d577add8a0 fix: unblock convex deploy typecheck 2026-01-28 13:15:18 -06:00
Shadow 90bd065f7e fix: correct public skill entries build 2026-01-28 13:12:41 -06:00
Shadow 219f05b160 fix: sanitize public skill and soul data 2026-01-28 13:03:19 -06:00
Shadow c8091ee8a8 chore: remove unauthenticated badge backfill 2026-01-28 08:33:14 -06:00
Shadow 63f367e1d6 chore: add unauthenticated badge backfill 2026-01-28 08:31:04 -06:00
Shadow e701d7b713 feat: add skill badges table 2026-01-28 01:11:46 -06:00
Shadow 79bd1f1d6c fix: query highlighted skills by batch index 2026-01-28 00:34:33 -06:00
Shadow ac51cc0236 fix: rely on highlighted badge 2026-01-27 23:27:36 -06:00
Shadow b9c23dc00e feat: add reports dashboard for moderation 2026-01-27 22:27:20 -06:00
Shadow f4e96995bc fix: allow clawdis parsed metadata 2026-01-27 21:39:23 -06:00
Shadow 33165db598 feat: unify skill routes with owner slugs 2026-01-27 21:28:46 -06:00
Shadow 4c10e4847b feat: add moderation management and backfill 2026-01-27 21:11:37 -06:00
Shadow 8d68b55333 chore: rename molthub branding 2026-01-27 18:25:02 -06:00
Jamie Turner 251de1f540 Performance optimizations for /skills page 2026-01-27 15:18:21 -06:00
Shadow 123f60fa93 Revert "Performance optimizations for /skills page" temporarily until we can deploy
This reverts commit 643faf71c8.
2026-01-27 12:20:15 -06:00
Jamie Turner 643faf71c8 Performance optimizations for /skills page 2026-01-27 12:09:57 -06:00
Aaron NgandPeter Steinberger a2c46fbb5d Search Fixes (#30)
* more search fixes

* update tests

* comments

* fix: tune search filters and limits (#30) (thanks @aaronn)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-25 00:08:29 +00:00
Peter Steinberger f51e0a087d test: fix lockfile mock version 2026-01-24 22:56:39 +00:00
emilianoandPeter Steinberger d9108b0948 feat: show published skills on user profile (#20)
* fix: resolve typecheck and lint errors

* fix: stabilize publish paths and token types

* feat: show published skills on user profile

* fix: document profile published skills (#20) (thanks @njoylab)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 22:55:18 +00:00
Peter Steinberger d7a017e1c3 fix: add update lookup test (#22) (thanks @daveonkels) 2026-01-24 22:30:09 +00:00
Dave OnkelsandClaude Opus 4.5 fffdf82540 fix: use path instead of url for skill metadata API call (#22)
The `cmdUpdate` function was passing a relative path to `apiRequest`
using the `url` property, but `url` expects a full URL. When `url` is
provided, it's used as-is without combining with the registry base URL.

This caused "Failed to parse URL from /api/v1/skills/<slug>" errors
when updating skills that don't have a local fingerprint match.

Changed to use `path` property which correctly combines with the
registry base URL via `new URL(args.path, registry)`.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:28:54 +00:00
Ahmed Fuad MireClaude Opus 4.5vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>Ahmed
a16e624766 fix: relax search token matching to require at least one match (#27)
* fix: relax search token matching to require at least one match

The search was requiring ALL query tokens to exist in the skill's
displayName, slug, or summary. This was too strict and caused valid
results to be filtered out. For example, searching "HTTP API client"
would fail to match skills about "HTTP API" that didn't mention "client".

Changed from `.every()` to `.some()` so at least one token must match,
allowing the vector similarity to determine relevance for the rest.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: update matchesExactTokens to require prefix matching for query tokens

* more inclusive token check

* Update convex/lib/searchText.ts

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>

---------

Co-authored-by: Ahmed <ahmed.mire@kaluza.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-01-24 21:23:03 +00:00
Peter Steinberger decce1d35c fix: skip missing skills in search hydration (#28) (thanks @aaronn) 2026-01-24 21:11:46 +00:00
Aaron Ng 468832af3f fix search (#28) 2026-01-24 21:11:06 +00:00
Shadow 54c793a660 fix: handle search embedding errors 2026-01-23 15:48:53 -06:00
Shadow 5d9a89a885 fix search 2026-01-23 15:24:26 -06:00
Peter Steinberger 31e9a57678 feat: add installs/trending sorts 2026-01-19 07:06:46 +00:00
Peter Steinberger 7680cc4ce8 feat: add idempotent star endpoints 2026-01-19 03:09:27 +00:00
Peter Steinberger de56255b95 fix: normalize monaco surface color 2026-01-19 02:21:51 +00:00
Peter Steinberger eaaa5e4423 Merge pull request #12 from NACC96/fix/search-mode-navigation
fix: search mode navigation and state management
2026-01-18 23:54:17 +00:00
Peter Steinberger 49e9c3c071 fix: stabilize search mode routing (#12) (thanks @NACC96) 2026-01-18 23:53:42 +00:00
NACC96 ef96fbee84 fix: auto-focus search input when search mode activates 2026-01-18 23:51:49 +00:00
NACC96 9fc032216e fix: update Header search links to use URL params instead of /search redirect 2026-01-18 23:51:49 +00:00
NACC96 236058b1b5 fix: preserve search flag in OnlyCrabsHome URL sync 2026-01-18 23:51:49 +00:00
NACC96 595bd2cf05 fix: search mode navigation and state management
- Fix "Explore search" button causing page refresh by using URL params
- Enable /search URL deep linking via beforeLoad redirect
- Fix logo click not closing search mode by properly syncing state with URL
2026-01-18 23:51:49 +00:00
Peter Steinberger 918b5528df chore: format peer check script 2026-01-18 23:42:04 +00:00
Peter Steinberger 2f6f11af3f ci: add peer dependency check 2026-01-18 23:40:55 +00:00
Peter Steinberger c90f92dbc7 fix: align auth core with convex auth 2026-01-18 23:33:51 +00:00
Peter Steinberger 8db8c89206 chore: update deps and adjust vite convex resolution 2026-01-18 23:29:59 +00:00
Peter Steinberger 00c31dcdd4 fix: derive auth from user query 2026-01-18 23:13:29 +00:00
Peter Steinberger 47646937b6 fix: dedupe convex auth modules 2026-01-18 22:33:09 +00:00
Shadow 10d250ea32 fix: keep ConvexAuthProvider during SSR 2026-01-18 14:43:24 -06:00
Peter Steinberger e7fa7afdf7 chore: bump clawdhub to 0.2.1 2026-01-18 16:28:40 +00:00
Peter Steinberger aa97727be8 fix: harden explore limit + tests/docs (#14) (thanks @jdrhyne) 2026-01-18 16:26:28 +00:00
Peter Steinberger d375496c17 Merge pull request #14 from jdrhyne/feat/explore-command
feat(cli): add explore command to browse latest updated skills
2026-01-18 16:25:48 +00:00
Jonathan Rhyne e108789ab1 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-17 23:07:54 -05:00
100 changed files with 5802 additions and 1169 deletions
+3 -2
View File
@@ -15,10 +15,12 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
bun-version: 1.3.6
- name: Install
run: bun install --frozen-lockfile
- name: Peer deps
run: bun run check:peers
- name: Lint
run: bun run lint
@@ -36,4 +38,3 @@ jobs:
- name: Build
run: bun run build
+2 -1
View File
@@ -40,5 +40,6 @@
- OAuth: GitHub OAuth App credentials required for login.
## Convex Ops (Gotchas)
- New Convex functions must be pushed before `convex run`: use `bunx convex dev --once` (dev) or `bunx convex deploy --prod` (prod).
- New Convex functions must be pushed before `convex run`: use `bunx convex dev --once` (dev) or `bunx convex deploy` (prod).
- For non-interactive prod deploys, use `bunx convex deploy -y` to skip confirmation.
- If `bunx convex run --env-file .env.local ...` returns `401 MissingAccessToken` despite `bunx convex login`, workaround: omit `--env-file` and use `--deployment-name <name>` / `--prod`.
+22 -1
View File
@@ -1,11 +1,32 @@
# 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.
+42 -36
View File
@@ -1,10 +1,11 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "clawdhub",
"dependencies": {
"@auth/core": "^0.34.3",
"@auth/core": "^0.37.4",
"@convex-dev/auth": "^0.0.90",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
@@ -15,14 +16,15 @@
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-router": "^1.151.3",
"@tanstack/react-router-devtools": "^1.151.3",
"@tanstack/react-start": "^1.151.5",
"@tanstack/router-plugin": "^1.151.5",
"@tanstack/react-router": "^1.151.6",
"@tanstack/react-router-devtools": "^1.151.6",
"@tanstack/react-start": "^1.152.0",
"@tanstack/router-plugin": "^1.151.6",
"@vercel/analytics": "^1.6.1",
"clawdhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.31.5",
"convex": "^1.31.6",
"convex-helpers": "^0.1.111",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.8",
"lucide-react": "^0.562.0",
@@ -51,6 +53,7 @@
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.4.0",
"only-allow": "^1.2.1",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"typescript": "^5.9.3",
@@ -60,7 +63,7 @@
},
"packages/clawdhub": {
"name": "clawdhub",
"version": "0.2.0",
"version": "0.3.0",
"bin": {
"clawdhub": "bin/clawdhub.js",
},
@@ -75,6 +78,7 @@
"ora": "^9.0.0",
"p-retry": "^7.1.1",
"semver": "^7.7.3",
"undici": "^7.16.0",
},
"devDependencies": {
"@types/node": "^25.0.9",
@@ -85,10 +89,10 @@
"name": "clawdhub-schema",
"version": "0.0.2",
"dependencies": {
"arktype": "latest",
"arktype": "^2.1.29",
},
"devDependencies": {
"typescript": "latest",
"typescript": "^5.9.3",
},
},
},
@@ -105,7 +109,7 @@
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="],
"@auth/core": ["@auth/core@0.37.4", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^5.9.6", "oauth4webapi": "^3.1.1", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
@@ -551,37 +555,37 @@
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.2", "", { "dependencies": { "@tanstack/devtools": "0.10.3" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-JNXvBO3jgq16GzTVm7p65n5zHNfMhnqF6Bm7CawjoqZrjEakxbM6Yvy63aKSIpbrdf+Wun2Xn8P0qD+vp56e1g=="],
"@tanstack/react-router": ["@tanstack/react-router@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.151.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Xb8444OgVpmwCl9hpUA+N/X0dsik/WWYWdldIYNAcmBAl1ZYYXm8kKnfjgHS2aMZzPnqN1BjKocKgc5jg3lK9Q=="],
"@tanstack/react-router": ["@tanstack/react-router@1.151.6", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.151.6", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-KDbz7kacZCOoDrUwYljz4I/qjqVGq+bgUhpi/CWubi7by0GZ3JEECwFl/+k+4V6ATinJDjTNmCGwFcdwqjQDtA=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.151.3", "", { "dependencies": { "@tanstack/router-devtools-core": "1.151.3" }, "peerDependencies": { "@tanstack/react-router": "^1.151.3", "@tanstack/router-core": "^1.151.3", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-WQ8cA4BkDBIglieexdHFvHbhvZOdtgCWO5BDR58MQoz+ZQwsWo9R0m6t6yVOQzkPi69t6Og9s1NogE4BF0/NYA=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.151.6", "", { "dependencies": { "@tanstack/router-devtools-core": "1.151.6" }, "peerDependencies": { "@tanstack/react-router": "^1.151.6", "@tanstack/router-core": "^1.151.6", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-mRRFzIAIOAWYcZrEr0FYy/1FmM51iWwUdK0J3nWuXjAIeEb7uizS0HkeNbzX5yxfGZgkplk23eCXIUmJcDuVRQ=="],
"@tanstack/react-start": ["@tanstack/react-start@1.151.5", "", { "dependencies": { "@tanstack/react-router": "1.151.3", "@tanstack/react-start-client": "1.151.3", "@tanstack/react-start-server": "1.151.3", "@tanstack/router-utils": "^1.143.11", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-plugin-core": "1.151.5", "@tanstack/start-server-core": "1.151.3", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-V3di0M9L6Y/dGCTJrqjTWvxHPxXkSnT2+Dt1ffmVSixlO0HiUDrc1BfEZb3avERPkJ0SL8zxSz6xFYsQZew/yg=="],
"@tanstack/react-start": ["@tanstack/react-start@1.152.0", "", { "dependencies": { "@tanstack/react-router": "1.151.6", "@tanstack/react-start-client": "1.152.0", "@tanstack/react-start-server": "1.152.0", "@tanstack/router-utils": "^1.143.11", "@tanstack/start-client-core": "1.152.0", "@tanstack/start-plugin-core": "1.152.0", "@tanstack/start-server-core": "1.152.0", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-btRNNIJGnXVEmD9yuiyNRvMITX5aocVJVdv/LQHxca4EcxntPEn1+HboPXFr6SDl9UNekH/6NZqv3LPz9tDm7A=="],
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.151.3", "", { "dependencies": { "@tanstack/react-router": "1.151.3", "@tanstack/router-core": "1.151.3", "@tanstack/start-client-core": "1.151.3", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-oW6hyCnetWu5dpPROUUxoX3Rpvc1Y7bEwqj3zfHCzGBQ0CsQqryjvtWifd9IBIVYW6W4qKD9ScLyzoLl7uw+ww=="],
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.152.0", "", { "dependencies": { "@tanstack/react-router": "1.151.6", "@tanstack/router-core": "1.151.6", "@tanstack/start-client-core": "1.152.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-YI2VVdCLk86QP5Q0oMXZWdk551haIisuF5AIr+S9ZAF415s4AgDpKRlXX251aqiHXYvv4rFnV1c9o7w02031Cw=="],
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-router": "1.151.3", "@tanstack/router-core": "1.151.3", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-server-core": "1.151.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Zdlw5gyNkffd52E6u8IFCNu/xwUFfuLnQId3DIkGzPBFi9rg5JfcRXANbjUL0SrnM3+nksyhnbnluYTlCrEmvg=="],
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.152.0", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/react-router": "1.151.6", "@tanstack/router-core": "1.151.6", "@tanstack/start-client-core": "1.152.0", "@tanstack/start-server-core": "1.152.0" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-oz3J3Ipj04IUAfVSE1DD41x8+7DDWHZez+fCqLnk8sw63ct013CeKwIvW9v1n0OtU6axekuVHHYBOsF5wO5/lg=="],
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
"@tanstack/router-core": ["@tanstack/router-core@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-JVaNgR/4xl6/5BhVVZCnaZrN+VgpIaHVIWnMiC8dgpZZAI+FfIUOXoTQ+95167cp1igjR8pKDAKEX+gDgD3yKw=="],
"@tanstack/router-core": ["@tanstack/router-core@1.151.6", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eyqWx6vhKffkINWLujDF2sxAG9GE/XUdi3HrlD94ddJO9MBi/90a1HJaTYFSV8LmngjcRv8A3tt7OvFdv/UqhA=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.151.3", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.151.3", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-C4ZjhYz3ASkromu0PkXvr6MVwHWsipfQ3fttPODMgjQ1Crvulixmd6yaeZjPoqYXWWTFw75qfiY1Fmh89mD+oQ=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.151.6", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.151.6", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-OHGGvEtnANEbEwjYCChbvCyCLk/3Cqh9G5bhM5DVqrZ+b9wfeu46IdEsbSi1JfuK2sCHNMS5MrJaE2HZPsFx6Q=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.151.5", "", { "dependencies": { "@tanstack/router-core": "1.151.3", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-YxXCVil0hMBwSYHYL1kGY9ENI0mi3FUDTqVty8XudddT3Jvx21lW94BAUU56VZAp7kuDIVpn8nsyk++ogDJgOg=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.151.6", "", { "dependencies": { "@tanstack/router-core": "1.151.6", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-IS4tkrkLIwI2EViGlUXCVgnKJ4EhWMM6w75XoJqd0X4t6K0/OiHkr3AQ0f2qZXbNciqLGxS88GLe5UiSAOS5Vw=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.151.5", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.151.3", "@tanstack/router-generator": "1.151.5", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.151.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-l+4jmNACXHDamjbP/bFcOLheqzzqGCpEMjMS/w+BqBEYIu0W+Jqwj8wOwMaEBcuk/Vhnvmkp+WN1RYdM6+GVLg=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.151.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.151.6", "@tanstack/router-generator": "1.151.6", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.151.6", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-Kz9wmAgcylung1KoXvEEVTW91PNh4U65MgVwSmz5fYQP7UqNHPvHqBFWKEu17a45SfZ4LufsNW3LTFT35tWGXg=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.143.11", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA=="],
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.151.3", "", { "dependencies": { "@tanstack/router-core": "1.151.3", "@tanstack/start-fn-stubs": "1.151.3", "@tanstack/start-storage-context": "1.151.3", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-44i4lPsY7rmQYY9kZYicc+x+/zDXkDGEn6qNVOA0Q4O6evNXr92eXPVBAYFly1y7m+wlFgeFWddBXbW08gUA7g=="],
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.152.0", "", { "dependencies": { "@tanstack/router-core": "1.151.6", "@tanstack/start-fn-stubs": "1.151.3", "@tanstack/start-storage-context": "1.151.6", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-G88urpJImiGZttawtciSj46Ko57TXO2pd11Zef6Yw1VLD6fJs+RF0q9N0P98SGs9Jm0oUjLusi3Ti550fkQ2qw=="],
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.151.3", "", {}, "sha512-/zWBnfsOwact936Bn0CxigudU1QRZdiNTsK7ME/LMXXA66XsDxkryX5+5FeGwU5ETNPfLAx6pRUet1mtUKnLCg=="],
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.151.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.151.3", "@tanstack/router-generator": "1.151.5", "@tanstack/router-plugin": "1.151.5", "@tanstack/router-utils": "1.143.11", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-server-core": "1.151.3", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.0", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-IA3XJ5jzJu2JcLFCTDgEqsR8SgsX1drJKZbvVgoYk6RS3scBtIs7PaBlJ/hn61TvYUx14UTQsJzFzxU6DRH1fQ=="],
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.152.0", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.151.6", "@tanstack/router-generator": "1.151.6", "@tanstack/router-plugin": "1.151.6", "@tanstack/router-utils": "1.143.11", "@tanstack/start-client-core": "1.152.0", "@tanstack/start-server-core": "1.152.0", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.0", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-XT+ECwpmi6cQj6gb9QHo1XOOw+7zGXOvwnrijg3HSzNKFDlawHycgOotnViTOjtVjhHLpW1/yGyivWX5OC8wTw=="],
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.151.3", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/router-core": "1.151.3", "@tanstack/start-client-core": "1.151.3", "@tanstack/start-storage-context": "1.151.3", "h3-v2": "npm:h3@2.0.1-rc.7", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3" } }, "sha512-Hg+VwS2PKkx7+EW9MGZeT3arY7K+JUKvjhuQ/Ugs05f8x/dks3cn91aK5DYkPN0YRMpTNbDwZJX/DePBSnse8w=="],
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.152.0", "", { "dependencies": { "@tanstack/history": "1.151.1", "@tanstack/router-core": "1.151.6", "@tanstack/start-client-core": "1.152.0", "@tanstack/start-storage-context": "1.151.6", "h3-v2": "npm:h3@2.0.1-rc.7", "seroval": "^1.4.1", "tiny-invariant": "^1.3.3" } }, "sha512-bMmc4tIhR3FClb1iWLw0zuhvY71b9k2iz8Jw0tSK/KKi++gl1bGyJjuPtx8kDbx5Ix1SoXp4QNdXuo7dv5INfQ=="],
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.151.3", "", { "dependencies": { "@tanstack/router-core": "1.151.3" } }, "sha512-3gUiBFfcV/OYbdwex6Dl4BWY2oTHy46fDF9CzOzTpujDcz3hI7pqWs68sxatChdyoQVMZR2Xf8os4VRLMgF3ig=="],
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.151.6", "", { "dependencies": { "@tanstack/router-core": "1.151.6" } }, "sha512-MvTcT40qnqatIpKjWSfMRxFzTkprGBxhX2c+em58iZLEsGksitMUWbprknD6AIUqjHty8V3LuhULks/o6tSugQ=="],
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
@@ -605,8 +609,6 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -739,9 +741,11 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"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=="],
"convex": ["convex@1.31.6", "", { "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-9cIsOzepa3s9DURRF+fZHxbNuzLgilg9XGQCc45v0Xx4FemqeIezpPFSJF9WHC9ckk43TDUUXLecvLVt9djPkw=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"convex-helpers": ["convex-helpers@0.1.111", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-0O59Ohi8HVc3+KULxSC6JHsw8cQJyc8gZ7OAfNRVX7T5Wy6LhPx3l8veYN9avKg7UiPlO7m1eBiQMHKclIyXyQ=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
@@ -1059,6 +1063,10 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"clawdhub": ["clawdhub@workspace:packages/clawdhub"],
"clawdhub-schema": ["clawdhub-schema@workspace:packages/schema"],
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -1075,7 +1083,7 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="],
"oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
@@ -1085,6 +1093,8 @@
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"only-allow": ["only-allow@1.2.2", "", { "dependencies": { "which-pm-runs": "1.1.0" }, "bin": { "only-allow": "bin.js" } }, "sha512-uxyNYDsCh5YIJ780G7hC5OHjVUr9reHsbZNMM80L9tZlTpb3hUzb36KXgW4ZUGtJKQnGA3xegmWg1BxhWV0jJA=="],
"ora": ["ora@9.0.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.2.2", "string-width": "^8.1.0", "strip-ansi": "^7.1.2" } }, "sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A=="],
"oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="],
@@ -1119,9 +1129,9 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="],
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"preact-render-to-string": ["preact-render-to-string@5.2.3", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA=="],
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
@@ -1319,6 +1329,8 @@
"whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="],
"which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
@@ -1345,10 +1357,6 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@convex-dev/auth/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"@convex-dev/auth/oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-ryJnSmj4UhrGLZZPJ6PKVb4wNPAIkW6iyLy+0TRwazd3L1u0wzMe8RfqevAh2HbcSkoeLiSYnOVDOys4JSGYyg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Z82FDl1ByxqPEPrAYYeTQVlx2FSHPe1qwX465c+96IRS3fTdSYRoJcRxg3g2fEG5I69z1dSEWQlNRRr0/677mg=="],
@@ -1385,8 +1393,6 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"preact-render-to-string/pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
+18
View File
@@ -12,6 +12,7 @@ import type * as auth from "../auth.js";
import type * as comments from "../comments.js";
import type * as crons from "../crons.js";
import type * as devSeed from "../devSeed.js";
import type * as devSeedExtra from "../devSeedExtra.js";
import type * as downloads from "../downloads.js";
import type * as githubBackups from "../githubBackups.js";
import type * as githubBackupsNode from "../githubBackupsNode.js";
@@ -21,16 +22,22 @@ 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_badges from "../lib/badges.js";
import type * as lib_changelog from "../lib/changelog.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_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_moderation from "../lib/moderation.js";
import type * as lib_public from "../lib/public.js";
import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
import type * as lib_skillPublish from "../lib/skillPublish.js";
import type * as lib_skillStats from "../lib/skillStats.js";
import type * as lib_skills from "../lib/skills.js";
import type * as lib_soulChangelog from "../lib/soulChangelog.js";
import type * as lib_soulPublish from "../lib/soulPublish.js";
@@ -41,12 +48,14 @@ import type * as rateLimits from "../rateLimits.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
import type * as seedSouls from "../seedSouls.js";
import type * as skillStatEvents from "../skillStatEvents.js";
import type * as skills from "../skills.js";
import type * as soulComments from "../soulComments.js";
import type * as soulDownloads from "../soulDownloads.js";
import type * as soulStars from "../soulStars.js";
import type * as souls from "../souls.js";
import type * as stars from "../stars.js";
import type * as statsMaintenance from "../statsMaintenance.js";
import type * as telemetry from "../telemetry.js";
import type * as tokens from "../tokens.js";
import type * as uploads from "../uploads.js";
@@ -64,6 +73,7 @@ declare const fullApi: ApiFromModules<{
comments: typeof comments;
crons: typeof crons;
devSeed: typeof devSeed;
devSeedExtra: typeof devSeedExtra;
downloads: typeof downloads;
githubBackups: typeof githubBackups;
githubBackupsNode: typeof githubBackupsNode;
@@ -73,16 +83,22 @@ 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/badges": typeof lib_badges;
"lib/changelog": typeof lib_changelog;
"lib/embeddings": typeof lib_embeddings;
"lib/githubBackup": typeof lib_githubBackup;
"lib/githubImport": typeof lib_githubImport;
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
"lib/leaderboards": typeof lib_leaderboards;
"lib/moderation": typeof lib_moderation;
"lib/public": typeof lib_public;
"lib/searchText": typeof lib_searchText;
"lib/skillBackfill": typeof lib_skillBackfill;
"lib/skillPublish": typeof lib_skillPublish;
"lib/skillStats": typeof lib_skillStats;
"lib/skills": typeof lib_skills;
"lib/soulChangelog": typeof lib_soulChangelog;
"lib/soulPublish": typeof lib_soulPublish;
@@ -93,12 +109,14 @@ declare const fullApi: ApiFromModules<{
search: typeof search;
seed: typeof seed;
seedSouls: typeof seedSouls;
skillStatEvents: typeof skillStatEvents;
skills: typeof skills;
soulComments: typeof soulComments;
soulDownloads: typeof soulDownloads;
soulStars: typeof soulStars;
souls: typeof souls;
stars: typeof stars;
statsMaintenance: typeof statsMaintenance;
telemetry: typeof telemetry;
tokens: typeof tokens;
uploads: typeof uploads;
+5 -4
View File
@@ -1,7 +1,8 @@
import { v } from 'convex/values'
import type { Doc } from './_generated/dataModel'
import { mutation, query } from './_generated/server'
import { assertRole, requireUser } from './lib/access'
import { assertModerator, requireUser } from './lib/access'
import { type PublicUser, toPublicUser } from './lib/public'
export const listBySkill = query({
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
@@ -13,10 +14,10 @@ export const listBySkill = query({
.order('desc')
.take(limit)
const results: Array<{ comment: Doc<'comments'>; user: Doc<'users'> | null }> = []
const results: Array<{ comment: Doc<'comments'>; user: PublicUser | null }> = []
for (const comment of comments) {
if (comment.softDeletedAt) continue
const user = await ctx.db.get(comment.userId)
const user = toPublicUser(await ctx.db.get(comment.userId))
results.push({ comment, user })
}
return results
@@ -59,7 +60,7 @@ export const remove = mutation({
const isOwner = comment.userId === user._id
if (!isOwner) {
assertRole(user, ['admin', 'moderator'])
assertModerator(user)
}
await ctx.db.patch(comment._id, {
+21
View File
@@ -10,4 +10,25 @@ crons.interval(
{ batchSize: 50, maxBatches: 5 },
)
crons.interval(
'trending-leaderboard',
{ minutes: 60 },
internal.leaderboards.rebuildTrendingLeaderboardInternal,
{ limit: 200 },
)
crons.interval(
'skill-stats-backfill',
{ minutes: 10 },
internal.statsMaintenance.runSkillStatBackfillInternal,
{ batchSize: 200, maxBatches: 5 },
)
crons.interval(
'skill-stat-events',
{ minutes: 15 },
internal.skillStatEvents.processSkillStatEventsAction,
{},
)
export default crons
+71 -41
View File
@@ -1,5 +1,6 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { ActionCtx } from './_generated/server'
import { internalAction, internalMutation } from './_generated/server'
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
import { parseClawdisMetadata, parseFrontmatter } from './lib/skills'
@@ -13,6 +14,17 @@ type SeedSkillSpec = {
rawSkillMd: string
}
type SeedActionArgs = {
reset?: boolean
}
type SeedActionResult = {
ok: true
results: Array<Record<string, unknown> & { slug: string }>
}
type SeedMutationResult = Record<string, unknown>
const SEED_SKILLS: SeedSkillSpec[] = [
{
slug: 'padel',
@@ -237,53 +249,19 @@ function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
)}${rawSkillMd.slice(frontmatterEnd)}`
}
export const seedNixSkills = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const results = []
for (const spec of SEED_SKILLS) {
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
const frontmatter = parseFrontmatter(skillMd)
const clawdis = parseClawdisMetadata(frontmatter)
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
const result = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
frontmatter,
clawdis,
skillMd,
slug: spec.slug,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
})
results.push({ slug: spec.slug, ...result })
}
return { ok: true, results }
},
})
export const seedPadelSkill = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
if (!spec) throw new Error('padel seed spec missing')
async function seedNixSkillsHandler(
ctx: ActionCtx,
args: SeedActionArgs,
): Promise<SeedActionResult> {
const results: Array<Record<string, unknown> & { slug: string }> = []
for (const spec of SEED_SKILLS) {
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
const frontmatter = parseFrontmatter(skillMd)
const clawdis = parseClawdisMetadata(frontmatter)
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
return ctx.runMutation(internal.devSeed.seedSkillMutation, {
const result: SeedMutationResult = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
@@ -295,7 +273,51 @@ export const seedPadelSkill = internalAction({
summary: spec.summary,
version: spec.version,
})
results.push({ slug: spec.slug, ...result })
}
return { ok: true, results }
}
export const seedNixSkills: ReturnType<typeof internalAction> = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: seedNixSkillsHandler,
})
async function seedPadelSkillHandler(
ctx: ActionCtx,
args: SeedActionArgs,
): Promise<SeedMutationResult> {
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
if (!spec) throw new Error('padel seed spec missing')
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
const frontmatter = parseFrontmatter(skillMd)
const clawdis = parseClawdisMetadata(frontmatter)
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
return (await ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
frontmatter,
clawdis,
skillMd,
slug: spec.slug,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
})) as SeedMutationResult
}
export const seedPadelSkill: ReturnType<typeof internalAction> = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: seedPadelSkillHandler,
})
export const seedSkillMutation = internalMutation({
@@ -364,6 +386,10 @@ export const seedSkillMutation = internalMutation({
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: {
downloads: 0,
installsCurrent: 0,
@@ -413,6 +439,10 @@ export const seedSkillMutation = internalMutation({
await ctx.db.patch(skillId, {
latestVersionId: versionId,
tags: { latest: versionId },
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: {
downloads: 0,
installsCurrent: 0,
+541
View File
@@ -0,0 +1,541 @@
/**
* Extra seed skills for pagination testing.
*
* This file contains 50 placeholder skills to test pagination behavior.
* Run with: bunx convex run internal.devSeedExtra.seedExtraSkillsInternal
* Or with reset: bunx convex run internal.devSeedExtra.seedExtraSkillsInternal '{"reset": true}'
*/
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Id } from './_generated/dataModel'
import type { ActionCtx } from './_generated/server'
import { internalAction, internalMutation } from './_generated/server'
import { parseClawdisMetadata, parseFrontmatter } from './lib/skills'
type SeedSkillSpec = {
slug: string
displayName: string
summary: string
version: string
metadata: Record<string, unknown>
rawSkillMd: string
}
function makeSkill(
slug: string,
displayName: string,
summary: string,
envVars: string[] = [],
commands: string[] = ['help', 'status', 'run'],
): SeedSkillSpec {
const cliHelp = `${slug} - ${summary}
Usage:
${slug} [command]
Commands:
${commands.map((cmd) => ` ${cmd.padEnd(12)} Run ${cmd} operation`).join('\n')}
Flags:
-h, --help Show help
--json Output as JSON
`
const rawSkillMd = `---
name: ${slug}
description: ${summary}
---
# ${displayName}
## CLI
\`\`\`bash
${commands.map((cmd) => `${slug} ${cmd}`).join('\n')}
\`\`\`
## Usage
Use this skill to ${summary.toLowerCase()}.
`
return {
slug,
displayName,
summary,
version: '0.1.0',
metadata: {
clawdbot: {
nix: {
plugin: `github:example/${slug}`,
systems: ['aarch64-darwin', 'x86_64-linux'],
},
config: {
requiredEnv: envVars,
},
cliHelp,
},
},
rawSkillMd,
}
}
// 50 placeholder skills for pagination testing
const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [
// DevOps & Infrastructure (10)
makeSkill(
'kubectl-helper',
'Kubectl Helper',
'Simplified kubectl commands for common Kubernetes operations.',
['KUBECONFIG'],
['pods', 'logs', 'exec', 'describe', 'apply'],
),
makeSkill(
'terraform-runner',
'Terraform Runner',
'Execute Terraform plans and applies with safety checks.',
['TF_VAR_region', 'AWS_PROFILE'],
['plan', 'apply', 'destroy', 'output', 'state'],
),
makeSkill(
'ansible-exec',
'Ansible Exec',
'Run Ansible playbooks and ad-hoc commands.',
['ANSIBLE_INVENTORY'],
['playbook', 'adhoc', 'inventory', 'facts', 'vault'],
),
makeSkill(
'docker-compose-mgr',
'Docker Compose Manager',
'Manage Docker Compose stacks and services.',
['DOCKER_HOST'],
['up', 'down', 'logs', 'ps', 'restart'],
),
makeSkill(
'k9s-wrapper',
'K9s Wrapper',
'Interactive Kubernetes cluster management via K9s.',
['KUBECONFIG'],
['launch', 'contexts', 'namespaces', 'pods', 'logs'],
),
makeSkill(
'helm-charts',
'Helm Charts',
'Manage Helm chart deployments and releases.',
['KUBECONFIG', 'HELM_REPO'],
['install', 'upgrade', 'rollback', 'list', 'search'],
),
makeSkill(
'prometheus-alerts',
'Prometheus Alerts',
'Query Prometheus metrics and manage alerting rules.',
['PROMETHEUS_URL'],
['query', 'alerts', 'rules', 'targets', 'status'],
),
makeSkill(
'grafana-dash',
'Grafana Dashboards',
'Create and manage Grafana dashboards programmatically.',
['GRAFANA_URL', 'GRAFANA_API_KEY'],
['list', 'export', 'import', 'create', 'delete'],
),
makeSkill(
'nginx-config',
'Nginx Config',
'Generate and validate Nginx configuration files.',
['NGINX_CONF_DIR'],
['generate', 'validate', 'reload', 'test', 'sites'],
),
makeSkill(
'jenkins-jobs',
'Jenkins Jobs',
'Manage Jenkins jobs and pipelines.',
['JENKINS_URL', 'JENKINS_TOKEN'],
['list', 'build', 'status', 'logs', 'config'],
),
// Productivity (8)
makeSkill(
'todoist-sync',
'Todoist Sync',
'Sync and manage Todoist tasks from the command line.',
['TODOIST_API_TOKEN'],
['list', 'add', 'complete', 'projects', 'labels'],
),
makeSkill(
'notion-backup',
'Notion Backup',
'Export and backup Notion workspaces.',
['NOTION_TOKEN'],
['export', 'backup', 'restore', 'pages', 'databases'],
),
makeSkill(
'gcal-manager',
'Google Calendar Manager',
'Manage Google Calendar events and schedules.',
['GOOGLE_CREDENTIALS_FILE'],
['events', 'create', 'delete', 'calendars', 'reminders'],
),
makeSkill(
'time-tracker',
'Time Tracker',
'Track time spent on projects and tasks.',
['TIMETRACK_DB'],
['start', 'stop', 'status', 'report', 'projects'],
),
makeSkill(
'email-digest',
'Email Digest',
'Generate email digests and summaries.',
['IMAP_SERVER', 'IMAP_USER'],
['fetch', 'digest', 'search', 'folders', 'unread'],
),
makeSkill(
'habit-tracker',
'Habit Tracker',
'Track daily habits and streaks.',
['HABITS_DB'],
['log', 'streak', 'stats', 'habits', 'remind'],
),
makeSkill(
'bookmark-sync',
'Bookmark Sync',
'Sync bookmarks across browsers and devices.',
['BOOKMARKS_DIR'],
['sync', 'export', 'import', 'search', 'tags'],
),
makeSkill(
'notes-export',
'Notes Export',
'Export notes to various formats.',
['NOTES_DIR'],
['export', 'convert', 'search', 'list', 'tags'],
),
// Media & Entertainment (6)
makeSkill(
'spotify-ctl',
'Spotify Control',
'Control Spotify playback from the terminal.',
['SPOTIFY_CLIENT_ID', 'SPOTIFY_CLIENT_SECRET'],
['play', 'pause', 'next', 'prev', 'search'],
),
makeSkill(
'plex-manager',
'Plex Manager',
'Manage Plex media libraries and playback.',
['PLEX_URL', 'PLEX_TOKEN'],
['libraries', 'scan', 'search', 'play', 'sessions'],
),
makeSkill(
'ytdl-wrapper',
'YouTube Downloader',
'Download videos from YouTube and other platforms.',
['YTDL_OUTPUT_DIR'],
['download', 'info', 'playlist', 'audio', 'formats'],
),
makeSkill(
'podcast-dl',
'Podcast Downloader',
'Download and manage podcast episodes.',
['PODCAST_DIR'],
['subscribe', 'download', 'list', 'play', 'search'],
),
makeSkill(
'audiobook-player',
'Audiobook Player',
'Manage and play audiobook collections.',
['AUDIOBOOK_DIR'],
['play', 'pause', 'bookmark', 'list', 'progress'],
),
makeSkill(
'music-lib',
'Music Library',
'Organize and query local music libraries.',
['MUSIC_DIR'],
['scan', 'search', 'play', 'playlist', 'stats'],
),
// Smart Home (8)
makeSkill(
'hass-control',
'Home Assistant Control',
'Control Home Assistant entities and automations.',
['HASS_URL', 'HASS_TOKEN'],
['entities', 'services', 'automations', 'scenes', 'history'],
),
makeSkill(
'zigbee-mqtt',
'Zigbee2MQTT',
'Manage Zigbee devices via MQTT.',
['MQTT_BROKER', 'ZIGBEE_TOPIC'],
['devices', 'pair', 'remove', 'rename', 'groups'],
),
makeSkill(
'tasmota-ctl',
'Tasmota Control',
'Control Tasmota-flashed devices.',
['TASMOTA_HOSTS'],
['status', 'power', 'config', 'update', 'backup'],
),
makeSkill(
'esphome-mgr',
'ESPHome Manager',
'Manage ESPHome device configurations.',
['ESPHOME_DIR'],
['compile', 'upload', 'logs', 'dashboard', 'config'],
),
makeSkill(
'mqtt-broker',
'MQTT Broker',
'Interact with MQTT brokers for IoT messaging.',
['MQTT_BROKER', 'MQTT_USER'],
['pub', 'sub', 'topics', 'clients', 'stats'],
),
makeSkill(
'hue-lights',
'Philips Hue',
'Control Philips Hue lights and scenes.',
['HUE_BRIDGE_IP', 'HUE_API_KEY'],
['lights', 'scenes', 'groups', 'schedules', 'sensors'],
),
makeSkill(
'smart-thermo',
'Smart Thermostat',
'Control smart thermostats and HVAC systems.',
['THERMOSTAT_API_KEY'],
['status', 'set', 'schedule', 'history', 'zones'],
),
makeSkill(
'cam-viewer',
'Camera Viewer',
'View and manage security camera feeds.',
['CAMERA_URLS'],
['list', 'snapshot', 'stream', 'record', 'events'],
),
// Finance (5)
makeSkill(
'budget-track',
'Budget Tracker',
'Track budgets and spending across categories.',
['BUDGET_DB'],
['summary', 'add', 'categories', 'report', 'goals'],
),
makeSkill(
'crypto-watch',
'Crypto Watcher',
'Monitor cryptocurrency prices and portfolios.',
['CRYPTO_API_KEY'],
['prices', 'portfolio', 'alerts', 'history', 'convert'],
),
makeSkill(
'stock-alerts',
'Stock Alerts',
'Set up stock price alerts and notifications.',
['STOCK_API_KEY'],
['quote', 'watch', 'alerts', 'portfolio', 'news'],
),
makeSkill(
'expense-cat',
'Expense Categorizer',
'Automatically categorize expenses.',
['EXPENSE_DB'],
['import', 'categorize', 'report', 'rules', 'export'],
),
makeSkill(
'invoice-gen',
'Invoice Generator',
'Generate and manage invoices.',
['INVOICE_DIR', 'COMPANY_INFO'],
['create', 'list', 'send', 'paid', 'overdue'],
),
// Communication (5)
makeSkill(
'slack-bot',
'Slack Bot',
'Interact with Slack channels and messages.',
['SLACK_TOKEN'],
['send', 'channels', 'users', 'search', 'files'],
),
makeSkill(
'discord-mgr',
'Discord Manager',
'Manage Discord servers and messages.',
['DISCORD_TOKEN'],
['send', 'servers', 'channels', 'members', 'roles'],
),
makeSkill(
'telegram-bot',
'Telegram Bot',
'Send and receive Telegram messages.',
['TELEGRAM_BOT_TOKEN'],
['send', 'receive', 'chats', 'files', 'inline'],
),
makeSkill(
'matrix-cli',
'Matrix CLI',
'Interact with Matrix chat rooms.',
['MATRIX_HOMESERVER', 'MATRIX_TOKEN'],
['send', 'rooms', 'join', 'leave', 'sync'],
),
makeSkill(
'irc-bridge',
'IRC Bridge',
'Bridge IRC channels to other platforms.',
['IRC_SERVER', 'IRC_NICK'],
['connect', 'join', 'send', 'channels', 'users'],
),
// Data & Analytics (5)
makeSkill(
'pg-queries',
'PostgreSQL Queries',
'Execute PostgreSQL queries and manage databases.',
['DATABASE_URL'],
['query', 'tables', 'schema', 'backup', 'restore'],
),
makeSkill(
'clickhouse-ql',
'ClickHouse Queries',
'Run ClickHouse analytics queries.',
['CLICKHOUSE_URL'],
['query', 'tables', 'insert', 'system', 'optimize'],
),
makeSkill(
'redis-cli',
'Redis CLI',
'Interact with Redis cache and data structures.',
['REDIS_URL'],
['get', 'set', 'keys', 'info', 'flush'],
),
makeSkill(
'elastic-search',
'Elasticsearch',
'Search and manage Elasticsearch indices.',
['ELASTICSEARCH_URL'],
['search', 'index', 'mapping', 'cluster', 'aliases'],
),
makeSkill(
'mongo-shell',
'MongoDB Shell',
'Query and manage MongoDB collections.',
['MONGODB_URI'],
['find', 'insert', 'update', 'delete', 'aggregate'],
),
// Security (3)
makeSkill(
'vault-secrets',
'Vault Secrets',
'Manage secrets in HashiCorp Vault.',
['VAULT_ADDR', 'VAULT_TOKEN'],
['read', 'write', 'list', 'delete', 'seal'],
),
makeSkill(
'gpg-keys',
'GPG Keys',
'Manage GPG keys and encryption.',
['GNUPGHOME'],
['list', 'generate', 'export', 'import', 'encrypt'],
),
makeSkill(
'ssh-rotate',
'SSH Key Rotator',
'Rotate and manage SSH keys.',
['SSH_KEY_DIR'],
['generate', 'rotate', 'deploy', 'list', 'revoke'],
),
]
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
const frontmatterEnd = rawSkillMd.indexOf('\n---', 3)
if (frontmatterEnd === -1) return rawSkillMd
return `${rawSkillMd.slice(0, frontmatterEnd)}\nmetadata: ${JSON.stringify(
metadata,
)}${rawSkillMd.slice(frontmatterEnd)}`
}
function randomStats() {
return {
downloads: Math.floor(Math.random() * 5000),
stars: Math.floor(Math.random() * 500),
installsCurrent: Math.floor(Math.random() * 200),
installsAllTime: Math.floor(Math.random() * 1000),
}
}
export const applyRandomStats = internalMutation({
args: {
skillId: v.id('skills'),
stats: v.object({
downloads: v.number(),
stars: v.number(),
installsCurrent: v.number(),
installsAllTime: v.number(),
}),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.skillId, {
statsDownloads: args.stats.downloads,
statsStars: args.stats.stars,
statsInstallsCurrent: args.stats.installsCurrent,
statsInstallsAllTime: args.stats.installsAllTime,
stats: {
downloads: args.stats.downloads,
stars: args.stats.stars,
installsCurrent: args.stats.installsCurrent,
installsAllTime: args.stats.installsAllTime,
versions: 1,
comments: 0,
},
})
},
})
export const seedExtraSkillsInternal = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: async (ctx: ActionCtx, args) => {
const results: Array<{ slug: string; ok: boolean; skipped?: boolean }> = []
for (const spec of EXTRA_SEED_SKILLS) {
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
const frontmatter = parseFrontmatter(skillMd)
const clawdis = parseClawdisMetadata(frontmatter)
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
const result = (await ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
frontmatter,
clawdis,
skillMd,
slug: spec.slug,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
})) as { ok: boolean; skipped?: boolean; skillId?: string }
// Apply random stats after creation (only if not skipped)
if (result.skillId && !result.skipped) {
const stats = randomStats()
await ctx.runMutation(internal.devSeedExtra.applyRandomStats, {
skillId: result.skillId as Id<'skills'>,
stats,
})
}
results.push({ slug: spec.slug, ok: result.ok, skipped: result.skipped })
}
const created = results.filter((r) => !r.skipped).length
const skipped = results.filter((r) => r.skipped).length
return { ok: true, total: results.length, created, skipped }
},
})
+4 -3
View File
@@ -2,6 +2,7 @@ import { v } from 'convex/values'
import { zipSync } from 'fflate'
import { api } from './_generated/api'
import { httpAction, mutation } from './_generated/server'
import { insertStatEvent } from './skillStatEvents'
export const downloadZip = httpAction(async (ctx, request) => {
const url = new URL(request.url)
@@ -69,9 +70,9 @@ export const increment = mutation({
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId)
if (!skill) return
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, downloads: skill.stats.downloads + 1 },
updatedAt: Date.now(),
await insertStatEvent(ctx, {
skillId: skill._id,
kind: 'download',
})
},
})
+14
View File
@@ -26,6 +26,8 @@ import {
soulsDeleteRouterV1Http,
soulsGetRouterV1Http,
soulsPostRouterV1Http,
starsDeleteRouterV1Http,
starsPostRouterV1Http,
whoamiV1Http,
} from './httpApiV1'
@@ -81,6 +83,18 @@ http.route({
handler: skillsDeleteRouterV1Http,
})
http.route({
pathPrefix: `${ApiRoutes.stars}/`,
method: 'POST',
handler: starsPostRouterV1Http,
})
http.route({
pathPrefix: `${ApiRoutes.stars}/`,
method: 'DELETE',
handler: starsDeleteRouterV1Http,
})
http.route({
path: ApiRoutes.whoami,
method: 'GET',
+22 -7
View File
@@ -11,7 +11,7 @@ vi.mock('./skills', () => ({
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
const { publishVersionForUser } = await import('./skills')
const { __handlers, cliSkillDeleteHttp, cliSkillUndeleteHttp } = await import('./httpApi')
const { __handlers } = await import('./httpApi')
const { hashSkillFiles } = await import('./lib/skills')
function makeCtx(partial: Record<string, unknown>) {
@@ -33,7 +33,7 @@ describe('httpApi handlers', () => {
expect(await response.json()).toEqual({ results: [] })
})
it('searchSkillsHttp forwards args', async () => {
it('searchSkillsHttp forwards args (approvedOnly alias)', async () => {
const runAction = vi.fn().mockResolvedValue([
{
score: 1,
@@ -48,14 +48,27 @@ describe('httpApi handlers', () => {
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: 5,
approvedOnly: true,
highlightedOnly: true,
})
expect(response.status).toBe(200)
const json = await response.json()
expect(json.results[0].slug).toBe('a')
})
it('searchSkillsHttp omits approvedOnly when false', async () => {
it('searchSkillsHttp forwards highlightedOnly', async () => {
const runAction = vi.fn().mockResolvedValue([])
await __handlers.searchSkillsHandler(
makeCtx({ runAction }),
new Request('https://example.com/api/search?q=test&highlightedOnly=true'),
)
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: undefined,
highlightedOnly: true,
})
})
it('searchSkillsHttp omits highlightedOnly when approvedOnly is false', async () => {
const runAction = vi.fn().mockResolvedValue([])
await __handlers.searchSkillsHandler(
makeCtx({ runAction }),
@@ -64,7 +77,7 @@ describe('httpApi handlers', () => {
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: undefined,
approvedOnly: undefined,
highlightedOnly: undefined,
})
})
@@ -416,13 +429,14 @@ describe('httpApi handlers', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const runMutation = vi.fn().mockResolvedValue({ ok: true })
const response = await cliSkillUndeleteHttp(
const response = await __handlers.cliSkillDeleteHandler(
makeCtx({ runMutation }),
new Request('https://x/api/cli/skill/undelete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'demo' }),
}),
false,
)
expect(response.status).toBe(200)
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
@@ -437,13 +451,14 @@ describe('httpApi handlers', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const runMutation = vi.fn().mockResolvedValue({ ok: true })
const response = await cliSkillDeleteHttp(
const response = await __handlers.cliSkillDeleteHandler(
makeCtx({ runMutation }),
new Request('https://x/api/cli/skill/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'demo' }),
}),
true,
)
expect(response.status).toBe(200)
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
+2 -1
View File
@@ -44,13 +44,14 @@ async function searchSkillsHandler(ctx: ActionCtx, request: Request) {
const query = url.searchParams.get('q')?.trim() ?? ''
const limit = toOptionalNumber(url.searchParams.get('limit'))
const approvedOnly = url.searchParams.get('approvedOnly') === 'true'
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true' || approvedOnly
if (!query) return json({ results: [] })
const results = (await ctx.runAction(api.search.searchSkills, {
query,
limit,
approvedOnly: approvedOnly || undefined,
highlightedOnly: highlightedOnly || undefined,
})) as SearchSkillEntry[]
return json({
+83
View File
@@ -158,6 +158,31 @@ describe('httpApiV1 handlers', () => {
expect(json.items[0].tags.latest).toBe('1.0.0')
})
it('lists skills supports sort aliases', async () => {
const checks: Array<[string, string]> = [
['rating', 'stars'],
['installs', 'installsCurrent'],
['installs-all-time', 'installsAllTime'],
['trending', 'trending'],
]
for (const [input, expected] of checks) {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('sort' in args || 'cursor' in args || 'limit' in args) {
expect(args.sort).toBe(expected)
return { items: [], nextCursor: null }
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.listSkillsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(`https://example.com/api/v1/skills?sort=${input}`),
)
expect(response.status).toBe(200)
}
})
it('get skill returns 404 when missing', async () => {
const runQuery = vi.fn().mockResolvedValue(null)
const runMutation = vi.fn().mockResolvedValue(okRate())
@@ -498,4 +523,62 @@ describe('httpApiV1 handlers', () => {
)
expect(response2.status).toBe(200)
})
it('stars require auth', async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.starsPostRouterV1Handler(
makeCtx({ runMutation }),
new Request('https://example.com/api/v1/stars/demo', { method: 'POST' }),
)
expect(response.status).toBe(401)
})
it('stars add succeeds', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:1',
user: { handle: 'p' },
} as never)
const runQuery = vi.fn().mockResolvedValue({ _id: 'skills:1' })
const runMutation = vi
.fn()
.mockResolvedValueOnce(okRate())
.mockResolvedValueOnce(okRate())
.mockResolvedValueOnce({ ok: true, starred: true, alreadyStarred: false })
const response = await __handlers.starsPostRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/stars/demo', {
method: 'POST',
headers: { Authorization: 'Bearer clh_test' },
}),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.ok).toBe(true)
expect(json.starred).toBe(true)
})
it('stars delete succeeds', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:1',
user: { handle: 'p' },
} as never)
const runQuery = vi.fn().mockResolvedValue({ _id: 'skills:1' })
const runMutation = vi
.fn()
.mockResolvedValueOnce(okRate())
.mockResolvedValueOnce(okRate())
.mockResolvedValueOnce({ ok: true, unstarred: true, alreadyUnstarred: false })
const response = await __handlers.starsDeleteRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/stars/demo', {
method: 'DELETE',
headers: { Authorization: 'Bearer clh_test' },
}),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.ok).toBe(true)
expect(json.unstarred).toBe(true)
})
})
+114 -6
View File
@@ -44,6 +44,9 @@ type ListSkillsResult = {
nextCursor: string | null
}
type SkillFile = Doc<'skillVersions'>['files'][number]
type SoulFile = Doc<'soulVersions'>['files'][number]
type GetBySlugResult = {
skill: {
_id: Id<'skills'>
@@ -56,7 +59,7 @@ type GetBySlugResult = {
updatedAt: number
} | null
latestVersion: Doc<'skillVersions'> | null
owner: { handle?: string; displayName?: string; image?: string } | null
owner: { _id: Id<'users'>; handle?: string; displayName?: string; image?: string } | null
} | null
type ListVersionsResult = {
@@ -191,11 +194,14 @@ async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
const sort = parseListSort(url.searchParams.get('sort'))
const cursor = sort === 'updated' ? rawCursor : undefined
const result = (await ctx.runQuery(api.skills.listPublicPage, {
limit,
cursor,
sort,
})) as ListSkillsResult
const items = await Promise.all(
@@ -261,6 +267,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
owner: result.owner
? {
handle: result.owner.handle ?? null,
userId: result.owner._id,
displayName: result.owner.displayName ?? null,
image: result.owner.image ?? null,
}
@@ -315,7 +322,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file) => ({
files: version.files.map((file: SkillFile) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
@@ -368,6 +375,10 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
if (!blob) return text('File missing in storage', 410, rate.headers)
const textContent = await blob.text()
const isSvg =
file.contentType?.toLowerCase().includes('svg') ||
file.path.toLowerCase().endsWith('.svg')
const headers = mergeHeaders(rate.headers, {
'Content-Type': file.contentType
? `${file.contentType}; charset=utf-8`
@@ -376,6 +387,13 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
ETag: file.sha256,
'X-Content-SHA256': file.sha256,
'X-Content-Size': String(file.size),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
// For any text response that a browser might try to render, lock it down.
// In particular, this prevents SVG <foreignObject> script execution from
// reading localStorage tokens on this origin.
'Content-Security-Policy': "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
})
return new Response(textContent, { status: 200, headers })
}
@@ -753,9 +771,36 @@ function toOptionalNumber(value: string | null) {
return Number.isFinite(parsed) ? parsed : undefined
}
type SkillListSort =
| 'updated'
| 'downloads'
| 'stars'
| 'installsCurrent'
| 'installsAllTime'
| 'trending'
function parseListSort(value: string | null): SkillListSort {
const normalized = value?.trim().toLowerCase()
if (normalized === 'downloads') return 'downloads'
if (normalized === 'stars' || normalized === 'rating') return 'stars'
if (
normalized === 'installs' ||
normalized === 'install' ||
normalized === 'installscurrent' ||
normalized === 'installs-current'
) {
return 'installsCurrent'
}
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
return 'installsAllTime'
}
if (normalized === 'trending') return 'trending'
return 'updated'
}
async function sha256Hex(bytes: Uint8Array) {
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
const digest = await crypto.subtle.digest('SHA-256', buffer)
const data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
return toHex(new Uint8Array(digest))
}
@@ -895,7 +940,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file) => ({
files: version.files.map((file: SoulFile) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
@@ -950,6 +995,10 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
void ctx.runMutation(api.soulDownloads.increment, { soulId: soulResult.soul._id })
const isSvg =
file.contentType?.toLowerCase().includes('svg') ||
file.path.toLowerCase().endsWith('.svg')
const headers = mergeHeaders(rate.headers, {
'Content-Type': file.contentType
? `${file.contentType}; charset=utf-8`
@@ -958,6 +1007,13 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
ETag: file.sha256,
'X-Content-SHA256': file.sha256,
'X-Content-Size': String(file.size),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
// For any text response that a browser might try to render, lock it down.
// In particular, this prevents SVG <foreignObject> script execution from
// reading localStorage tokens on this origin.
'Content-Security-Policy': "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
})
return new Response(textContent, { status: 200, headers })
}
@@ -1047,6 +1103,56 @@ async function soulsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
}
export const soulsDeleteRouterV1Http = httpAction(soulsDeleteRouterV1Handler)
async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/stars/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill) return text('Skill not found', 404, rate.headers)
const result = await ctx.runMutation(internal.stars.addStarInternal, {
userId,
skillId: skill._id,
})
return json(result, 200, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
}
export const starsPostRouterV1Http = httpAction(starsPostRouterV1Handler)
async function starsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/stars/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill) return text('Skill not found', 404, rate.headers)
const result = await ctx.runMutation(internal.stars.removeStarInternal, {
userId,
skillId: skill._id,
})
return json(result, 200, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
}
export const starsDeleteRouterV1Http = httpAction(starsDeleteRouterV1Handler)
export const __handlers = {
searchSkillsV1Handler,
resolveSkillVersionV1Handler,
@@ -1060,5 +1166,7 @@ export const __handlers = {
publishSoulV1Handler,
soulsPostRouterV1Handler,
soulsDeleteRouterV1Handler,
starsPostRouterV1Handler,
starsDeleteRouterV1Handler,
whoamiV1Handler,
}
+39
View File
@@ -0,0 +1,39 @@
import { v } from 'convex/values'
import { internalMutation } from './_generated/server'
import { buildTrendingLeaderboard } from './lib/leaderboards'
const MAX_TRENDING_LIMIT = 200
const KEEP_LEADERBOARD_ENTRIES = 3
export const rebuildTrendingLeaderboardInternal = internalMutation({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT)
const now = Date.now()
const { startDay, endDay, items } = await buildTrendingLeaderboard(ctx, { limit, now })
await ctx.db.insert('skillLeaderboards', {
kind: 'trending',
generatedAt: now,
rangeStartDay: startDay,
rangeEndDay: endDay,
items,
})
const recent = await ctx.db
.query('skillLeaderboards')
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
.order('desc')
.take(KEEP_LEADERBOARD_ENTRIES + 5)
for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) {
await ctx.db.delete(entry._id)
}
return { ok: true as const, count: items.length }
},
})
function clampInt(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
+10 -2
View File
@@ -1,5 +1,5 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import { api } from '../_generated/api'
import { internal } from '../_generated/api'
import type { Doc } from '../_generated/dataModel'
import type { ActionCtx, MutationCtx, QueryCtx } from '../_generated/server'
@@ -16,7 +16,7 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
export async function requireUserFromAction(ctx: ActionCtx) {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Unauthorized')
const user = await ctx.runQuery(api.users.getById, { userId })
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
if (!user || user.deletedAt) throw new Error('User not found')
return { userId, user: user as Doc<'users'> }
}
@@ -26,3 +26,11 @@ export function assertRole(user: Doc<'users'>, allowed: Role[]) {
throw new Error('Forbidden')
}
}
export function assertAdmin(user: Doc<'users'>) {
assertRole(user, ['admin'])
}
export function assertModerator(user: Doc<'users'>) {
assertRole(user, ['admin', 'moderator'])
}
+50
View File
@@ -0,0 +1,50 @@
import type { Doc, Id } from '../_generated/dataModel'
import type { QueryCtx } from '../_generated/server'
type BadgeKind = Doc<'skillBadges'>['kind']
export type SkillBadgeMap = Partial<Record<BadgeKind, { byUserId: Id<'users'>; at: number }>>
export type SkillBadgeSource = { badges?: SkillBadgeMap | null }
type BadgeCtx = Pick<QueryCtx, 'db'>
export function isSkillHighlighted(skill: SkillBadgeSource) {
return Boolean(skill.badges?.highlighted)
}
export function isSkillOfficial(skill: SkillBadgeSource) {
return Boolean(skill.badges?.official)
}
export function isSkillDeprecated(skill: SkillBadgeSource) {
return Boolean(skill.badges?.deprecated)
}
export function buildBadgeMap(records: Doc<'skillBadges'>[]): SkillBadgeMap {
return records.reduce<SkillBadgeMap>((acc, record) => {
acc[record.kind] = { byUserId: record.byUserId, at: record.at }
return acc
}, {})
}
export async function getSkillBadgeMap(
ctx: BadgeCtx,
skillId: Id<'skills'>,
): Promise<SkillBadgeMap> {
const records = await ctx.db
.query('skillBadges')
.withIndex('by_skill', (q) => q.eq('skillId', skillId))
.collect()
return buildBadgeMap(records)
}
export async function getSkillBadgeMaps(
ctx: BadgeCtx,
skillIds: Array<Id<'skills'>>,
): Promise<Map<Id<'skills'>, SkillBadgeMap>> {
const entries = await Promise.all(
skillIds.map(async (skillId) => [skillId, await getSkillBadgeMap(ctx, skillId)] as const),
)
return new Map(entries)
}
+103
View File
@@ -0,0 +1,103 @@
import type { Id } from '../_generated/dataModel'
import type { MutationCtx, QueryCtx } from '../_generated/server'
const DAY_MS = 24 * 60 * 60 * 1000
export const TRENDING_DAYS = 7
type LeaderboardEntry = {
skillId: Id<'skills'>
score: number
installs: number
downloads: number
}
export function toDayKey(timestamp: number) {
return Math.floor(timestamp / DAY_MS)
}
export function getTrendingRange(now: number) {
const endDay = toDayKey(now)
const startDay = endDay - (TRENDING_DAYS - 1)
return { startDay, endDay }
}
export async function buildTrendingLeaderboard(
ctx: QueryCtx | MutationCtx,
params: { limit: number; now?: number },
) {
const now = params.now ?? Date.now()
const { startDay, endDay } = getTrendingRange(now)
const rows = await ctx.db
.query('skillDailyStats')
.withIndex('by_day', (q) => q.gte('day', startDay).lte('day', endDay))
.collect()
const totals = new Map<Id<'skills'>, { installs: number; downloads: number }>()
for (const row of rows) {
const current = totals.get(row.skillId) ?? { installs: 0, downloads: 0 }
current.installs += row.installs
current.downloads += row.downloads
totals.set(row.skillId, current)
}
const entries = Array.from(totals, ([skillId, totalsEntry]) => ({
skillId,
installs: totalsEntry.installs,
downloads: totalsEntry.downloads,
score: totalsEntry.installs,
}))
const items = topN(entries, params.limit, compareTrendingEntries).sort((a, b) =>
compareTrendingEntries(b, a),
)
return { startDay, endDay, items }
}
function compareTrendingEntries(a: LeaderboardEntry, b: LeaderboardEntry) {
if (a.score !== b.score) return a.score - b.score
if (a.downloads !== b.downloads) return a.downloads - b.downloads
return 0
}
function topN<T>(entries: T[], limit: number, compare: (a: T, b: T) => number) {
if (entries.length <= limit) return entries.slice()
const heap: T[] = []
for (const entry of entries) {
if (heap.length < limit) {
heap.push(entry)
siftUp(heap, heap.length - 1, compare)
continue
}
if (compare(entry, heap[0]) <= 0) continue
heap[0] = entry
siftDown(heap, 0, compare)
}
return heap
}
function siftUp<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
let current = index
while (current > 0) {
const parent = Math.floor((current - 1) / 2)
if (compare(heap[current], heap[parent]) >= 0) break
;[heap[current], heap[parent]] = [heap[parent], heap[current]]
current = parent
}
}
function siftDown<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
let current = index
const length = heap.length
while (true) {
const left = current * 2 + 1
const right = current * 2 + 2
let smallest = current
if (left < length && compare(heap[left], heap[smallest]) < 0) smallest = left
if (right < length && compare(heap[right], heap[smallest]) < 0) smallest = right
if (smallest === current) break
;[heap[current], heap[smallest]] = [heap[smallest], heap[current]]
current = smallest
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { Doc } from '../_generated/dataModel'
const FLAG_RULES: Array<{ flag: string; pattern: RegExp }> = [
{ flag: 'suspicious.keyword', pattern: /(malware|stealer|phish|phishing|keylogger)/i },
{ flag: 'suspicious.secrets', pattern: /(api[-_ ]?key|token|password|private key|secret)/i },
{ flag: 'suspicious.crypto', pattern: /(wallet|seed phrase|mnemonic|crypto)/i },
{ flag: 'suspicious.webhook', pattern: /(discord\.gg|webhook|hooks\.slack)/i },
{ flag: 'suspicious.script', pattern: /(curl[^\n]+\|\s*(sh|bash))/i },
{ flag: 'suspicious.url_shortener', pattern: /(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd)/i },
]
export function deriveModerationFlags({
skill,
parsed,
files,
}: {
skill: Pick<Doc<'skills'>, 'slug' | 'displayName' | 'summary'>
parsed: Doc<'skillVersions'>['parsed']
files: Doc<'skillVersions'>['files']
}) {
const text = [
skill.slug,
skill.displayName,
skill.summary ?? '',
JSON.stringify(parsed?.frontmatter ?? {}),
JSON.stringify(parsed?.metadata ?? {}),
JSON.stringify((parsed as { moltbot?: unknown } | undefined)?.moltbot ?? {}),
...files.map((file) => file.path),
]
.filter(Boolean)
.join('\n')
const flags = new Set<string>()
for (const rule of FLAG_RULES) {
if (rule.pattern.test(text)) {
flags.add(rule.flag)
}
}
return Array.from(flags)
}
+89
View File
@@ -0,0 +1,89 @@
import type { Doc } from '../_generated/dataModel'
export type PublicUser = Pick<
Doc<'users'>,
'_id' | '_creationTime' | 'handle' | 'name' | 'displayName' | 'image' | 'bio'
>
export type PublicSkill = Pick<
Doc<'skills'>,
| '_id'
| '_creationTime'
| 'slug'
| 'displayName'
| 'summary'
| 'ownerUserId'
| 'canonicalSkillId'
| 'forkOf'
| 'latestVersionId'
| 'tags'
| 'badges'
| 'stats'
| 'createdAt'
| 'updatedAt'
>
export type PublicSoul = Pick<
Doc<'souls'>,
| '_id'
| '_creationTime'
| 'slug'
| 'displayName'
| 'summary'
| 'ownerUserId'
| 'latestVersionId'
| 'tags'
| 'stats'
| 'createdAt'
| 'updatedAt'
>
export function toPublicUser(user: Doc<'users'> | null | undefined): PublicUser | null {
if (!user || user.deletedAt) return null
return {
_id: user._id,
_creationTime: user._creationTime,
handle: user.handle,
name: user.name,
displayName: user.displayName,
image: user.image,
bio: user.bio,
}
}
export function toPublicSkill(skill: Doc<'skills'> | null | undefined): PublicSkill | null {
if (!skill || skill.softDeletedAt) return null
return {
_id: skill._id,
_creationTime: skill._creationTime,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
ownerUserId: skill.ownerUserId,
canonicalSkillId: skill.canonicalSkillId,
forkOf: skill.forkOf,
latestVersionId: skill.latestVersionId,
tags: skill.tags,
badges: skill.badges,
stats: skill.stats,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
}
}
export function toPublicSoul(soul: Doc<'souls'> | null | undefined): PublicSoul | null {
if (!soul || soul.softDeletedAt) return null
return {
_id: soul._id,
_creationTime: soul._creationTime,
slug: soul.slug,
displayName: soul.displayName,
summary: soul.summary,
ownerUserId: soul.ownerUserId,
latestVersionId: soul.latestVersionId,
tags: soul.tags,
stats: soul.stats,
createdAt: soul.createdAt,
updatedAt: soul.updatedAt,
}
}
+16 -2
View File
@@ -13,12 +13,26 @@ describe('searchText', () => {
])
})
it('matchesExactTokens requires all query tokens', () => {
it('matchesExactTokens requires at least one query token to prefix-match', () => {
const queryTokens = tokenize('Remind Me')
expect(matchesExactTokens(queryTokens, ['Remind Me', '/remind-me', 'Short summary'])).toBe(true)
// "Reminder" starts with "remind", so it matches with prefix matching
expect(matchesExactTokens(queryTokens, ['Reminder tool', '/reminder', 'Short summary'])).toBe(
false,
true,
)
// Matches because "remind" token is present
expect(matchesExactTokens(queryTokens, ['Remind tool', '/remind', 'Short summary'])).toBe(true)
// No matching tokens at all
expect(matchesExactTokens(queryTokens, ['Other tool', '/other', 'Short summary'])).toBe(false)
})
it('matchesExactTokens supports prefix matching for partial queries', () => {
// "go" should match "gohome" because "gohome" starts with "go"
expect(matchesExactTokens(['go'], ['GoHome', '/gohome', 'Navigate home'])).toBe(true)
// "pad" should match "padel"
expect(matchesExactTokens(['pad'], ['Padel', '/padel', 'Tennis-like sport'])).toBe(true)
// "xyz" should not match anything
expect(matchesExactTokens(['xyz'], ['GoHome', '/gohome', 'Navigate home'])).toBe(false)
})
it('matchesExactTokens ignores empty inputs', () => {
+4 -2
View File
@@ -18,8 +18,10 @@ export function matchesExactTokens(
if (!text) return false
const textTokens = tokenize(text)
if (textTokens.length === 0) return false
const textSet = new Set(textTokens)
return queryTokens.every((token) => textSet.has(token))
// Require at least one token to prefix-match, allowing vector similarity to determine relevance
return queryTokens.some((queryToken) =>
textTokens.some((textToken) => textToken.includes(queryToken)),
)
}
export const __test = { normalize, tokenize, matchesExactTokens }
+22 -13
View File
@@ -3,8 +3,10 @@ import semver from 'semver'
import { api, internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx, MutationCtx } from '../_generated/server'
import { getSkillBadgeMap, isSkillHighlighted } from './badges'
import { generateChangelogForPublish } from './changelog'
import { generateEmbedding } from './embeddings'
import type { PublicUser } from './public'
import {
buildEmbeddingText,
getFrontmatterMetadata,
@@ -75,16 +77,20 @@ export async function publishVersionForUser(
if (sanitizedFiles.some((file) => !file.path)) {
throw new ConvexError('Invalid file paths')
}
if (sanitizedFiles.some((file) => !isTextFile(file.path ?? '', file.contentType ?? undefined))) {
const safeFiles = sanitizedFiles.map((file) => ({
...file,
path: file.path as string,
}))
if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
throw new ConvexError('Only text-based files are allowed')
}
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
if (totalBytes > MAX_TOTAL_BYTES) {
throw new ConvexError('Skill bundle exceeds 50MB limit')
}
const readmeFile = sanitizedFiles.find(
const readmeFile = safeFiles.find(
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
)
if (!readmeFile) throw new ConvexError('SKILL.md is required')
@@ -95,7 +101,7 @@ export async function publishVersionForUser(
const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
const otherFiles = [] as Array<{ path: string; content: string }>
for (const file of sanitizedFiles) {
for (const file of safeFiles) {
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
const content = await fetchText(ctx, file.storageId)
@@ -110,7 +116,7 @@ export async function publishVersionForUser(
})
const fingerprintPromise = hashSkillFiles(
sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
)
const changelogPromise =
@@ -120,7 +126,7 @@ export async function publishVersionForUser(
slug,
version,
readmeText,
files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
})
const embeddingPromise = generateEmbedding(embeddingText)
@@ -148,9 +154,9 @@ export async function publishVersionForUser(
version: args.forkOf.version?.trim() || undefined,
}
: undefined,
files: sanitizedFiles.map((file) => ({
files: safeFiles.map((file) => ({
...file,
path: file.path ?? '',
path: file.path,
})),
parsed: {
frontmatter,
@@ -160,7 +166,9 @@ export async function publishVersionForUser(
embedding,
})) as PublishResult
const owner = (await ctx.runQuery(api.users.getById, { userId })) as Doc<'users'> | null
const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
userId,
})) as Doc<'users'> | null
const ownerHandle = owner?.handle ?? owner?.displayName ?? owner?.name ?? 'unknown'
void ctx.scheduler
@@ -169,7 +177,7 @@ export async function publishVersionForUser(
version,
displayName,
ownerHandle,
files: sanitizedFiles,
files: safeFiles,
publishedAt: Date.now(),
})
.catch((error) => {
@@ -212,13 +220,14 @@ export async function queueHighlightedWebhook(ctx: MutationCtx, skillId: Id<'ski
const owner = await ctx.db.get(skill.ownerUserId)
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
const badges = await getSkillBadgeMap(ctx, skillId)
const payload: WebhookSkillPayload = {
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary ?? undefined,
version: latestVersion?.version ?? undefined,
ownerHandle: owner?.handle ?? owner?.name ?? undefined,
batch: skill.batch ?? undefined,
highlighted: isSkillHighlighted({ badges }),
tags: Object.keys(skill.tags ?? {}),
}
@@ -255,7 +264,7 @@ async function schedulePublishWebhook(
) {
const result = (await ctx.runQuery(api.skills.getBySlug, {
slug: params.slug,
})) as { skill: Doc<'skills'>; owner: Doc<'users'> | null } | null
})) as { skill: Doc<'skills'>; owner: PublicUser | null } | null
if (!result?.skill) return
const payload: WebhookSkillPayload = {
@@ -264,7 +273,7 @@ async function schedulePublishWebhook(
summary: result.skill.summary ?? undefined,
version: params.version,
ownerHandle: result.owner?.handle ?? result.owner?.name ?? undefined,
batch: result.skill.batch ?? undefined,
highlighted: isSkillHighlighted(result.skill),
tags: Object.keys(result.skill.tags ?? {}),
}
+80
View File
@@ -0,0 +1,80 @@
import type { Doc, Id } from '../_generated/dataModel'
import type { MutationCtx } from '../_generated/server'
import { toDayKey } from './leaderboards'
type SkillStatDeltas = {
downloads?: number
stars?: number
installsCurrent?: number
installsAllTime?: number
}
export function applySkillStatDeltas(skill: Doc<'skills'>, deltas: SkillStatDeltas) {
const currentDownloads =
typeof skill.statsDownloads === 'number' ? skill.statsDownloads : skill.stats.downloads
const currentStars = typeof skill.statsStars === 'number' ? skill.statsStars : skill.stats.stars
const currentInstallsCurrent =
typeof skill.statsInstallsCurrent === 'number'
? skill.statsInstallsCurrent
: (skill.stats.installsCurrent ?? 0)
const currentInstallsAllTime =
typeof skill.statsInstallsAllTime === 'number'
? skill.statsInstallsAllTime
: (skill.stats.installsAllTime ?? 0)
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0))
const nextStars = Math.max(0, currentStars + (deltas.stars ?? 0))
const nextInstallsCurrent = Math.max(0, currentInstallsCurrent + (deltas.installsCurrent ?? 0))
const nextInstallsAllTime = Math.max(0, currentInstallsAllTime + (deltas.installsAllTime ?? 0))
return {
statsDownloads: nextDownloads,
statsStars: nextStars,
statsInstallsCurrent: nextInstallsCurrent,
statsInstallsAllTime: nextInstallsAllTime,
stats: {
...skill.stats,
downloads: nextDownloads,
stars: nextStars,
installsCurrent: nextInstallsCurrent,
installsAllTime: nextInstallsAllTime,
},
}
}
export async function bumpDailySkillStats(
ctx: MutationCtx,
params: {
skillId: Id<'skills'>
now: number
downloads?: number
installs?: number
},
) {
const downloads = params.downloads ?? 0
const installs = params.installs ?? 0
if (downloads === 0 && installs === 0) return
const day = toDayKey(params.now)
const existing = await ctx.db
.query('skillDailyStats')
.withIndex('by_skill_day', (q) => q.eq('skillId', params.skillId).eq('day', day))
.unique()
if (existing) {
await ctx.db.patch(existing._id, {
downloads: Math.max(0, existing.downloads + downloads),
installs: Math.max(0, existing.installs + installs),
updatedAt: params.now,
})
return
}
await ctx.db.insert('skillDailyStats', {
skillId: params.skillId,
day,
downloads: Math.max(0, downloads),
installs: Math.max(0, installs),
updatedAt: params.now,
})
}
+4 -2
View File
@@ -1,6 +1,6 @@
import { ConvexError } from 'convex/values'
import semver from 'semver'
import { api, internal } from '../_generated/api'
import { internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { generateEmbedding } from './embeddings'
@@ -171,7 +171,9 @@ export async function publishSoulVersionForUser(
embedding,
})) as PublishResult
const owner = (await ctx.runQuery(api.users.getById, { userId })) as Doc<'users'> | null
const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
userId,
})) as Doc<'users'> | null
const ownerHandle = owner?.handle ?? owner?.name ?? userId
void ctx.scheduler
+2 -2
View File
@@ -40,7 +40,7 @@ describe('webhook filtering', () => {
}
const allowed = shouldSendWebhook(
'skill.publish',
{ slug: 'demo', displayName: 'Demo', batch: 'latest' },
{ slug: 'demo', displayName: 'Demo', highlighted: false },
config,
)
expect(allowed).toBe(false)
@@ -54,7 +54,7 @@ describe('webhook filtering', () => {
}
const allowed = shouldSendWebhook(
'skill.highlighted',
{ slug: 'demo', displayName: 'Demo', batch: 'latest' },
{ slug: 'demo', displayName: 'Demo', highlighted: true },
config,
)
expect(allowed).toBe(true)
+2 -2
View File
@@ -6,7 +6,7 @@ export type WebhookSkillPayload = {
summary?: string
version?: string
ownerHandle?: string
batch?: string
highlighted?: boolean
tags?: string[]
}
@@ -33,7 +33,7 @@ export function shouldSendWebhook(
if (!config.url) return false
if (!config.highlightedOnly) return true
if (event === 'skill.highlighted') return true
return skill.batch === 'highlighted'
return Boolean(skill.highlighted)
}
export function buildDiscordPayload(
+366
View File
@@ -265,6 +265,34 @@ type FingerprintBackfillPageResult = {
isDone: boolean
}
type BadgeBackfillStats = {
skillsScanned: number
skillsPatched: number
highlightsPatched: number
}
type SkillBadgeTableBackfillStats = {
skillsScanned: number
recordsInserted: number
}
type BadgeBackfillPageItem = {
skillId: Id<'skills'>
ownerUserId: Id<'users'>
createdAt?: number
updatedAt?: number
batch?: string
badges?: Doc<'skills'>['badges']
}
type BadgeBackfillPageResult = {
items: BadgeBackfillPageItem[]
cursor: string | null
isDone: boolean
}
type BadgeKind = Doc<'skillBadges'>['kind']
export const getSkillFingerprintBackfillPageInternal = internalQuery({
args: {
cursor: v.optional(v.string()),
@@ -467,6 +495,344 @@ export const scheduleBackfillSkillFingerprints: ReturnType<typeof action> = acti
},
})
export const getSkillBadgeBackfillPageInternal = internalQuery({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args): Promise<BadgeBackfillPageResult> => {
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 })
const items: BadgeBackfillPageItem[] = page.map((skill) => ({
skillId: skill._id,
ownerUserId: skill.ownerUserId,
createdAt: skill.createdAt ?? undefined,
updatedAt: skill.updatedAt ?? undefined,
batch: skill.batch ?? undefined,
badges: skill.badges ?? undefined,
}))
return { items, cursor: continueCursor, isDone }
},
})
export const applySkillBadgeBackfillPatchInternal = internalMutation({
args: {
skillId: v.id('skills'),
badges: v.optional(
v.object({
redactionApproved: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
highlighted: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
official: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
deprecated: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
}),
),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.skillId, { badges: args.badges ?? undefined, updatedAt: Date.now() })
return { ok: true as const }
},
})
export const upsertSkillBadgeRecordInternal = internalMutation({
args: {
skillId: v.id('skills'),
kind: v.union(
v.literal('highlighted'),
v.literal('official'),
v.literal('deprecated'),
v.literal('redactionApproved'),
),
byUserId: v.id('users'),
at: v.number(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query('skillBadges')
.withIndex('by_skill_kind', (q) => q.eq('skillId', args.skillId).eq('kind', args.kind))
.unique()
if (existing) return { inserted: false as const }
await ctx.db.insert('skillBadges', {
skillId: args.skillId,
kind: args.kind,
byUserId: args.byUserId,
at: args.at,
})
return { inserted: true as const }
},
})
export type BadgeBackfillActionArgs = {
dryRun?: boolean
batchSize?: number
maxBatches?: number
}
export type BadgeBackfillActionResult = { ok: true; stats: BadgeBackfillStats }
export async function backfillSkillBadgesInternalHandler(
ctx: ActionCtx,
args: BadgeBackfillActionArgs,
): Promise<BadgeBackfillActionResult> {
const dryRun = Boolean(args.dryRun)
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
const totals: BadgeBackfillStats = {
skillsScanned: 0,
skillsPatched: 0,
highlightsPatched: 0,
}
let cursor: string | null = null
let isDone = false
for (let i = 0; i < maxBatches; i++) {
const page = (await ctx.runQuery(internal.maintenance.getSkillBadgeBackfillPageInternal, {
cursor: cursor ?? undefined,
batchSize,
})) as BadgeBackfillPageResult
cursor = page.cursor
isDone = page.isDone
for (const item of page.items) {
totals.skillsScanned++
const shouldHighlight = item.batch === 'highlighted' && !item.badges?.highlighted
if (!shouldHighlight) continue
totals.skillsPatched++
totals.highlightsPatched++
if (dryRun) continue
const at = item.updatedAt ?? item.createdAt ?? Date.now()
await ctx.runMutation(internal.maintenance.applySkillBadgeBackfillPatchInternal, {
skillId: item.skillId,
badges: {
...item.badges,
highlighted: {
byUserId: item.ownerUserId,
at,
},
},
})
}
if (isDone) break
}
if (!isDone) {
throw new ConvexError('Backfill incomplete (maxBatches reached)')
}
return { ok: true as const, stats: totals }
}
export const backfillSkillBadgesInternal = internalAction({
args: {
dryRun: v.optional(v.boolean()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
},
handler: backfillSkillBadgesInternalHandler,
})
export const backfillSkillBadges: ReturnType<typeof action> = action({
args: {
dryRun: v.optional(v.boolean()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
},
handler: async (ctx, args): Promise<BadgeBackfillActionResult> => {
const { user } = await requireUserFromAction(ctx)
assertRole(user, ['admin'])
return ctx.runAction(
internal.maintenance.backfillSkillBadgesInternal,
args,
) as Promise<BadgeBackfillActionResult>
},
})
export const scheduleBackfillSkillBadges: ReturnType<typeof action> = action({
args: { dryRun: v.optional(v.boolean()) },
handler: async (ctx, args) => {
const { user } = await requireUserFromAction(ctx)
assertRole(user, ['admin'])
await ctx.scheduler.runAfter(0, internal.maintenance.backfillSkillBadgesInternal, {
dryRun: Boolean(args.dryRun),
batchSize: DEFAULT_BATCH_SIZE,
maxBatches: DEFAULT_MAX_BATCHES,
})
return { ok: true as const }
},
})
export type SkillBadgeTableBackfillActionResult = {
ok: true
stats: SkillBadgeTableBackfillStats
}
export async function backfillSkillBadgeTableInternalHandler(
ctx: ActionCtx,
args: BadgeBackfillActionArgs,
): Promise<SkillBadgeTableBackfillActionResult> {
const dryRun = Boolean(args.dryRun)
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
const totals: SkillBadgeTableBackfillStats = {
skillsScanned: 0,
recordsInserted: 0,
}
let cursor: string | null = null
let isDone = false
for (let i = 0; i < maxBatches; i++) {
const page = (await ctx.runQuery(internal.maintenance.getSkillBadgeBackfillPageInternal, {
cursor: cursor ?? undefined,
batchSize,
})) as BadgeBackfillPageResult
cursor = page.cursor
isDone = page.isDone
for (const item of page.items) {
totals.skillsScanned++
const badges = item.badges ?? {}
const entries: Array<{ kind: BadgeKind; byUserId: Id<'users'>; at: number }> = []
if (badges.redactionApproved) {
entries.push({
kind: 'redactionApproved',
byUserId: badges.redactionApproved.byUserId,
at: badges.redactionApproved.at,
})
}
if (badges.official) {
entries.push({
kind: 'official',
byUserId: badges.official.byUserId,
at: badges.official.at,
})
}
if (badges.deprecated) {
entries.push({
kind: 'deprecated',
byUserId: badges.deprecated.byUserId,
at: badges.deprecated.at,
})
}
const highlighted =
badges.highlighted ??
(item.batch === 'highlighted'
? {
byUserId: item.ownerUserId,
at: item.updatedAt ?? item.createdAt ?? Date.now(),
}
: undefined)
if (highlighted) {
entries.push({
kind: 'highlighted',
byUserId: highlighted.byUserId,
at: highlighted.at,
})
}
if (dryRun) continue
for (const entry of entries) {
const result = await ctx.runMutation(internal.maintenance.upsertSkillBadgeRecordInternal, {
skillId: item.skillId,
kind: entry.kind,
byUserId: entry.byUserId,
at: entry.at,
})
if (result.inserted) {
totals.recordsInserted++
}
}
}
if (isDone) break
}
if (!isDone) {
throw new ConvexError('Backfill incomplete (maxBatches reached)')
}
return { ok: true as const, stats: totals }
}
export const backfillSkillBadgeTableInternal = internalAction({
args: {
dryRun: v.optional(v.boolean()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
},
handler: backfillSkillBadgeTableInternalHandler,
})
export const backfillSkillBadgeTable: ReturnType<typeof action> = action({
args: {
dryRun: v.optional(v.boolean()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
},
handler: async (ctx, args): Promise<SkillBadgeTableBackfillActionResult> => {
const { user } = await requireUserFromAction(ctx)
assertRole(user, ['admin'])
return ctx.runAction(
internal.maintenance.backfillSkillBadgeTableInternal,
args,
) as Promise<SkillBadgeTableBackfillActionResult>
},
})
export const scheduleBackfillSkillBadgeTable: ReturnType<typeof action> = action({
args: { dryRun: v.optional(v.boolean()) },
handler: async (ctx, args) => {
const { user } = await requireUserFromAction(ctx)
assertRole(user, ['admin'])
await ctx.scheduler.runAfter(0, internal.maintenance.backfillSkillBadgeTableInternal, {
dryRun: Boolean(args.dryRun),
batchSize: DEFAULT_BATCH_SIZE,
maxBatches: DEFAULT_MAX_BATCHES,
})
return { ok: true as const }
},
})
function clampInt(value: number, min: number, max: number) {
const rounded = Math.trunc(value)
if (!Number.isFinite(rounded)) return min
+135 -1
View File
@@ -3,6 +3,8 @@ import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
const authSchema = authTables as unknown as Record<string, ReturnType<typeof defineTable>>
const users = defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
@@ -47,8 +49,41 @@ const skills = defineTable({
at: v.number(),
}),
),
highlighted: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
official: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
deprecated: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
}),
moderationStatus: v.optional(
v.union(v.literal('active'), v.literal('hidden'), v.literal('removed')),
),
moderationNotes: v.optional(v.string()),
moderationReason: v.optional(v.string()),
moderationFlags: v.optional(v.array(v.string())),
lastReviewedAt: v.optional(v.number()),
hiddenAt: v.optional(v.number()),
hiddenBy: v.optional(v.id('users')),
reportCount: v.optional(v.number()),
lastReportedAt: v.optional(v.number()),
batch: v.optional(v.string()),
statsDownloads: v.optional(v.number()),
statsStars: v.optional(v.number()),
statsInstallsCurrent: v.optional(v.number()),
statsInstallsAllTime: v.optional(v.number()),
stats: v.object({
downloads: v.number(),
installsCurrent: v.optional(v.number()),
@@ -63,7 +98,12 @@ const skills = defineTable({
.index('by_slug', ['slug'])
.index('by_owner', ['ownerUserId'])
.index('by_updated', ['updatedAt'])
.index('by_stats_downloads', ['statsDownloads', 'updatedAt'])
.index('by_stats_stars', ['statsStars', 'updatedAt'])
.index('by_stats_installs_current', ['statsInstallsCurrent', 'updatedAt'])
.index('by_stats_installs_all_time', ['statsInstallsAllTime', 'updatedAt'])
.index('by_batch', ['batch'])
.index('by_active_updated', ['softDeletedAt', 'updatedAt'])
const souls = defineTable({
slug: v.string(),
@@ -131,6 +171,7 @@ const soulVersions = defineTable({
parsed: v.object({
frontmatter: v.record(v.string(), v.any()),
metadata: v.optional(v.any()),
clawdis: v.optional(v.any()),
}),
createdBy: v.id('users'),
createdAt: v.number(),
@@ -149,6 +190,21 @@ const skillVersionFingerprints = defineTable({
.index('by_fingerprint', ['fingerprint'])
.index('by_skill_fingerprint', ['skillId', 'fingerprint'])
const skillBadges = defineTable({
skillId: v.id('skills'),
kind: v.union(
v.literal('highlighted'),
v.literal('official'),
v.literal('deprecated'),
v.literal('redactionApproved'),
),
byUserId: v.id('users'),
at: v.number(),
})
.index('by_skill', ['skillId'])
.index('by_skill_kind', ['skillId', 'kind'])
.index('by_kind_at', ['kind', 'at'])
const soulVersionFingerprints = defineTable({
soulId: v.id('souls'),
versionId: v.id('soulVersions'),
@@ -177,6 +233,67 @@ const skillEmbeddings = defineTable({
filterFields: ['visibility'],
})
const skillDailyStats = defineTable({
skillId: v.id('skills'),
day: v.number(),
downloads: v.number(),
installs: v.number(),
updatedAt: v.number(),
})
.index('by_skill_day', ['skillId', 'day'])
.index('by_day', ['day'])
const skillLeaderboards = defineTable({
kind: v.string(),
generatedAt: v.number(),
rangeStartDay: v.number(),
rangeEndDay: v.number(),
items: v.array(
v.object({
skillId: v.id('skills'),
score: v.number(),
installs: v.number(),
downloads: v.number(),
}),
),
}).index('by_kind', ['kind', 'generatedAt'])
const skillStatBackfillState = defineTable({
key: v.string(),
cursor: v.optional(v.string()),
doneAt: v.optional(v.number()),
updatedAt: v.number(),
}).index('by_key', ['key'])
const skillStatEvents = defineTable({
skillId: v.id('skills'),
kind: v.union(
v.literal('download'),
v.literal('star'),
v.literal('unstar'),
v.literal('install_new'),
v.literal('install_reactivate'),
v.literal('install_deactivate'),
v.literal('install_clear'),
),
delta: v.optional(
v.object({
allTime: v.number(),
current: v.number(),
}),
),
occurredAt: v.number(),
processedAt: v.optional(v.number()),
})
.index('by_unprocessed', ['processedAt'])
.index('by_skill', ['skillId'])
const skillStatUpdateCursors = defineTable({
key: v.string(),
cursorCreationTime: v.optional(v.number()),
updatedAt: v.number(),
}).index('by_key', ['key'])
const soulEmbeddings = defineTable({
soulId: v.id('souls'),
versionId: v.id('soulVersions'),
@@ -206,6 +323,16 @@ const comments = defineTable({
.index('by_skill', ['skillId'])
.index('by_user', ['userId'])
const skillReports = defineTable({
skillId: v.id('skills'),
userId: v.id('users'),
reason: v.optional(v.string()),
createdAt: v.number(),
})
.index('by_skill', ['skillId'])
.index('by_user', ['userId'])
.index('by_skill_user', ['skillId', 'userId'])
const soulComments = defineTable({
soulId: v.id('souls'),
userId: v.id('users'),
@@ -313,17 +440,24 @@ const userSkillRootInstalls = defineTable({
.index('by_skill', ['skillId'])
export default defineSchema({
...authTables,
...authSchema,
users,
skills,
souls,
skillVersions,
soulVersions,
skillVersionFingerprints,
skillBadges,
soulVersionFingerprints,
skillEmbeddings,
soulEmbeddings,
skillDailyStats,
skillLeaderboards,
skillStatBackfillState,
skillStatEvents,
skillStatUpdateCursors,
comments,
skillReports,
soulComments,
stars,
soulStars,
+90 -26
View File
@@ -2,13 +2,16 @@ import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import { action, internalQuery } from './_generated/server'
import { getSkillBadgeMaps, isSkillHighlighted, type SkillBadgeMap } from './lib/badges'
import { generateEmbedding } from './lib/embeddings'
import { toPublicSkill, toPublicSoul } from './lib/public'
import { matchesExactTokens, tokenize } from './lib/searchText'
type HydratedEntry = {
embeddingId: Id<'skillEmbeddings'>
skill: Doc<'skills'> | null
skill: NonNullable<ReturnType<typeof toPublicSkill>>
version: Doc<'skillVersions'> | null
ownerHandle: string | null
}
type SearchResult = HydratedEntry & { score: number }
@@ -29,10 +32,17 @@ export const searchSkills: ReturnType<typeof action> = action({
if (!query) return []
const queryTokens = tokenize(query)
if (queryTokens.length === 0) return []
const vector = await generateEmbedding(query)
let vector: number[]
try {
vector = await generateEmbedding(query)
} catch (error) {
console.warn('Search embedding generation failed', error)
return []
}
const limit = args.limit ?? 10
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
let hydrated: HydratedEntry[] = []
let scoreById = new Map<Id<'skillEmbeddings'>, number>()
let exactMatches: HydratedEntry[] = []
@@ -52,15 +62,27 @@ export const searchSkills: ReturnType<typeof action> = action({
results.map((result) => [result._id, result._score]),
)
const badgeMapEntries = (await ctx.runQuery(internal.search.getSkillBadgeMapsInternal, {
skillIds: hydrated.map((entry) => entry.skill._id),
})) as Array<[Id<'skills'>, SkillBadgeMap]>
const badgeMapBySkillId = new Map(badgeMapEntries)
const hydratedWithBadges = hydrated.map((entry) => ({
...entry,
skill: {
...entry.skill,
badges: badgeMapBySkillId.get(entry.skill._id) ?? {},
},
}))
const filtered = args.highlightedOnly
? hydrated.filter((entry) => entry.skill?.batch === 'highlighted')
: hydrated
? hydratedWithBadges.filter((entry) => isSkillHighlighted(entry.skill))
: hydratedWithBadges
exactMatches = filtered.filter((entry) =>
matchesExactTokens(queryTokens, [
entry.skill?.displayName,
entry.skill?.slug,
entry.skill?.summary,
entry.skill.displayName,
entry.skill.slug,
entry.skill.summary,
]),
)
@@ -83,27 +105,52 @@ export const searchSkills: ReturnType<typeof action> = action({
},
})
export const getBadgeMapsForSkills = internalQuery({
args: { skillIds: v.array(v.id('skills')) },
handler: async (ctx, args): Promise<Array<[Id<'skills'>, SkillBadgeMap]>> => {
const badgeMap = await getSkillBadgeMaps(ctx, args.skillIds)
return Array.from(badgeMap.entries())
},
})
export const hydrateResults = internalQuery({
args: { embeddingIds: v.array(v.id('skillEmbeddings')) },
handler: async (ctx, args): Promise<HydratedEntry[]> => {
const entries: HydratedEntry[] = []
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
for (const embeddingId of args.embeddingIds) {
const embedding = await ctx.db.get(embeddingId)
if (!embedding) continue
const skill = await ctx.db.get(embedding.skillId)
if (skill?.softDeletedAt) continue
const version = await ctx.db.get(embedding.versionId)
entries.push({ embeddingId, skill, version })
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
const cached = ownerHandleCache.get(ownerUserId)
if (cached) return cached
const handlePromise = ctx.db
.get(ownerUserId)
.then((owner) => owner?.handle ?? owner?._id ?? null)
ownerHandleCache.set(ownerUserId, handlePromise)
return handlePromise
}
return entries
const entries = await Promise.all(
args.embeddingIds.map(async (embeddingId) => {
const embedding = await ctx.db.get(embeddingId)
if (!embedding) return null
const skill = await ctx.db.get(embedding.skillId)
if (!skill || skill.softDeletedAt) return null
const [version, ownerHandle] = await Promise.all([
ctx.db.get(embedding.versionId),
getOwnerHandle(skill.ownerUserId),
])
const publicSkill = toPublicSkill(skill)
if (!publicSkill) return null
return { embeddingId, skill: publicSkill, version, ownerHandle }
}),
)
return entries.filter((entry): entry is HydratedEntry => entry !== null)
},
})
type HydratedSoulEntry = {
embeddingId: Id<'soulEmbeddings'>
soul: Doc<'souls'> | null
soul: NonNullable<ReturnType<typeof toPublicSoul>>
version: Doc<'soulVersions'> | null
}
@@ -119,10 +166,17 @@ export const searchSouls: ReturnType<typeof action> = action({
if (!query) return []
const queryTokens = tokenize(query)
if (queryTokens.length === 0) return []
const vector = await generateEmbedding(query)
let vector: number[]
try {
vector = await generateEmbedding(query)
} catch (error) {
console.warn('Search embedding generation failed', error)
return []
}
const limit = args.limit ?? 10
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
let hydrated: HydratedSoulEntry[] = []
let scoreById = new Map<Id<'soulEmbeddings'>, number>()
let exactMatches: HydratedSoulEntry[] = []
@@ -144,9 +198,9 @@ export const searchSouls: ReturnType<typeof action> = action({
exactMatches = hydrated.filter((entry) =>
matchesExactTokens(queryTokens, [
entry.soul?.displayName,
entry.soul?.slug,
entry.soul?.summary,
entry.soul.displayName,
entry.soul.slug,
entry.soul.summary,
]),
)
@@ -180,11 +234,21 @@ export const hydrateSoulResults = internalQuery({
const soul = await ctx.db.get(embedding.soulId)
if (soul?.softDeletedAt) continue
const version = await ctx.db.get(embedding.versionId)
entries.push({ embeddingId, soul, version })
const publicSoul = toPublicSoul(soul)
if (!publicSoul) continue
entries.push({ embeddingId, soul: publicSoul, version })
}
return entries
},
})
export const getSkillBadgeMapsInternal = internalQuery({
args: { skillIds: v.array(v.id('skills')) },
handler: async (ctx, args) => {
const badgeMap = await getSkillBadgeMaps(ctx, args.skillIds)
return Array.from(badgeMap.entries())
},
})
export const __test = { getNextCandidateLimit }
+2 -1
View File
@@ -242,7 +242,8 @@ export const ensureSeedUserInternal = internalMutation({
})
async function sha256Hex(bytes: Uint8Array) {
const digest = await crypto.subtle.digest('SHA-256', bytes)
const data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
return toHex(new Uint8Array(digest))
}
+568
View File
@@ -0,0 +1,568 @@
/**
* Skill Stat Events - Event-sourced stats processing for skills
*
* Instead of updating skill stats synchronously in the hot path (which can cause
* contention when multiple users download/star/install the same skill), we insert
* lightweight event records and process them in batches via a cron job.
*
* Flow:
* 1. User action (download, star, install) → insertStatEvent() writes to skillStatEvents table
* 2. Cron job runs every 5 minutes → processSkillStatEventsInternal() processes batches
* 3. Events are aggregated per-skill to minimize database operations
* 4. Stats are applied to skill documents and daily stats tables
* 5. Events are marked as processed (kept forever for auditing)
*/
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { MutationCtx } from './_generated/server'
import { internalAction, internalMutation, internalQuery } from './_generated/server'
import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
/**
* Event types that affect skill stats:
*
* - download: User downloaded skill as zip (+1 downloads)
* - star: User starred the skill (+1 stars)
* - unstar: User removed their star (-1 stars)
* - install_new: First time this user installed this skill (+1 installsAllTime, +1 installsCurrent)
* - install_reactivate: User re-added skill after removing it (+1 installsCurrent only)
* - install_deactivate: User removed skill from all projects (-1 installsCurrent)
* - install_clear: User cleared all telemetry data (custom delta for both allTime and current)
*/
export type StatEventKind =
| 'download'
| 'star'
| 'unstar'
| 'install_new'
| 'install_reactivate'
| 'install_deactivate'
| 'install_clear'
/**
* Insert a stat event to be processed later by the cron job.
*
* This is called from the hot path (downloads, stars, telemetry) instead of
* directly updating skill stats. It's a single insert with no read-modify-write
* cycle, so it's fast and doesn't contend with other operations on the same skill.
*
* @param ctx - Mutation context
* @param params.skillId - The skill being affected
* @param params.kind - Type of event (download, star, install_new, etc.)
* @param params.occurredAt - When the event happened (defaults to now). Important for
* daily stats bucketing - we want downloads at 11:55 PM Monday
* to count toward Monday's stats even if processed on Tuesday.
* @param params.delta - Only used for install_clear events, specifies exact delta amounts
*/
export async function insertStatEvent(
ctx: MutationCtx,
params: {
skillId: Id<'skills'>
kind: StatEventKind
occurredAt?: number
delta?: { allTime: number; current: number }
},
) {
await ctx.db.insert('skillStatEvents', {
skillId: params.skillId,
kind: params.kind,
delta: params.delta,
occurredAt: params.occurredAt ?? Date.now(),
processedAt: undefined,
})
}
/**
* Aggregated deltas for a single skill after processing multiple events.
*
* When we process a batch of 100 events, many might be for the same skill.
* Instead of updating the skill document once per event, we aggregate all
* events for each skill and apply a single update.
*
* The downloadEvents and installNewEvents arrays store the original timestamps
* so we can update daily stats with the correct day bucket for each event.
*/
type AggregatedDeltas = {
downloads: number
stars: number
installsAllTime: number
installsCurrent: number
/** Original timestamps for each download event (for daily stats bucketing) */
downloadEvents: number[]
/** Original timestamps for each new install event (for daily stats bucketing) */
installNewEvents: number[]
}
/**
* Aggregate multiple events for a single skill into net deltas.
*
* Example: If a skill has these events in the batch:
* - download (Mon 11pm)
* - download (Tue 1am)
* - star
* - unstar
* - star
*
* The result would be:
* - downloads: 2
* - stars: 1 (net: +1 -1 +1 = +1)
* - downloadEvents: [<Mon 11pm timestamp>, <Tue 1am timestamp>]
*
* This aggregation reduces the number of database operations from N events
* to 1 skill update + N daily stat updates (which themselves may coalesce
* if multiple events fall on the same day).
*/
function aggregateEvents(events: Doc<'skillStatEvents'>[]): AggregatedDeltas {
const result: AggregatedDeltas = {
downloads: 0,
stars: 0,
installsAllTime: 0,
installsCurrent: 0,
downloadEvents: [],
installNewEvents: [],
}
for (const event of events) {
switch (event.kind) {
case 'download':
result.downloads += 1
result.downloadEvents.push(event.occurredAt)
break
case 'star':
result.stars += 1
break
case 'unstar':
result.stars -= 1
break
case 'install_new':
// New user installing for the first time: count toward both lifetime and current
result.installsAllTime += 1
result.installsCurrent += 1
result.installNewEvents.push(event.occurredAt)
break
case 'install_reactivate':
// User re-added skill after removing: only affects current count
result.installsCurrent += 1
break
case 'install_deactivate':
// User removed skill from all projects: only affects current count
result.installsCurrent -= 1
break
case 'install_clear':
// User cleared telemetry: uses custom delta values (typically negative)
if (event.delta) {
result.installsAllTime += event.delta.allTime
result.installsCurrent += event.delta.current
}
break
}
}
return result
}
/**
* Process a batch of unprocessed stat events.
*
* Called by cron every 5 minutes. Processes up to batchSize events (default 100).
* If the batch is full, schedules an immediate follow-up run to drain the queue.
*
* Processing steps:
* 1. Query unprocessed events (processedAt is undefined)
* 2. Group events by skillId to minimize skill document fetches
* 3. For each skill:
* a. Fetch the skill document once
* b. Aggregate all events for this skill into net deltas
* c. Apply deltas to skill stats (downloads, stars, installs)
* d. Update daily stats for trending (using original event timestamps)
* e. Mark all events as processed
* 4. If batch was full, schedule another run immediately
*
* Aggregation levels:
* - Level 1: Batch of 100 events from the queue
* - Level 2: Group by skillId (e.g., 100 events → 30 unique skills)
* - Level 3: Aggregate events per skill (e.g., 5 events → 1 skill update)
* - Level 4: Daily stats may coalesce (e.g., 3 downloads same day → 1 upsert)
*/
export const processSkillStatEventsInternal = internalMutation({
args: { batchSize: v.optional(v.number()) },
handler: async (ctx, args) => {
const batchSize = args.batchSize ?? 100
const now = Date.now()
// Level 1: Fetch a batch of unprocessed events
const events = await ctx.db
.query('skillStatEvents')
.withIndex('by_unprocessed', (q) => q.eq('processedAt', undefined))
.take(batchSize)
if (events.length === 0) {
return { processed: 0 }
}
// Level 2: Group events by skillId to minimize database reads
// Instead of fetching the same skill document multiple times,
// we fetch it once and process all its events together
const eventsBySkill = new Map<Id<'skills'>, Doc<'skillStatEvents'>[]>()
for (const event of events) {
const existing = eventsBySkill.get(event.skillId) ?? []
existing.push(event)
eventsBySkill.set(event.skillId, existing)
}
// Process each skill's events
for (const [skillId, skillEvents] of eventsBySkill) {
const skill = await ctx.db.get(skillId)
// Skill was deleted - just mark events as processed
if (!skill) {
for (const event of skillEvents) {
await ctx.db.patch(event._id, { processedAt: now })
}
continue
}
// Level 3: Aggregate all events for this skill into net deltas
// e.g., 3 downloads + 2 stars - 1 unstar → { downloads: 3, stars: 1 }
const deltas = aggregateEvents(skillEvents)
// Apply aggregated deltas to skill stats (single update per skill)
if (
deltas.downloads !== 0 ||
deltas.stars !== 0 ||
deltas.installsAllTime !== 0 ||
deltas.installsCurrent !== 0
) {
const patch = applySkillStatDeltas(skill, {
downloads: deltas.downloads,
stars: deltas.stars,
installsAllTime: deltas.installsAllTime,
installsCurrent: deltas.installsCurrent,
})
await ctx.db.patch(skill._id, {
...patch,
updatedAt: now,
})
}
// Update daily stats for trending/leaderboards
// We use the ORIGINAL event timestamp (occurredAt) so that:
// - A download at Mon 11:55 PM counts toward Monday's stats
// - Even if the cron processes it on Tuesday
//
// Level 4: bumpDailySkillStats does its own coalescing - multiple
// events on the same day will update the same daily record
for (const occurredAt of deltas.downloadEvents) {
await bumpDailySkillStats(ctx, { skillId, now: occurredAt, downloads: 1 })
}
for (const occurredAt of deltas.installNewEvents) {
await bumpDailySkillStats(ctx, { skillId, now: occurredAt, installs: 1 })
}
// Mark all events for this skill as processed
for (const event of skillEvents) {
await ctx.db.patch(event._id, { processedAt: now })
}
}
// If we hit the batch limit, there may be more events waiting.
// Schedule an immediate follow-up run to drain the queue.
// This ensures high-volume periods don't create a backlog.
if (events.length === batchSize) {
await ctx.scheduler.runAfter(0, internal.skillStatEvents.processSkillStatEventsInternal, {
batchSize,
})
}
return { processed: events.length }
},
})
// ============================================================================
// Action-based processing (cursor-based, runs outside transaction window)
// ============================================================================
const CURSOR_KEY = 'skill_stat_events'
const EVENT_BATCH_SIZE = 500
const MAX_SKILLS_PER_RUN = 500
/**
* Fetch a batch of events after the given cursor (by _creationTime).
* Returns events sorted by _creationTime ascending.
*/
export const getUnprocessedEventBatch = internalQuery({
args: {
cursorCreationTime: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = args.limit ?? EVENT_BATCH_SIZE
const cursor = args.cursorCreationTime
// Query events after the cursor using the built-in creation time index
const events = await ctx.db
.query('skillStatEvents')
.withIndex('by_creation_time', (q) =>
cursor !== undefined ? q.gt('_creationTime', cursor) : q,
)
.take(limit)
return events
},
})
/**
* Get the current cursor position from the cursors table.
*/
export const getStatEventCursor = internalQuery({
args: {},
handler: async (ctx) => {
const cursor = await ctx.db
.query('skillStatUpdateCursors')
.withIndex('by_key', (q) => q.eq('key', CURSOR_KEY))
.unique()
return cursor?.cursorCreationTime
},
})
/**
* Validator for skill deltas passed to the mutation.
*/
const skillDeltaValidator = v.object({
skillId: v.id('skills'),
downloads: v.number(),
stars: v.number(),
installsAllTime: v.number(),
installsCurrent: v.number(),
downloadEvents: v.array(v.number()),
installNewEvents: v.array(v.number()),
})
/**
* Apply aggregated stats to skills and update the cursor.
* This is a single atomic mutation that:
* 1. Updates all affected skills with their aggregated deltas
* 2. Updates daily stats for trending
* 3. Advances the cursor to the new position
*/
export const applyAggregatedStatsAndUpdateCursor = internalMutation({
args: {
skillDeltas: v.array(skillDeltaValidator),
newCursor: v.number(),
},
handler: async (ctx, args) => {
const now = Date.now()
// Process each skill's aggregated deltas
for (const delta of args.skillDeltas) {
const skill = await ctx.db.get(delta.skillId)
// Skill was deleted - skip
if (!skill) {
continue
}
// Apply aggregated deltas to skill stats
if (
delta.downloads !== 0 ||
delta.stars !== 0 ||
delta.installsAllTime !== 0 ||
delta.installsCurrent !== 0
) {
const patch = applySkillStatDeltas(skill, {
downloads: delta.downloads,
stars: delta.stars,
installsAllTime: delta.installsAllTime,
installsCurrent: delta.installsCurrent,
})
await ctx.db.patch(skill._id, {
...patch,
updatedAt: now,
})
}
// Update daily stats for trending/leaderboards
for (const occurredAt of delta.downloadEvents) {
await bumpDailySkillStats(ctx, { skillId: delta.skillId, now: occurredAt, downloads: 1 })
}
for (const occurredAt of delta.installNewEvents) {
await bumpDailySkillStats(ctx, { skillId: delta.skillId, now: occurredAt, installs: 1 })
}
}
// Update cursor position (upsert)
const existingCursor = await ctx.db
.query('skillStatUpdateCursors')
.withIndex('by_key', (q) => q.eq('key', CURSOR_KEY))
.unique()
if (existingCursor) {
await ctx.db.patch(existingCursor._id, {
cursorCreationTime: args.newCursor,
updatedAt: now,
})
} else {
await ctx.db.insert('skillStatUpdateCursors', {
key: CURSOR_KEY,
cursorCreationTime: args.newCursor,
updatedAt: now,
})
}
return { skillsUpdated: args.skillDeltas.length }
},
})
/**
* Action that processes skill stat events in batches outside the transaction window.
*
* Algorithm:
* 1. Get current cursor position
* 2. Fetch events in batches of 500, aggregating as we go
* 3. Stop when we have >= 500 unique skills OR run out of events
* 4. Call mutation to apply all deltas and update cursor atomically
* 5. Self-schedule if we stopped due to skill limit (not exhaustion)
*/
export const processSkillStatEventsAction = internalAction({
args: {},
handler: async (ctx) => {
// Get current cursor position (convert null to undefined for consistency)
const cursorResult = await ctx.runQuery(internal.skillStatEvents.getStatEventCursor)
let cursor: number | undefined = cursorResult ?? undefined
console.log(`[STAT-AGG] Starting aggregation, cursor=${cursor ?? 'none'}`)
// Aggregated deltas per skill
const aggregatedBySkill = new Map<
Id<'skills'>,
{
downloads: number
stars: number
installsAllTime: number
installsCurrent: number
downloadEvents: number[]
installNewEvents: number[]
}
>()
let maxCreationTime: number | undefined = cursor
let exhausted = false
let totalEventsFetched = 0
// Fetch and aggregate until we have enough skills or run out of events
while (aggregatedBySkill.size < MAX_SKILLS_PER_RUN) {
const events = await ctx.runQuery(internal.skillStatEvents.getUnprocessedEventBatch, {
cursorCreationTime: cursor,
limit: EVENT_BATCH_SIZE,
})
if (events.length === 0) {
exhausted = true
break
}
totalEventsFetched += events.length
const skillsBefore = aggregatedBySkill.size
// Aggregate events into per-skill deltas
for (const event of events) {
let skillDelta = aggregatedBySkill.get(event.skillId)
if (!skillDelta) {
skillDelta = {
downloads: 0,
stars: 0,
installsAllTime: 0,
installsCurrent: 0,
downloadEvents: [],
installNewEvents: [],
}
aggregatedBySkill.set(event.skillId, skillDelta)
}
// Apply event to aggregated deltas
switch (event.kind) {
case 'download':
skillDelta.downloads += 1
skillDelta.downloadEvents.push(event.occurredAt)
break
case 'star':
skillDelta.stars += 1
break
case 'unstar':
skillDelta.stars -= 1
break
case 'install_new':
skillDelta.installsAllTime += 1
skillDelta.installsCurrent += 1
skillDelta.installNewEvents.push(event.occurredAt)
break
case 'install_reactivate':
skillDelta.installsCurrent += 1
break
case 'install_deactivate':
skillDelta.installsCurrent -= 1
break
case 'install_clear':
if (event.delta) {
skillDelta.installsAllTime += event.delta.allTime
skillDelta.installsCurrent += event.delta.current
}
break
}
// Track highest _creationTime seen
if (maxCreationTime === undefined || event._creationTime > maxCreationTime) {
maxCreationTime = event._creationTime
}
}
// Update cursor for next batch fetch
cursor = events[events.length - 1]._creationTime
console.log(
`[STAT-AGG] Fetched ${events.length} events, ${aggregatedBySkill.size - skillsBefore} new skills (${aggregatedBySkill.size} total)`,
)
// If we got fewer than requested, we've exhausted the events
if (events.length < EVENT_BATCH_SIZE) {
exhausted = true
break
}
}
// If we have nothing to process, we're done
if (aggregatedBySkill.size === 0 || maxCreationTime === undefined) {
console.log('[STAT-AGG] No events to process, done')
return { processed: 0, skillsUpdated: 0, exhausted: true }
}
// Convert map to array for mutation
const skillDeltas = Array.from(aggregatedBySkill.entries()).map(([skillId, delta]) => ({
skillId,
...delta,
}))
console.log(
`[STAT-AGG] Running mutation for ${skillDeltas.length} skills (${totalEventsFetched} total events)`,
)
// Apply all deltas and update cursor atomically
await ctx.runMutation(internal.skillStatEvents.applyAggregatedStatsAndUpdateCursor, {
skillDeltas,
newCursor: maxCreationTime,
})
// Self-schedule if we stopped because of skill limit, not exhaustion
if (!exhausted) {
console.log('[STAT-AGG] More events remaining, self-scheduling')
await ctx.scheduler.runAfter(0, internal.skillStatEvents.processSkillStatEventsAction, {})
} else {
console.log('[STAT-AGG] All events processed, done')
}
return {
skillsUpdated: skillDeltas.length,
exhausted,
}
},
})
+896 -54
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -1,7 +1,8 @@
import { v } from 'convex/values'
import type { Doc } from './_generated/dataModel'
import { mutation, query } from './_generated/server'
import { assertRole, requireUser } from './lib/access'
import { assertModerator, requireUser } from './lib/access'
import { type PublicUser, toPublicUser } from './lib/public'
export const listBySoul = query({
args: { soulId: v.id('souls'), limit: v.optional(v.number()) },
@@ -13,10 +14,10 @@ export const listBySoul = query({
.order('desc')
.take(limit)
const results: Array<{ comment: Doc<'soulComments'>; user: Doc<'users'> | null }> = []
const results: Array<{ comment: Doc<'soulComments'>; user: PublicUser | null }> = []
for (const comment of comments) {
if (comment.softDeletedAt) continue
const user = await ctx.db.get(comment.userId)
const user = toPublicUser(await ctx.db.get(comment.userId))
results.push({ comment, user })
}
return results
@@ -59,7 +60,7 @@ export const remove = mutation({
const isOwner = comment.userId === user._id
if (!isOwner) {
assertRole(user, ['admin', 'moderator'])
assertModerator(user)
}
await ctx.db.patch(comment._id, {
+5 -3
View File
@@ -1,7 +1,7 @@
import { v } from 'convex/values'
import type { Doc } from './_generated/dataModel'
import { mutation, query } from './_generated/server'
import { requireUser } from './lib/access'
import { toPublicSoul } from './lib/public'
export const isStarred = query({
args: { soulId: v.id('souls') },
@@ -59,10 +59,12 @@ export const listByUser = query({
.order('desc')
.take(limit)
const souls: Doc<'souls'>[] = []
const souls: NonNullable<ReturnType<typeof toPublicSoul>>[] = []
for (const star of stars) {
const soul = await ctx.db.get(star.soulId)
if (soul) souls.push(soul)
const publicSoul = toPublicSoul(soul)
if (!publicSoul) continue
souls.push(publicSoul)
}
return souls
},
+26 -10
View File
@@ -2,7 +2,8 @@ import { ConvexError, v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
import { assertRole, requireUser, requireUserFromAction } from './lib/access'
import { assertModerator, requireUser, requireUserFromAction } from './lib/access'
import { toPublicSoul, toPublicUser } from './lib/public'
import { getFrontmatterValue, hashSkillFiles } from './lib/skills'
import { generateSoulChangelogPreview } from './lib/soulChangelog'
import { fetchText, type PublishResult, publishSoulVersionForUser } from './lib/soulPublish'
@@ -27,9 +28,11 @@ export const getBySlug = query({
const soul = matches[0] ?? null
if (!soul || soul.softDeletedAt) return null
const latestVersion = soul.latestVersionId ? await ctx.db.get(soul.latestVersionId) : null
const owner = await ctx.db.get(soul.ownerUserId)
const owner = toPublicUser(await ctx.db.get(soul.ownerUserId))
const publicSoul = toPublicSoul(soul)
if (!publicSoul) return null
return { soul, latestVersion, owner }
return { soul: publicSoul, latestVersion, owner }
},
})
@@ -59,13 +62,21 @@ export const list = query({
.withIndex('by_owner', (q) => q.eq('ownerUserId', ownerUserId))
.order('desc')
.take(limit * 5)
return entries.filter((soul) => !soul.softDeletedAt).slice(0, limit)
return entries
.filter((soul) => !soul.softDeletedAt)
.slice(0, limit)
.map((soul) => toPublicSoul(soul))
.filter((soul): soul is NonNullable<typeof soul> => Boolean(soul))
}
const entries = await ctx.db
.query('souls')
.order('desc')
.take(limit * 5)
return entries.filter((soul) => !soul.softDeletedAt).slice(0, limit)
return entries
.filter((soul) => !soul.softDeletedAt)
.slice(0, limit)
.map((soul) => toPublicSoul(soul))
.filter((soul): soul is NonNullable<typeof soul> => Boolean(soul))
},
})
@@ -82,12 +93,17 @@ export const listPublicPage = query({
.order('desc')
.paginate({ cursor: args.cursor ?? null, numItems: limit })
const items: Array<{ soul: Doc<'souls'>; latestVersion: Doc<'soulVersions'> | null }> = []
const items: Array<{
soul: NonNullable<ReturnType<typeof toPublicSoul>>
latestVersion: Doc<'soulVersions'> | null
}> = []
for (const soul of page) {
if (soul.softDeletedAt) continue
const latestVersion = soul.latestVersionId ? await ctx.db.get(soul.latestVersionId) : null
items.push({ soul, latestVersion })
const publicSoul = toPublicSoul(soul)
if (!publicSoul) continue
items.push({ soul: publicSoul, latestVersion })
}
return { items, nextCursor: isDone ? null : continueCursor }
@@ -309,7 +325,7 @@ export const updateTags = mutation({
const soul = await ctx.db.get(args.soulId)
if (!soul) throw new Error('Soul not found')
if (soul.ownerUserId !== user._id) {
assertRole(user, ['admin', 'moderator'])
assertModerator(user)
}
const nextTags = { ...soul.tags }
@@ -377,7 +393,7 @@ export const insertVersion = internalMutation({
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.order('desc')
.take(2)
let soul = soulMatches[0] ?? null
let soul: Doc<'souls'> | null = soulMatches[0] ?? null
if (soul && soul.ownerUserId !== userId) {
throw new Error('Only the owner can publish updates')
@@ -506,7 +522,7 @@ export const setSoulSoftDeletedInternal = internalMutation({
if (!soul) throw new Error('Soul not found')
if (soul.ownerUserId !== args.userId) {
assertRole(user, ['admin', 'moderator'])
assertModerator(user)
}
const now = Date.now()
+50 -12
View File
@@ -1,7 +1,8 @@
import { v } from 'convex/values'
import type { Doc } from './_generated/dataModel'
import { mutation, query } from './_generated/server'
import { internalMutation, mutation, query } from './_generated/server'
import { requireUser } from './lib/access'
import { toPublicSkill } from './lib/public'
import { insertStatEvent } from './skillStatEvents'
export const isStarred = query({
args: { skillId: v.id('skills') },
@@ -29,10 +30,7 @@ export const toggle = mutation({
if (existing) {
await ctx.db.delete(existing._id)
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, stars: Math.max(0, skill.stats.stars - 1) },
updatedAt: Date.now(),
})
await insertStatEvent(ctx, { skillId: skill._id, kind: 'unstar' })
return { starred: false }
}
@@ -42,10 +40,7 @@ export const toggle = mutation({
createdAt: Date.now(),
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, stars: skill.stats.stars + 1 },
updatedAt: Date.now(),
})
await insertStatEvent(ctx, { skillId: skill._id, kind: 'star' })
return { starred: true }
},
@@ -60,11 +55,54 @@ export const listByUser = query({
.withIndex('by_user', (q) => q.eq('userId', args.userId))
.order('desc')
.take(limit)
const skills: Doc<'skills'>[] = []
const skills: NonNullable<ReturnType<typeof toPublicSkill>>[] = []
for (const star of stars) {
const skill = await ctx.db.get(star.skillId)
if (skill) skills.push(skill)
const publicSkill = toPublicSkill(skill)
if (!publicSkill) continue
skills.push(publicSkill)
}
return skills
},
})
export const addStarInternal = internalMutation({
args: { userId: v.id('users'), skillId: v.id('skills') },
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
const existing = await ctx.db
.query('stars')
.withIndex('by_skill_user', (q) => q.eq('skillId', args.skillId).eq('userId', args.userId))
.unique()
if (existing) return { ok: true as const, starred: true, alreadyStarred: true }
await ctx.db.insert('stars', {
skillId: args.skillId,
userId: args.userId,
createdAt: Date.now(),
})
await insertStatEvent(ctx, { skillId: skill._id, kind: 'star' })
return { ok: true as const, starred: true, alreadyStarred: false }
},
})
export const removeStarInternal = internalMutation({
args: { userId: v.id('users'), skillId: v.id('skills') },
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
const existing = await ctx.db
.query('stars')
.withIndex('by_skill_user', (q) => q.eq('skillId', args.skillId).eq('userId', args.userId))
.unique()
if (!existing) return { ok: true as const, unstarred: false, alreadyUnstarred: true }
await ctx.db.delete(existing._id)
await insertStatEvent(ctx, { skillId: skill._id, kind: 'unstar' })
return { ok: true as const, unstarred: true, alreadyUnstarred: false }
},
})
+205
View File
@@ -0,0 +1,205 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc } from './_generated/dataModel'
import type { ActionCtx } from './_generated/server'
import { internalAction, internalMutation, internalQuery } from './_generated/server'
const DEFAULT_BATCH_SIZE = 200
const MAX_BATCH_SIZE = 1000
const DEFAULT_MAX_BATCHES = 5
const MAX_MAX_BATCHES = 50
const BACKFILL_STATE_KEY = 'default'
export const backfillSkillStatFieldsInternal = internalMutation({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
const { page, isDone, continueCursor } = await ctx.db
.query('skills')
.order('asc')
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
let patched = 0
for (const skill of page) {
const next = buildSkillStatPatch(skill)
if (!next) continue
await ctx.db.patch(skill._id, next)
patched += 1
}
return {
ok: true as const,
scanned: page.length,
patched,
cursor: isDone ? null : continueCursor,
isDone,
}
},
})
type BackfillState = {
cursor: string | null
doneAt?: number
}
type BackfillActionArgs = {
batchSize?: number
maxBatches?: number
resetCursor?: boolean
}
type BackfillStats = {
scanned: number
patched: number
batches: number
}
type BackfillActionResult = {
ok: true
isDone: boolean
cursor: string | null
stats: BackfillStats
}
export const getSkillStatBackfillStateInternal = internalQuery({
args: {},
handler: async (ctx): Promise<BackfillState> => {
const state = await ctx.db
.query('skillStatBackfillState')
.withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
.unique()
return { cursor: state?.cursor ?? null, doneAt: state?.doneAt }
},
})
export const setSkillStatBackfillStateInternal = internalMutation({
args: {
cursor: v.optional(v.string()),
doneAt: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now()
const state = await ctx.db
.query('skillStatBackfillState')
.withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
.unique()
if (!state) {
await ctx.db.insert('skillStatBackfillState', {
key: BACKFILL_STATE_KEY,
cursor: args.cursor,
doneAt: args.doneAt,
updatedAt: now,
})
return { ok: true as const }
}
await ctx.db.patch(state._id, {
cursor: args.cursor,
doneAt: args.doneAt,
updatedAt: now,
})
return { ok: true as const }
},
})
async function runSkillStatBackfillInternalHandler(
ctx: ActionCtx,
args: BackfillActionArgs,
): Promise<BackfillActionResult> {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
if (args.resetCursor) {
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
cursor: undefined,
doneAt: undefined,
})
}
const state = (await ctx.runQuery(
internal.statsMaintenance.getSkillStatBackfillStateInternal,
{},
)) as BackfillState
if (state.doneAt && !args.resetCursor) {
return {
ok: true,
isDone: true,
cursor: null,
stats: { scanned: 0, patched: 0, batches: 0 },
}
}
let cursor: string | null = state.cursor ?? null
const stats: BackfillStats = { scanned: 0, patched: 0, batches: 0 }
for (let i = 0; i < maxBatches; i += 1) {
const result = (await ctx.runMutation(
internal.statsMaintenance.backfillSkillStatFieldsInternal,
{
cursor: cursor ?? undefined,
batchSize,
},
)) as { scanned: number; patched: number; cursor: string | null; isDone: boolean }
stats.scanned += result.scanned
stats.patched += result.patched
stats.batches += 1
cursor = result.cursor
if (result.isDone) {
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
cursor: undefined,
doneAt: Date.now(),
})
return { ok: true, isDone: true, cursor: null, stats }
}
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
cursor: cursor ?? undefined,
doneAt: undefined,
})
}
return { ok: true, isDone: false, cursor, stats }
}
export const runSkillStatBackfillInternal: ReturnType<typeof internalAction> = internalAction({
args: {
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
resetCursor: v.optional(v.boolean()),
},
handler: runSkillStatBackfillInternalHandler,
})
function buildSkillStatPatch(skill: Doc<'skills'>) {
const stats = skill.stats
const nextDownloads = stats.downloads
const nextStars = stats.stars
const nextInstallsCurrent = stats.installsCurrent ?? 0
const nextInstallsAllTime = stats.installsAllTime ?? 0
if (
skill.statsDownloads === nextDownloads &&
skill.statsStars === nextStars &&
skill.statsInstallsCurrent === nextInstallsCurrent &&
skill.statsInstallsAllTime === nextInstallsAllTime
) {
return null
}
return {
statsDownloads: nextDownloads,
statsStars: nextStars,
statsInstallsCurrent: nextInstallsCurrent,
statsInstallsAllTime: nextInstallsAllTime,
}
}
function clampInt(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
+28 -37
View File
@@ -4,6 +4,7 @@ import type { Id } from './_generated/dataModel'
import type { MutationCtx, QueryCtx } from './_generated/server'
import { internalMutation, mutation, query } from './_generated/server'
import { requireUser } from './lib/access'
import { insertStatEvent } from './skillStatEvents'
const TELEMETRY_STALE_MS = 120 * 24 * 60 * 60 * 1000
@@ -87,7 +88,13 @@ export const getMyInstalled = query({
lastSeenAt: number
expiredAt?: number
skills: Array<{
skill: { slug: string; displayName: string; summary?: string; stats: unknown }
skill: {
slug: string
displayName: string
summary?: string
stats: unknown
ownerUserId: Id<'users'>
}
firstSeenAt: number
lastSeenAt: number
lastVersion?: string
@@ -104,7 +111,13 @@ export const getMyInstalled = query({
const filtered = includeRemoved ? installs : installs.filter((entry) => !entry.removedAt)
const skills: Array<{
skill: { slug: string; displayName: string; summary?: string; stats: unknown }
skill: {
slug: string
displayName: string
summary?: string
stats: unknown
ownerUserId: Id<'users'>
}
firstSeenAt: number
lastSeenAt: number
lastVersion?: string
@@ -120,6 +133,7 @@ export const getMyInstalled = query({
displayName: skill.displayName,
summary: skill.summary,
stats: skill.stats,
ownerUserId: skill.ownerUserId,
},
firstSeenAt: entry.firstSeenAt,
lastSeenAt: entry.lastSeenAt,
@@ -157,24 +171,13 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<'use
await ctx.db.delete(entry._id)
continue
}
const stats = skill.stats as {
downloads: number
installsCurrent?: number
installsAllTime?: number
stars: number
versions: number
comments: number
}
await ctx.db.patch(skill._id, {
stats: {
...stats,
installsCurrent: Math.max(
0,
(stats.installsCurrent ?? 0) - (entry.activeRoots > 0 ? 1 : 0),
),
installsAllTime: Math.max(0, (stats.installsAllTime ?? 0) - 1),
await insertStatEvent(ctx, {
skillId: skill._id,
kind: 'install_clear',
delta: {
allTime: -1,
current: entry.activeRoots > 0 ? -1 : 0,
},
updatedAt: Date.now(),
})
await ctx.db.delete(entry._id)
}
@@ -381,25 +384,13 @@ async function bumpSkillInstallCounts(
ctx: MutationCtx,
params: { skillId: Id<'skills'>; deltaAllTime: number; deltaCurrent: number },
) {
const skill = await ctx.db.get(params.skillId)
if (!skill) return
const stats = skill.stats as {
downloads: number
installsCurrent?: number
installsAllTime?: number
stars: number
versions: number
comments: number
if (params.deltaAllTime === 1 && params.deltaCurrent === 1) {
await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_new' })
} else if (params.deltaAllTime === 0 && params.deltaCurrent === 1) {
await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_reactivate' })
} else if (params.deltaAllTime === 0 && params.deltaCurrent === -1) {
await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_deactivate' })
}
await ctx.db.patch(skill._id, {
stats: {
...stats,
installsAllTime: Math.max(0, (stats.installsAllTime ?? 0) + params.deltaAllTime),
installsCurrent: Math.max(0, (stats.installsCurrent ?? 0) + params.deltaCurrent),
},
updatedAt: Date.now(),
})
}
async function expireStaleRoots(
+12 -5
View File
@@ -1,13 +1,19 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import { v } from 'convex/values'
import { internal } from './_generated/api'
import { mutation, query } from './_generated/server'
import { assertRole, requireUser } from './lib/access'
import { internalQuery, mutation, query } from './_generated/server'
import { assertAdmin, requireUser } from './lib/access'
import { toPublicUser } from './lib/public'
const DEFAULT_ROLE = 'user'
const ADMIN_HANDLE = 'steipete'
export const getById = query({
args: { userId: v.id('users') },
handler: async (ctx, args) => toPublicUser(await ctx.db.get(args.userId)),
})
export const getByIdInternal = internalQuery({
args: { userId: v.id('users') },
handler: async (ctx, args) => ctx.db.get(args.userId),
})
@@ -78,7 +84,7 @@ export const list = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertRole(user, ['admin'])
assertAdmin(user)
const limit = args.limit ?? 50
return ctx.db.query('users').order('desc').take(limit)
},
@@ -87,10 +93,11 @@ export const list = query({
export const getByHandle = query({
args: { handle: v.string() },
handler: async (ctx, args) => {
return ctx.db
const user = await ctx.db
.query('users')
.withIndex('handle', (q) => q.eq('handle', args.handle))
.unique()
return toPublicUser(user)
},
})
@@ -101,7 +108,7 @@ export const setRole = mutation({
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertRole(user, ['admin'])
assertAdmin(user)
await ctx.db.patch(args.userId, { role: args.role, updatedAt: Date.now() })
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
+2 -2
View File
@@ -11,7 +11,7 @@ export const sendDiscordWebhook = internalAction({
summary: v.optional(v.string()),
version: v.optional(v.string()),
ownerHandle: v.optional(v.string()),
batch: v.optional(v.string()),
highlighted: v.optional(v.boolean()),
tags: v.optional(v.array(v.string())),
}),
},
@@ -21,7 +21,7 @@ export const sendDiscordWebhook = internalAction({
event: args.event,
slug: args.skill.slug,
version: args.skill.version ?? null,
batch: args.skill.batch ?? null,
highlighted: args.skill.highlighted ?? false,
highlightedOnly: config.highlightedOnly,
}
if (!shouldSendWebhook(args.event, args.skill, config)) {
+2 -1
View File
@@ -30,7 +30,8 @@ Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Ret
Public read:
- `GET /api/v1/search?q=...`
- `GET /api/v1/skills?limit=&cursor=`
- `GET /api/v1/skills?limit=&cursor=&sort=`
- `sort`: `updated` (default), `downloads`, `stars` (`rating`), `installsCurrent` (`installs`), `installsAllTime`, `trending`
- `GET /api/v1/skills/{slug}`
- `GET /api/v1/skills/{slug}/versions?limit=&cursor=`
- `GET /api/v1/skills/{slug}/versions/{version}`
+9 -1
View File
@@ -47,6 +47,12 @@ 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=...`.
@@ -55,7 +61,9 @@ Stores your API token + cached registry URL.
- Lists latest updated skills via `/api/v1/skills?limit=...` (sorted by `updatedAt` desc).
- Flags:
- `--limit <n>` (150, default: 25)
- `--limit <n>` (1200, default: 25)
- `--sort newest|downloads|rating|installs|installsAllTime|trending` (default: newest)
- `--json` (machine-readable output)
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
### `install <slug>`
+21 -2
View File
@@ -44,8 +44,13 @@ Response:
Query params:
- `limit` (optional): integer
- `cursor` (optional): pagination cursor
- `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).
Response:
@@ -140,6 +145,20 @@ Publishes a new version.
Soft-delete / restore a skill (owner/admin only).
### `POST /api/v1/stars/{slug}` / `DELETE /api/v1/stars/{slug}`
Add/remove a star (highlights). Both endpoints are idempotent.
Responses:
```json
{ "ok": true, "starred": true, "alreadyStarred": false }
```
```json
{ "ok": true, "unstarred": true, "alreadyUnstarred": false }
```
## Legacy CLI endpoints (deprecated)
Still supported for older CLI versions:
+11 -5
View File
@@ -29,7 +29,7 @@ read_when:
- `handle` (GitHub login)
- `name`, `bio`
- `avatarUrl` (GitHub, fallback gravatar)
- `role`: `admin | moderator | user`
- `role`: `admin | moderator | user` (moderators can soft-delete and flag; admins can hard-delete + change owners)
- `createdAt`, `updatedAt`
### Skill
@@ -40,9 +40,14 @@ read_when:
- `latestVersionId`
- `latestTagVersionId` (for `latest` tag)
- `tags` map: `{ tag -> versionId }`
- `badges`: `{ redactionApproved?: { byUserId, at } }`
- `badges`: `{ redactionApproved?: { byUserId, at }, highlighted?: { byUserId, at }, official?: { byUserId, at }, deprecated?: { byUserId, at } }`
- `official` marks admin-verified/official skills.
- `deprecated` marks skills that should not be used for new integrations.
- `moderationStatus`: `active | hidden | removed`
- `moderationFlags`: `string[]` (automatic detection)
- `moderationNotes`, `moderationReason`
- `hiddenAt`, `hiddenBy`, `lastReviewedAt`, `reportCount`
- `stats`: `{ downloads, stars, versions, comments }`
- `status`: `active` only (soft-delete on version/comment only)
- `createdAt`, `updatedAt`
### SkillVersion
@@ -118,7 +123,8 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
## Auth + roles
- Convex Auth with GitHub OAuth App.
- Default role `user`; bootstrap `steipete` to `admin` on first login.
- Admin UI to promote/demote roles; all changes logged.
- Management console: moderators can hide/restore skills + mark duplicates; admins can change owners, approve badges, and hard-delete.
- Role changes are admin-only and audited.
## Upload flow (50MB per version)
1) Client requests upload session.
@@ -151,7 +157,7 @@ Seed data lives in `convex/seed.ts` for local dev.
- Soft-delete versions; downloads remain for non-deleted versions only.
## UI (SPA)
- Home: search + filters + trending/featured + “Highlighted” batch.
- Home: search + filters + trending/featured + “Highlighted” badge.
- Skill detail: README render, files list, version history, tags, stats, badges.
- Upload/edit: file picker + version + tag + changelog.
- Account settings: name + delete account (soft delete).
+1 -1
View File
@@ -4,13 +4,13 @@ import { spawnSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { unzipSync } from 'fflate'
import {
ApiRoutes,
ApiV1SearchResponseSchema,
ApiV1WhoamiResponseSchema,
parseArk,
} from 'clawdhub-schema'
import { unzipSync } from 'fflate'
import { Agent, setGlobalDispatcher } from 'undici'
import { describe, expect, it } from 'vitest'
import { readGlobalConfig } from '../packages/clawdhub/src/config'
+10 -6
View File
@@ -6,10 +6,12 @@
"packages/*"
],
"scripts": {
"preinstall": "bunx only-allow bun",
"dev": "bun --bun vite dev --port 3000",
"build": "bun --bun vite build",
"preview": "bun --bun vite preview",
"docs:list": "bun scripts/docs-list.ts",
"check:peers": "bun scripts/check-peer-deps.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vitest run -c vitest.e2e.config.ts",
@@ -23,7 +25,7 @@
"format": "biome format --write ."
},
"dependencies": {
"@auth/core": "^0.34.3",
"@auth/core": "^0.37.4",
"@convex-dev/auth": "^0.0.90",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
@@ -34,14 +36,15 @@
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-router": "^1.151.3",
"@tanstack/react-router-devtools": "^1.151.3",
"@tanstack/react-start": "^1.151.5",
"@tanstack/router-plugin": "^1.151.5",
"@tanstack/react-router": "^1.151.6",
"@tanstack/react-router-devtools": "^1.151.6",
"@tanstack/react-start": "^1.152.0",
"@tanstack/router-plugin": "^1.151.6",
"@vercel/analytics": "^1.6.1",
"clawdhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.31.5",
"convex": "^1.31.6",
"convex-helpers": "^0.1.111",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.8",
"lucide-react": "^0.562.0",
@@ -70,6 +73,7 @@
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.4.0",
"only-allow": "^1.2.1",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"typescript": "^5.9.3",
+4 -3
View File
@@ -1,7 +1,7 @@
{
"name": "clawdhub",
"version": "0.2.0",
"description": "ClawdHub CLI \u2014 install, update, search, and publish agent skills.",
"version": "0.3.0",
"description": "ClawdHub CLI \\u2014 install, update, search, and publish agent skills.",
"license": "MIT",
"type": "module",
"bin": {
@@ -28,7 +28,8 @@
"mime": "^4.1.0",
"ora": "^9.0.0",
"p-retry": "^7.1.1",
"semver": "^7.7.3"
"semver": "^7.7.3",
"undici": "^7.16.0"
},
"devDependencies": {
"@types/node": "^25.0.9",
+30 -2
View File
@@ -8,7 +8,9 @@ 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'
@@ -188,15 +190,21 @@ program
.description('Browse latest updated skills from the registry')
.option(
'--limit <n>',
'Number of skills to show (max 50)',
'Number of skills to show (max 200)',
(value) => Number.parseInt(value, 10),
25,
)
.option(
'--sort <order>',
'Sort by newest, downloads, rating, installs, installsAllTime, or trending',
'newest',
)
.option('--json', 'Output JSON')
.action(async (options) => {
const opts = await resolveGlobalOpts()
const limit =
typeof options.limit === 'number' && Number.isFinite(options.limit) ? options.limit : 25
await cmdExplore(opts, limit)
await cmdExplore(opts, { limit, sort: options.sort, json: options.json })
})
program
@@ -234,6 +242,26 @@ program
await cmdUndeleteSkill(opts, slug, options, isInputAllowed())
})
program
.command('star')
.description('Add a skill to your highlights')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdStarSkill(opts, slug, options, isInputAllowed())
})
program
.command('unstar')
.description('Remove a skill from your highlights')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
const opts = await resolveGlobalOpts()
await cmdUnstarSkill(opts, slug, options, isInputAllowed())
})
program
.command('sync')
.description('Scan local skills and publish new/updated ones')
@@ -1,11 +1,14 @@
/* @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')
@@ -13,13 +16,51 @@ vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const mockSpinner = { stop: vi.fn(), fail: vi.fn() }
const mockSpinner = {
stop: vi.fn(),
fail: vi.fn(),
start: vi.fn(),
succeed: vi.fn(),
isSpinning: false,
text: '',
}
vi.mock('../ui.js', () => ({
createSpinner: vi.fn(() => mockSpinner),
fail: (message: string) => {
throw new Error(message)
},
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
isInteractive: () => false,
promptConfirm: vi.fn(async () => false),
}))
const { clampLimit, cmdExplore, formatExploreLine } = await import('./skills')
vi.mock('../../skills.js', () => ({
extractZipToDir: vi.fn(),
hashSkillFiles: vi.fn(),
listTextFiles: vi.fn(),
readLockfile: vi.fn(),
readSkillOrigin: vi.fn(),
writeLockfile: vi.fn(),
writeSkillOrigin: vi.fn(),
}))
vi.mock('node:fs/promises', () => ({
mkdir: vi.fn(),
rm: vi.fn(),
stat: vi.fn(),
}))
const { clampLimit, cmdExplore, cmdUpdate, formatExploreLine } = await import('./skills')
const {
extractZipToDir,
hashSkillFiles,
listTextFiles,
readLockfile,
readSkillOrigin,
writeLockfile,
writeSkillOrigin,
} = await import('../../skills.js')
const { rm, stat } = await import('node:fs/promises')
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -43,7 +84,9 @@ describe('explore helpers', () => {
expect(clampLimit(0)).toBe(1)
expect(clampLimit(1)).toBe(1)
expect(clampLimit(50)).toBe(50)
expect(clampLimit(99)).toBe(50)
expect(clampLimit(99)).toBe(99)
expect(clampLimit(200)).toBe(200)
expect(clampLimit(250)).toBe(200)
expect(clampLimit(Number.NaN)).toBe(25)
expect(clampLimit(Number.POSITIVE_INFINITY)).toBe(25)
expect(clampLimit(Number.NaN, 10)).toBe(10)
@@ -68,7 +111,7 @@ describe('cmdExplore', () => {
it('clamps limit and handles empty results', async () => {
mockApiRequest.mockResolvedValue({ items: [] })
await cmdExplore(makeOpts(), 0)
await cmdExplore(makeOpts(), { limit: 0 })
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
@@ -87,12 +130,62 @@ describe('cmdExplore', () => {
}
mockApiRequest.mockResolvedValue({ items: [item] })
await cmdExplore(makeOpts(), 100)
await cmdExplore(makeOpts(), { limit: 250 })
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
expect(url.searchParams.get('limit')).toBe('50')
expect(url.searchParams.get('limit')).toBe('200')
expect(mockLog).toHaveBeenCalledWith(formatExploreLine(item))
nowSpy.mockRestore()
})
it('supports sort and json output', async () => {
const payload = { items: [], nextCursor: null }
mockApiRequest.mockResolvedValue(payload)
await cmdExplore(makeOpts(), { limit: 10, sort: 'installs', json: true })
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
expect(url.searchParams.get('limit')).toBe('10')
expect(url.searchParams.get('sort')).toBe('installsCurrent')
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(payload, null, 2))
})
it('supports all-time installs and trending sorts', async () => {
mockApiRequest.mockResolvedValue({ items: [], nextCursor: null })
await cmdExplore(makeOpts(), { limit: 5, sort: 'installsAllTime' })
await cmdExplore(makeOpts(), { limit: 5, sort: 'trending' })
const first = new URL(String(mockApiRequest.mock.calls[0]?.[1]?.url))
const second = new URL(String(mockApiRequest.mock.calls[1]?.[1]?.url))
expect(first.searchParams.get('sort')).toBe('installsAllTime')
expect(second.searchParams.get('sort')).toBe('trending')
})
})
describe('cmdUpdate', () => {
it('uses path-based skill lookup when no local fingerprint is available', async () => {
mockApiRequest.mockResolvedValue({ latestVersion: { version: '1.0.0' } })
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]))
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: { demo: { version: '0.1.0', installedAt: 123 } },
})
vi.mocked(writeLockfile).mockResolvedValue()
vi.mocked(readSkillOrigin).mockResolvedValue(null)
vi.mocked(writeSkillOrigin).mockResolvedValue()
vi.mocked(extractZipToDir).mockResolvedValue()
vi.mocked(listTextFiles).mockResolvedValue([])
vi.mocked(hashSkillFiles).mockReturnValue({ fingerprint: 'hash', files: [] })
vi.mocked(stat).mockRejectedValue(new Error('missing'))
vi.mocked(rm).mockResolvedValue()
await cmdUpdate(makeOpts(), 'demo', {}, false)
const [, args] = mockApiRequest.mock.calls[0] ?? []
expect(args?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent('demo')}`)
expect(args?.url).toBeUndefined()
})
})
+53 -4
View File
@@ -153,7 +153,7 @@ export async function cmdUpdate(
} else {
const meta = await apiRequest(
registry,
{ method: 'GET', url: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
ApiV1SkillResponseSchema,
)
resolveResult = { match: null, latestVersion: meta.latestVersion ?? null }
@@ -242,13 +242,27 @@ export async function cmdList(opts: GlobalOpts) {
}
}
export async function cmdExplore(opts: GlobalOpts, limit = 25) {
type ExploreSort = 'newest' | 'downloads' | 'rating' | 'installs' | 'installsAllTime' | 'trending'
type ApiExploreSort =
| 'updated'
| 'downloads'
| 'stars'
| 'installsCurrent'
| 'installsAllTime'
| 'trending'
export async function cmdExplore(
opts: GlobalOpts,
options: { limit?: number; sort?: string; json?: boolean } = {},
) {
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching latest skills')
try {
const url = new URL(ApiRoutes.skills, registry)
const boundedLimit = clampLimit(limit)
const boundedLimit = clampLimit(options.limit ?? 25)
const { apiSort } = resolveExploreSort(options.sort)
url.searchParams.set('limit', String(boundedLimit))
if (apiSort !== 'updated') url.searchParams.set('sort', apiSort)
const result = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
@@ -256,6 +270,10 @@ export async function cmdExplore(opts: GlobalOpts, limit = 25) {
)
spinner.stop()
if (options.json) {
console.log(JSON.stringify(result, null, 2))
return
}
if (result.items.length === 0) {
console.log('No skills found.')
return
@@ -284,7 +302,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), 50)
return Math.min(Math.max(1, limit), 200)
}
function formatRelativeTime(timestamp: number): string {
@@ -310,6 +328,37 @@ 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)
@@ -0,0 +1,46 @@
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import { ApiRoutes, ApiV1StarResponseSchema } from '../../schema/index.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
async function requireToken() {
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawdhub login')
return token
}
export async function cmdStarSkill(
opts: GlobalOpts,
slugArg: string,
options: { yes?: boolean },
inputAllowed: boolean,
) {
const slug = slugArg.trim().toLowerCase()
if (!slug) fail('Slug required')
const allowPrompt = isInteractive() && inputAllowed !== false
if (!options.yes) {
if (!allowPrompt) fail('Pass --yes (no input)')
const ok = await promptConfirm(`Star ${slug}?`)
if (!ok) return
}
const token = await requireToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`Starring ${slug}`)
try {
const result = await apiRequest(
registry,
{ method: 'POST', path: `${ApiRoutes.stars}/${encodeURIComponent(slug)}`, token },
ApiV1StarResponseSchema,
)
spinner.succeed(result.alreadyStarred ? `OK. ${slug} already starred.` : `OK. Starred ${slug}`)
return result
} catch (error) {
spinner.fail(formatError(error))
throw error
}
}
@@ -0,0 +1,48 @@
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import { ApiRoutes, ApiV1UnstarResponseSchema } from '../../schema/index.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
async function requireToken() {
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawdhub login')
return token
}
export async function cmdUnstarSkill(
opts: GlobalOpts,
slugArg: string,
options: { yes?: boolean },
inputAllowed: boolean,
) {
const slug = slugArg.trim().toLowerCase()
if (!slug) fail('Slug required')
const allowPrompt = isInteractive() && inputAllowed !== false
if (!options.yes) {
if (!allowPrompt) fail('Pass --yes (no input)')
const ok = await promptConfirm(`Unstar ${slug}?`)
if (!ok) return
}
const token = await requireToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`Unstarring ${slug}`)
try {
const result = await apiRequest(
registry,
{ method: 'DELETE', path: `${ApiRoutes.stars}/${encodeURIComponent(slug)}`, token },
ApiV1UnstarResponseSchema,
)
spinner.succeed(
result.alreadyUnstarred ? `OK. ${slug} already unstarred.` : `OK. Unstarred ${slug}`,
)
return result
} catch (error) {
spinner.fail(formatError(error))
throw error
}
}
+1
View File
@@ -16,6 +16,7 @@ export const ApiRoutes = {
resolve: '/api/v1/resolve',
download: '/api/v1/download',
skills: '/api/v1/skills',
stars: '/api/v1/stars',
souls: '/api/v1/souls',
whoami: '/api/v1/whoami',
} as const
+12
View File
@@ -215,6 +215,18 @@ export const ApiV1DeleteResponseSchema = type({
ok: 'true',
})
export const ApiV1StarResponseSchema = type({
ok: 'true',
starred: 'boolean',
alreadyStarred: 'boolean',
})
export const ApiV1UnstarResponseSchema = type({
ok: 'true',
unstarred: 'boolean',
alreadyUnstarred: 'boolean',
})
export const SkillInstallSpecSchema = type({
id: 'string?',
kind: '"brew"|"node"|"go"|"uv"',
+1
View File
@@ -15,6 +15,7 @@ export declare const ApiRoutes: {
readonly resolve: "/api/v1/resolve";
readonly download: "/api/v1/download";
readonly skills: "/api/v1/skills";
readonly stars: "/api/v1/stars";
readonly souls: "/api/v1/souls";
readonly whoami: "/api/v1/whoami";
};
+1
View File
@@ -15,6 +15,7 @@ export const ApiRoutes = {
resolve: '/api/v1/resolve',
download: '/api/v1/download',
skills: '/api/v1/skills',
stars: '/api/v1/stars',
souls: '/api/v1/souls',
whoami: '/api/v1/whoami',
};
+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,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
+16 -6
View File
@@ -221,6 +221,16 @@ export declare const ApiV1PublishResponseSchema: import("arktype/internal/varian
export declare const ApiV1DeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
}, {}>;
export declare const ApiV1StarResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
starred: boolean;
alreadyStarred: boolean;
}, {}>;
export declare const ApiV1UnstarResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
unstarred: boolean;
alreadyUnstarred: boolean;
}, {}>;
export declare const SkillInstallSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{
kind: "brew" | "node" | "go" | "uv";
id?: string | undefined;
@@ -237,20 +247,20 @@ export declare const NixPluginSpecSchema: import("arktype/internal/variants/obje
systems?: string[] | undefined;
}, {}>;
export type NixPluginSpec = (typeof NixPluginSpecSchema)[inferred];
export declare const ClawdbotConfigSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{
export declare const MoltbotConfigSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{
requiredEnv?: string[] | undefined;
stateDirs?: string[] | undefined;
example?: string | undefined;
}, {}>;
export type ClawdbotConfigSpec = (typeof ClawdbotConfigSpecSchema)[inferred];
export declare const ClawdisRequiresSchema: import("arktype/internal/variants/object.ts").ObjectType<{
export type MoltbotConfigSpec = (typeof MoltbotConfigSpecSchema)[inferred];
export declare const MoltbotRequiresSchema: import("arktype/internal/variants/object.ts").ObjectType<{
bins?: string[] | undefined;
anyBins?: string[] | undefined;
env?: string[] | undefined;
config?: string[] | undefined;
}, {}>;
export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred];
export declare const ClawdisSkillMetadataSchema: import("arktype/internal/variants/object.ts").ObjectType<{
export type MoltbotRequires = (typeof MoltbotRequiresSchema)[inferred];
export declare const MoltbotSkillMetadataSchema: import("arktype/internal/variants/object.ts").ObjectType<{
always?: boolean | undefined;
skillKey?: string | undefined;
primaryEnv?: string | undefined;
@@ -284,4 +294,4 @@ export declare const ClawdisSkillMetadataSchema: import("arktype/internal/varian
example?: string | undefined;
} | undefined;
}, {}>;
export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred];
export type MoltbotSkillMetadata = (typeof MoltbotSkillMetadataSchema)[inferred];
+16 -6
View File
@@ -192,6 +192,16 @@ export const ApiV1PublishResponseSchema = type({
export const ApiV1DeleteResponseSchema = type({
ok: 'true',
});
export const ApiV1StarResponseSchema = type({
ok: 'true',
starred: 'boolean',
alreadyStarred: 'boolean',
});
export const ApiV1UnstarResponseSchema = type({
ok: 'true',
unstarred: 'boolean',
alreadyUnstarred: 'boolean',
});
export const SkillInstallSpecSchema = type({
id: 'string?',
kind: '"brew"|"node"|"go"|"uv"',
@@ -206,18 +216,18 @@ export const NixPluginSpecSchema = type({
plugin: 'string',
systems: 'string[]?',
});
export const ClawdbotConfigSpecSchema = type({
export const MoltbotConfigSpecSchema = type({
requiredEnv: 'string[]?',
stateDirs: 'string[]?',
example: 'string?',
});
export const ClawdisRequiresSchema = type({
export const MoltbotRequiresSchema = type({
bins: 'string[]?',
anyBins: 'string[]?',
env: 'string[]?',
config: 'string[]?',
});
export const ClawdisSkillMetadataSchema = type({
export const MoltbotSkillMetadataSchema = type({
always: 'boolean?',
skillKey: 'string?',
primaryEnv: 'string?',
@@ -225,9 +235,9 @@ export const ClawdisSkillMetadataSchema = type({
homepage: 'string?',
os: 'string[]?',
cliHelp: 'string?',
requires: ClawdisRequiresSchema.optional(),
requires: MoltbotRequiresSchema.optional(),
install: SkillInstallSpecSchema.array().optional(),
nix: NixPluginSpecSchema.optional(),
config: ClawdbotConfigSpecSchema.optional(),
config: MoltbotConfigSpecSchema.optional(),
});
//# sourceMappingURL=schemas.js.map
//# sourceMappingURL=schemas.js.map
File diff suppressed because one or more lines are too long
+1
View File
@@ -16,6 +16,7 @@ export const ApiRoutes = {
resolve: '/api/v1/resolve',
download: '/api/v1/download',
skills: '/api/v1/skills',
stars: '/api/v1/stars',
souls: '/api/v1/souls',
whoami: '/api/v1/whoami',
} as const
+12
View File
@@ -226,6 +226,18 @@ export const ApiV1DeleteResponseSchema = type({
ok: 'true',
})
export const ApiV1StarResponseSchema = type({
ok: 'true',
starred: 'boolean',
alreadyStarred: 'boolean',
})
export const ApiV1UnstarResponseSchema = type({
ok: 'true',
unstarred: 'boolean',
alreadyUnstarred: 'boolean',
})
export const SkillInstallSpecSchema = type({
id: 'string?',
kind: '"brew"|"node"|"go"|"uv"',
+56
View File
@@ -0,0 +1,56 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import semver from 'semver'
type PackageJson = {
name?: string
version?: string
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
}
async function readJson(path: string): Promise<PackageJson> {
const raw = await readFile(path, 'utf8')
return JSON.parse(raw) as PackageJson
}
async function main() {
const root = process.cwd()
const rootPkgPath = join(root, 'package.json')
const authPkgPath = join(root, 'node_modules', '@convex-dev', 'auth', 'package.json')
const corePkgPath = join(root, 'node_modules', '@auth', 'core', 'package.json')
const rootPkg = await readJson(rootPkgPath)
const authPkg = await readJson(authPkgPath)
const corePkg = await readJson(corePkgPath)
const peerRange = authPkg.peerDependencies?.['@auth/core']
const declaredRange = rootPkg.dependencies?.['@auth/core']
const installedVersion = corePkg.version
if (!peerRange) {
throw new Error('Missing @auth/core peer range in @convex-dev/auth package.json')
}
if (!declaredRange) {
throw new Error('Missing @auth/core dependency in root package.json')
}
if (!installedVersion) {
throw new Error('Missing @auth/core version in node_modules')
}
if (!semver.intersects(declaredRange, peerRange, { includePrerelease: true })) {
throw new Error(
`@auth/core range mismatch: package.json declares "${declaredRange}" but @convex-dev/auth requires "${peerRange}"`,
)
}
if (!semver.satisfies(installedVersion, peerRange, { includePrerelease: true })) {
throw new Error(
`@auth/core version mismatch: installed "${installedVersion}" does not satisfy @convex-dev/auth peer "${peerRange}"`,
)
}
console.log(`peer ok: @auth/core ${installedVersion} satisfies @convex-dev/auth (${peerRange})`)
}
await main()
+26 -22
View File
@@ -4,6 +4,7 @@ import { vi } from 'vitest'
import { SkillDetailPage } from '../components/SkillDetailPage'
const navigateMock = vi.fn()
const useAuthStatusMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
@@ -13,18 +14,27 @@ const useQueryMock = vi.fn()
const getReadmeMock = vi.fn()
vi.mock('convex/react', () => ({
useConvexAuth: () => ({ isAuthenticated: false }),
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
useAction: () => getReadmeMock,
}))
vi.mock('../lib/useAuthStatus', () => ({
useAuthStatus: () => useAuthStatusMock(),
}))
describe('SkillDetailPage', () => {
beforeEach(() => {
useQueryMock.mockReset()
getReadmeMock.mockReset()
navigateMock.mockReset()
useAuthStatusMock.mockReset()
getReadmeMock.mockResolvedValue({ text: '' })
useAuthStatusMock.mockReturnValue({
isAuthenticated: false,
isLoading: false,
me: null,
})
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
return undefined
@@ -32,9 +42,7 @@ describe('SkillDetailPage', () => {
})
it('shows a loading indicator while loading', () => {
useQueryMock
.mockImplementationOnce(() => null) // me
.mockImplementationOnce(() => undefined) // getBySlug
useQueryMock.mockImplementationOnce(() => undefined) // getBySlug
render(<SkillDetailPage slug="weather" />)
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
@@ -42,30 +50,26 @@ describe('SkillDetailPage', () => {
})
it('shows not found when skill query resolves to null', async () => {
useQueryMock
.mockImplementationOnce(() => null) // me
.mockImplementationOnce(() => null) // getBySlug
useQueryMock.mockImplementationOnce(() => null) // getBySlug
render(<SkillDetailPage slug="missing-skill" />)
expect(await screen.findByText(/Skill not found/i)).toBeTruthy()
})
it('redirects legacy routes to canonical owner/slug', async () => {
useQueryMock
.mockImplementationOnce(() => null) // me
.mockImplementationOnce(() => ({
skill: {
_id: 'skills:1',
slug: 'weather',
displayName: 'Weather',
summary: 'Get current weather.',
ownerUserId: 'users:1',
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: { handle: 'steipete', name: 'Peter' },
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
}))
useQueryMock.mockImplementationOnce(() => ({
skill: {
_id: 'skills:1',
slug: 'weather',
displayName: 'Weather',
summary: 'Get current weather.',
ownerUserId: 'users:1',
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: { handle: 'steipete', name: 'Peter' },
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
}))
render(<SkillDetailPage slug="weather" redirectToCanonical />)
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
+35 -15
View File
@@ -1,12 +1,13 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SkillsIndex } from '../routes/skills/index'
const navigateMock = vi.fn()
const useQueryMock = vi.fn()
const useActionMock = vi.fn()
const usePaginatedQueryMock = vi.fn()
let searchMock: Record<string, unknown> = {}
vi.mock('@tanstack/react-router', () => ({
@@ -14,22 +15,30 @@ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
useSearch: () => searchMock,
}),
Link: (props: { children: unknown }) => <a href="/">{props.children}</a>,
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
}))
vi.mock('convex/react', () => ({
useAction: (...args: unknown[]) => useActionMock(...args),
useQuery: (...args: unknown[]) => useQueryMock(...args),
}))
vi.mock('convex-helpers/react', () => ({
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
}))
describe('SkillsIndex', () => {
beforeEach(() => {
useQueryMock.mockReset()
usePaginatedQueryMock.mockReset()
useActionMock.mockReset()
navigateMock.mockReset()
searchMock = {}
useActionMock.mockReturnValue(() => Promise.resolve([]))
useQueryMock.mockReturnValue({ items: [], nextCursor: null })
// Default: return empty results with Exhausted status
usePaginatedQueryMock.mockReturnValue({
results: [],
status: 'Exhausted',
loadMore: vi.fn(),
})
})
afterEach(() => {
@@ -39,10 +48,12 @@ describe('SkillsIndex', () => {
it('requests the first skills page', () => {
render(<SkillsIndex />)
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), {
cursor: undefined,
limit: 50,
})
// usePaginatedQuery should be called with the API endpoint and empty args
expect(usePaginatedQueryMock).toHaveBeenCalledWith(
expect.anything(),
{},
{ initialNumItems: 25 },
)
})
it('renders an empty state when no skills are returned', () => {
@@ -54,19 +65,29 @@ describe('SkillsIndex', () => {
searchMock = { q: 'remind' }
const actionFn = vi.fn().mockResolvedValue([])
useActionMock.mockReturnValue(actionFn)
useQueryMock.mockReturnValue(undefined)
vi.useFakeTimers()
render(<SkillsIndex />)
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip')
// usePaginatedQuery should be called with 'skip' when there's a search query
expect(usePaginatedQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip', {
initialNumItems: 25,
})
await act(async () => {
await vi.runAllTimersAsync()
})
expect(actionFn).toHaveBeenCalledWith({
query: 'remind',
highlightedOnly: false,
limit: 50,
limit: 25,
})
await act(async () => {
await vi.runAllTimersAsync()
})
expect(actionFn).toHaveBeenCalledWith({
query: 'remind',
highlightedOnly: false,
limit: 25,
})
})
@@ -75,10 +96,9 @@ describe('SkillsIndex', () => {
vi.stubGlobal('IntersectionObserver', undefined)
const actionFn = vi
.fn()
.mockResolvedValueOnce(makeSearchResults(25))
.mockResolvedValueOnce(makeSearchResults(50))
.mockResolvedValueOnce(makeSearchResults(100))
useActionMock.mockReturnValue(actionFn)
useQueryMock.mockReturnValue(undefined)
vi.useFakeTimers()
render(<SkillsIndex />)
@@ -95,7 +115,7 @@ describe('SkillsIndex', () => {
expect(actionFn).toHaveBeenLastCalledWith({
query: 'remind',
highlightedOnly: false,
limit: 100,
limit: 50,
})
})
})
+11 -1
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,6 +27,10 @@ vi.mock('convex/react', () => ({
},
}))
vi.mock('../lib/useAuthStatus', () => ({
useAuthStatus: () => useAuthStatusMock(),
}))
describe('Upload route', () => {
beforeEach(() => {
generateUploadUrl.mockReset()
@@ -34,7 +38,13 @@ describe('Upload route', () => {
generateChangelogPreview.mockReset()
fetchMock.mockReset()
useQueryMock.mockReset()
useAuthStatusMock.mockReset()
useActionCallCount = 0
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: 'users:1' },
})
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
return null
+3 -5
View File
@@ -3,15 +3,13 @@ import { convex } from '../convex/client'
import { UserBootstrap } from './UserBootstrap'
export function AppProviders({ children }: { children: React.ReactNode }) {
if (typeof window === 'undefined') {
return <>{children}</>
}
return (
<ConvexAuthProvider
client={convex}
replaceURL={(relativeUrl) => {
window.history.replaceState(null, '', relativeUrl)
if (typeof window !== 'undefined') {
window.history.replaceState(null, '', relativeUrl)
}
}}
>
<UserBootstrap />
+108 -36
View File
@@ -1,13 +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 { isModerator } from '../lib/roles'
import { getClawdHubSiteUrl, getSiteMode, getSiteName } from '../lib/site'
import { applyTheme, useThemeMode } from '../lib/theme'
import { startThemeTransition } from '../lib/theme-transition'
import { useAuthStatus } from '../lib/useAuthStatus'
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,9 +18,8 @@ import {
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group'
export default function Header() {
const { isAuthenticated, isLoading } = useConvexAuth()
const { isAuthenticated, isLoading, me } = useAuthStatus()
const { signIn, signOut } = useAuthActions()
const me = useQuery(api.users.me)
const { mode, setMode } = useThemeMode()
const toggleRef = useRef<HTMLDivElement | null>(null)
const siteMode = getSiteMode()
@@ -31,7 +30,7 @@ export default function Header() {
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined)
const handle = me?.handle ?? me?.displayName ?? 'user'
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase()
const isModerator = me?.role === 'admin' || me?.role === 'moderator'
const isStaff = isModerator(me)
const setTheme = (next: 'system' | 'light' | 'dark') => {
startThemeTransition({
@@ -49,7 +48,11 @@ export default function Header() {
return (
<header className="navbar">
<div className="navbar-inner">
<Link to="/" search={{ q: undefined, highlighted: undefined }} className="brand">
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: undefined }}
className="brand"
>
<span className="brand-mark">
<img src="/clawd-logo.png" alt="" aria-hidden="true" />
</span>
@@ -57,31 +60,67 @@ 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,
focus: undefined,
}}
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
</Link>
)}
<Link to="/upload" search={{ updateSlug: undefined }}>
Upload
</Link>
{isSoulMode ? null : <Link to="/import">Import</Link>}
<Link
to={isSoulMode ? '/souls' : '/skills'}
search={
isSoulMode
? undefined
? {
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: 'search',
}
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
focus: 'search',
}
}
>
{isSoulMode ? 'Souls' : 'Skills'}
</Link>
<Link to="/upload" search={{ updateSlug: undefined }}>
Upload
</Link>
{isSoulMode ? null : <Link to="/import">Import</Link>}
<Link to="/search" search={{ q: undefined, highlighted: undefined }}>
Search
</Link>
{me ? <Link to="/stars">Stars</Link> : null}
{isModerator ? <Link to="/admin">Admin</Link> : null}
{isStaff ? (
<Link to="/management" search={{ skill: undefined }}>
Management
</Link>
) : null}
</nav>
<div className="nav-actions">
<div className="nav-mobile">
@@ -98,22 +137,34 @@ export default function Header() {
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
<Link
to={isSoulMode ? '/souls' : '/skills'}
search={
isSoulMode
? undefined
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}
}
>
{isSoulMode ? 'Souls' : 'Skills'}
</Link>
{isSoulMode ? (
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
</Link>
)}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/upload" search={{ updateSlug: undefined }}>
@@ -126,7 +177,27 @@ export default function Header() {
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/search" search={{ q: undefined, highlighted: undefined }}>
<Link
to={isSoulMode ? '/souls' : '/skills'}
search={
isSoulMode
? {
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: 'search',
}
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
focus: 'search',
}
}
>
Search
</Link>
</DropdownMenuItem>
@@ -135,9 +206,11 @@ export default function Header() {
<Link to="/stars">Stars</Link>
</DropdownMenuItem>
) : null}
{isModerator ? (
{isStaff ? (
<DropdownMenuItem asChild>
<Link to="/admin">Admin</Link>
<Link to="/management" search={{ skill: undefined }}>
Management
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
@@ -156,9 +229,8 @@ export default function Header() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="theme-toggle">
<div className="theme-toggle" ref={toggleRef}>
<ToggleGroup
ref={toggleRef}
type="single"
value={mode}
onValueChange={(value) => {
+16 -7
View File
@@ -1,21 +1,30 @@
import { Link } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import type { Doc } from '../../convex/_generated/dataModel'
import type { PublicSkill } from '../lib/publicUser'
type SkillCardProps = {
skill: Doc<'skills'>
badge?: string
skill: PublicSkill
badge?: string | string[]
chip?: string
summaryFallback: string
meta: ReactNode
href?: string
}
export function SkillCard({ skill, badge, chip, summaryFallback, meta }: SkillCardProps) {
export function SkillCard({ skill, badge, chip, summaryFallback, meta, href }: SkillCardProps) {
const owner = encodeURIComponent(String(skill.ownerUserId))
const link = href ?? `/${owner}/${skill.slug}`
const badges = Array.isArray(badge) ? badge : badge ? [badge] : []
return (
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="card skill-card">
{badge || chip ? (
<Link to={link} className="card skill-card">
{badges.length || chip ? (
<div className="skill-card-tags">
{badge ? <div className="tag">{badge}</div> : null}
{badges.map((label) => (
<div key={label} className="tag">
{label}
</div>
))}
{chip ? <div className="tag tag-accent tag-compact">{chip}</div> : null}
</div>
) : null}
+76 -40
View File
@@ -1,11 +1,15 @@
import { useNavigate } from '@tanstack/react-router'
import { Link, useNavigate } from '@tanstack/react-router'
import type { ClawdisSkillMetadata, SkillInstallSpec } from 'clawdhub-schema'
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
import { useAction, useMutation, useQuery } from 'convex/react'
import { useEffect, useMemo, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { api } from '../../convex/_generated/api'
import type { Doc, Id } from '../../convex/_generated/dataModel'
import { getSkillBadges } from '../lib/badges'
import type { PublicSkill, PublicUser } from '../lib/publicUser'
import { canManageSkill, isModerator } from '../lib/roles'
import { useAuthStatus } from '../lib/useAuthStatus'
import { SkillDiffCard } from './SkillDiffCard'
type SkillDetailPageProps = {
@@ -14,20 +18,37 @@ type SkillDetailPageProps = {
redirectToCanonical?: boolean
}
type SkillBySlugResult = {
skill: PublicSkill
latestVersion: Doc<'skillVersions'> | null
owner: PublicUser | null
forkOf: {
kind: 'fork' | 'duplicate'
version: string | null
skill: { slug: string; displayName: string }
owner: { handle: string | null; userId: Id<'users'> | null }
} | null
canonical: {
skill: { slug: string; displayName: string }
owner: { handle: string | null; userId: Id<'users'> | null }
} | null
} | null
type SkillFile = Doc<'skillVersions'>['files'][number]
export function SkillDetailPage({
slug,
canonicalOwner,
redirectToCanonical,
}: SkillDetailPageProps) {
const navigate = useNavigate()
const { isAuthenticated } = useConvexAuth()
const me = useQuery(api.users.me)
const result = useQuery(api.skills.getBySlug, { slug })
const { isAuthenticated, me } = useAuthStatus()
const result = useQuery(api.skills.getBySlug, { slug }) as SkillBySlugResult | undefined
const toggleStar = useMutation(api.stars.toggle)
const reportSkill = useMutation(api.skills.report)
const addComment = useMutation(api.comments.add)
const removeComment = useMutation(api.comments.remove)
const updateTags = useMutation(api.skills.updateTags)
const setBatch = useMutation(api.skills.setBatch)
const getReadme = useAction(api.skills.getReadme)
const [readme, setReadme] = useState<string | null>(null)
const [readmeError, setReadmeError] = useState<string | null>(null)
@@ -56,41 +77,42 @@ export function SkillDetailPage({
const comments = useQuery(
api.comments.listBySkill,
skill ? { skillId: skill._id, limit: 50 } : 'skip',
) as Array<{ comment: Doc<'comments'>; user: Doc<'users'> | null }> | undefined
) as Array<{ comment: Doc<'comments'>; user: PublicUser | null }> | undefined
const canManage = Boolean(
me && skill && (me._id === skill.ownerUserId || ['admin', 'moderator'].includes(me.role ?? '')),
)
const canHighlight = Boolean(me && ['admin', 'moderator'].includes(me.role ?? ''))
const canManage = canManageSkill(me, skill)
const isStaff = isModerator(me)
const ownerHandle = owner?.handle ?? owner?.name ?? null
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null)
const wantsCanonicalRedirect = Boolean(
ownerHandle &&
ownerParam &&
(redirectToCanonical ||
(typeof canonicalOwner === 'string' && canonicalOwner && canonicalOwner !== ownerHandle)),
(typeof canonicalOwner === 'string' && canonicalOwner && canonicalOwner !== ownerParam)),
)
const forkOf = result?.forkOf ?? null
const canonical = result?.canonical ?? null
const forkOfLabel = forkOf?.kind === 'duplicate' ? 'duplicate of' : 'fork of'
const forkOfOwnerHandle = forkOf?.owner?.handle ?? null
const forkOfOwnerId = forkOf?.owner?.userId ?? null
const canonicalOwnerHandle = canonical?.owner?.handle ?? null
const canonicalOwnerId = canonical?.owner?.userId ?? null
const forkOfHref = forkOf?.skill?.slug
? buildSkillHref(forkOfOwnerHandle, forkOf.skill.slug)
? buildSkillHref(forkOfOwnerHandle, forkOfOwnerId, forkOf.skill.slug)
: null
const canonicalHref =
canonical?.skill?.slug && canonical.skill.slug !== forkOf?.skill?.slug
? buildSkillHref(canonicalOwnerHandle, canonical.skill.slug)
? buildSkillHref(canonicalOwnerHandle, canonicalOwnerId, canonical.skill.slug)
: null
useEffect(() => {
if (!wantsCanonicalRedirect || !ownerHandle) return
if (!wantsCanonicalRedirect || !ownerParam) return
void navigate({
to: '/$owner/$slug',
params: { owner: ownerHandle, slug },
params: { owner: ownerParam, slug },
replace: true,
})
}, [navigate, ownerHandle, slug, wantsCanonicalRedirect])
}, [navigate, ownerParam, slug, wantsCanonicalRedirect])
const versionById = new Map<Id<'skillVersions'>, Doc<'skillVersions'>>(
(diffVersions ?? versions ?? []).map((version) => [version._id, version]),
@@ -122,7 +144,7 @@ export function SkillDetailPage({
if (!readme) return null
return stripFrontmatter(readme)
}, [readme])
const latestFiles = latestVersion?.files ?? []
const latestFiles: SkillFile[] = latestVersion?.files ?? []
useEffect(() => {
if (!latestVersion) return
@@ -219,7 +241,11 @@ export function SkillDetailPage({
</a>
</div>
) : null}
{skill.batch === 'highlighted' ? <div className="tag">Highlighted</div> : null}
{getSkillBadges(skill).map((badge) => (
<div key={badge} className="tag">
{badge}
</div>
))}
<div className="skill-actions">
{isAuthenticated ? (
<button
@@ -231,23 +257,37 @@ export function SkillDetailPage({
<span aria-hidden="true"></span>
</button>
) : null}
{canHighlight ? (
{isAuthenticated ? (
<button
className={`highlight-toggle${skill.batch === 'highlighted' ? ' is-active' : ''}`}
className="btn btn-ghost"
type="button"
onClick={() =>
void setBatch({
skillId: skill._id,
batch: skill.batch === 'highlighted' ? undefined : 'highlighted',
})
}
aria-label={
skill.batch === 'highlighted' ? 'Unhighlight skill' : 'Highlight skill'
}
onClick={async () => {
const reason = window.prompt('Report this skill? Add a reason if you want.')
if (reason === null) return
try {
const result = await reportSkill({
skillId: skill._id,
reason: reason.trim() || undefined,
})
if (result.reported) {
window.alert('Thanks — your report has been submitted.')
} else {
window.alert('You have already reported this skill.')
}
} catch (error) {
console.error('Failed to report skill', error)
window.alert('Unable to submit report. Please try again.')
}
}}
>
<span aria-hidden="true"></span>
Report
</button>
) : null}
{isStaff ? (
<Link className="btn" to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
) : null}
</div>
</div>
<div className="skill-hero-cta">
@@ -610,11 +650,7 @@ export function SkillDetailPage({
<strong>@{entry.user?.handle ?? entry.user?.name ?? 'user'}</strong>
<div style={{ color: '#5c554e' }}>{entry.comment.body}</div>
</div>
{isAuthenticated &&
me &&
(me._id === entry.comment.userId ||
me.role === 'admin' ||
me.role === 'moderator') ? (
{isAuthenticated && me && (me._id === entry.comment.userId || isModerator(me)) ? (
<button
className="btn"
type="button"
@@ -633,9 +669,9 @@ export function SkillDetailPage({
)
}
function buildSkillHref(ownerHandle: string | null, slug: string) {
if (ownerHandle) return `/${ownerHandle}/${slug}`
return `/skills/${slug}`
function buildSkillHref(ownerHandle: string | null, ownerId: Id<'users'> | null, slug: string) {
const owner = ownerHandle?.trim() || (ownerId ? String(ownerId) : 'unknown')
return `/${owner}/${slug}`
}
function formatConfigSnippet(raw: string) {
+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 = styles.getPropertyValue('--surface').trim() || '#ffffff'
const surface = normalizeHex(styles.getPropertyValue('--surface').trim() || '#ffffff')
const surfaceMuted = styles.getPropertyValue('--surface-muted').trim() || '#f6f1ec'
const ink = styles.getPropertyValue('--ink').trim() || '#1d1a17'
const inkSoft = styles.getPropertyValue('--ink-soft').trim() || '#4c463f'
@@ -468,7 +468,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
}
function normalizeHex(value: string) {
if (!value.startsWith('#')) return '#000000'
if (!value.startsWith('#')) return value
if (value.length === 4) {
return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { Link } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import type { Doc } from '../../convex/_generated/dataModel'
import type { PublicSoul } from '../lib/publicUser'
type SoulCardProps = {
soul: Doc<'souls'>
soul: PublicSoul
summaryFallback: string
meta: ReactNode
}
+14 -10
View File
@@ -1,18 +1,26 @@
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
import { useAction, useMutation, useQuery } from 'convex/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import type { PublicSoul, PublicUser } from '../lib/publicUser'
import { isModerator } from '../lib/roles'
import { useAuthStatus } from '../lib/useAuthStatus'
type SoulDetailPageProps = {
slug: string
}
type SoulBySlugResult = {
soul: PublicSoul
latestVersion: Doc<'soulVersions'> | null
owner: PublicUser | null
} | null
export function SoulDetailPage({ slug }: SoulDetailPageProps) {
const { isAuthenticated } = useConvexAuth()
const me = useQuery(api.users.me)
const result = useQuery(api.souls.getBySlug, { slug })
const { isAuthenticated, me } = useAuthStatus()
const result = useQuery(api.souls.getBySlug, { slug }) as SoulBySlugResult | undefined
const toggleStar = useMutation(api.soulStars.toggle)
const addComment = useMutation(api.soulComments.add)
const removeComment = useMutation(api.soulComments.remove)
@@ -40,7 +48,7 @@ export function SoulDetailPage({ slug }: SoulDetailPageProps) {
const comments = useQuery(
api.soulComments.listBySoul,
soul ? { soulId: soul._id, limit: 50 } : 'skip',
) as Array<{ comment: Doc<'soulComments'>; user: Doc<'users'> | null }> | undefined
) as Array<{ comment: Doc<'soulComments'>; user: PublicUser | null }> | undefined
const readmeContent = useMemo(() => {
if (!readme) return null
@@ -227,11 +235,7 @@ export function SoulDetailPage({ slug }: SoulDetailPageProps) {
<strong>@{entry.user?.handle ?? entry.user?.name ?? 'user'}</strong>
<div style={{ color: '#5c554e' }}>{entry.comment.body}</div>
</div>
{isAuthenticated &&
me &&
(me._id === entry.comment.userId ||
me.role === 'admin' ||
me.role === 'moderator') ? (
{isAuthenticated && me && (me._id === entry.comment.userId || isModerator(me)) ? (
<button
className="btn"
type="button"
+3 -2
View File
@@ -1,9 +1,10 @@
import { useConvexAuth, useMutation } from 'convex/react'
import { useMutation } from 'convex/react'
import { useEffect, useRef } from 'react'
import { api } from '../../convex/_generated/api'
import { useAuthStatus } from '../lib/useAuthStatus'
export function UserBootstrap() {
const { isAuthenticated, isLoading } = useConvexAuth()
const { isAuthenticated, isLoading } = useAuthStatus()
const ensureUser = useMutation(api.users.ensure)
const didRun = useRef(false)
+29
View File
@@ -0,0 +1,29 @@
import type { Doc, Id } from '../../convex/_generated/dataModel'
type BadgeKind = Doc<'skillBadges'>['kind']
type SkillBadgeMap = Partial<Record<BadgeKind, { byUserId: Id<'users'>; at: number }>>
type SkillLike = { badges?: SkillBadgeMap | null }
type BadgeLabel = 'Deprecated' | 'Official' | 'Highlighted'
export function isSkillHighlighted(skill: SkillLike) {
return Boolean(skill.badges?.highlighted)
}
export function isSkillOfficial(skill: SkillLike) {
return Boolean(skill.badges?.official)
}
export function isSkillDeprecated(skill: SkillLike) {
return Boolean(skill.badges?.deprecated)
}
export function getSkillBadges(skill: SkillLike): BadgeLabel[] {
const badges: BadgeLabel[] = []
if (isSkillDeprecated(skill)) badges.push('Deprecated')
if (isSkillOfficial(skill)) badges.push('Official')
if (isSkillHighlighted(skill)) badges.push('Highlighted')
return badges
}
+3 -2
View File
@@ -50,7 +50,7 @@ describe('og helpers', () => {
const meta = buildSkillMeta({ slug: 'parser' })
expect(meta.title).toBe('parser — ClawdHub')
expect(meta.description).toMatch(/ClawdHub — a fast skill registry/i)
expect(meta.url).toContain('/skills/parser')
expect(meta.url).toContain('/unknown/parser')
expect(meta.owner).toBeNull()
expect(meta.image).toContain('slug=parser')
})
@@ -76,7 +76,7 @@ describe('og helpers', () => {
ok: true,
json: async () => ({
skill: { displayName: 'Weather', summary: 'Forecasts' },
owner: { handle: 'steipete' },
owner: { handle: 'steipete', userId: 'users:1' },
latestVersion: { version: '1.2.3' },
}),
}))
@@ -87,6 +87,7 @@ describe('og helpers', () => {
displayName: 'Weather',
summary: 'Forecasts',
owner: 'steipete',
ownerId: 'users:1',
version: '1.2.3',
})
})
+6 -2
View File
@@ -3,6 +3,7 @@ import { getClawdHubSiteUrl, getOnlyCrabsSiteUrl } from './site'
type SkillMetaSource = {
slug: string
owner?: string | null
ownerId?: string | null
displayName?: string | null
summary?: string | null
version?: string | null
@@ -58,13 +59,14 @@ export async function fetchSkillMeta(slug: string) {
if (!response.ok) return null
const payload = (await response.json()) as {
skill?: { displayName?: string; summary?: string | null } | null
owner?: { handle?: string | null } | null
owner?: { handle?: string | null; userId?: string | null } | null
latestVersion?: { version?: string | null } | null
}
return {
displayName: payload.skill?.displayName ?? null,
summary: payload.skill?.summary ?? null,
owner: payload.owner?.handle ?? null,
ownerId: payload.owner?.userId ?? null,
version: payload.latestVersion?.version ?? null,
}
} catch {
@@ -97,13 +99,15 @@ export async function fetchSoulMeta(slug: string) {
export function buildSkillMeta(source: SkillMetaSource): SkillMeta {
const siteUrl = getSiteUrl()
const owner = clean(source.owner)
const ownerId = clean(source.ownerId)
const displayName = clean(source.displayName) || clean(source.slug)
const summary = clean(source.summary)
const version = clean(source.version)
const title = `${displayName} — ClawdHub`
const description =
summary || (owner ? `Agent skill by @${owner} on ClawdHub.` : DEFAULT_DESCRIPTION)
const url = owner ? `${siteUrl}/${owner}/${source.slug}` : `${siteUrl}/skills/${source.slug}`
const ownerPath = owner || ownerId || 'unknown'
const url = `${siteUrl}/${ownerPath}/${source.slug}`
const imageParams = new URLSearchParams()
imageParams.set('v', OG_SKILL_IMAGE_LAYOUT_VERSION)
imageParams.set('slug', source.slug)
+39
View File
@@ -0,0 +1,39 @@
import type { Doc } from '../../convex/_generated/dataModel'
export type PublicUser = Pick<
Doc<'users'>,
'_id' | '_creationTime' | 'handle' | 'name' | 'displayName' | 'image' | 'bio'
>
export type PublicSkill = Pick<
Doc<'skills'>,
| '_id'
| '_creationTime'
| 'slug'
| 'displayName'
| 'summary'
| 'ownerUserId'
| 'canonicalSkillId'
| 'forkOf'
| 'latestVersionId'
| 'tags'
| 'badges'
| 'stats'
| 'createdAt'
| 'updatedAt'
>
export type PublicSoul = Pick<
Doc<'souls'>,
| '_id'
| '_creationTime'
| 'slug'
| 'displayName'
| 'summary'
| 'ownerUserId'
| 'latestVersionId'
| 'tags'
| 'stats'
| 'createdAt'
| 'updatedAt'
>
+19
View File
@@ -0,0 +1,19 @@
import type { Doc } from '../../convex/_generated/dataModel'
import type { PublicSkill } from './publicUser'
type User = Doc<'users'> | null | undefined
type Skill = PublicSkill | null | undefined
export function isAdmin(user: User) {
return user?.role === 'admin'
}
export function isModerator(user: User) {
return user?.role === 'admin' || user?.role === 'moderator'
}
export function canManageSkill(user: User, skill: Skill) {
if (!user || !skill) return false
return user._id === skill.ownerUserId || isModerator(user)
}
+78
View File
@@ -46,6 +46,64 @@ export async function expandFiles(selected: File[]) {
return expanded
}
export async function expandDroppedItems(items: DataTransferItemList | null) {
if (!items || items.length === 0) return []
const dropped: File[] = []
const entries: FileSystemEntry[] = []
for (const item of Array.from(items)) {
const entry = (item as WebkitDataTransferItem).webkitGetAsEntry?.()
if (entry) {
entries.push(entry)
continue
}
const file = item.getAsFile()
if (file) dropped.push(file)
}
if (entries.length === 0) return dropped
for (const entry of entries) {
await collectEntry(entry, '', dropped)
}
return dropped
}
async function collectEntry(entry: FileSystemEntry, parentPath: string, files: File[]) {
if (entry.isFile && entry.file) {
const file = await new Promise<File>((resolve, reject) => {
entry.file?.(resolve, reject)
})
const fullPath = entry.fullPath?.replace(/^\/+/, '')
const path = fullPath || (parentPath ? `${parentPath}/${file.name}` : file.name)
files.push(new File([file], path, { type: file.type, lastModified: file.lastModified }))
return
}
if (!entry.isDirectory || !entry.createReader) return
const basePath =
entry.fullPath?.replace(/^\/+/, '') || (parentPath ? `${parentPath}/${entry.name}` : entry.name)
const reader = entry.createReader()
const children = await readAllEntries(reader)
for (const child of children) {
await collectEntry(child, basePath, files)
}
}
async function readAllEntries(reader: FileSystemDirectoryReader) {
const entries: FileSystemEntry[] = []
while (true) {
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject)
})
if (batch.length === 0) break
entries.push(...batch)
}
return entries
}
function pushArchiveEntries(target: File[], entries: Array<{ path: string; data: Uint8Array }>) {
const normalized = entries
.map((entry) => ({ ...entry, path: normalizePath(entry.path) }))
@@ -165,3 +223,23 @@ function isTextPath(path: string) {
if (!extension) return false
return TEXT_FILE_EXTENSION_SET.has(extension)
}
type WebkitDataTransferItem = DataTransferItem & {
webkitGetAsEntry?: () => FileSystemEntry | null
}
type FileSystemEntry = {
isFile: boolean
isDirectory: boolean
name: string
fullPath?: string
file?: (callback: (file: File) => void, errorCallback?: (error: DOMException) => void) => void
createReader?: () => FileSystemDirectoryReader
}
type FileSystemDirectoryReader = {
readEntries: (
callback: (entries: FileSystemEntry[]) => void,
errorCallback?: (error: DOMException) => void,
) => void
}
+12
View File
@@ -0,0 +1,12 @@
import { useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
export function useAuthStatus() {
const me = useQuery(api.users.me) as Doc<'users'> | null | undefined
return {
me,
isLoading: me === undefined,
isAuthenticated: Boolean(me),
}
}
+17 -38
View File
@@ -12,7 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as UploadRouteImport } from './routes/upload'
import { Route as StarsRouteImport } from './routes/stars'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as SearchRouteImport } from './routes/search'
import { Route as ManagementRouteImport } from './routes/management'
import { Route as ImportRouteImport } from './routes/import'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as AdminRouteImport } from './routes/admin'
@@ -21,7 +21,6 @@ import { Route as SoulsIndexRouteImport } from './routes/souls/index'
import { Route as SkillsIndexRouteImport } from './routes/skills/index'
import { Route as UHandleRouteImport } from './routes/u/$handle'
import { Route as SoulsSlugRouteImport } from './routes/souls/$slug'
import { Route as SkillsSlugRouteImport } from './routes/skills/$slug'
import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
@@ -40,9 +39,9 @@ const SettingsRoute = SettingsRouteImport.update({
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const SearchRoute = SearchRouteImport.update({
id: '/search',
path: '/search',
const ManagementRoute = ManagementRouteImport.update({
id: '/management',
path: '/management',
getParentRoute: () => rootRouteImport,
} as any)
const ImportRoute = ImportRouteImport.update({
@@ -85,11 +84,6 @@ const SoulsSlugRoute = SoulsSlugRouteImport.update({
path: '/souls/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const SkillsSlugRoute = SkillsSlugRouteImport.update({
id: '/skills/$slug',
path: '/skills/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const CliAuthRoute = CliAuthRouteImport.update({
id: '/cli/auth',
path: '/cli/auth',
@@ -106,13 +100,12 @@ export interface FileRoutesByFullPath {
'/admin': typeof AdminRoute
'/dashboard': typeof DashboardRoute
'/import': typeof ImportRoute
'/search': typeof SearchRoute
'/management': typeof ManagementRoute
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/cli/auth': typeof CliAuthRoute
'/skills/$slug': typeof SkillsSlugRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
'/skills/': typeof SkillsIndexRoute
@@ -123,13 +116,12 @@ export interface FileRoutesByTo {
'/admin': typeof AdminRoute
'/dashboard': typeof DashboardRoute
'/import': typeof ImportRoute
'/search': typeof SearchRoute
'/management': typeof ManagementRoute
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/cli/auth': typeof CliAuthRoute
'/skills/$slug': typeof SkillsSlugRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
'/skills': typeof SkillsIndexRoute
@@ -141,13 +133,12 @@ export interface FileRoutesById {
'/admin': typeof AdminRoute
'/dashboard': typeof DashboardRoute
'/import': typeof ImportRoute
'/search': typeof SearchRoute
'/management': typeof ManagementRoute
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/cli/auth': typeof CliAuthRoute
'/skills/$slug': typeof SkillsSlugRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
'/skills/': typeof SkillsIndexRoute
@@ -160,13 +151,12 @@ export interface FileRouteTypes {
| '/admin'
| '/dashboard'
| '/import'
| '/search'
| '/management'
| '/settings'
| '/stars'
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/skills/$slug'
| '/souls/$slug'
| '/u/$handle'
| '/skills/'
@@ -177,13 +167,12 @@ export interface FileRouteTypes {
| '/admin'
| '/dashboard'
| '/import'
| '/search'
| '/management'
| '/settings'
| '/stars'
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/skills/$slug'
| '/souls/$slug'
| '/u/$handle'
| '/skills'
@@ -194,13 +183,12 @@ export interface FileRouteTypes {
| '/admin'
| '/dashboard'
| '/import'
| '/search'
| '/management'
| '/settings'
| '/stars'
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/skills/$slug'
| '/souls/$slug'
| '/u/$handle'
| '/skills/'
@@ -212,13 +200,12 @@ export interface RootRouteChildren {
AdminRoute: typeof AdminRoute
DashboardRoute: typeof DashboardRoute
ImportRoute: typeof ImportRoute
SearchRoute: typeof SearchRoute
ManagementRoute: typeof ManagementRoute
SettingsRoute: typeof SettingsRoute
StarsRoute: typeof StarsRoute
UploadRoute: typeof UploadRoute
OwnerSlugRoute: typeof OwnerSlugRoute
CliAuthRoute: typeof CliAuthRoute
SkillsSlugRoute: typeof SkillsSlugRoute
SoulsSlugRoute: typeof SoulsSlugRoute
UHandleRoute: typeof UHandleRoute
SkillsIndexRoute: typeof SkillsIndexRoute
@@ -248,11 +235,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/search': {
id: '/search'
path: '/search'
fullPath: '/search'
preLoaderRoute: typeof SearchRouteImport
'/management': {
id: '/management'
path: '/management'
fullPath: '/management'
preLoaderRoute: typeof ManagementRouteImport
parentRoute: typeof rootRouteImport
}
'/import': {
@@ -311,13 +298,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SoulsSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/skills/$slug': {
id: '/skills/$slug'
path: '/skills/$slug'
fullPath: '/skills/$slug'
preLoaderRoute: typeof SkillsSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/cli/auth': {
id: '/cli/auth'
path: '/cli/auth'
@@ -340,13 +320,12 @@ const rootRouteChildren: RootRouteChildren = {
AdminRoute: AdminRoute,
DashboardRoute: DashboardRoute,
ImportRoute: ImportRoute,
SearchRoute: SearchRoute,
ManagementRoute: ManagementRoute,
SettingsRoute: SettingsRoute,
StarsRoute: StarsRoute,
UploadRoute: UploadRoute,
OwnerSlugRoute: OwnerSlugRoute,
CliAuthRoute: CliAuthRoute,
SkillsSlugRoute: SkillsSlugRoute,
SoulsSlugRoute: SoulsSlugRoute,
UHandleRoute: UHandleRoute,
SkillsIndexRoute: SkillsIndexRoute,
+8 -82
View File
@@ -1,85 +1,11 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/admin')({
component: Admin,
beforeLoad: () => {
throw redirect({
to: '/management',
search: { skill: undefined },
replace: true,
})
},
})
function Admin() {
const users = useQuery(api.users.list, { limit: 50 }) as Doc<'users'>[] | undefined
const skills = useQuery(api.skills.list, { limit: 20 }) as Doc<'skills'>[] | undefined
const setRole = useMutation(api.users.setRole)
const setBatch = useMutation(api.skills.setBatch)
if (!users) {
return (
<main className="section">
<div className="card">Admin only.</div>
</main>
)
}
return (
<main className="section">
<h1 className="section-title">Admin console</h1>
<p className="section-subtitle">Promote users and curate skills.</p>
<div className="card">
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Users
</h2>
<div style={{ display: 'grid', gap: 10, marginTop: 12 }}>
{users.map((user) => (
<div key={user._id} className="stat" style={{ justifyContent: 'space-between' }}>
<span className="mono">@{user.handle ?? user.name ?? 'user'}</span>
<select
value={user.role ?? 'user'}
onChange={(event) => {
const value = event.target.value
if (value === 'admin' || value === 'moderator' || value === 'user') {
void setRole({ userId: user._id, role: value })
}
}}
>
<option value="user">User</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
</div>
))}
</div>
</div>
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Skills
</h2>
<div style={{ display: 'grid', gap: 10, marginTop: 12 }}>
{(skills ?? []).map((skill) => (
<div key={skill._id} className="stat" style={{ justifyContent: 'space-between' }}>
<Link to="/skills/$slug" params={{ slug: skill.slug }}>
{skill.displayName}
</Link>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn"
type="button"
onClick={() =>
void setBatch({
skillId: skill._id,
batch: skill.batch === 'highlighted' ? undefined : 'highlighted',
})
}
>
{skill.batch === 'highlighted' ? 'Unhighlight' : 'Highlight'}
</button>
</div>
</div>
))}
</div>
</div>
</main>
)
}
+3 -3
View File
@@ -1,17 +1,17 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { createFileRoute } from '@tanstack/react-router'
import { useConvexAuth, useMutation, useQuery } from 'convex/react'
import { useMutation } from 'convex/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../../convex/_generated/api'
import { useAuthStatus } from '../../lib/useAuthStatus'
export const Route = createFileRoute('/cli/auth')({
component: CliAuth,
})
function CliAuth() {
const { isAuthenticated, isLoading } = useConvexAuth()
const { isAuthenticated, isLoading, me } = useAuthStatus()
const { signIn } = useAuthActions()
const me = useQuery(api.users.me)
const createToken = useMutation(api.tokens.create)
const search = Route.useSearch() as {
+24 -32
View File
@@ -3,14 +3,18 @@ import { useQuery } from 'convex/react'
import { Package, Plus, Upload } from 'lucide-react'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import type { PublicSkill } from '../lib/publicUser'
export const Route = createFileRoute('/dashboard')({
component: Dashboard,
})
function Dashboard() {
const me = useQuery(api.users.me)
const mySkills = useQuery(api.skills.list, me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip')
const me = useQuery(api.users.me) as Doc<'users'> | null | undefined
const mySkills = useQuery(
api.skills.list,
me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip',
) as PublicSkill[] | undefined
if (!me) {
return (
@@ -21,7 +25,7 @@ function Dashboard() {
}
const skills = mySkills ?? []
const ownerHandle = me.handle ?? null
const ownerHandle = me.handle ?? me.name ?? me.displayName ?? me._id
return (
<main className="section">
@@ -29,7 +33,7 @@ function Dashboard() {
<h1 className="section-title" style={{ margin: 0 }}>
My Skills
</h1>
<Link to="/upload" className="btn btn-primary">
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
<Plus className="h-4 w-4" aria-hidden="true" />
Upload New Skill
</Link>
@@ -40,7 +44,7 @@ function Dashboard() {
<Package className="dashboard-empty-icon" aria-hidden="true" />
<h2>No skills yet</h2>
<p>Upload your first skill to share it with the community.</p>
<Link to="/upload" className="btn btn-primary">
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
<Upload className="h-4 w-4" aria-hidden="true" />
Upload a Skill
</Link>
@@ -56,23 +60,17 @@ function Dashboard() {
)
}
function SkillCard({ skill, ownerHandle }: { skill: Doc<'skills'>; ownerHandle: string | null }) {
function SkillCard({ skill, ownerHandle }: { skill: PublicSkill; ownerHandle: string | null }) {
return (
<div className="dashboard-skill-card">
<div className="dashboard-skill-info">
{ownerHandle ? (
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle, slug: skill.slug }}
className="dashboard-skill-name"
>
{skill.displayName}
</Link>
) : (
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="dashboard-skill-name">
{skill.displayName}
</Link>
)}
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? 'unknown', slug: skill.slug }}
className="dashboard-skill-name"
>
{skill.displayName}
</Link>
<span className="dashboard-skill-slug">/{skill.slug}</span>
{skill.summary && <p className="dashboard-skill-description">{skill.summary}</p>}
<div className="dashboard-skill-stats">
@@ -86,19 +84,13 @@ function SkillCard({ skill, ownerHandle }: { skill: Doc<'skills'>; ownerHandle:
<Upload className="h-3 w-3" aria-hidden="true" />
New Version
</Link>
{ownerHandle ? (
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle, slug: skill.slug }}
className="btn btn-ghost btn-sm"
>
View
</Link>
) : (
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="btn btn-ghost btn-sm">
View
</Link>
)}
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? 'unknown', slug: skill.slug }}
className="btn btn-ghost btn-sm"
>
View
</Link>
</div>
</div>
)
+5 -3
View File
@@ -1,8 +1,9 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useAction, useConvexAuth } from 'convex/react'
import { useAction } from 'convex/react'
import { useMemo, useState } from 'react'
import { api } from '../../convex/_generated/api'
import { formatBytes } from '../lib/uploadUtils'
import { useAuthStatus } from '../lib/useAuthStatus'
export const Route = createFileRoute('/import')({
component: ImportGitHub,
@@ -37,7 +38,7 @@ type CandidatePreview = {
}
function ImportGitHub() {
const { isAuthenticated, isLoading } = useConvexAuth()
const { isAuthenticated, isLoading, me } = useAuthStatus()
const previewImport = useAction(api.githubImport.previewGitHubImport)
const previewCandidate = useAction(api.githubImport.previewGitHubImportCandidate)
const importSkill = useAction(api.githubImport.importGitHubSkill)
@@ -166,7 +167,8 @@ function ImportGitHub() {
})
const nextSlug = result.slug
setStatus('Imported.')
await navigate({ to: '/skills/$slug', params: { slug: nextSlug } })
const ownerParam = me?.handle ?? (me?._id ? String(me._id) : 'unknown')
await navigate({ to: '/$owner/$slug', params: { owner: ownerParam, slug: nextSlug } })
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed')
setStatus(null)
+144 -356
View File
@@ -2,17 +2,14 @@ import { createFileRoute, Link } from '@tanstack/react-router'
import { useAction, useQuery } from 'convex/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import { InstallSwitcher } from '../components/InstallSwitcher'
import { SkillCard } from '../components/SkillCard'
import { SoulCard } from '../components/SoulCard'
import { getSkillBadges } from '../lib/badges'
import type { PublicSkill, PublicSoul } from '../lib/publicUser'
import { getSiteMode } from '../lib/site'
export const Route = createFileRoute('/')({
validateSearch: (search) => ({
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
}),
component: Home,
})
@@ -22,75 +19,16 @@ 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])
(useQuery(api.skills.list, {
batch: 'highlighted',
limit: 6,
}) as PublicSkill[]) ?? []
const latest = (useQuery(api.skills.list, { limit: 12 }) as PublicSkill[]) ?? []
return (
<main>
<section className={`hero${searchMode ? ' search-mode' : ''}`}>
<section className="hero">
<div className="hero-inner">
<div className="hero-copy fade-up" data-delay="1">
<span className="hero-badge">Lobster-light. Agent-right.</span>
@@ -103,148 +41,6 @@ function SkillsHome() {
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
Publish a skill
</Link>
<button
className="btn"
type="button"
onClick={() => {
setSearchMode(true)
inputRef.current?.focus()
}}
>
Explore search
</button>
</div>
</div>
<div className="hero-card hero-search-card fade-up" data-delay="2">
<form
className="search-bar"
onSubmit={(event) => {
event.preventDefault()
if (!searchMode) setSearchMode(true)
inputRef.current?.focus()
}}
>
<span className="mono">/</span>
<input
ref={inputRef}
className="search-input"
placeholder="Search skills, tags, or capabilities"
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setSearchMode(true)}
onKeyDown={(event) => {
if (event.key === 'Escape' && !trimmedQuery) {
setSearchMode(false)
inputRef.current?.blur()
}
}}
/>
<button
className="search-filter-button"
type="button"
aria-pressed={highlightedOnly}
onClick={() => {
setHighlightedOnly((value) => !value)
setSearchMode(true)
}}
>
Highlighted
</button>
</form>
{!searchMode ? (
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="stat">Search skills. Versioned, rollback-ready.</div>
<InstallSwitcher exampleSlug="sonoscli" />
</div>
) : null}
</div>
</div>
</section>
{searchMode ? (
<section className="section">
<h2 className="section-title">Search results</h2>
<p className="section-subtitle">
{isSearching ? 'Searching now.' : 'Instant results as you type.'}
</p>
<div className="grid">
{!hasQuery ? (
<div className="card">Start typing to search.</div>
) : results.length === 0 ? (
<div className="card">No results yet. Try a different prompt.</div>
) : (
results.map((result) => (
<Link
key={result.skill._id}
to="/skills/$slug"
params={{ slug: result.skill.slug }}
className="card"
>
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
{result.skill.displayName}
</h3>
<p className="section-subtitle" style={{ margin: 0 }}>
{result.skill.summary ?? 'Skill pack'}
</p>
{result.skill.batch === 'highlighted' ? (
<div className="tag">Highlighted</div>
) : null}
</Link>
))
)}
</div>
</section>
) : (
<>
<section className="section">
<h2 className="section-title">Highlighted batch</h2>
<p className="section-subtitle">Curated signal highlighted for quick trust.</p>
<div className="grid">
{highlighted.length === 0 ? (
<div className="card">No highlighted skills yet.</div>
) : (
highlighted.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge="Highlighted"
summaryFallback="A fresh skill bundle."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))
)}
</div>
</section>
<section className="section">
<h2 className="section-title">Latest drops</h2>
<p className="section-subtitle">Newest uploads across the registry.</p>
<div className="grid">
{latest.length === 0 ? (
<div className="card">No skills yet. Be the first.</div>
) : (
latest.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.versions} versions · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))
)}
</div>
<div className="section-cta">
<Link
to="/skills"
search={{
@@ -253,43 +49,98 @@ function SkillsHome() {
dir: undefined,
highlighted: undefined,
view: undefined,
focus: undefined,
}}
className="btn"
>
See all skills
Browse skills
</Link>
</div>
</section>
</>
)}
</div>
<div className="hero-card hero-search-card fade-up" data-delay="2">
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="stat">Search skills. Versioned, rollback-ready.</div>
<InstallSwitcher exampleSlug="sonoscli" />
</div>
</div>
</div>
</section>
<section className="section">
<h2 className="section-title">Highlighted skills</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={getSkillBadges(skill)}
summaryFallback="A fresh skill bundle."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))
)}
</div>
</section>
<section className="section">
<h2 className="section-title">Latest drops</h2>
<p className="section-subtitle">Newest uploads across the registry.</p>
<div className="grid">
{latest.length === 0 ? (
<div className="card">No skills yet. Be the first.</div>
) : (
latest.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.versions} versions · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))
)}
</div>
<div className="section-cta">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
focus: undefined,
}}
className="btn"
>
See all skills
</Link>
</div>
</section>
</main>
)
}
function OnlyCrabsHome() {
const navigate = Route.useNavigate()
const search = Route.useSearch()
const searchSouls = useAction(api.search.searchSouls)
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds)
const latest = (useQuery(api.souls.list, { limit: 12 }) as Doc<'souls'>[]) ?? []
const [query, setQuery] = useState(search.q ?? '')
const [results, setResults] = useState<
Array<{ soul: Doc<'souls'>; version: Doc<'soulVersions'> | null; score: number }>
>([])
const [isSearching, setIsSearching] = useState(false)
const [searchMode, setSearchMode] = useState(Boolean(search.q))
const searchRequest = useRef(0)
const latest = (useQuery(api.souls.list, { limit: 12 }) as PublicSoul[]) ?? []
const [query, setQuery] = useState('')
const seedEnsuredRef = useRef(false)
const inputRef = useRef<HTMLInputElement | null>(null)
const trimmedQuery = useMemo(() => query.trim(), [query])
const hasQuery = trimmedQuery.length > 0
useEffect(() => {
setQuery(search.q ?? '')
if (search.q) {
setSearchMode(true)
}
}, [search.q])
useEffect(() => {
if (seedEnsuredRef.current) return
@@ -297,49 +148,9 @@ function OnlyCrabsHome() {
void ensureSoulSeeds({})
}, [ensureSoulSeeds])
useEffect(() => {
void navigate({
search: () => ({
q: trimmedQuery || undefined,
highlighted: undefined,
}),
replace: true,
})
}, [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 searchSouls({ query: trimmedQuery })) as Array<{
soul: Doc<'souls'>
version: Doc<'soulVersions'> | null
score: number
}>
if (requestId === searchRequest.current) {
setResults(data)
}
} finally {
if (requestId === searchRequest.current) {
setIsSearching(false)
}
}
})()
}, 220)
return () => window.clearTimeout(handle)
}, [searchSouls, trimmedQuery])
return (
<main>
<section className={`hero${searchMode ? ' search-mode' : ''}`}>
<section className="hero">
<div className="hero-inner">
<div className="hero-copy fade-up" data-delay="1">
<span className="hero-badge">SOUL.md, shared.</span>
@@ -354,7 +165,13 @@ function OnlyCrabsHome() {
</Link>
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
className="btn"
>
Browse souls
@@ -366,99 +183,70 @@ function OnlyCrabsHome() {
className="search-bar"
onSubmit={(event) => {
event.preventDefault()
if (!searchMode) setSearchMode(true)
inputRef.current?.focus()
void navigate({
to: '/souls',
search: {
q: trimmedQuery || undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
},
})
}}
>
<span className="mono">/</span>
<input
ref={inputRef}
className="search-input"
placeholder="Search souls, prompts, or lore"
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setSearchMode(true)}
onKeyDown={(event) => {
if (event.key === 'Escape' && !trimmedQuery) {
setSearchMode(false)
inputRef.current?.blur()
}
}}
/>
</form>
{!searchMode ? (
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="stat">Search souls. Versioned, readable, easy to remix.</div>
</div>
) : null}
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="stat">Search souls. Versioned, readable, easy to remix.</div>
</div>
</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.soul._id}
to="/souls/$slug"
params={{ slug: result.soul.slug }}
className="card"
>
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
{result.soul.displayName}
</h3>
<p className="section-subtitle" style={{ margin: 0 }}>
{result.soul.summary ?? 'SOUL.md bundle'}
</p>
</Link>
))
)}
</div>
</section>
) : (
<section className="section">
<h2 className="section-title">Latest souls</h2>
<p className="section-subtitle">Newest SOUL.md bundles across the hub.</p>
<div className="grid">
{latest.length === 0 ? (
<div className="card">No souls yet. Be the first.</div>
) : (
latest.map((soul) => (
<SoulCard
key={soul._id}
soul={soul}
summaryFallback="A SOUL.md bundle."
meta={
<div className="stat">
{soul.stats.stars} · {soul.stats.downloads} · {soul.stats.versions} v
</div>
}
/>
))
)}
</div>
<div className="section-cta">
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
className="btn"
>
See all souls
</Link>
</div>
</section>
)}
<section className="section">
<h2 className="section-title">Latest souls</h2>
<p className="section-subtitle">Newest SOUL.md bundles across the hub.</p>
<div className="grid">
{latest.length === 0 ? (
<div className="card">No souls yet. Be the first.</div>
) : (
latest.map((soul) => (
<SoulCard
key={soul._id}
soul={soul}
summaryFallback="A SOUL.md bundle."
meta={
<div className="stat">
{soul.stats.stars} · {soul.stats.downloads} · {soul.stats.versions} v
</div>
}
/>
))
)}
</div>
<div className="section-cta">
<Link
to="/souls"
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
className="btn"
>
See all souls
</Link>
</div>
</section>
</main>
)
}
+529
View File
@@ -0,0 +1,529 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery } from 'convex/react'
import { useEffect, useState } from 'react'
import { api } from '../../convex/_generated/api'
import type { Doc, Id } from '../../convex/_generated/dataModel'
import {
getSkillBadges,
isSkillDeprecated,
isSkillHighlighted,
isSkillOfficial,
} from '../lib/badges'
import { isAdmin, isModerator } from '../lib/roles'
import { useAuthStatus } from '../lib/useAuthStatus'
type ManagementSkillEntry = {
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
owner: Doc<'users'> | null
}
type RecentVersionEntry = {
version: Doc<'skillVersions'>
skill: Doc<'skills'> | null
owner: Doc<'users'> | null
}
type DuplicateCandidateEntry = {
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
fingerprint: string | null
matches: Array<{ skill: Doc<'skills'>; owner: Doc<'users'> | null }>
owner: Doc<'users'> | null
}
type SkillBySlugResult = {
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
owner: Doc<'users'> | null
canonical: {
skill: { slug: string; displayName: string }
owner: { handle: string | null; userId: Id<'users'> | null }
} | null
} | null
function resolveOwnerParam(handle: string | null | undefined, ownerId?: Id<'users'>) {
return handle?.trim() || (ownerId ? String(ownerId) : 'unknown')
}
export const Route = createFileRoute('/management')({
validateSearch: (search) => ({
skill: typeof search.skill === 'string' && search.skill.trim() ? search.skill : undefined,
}),
component: Management,
})
function Management() {
const { me } = useAuthStatus()
const search = Route.useSearch()
const staff = isModerator(me)
const admin = isAdmin(me)
const users = useQuery(api.users.list, admin ? { limit: 50 } : 'skip') as
| Doc<'users'>[]
| undefined
const selectedSlug = search.skill?.trim()
const selectedSkill = useQuery(
api.skills.getBySlug,
staff && selectedSlug ? { slug: selectedSlug } : 'skip',
) as SkillBySlugResult | undefined
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : 'skip') as
| RecentVersionEntry[]
| undefined
const reportedSkills = useQuery(api.skills.listReportedSkills, staff ? { limit: 25 } : 'skip') as
| ManagementSkillEntry[]
| undefined
const duplicateCandidates = useQuery(
api.skills.listDuplicateCandidates,
staff ? { limit: 20 } : 'skip',
) as DuplicateCandidateEntry[] | undefined
const setRole = useMutation(api.users.setRole)
const setBatch = useMutation(api.skills.setBatch)
const setSoftDeleted = useMutation(api.skills.setSoftDeleted)
const hardDelete = useMutation(api.skills.hardDelete)
const changeOwner = useMutation(api.skills.changeOwner)
const setDuplicate = useMutation(api.skills.setDuplicate)
const setOfficialBadge = useMutation(api.skills.setOfficialBadge)
const setDeprecatedBadge = useMutation(api.skills.setDeprecatedBadge)
const [selectedDuplicate, setSelectedDuplicate] = useState('')
const [selectedOwner, setSelectedOwner] = useState('')
const selectedSkillId = selectedSkill?.skill?._id ?? null
const selectedOwnerUserId = selectedSkill?.skill?.ownerUserId ?? null
const selectedCanonicalSlug = selectedSkill?.canonical?.skill?.slug ?? ''
useEffect(() => {
if (!selectedSkillId || !selectedOwnerUserId) return
setSelectedDuplicate(selectedCanonicalSlug)
setSelectedOwner(String(selectedOwnerUserId))
}, [selectedCanonicalSlug, selectedOwnerUserId, selectedSkillId])
if (!staff) {
return (
<main className="section">
<div className="card">Management only.</div>
</main>
)
}
if (!recentVersions || !reportedSkills || !duplicateCandidates) {
return (
<main className="section">
<div className="card">Loading management console</div>
</main>
)
}
return (
<main className="section">
<h1 className="section-title">Management console</h1>
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
<div className="card">
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Reported skills
</h2>
<div className="management-list">
{reportedSkills.length === 0 ? (
<div className="stat">No reports yet.</div>
) : (
reportedSkills.map((entry) => {
const { skill, latestVersion, owner } = entry
const ownerParam = resolveOwnerParam(
owner?.handle ?? null,
owner?._id ?? skill.ownerUserId,
)
return (
<div key={skill._id} className="management-item">
<div className="management-item-main">
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
{skill.displayName}
</Link>
<div className="section-subtitle" style={{ margin: 0 }}>
@{owner?.handle ?? owner?.name ?? 'user'} · v{latestVersion?.version ?? '—'} ·
{skill.reportCount ?? 0} report{(skill.reportCount ?? 0) === 1 ? '' : 's'}
{skill.lastReportedAt
? ` · last ${formatTimestamp(skill.lastReportedAt)}`
: ''}
</div>
</div>
<div className="management-actions">
<button
className="btn"
type="button"
onClick={() =>
void setSoftDeleted({ skillId: skill._id, deleted: !skill.softDeletedAt })
}
>
{skill.softDeletedAt ? 'Restore' : 'Hide'}
</button>
{admin ? (
<button
className="btn"
type="button"
onClick={() => {
if (!window.confirm(`Hard delete ${skill.displayName}?`)) return
void hardDelete({ skillId: skill._id })
}}
>
Hard delete
</button>
) : null}
</div>
</div>
)
})
)}
</div>
</div>
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Skill tools
</h2>
{selectedSlug ? (
<div className="section-subtitle" style={{ marginTop: 8 }}>
Managing "{selectedSlug}" ·{' '}
<Link to="/management" search={{ skill: undefined }}>
Clear selection
</Link>
</div>
) : null}
<div className="management-list">
{!selectedSlug ? (
<div className="stat">Use the Manage button on a skill to open tooling here.</div>
) : selectedSkill === undefined ? (
<div className="stat">Loading skill</div>
) : !selectedSkill?.skill ? (
<div className="stat">No skill found for "{selectedSlug}".</div>
) : (
(() => {
const { skill, latestVersion, owner, canonical } = selectedSkill
const ownerParam = resolveOwnerParam(
owner?.handle ?? null,
owner?._id ?? skill.ownerUserId,
)
const moderationStatus =
skill.moderationStatus ?? (skill.softDeletedAt ? 'hidden' : 'active')
const isHighlighted = isSkillHighlighted(skill)
const isOfficial = isSkillOfficial(skill)
const isDeprecated = isSkillDeprecated(skill)
const badges = getSkillBadges(skill)
return (
<div key={skill._id} className="management-item">
<div className="management-item-main">
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
{skill.displayName}
</Link>
<div className="section-subtitle" style={{ margin: 0 }}>
@{owner?.handle ?? owner?.name ?? 'user'} · v{latestVersion?.version ?? '—'} ·
updated {formatTimestamp(skill.updatedAt)} · {moderationStatus}
{badges.length ? ` · ${badges.join(', ').toLowerCase()}` : ''}
</div>
{skill.moderationFlags?.length ? (
<div className="management-tags">
{skill.moderationFlags.map((flag: string) => (
<span key={flag} className="tag">
{flag}
</span>
))}
</div>
) : null}
<div className="management-controls">
<label className="management-control">
<span className="mono">duplicate of</span>
<input
className="search-input"
value={selectedDuplicate}
onChange={(event) => setSelectedDuplicate(event.target.value)}
placeholder={canonical?.skill?.slug ?? 'canonical slug'}
/>
</label>
<button
className="btn"
type="button"
onClick={() =>
void setDuplicate({
skillId: skill._id,
canonicalSlug: selectedDuplicate.trim() || undefined,
})
}
>
Set duplicate
</button>
{admin ? (
<label className="management-control">
<span className="mono">owner</span>
<select
value={selectedOwner}
onChange={(event) => setSelectedOwner(event.target.value)}
>
{(users ?? []).map((user) => (
<option key={user._id} value={user._id}>
@{user.handle ?? user.name ?? 'user'}
</option>
))}
</select>
<button
className="btn"
type="button"
onClick={() =>
void changeOwner({
skillId: skill._id,
ownerUserId: selectedOwner as Doc<'users'>['_id'],
})
}
>
Change owner
</button>
</label>
) : null}
</div>
</div>
<div className="management-actions">
<Link
className="btn"
to="/$owner/$slug"
params={{ owner: ownerParam, slug: skill.slug }}
>
View
</Link>
<button
className="btn"
type="button"
onClick={() =>
void setSoftDeleted({ skillId: skill._id, deleted: !skill.softDeletedAt })
}
>
{skill.softDeletedAt ? 'Restore' : 'Hide'}
</button>
<button
className="btn"
type="button"
onClick={() =>
void setBatch({
skillId: skill._id,
batch: isHighlighted ? undefined : 'highlighted',
})
}
>
{isHighlighted ? 'Unhighlight' : 'Highlight'}
</button>
{admin ? (
<button
className="btn"
type="button"
onClick={() => {
if (!window.confirm(`Hard delete ${skill.displayName}?`)) return
void hardDelete({ skillId: skill._id })
}}
>
Hard delete
</button>
) : null}
{admin ? (
<>
<button
className="btn"
type="button"
onClick={() =>
void setOfficialBadge({
skillId: skill._id,
official: !isOfficial,
})
}
>
{isOfficial ? 'Remove official' : 'Mark official'}
</button>
<button
className="btn"
type="button"
onClick={() =>
void setDeprecatedBadge({
skillId: skill._id,
deprecated: !isDeprecated,
})
}
>
{isDeprecated ? 'Remove deprecated' : 'Mark deprecated'}
</button>
</>
) : null}
</div>
</div>
)
})()
)}
</div>
</div>
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Duplicate candidates
</h2>
<div className="management-list">
{duplicateCandidates.length === 0 ? (
<div className="stat">No duplicate candidates.</div>
) : (
duplicateCandidates.map((entry) => (
<div key={entry.skill._id} className="management-item">
<div className="management-item-main">
<Link
to="/$owner/$slug"
params={{
owner: resolveOwnerParam(
entry.owner?.handle ?? null,
entry.owner?._id ?? entry.skill.ownerUserId,
),
slug: entry.skill.slug,
}}
>
{entry.skill.displayName}
</Link>
<div className="section-subtitle" style={{ margin: 0 }}>
@{entry.owner?.handle ?? entry.owner?.name ?? 'user'} · v
{entry.latestVersion?.version ?? '—'} · fingerprint{' '}
{entry.fingerprint?.slice(0, 8)}
</div>
<div className="management-sublist">
{entry.matches.map((match) => (
<div key={match.skill._id} className="management-subitem">
<div>
<strong>{match.skill.displayName}</strong>
<div className="section-subtitle" style={{ margin: 0 }}>
@{match.owner?.handle ?? match.owner?.name ?? 'user'} ·{' '}
{match.skill.slug}
</div>
</div>
<div className="management-actions">
<Link
className="btn"
to="/$owner/$slug"
params={{
owner: resolveOwnerParam(
match.owner?.handle ?? null,
match.owner?._id ?? match.skill.ownerUserId,
),
slug: match.skill.slug,
}}
>
View
</Link>
<button
className="btn"
type="button"
onClick={() =>
void setDuplicate({
skillId: entry.skill._id,
canonicalSlug: match.skill.slug,
})
}
>
Mark duplicate
</button>
</div>
</div>
))}
</div>
</div>
<div className="management-actions">
<Link
className="btn"
to="/$owner/$slug"
params={{
owner: resolveOwnerParam(
entry.owner?.handle ?? null,
entry.owner?._id ?? entry.skill.ownerUserId,
),
slug: entry.skill.slug,
}}
>
View
</Link>
</div>
</div>
))
)}
</div>
</div>
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Recent pushes
</h2>
<div className="management-list">
{recentVersions.length === 0 ? (
<div className="stat">No recent versions.</div>
) : (
recentVersions.map((entry) => (
<div key={entry.version._id} className="management-item">
<div className="management-item-main">
<strong>{entry.skill?.displayName ?? 'Unknown skill'}</strong>
<div className="section-subtitle" style={{ margin: 0 }}>
v{entry.version.version} · @{entry.owner?.handle ?? entry.owner?.name ?? 'user'}
</div>
</div>
<div className="management-actions">
{entry.skill ? (
<Link
className="btn"
to="/$owner/$slug"
params={{
owner: resolveOwnerParam(
entry.owner?.handle ?? null,
entry.owner?._id ?? entry.skill.ownerUserId,
),
slug: entry.skill.slug,
}}
>
View
</Link>
) : null}
</div>
</div>
))
)}
</div>
</div>
{admin ? (
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Users
</h2>
<div className="management-list">
{(users ?? []).map((user) => (
<div key={user._id} className="management-item">
<div className="management-item-main">
<span className="mono">@{user.handle ?? user.name ?? 'user'}</span>
</div>
<div className="management-actions">
<select
value={user.role ?? 'user'}
onChange={(event) => {
const value = event.target.value
if (value === 'admin' || value === 'moderator' || value === 'user') {
void setRole({ userId: user._id, role: value })
}
}}
>
<option value="user">User</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
))}
</div>
</div>
) : null}
</main>
)
}
function formatTimestamp(value: number) {
return new Date(value).toLocaleString()
}
-29
View File
@@ -1,29 +0,0 @@
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,
}),
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 ? true : undefined,
}),
replace: true,
})
}, [navigate, search.highlighted, search.q])
return null
}
+11 -1
View File
@@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery } from 'convex/react'
import { useEffect, useState } from 'react'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
import { gravatarUrl } from '../lib/gravatar'
export const Route = createFileRoute('/settings')({
@@ -12,7 +13,16 @@ function Settings() {
const me = useQuery(api.users.me)
const updateProfile = useMutation(api.users.updateProfile)
const deleteAccount = useMutation(api.users.deleteAccount)
const tokens = useQuery(api.tokens.listMine)
const tokens = useQuery(api.tokens.listMine) as
| Array<{
_id: Id<'apiTokens'>
label: string
prefix: string
createdAt: number
lastUsedAt?: number
revokedAt?: number
}>
| undefined
const createToken = useMutation(api.tokens.create)
const revokeToken = useMutation(api.tokens.revoke)
const [displayName, setDisplayName] = useState('')
-55
View File
@@ -1,55 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { SkillDetailPage } from '../../components/SkillDetailPage'
import { buildSkillMeta, fetchSkillMeta } from '../../lib/og'
export const Route = createFileRoute('/skills/$slug')({
loader: async ({ params }) => {
const data = await fetchSkillMeta(params.slug)
return {
owner: data?.owner ?? null,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
}
},
head: ({ params, loaderData }) => {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? null,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
})
return {
links: [
{
rel: 'canonical',
href: meta.url,
},
],
meta: [
{ title: meta.title },
{ name: 'description', content: meta.description },
{ property: 'og:title', content: meta.title },
{ property: 'og:description', content: meta.description },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: meta.url },
{ property: 'og:image', content: meta.image },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ property: 'og:image:alt', content: meta.title },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: meta.title },
{ name: 'twitter:description', content: meta.description },
{ name: 'twitter:image', content: meta.image },
{ name: 'twitter:image:alt', content: meta.title },
],
}
},
component: LegacySkill,
})
function LegacySkill() {
const { slug } = Route.useParams()
return <SkillDetailPage slug={slug} redirectToCanonical />
}
+73 -58
View File
@@ -1,12 +1,15 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useAction, useQuery } from 'convex/react'
import { useAction } from 'convex/react'
import { usePaginatedQuery } from 'convex-helpers/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../../convex/_generated/api'
import type { Doc } from '../../../convex/_generated/dataModel'
import { SkillCard } from '../../components/SkillCard'
import { getSkillBadges, isSkillHighlighted } from '../../lib/badges'
import type { PublicSkill } from '../../lib/publicUser'
const sortKeys = ['newest', 'downloads', 'installs', 'stars', 'name', 'updated'] as const
const pageSize = 50
const pageSize = 25
type SortKey = (typeof sortKeys)[number]
type SortDir = 'asc' | 'desc'
@@ -21,14 +24,36 @@ function parseDir(value: unknown, sort: SortKey): SortDir {
return sort === 'name' ? 'asc' : 'desc'
}
type SkillListEntry = {
skill: PublicSkill
latestVersion: Doc<'skillVersions'> | null
ownerHandle?: string | null
}
type SkillSearchEntry = {
skill: PublicSkill
version: Doc<'skillVersions'> | null
score: number
ownerHandle?: string | null
}
function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) {
const owner = ownerHandle?.trim() || String(skill.ownerUserId)
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`
}
export const Route = createFileRoute('/skills/')({
validateSearch: (search) => {
return {
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
highlighted:
search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true
? true
: undefined,
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
focus: search.focus === 'search' ? 'search' : undefined,
}
},
component: SkillsIndex,
@@ -43,50 +68,44 @@ export function SkillsIndex() {
const highlightedOnly = search.highlighted ?? false
const [query, setQuery] = useState(search.q ?? '')
const searchSkills = useAction(api.search.searchSkills)
const [pages, setPages] = useState<
Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
>([])
const [cursor, setCursor] = useState<string | null>(null)
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [searchResults, setSearchResults] = useState<
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
>([])
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([])
const [searchLimit, setSearchLimit] = useState(pageSize)
const [isSearching, setIsSearching] = useState(false)
const searchRequest = useRef(0)
const loadMoreRef = useRef<HTMLDivElement | null>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const trimmedQuery = useMemo(() => query.trim(), [query])
const hasQuery = trimmedQuery.length > 0
const searchKey = trimmedQuery ? `${trimmedQuery}::${highlightedOnly ? '1' : '0'}` : ''
const listPage = useQuery(
api.skills.listPublicPage,
hasQuery ? 'skip' : { cursor: cursor ?? undefined, limit: pageSize },
) as
| {
items: Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
nextCursor: string | null
}
| undefined
const isLoadingList = !hasQuery && pages.length === 0 && listPage === undefined
// Use convex-helpers usePaginatedQuery for better cache behavior
const {
results: paginatedResults,
status: paginationStatus,
loadMore: loadMorePaginated,
} = usePaginatedQuery(api.skills.listPublicPageV2, hasQuery ? 'skip' : {}, {
initialNumItems: pageSize,
})
// Derive loading states from pagination status
// status: 'LoadingFirstPage' | 'CanLoadMore' | 'LoadingMore' | 'Exhausted'
const isLoadingList = paginationStatus === 'LoadingFirstPage'
const canLoadMoreList = paginationStatus === 'CanLoadMore'
const isLoadingMoreList = paginationStatus === 'LoadingMore'
useEffect(() => {
setQuery(search.q ?? '')
}, [search.q])
// Auto-focus search input when focus=search param is present
useEffect(() => {
if (hasQuery) return
setPages([])
setCursor(null)
setNextCursor(null)
}, [hasQuery])
useEffect(() => {
if (hasQuery || !listPage) return
setNextCursor(listPage.nextCursor)
setPages((prev) => (cursor ? [...prev, ...listPage.items] : listPage.items))
}, [cursor, hasQuery, listPage])
if (search.focus === 'search' && searchInputRef.current) {
searchInputRef.current.focus()
// Clear the focus param from URL to avoid re-focusing on navigation
void navigate({ search: (prev) => ({ ...prev, focus: undefined }), replace: true })
}
}, [search.focus, navigate])
useEffect(() => {
if (!searchKey) {
@@ -110,11 +129,7 @@ export function SkillsIndex() {
query: trimmedQuery,
highlightedOnly,
limit: searchLimit,
})) as Array<{
skill: Doc<'skills'>
version: Doc<'skillVersions'> | null
score: number
}>
})) as Array<SkillSearchEntry>
if (requestId === searchRequest.current) {
setSearchResults(data)
}
@@ -133,14 +148,15 @@ export function SkillsIndex() {
return searchResults.map((entry) => ({
skill: entry.skill,
latestVersion: entry.version,
ownerHandle: entry.ownerHandle ?? null,
}))
}
return pages
}, [hasQuery, pages, searchResults])
// paginatedResults is an array of page items from usePaginatedQuery
return paginatedResults as Array<SkillListEntry>
}, [hasQuery, paginatedResults, searchResults])
const filtered = useMemo(
() =>
baseItems.filter((entry) => (highlightedOnly ? entry.skill.batch === 'highlighted' : true)),
() => baseItems.filter((entry) => (highlightedOnly ? isSkillHighlighted(entry.skill) : true)),
[baseItems, highlightedOnly],
)
@@ -175,20 +191,18 @@ export function SkillsIndex() {
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList
const canLoadMore = hasQuery
? !isSearching && searchResults.length === searchLimit && searchResults.length > 0
: nextCursor !== null
const isLoadingMore = hasQuery
? isSearching && searchResults.length > 0
: listPage === undefined && pages.length > 0
: canLoadMoreList
const isLoadingMore = hasQuery ? isSearching && searchResults.length > 0 : isLoadingMoreList
const canAutoLoad = typeof IntersectionObserver !== 'undefined'
const loadMore = useCallback(() => {
if (isLoadingMore || !canLoadMore) return
if (hasQuery) {
setSearchLimit((value) => value + pageSize)
} else if (nextCursor) {
setCursor(nextCursor)
} else {
loadMorePaginated(pageSize)
}
}, [canLoadMore, hasQuery, isLoadingMore, nextCursor])
}, [canLoadMore, hasQuery, isLoadingMore, loadMorePaginated])
useEffect(() => {
if (!canLoadMore || typeof IntersectionObserver === 'undefined') return
@@ -222,6 +236,7 @@ export function SkillsIndex() {
<div className="skills-toolbar">
<div className="skills-search">
<input
ref={searchInputRef}
className="skills-search-input"
value={query}
onChange={(event) => {
@@ -322,11 +337,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 (
<SkillCard
key={skill._id}
skill={skill}
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
href={skillHref}
badge={getSkillBadges(skill)}
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
summaryFallback="Agent-ready skill pack."
meta={
@@ -344,20 +361,18 @@ export function SkillsIndex() {
{sorted.map((entry) => {
const skill = entry.skill
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
const skillHref = buildSkillHref(skill, entry.ownerHandle)
return (
<Link
key={skill._id}
className="skills-row"
to="/skills/$slug"
params={{ slug: skill.slug }}
>
<Link key={skill._id} className="skills-row" to={skillHref}>
<div className="skills-row-main">
<div className="skills-row-title">
<span>{skill.displayName}</span>
<span className="skills-row-slug">/{skill.slug}</span>
{skill.batch === 'highlighted' ? (
<span className="tag">Highlighted</span>
) : null}
{getSkillBadges(skill).map((badge) => (
<span key={badge} className="tag">
{badge}
</span>
))}
{isPlugin ? (
<span className="tag tag-accent tag-compact">Plugin bundle (nix)</span>
) : null}
+14 -2
View File
@@ -2,8 +2,8 @@ import { createFileRoute, Link } from '@tanstack/react-router'
import { useAction, useQuery } from 'convex/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../../convex/_generated/api'
import type { Doc } from '../../../convex/_generated/dataModel'
import { SoulCard } from '../../components/SoulCard'
import type { PublicSoul } from '../../lib/publicUser'
const sortKeys = ['newest', 'downloads', 'stars', 'name', 'updated'] as const
type SortKey = (typeof sortKeys)[number]
@@ -27,6 +27,7 @@ export const Route = createFileRoute('/souls/')({
sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
focus: search.focus === 'search' ? 'search' : undefined,
}
},
component: SoulsIndex,
@@ -40,15 +41,25 @@ function SoulsIndex() {
const view = search.view ?? 'list'
const [query, setQuery] = useState(search.q ?? '')
const souls = useQuery(api.souls.list, { limit: 500 }) as Doc<'souls'>[] | undefined
const souls = useQuery(api.souls.list, { limit: 500 }) as PublicSoul[] | undefined
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds)
const seedEnsuredRef = useRef(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const isLoadingSouls = souls === undefined
useEffect(() => {
setQuery(search.q ?? '')
}, [search.q])
// Auto-focus search input when focus=search param is present
useEffect(() => {
if (search.focus === 'search' && searchInputRef.current) {
searchInputRef.current.focus()
// Clear the focus param from URL to avoid re-focusing on navigation
void navigate({ search: (prev) => ({ ...prev, focus: undefined }), replace: true })
}
}, [search.focus, navigate])
useEffect(() => {
if (seedEnsuredRef.current) return
seedEnsuredRef.current = true
@@ -108,6 +119,7 @@ function SoulsIndex() {
<div className="skills-toolbar">
<div className="skills-search">
<input
ref={searchInputRef}
className="skills-search-input"
value={query}
onChange={(event) => {
+34 -11
View File
@@ -1,19 +1,22 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from 'convex/react'
import { useMutation, useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import type { PublicSkill } from '../lib/publicUser'
export const Route = createFileRoute('/stars')({
component: Stars,
})
function Stars() {
const me = useQuery(api.users.me)
const me = useQuery(api.users.me) as Doc<'users'> | null | undefined
const skills =
(useQuery(api.stars.listByUser, me ? { userId: me._id, limit: 50 } : 'skip') as
| Doc<'skills'>[]
| PublicSkill[]
| undefined) ?? []
const toggleStar = useMutation(api.stars.toggle)
if (!me) {
return (
<main className="section">
@@ -30,14 +33,34 @@ function Stars() {
{skills.length === 0 ? (
<div className="card">No stars yet.</div>
) : (
skills.map((skill) => (
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
{skill.displayName}
</h3>
<div className="stat"> {skill.stats.stars}</div>
</Link>
))
skills.map((skill) => {
const owner = encodeURIComponent(String(skill.ownerUserId))
return (
<div key={skill._id} className="card skill-card">
<Link to="/$owner/$slug" params={{ owner, slug: skill.slug }}>
<h3 className="skill-card-title">{skill.displayName}</h3>
</Link>
<div className="skill-card-footer skill-card-footer-inline">
<span className="stat"> {skill.stats.stars}</span>
<button
className="star-toggle is-active"
type="button"
onClick={async () => {
try {
await toggleStar({ skillId: skill._id })
} catch (error) {
console.error('Failed to unstar skill:', error)
window.alert('Unable to unstar this skill. Please try again.')
}
}}
aria-label={`Unstar ${skill.displayName}`}
>
<span aria-hidden="true"></span>
</button>
</div>
</div>
)
})
)}
</div>
</main>
+51 -6
View File
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react'
import { api } from '../../../convex/_generated/api'
import type { Doc } from '../../../convex/_generated/dataModel'
import { SkillCard } from '../../components/SkillCard'
import { getSkillBadges } from '../../lib/badges'
import type { PublicSkill, PublicUser } from '../../lib/publicUser'
export const Route = createFileRoute('/u/$handle')({
component: UserProfile,
@@ -11,12 +13,16 @@ export const Route = createFileRoute('/u/$handle')({
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 me = useQuery(api.users.me) as Doc<'users'> | null | undefined
const user = useQuery(api.users.getByHandle, { handle }) as PublicUser | null | undefined
const publishedSkills = useQuery(
api.skills.list,
user ? { ownerUserId: user._id, limit: 50 } : 'skip',
) as PublicSkill[] | undefined
const starredSkills = useQuery(
api.stars.listByUser,
user ? { userId: user._id, limit: 50 } : 'skip',
) as Doc<'skills'>[] | undefined
) as PublicSkill[] | undefined
const isSelf = Boolean(me && user && me._id === user._id)
const [tab, setTab] = useState<'stars' | 'installed'>('stars')
@@ -54,6 +60,8 @@ function UserProfile() {
const initial = displayName.charAt(0).toUpperCase()
const isLoadingSkills = starredSkills === undefined
const skills = starredSkills ?? []
const isLoadingPublished = publishedSkills === undefined
const published = publishedSkills ?? []
return (
<main className="section">
@@ -98,6 +106,34 @@ function UserProfile() {
/>
) : (
<>
<h2 className="section-title" style={{ fontSize: '1.3rem' }}>
Published
</h2>
<p className="section-subtitle">Skills published by this user.</p>
{isLoadingPublished ? (
<div className="card">
<div className="loading-indicator">Loading published skills</div>
</div>
) : published.length > 0 ? (
<div className="grid" style={{ marginBottom: 18 }}>
{published.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge={getSkillBadges(skill)}
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>
@@ -115,7 +151,7 @@ function UserProfile() {
<SkillCard
key={skill._id}
skill={skill}
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
badge={getSkillBadges(skill)}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
@@ -223,7 +259,10 @@ function InstalledSection(props: {
<div className="telemetry-skill-list">
{root.skills.map((entry) => (
<div key={`${root.rootId}:${entry.skill.slug}`} className="telemetry-skill-row">
<a className="telemetry-skill-link" href={`/skills/${entry.skill.slug}`}>
<a
className="telemetry-skill-link"
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
>
<span>{entry.skill.displayName}</span>
<span className="telemetry-skill-slug">/{entry.skill.slug}</span>
</a>
@@ -251,7 +290,13 @@ type TelemetryResponse = {
lastSeenAt: number
expiredAt?: number
skills: Array<{
skill: { slug: string; displayName: string; summary?: string; stats: unknown }
skill: {
slug: string
displayName: string
summary?: string
stats: unknown
ownerUserId: string
}
firstSeenAt: number
lastSeenAt: number
lastVersion?: string
+18 -7
View File
@@ -1,10 +1,11 @@
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
import { useAction, useMutation, useQuery } from 'convex/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import semver from 'semver'
import { api } from '../../convex/_generated/api'
import { getSiteMode } from '../lib/site'
import { expandFiles } from '../lib/uploadFiles'
import { expandDroppedItems, expandFiles } from '../lib/uploadFiles'
import { useAuthStatus } from '../lib/useAuthStatus'
import {
formatBytes,
formatPublishError,
@@ -24,7 +25,7 @@ export const Route = createFileRoute('/upload')({
})
export function Upload() {
const { isAuthenticated } = useConvexAuth()
const { isAuthenticated, me } = useAuthStatus()
const { updateSlug } = useSearch({ from: '/upload' })
const siteMode = getSiteMode()
const isSoulMode = siteMode === 'souls'
@@ -308,9 +309,10 @@ export function Upload() {
setHasAttempted(false)
setChangelogSource('user')
if (result) {
const ownerParam = me?.handle ?? (me?._id ? String(me._id) : 'unknown')
void navigate({
to: isSoulMode ? '/souls/$slug' : '/skills/$slug',
params: { slug: trimmedSlug },
to: isSoulMode ? '/souls/$slug' : '/$owner/$slug',
params: isSoulMode ? { slug: trimmedSlug } : { owner: ownerParam, slug: trimmedSlug },
})
}
} catch (error) {
@@ -384,8 +386,14 @@ export function Upload() {
onDrop={(event) => {
event.preventDefault()
setIsDragging(false)
const dropped = Array.from(event.dataTransfer.files)
void expandFiles(dropped).then((next) => setFiles(next))
const items = event.dataTransfer.items
void (async () => {
const dropped = items?.length
? await expandDroppedItems(items)
: Array.from(event.dataTransfer.files)
const next = await expandFiles(dropped)
setFiles(next)
})()
}}
>
<input
@@ -395,6 +403,9 @@ export function Upload() {
data-testid="upload-input"
type="file"
multiple
// @ts-expect-error - non-standard attribute to allow folder selection
webkitdirectory=""
directory=""
onChange={(event) => {
const picked = Array.from(event.target.files ?? [])
void expandFiles(picked).then((next) => setFiles(next))
+100
View File
@@ -378,9 +378,11 @@ code {
}
.section {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px 72px;
box-sizing: border-box;
}
.upload-shell {
@@ -926,6 +928,7 @@ code {
}
.card {
width: 100%;
background: var(--surface);
border-radius: var(--radius-md);
border: 1px solid var(--line);
@@ -933,6 +936,7 @@ code {
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
@@ -1002,6 +1006,13 @@ code {
margin-top: auto;
}
.skill-card-footer-inline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hero-install {
display: grid;
gap: 10px;
@@ -2616,3 +2627,92 @@ html.theme-transition::view-transition-new(theme) {
justify-content: center;
}
}
.management-list {
display: grid;
gap: 16px;
margin-top: 12px;
}
.management-item {
display: grid;
gap: 16px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--surface);
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.management-item-main {
display: grid;
gap: 6px;
min-width: 0;
}
.management-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.management-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.management-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.management-control {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.management-control .mono {
font-size: 12px;
color: var(--ink-soft);
}
.management-control input {
min-width: 180px;
}
.management-sublist {
display: grid;
gap: 8px;
margin-top: 10px;
padding-left: 12px;
border-left: 1px solid var(--line);
}
.management-subitem {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
}
@media (max-width: 900px) {
.management-item {
grid-template-columns: 1fr;
}
.management-actions {
justify-content: flex-start;
}
.management-subitem {
justify-content: flex-start;
}
}
+20 -13
View File
@@ -1,4 +1,5 @@
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
@@ -8,25 +9,31 @@ import { defineConfig } from 'vite'
import viteTsConfigPaths from 'vite-tsconfig-paths'
const require = createRequire(import.meta.url)
const resvgWasmPath = require.resolve('@resvg/resvg-wasm/index_bg.wasm')
const bricolageBoldPath = require.resolve(
'@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2',
)
const bricolageTextPath = require.resolve(
'@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2',
)
const plexMonoPath = require.resolve(
'@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2',
)
const convexEntry = require.resolve('convex')
const convexRoot = dirname(dirname(dirname(convexEntry)))
const convexReactPath = join(convexRoot, 'dist/esm/react/index.js')
const convexBrowserPath = join(convexRoot, 'dist/esm/browser/index.js')
const convexValuesPath = join(convexRoot, 'dist/esm/values/index.js')
const convexAuthReactPath = require.resolve('@convex-dev/auth/react')
const config = defineConfig({
resolve: {
dedupe: ['convex', '@convex-dev/auth', 'react', 'react-dom'],
alias: {
'convex/react': convexReactPath,
'convex/browser': convexBrowserPath,
'convex/values': convexValuesPath,
'@convex-dev/auth/react': convexAuthReactPath,
},
},
optimizeDeps: {
include: ['convex/react', 'convex/browser'],
},
plugins: [
devtools(),
nitro({
serverDir: 'server',
externals: {
traceInclude: [resvgWasmPath, bricolageBoldPath, bricolageTextPath, plexMonoPath],
},
}),
// this is the plugin that enables path aliases
viteTsConfigPaths({