mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: bootstrap clawdhub
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Frontend
|
||||
VITE_CONVEX_URL=
|
||||
VITE_CONVEX_SITE_URL=
|
||||
SITE_URL=http://localhost:3000
|
||||
CONVEX_SITE_URL=
|
||||
|
||||
# Convex Auth (GitHub OAuth App)
|
||||
AUTH_GITHUB_ID=
|
||||
AUTH_GITHUB_SECRET=
|
||||
|
||||
# Convex Auth JWT keys (generated via @convex-dev/auth CLI)
|
||||
JWT_PRIVATE_KEY=
|
||||
JWKS=
|
||||
|
||||
# Embeddings
|
||||
OPENAI_API_KEY=
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.vercel
|
||||
convex/_generated
|
||||
count.txt
|
||||
.env
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
.output
|
||||
.vinxi
|
||||
todos.json
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"ignorePatterns": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"coverage",
|
||||
"convex/_generated",
|
||||
".tanstack",
|
||||
"public"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# ClawdHub
|
||||
|
||||
Minimal skill registry powered by TanStack Start + Convex.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.local.example .env.local
|
||||
bun --bun run dev
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
bunx convex dev
|
||||
```
|
||||
|
||||
## Convex Auth setup
|
||||
|
||||
```bash
|
||||
bunx auth --deployment-name <deployment> --web-server-url http://localhost:3000
|
||||
```
|
||||
|
||||
This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints the values for local `.env.local`.
|
||||
|
||||
## Environment
|
||||
|
||||
- `VITE_CONVEX_URL`: Convex deployment URL (`https://<deployment>.convex.cloud`).
|
||||
- `VITE_CONVEX_SITE_URL`: Convex site URL (`https://<deployment>.convex.site`).
|
||||
- `CONVEX_SITE_URL`: same as `VITE_CONVEX_SITE_URL` (auth + cookies).
|
||||
- `SITE_URL`: App URL (local: `http://localhost:3000`).
|
||||
- `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET`: GitHub OAuth App.
|
||||
- `JWT_PRIVATE_KEY` / `JWKS`: Convex Auth keys.
|
||||
- `OPENAI_API_KEY`: embeddings.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
bun run build
|
||||
bun run test
|
||||
bun run coverage
|
||||
bun run lint
|
||||
```
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json",
|
||||
"files": {
|
||||
"ignore": ["node_modules", "dist", "coverage", "convex/_generated", ".tanstack", "public"]
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"functions": "convex"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
providers: [
|
||||
{
|
||||
domain: process.env.CONVEX_SITE_URL,
|
||||
applicationID: 'convex',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import GitHub from '@auth/core/providers/github'
|
||||
import { convexAuth } from '@convex-dev/auth/server'
|
||||
|
||||
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
providers: [
|
||||
GitHub({
|
||||
clientId: process.env.AUTH_GITHUB_ID ?? '',
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET ?? '',
|
||||
profile(profile) {
|
||||
return {
|
||||
id: String(profile.id),
|
||||
name: profile.login,
|
||||
email: profile.email ?? undefined,
|
||||
image: profile.avatar_url,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { v } from 'convex/values'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { assertRole, requireUser } from './lib/access'
|
||||
|
||||
export const listBySkill = query({
|
||||
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = args.limit ?? 50
|
||||
const comments = await ctx.db
|
||||
.query('comments')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
|
||||
.order('desc')
|
||||
.take(limit)
|
||||
|
||||
const results = [] as Array<{ comment: any; user: any }>
|
||||
for (const comment of comments) {
|
||||
if (comment.softDeletedAt) continue
|
||||
const user = await ctx.db.get(comment.userId)
|
||||
results.push({ comment, user })
|
||||
}
|
||||
return results
|
||||
},
|
||||
})
|
||||
|
||||
export const add = mutation({
|
||||
args: { skillId: v.id('skills'), body: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx)
|
||||
const body = args.body.trim()
|
||||
if (!body) throw new Error('Comment body required')
|
||||
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
|
||||
await ctx.db.insert('comments', {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
body,
|
||||
createdAt: Date.now(),
|
||||
softDeletedAt: undefined,
|
||||
deletedBy: undefined,
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const remove = mutation({
|
||||
args: { commentId: v.id('comments') },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
const comment = await ctx.db.get(args.commentId)
|
||||
if (!comment) throw new Error('Comment not found')
|
||||
if (comment.softDeletedAt) return
|
||||
|
||||
const isOwner = comment.userId === user._id
|
||||
if (!isOwner) {
|
||||
assertRole(user, ['admin', 'moderator'])
|
||||
}
|
||||
|
||||
await ctx.db.patch(comment._id, {
|
||||
softDeletedAt: Date.now(),
|
||||
deletedBy: user._id,
|
||||
})
|
||||
|
||||
const skill = await ctx.db.get(comment.skillId)
|
||||
if (skill) {
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'comment.delete',
|
||||
targetType: 'comment',
|
||||
targetId: comment._id,
|
||||
metadata: { skillId: comment.skillId },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { httpAction, mutation } from './_generated/server'
|
||||
import { api } from './_generated/api'
|
||||
import { zipSync } from 'fflate'
|
||||
import { v } from 'convex/values'
|
||||
|
||||
export const downloadZip = httpAction(async (ctx, request) => {
|
||||
const url = new URL(request.url)
|
||||
const slug = url.searchParams.get('slug')?.trim().toLowerCase()
|
||||
const versionParam = url.searchParams.get('version')?.trim()
|
||||
const tagParam = url.searchParams.get('tag')?.trim()
|
||||
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 })
|
||||
}
|
||||
|
||||
const skillResult = await ctx.runQuery(api.skills.getBySlug, { slug })
|
||||
if (!skillResult?.skill) {
|
||||
return new Response('Skill not found', { status: 404 })
|
||||
}
|
||||
|
||||
const skill = skillResult.skill
|
||||
let version = skillResult.latestVersion
|
||||
|
||||
if (versionParam) {
|
||||
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
|
||||
skillId: skill._id,
|
||||
version: versionParam,
|
||||
})
|
||||
} else if (tagParam) {
|
||||
const versionId = skill.tags[tagParam]
|
||||
if (versionId) {
|
||||
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
|
||||
}
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
return new Response('Version not found', { status: 404 })
|
||||
}
|
||||
if (version.softDeletedAt) {
|
||||
return new Response('Version not available', { status: 410 })
|
||||
}
|
||||
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
for (const file of version.files) {
|
||||
const blob = await ctx.storage.get(file.storageId)
|
||||
if (!blob) continue
|
||||
const buffer = new Uint8Array(await blob.arrayBuffer())
|
||||
files[file.path] = buffer
|
||||
}
|
||||
|
||||
const zipData = zipSync(files, { level: 6 })
|
||||
|
||||
await ctx.runMutation(api.downloads.increment, { skillId: skill._id })
|
||||
|
||||
return new Response(zipData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${slug}-${version.version}.zip"`,
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const increment = mutation({
|
||||
args: { skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) return
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, downloads: skill.stats.downloads + 1 },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { httpRouter } from 'convex/server'
|
||||
import { auth } from './auth'
|
||||
import { downloadZip } from './downloads'
|
||||
|
||||
const http = httpRouter()
|
||||
|
||||
auth.addHttpRoutes(http)
|
||||
|
||||
http.route({
|
||||
path: '/api/download',
|
||||
method: 'GET',
|
||||
handler: downloadZip,
|
||||
})
|
||||
|
||||
export default http
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from '../_generated/server'
|
||||
import type { Doc } from '../_generated/dataModel'
|
||||
import { api } from '../_generated/api'
|
||||
|
||||
export type Role = 'admin' | 'moderator' | 'user'
|
||||
|
||||
export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
const user = await ctx.db.get(userId)
|
||||
if (!user || user.deletedAt) throw new Error('User not found')
|
||||
return { userId, user }
|
||||
}
|
||||
|
||||
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 })
|
||||
if (!user || user.deletedAt) throw new Error('User not found')
|
||||
return { userId, user: user as Doc<'users'> }
|
||||
}
|
||||
|
||||
export function assertRole(user: Doc<'users'>, allowed: Role[]) {
|
||||
if (!user.role || !allowed.includes(user.role as Role)) {
|
||||
throw new Error('Forbidden')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export const EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
export const EMBEDDING_DIMENSIONS = 1536
|
||||
|
||||
export async function generateEmbedding(text: string) {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) throw new Error('OPENAI_API_KEY is not configured')
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: EMBEDDING_MODEL,
|
||||
input: text,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(`Embedding failed: ${message}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
data?: Array<{ embedding: number[] }>
|
||||
}
|
||||
const embedding = payload.data?.[0]?.embedding
|
||||
if (!embedding) throw new Error('Embedding missing from response')
|
||||
return embedding
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildEmbeddingText,
|
||||
isTextFile,
|
||||
parseClawdisMetadata,
|
||||
parseFrontmatter,
|
||||
sanitizePath,
|
||||
} from './skills'
|
||||
|
||||
describe('skills utils', () => {
|
||||
it('parses frontmatter', () => {
|
||||
const frontmatter = parseFrontmatter(`---\nname: demo\ndescription: Hello\n---\nBody`)
|
||||
expect(frontmatter.name).toBe('demo')
|
||||
expect(frontmatter.description).toBe('Hello')
|
||||
})
|
||||
|
||||
it('parses clawdis metadata', () => {
|
||||
const frontmatter = parseFrontmatter(
|
||||
`---\nmetadata: {"clawdis":{"requires":{"bins":["rg"]},"emoji":"🦞"}}\n---\nBody`,
|
||||
)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
expect(clawdis?.emoji).toBe('🦞')
|
||||
expect(clawdis?.requires?.bins).toEqual(['rg'])
|
||||
})
|
||||
|
||||
it('sanitizes file paths', () => {
|
||||
expect(sanitizePath('good/file.md')).toBe('good/file.md')
|
||||
expect(sanitizePath('../bad/file.md')).toBeNull()
|
||||
expect(sanitizePath('')).toBeNull()
|
||||
})
|
||||
|
||||
it('detects text files', () => {
|
||||
expect(isTextFile('SKILL.md')).toBe(true)
|
||||
expect(isTextFile('image.png')).toBe(false)
|
||||
expect(isTextFile('note.txt', 'text/plain')).toBe(true)
|
||||
})
|
||||
|
||||
it('builds embedding text', () => {
|
||||
const frontmatter = { name: 'Demo', description: 'Hello' }
|
||||
const text = buildEmbeddingText({
|
||||
frontmatter,
|
||||
readme: 'Readme body',
|
||||
otherFiles: [{ path: 'a.txt', content: 'File text' }],
|
||||
})
|
||||
expect(text).toContain('Demo')
|
||||
expect(text).toContain('Readme body')
|
||||
expect(text).toContain('a.txt')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
export type ParsedSkillFrontmatter = Record<string, string>
|
||||
|
||||
export type SkillInstallSpec = {
|
||||
id?: string
|
||||
kind: 'brew' | 'node' | 'go' | 'uv'
|
||||
label?: string
|
||||
bins?: string[]
|
||||
formula?: string
|
||||
package?: string
|
||||
module?: string
|
||||
}
|
||||
|
||||
export type ClawdisSkillMetadata = {
|
||||
always?: boolean
|
||||
skillKey?: string
|
||||
primaryEnv?: string
|
||||
emoji?: string
|
||||
homepage?: string
|
||||
os?: string[]
|
||||
requires?: {
|
||||
bins?: string[]
|
||||
anyBins?: string[]
|
||||
env?: string[]
|
||||
config?: string[]
|
||||
}
|
||||
install?: SkillInstallSpec[]
|
||||
}
|
||||
|
||||
const FRONTMATTER_START = '---'
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'md',
|
||||
'mdx',
|
||||
'txt',
|
||||
'json',
|
||||
'json5',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'js',
|
||||
'cjs',
|
||||
'mjs',
|
||||
'ts',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'py',
|
||||
'sh',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'swift',
|
||||
'kt',
|
||||
'java',
|
||||
'cs',
|
||||
'cpp',
|
||||
'c',
|
||||
'h',
|
||||
'hpp',
|
||||
'sql',
|
||||
'csv',
|
||||
'ini',
|
||||
'cfg',
|
||||
'env',
|
||||
'xml',
|
||||
'html',
|
||||
'css',
|
||||
'scss',
|
||||
'sass',
|
||||
])
|
||||
|
||||
export function parseFrontmatter(content: string): ParsedSkillFrontmatter {
|
||||
const frontmatter: ParsedSkillFrontmatter = {}
|
||||
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
if (!normalized.startsWith(FRONTMATTER_START)) return frontmatter
|
||||
const endIndex = normalized.indexOf(`\n${FRONTMATTER_START}`, 3)
|
||||
if (endIndex === -1) return frontmatter
|
||||
const block = normalized.slice(4, endIndex)
|
||||
for (const line of block.split('\n')) {
|
||||
const match = line.match(/^([\w-]+):\s*(.*)$/)
|
||||
if (!match) continue
|
||||
const key = match[1]
|
||||
const rawValue = match[2].trim()
|
||||
if (!key || !rawValue) continue
|
||||
frontmatter[key] = stripQuotes(rawValue)
|
||||
}
|
||||
return frontmatter
|
||||
}
|
||||
|
||||
export function getFrontmatterValue(frontmatter: ParsedSkillFrontmatter, key: string) {
|
||||
const raw = frontmatter[key]
|
||||
return typeof raw === 'string' ? raw : undefined
|
||||
}
|
||||
|
||||
export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) {
|
||||
const raw = getFrontmatterValue(frontmatter, 'metadata')
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { clawdis?: unknown }
|
||||
if (!parsed || typeof parsed !== 'object') return undefined
|
||||
const clawdis = (parsed as { clawdis?: unknown }).clawdis
|
||||
if (!clawdis || typeof clawdis !== 'object') return undefined
|
||||
const clawdisObj = clawdis as Record<string, unknown>
|
||||
const requiresRaw =
|
||||
typeof clawdisObj.requires === 'object' && clawdisObj.requires !== null
|
||||
? (clawdisObj.requires as Record<string, unknown>)
|
||||
: undefined
|
||||
const installRaw = Array.isArray(clawdisObj.install)
|
||||
? (clawdisObj.install as unknown[])
|
||||
: []
|
||||
const install = installRaw
|
||||
.map((entry) => parseInstallSpec(entry))
|
||||
.filter((entry): entry is SkillInstallSpec => Boolean(entry))
|
||||
const osRaw = normalizeStringList(clawdisObj.os)
|
||||
|
||||
const metadata: ClawdisSkillMetadata = {
|
||||
always: typeof clawdisObj.always === 'boolean' ? clawdisObj.always : undefined,
|
||||
emoji: typeof clawdisObj.emoji === 'string' ? clawdisObj.emoji : undefined,
|
||||
homepage: typeof clawdisObj.homepage === 'string' ? clawdisObj.homepage : undefined,
|
||||
skillKey: typeof clawdisObj.skillKey === 'string' ? clawdisObj.skillKey : undefined,
|
||||
primaryEnv:
|
||||
typeof clawdisObj.primaryEnv === 'string' ? clawdisObj.primaryEnv : undefined,
|
||||
os: osRaw.length > 0 ? osRaw : undefined,
|
||||
requires: requiresRaw
|
||||
? {
|
||||
bins: normalizeStringList(requiresRaw.bins),
|
||||
anyBins: normalizeStringList(requiresRaw.anyBins),
|
||||
env: normalizeStringList(requiresRaw.env),
|
||||
config: normalizeStringList(requiresRaw.config),
|
||||
}
|
||||
: undefined,
|
||||
install: install.length > 0 ? install : undefined,
|
||||
}
|
||||
|
||||
return metadata
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function isTextFile(path: string, contentType?: string | null) {
|
||||
const trimmed = path.trim().toLowerCase()
|
||||
if (!trimmed) return false
|
||||
const parts = trimmed.split('.')
|
||||
const extension = parts.length > 1 ? parts.at(-1) ?? '' : ''
|
||||
if (contentType) {
|
||||
if (contentType.startsWith('text/')) return true
|
||||
if (
|
||||
[
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'application/yaml',
|
||||
'application/x-yaml',
|
||||
'application/toml',
|
||||
'application/javascript',
|
||||
'application/typescript',
|
||||
'application/markdown',
|
||||
].includes(contentType)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (extension && TEXT_EXTENSIONS.has(extension)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function sanitizePath(path: string) {
|
||||
const trimmed = path.trim().replace(/^\/+/, '')
|
||||
if (!trimmed || trimmed.includes('..') || trimmed.includes('\\')) {
|
||||
return null
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function buildEmbeddingText(params: {
|
||||
frontmatter: ParsedSkillFrontmatter
|
||||
readme: string
|
||||
otherFiles: Array<{ path: string; content: string }>
|
||||
maxChars?: number
|
||||
}) {
|
||||
const { frontmatter, readme, otherFiles, maxChars = 200_000 } = params
|
||||
const headerParts = [
|
||||
frontmatter.name,
|
||||
frontmatter.description,
|
||||
frontmatter.homepage,
|
||||
frontmatter.website,
|
||||
frontmatter.url,
|
||||
frontmatter.emoji,
|
||||
].filter(Boolean)
|
||||
const fileParts = otherFiles.map((file) => `# ${file.path}\n${file.content}`)
|
||||
const raw = [headerParts.join('\n'), readme, ...fileParts].filter(Boolean).join('\n\n')
|
||||
if (raw.length <= maxChars) return raw
|
||||
return raw.slice(0, maxChars)
|
||||
}
|
||||
|
||||
function stripQuotes(value: string) {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeStringList(input: unknown): string[] {
|
||||
if (!input) return []
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((value) => String(value).trim()).filter(Boolean)
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
return input
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
||||
if (!input || typeof input !== 'object') return undefined
|
||||
const raw = input as Record<string, unknown>
|
||||
const kindRaw = typeof raw.kind === 'string' ? raw.kind : typeof raw.type === 'string' ? raw.type : ''
|
||||
const kind = kindRaw.trim().toLowerCase()
|
||||
if (kind !== 'brew' && kind !== 'node' && kind !== 'go' && kind !== 'uv') return undefined
|
||||
|
||||
const spec: SkillInstallSpec = { kind: kind as SkillInstallSpec['kind'] }
|
||||
if (typeof raw.id === 'string') spec.id = raw.id
|
||||
if (typeof raw.label === 'string') spec.label = raw.label
|
||||
const bins = normalizeStringList(raw.bins)
|
||||
if (bins.length > 0) spec.bins = bins
|
||||
if (typeof raw.formula === 'string') spec.formula = raw.formula
|
||||
if (typeof raw.package === 'string') spec.package = raw.package
|
||||
if (typeof raw.module === 'string') spec.module = raw.module
|
||||
return spec
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { defineSchema, defineTable } from 'convex/server'
|
||||
import { v } from 'convex/values'
|
||||
import { authTables } from '@convex-dev/auth/server'
|
||||
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
|
||||
|
||||
const users = defineTable({
|
||||
name: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
emailVerificationTime: v.optional(v.number()),
|
||||
phone: v.optional(v.string()),
|
||||
phoneVerificationTime: v.optional(v.number()),
|
||||
isAnonymous: v.optional(v.boolean()),
|
||||
handle: v.optional(v.string()),
|
||||
displayName: v.optional(v.string()),
|
||||
bio: v.optional(v.string()),
|
||||
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
|
||||
deletedAt: v.optional(v.number()),
|
||||
createdAt: v.optional(v.number()),
|
||||
updatedAt: v.optional(v.number()),
|
||||
})
|
||||
.index('email', ['email'])
|
||||
.index('phone', ['phone'])
|
||||
.index('handle', ['handle'])
|
||||
|
||||
const skills = defineTable({
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
ownerUserId: v.id('users'),
|
||||
latestVersionId: v.optional(v.id('skillVersions')),
|
||||
tags: v.record(v.string(), v.id('skillVersions')),
|
||||
badges: v.object({
|
||||
redactionApproved: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
batch: v.optional(v.string()),
|
||||
stats: v.object({
|
||||
downloads: v.number(),
|
||||
stars: v.number(),
|
||||
versions: v.number(),
|
||||
comments: v.number(),
|
||||
}),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_slug', ['slug'])
|
||||
.index('by_owner', ['ownerUserId'])
|
||||
.index('by_updated', ['updatedAt'])
|
||||
.index('by_batch', ['batch'])
|
||||
|
||||
const skillVersions = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
version: v.string(),
|
||||
changelog: v.string(),
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
size: v.number(),
|
||||
storageId: v.id('_storage'),
|
||||
sha256: v.string(),
|
||||
contentType: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
parsed: v.object({
|
||||
frontmatter: v.record(v.string(), v.string()),
|
||||
metadata: v.optional(v.any()),
|
||||
clawdis: v.optional(v.any()),
|
||||
}),
|
||||
createdBy: v.id('users'),
|
||||
createdAt: v.number(),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_skill_version', ['skillId', 'version'])
|
||||
|
||||
const skillEmbeddings = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
versionId: v.id('skillVersions'),
|
||||
ownerId: v.id('users'),
|
||||
embedding: v.array(v.number()),
|
||||
isLatest: v.boolean(),
|
||||
isApproved: v.boolean(),
|
||||
visibility: v.string(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_version', ['versionId'])
|
||||
.vectorIndex('by_embedding', {
|
||||
vectorField: 'embedding',
|
||||
dimensions: EMBEDDING_DIMENSIONS,
|
||||
filterFields: ['visibility'],
|
||||
})
|
||||
|
||||
const comments = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
userId: v.id('users'),
|
||||
body: v.string(),
|
||||
createdAt: v.number(),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
deletedBy: v.optional(v.id('users')),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_user', ['userId'])
|
||||
|
||||
const stars = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
userId: v.id('users'),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_user', ['userId'])
|
||||
.index('by_skill_user', ['skillId', 'userId'])
|
||||
|
||||
const auditLogs = defineTable({
|
||||
actorUserId: v.id('users'),
|
||||
action: v.string(),
|
||||
targetType: v.string(),
|
||||
targetId: v.string(),
|
||||
metadata: v.optional(v.any()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index('by_actor', ['actorUserId'])
|
||||
.index('by_target', ['targetType', 'targetId'])
|
||||
|
||||
export default defineSchema({
|
||||
...authTables,
|
||||
users,
|
||||
skills,
|
||||
skillVersions,
|
||||
skillEmbeddings,
|
||||
comments,
|
||||
stars,
|
||||
auditLogs,
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { v, type Id } from 'convex/values'
|
||||
import { action, query } from './_generated/server'
|
||||
import { api } from './_generated/api'
|
||||
import { generateEmbedding } from './lib/embeddings'
|
||||
|
||||
export const searchSkills = action({
|
||||
args: {
|
||||
query: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
approvedOnly: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const query = args.query.trim()
|
||||
if (!query) return []
|
||||
const vector = await generateEmbedding(query)
|
||||
const results = await ctx.vectorSearch('skillEmbeddings', 'by_embedding', {
|
||||
vector,
|
||||
limit: args.limit ?? 10,
|
||||
filter: (q) =>
|
||||
args.approvedOnly
|
||||
? q.eq('visibility', 'latest-approved')
|
||||
: q.or(q.eq('visibility', 'latest'), q.eq('visibility', 'latest-approved')),
|
||||
})
|
||||
|
||||
const hydrated = await ctx.runQuery(api.search.hydrateResults, {
|
||||
embeddingIds: results.map((result) => result._id),
|
||||
})
|
||||
|
||||
const scoreById = new Map(results.map((result) => [result._id, result._score]))
|
||||
|
||||
return hydrated
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
score: scoreById.get(entry.embeddingId) ?? 0,
|
||||
}))
|
||||
.filter((entry) => entry.skill)
|
||||
},
|
||||
})
|
||||
|
||||
export const hydrateResults = query({
|
||||
args: { embeddingIds: v.array(v.id('skillEmbeddings')) },
|
||||
handler: async (ctx, args) => {
|
||||
const entries = [] as Array<{
|
||||
embeddingId: Id<'skillEmbeddings'>
|
||||
skill: unknown
|
||||
version: unknown
|
||||
}>
|
||||
|
||||
for (const embeddingId of args.embeddingIds) {
|
||||
const embedding = await ctx.db.get(embeddingId)
|
||||
if (!embedding) continue
|
||||
const skill = await ctx.db.get(embedding.skillId)
|
||||
const version = await ctx.db.get(embedding.versionId)
|
||||
entries.push({ embeddingId, skill, version })
|
||||
}
|
||||
|
||||
return entries
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,455 @@
|
||||
import { v, type Id } from 'convex/values'
|
||||
import { action, internalMutation, mutation, query } from './_generated/server'
|
||||
import { internal } from './_generated/api'
|
||||
import { requireUser, requireUserFromAction, assertRole } from './lib/access'
|
||||
import { generateEmbedding } from './lib/embeddings'
|
||||
import {
|
||||
buildEmbeddingText,
|
||||
getFrontmatterValue,
|
||||
isTextFile,
|
||||
parseClawdisMetadata,
|
||||
parseFrontmatter,
|
||||
sanitizePath,
|
||||
} from './lib/skills'
|
||||
import semver from 'semver'
|
||||
|
||||
const MAX_TOTAL_BYTES = 50 * 1024 * 1024
|
||||
const MAX_FILES_FOR_EMBEDDING = 40
|
||||
|
||||
export const getBySlug = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
|
||||
.unique()
|
||||
if (!skill) return null
|
||||
const latestVersion = skill.latestVersionId
|
||||
? await ctx.db.get(skill.latestVersionId)
|
||||
: null
|
||||
const owner = await ctx.db.get(skill.ownerUserId)
|
||||
return { skill, latestVersion, owner }
|
||||
},
|
||||
})
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
batch: v.optional(v.string()),
|
||||
ownerUserId: v.optional(v.id('users')),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const limit = args.limit ?? 24
|
||||
if (args.batch) {
|
||||
return ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_batch', (q) => q.eq('batch', args.batch))
|
||||
.order('desc')
|
||||
.take(limit)
|
||||
}
|
||||
if (args.ownerUserId) {
|
||||
return ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
|
||||
.order('desc')
|
||||
.take(limit)
|
||||
}
|
||||
return ctx.db.query('skills').order('desc').take(limit)
|
||||
},
|
||||
})
|
||||
|
||||
export const listVersions = query({
|
||||
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = args.limit ?? 20
|
||||
return ctx.db
|
||||
.query('skillVersions')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
|
||||
.order('desc')
|
||||
.take(limit)
|
||||
},
|
||||
})
|
||||
|
||||
export const getVersionById = query({
|
||||
args: { versionId: v.id('skillVersions') },
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
})
|
||||
|
||||
export const getVersionBySkillAndVersion = query({
|
||||
args: { skillId: v.id('skills'), version: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return ctx.db
|
||||
.query('skillVersions')
|
||||
.withIndex('by_skill_version', (q) => q.eq('skillId', args.skillId).eq('version', args.version))
|
||||
.unique()
|
||||
},
|
||||
})
|
||||
|
||||
export const publishVersion = action({
|
||||
args: {
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
version: v.string(),
|
||||
changelog: v.string(),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
size: v.number(),
|
||||
storageId: v.id('_storage'),
|
||||
sha256: v.string(),
|
||||
contentType: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireUserFromAction(ctx)
|
||||
|
||||
const version = args.version.trim()
|
||||
const slug = args.slug.trim().toLowerCase()
|
||||
const displayName = args.displayName.trim()
|
||||
if (!slug || !displayName) throw new Error('Slug and display name required')
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
||||
throw new Error('Slug must be lowercase and url-safe')
|
||||
}
|
||||
if (!semver.valid(version)) {
|
||||
throw new Error('Version must be valid semver')
|
||||
}
|
||||
if (!args.changelog.trim()) {
|
||||
throw new Error('Changelog is required')
|
||||
}
|
||||
|
||||
const sanitizedFiles = args.files.map((file) => ({
|
||||
...file,
|
||||
path: sanitizePath(file.path),
|
||||
}))
|
||||
if (sanitizedFiles.some((file) => !file.path)) {
|
||||
throw new Error('Invalid file paths')
|
||||
}
|
||||
if (
|
||||
sanitizedFiles.some(
|
||||
(file) => !isTextFile(file.path ?? '', file.contentType ?? undefined),
|
||||
)
|
||||
) {
|
||||
throw new Error('Only text-based files are allowed')
|
||||
}
|
||||
|
||||
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
throw new Error('Skill bundle exceeds 50MB limit')
|
||||
}
|
||||
|
||||
const readmeFile = sanitizedFiles.find(
|
||||
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
|
||||
)
|
||||
if (!readmeFile) throw new Error('SKILL.md is required')
|
||||
|
||||
const readmeText = await fetchText(ctx, readmeFile.storageId)
|
||||
const frontmatter = parseFrontmatter(readmeText)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
const metadataRaw = getFrontmatterValue(frontmatter, 'metadata')
|
||||
const metadata = metadataRaw ? safeJson(metadataRaw) : undefined
|
||||
|
||||
const otherFiles = [] as Array<{ path: string; content: string }>
|
||||
for (const file of sanitizedFiles) {
|
||||
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
|
||||
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
|
||||
const content = await fetchText(ctx, file.storageId)
|
||||
otherFiles.push({ path: file.path, content })
|
||||
if (otherFiles.length >= MAX_FILES_FOR_EMBEDDING) break
|
||||
}
|
||||
|
||||
const embeddingText = buildEmbeddingText({
|
||||
frontmatter,
|
||||
readme: readmeText,
|
||||
otherFiles,
|
||||
})
|
||||
|
||||
const embedding = await generateEmbedding(embeddingText)
|
||||
|
||||
return ctx.runMutation(internal.skills.insertVersion, {
|
||||
slug,
|
||||
displayName,
|
||||
version,
|
||||
changelog: args.changelog.trim(),
|
||||
tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
|
||||
files: sanitizedFiles.map((file) => ({
|
||||
...file,
|
||||
path: file.path ?? '',
|
||||
})),
|
||||
parsed: {
|
||||
frontmatter,
|
||||
metadata,
|
||||
clawdis,
|
||||
},
|
||||
embedding,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const getReadme = action({
|
||||
args: { versionId: v.id('skillVersions') },
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.runQuery(api.skills.getVersionById, {
|
||||
versionId: args.versionId,
|
||||
})
|
||||
if (!version) throw new Error('Version not found')
|
||||
const readmeFile = version.files.find(
|
||||
(file) => file.path.toLowerCase() === 'skill.md' || file.path.toLowerCase() === 'skills.md',
|
||||
)
|
||||
if (!readmeFile) throw new Error('SKILL.md not found')
|
||||
const text = await fetchText(ctx, readmeFile.storageId)
|
||||
return { path: readmeFile.path, text }
|
||||
},
|
||||
})
|
||||
|
||||
export const updateTags = mutation({
|
||||
args: {
|
||||
skillId: v.id('skills'),
|
||||
tags: v.array(v.object({ tag: v.string(), versionId: v.id('skillVersions') })),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
if (skill.ownerUserId !== user._id) {
|
||||
assertRole(user, ['admin', 'moderator'])
|
||||
}
|
||||
|
||||
const nextTags = { ...skill.tags }
|
||||
for (const entry of args.tags) {
|
||||
nextTags[entry.tag] = entry.versionId
|
||||
}
|
||||
|
||||
const latestEntry = args.tags.find((entry) => entry.tag === 'latest')
|
||||
await ctx.db.patch(skill._id, {
|
||||
tags: nextTags,
|
||||
latestVersionId: latestEntry ? latestEntry.versionId : skill.latestVersionId,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
if (latestEntry) {
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
const isLatest = embedding.versionId === latestEntry.versionId
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isLatest,
|
||||
visibility: visibilityFor(isLatest, embedding.isApproved),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const setRedactionApproved = mutation({
|
||||
args: { skillId: v.id('skills'), approved: v.boolean() },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertRole(user, ['admin', 'moderator'])
|
||||
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
|
||||
const badge = args.approved
|
||||
? { byUserId: user._id, at: Date.now() }
|
||||
: undefined
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
badges: { ...skill.badges, redactionApproved: badge },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isApproved: Boolean(badge),
|
||||
visibility: visibilityFor(embedding.isLatest, Boolean(badge)),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: args.approved ? 'badge.set' : 'badge.unset',
|
||||
targetType: 'skill',
|
||||
targetId: skill._id,
|
||||
metadata: { badge: 'redactionApproved', approved: args.approved },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const setBatch = mutation({
|
||||
args: { skillId: v.id('skills'), batch: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertRole(user, ['admin', 'moderator'])
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
await ctx.db.patch(skill._id, {
|
||||
batch: args.batch?.trim() || undefined,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'batch.set',
|
||||
targetType: 'skill',
|
||||
targetId: skill._id,
|
||||
metadata: { batch: args.batch?.trim() ?? null },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const insertVersion = internalMutation({
|
||||
args: {
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
version: v.string(),
|
||||
changelog: v.string(),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
size: v.number(),
|
||||
storageId: v.id('_storage'),
|
||||
sha256: v.string(),
|
||||
contentType: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
parsed: v.object({
|
||||
frontmatter: v.record(v.string(), v.string()),
|
||||
metadata: v.optional(v.any()),
|
||||
clawdis: v.optional(v.any()),
|
||||
}),
|
||||
embedding: v.array(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId, user } = await requireUser(ctx)
|
||||
|
||||
let skill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
|
||||
.unique()
|
||||
|
||||
if (skill && skill.ownerUserId !== userId) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (!skill) {
|
||||
const summary = getFrontmatterValue(args.parsed.frontmatter, 'description')
|
||||
const skillId = await ctx.db.insert('skills', {
|
||||
slug: args.slug,
|
||||
displayName: args.displayName,
|
||||
summary: summary ?? undefined,
|
||||
ownerUserId: userId,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
badges: { redactionApproved: undefined },
|
||||
stats: { downloads: 0, stars: 0, versions: 0, comments: 0 },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
skill = await ctx.db.get(skillId)
|
||||
}
|
||||
|
||||
if (!skill) throw new Error('Skill creation failed')
|
||||
|
||||
const existingVersion = await ctx.db
|
||||
.query('skillVersions')
|
||||
.withIndex('by_skill_version', (q) =>
|
||||
q.eq('skillId', skill._id).eq('version', args.version),
|
||||
)
|
||||
.unique()
|
||||
if (existingVersion) {
|
||||
throw new Error('Version already exists')
|
||||
}
|
||||
|
||||
const versionId = await ctx.db.insert('skillVersions', {
|
||||
skillId: skill._id,
|
||||
version: args.version,
|
||||
changelog: args.changelog,
|
||||
files: args.files,
|
||||
parsed: args.parsed,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
softDeletedAt: undefined,
|
||||
})
|
||||
|
||||
const nextTags: Record<string, Id<'skillVersions'>> = { ...skill.tags }
|
||||
nextTags.latest = versionId
|
||||
for (const tag of args.tags ?? []) {
|
||||
nextTags[tag] = versionId
|
||||
}
|
||||
|
||||
const latestBefore = skill.latestVersionId
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
displayName: args.displayName,
|
||||
summary: getFrontmatterValue(args.parsed.frontmatter, 'description') ?? skill.summary,
|
||||
latestVersionId: versionId,
|
||||
tags: nextTags,
|
||||
stats: { ...skill.stats, versions: skill.stats.versions + 1 },
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const embeddingId = await ctx.db.insert('skillEmbeddings', {
|
||||
skillId: skill._id,
|
||||
versionId,
|
||||
ownerId: userId,
|
||||
embedding: args.embedding,
|
||||
isLatest: true,
|
||||
isApproved: Boolean(skill.badges.redactionApproved),
|
||||
visibility: visibilityFor(true, Boolean(skill.badges.redactionApproved)),
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (latestBefore) {
|
||||
const previousEmbedding = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_version', (q) => q.eq('versionId', latestBefore))
|
||||
.unique()
|
||||
if (previousEmbedding) {
|
||||
await ctx.db.patch(previousEmbedding._id, {
|
||||
isLatest: false,
|
||||
visibility: visibilityFor(false, previousEmbedding.isApproved),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { skillId: skill._id, versionId, embeddingId }
|
||||
},
|
||||
})
|
||||
|
||||
async function fetchText(
|
||||
ctx: { storage: { get: (id: Id<'_storage'>) => Promise<Blob | null> } },
|
||||
storageId: Id<'_storage'>,
|
||||
) {
|
||||
const blob = await ctx.storage.get(storageId)
|
||||
if (!blob) throw new Error('File missing in storage')
|
||||
return blob.text()
|
||||
}
|
||||
|
||||
function safeJson(value: string) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function visibilityFor(isLatest: boolean, isApproved: boolean) {
|
||||
if (isLatest && isApproved) return 'latest-approved'
|
||||
if (isLatest) return 'latest'
|
||||
if (isApproved) return 'archived-approved'
|
||||
return 'archived'
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { v } from 'convex/values'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { requireUser } from './lib/access'
|
||||
|
||||
export const isStarred = query({
|
||||
args: { skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx)
|
||||
const existing = await ctx.db
|
||||
.query('stars')
|
||||
.withIndex('by_skill_user', (q) => q.eq('skillId', args.skillId).eq('userId', userId))
|
||||
.unique()
|
||||
return Boolean(existing)
|
||||
},
|
||||
})
|
||||
|
||||
export const toggle = mutation({
|
||||
args: { skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx)
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
|
||||
const existing = await ctx.db
|
||||
.query('stars')
|
||||
.withIndex('by_skill_user', (q) => q.eq('skillId', args.skillId).eq('userId', userId))
|
||||
.unique()
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.delete(existing._id)
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, stars: Math.max(0, skill.stats.stars - 1) },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return { starred: false }
|
||||
}
|
||||
|
||||
await ctx.db.insert('stars', {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, stars: skill.stats.stars + 1 },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
return { starred: true }
|
||||
},
|
||||
})
|
||||
|
||||
export const listByUser = query({
|
||||
args: { userId: v.id('users'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = args.limit ?? 50
|
||||
const stars = await ctx.db
|
||||
.query('stars')
|
||||
.withIndex('by_user', (q) => q.eq('userId', args.userId))
|
||||
.order('desc')
|
||||
.take(limit)
|
||||
const skills = [] as any[]
|
||||
for (const star of stars) {
|
||||
const skill = await ctx.db.get(star.skillId)
|
||||
if (skill) skills.push(skill)
|
||||
}
|
||||
return skills
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "Bundler",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mutation } from './_generated/server'
|
||||
import { requireUser } from './lib/access'
|
||||
|
||||
export const generateUploadUrl = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
await requireUser(ctx)
|
||||
return ctx.storage.generateUploadUrl()
|
||||
},
|
||||
})
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { v } from 'convex/values'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { requireUser, assertRole } from './lib/access'
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
|
||||
const DEFAULT_ROLE = 'user'
|
||||
const ADMIN_HANDLE = 'steipete'
|
||||
|
||||
export const getById = query({
|
||||
args: { userId: v.id('users') },
|
||||
handler: async (ctx, args) => ctx.db.get(args.userId),
|
||||
})
|
||||
|
||||
export const me = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) return null
|
||||
const user = await ctx.db.get(userId)
|
||||
if (!user || user.deletedAt) return null
|
||||
return user
|
||||
},
|
||||
})
|
||||
|
||||
export const ensure = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const { userId, user } = await requireUser(ctx)
|
||||
const now = Date.now()
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
const handle = user.handle ?? user.name ?? user.email?.split('@')[0]
|
||||
if (!user.handle && handle) updates.handle = handle
|
||||
if (!user.displayName) updates.displayName = handle
|
||||
if (!user.role) {
|
||||
updates.role = handle === ADMIN_HANDLE ? 'admin' : DEFAULT_ROLE
|
||||
}
|
||||
if (!user.createdAt) updates.createdAt = user._creationTime
|
||||
updates.updatedAt = now
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await ctx.db.patch(userId, updates)
|
||||
}
|
||||
|
||||
return ctx.db.get(userId)
|
||||
},
|
||||
})
|
||||
|
||||
export const updateProfile = mutation({
|
||||
args: {
|
||||
displayName: v.string(),
|
||||
bio: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx)
|
||||
await ctx.db.patch(userId, {
|
||||
displayName: args.displayName.trim(),
|
||||
bio: args.bio?.trim(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const deleteAccount = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const { userId } = await requireUser(ctx)
|
||||
await ctx.db.patch(userId, {
|
||||
deletedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const list = query({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertRole(user, ['admin'])
|
||||
const limit = args.limit ?? 50
|
||||
return ctx.db.query('users').order('desc').take(limit)
|
||||
},
|
||||
})
|
||||
|
||||
export const getByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return ctx.db
|
||||
.query('users')
|
||||
.withIndex('handle', (q) => q.eq('handle', args.handle))
|
||||
.unique()
|
||||
},
|
||||
})
|
||||
|
||||
export const setRole = mutation({
|
||||
args: { userId: v.id('users'), role: v.union(v.literal('admin'), v.literal('moderator'), v.literal('user')) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertRole(user, ['admin'])
|
||||
await ctx.db.patch(args.userId, { role: args.role, updatedAt: Date.now() })
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'role.change',
|
||||
targetType: 'user',
|
||||
targetId: args.userId,
|
||||
metadata: { role: args.role },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
---
|
||||
summary: "ClawdHub spec: skills registry, versioning, vector search, moderation"
|
||||
read_when:
|
||||
- Bootstrapping ClawdHub
|
||||
- Implementing schema/auth/search/versioning
|
||||
- Reviewing API and upload/download flows
|
||||
---
|
||||
|
||||
# ClawdHub — product + implementation spec (v1)
|
||||
|
||||
## Goals
|
||||
- Minimal, fast SPA for browsing and publishing agent skills.
|
||||
- Skills stored in Convex (files + metadata + versions + stats).
|
||||
- GitHub OAuth login; optional GitHub App repo sync later.
|
||||
- Vector-based search over skill text + metadata.
|
||||
- Versioning, tags (`latest` + user tags), changelog, rollback (tag movement).
|
||||
- Public read access; upload requires auth.
|
||||
- Moderation: badges + comment delete; audit everything.
|
||||
|
||||
## Non-goals (v1)
|
||||
- Paid features, private skills, or binary assets.
|
||||
- GitHub App sync (future phase).
|
||||
|
||||
## Core objects
|
||||
|
||||
### User
|
||||
- `authId` (from Convex Auth provider)
|
||||
- `handle` (GitHub login)
|
||||
- `name`, `bio`
|
||||
- `avatarUrl` (GitHub, fallback gravatar)
|
||||
- `role`: `admin | moderator | user`
|
||||
- `createdAt`, `updatedAt`
|
||||
|
||||
### Skill
|
||||
- `slug` (unique)
|
||||
- `displayName`
|
||||
- `ownerUserId`
|
||||
- `summary` (from SKILL.md frontmatter `description`)
|
||||
- `latestVersionId`
|
||||
- `latestTagVersionId` (for `latest` tag)
|
||||
- `tags` map: `{ tag -> versionId }`
|
||||
- `badges`: `{ redactionApproved?: { byUserId, at } }`
|
||||
- `stats`: `{ downloads, stars, versions, comments }`
|
||||
- `status`: `active` only (soft-delete on version/comment only)
|
||||
- `createdAt`, `updatedAt`
|
||||
|
||||
### SkillVersion
|
||||
- `skillId`
|
||||
- `version` (semver string)
|
||||
- `tag` (string, optional; `latest` always maintained separately)
|
||||
- `changelog` (required)
|
||||
- `files`: list of file metadata
|
||||
- `path`, `size`, `storageId`, `sha256`
|
||||
- `parsed` (metadata extracted from SKILL.md)
|
||||
- `vectorDocId` (if using RAG component) OR `embeddingId`
|
||||
- `createdBy`, `createdAt`
|
||||
- `softDeletedAt` (nullable)
|
||||
|
||||
### Parsed Skill Metadata
|
||||
From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
|
||||
- `name`, `description`, `homepage`, `website`, `url`, `emoji`
|
||||
- `metadata.clawdis`: `always`, `skillKey`, `primaryEnv`, `emoji`, `homepage`, `os`,
|
||||
`requires` (`bins`, `anyBins`, `env`, `config`), `install[]`
|
||||
|
||||
### Comment
|
||||
- `skillId`, `userId`, `body`
|
||||
- `softDeletedAt`, `deletedBy`
|
||||
- `createdAt`
|
||||
|
||||
### Star
|
||||
- `skillId`, `userId`, `createdAt`
|
||||
|
||||
### AuditLog
|
||||
- `actorUserId`
|
||||
- `action` (enum: `badge.set`, `badge.unset`, `comment.delete`, `role.change`)
|
||||
- `targetType` / `targetId`
|
||||
- `metadata` (json)
|
||||
- `createdAt`
|
||||
|
||||
## 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.
|
||||
|
||||
## Upload flow (50MB per version)
|
||||
1) Client requests upload session.
|
||||
2) Client uploads each file via Convex upload URLs (no binaries, text only).
|
||||
3) Client submits metadata + file list + changelog + version + tags.
|
||||
4) Server validates:
|
||||
- total size ≤ 50MB
|
||||
- file extensions/text content
|
||||
- SKILL.md exists and frontmatter parseable
|
||||
- version uniqueness
|
||||
5) Server stores files + metadata, sets `latest` tag, updates stats.
|
||||
|
||||
## Versioning + tags
|
||||
- Each upload is a new `SkillVersion`.
|
||||
- `latest` tag always points to most recent version unless user re-tags.
|
||||
- Rollback: move `latest` (and optionally other tags) to an older version.
|
||||
- Changelog required for any update.
|
||||
|
||||
## Search
|
||||
- Vector search over: SKILL.md + other text files + metadata summary.
|
||||
- Convex embeddings + vector index.
|
||||
- Filters: tag, owner, `redactionApproved` only, min stars, updatedAt.
|
||||
|
||||
## Download API
|
||||
- JSON API for skill metadata + versions.
|
||||
- Download endpoint returns zip of a version (HTTP action).
|
||||
- Soft-delete versions; downloads remain for non-deleted versions only.
|
||||
|
||||
## UI (SPA)
|
||||
- Home: search + filters + trending/featured + “Highlighted” batch.
|
||||
- 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).
|
||||
- Admin: user role management + badge approvals + audit log.
|
||||
|
||||
## Testing + quality
|
||||
- Vitest 4 with >80% coverage.
|
||||
- Lint: Biome + Oxlint (type-aware).
|
||||
|
||||
## Vercel
|
||||
- Env vars: Convex deployment URLs + GitHub OAuth client + OpenAI key (if used).
|
||||
- SPA feel: client-side transitions, prefetching, optimistic UI.
|
||||
|
||||
## Open questions (carry forward)
|
||||
- Embeddings provider key + rate limits.
|
||||
- Zip generation memory limits (optimize with streaming if needed).
|
||||
- GitHub App repo sync (phase 2).
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "clawdhub",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --bun vite dev --port 3000",
|
||||
"build": "bun --bun vite build",
|
||||
"preview": "bun --bun vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"lint": "bun run lint:biome && bun run lint:oxlint",
|
||||
"lint:biome": "biome check .",
|
||||
"lint:oxlint": "oxlint --type-aware --tsconfig ./tsconfig.json ./src ./convex",
|
||||
"format": "biome format --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.3",
|
||||
"@convex-dev/auth": "^0.0.90",
|
||||
"@fontsource/bricolage-grotesque": "^5.2.10",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/manrope": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-devtools": "^0.7.0",
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
"@tanstack/react-router-devtools": "^1.132.0",
|
||||
"@tanstack/react-router-ssr-query": "^1.131.7",
|
||||
"@tanstack/react-start": "^1.132.0",
|
||||
"@tanstack/router-plugin": "^1.132.0",
|
||||
"convex": "^1.31.2",
|
||||
"fflate": "^0.8.2",
|
||||
"lucide-react": "^0.561.0",
|
||||
"nitro": "latest",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"vite-tsconfig-paths": "^6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.10",
|
||||
"@tanstack/devtools-vite": "^0.3.11",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"jsdom": "^27.0.0",
|
||||
"oxlint": "^1.36.0",
|
||||
"oxlint-tsgolint": "^0.10.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^4",
|
||||
"web-vitals": "^5.1.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "TanStack App",
|
||||
"name": "Create TanStack App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,21 @@
|
||||
import { ConvexAuthProvider } from '@convex-dev/auth/react'
|
||||
import { convex } from '../convex/client'
|
||||
import { UserBootstrap } from './UserBootstrap'
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
if (typeof window === 'undefined') {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<ConvexAuthProvider
|
||||
client={convex}
|
||||
replaceURL={(relativeUrl) => {
|
||||
window.history.replaceState(null, '', relativeUrl)
|
||||
}}
|
||||
>
|
||||
<UserBootstrap />
|
||||
{children}
|
||||
</ConvexAuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function ClientOnly({
|
||||
children,
|
||||
fallback = null,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
}) {
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
if (!ready) return <>{fallback}</>
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useAuthActions } from '@convex-dev/auth/react'
|
||||
import { useConvexAuth, useQuery } from 'convex/react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import { gravatarUrl } from '../lib/gravatar'
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth()
|
||||
const { signIn, signOut } = useAuthActions()
|
||||
const me = useQuery(api.users.me)
|
||||
|
||||
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined)
|
||||
|
||||
return (
|
||||
<header className="navbar">
|
||||
<div className="navbar-inner">
|
||||
<Link to="/" className="brand">
|
||||
<span className="brand-mark" />
|
||||
ClawdHub
|
||||
</Link>
|
||||
<nav className="nav-links">
|
||||
<Link to="/upload">Upload</Link>
|
||||
<Link to="/search">Search</Link>
|
||||
{me ? <Link to="/stars">Stars</Link> : null}
|
||||
{me?.role === 'admin' || me?.role === 'moderator' ? (
|
||||
<Link to="/admin">Admin</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
<div className="nav-actions">
|
||||
{isAuthenticated && me ? (
|
||||
<>
|
||||
<Link to="/settings" className="btn">
|
||||
<span className="mono">@{me.handle ?? me.displayName ?? 'user'}</span>
|
||||
</Link>
|
||||
<button className="btn" type="button" onClick={() => void signOut()}>
|
||||
Sign out
|
||||
</button>
|
||||
{avatar ? (
|
||||
<img
|
||||
src={avatar}
|
||||
alt={me.displayName ?? me.name ?? 'User avatar'}
|
||||
style={{ width: 36, height: 36, borderRadius: '50%' }}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={() => void signIn('github')}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useMutation, useConvexAuth } from 'convex/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
|
||||
export function UserBootstrap() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth()
|
||||
const ensureUser = useMutation(api.users.ensure)
|
||||
const didRun = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !isAuthenticated || didRun.current) return
|
||||
didRun.current = true
|
||||
void ensureUser()
|
||||
}, [isAuthenticated, isLoading, ensureUser])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ConvexReactClient } from 'convex/react'
|
||||
|
||||
export const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string)
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { gravatarUrl } from './gravatar'
|
||||
|
||||
describe('gravatarUrl', () => {
|
||||
it('generates a stable hash', () => {
|
||||
const url = gravatarUrl('MyEmailAddress@example.com ')
|
||||
expect(url).toContain('0bc83cb571cd1c50ba6f3e8a78ef1346')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
function md5cycle(x: number[], k: number[]) {
|
||||
let [a, b, c, d] = x
|
||||
|
||||
a = ff(a, b, c, d, k[0], 7, -680876936)
|
||||
d = ff(d, a, b, c, k[1], 12, -389564586)
|
||||
c = ff(c, d, a, b, k[2], 17, 606105819)
|
||||
b = ff(b, c, d, a, k[3], 22, -1044525330)
|
||||
a = ff(a, b, c, d, k[4], 7, -176418897)
|
||||
d = ff(d, a, b, c, k[5], 12, 1200080426)
|
||||
c = ff(c, d, a, b, k[6], 17, -1473231341)
|
||||
b = ff(b, c, d, a, k[7], 22, -45705983)
|
||||
a = ff(a, b, c, d, k[8], 7, 1770035416)
|
||||
d = ff(d, a, b, c, k[9], 12, -1958414417)
|
||||
c = ff(c, d, a, b, k[10], 17, -42063)
|
||||
b = ff(b, c, d, a, k[11], 22, -1990404162)
|
||||
a = ff(a, b, c, d, k[12], 7, 1804603682)
|
||||
d = ff(d, a, b, c, k[13], 12, -40341101)
|
||||
c = ff(c, d, a, b, k[14], 17, -1502002290)
|
||||
b = ff(b, c, d, a, k[15], 22, 1236535329)
|
||||
|
||||
a = gg(a, b, c, d, k[1], 5, -165796510)
|
||||
d = gg(d, a, b, c, k[6], 9, -1069501632)
|
||||
c = gg(c, d, a, b, k[11], 14, 643717713)
|
||||
b = gg(b, c, d, a, k[0], 20, -373897302)
|
||||
a = gg(a, b, c, d, k[5], 5, -701558691)
|
||||
d = gg(d, a, b, c, k[10], 9, 38016083)
|
||||
c = gg(c, d, a, b, k[15], 14, -660478335)
|
||||
b = gg(b, c, d, a, k[4], 20, -405537848)
|
||||
a = gg(a, b, c, d, k[9], 5, 568446438)
|
||||
d = gg(d, a, b, c, k[14], 9, -1019803690)
|
||||
c = gg(c, d, a, b, k[3], 14, -187363961)
|
||||
b = gg(b, c, d, a, k[8], 20, 1163531501)
|
||||
a = gg(a, b, c, d, k[13], 5, -1444681467)
|
||||
d = gg(d, a, b, c, k[2], 9, -51403784)
|
||||
c = gg(c, d, a, b, k[7], 14, 1735328473)
|
||||
b = gg(b, c, d, a, k[12], 20, -1926607734)
|
||||
|
||||
a = hh(a, b, c, d, k[5], 4, -378558)
|
||||
d = hh(d, a, b, c, k[8], 11, -2022574463)
|
||||
c = hh(c, d, a, b, k[11], 16, 1839030562)
|
||||
b = hh(b, c, d, a, k[14], 23, -35309556)
|
||||
a = hh(a, b, c, d, k[1], 4, -1530992060)
|
||||
d = hh(d, a, b, c, k[4], 11, 1272893353)
|
||||
c = hh(c, d, a, b, k[7], 16, -155497632)
|
||||
b = hh(b, c, d, a, k[10], 23, -1094730640)
|
||||
a = hh(a, b, c, d, k[13], 4, 681279174)
|
||||
d = hh(d, a, b, c, k[0], 11, -358537222)
|
||||
c = hh(c, d, a, b, k[3], 16, -722521979)
|
||||
b = hh(b, c, d, a, k[6], 23, 76029189)
|
||||
a = hh(a, b, c, d, k[9], 4, -640364487)
|
||||
d = hh(d, a, b, c, k[12], 11, -421815835)
|
||||
c = hh(c, d, a, b, k[15], 16, 530742520)
|
||||
b = hh(b, c, d, a, k[2], 23, -995338651)
|
||||
|
||||
a = ii(a, b, c, d, k[0], 6, -198630844)
|
||||
d = ii(d, a, b, c, k[7], 10, 1126891415)
|
||||
c = ii(c, d, a, b, k[14], 15, -1416354905)
|
||||
b = ii(b, c, d, a, k[5], 21, -57434055)
|
||||
a = ii(a, b, c, d, k[12], 6, 1700485571)
|
||||
d = ii(d, a, b, c, k[3], 10, -1894986606)
|
||||
c = ii(c, d, a, b, k[10], 15, -1051523)
|
||||
b = ii(b, c, d, a, k[1], 21, -2054922799)
|
||||
a = ii(a, b, c, d, k[8], 6, 1873313359)
|
||||
d = ii(d, a, b, c, k[15], 10, -30611744)
|
||||
c = ii(c, d, a, b, k[6], 15, -1560198380)
|
||||
b = ii(b, c, d, a, k[13], 21, 1309151649)
|
||||
a = ii(a, b, c, d, k[4], 6, -145523070)
|
||||
d = ii(d, a, b, c, k[11], 10, -1120210379)
|
||||
c = ii(c, d, a, b, k[2], 15, 718787259)
|
||||
b = ii(b, c, d, a, k[9], 21, -343485551)
|
||||
|
||||
x[0] = add32(a, x[0])
|
||||
x[1] = add32(b, x[1])
|
||||
x[2] = add32(c, x[2])
|
||||
x[3] = add32(d, x[3])
|
||||
}
|
||||
|
||||
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
|
||||
a = add32(add32(a, q), add32(x, t))
|
||||
return add32((a << s) | (a >>> (32 - s)), b)
|
||||
}
|
||||
|
||||
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||
return cmn((b & c) | (~b & d), a, b, x, s, t)
|
||||
}
|
||||
|
||||
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||
return cmn((b & d) | (c & ~d), a, b, x, s, t)
|
||||
}
|
||||
|
||||
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||
return cmn(b ^ c ^ d, a, b, x, s, t)
|
||||
}
|
||||
|
||||
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
|
||||
return cmn(c ^ (b | ~d), a, b, x, s, t)
|
||||
}
|
||||
|
||||
function md51(s: string) {
|
||||
const n = s.length
|
||||
const state = [1732584193, -271733879, -1732584194, 271733878]
|
||||
let i
|
||||
for (i = 64; i <= n; i += 64) {
|
||||
md5cycle(state, md5blk(s.substring(i - 64, i)))
|
||||
}
|
||||
s = s.substring(i - 64)
|
||||
const tail = Array(16).fill(0) as number[]
|
||||
for (i = 0; i < s.length; i += 1) {
|
||||
tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3)
|
||||
}
|
||||
tail[i >> 2] |= 0x80 << ((i % 4) << 3)
|
||||
if (i > 55) {
|
||||
md5cycle(state, tail)
|
||||
for (let j = 0; j < 16; j += 1) tail[j] = 0
|
||||
}
|
||||
tail[14] = n * 8
|
||||
md5cycle(state, tail)
|
||||
return state
|
||||
}
|
||||
|
||||
function md5blk(s: string) {
|
||||
const md5blks: number[] = []
|
||||
for (let i = 0; i < 64; i += 4) {
|
||||
md5blks[i >> 2] =
|
||||
s.charCodeAt(i) +
|
||||
(s.charCodeAt(i + 1) << 8) +
|
||||
(s.charCodeAt(i + 2) << 16) +
|
||||
(s.charCodeAt(i + 3) << 24)
|
||||
}
|
||||
return md5blks
|
||||
}
|
||||
|
||||
function rhex(n: number) {
|
||||
const s = '0123456789abcdef'
|
||||
let j = 0
|
||||
let out = ''
|
||||
for (; j < 4; j += 1) {
|
||||
out += s.charAt((n >> (j * 8 + 4)) & 0x0f) + s.charAt((n >> (j * 8)) & 0x0f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function hex(x: number[]) {
|
||||
for (let i = 0; i < x.length; i += 1) {
|
||||
x[i] = Number(x[i])
|
||||
}
|
||||
return x.map(rhex).join('')
|
||||
}
|
||||
|
||||
function add32(a: number, b: number) {
|
||||
return (a + b) & 0xffffffff
|
||||
}
|
||||
|
||||
export function gravatarUrl(email: string, size = 160) {
|
||||
const normalized = email.trim().toLowerCase()
|
||||
const hash = hex(md51(normalized))
|
||||
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=${size}`
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,171 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as UploadRouteImport } from './routes/upload'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as SearchRouteImport } from './routes/search'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SkillsSlugRouteImport } from './routes/skills/$slug'
|
||||
|
||||
const UploadRoute = UploadRouteImport.update({
|
||||
id: '/upload',
|
||||
path: '/upload',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SearchRoute = SearchRouteImport.update({
|
||||
id: '/search',
|
||||
path: '/search',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminRoute = AdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SkillsSlugRoute = SkillsSlugRouteImport.update({
|
||||
id: '/skills/$slug',
|
||||
path: '/skills/$slug',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
'/skills/$slug': typeof SkillsSlugRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
'/skills/$slug': typeof SkillsSlugRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
'/skills/$slug': typeof SkillsSlugRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/upload'
|
||||
| '/skills/$slug'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/admin' | '/search' | '/settings' | '/upload' | '/skills/$slug'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/upload'
|
||||
| '/skills/$slug'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AdminRoute: typeof AdminRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
UploadRoute: typeof UploadRoute
|
||||
SkillsSlugRoute: typeof SkillsSlugRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/upload': {
|
||||
id: '/upload'
|
||||
path: '/upload'
|
||||
fullPath: '/upload'
|
||||
preLoaderRoute: typeof UploadRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/search': {
|
||||
id: '/search'
|
||||
path: '/search'
|
||||
fullPath: '/search'
|
||||
preLoaderRoute: typeof SearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin': {
|
||||
id: '/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof AdminRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/skills/$slug': {
|
||||
id: '/skills/$slug'
|
||||
path: '/skills/$slug'
|
||||
fullPath: '/skills/$slug'
|
||||
preLoaderRoute: typeof SkillsSlugRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AdminRoute: AdminRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
UploadRoute: UploadRoute,
|
||||
SkillsSlugRoute: SkillsSlugRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
// Create a new router instance
|
||||
export const getRouter = () => {
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {},
|
||||
|
||||
scrollRestoration: true,
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
|
||||
import Header from '../components/Header'
|
||||
import { AppProviders } from '../components/AppProviders'
|
||||
import { ClientOnly } from '../components/ClientOnly'
|
||||
|
||||
import appCss from '../styles.css?url'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: 'utf-8',
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{
|
||||
title: 'ClawdHub',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
content: 'ClawdHub — a fast skill registry for agents, with vector search.',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: appCss,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
shellComponent: RootDocument,
|
||||
})
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<ClientOnly>
|
||||
<AppProviders>
|
||||
<Header />
|
||||
{children}
|
||||
{import.meta.env.DEV ? (
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</AppProviders>
|
||||
</ClientOnly>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
|
||||
export const Route = createFileRoute('/admin')({
|
||||
component: Admin,
|
||||
})
|
||||
|
||||
function Admin() {
|
||||
const users = useQuery(api.users.list, { limit: 50 })
|
||||
const skills = useQuery(api.skills.list, { limit: 20 })
|
||||
const setRole = useMutation(api.users.setRole)
|
||||
const setApproved = useMutation(api.skills.setRedactionApproved)
|
||||
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) =>
|
||||
void setRole({ userId: user._id, role: event.target.value as any })
|
||||
}
|
||||
>
|
||||
<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 setApproved({
|
||||
skillId: skill._id,
|
||||
approved: !skill.badges.redactionApproved,
|
||||
})
|
||||
}
|
||||
>
|
||||
{skill.badges.redactionApproved ? 'Revoke' : 'Approve'}
|
||||
</button>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from 'convex/react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
})
|
||||
|
||||
function Home() {
|
||||
const highlighted = useQuery(api.skills.list, { batch: 'highlighted', limit: 6 }) ?? []
|
||||
const latest = useQuery(api.skills.list, { limit: 12 }) ?? []
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero">
|
||||
<div className="hero-inner">
|
||||
<div className="fade-up" data-delay="1">
|
||||
<span className="hero-badge">Lobster-light. Agent-right.</span>
|
||||
<h1 className="hero-title">ClawdHub, the skill dock for sharp agents.</h1>
|
||||
<p className="hero-subtitle">
|
||||
Upload AgentSkills bundles, version them like npm, and make them searchable with
|
||||
vectors. No gatekeeping, just signal.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 20 }}>
|
||||
<Link to="/upload" className="btn btn-primary">
|
||||
Publish a skill
|
||||
</Link>
|
||||
<Link to="/search" className="btn">
|
||||
Explore search
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-card fade-up" data-delay="2">
|
||||
<div className="search-bar">
|
||||
<span className="mono">/</span>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder="Search skills, tags, or capabilities"
|
||||
disabled
|
||||
/>
|
||||
<Link to="/search" className="btn">
|
||||
Search
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 12, marginTop: 18 }}>
|
||||
<div className="stat">Vector search · Latest tags · 50MB per version</div>
|
||||
<div className="stat">Redaction badges · Soft delete · Rollback ready</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title">Highlighted batch</h2>
|
||||
<p className="section-subtitle">Curated signal — approved and easy to trust.</p>
|
||||
<div className="grid">
|
||||
{highlighted.length === 0 ? (
|
||||
<div className="card">No highlighted skills yet.</div>
|
||||
) : (
|
||||
highlighted.map((skill) => (
|
||||
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
|
||||
<div className="tag">Highlighted</div>
|
||||
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
{skill.summary ?? 'A fresh skill bundle.'}
|
||||
</p>
|
||||
<div className="stat">⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads}</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title">Latest drops</h2>
|
||||
<p className="section-subtitle">Newest uploads across the registry.</p>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<div className="card">No skills yet. Be the first.</div>
|
||||
) : (
|
||||
latest.map((skill) => (
|
||||
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
|
||||
<div className="stat">{skill.summary ?? 'Agent-ready skill pack.'}</div>
|
||||
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<div className="stat">
|
||||
{skill.stats.versions} versions · {skill.stats.downloads} downloads
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useAction } from 'convex/react'
|
||||
import { useState } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
|
||||
export const Route = createFileRoute('/search')({
|
||||
component: Search,
|
||||
})
|
||||
|
||||
function Search() {
|
||||
const searchSkills = useAction(api.search.searchSkills)
|
||||
const [query, setQuery] = useState('')
|
||||
const [approvedOnly, setApprovedOnly] = useState(true)
|
||||
const [results, setResults] = useState<
|
||||
Array<{ skill: any; version: any; score: number }>
|
||||
>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!query.trim()) return
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const data = await searchSkills({ query, approvedOnly })
|
||||
setResults(data as any)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Vector search</h1>
|
||||
<p className="section-subtitle">Ask for capabilities, get skill packs.</p>
|
||||
<form onSubmit={onSubmit} className="search-bar" style={{ marginBottom: 20 }}>
|
||||
<input
|
||||
className="search-input"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="e.g. summarize PDFs, book travel, scrape web"
|
||||
/>
|
||||
<button className="btn btn-primary" type="submit" disabled={isSearching}>
|
||||
{isSearching ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
</form>
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={approvedOnly}
|
||||
onChange={(event) => setApprovedOnly(event.target.checked)}
|
||||
/>
|
||||
Only redaction-approved skills
|
||||
</label>
|
||||
|
||||
<div className="grid" style={{ marginTop: 24 }}>
|
||||
{results.length === 0 ? (
|
||||
<div className="card">No results yet. Try a different prompt.</div>
|
||||
) : (
|
||||
results.map((result) => (
|
||||
<Link
|
||||
key={result.skill._id}
|
||||
to="/skills/$slug"
|
||||
params={{ slug: result.skill.slug }}
|
||||
className="card"
|
||||
>
|
||||
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
|
||||
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
{result.skill.displayName}
|
||||
</h3>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
{result.skill.summary ?? 'Skill pack'}
|
||||
</p>
|
||||
{result.skill.badges?.redactionApproved ? (
|
||||
<div className="tag">Redaction approved</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
component: Settings,
|
||||
})
|
||||
|
||||
function Settings() {
|
||||
const me = useQuery(api.users.me)
|
||||
const updateProfile = useMutation(api.users.updateProfile)
|
||||
const deleteAccount = useMutation(api.users.deleteAccount)
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [bio, setBio] = useState('')
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return
|
||||
setDisplayName(me.displayName ?? '')
|
||||
setBio(me.bio ?? '')
|
||||
}, [me])
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Sign in to access settings.</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function onSave(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
await updateProfile({ displayName, bio })
|
||||
setStatus('Saved.')
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
const ok = window.confirm('Soft delete your account? This cannot be undone.')
|
||||
if (!ok) return
|
||||
await deleteAccount()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Settings</h1>
|
||||
<form className="card" onSubmit={onSave} style={{ display: 'grid', gap: 16 }}>
|
||||
<label>
|
||||
Display name
|
||||
<input
|
||||
className="search-input"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Bio
|
||||
<textarea
|
||||
className="search-input"
|
||||
rows={3}
|
||||
value={bio}
|
||||
onChange={(event) => setBio(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
Save
|
||||
</button>
|
||||
{status ? <div className="stat">{status}</div> : null}
|
||||
</form>
|
||||
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
Danger zone
|
||||
</h2>
|
||||
<p className="section-subtitle">Soft delete your account. Skills remain public.</p>
|
||||
<button className="btn" type="button" onClick={() => void onDelete()}>
|
||||
Delete account
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { api } from '../../../convex/_generated/api'
|
||||
|
||||
export const Route = createFileRoute('/skills/$slug')({
|
||||
component: SkillDetail,
|
||||
})
|
||||
|
||||
function SkillDetail() {
|
||||
const { slug } = Route.useParams()
|
||||
const { isAuthenticated } = useConvexAuth()
|
||||
const me = useQuery(api.users.me)
|
||||
const result = useQuery(api.skills.getBySlug, { slug })
|
||||
const toggleStar = useMutation(api.stars.toggle)
|
||||
const addComment = useMutation(api.comments.add)
|
||||
const removeComment = useMutation(api.comments.remove)
|
||||
const updateTags = useMutation(api.skills.updateTags)
|
||||
const getReadme = useAction(api.skills.getReadme)
|
||||
const [readme, setReadme] = useState<string | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
const [tagName, setTagName] = useState('latest')
|
||||
const [tagVersionId, setTagVersionId] = useState<string | null>(null)
|
||||
|
||||
const skill = result?.skill
|
||||
const owner = result?.owner
|
||||
const latestVersion = result?.latestVersion
|
||||
const versions = useQuery(
|
||||
api.skills.listVersions,
|
||||
skill ? { skillId: skill._id, limit: 10 } : 'skip',
|
||||
)
|
||||
|
||||
const isStarred = useQuery(
|
||||
api.stars.isStarred,
|
||||
isAuthenticated && skill ? { skillId: skill._id } : 'skip',
|
||||
)
|
||||
const comments = useQuery(
|
||||
api.comments.listBySkill,
|
||||
skill ? { skillId: skill._id, limit: 50 } : 'skip',
|
||||
)
|
||||
|
||||
const canManage =
|
||||
Boolean(me && skill && (me._id === skill.ownerUserId || ['admin', 'moderator'].includes(me.role ?? '')))
|
||||
|
||||
const versionById = new Map((versions ?? []).map((version) => [version._id, version]))
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestVersion) return
|
||||
void getReadme({ versionId: latestVersion._id }).then((data) => {
|
||||
setReadme(data.text)
|
||||
})
|
||||
}, [latestVersion?._id, getReadme])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tagVersionId && latestVersion) {
|
||||
setTagVersionId(latestVersion._id)
|
||||
}
|
||||
}, [latestVersion, tagVersionId])
|
||||
|
||||
if (!skill) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Skill not found.</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div className="card">
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
{skill.displayName}
|
||||
</h1>
|
||||
<p className="section-subtitle">{skill.summary ?? 'No summary provided.'}</p>
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · v{latestVersion?.version}
|
||||
</div>
|
||||
{owner?.handle ? (
|
||||
<div className="stat">
|
||||
by <a href={`/u/${owner.handle}`}>@{owner.handle}</a>
|
||||
</div>
|
||||
) : null}
|
||||
{skill.badges.redactionApproved ? (
|
||||
<div className="tag">Redaction approved</div>
|
||||
) : null}
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => void toggleStar({ skillId: skill._id })}
|
||||
>
|
||||
{isStarred ? 'Unstar' : 'Star'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
SKILL.md
|
||||
</h2>
|
||||
<div className="markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{readme ?? 'Loading…'}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
Comments
|
||||
</h2>
|
||||
{isAuthenticated ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!comment.trim()) return
|
||||
void addComment({ skillId: skill._id, body: comment.trim() }).then(() =>
|
||||
setComment(''),
|
||||
)
|
||||
}}
|
||||
style={{ display: 'grid', gap: 10, marginTop: 12 }}
|
||||
>
|
||||
<textarea
|
||||
className="search-input"
|
||||
rows={2}
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Leave a note…"
|
||||
/>
|
||||
<button className="btn" type="submit">
|
||||
Post comment
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="section-subtitle">Sign in to comment.</p>
|
||||
)}
|
||||
<div style={{ display: 'grid', gap: 12, marginTop: 16 }}>
|
||||
{(comments ?? []).length === 0 ? (
|
||||
<div className="stat">No comments yet.</div>
|
||||
) : (
|
||||
(comments ?? []).map((entry: any) => (
|
||||
<div key={entry.comment._id} className="stat" style={{ alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<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') ? (
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => void removeComment({ commentId: entry.comment._id })}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div className="card">
|
||||
<h3 className="section-title" style={{ fontSize: '1.1rem', margin: 0 }}>
|
||||
Versions
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{(versions ?? []).map((version) => (
|
||||
<div key={version._id} className="stat" style={{ alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div>
|
||||
v{version.version} · {new Date(version.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div style={{ color: '#5c554e' }}>{version.changelog}</div>
|
||||
</div>
|
||||
<a
|
||||
className="btn"
|
||||
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/download?slug=${skill.slug}&version=${version.version}`}
|
||||
>
|
||||
Zip
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3 className="section-title" style={{ fontSize: '1.1rem', margin: 0 }}>
|
||||
Tags
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{Object.entries(skill.tags ?? {}).map(([tag, versionId]) => (
|
||||
<div key={tag} className="stat">
|
||||
<strong>{tag}</strong>
|
||||
<span>
|
||||
{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="card">
|
||||
<h3 className="section-title" style={{ fontSize: '1.1rem', margin: 0 }}>
|
||||
Rollback / tag
|
||||
</h3>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!tagName.trim() || !tagVersionId) return
|
||||
void updateTags({
|
||||
skillId: skill._id,
|
||||
tags: [{ tag: tagName.trim(), versionId: tagVersionId as any }],
|
||||
})
|
||||
}}
|
||||
style={{ display: 'grid', gap: 10, marginTop: 10 }}
|
||||
>
|
||||
<input
|
||||
className="search-input"
|
||||
value={tagName}
|
||||
onChange={(event) => setTagName(event.target.value)}
|
||||
placeholder="latest"
|
||||
/>
|
||||
<select
|
||||
className="search-input"
|
||||
value={tagVersionId ?? ''}
|
||||
onChange={(event) => setTagVersionId(event.target.value)}
|
||||
>
|
||||
{(versions ?? []).map((version) => (
|
||||
<option key={version._id} value={version._id}>
|
||||
v{version.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" type="submit">
|
||||
Update tag
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="card">
|
||||
<h3 className="section-title" style={{ fontSize: '1.1rem', margin: 0 }}>
|
||||
Download
|
||||
</h3>
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/download?slug=${skill.slug}`}
|
||||
>
|
||||
Download zip
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from 'convex/react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
|
||||
export const Route = createFileRoute('/stars')({
|
||||
component: Stars,
|
||||
})
|
||||
|
||||
function Stars() {
|
||||
const me = useQuery(api.users.me)
|
||||
const skills = useQuery(
|
||||
api.stars.listByUser,
|
||||
me ? { userId: me._id, limit: 50 } : 'skip',
|
||||
)
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Sign in to see your highlights.</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Your highlights</h1>
|
||||
<p className="section-subtitle">Skills you’ve starred for quick access.</p>
|
||||
<div className="grid">
|
||||
{(skills ?? []).length === 0 ? (
|
||||
<div className="card">No stars yet.</div>
|
||||
) : (
|
||||
(skills ?? []).map((skill) => (
|
||||
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
|
||||
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<div className="stat">⭐ {skill.stats.stars}</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from 'convex/react'
|
||||
import { api } from '../../../convex/_generated/api'
|
||||
|
||||
export const Route = createFileRoute('/u/$handle')({
|
||||
component: UserProfile,
|
||||
})
|
||||
|
||||
function UserProfile() {
|
||||
const { handle } = Route.useParams()
|
||||
const user = useQuery(api.users.getByHandle, { handle })
|
||||
const skills = useQuery(
|
||||
api.stars.listByUser,
|
||||
user ? { userId: user._id, limit: 50 } : 'skip',
|
||||
)
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">User not found.</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">@{user.handle ?? user.name}</h1>
|
||||
<p className="section-subtitle">Highlighted skills</p>
|
||||
<div className="grid">
|
||||
{(skills ?? []).length === 0 ? (
|
||||
<div className="card">No highlights yet.</div>
|
||||
) : (
|
||||
(skills ?? []).map((skill) => (
|
||||
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
|
||||
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<div className="stat">⭐ {skill.stats.stars}</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useAction, useConvexAuth, useMutation } from 'convex/react'
|
||||
import { useState } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
|
||||
export const Route = createFileRoute('/upload')({
|
||||
component: Upload,
|
||||
})
|
||||
|
||||
function Upload() {
|
||||
const { isAuthenticated } = useConvexAuth()
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl)
|
||||
const publishVersion = useAction(api.skills.publishVersion)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [slug, setSlug] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [version, setVersion] = useState('1.0.0')
|
||||
const [tags, setTags] = useState('latest')
|
||||
const [changelog, setChangelog] = useState('')
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Sign in to upload a skill.</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (files.length === 0) return
|
||||
setError(null)
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
|
||||
if (totalBytes > 50 * 1024 * 1024) {
|
||||
setError('Total size exceeds 50MB per version.')
|
||||
return
|
||||
}
|
||||
if (!files.some((file) => file.name.toLowerCase() === 'skill.md' || file.name.toLowerCase() === 'skills.md')) {
|
||||
setError('SKILL.md is required.')
|
||||
return
|
||||
}
|
||||
setStatus('Uploading files…')
|
||||
|
||||
const uploaded = [] as Array<{
|
||||
path: string
|
||||
size: number
|
||||
storageId: string
|
||||
sha256: string
|
||||
contentType?: string
|
||||
}>
|
||||
|
||||
for (const file of files) {
|
||||
const uploadUrl = await generateUploadUrl()
|
||||
const storageId = await uploadFile(uploadUrl, file)
|
||||
const sha256 = await hashFile(file)
|
||||
const path = file.webkitRelativePath || file.name
|
||||
uploaded.push({
|
||||
path,
|
||||
size: file.size,
|
||||
storageId,
|
||||
sha256,
|
||||
contentType: file.type || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
setStatus('Publishing version…')
|
||||
await publishVersion({
|
||||
slug,
|
||||
displayName,
|
||||
version,
|
||||
changelog,
|
||||
tags: tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
files: uploaded,
|
||||
})
|
||||
setStatus('Published.')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Publish a skill</h1>
|
||||
<p className="section-subtitle">Bundle SKILL.md + text files, then ship.</p>
|
||||
<form className="card" onSubmit={handleSubmit} style={{ display: 'grid', gap: 16 }}>
|
||||
<label>
|
||||
Slug
|
||||
<input
|
||||
className="search-input"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder="my-skill-pack"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Display name
|
||||
<input
|
||||
className="search-input"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="My Skill Pack"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Version
|
||||
<input
|
||||
className="search-input"
|
||||
value={version}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Tags (comma-separated)
|
||||
<input
|
||||
className="search-input"
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
placeholder="latest, beta"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Changelog
|
||||
<textarea
|
||||
className="search-input"
|
||||
rows={3}
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
placeholder="What changed in this version?"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Files (must include SKILL.md)
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) => setFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
Publish
|
||||
</button>
|
||||
{error ? <div className="stat" style={{ color: '#b84a3a' }}>{error}</div> : null}
|
||||
{status ? <div className="stat">{status}</div> : null}
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function uploadFile(uploadUrl: string, file: File) {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${await response.text()}`)
|
||||
}
|
||||
const payload = (await response.json()) as { storageId: string }
|
||||
return payload.storageId
|
||||
}
|
||||
|
||||
async function hashFile(file: File) {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const hash = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const bytes = new Uint8Array(hash)
|
||||
return Array.from(bytes)
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource/bricolage-grotesque/latin.css";
|
||||
@import "@fontsource/manrope/latin.css";
|
||||
@import "@fontsource/ibm-plex-mono/latin.css";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f8f5ef;
|
||||
--bg-soft: #fdfaf4;
|
||||
--ink: #1d1a17;
|
||||
--ink-soft: #4c463f;
|
||||
--accent: #ff6b4a;
|
||||
--accent-deep: #e54f31;
|
||||
--seafoam: #2bc6a4;
|
||||
--gold: #f0c46a;
|
||||
--line: rgba(29, 26, 23, 0.12);
|
||||
--shadow: 0 24px 60px rgba(29, 26, 23, 0.1);
|
||||
--radius-lg: 28px;
|
||||
--radius-md: 18px;
|
||||
--radius-sm: 12px;
|
||||
--font-display: "Bricolage Grotesque", "Manrope", sans-serif;
|
||||
--font-body: "Manrope", "Bricolage Grotesque", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink);
|
||||
background: radial-gradient(1200px 800px at 20% -10%, #fff1d8 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 90% 10%, #ffe8e0 0%, transparent 60%), var(--bg);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(248, 245, 239, 0.85);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: -0.03em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #ffd3c2 0%, #ff6b4a 60%, #d1492f 100%);
|
||||
box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--line);
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 20px rgba(29, 26, 23, 0.12);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 80px 24px 60px;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2.6rem, 4vw, 4rem);
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
background: var(--bg-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid rgba(255, 107, 74, 0.15);
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 107, 74, 0.12);
|
||||
color: var(--accent-deep);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 72px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
color: var(--ink-soft);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 16px 30px rgba(29, 26, 23, 0.12);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(43, 198, 164, 0.16);
|
||||
color: #1a6b5b;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.markdown {
|
||||
line-height: 1.7;
|
||||
color: #3f3a34;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3 {
|
||||
font-family: var(--font-display);
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.markdown code {
|
||||
background: rgba(255, 107, 74, 0.12);
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.fade-up {
|
||||
animation: fadeUp 0.6s ease forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
.fade-up[data-delay='1'] {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.fade-up[data-delay='2'] {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.fade-up[data-delay='3'] {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { devtools } from '@tanstack/devtools-vite'
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import viteTsConfigPaths from 'vite-tsconfig-paths'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { nitro } from 'nitro/vite'
|
||||
|
||||
const config = defineConfig({
|
||||
plugins: [
|
||||
devtools(),
|
||||
nitro(),
|
||||
// this is the plugin that enables path aliases
|
||||
viteTsConfigPaths({
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
tailwindcss(),
|
||||
tanstackStart(),
|
||||
viteReact(),
|
||||
],
|
||||
})
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
include: ['src/lib/**/*.{ts,tsx}', 'convex/lib/**/*.ts'],
|
||||
exclude: ['node_modules/', 'dist/', 'coverage/', 'convex/_generated/'],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
// Vitest setup (intentionally minimal for now)
|
||||
Reference in New Issue
Block a user