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
63 changed files with 4047 additions and 820 deletions
+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`.
+14 -2
View File
@@ -23,7 +23,8 @@
"@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",
@@ -52,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",
@@ -739,7 +741,9 @@
"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=="],
"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=="],
@@ -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=="],
@@ -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=="],
@@ -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=="],
+10
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";
@@ -24,12 +25,15 @@ 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";
@@ -44,6 +48,7 @@ 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";
@@ -68,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;
@@ -80,12 +86,15 @@ declare const fullApi: ApiFromModules<{
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;
@@ -100,6 +109,7 @@ 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;
+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, {
+7
View File
@@ -24,4 +24,11 @@ crons.interval(
{ batchSize: 200, maxBatches: 5 },
)
crons.interval(
'skill-stat-events',
{ minutes: 15 },
internal.skillStatEvents.processSkillStatEventsAction,
{},
)
export default crons
+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 -7
View File
@@ -2,7 +2,7 @@ import { v } from 'convex/values'
import { zipSync } from 'fflate'
import { api } from './_generated/api'
import { httpAction, mutation } from './_generated/server'
import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
import { insertStatEvent } from './skillStatEvents'
export const downloadZip = httpAction(async (ctx, request) => {
const url = new URL(request.url)
@@ -70,12 +70,9 @@ export const increment = mutation({
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId)
if (!skill) return
const now = Date.now()
const patch = applySkillStatDeltas(skill, { downloads: 1 })
await ctx.db.patch(skill._id, {
...patch,
updatedAt: now,
await insertStatEvent(ctx, {
skillId: skill._id,
kind: 'download',
})
await bumpDailySkillStats(ctx, { skillId: skill._id, now, downloads: 1 })
},
})
+24 -1
View File
@@ -59,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 = {
@@ -267,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,
}
@@ -374,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`
@@ -382,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 })
}
@@ -983,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`
@@ -991,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 })
}
+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)
}
+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,
}
}
+9 -4
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,
@@ -164,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
@@ -216,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 ?? {}),
}
@@ -259,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 = {
@@ -268,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 ?? {}),
}
+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
+92 -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,7 +49,36 @@ 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()),
@@ -72,6 +103,7 @@ const skills = defineTable({
.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(),
@@ -139,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(),
@@ -157,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'),
@@ -217,6 +265,35 @@ const skillStatBackfillState = defineTable({
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'),
@@ -246,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'),
@@ -353,20 +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,
+46 -12
View File
@@ -2,12 +2,14 @@ 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
}
@@ -60,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,
]),
)
@@ -91,6 +105,14 @@ 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[]> => {
@@ -116,7 +138,9 @@ export const hydrateResults = internalQuery({
ctx.db.get(embedding.versionId),
getOwnerHandle(skill.ownerUserId),
])
return { embeddingId, skill, version, ownerHandle }
const publicSkill = toPublicSkill(skill)
if (!publicSkill) return null
return { embeddingId, skill: publicSkill, version, ownerHandle }
}),
)
@@ -126,7 +150,7 @@ export const hydrateResults = internalQuery({
type HydratedSoulEntry = {
embeddingId: Id<'soulEmbeddings'>
soul: Doc<'souls'> | null
soul: NonNullable<ReturnType<typeof toPublicSoul>>
version: Doc<'soulVersions'> | null
}
@@ -174,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,
]),
)
@@ -210,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 }
+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,
}
},
})
+781 -48
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
},
+25 -9
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 }
@@ -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()
+10 -21
View File
@@ -1,8 +1,8 @@
import { v } from 'convex/values'
import type { Doc } from './_generated/dataModel'
import { internalMutation, mutation, query } from './_generated/server'
import { requireUser } from './lib/access'
import { applySkillStatDeltas } from './lib/skillStats'
import { toPublicSkill } from './lib/public'
import { insertStatEvent } from './skillStatEvents'
export const isStarred = query({
args: { skillId: v.id('skills') },
@@ -30,11 +30,7 @@ export const toggle = mutation({
if (existing) {
await ctx.db.delete(existing._id)
const patch = applySkillStatDeltas(skill, { stars: -1 })
await ctx.db.patch(skill._id, {
...patch,
updatedAt: Date.now(),
})
await insertStatEvent(ctx, { skillId: skill._id, kind: 'unstar' })
return { starred: false }
}
@@ -44,10 +40,7 @@ export const toggle = mutation({
createdAt: Date.now(),
})
await ctx.db.patch(skill._id, {
...applySkillStatDeltas(skill, { stars: 1 }),
updatedAt: Date.now(),
})
await insertStatEvent(ctx, { skillId: skill._id, kind: 'star' })
return { starred: true }
},
@@ -62,10 +55,12 @@ 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
},
@@ -88,10 +83,7 @@ export const addStarInternal = internalMutation({
createdAt: Date.now(),
})
await ctx.db.patch(skill._id, {
...applySkillStatDeltas(skill, { stars: 1 }),
updatedAt: Date.now(),
})
await insertStatEvent(ctx, { skillId: skill._id, kind: 'star' })
return { ok: true as const, starred: true, alreadyStarred: false }
},
@@ -109,10 +101,7 @@ export const removeStarInternal = internalMutation({
if (!existing) return { ok: true as const, unstarred: false, alreadyUnstarred: true }
await ctx.db.delete(existing._id)
await ctx.db.patch(skill._id, {
...applySkillStatDeltas(skill, { stars: -1 }),
updatedAt: Date.now(),
})
await insertStatEvent(ctx, { skillId: skill._id, kind: 'unstar' })
return { ok: true as const, unstarred: true, alreadyUnstarred: false }
},
+29 -29
View File
@@ -4,7 +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 { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
import { insertStatEvent } from './skillStatEvents'
const TELEMETRY_STALE_MS = 120 * 24 * 60 * 60 * 1000
@@ -88,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
@@ -105,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
@@ -121,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,
@@ -158,13 +171,13 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<'use
await ctx.db.delete(entry._id)
continue
}
const patch = applySkillStatDeltas(skill, {
installsCurrent: entry.activeRoots > 0 ? -1 : 0,
installsAllTime: -1,
})
await ctx.db.patch(skill._id, {
...patch,
updatedAt: Date.now(),
await insertStatEvent(ctx, {
skillId: skill._id,
kind: 'install_clear',
delta: {
allTime: -1,
current: entry.activeRoots > 0 ? -1 : 0,
},
})
await ctx.db.delete(entry._id)
}
@@ -371,25 +384,12 @@ 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 now = Date.now()
const patch = applySkillStatDeltas(skill, {
installsAllTime: params.deltaAllTime,
installsCurrent: params.deltaCurrent,
})
await ctx.db.patch(skill._id, {
...patch,
updatedAt: now,
})
if (params.deltaAllTime > 0) {
await bumpDailySkillStats(ctx, {
skillId: params.skillId,
now,
installs: params.deltaAllTime,
})
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' })
}
}
+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)) {
+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'
+4 -1
View File
@@ -6,6 +6,7 @@
"packages/*"
],
"scripts": {
"preinstall": "bunx only-allow bun",
"dev": "bun --bun vite dev --port 3000",
"build": "bun --bun vite build",
"preview": "bun --bun vite preview",
@@ -42,7 +43,8 @@
"@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",
@@ -71,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",
+6 -6
View File
@@ -247,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;
@@ -294,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];
+5 -5
View File
@@ -216,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?',
@@ -235,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
File diff suppressed because one or more lines are too long
-68
View File
@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
redirect: (options: unknown) => ({ redirect: options }),
}))
import { Route } from '../routes/search'
describe('search route', () => {
it('redirects to the skills index', () => {
const route = Route as unknown as {
__config: {
beforeLoad?: (args: { search: { q?: string; highlighted?: boolean } }) => void
}
}
const beforeLoad = route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean }
}) => void
let thrown: unknown
try {
beforeLoad({ search: { q: 'crab', highlighted: true } })
} catch (error) {
thrown = error
}
expect(thrown).toEqual({
redirect: {
to: '/skills',
search: {
q: 'crab',
highlighted: true,
},
replace: true,
},
})
})
it('redirects to the skills index without query', () => {
const route = Route as unknown as {
__config: {
beforeLoad?: (args: { search: { q?: string; highlighted?: boolean } }) => void
}
}
const beforeLoad = route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean }
}) => void
let thrown: unknown
try {
beforeLoad({ search: {} })
} catch (error) {
thrown = error
}
expect(thrown).toEqual({
redirect: {
to: '/skills',
search: {
q: undefined,
highlighted: undefined,
},
replace: true,
},
})
})
})
+33 -14
View File
@@ -6,8 +6,8 @@ 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', () => ({
@@ -20,17 +20,25 @@ vi.mock('@tanstack/react-router', () => ({
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(() => {
@@ -40,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', () => {
@@ -55,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,
})
})
@@ -76,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 />)
@@ -96,7 +115,7 @@ describe('SkillsIndex', () => {
expect(actionFn).toHaveBeenLastCalledWith({
query: 'remind',
highlightedOnly: false,
limit: 100,
limit: 50,
})
})
})
+70 -10
View File
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
import { Menu, Monitor, Moon, Sun } from 'lucide-react'
import { useMemo, useRef } from 'react'
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'
@@ -29,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({
@@ -62,7 +63,13 @@ export default function Header() {
{isSoulMode ? (
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
@@ -75,6 +82,7 @@ export default function Header() {
dir: undefined,
highlighted: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
@@ -84,11 +92,35 @@ export default function Header() {
Upload
</Link>
{isSoulMode ? null : <Link to="/import">Import</Link>}
<Link to="/" search={{ q: undefined, highlighted: undefined, search: true }}>
<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>
{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">
@@ -108,7 +140,13 @@ export default function Header() {
{isSoulMode ? (
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
search={{
q: undefined,
sort: undefined,
dir: undefined,
view: undefined,
focus: undefined,
}}
>
Souls
</Link>
@@ -121,6 +159,7 @@ export default function Header() {
dir: undefined,
highlighted: undefined,
view: undefined,
focus: undefined,
}}
>
Skills
@@ -138,7 +177,27 @@ export default function Header() {
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/" search={{ q: undefined, highlighted: undefined, search: true }}>
<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>
@@ -147,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 />
@@ -168,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) => {
+12 -6
View File
@@ -1,10 +1,10 @@
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
@@ -12,13 +12,19 @@ type SkillCardProps = {
}
export function SkillCard({ skill, badge, chip, summaryFallback, meta, href }: SkillCardProps) {
const link = href ?? `/skills/${skill.slug}`
const owner = encodeURIComponent(String(skill.ownerUserId))
const link = href ?? `/${owner}/${skill.slug}`
const badges = Array.isArray(badge) ? badge : badge ? [badge] : []
return (
<Link to={link} className="card skill-card">
{badge || chip ? (
{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}
+70 -36
View File
@@ -1,4 +1,4 @@
import { useNavigate } from '@tanstack/react-router'
import { Link, useNavigate } from '@tanstack/react-router'
import type { ClawdisSkillMetadata, SkillInstallSpec } from 'clawdhub-schema'
import { useAction, useMutation, useQuery } from 'convex/react'
import { useEffect, useMemo, useState } from 'react'
@@ -6,6 +6,9 @@ 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'
@@ -15,6 +18,22 @@ 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({
@@ -24,12 +43,12 @@ export function SkillDetailPage({
}: SkillDetailPageProps) {
const navigate = useNavigate()
const { isAuthenticated, me } = useAuthStatus()
const result = useQuery(api.skills.getBySlug, { slug })
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)
@@ -58,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]),
@@ -221,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
@@ -233,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">
@@ -612,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"
@@ -635,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
@@ -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
}
+11 -7
View File
@@ -4,15 +4,23 @@ 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, me } = useAuthStatus()
const result = useQuery(api.souls.getBySlug, { slug })
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"
+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
}
+2 -1
View File
@@ -1,8 +1,9 @@
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)
const me = useQuery(api.users.me) as Doc<'users'> | null | undefined
return {
me,
isLoading: me === undefined,
+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>
)
}
+19 -30
View File
@@ -3,17 +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 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 Doc<'skills'>[] | undefined
) as PublicSkill[] | undefined
if (!me) {
return (
@@ -24,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">
@@ -59,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">
@@ -89,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>
)
+3 -2
View File
@@ -38,7 +38,7 @@ type CandidatePreview = {
}
function ImportGitHub() {
const { isAuthenticated, isLoading } = useAuthStatus()
const { isAuthenticated, isLoading, me } = useAuthStatus()
const previewImport = useAction(api.githubImport.previewGitHubImport)
const previewCandidate = useAction(api.githubImport.previewGitHubImportCandidate)
const importSkill = useAction(api.githubImport.importGitHubSkill)
@@ -167,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)
+93 -167
View File
@@ -2,24 +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' || search.highlighted === true
? true
: undefined,
search:
search.search === '1' || search.search === 'true' || search.search === true
? true
: undefined,
}),
component: Home,
})
@@ -30,8 +20,11 @@ function Home() {
function SkillsHome() {
const highlighted =
(useQuery(api.skills.list, { batch: 'highlighted', limit: 6 }) as Doc<'skills'>[]) ?? []
const latest = (useQuery(api.skills.list, { limit: 12 }) as Doc<'skills'>[]) ?? []
(useQuery(api.skills.list, {
batch: 'highlighted',
limit: 6,
}) as PublicSkill[]) ?? []
const latest = (useQuery(api.skills.list, { limit: 12 }) as PublicSkill[]) ?? []
return (
<main>
@@ -48,7 +41,18 @@ function SkillsHome() {
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
Publish a skill
</Link>
<Link to="/skills" className="btn">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
focus: undefined,
}}
className="btn"
>
Browse skills
</Link>
</div>
@@ -63,7 +67,7 @@ function SkillsHome() {
</section>
<section className="section">
<h2 className="section-title">Highlighted batch</h2>
<h2 className="section-title">Highlighted skills</h2>
<p className="section-subtitle">Curated signal highlighted for quick trust.</p>
<div className="grid">
{highlighted.length === 0 ? (
@@ -73,7 +77,7 @@ function SkillsHome() {
<SkillCard
key={skill._id}
skill={skill}
badge="Highlighted"
badge={getSkillBadges(skill)}
summaryFallback="A fresh skill bundle."
meta={
<div className="stat">
@@ -110,7 +114,18 @@ function SkillsHome() {
)}
</div>
<div className="section-cta">
<Link to="/skills" className="btn">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
focus: undefined,
}}
className="btn"
>
See all skills
</Link>
</div>
@@ -121,30 +136,11 @@ function SkillsHome() {
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 || search.search))
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 || search.search) {
setSearchMode(true)
} else {
setSearchMode(false)
}
}, [search.q, search.search])
useEffect(() => {
if (seedEnsuredRef.current) return
@@ -152,56 +148,9 @@ function OnlyCrabsHome() {
void ensureSoulSeeds({})
}, [ensureSoulSeeds])
useEffect(() => {
void navigate({
search: () => ({
q: trimmedQuery || undefined,
highlighted: undefined,
search: searchMode && !trimmedQuery ? true : undefined,
}),
replace: true,
})
}, [navigate, searchMode, trimmedQuery])
useEffect(() => {
if (searchMode && inputRef.current) {
inputRef.current.focus()
}
}, [searchMode])
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>
@@ -216,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
@@ -228,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()
}
-18
View File
@@ -1,18 +0,0 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/search')({
validateSearch: (search) => ({
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
}),
beforeLoad: ({ search }) => {
throw redirect({
to: '/skills',
search: {
q: search.q || undefined,
highlighted: search.highlighted || undefined,
},
replace: true,
})
},
})
-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 />
}
+48 -45
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'
@@ -22,20 +25,20 @@ function parseDir(value: unknown, sort: SortKey): SortDir {
}
type SkillListEntry = {
skill: Doc<'skills'>
skill: PublicSkill
latestVersion: Doc<'skillVersions'> | null
ownerHandle?: string | null
}
type SkillSearchEntry = {
skill: Doc<'skills'>
skill: PublicSkill
version: Doc<'skillVersions'> | null
score: number
ownerHandle?: string | null
}
function buildSkillHref(skill: Doc<'skills'>, ownerHandle?: string | null) {
const owner = ownerHandle ?? 'unknown'
function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) {
const owner = ownerHandle?.trim() || String(skill.ownerUserId)
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`
}
@@ -50,6 +53,7 @@ export const Route = createFileRoute('/skills/')({
? true
: undefined,
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
focus: search.focus === 'search' ? 'search' : undefined,
}
},
component: SkillsIndex,
@@ -64,46 +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<SkillListEntry>>([])
const [cursor, setCursor] = useState<string | null>(null)
const [nextCursor, setNextCursor] = useState<string | null>(null)
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<SkillListEntry>
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) {
@@ -149,12 +151,12 @@ export function SkillsIndex() {
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],
)
@@ -189,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
@@ -236,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) => {
@@ -342,7 +343,7 @@ export function SkillsIndex() {
key={skill._id}
skill={skill}
href={skillHref}
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
badge={getSkillBadges(skill)}
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
summaryFallback="Agent-ready skill pack."
meta={
@@ -367,9 +368,11 @@ export function SkillsIndex() {
<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) => {
+30 -26
View File
@@ -2,16 +2,17 @@ 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 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)
@@ -32,31 +33,34 @@ function Stars() {
{skills.length === 0 ? (
<div className="card">No stars yet.</div>
) : (
skills.map((skill) => (
<div key={skill._id} className="card skill-card">
<Link to="/skills/$slug" params={{ slug: skill.slug }}>
<h3 className="skill-card-title">{skill.displayName}</h3>
</Link>
<div className="skill-card-footer skill-card-footer-inline">
<span className="stat"> {skill.stats.stars}</span>
<button
className="star-toggle is-active"
type="button"
onClick={async () => {
try {
await toggleStar({ skillId: skill._id })
} catch (error) {
console.error('Failed to unstar skill:', error)
window.alert('Unable to unstar this skill. Please try again.')
}
}}
aria-label={`Unstar ${skill.displayName}`}
>
<span aria-hidden="true"></span>
</button>
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>
))
)
})
)}
</div>
</main>
+19 -8
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,16 +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 Doc<'skills'>[] | undefined
) 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')
@@ -119,7 +121,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">
@@ -149,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">
@@ -257,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>
@@ -285,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
+16 -6
View File
@@ -4,7 +4,7 @@ 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,
@@ -25,7 +25,7 @@ export const Route = createFileRoute('/upload')({
})
export function Upload() {
const { isAuthenticated } = useAuthStatus()
const { isAuthenticated, me } = useAuthStatus()
const { updateSlug } = useSearch({ from: '/upload' })
const siteMode = getSiteMode()
const isSoulMode = siteMode === 'souls'
@@ -309,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) {
@@ -385,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
@@ -396,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))
+93
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;
@@ -2623,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;
}
}
-13
View File
@@ -9,16 +9,6 @@ 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)))
@@ -44,9 +34,6 @@ const config = defineConfig({
devtools(),
nitro({
serverDir: 'server',
externals: {
traceInclude: [resvgWasmPath, bricolageBoldPath, bricolageTextPath, plexMonoPath],
},
}),
// this is the plugin that enables path aliases
viteTsConfigPaths({