mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 01:12:11 +00:00
Compare commits
5
Commits
v0.3.0
...
bugfix/search
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e790c4d30a | ||
|
|
bbd517e5b5 | ||
|
|
54c793a660 | ||
|
|
5d9a89a885 | ||
|
|
31e9a57678 |
+10
-1
@@ -1,9 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.1 - Unreleased
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
- Registry: drop missing skills during search hydration (thanks @aaronn, #28).
|
||||
|
||||
## 0.3.0 - 2026-01-19
|
||||
|
||||
### Added
|
||||
- CLI: add `explore` command for latest updates, with limit clamping + tests/docs (thanks @jdrhyne, #14).
|
||||
- CLI: `explore --json` output + new sorts (`installs`, `installsAllTime`, `trending`) and limit up to 200.
|
||||
- API: `/api/v1/skills` supports installs + trending sorts (7-day installs).
|
||||
- API: idempotent `POST/DELETE /api/v1/stars/{slug}` endpoints.
|
||||
- Registry: trending leaderboard + daily stats backfill for installs-based sorts.
|
||||
|
||||
### Fixed
|
||||
- Web: keep search mode navigation and state in sync (thanks @NACC96, #12).
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"packages/clawdhub": {
|
||||
"name": "clawdhub",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
},
|
||||
@@ -76,6 +76,7 @@
|
||||
"ora": "^9.0.0",
|
||||
"p-retry": "^7.1.1",
|
||||
"semver": "^7.7.3",
|
||||
"undici": "^7.16.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.9",
|
||||
|
||||
Vendored
+8
@@ -21,6 +21,7 @@ import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as httpApi from "../httpApi.js";
|
||||
import type * as httpApiV1 from "../httpApiV1.js";
|
||||
import type * as leaderboards from "../leaderboards.js";
|
||||
import type * as lib_access from "../lib/access.js";
|
||||
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
|
||||
import type * as lib_changelog from "../lib/changelog.js";
|
||||
@@ -28,9 +29,11 @@ import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubBackup from "../lib/githubBackup.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
|
||||
import type * as lib_leaderboards from "../lib/leaderboards.js";
|
||||
import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillPublish from "../lib/skillPublish.js";
|
||||
import type * as lib_skillStats from "../lib/skillStats.js";
|
||||
import type * as lib_skills from "../lib/skills.js";
|
||||
import type * as lib_soulChangelog from "../lib/soulChangelog.js";
|
||||
import type * as lib_soulPublish from "../lib/soulPublish.js";
|
||||
@@ -47,6 +50,7 @@ import type * as soulDownloads from "../soulDownloads.js";
|
||||
import type * as soulStars from "../soulStars.js";
|
||||
import type * as souls from "../souls.js";
|
||||
import type * as stars from "../stars.js";
|
||||
import type * as statsMaintenance from "../statsMaintenance.js";
|
||||
import type * as telemetry from "../telemetry.js";
|
||||
import type * as tokens from "../tokens.js";
|
||||
import type * as uploads from "../uploads.js";
|
||||
@@ -73,6 +77,7 @@ declare const fullApi: ApiFromModules<{
|
||||
http: typeof http;
|
||||
httpApi: typeof httpApi;
|
||||
httpApiV1: typeof httpApiV1;
|
||||
leaderboards: typeof leaderboards;
|
||||
"lib/access": typeof lib_access;
|
||||
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
|
||||
"lib/changelog": typeof lib_changelog;
|
||||
@@ -80,9 +85,11 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
"lib/leaderboards": typeof lib_leaderboards;
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillPublish": typeof lib_skillPublish;
|
||||
"lib/skillStats": typeof lib_skillStats;
|
||||
"lib/skills": typeof lib_skills;
|
||||
"lib/soulChangelog": typeof lib_soulChangelog;
|
||||
"lib/soulPublish": typeof lib_soulPublish;
|
||||
@@ -99,6 +106,7 @@ declare const fullApi: ApiFromModules<{
|
||||
soulStars: typeof soulStars;
|
||||
souls: typeof souls;
|
||||
stars: typeof stars;
|
||||
statsMaintenance: typeof statsMaintenance;
|
||||
telemetry: typeof telemetry;
|
||||
tokens: typeof tokens;
|
||||
uploads: typeof uploads;
|
||||
|
||||
@@ -10,4 +10,18 @@ crons.interval(
|
||||
{ batchSize: 50, maxBatches: 5 },
|
||||
)
|
||||
|
||||
crons.interval(
|
||||
'trending-leaderboard',
|
||||
{ minutes: 60 },
|
||||
internal.leaderboards.rebuildTrendingLeaderboardInternal,
|
||||
{ limit: 200 },
|
||||
)
|
||||
|
||||
crons.interval(
|
||||
'skill-stats-backfill',
|
||||
{ minutes: 10 },
|
||||
internal.statsMaintenance.runSkillStatBackfillInternal,
|
||||
{ batchSize: 200, maxBatches: 5 },
|
||||
)
|
||||
|
||||
export default crons
|
||||
|
||||
@@ -364,6 +364,10 @@ export const seedSkillMutation = internalMutation({
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
badges: { redactionApproved: undefined },
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
@@ -413,6 +417,10 @@ export const seedSkillMutation = internalMutation({
|
||||
await ctx.db.patch(skillId, {
|
||||
latestVersionId: versionId,
|
||||
tags: { latest: versionId },
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
|
||||
+6
-2
@@ -2,6 +2,7 @@ import { v } from 'convex/values'
|
||||
import { zipSync } from 'fflate'
|
||||
import { api } from './_generated/api'
|
||||
import { httpAction, mutation } from './_generated/server'
|
||||
import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
|
||||
|
||||
export const downloadZip = httpAction(async (ctx, request) => {
|
||||
const url = new URL(request.url)
|
||||
@@ -69,9 +70,12 @@ export const increment = mutation({
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) return
|
||||
const now = Date.now()
|
||||
const patch = applySkillStatDeltas(skill, { downloads: 1 })
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, downloads: skill.stats.downloads + 1 },
|
||||
updatedAt: Date.now(),
|
||||
...patch,
|
||||
updatedAt: now,
|
||||
})
|
||||
await bumpDailySkillStats(ctx, { skillId: skill._id, now, downloads: 1 })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -158,6 +158,31 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(json.items[0].tags.latest).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('lists skills supports sort aliases', async () => {
|
||||
const checks: Array<[string, string]> = [
|
||||
['rating', 'stars'],
|
||||
['installs', 'installsCurrent'],
|
||||
['installs-all-time', 'installsAllTime'],
|
||||
['trending', 'trending'],
|
||||
]
|
||||
|
||||
for (const [input, expected] of checks) {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('sort' in args || 'cursor' in args || 'limit' in args) {
|
||||
expect(args.sort).toBe(expected)
|
||||
return { items: [], nextCursor: null }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.listSkillsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request(`https://example.com/api/v1/skills?sort=${input}`),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('get skill returns 404 when missing', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue(null)
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
|
||||
+31
-1
@@ -191,11 +191,14 @@ async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
|
||||
const url = new URL(request.url)
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const cursor = url.searchParams.get('cursor')?.trim() || undefined
|
||||
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
|
||||
const sort = parseListSort(url.searchParams.get('sort'))
|
||||
const cursor = sort === 'updated' ? rawCursor : undefined
|
||||
|
||||
const result = (await ctx.runQuery(api.skills.listPublicPage, {
|
||||
limit,
|
||||
cursor,
|
||||
sort,
|
||||
})) as ListSkillsResult
|
||||
|
||||
const items = await Promise.all(
|
||||
@@ -753,6 +756,33 @@ function toOptionalNumber(value: string | null) {
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
type SkillListSort =
|
||||
| 'updated'
|
||||
| 'downloads'
|
||||
| 'stars'
|
||||
| 'installsCurrent'
|
||||
| 'installsAllTime'
|
||||
| 'trending'
|
||||
|
||||
function parseListSort(value: string | null): SkillListSort {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
if (normalized === 'downloads') return 'downloads'
|
||||
if (normalized === 'stars' || normalized === 'rating') return 'stars'
|
||||
if (
|
||||
normalized === 'installs' ||
|
||||
normalized === 'install' ||
|
||||
normalized === 'installscurrent' ||
|
||||
normalized === 'installs-current'
|
||||
) {
|
||||
return 'installsCurrent'
|
||||
}
|
||||
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
|
||||
return 'installsAllTime'
|
||||
}
|
||||
if (normalized === 'trending') return 'trending'
|
||||
return 'updated'
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internalMutation } from './_generated/server'
|
||||
import { buildTrendingLeaderboard } from './lib/leaderboards'
|
||||
|
||||
const MAX_TRENDING_LIMIT = 200
|
||||
const KEEP_LEADERBOARD_ENTRIES = 3
|
||||
|
||||
export const rebuildTrendingLeaderboardInternal = internalMutation({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT)
|
||||
const now = Date.now()
|
||||
const { startDay, endDay, items } = await buildTrendingLeaderboard(ctx, { limit, now })
|
||||
|
||||
await ctx.db.insert('skillLeaderboards', {
|
||||
kind: 'trending',
|
||||
generatedAt: now,
|
||||
rangeStartDay: startDay,
|
||||
rangeEndDay: endDay,
|
||||
items,
|
||||
})
|
||||
|
||||
const recent = await ctx.db
|
||||
.query('skillLeaderboards')
|
||||
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
|
||||
.order('desc')
|
||||
.take(KEEP_LEADERBOARD_ENTRIES + 5)
|
||||
|
||||
for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) {
|
||||
await ctx.db.delete(entry._id)
|
||||
}
|
||||
|
||||
return { ok: true as const, count: items.length }
|
||||
},
|
||||
})
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Id } from '../_generated/dataModel'
|
||||
import type { MutationCtx, QueryCtx } from '../_generated/server'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
export const TRENDING_DAYS = 7
|
||||
|
||||
type LeaderboardEntry = {
|
||||
skillId: Id<'skills'>
|
||||
score: number
|
||||
installs: number
|
||||
downloads: number
|
||||
}
|
||||
|
||||
export function toDayKey(timestamp: number) {
|
||||
return Math.floor(timestamp / DAY_MS)
|
||||
}
|
||||
|
||||
export function getTrendingRange(now: number) {
|
||||
const endDay = toDayKey(now)
|
||||
const startDay = endDay - (TRENDING_DAYS - 1)
|
||||
return { startDay, endDay }
|
||||
}
|
||||
|
||||
export async function buildTrendingLeaderboard(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
params: { limit: number; now?: number },
|
||||
) {
|
||||
const now = params.now ?? Date.now()
|
||||
const { startDay, endDay } = getTrendingRange(now)
|
||||
const rows = await ctx.db
|
||||
.query('skillDailyStats')
|
||||
.withIndex('by_day', (q) => q.gte('day', startDay).lte('day', endDay))
|
||||
.collect()
|
||||
|
||||
const totals = new Map<Id<'skills'>, { installs: number; downloads: number }>()
|
||||
for (const row of rows) {
|
||||
const current = totals.get(row.skillId) ?? { installs: 0, downloads: 0 }
|
||||
current.installs += row.installs
|
||||
current.downloads += row.downloads
|
||||
totals.set(row.skillId, current)
|
||||
}
|
||||
|
||||
const entries = Array.from(totals, ([skillId, totalsEntry]) => ({
|
||||
skillId,
|
||||
installs: totalsEntry.installs,
|
||||
downloads: totalsEntry.downloads,
|
||||
score: totalsEntry.installs,
|
||||
}))
|
||||
|
||||
const items = topN(entries, params.limit, compareTrendingEntries).sort((a, b) =>
|
||||
compareTrendingEntries(b, a),
|
||||
)
|
||||
|
||||
return { startDay, endDay, items }
|
||||
}
|
||||
|
||||
function compareTrendingEntries(a: LeaderboardEntry, b: LeaderboardEntry) {
|
||||
if (a.score !== b.score) return a.score - b.score
|
||||
if (a.downloads !== b.downloads) return a.downloads - b.downloads
|
||||
return 0
|
||||
}
|
||||
|
||||
function topN<T>(entries: T[], limit: number, compare: (a: T, b: T) => number) {
|
||||
if (entries.length <= limit) return entries.slice()
|
||||
|
||||
const heap: T[] = []
|
||||
for (const entry of entries) {
|
||||
if (heap.length < limit) {
|
||||
heap.push(entry)
|
||||
siftUp(heap, heap.length - 1, compare)
|
||||
continue
|
||||
}
|
||||
if (compare(entry, heap[0]) <= 0) continue
|
||||
heap[0] = entry
|
||||
siftDown(heap, 0, compare)
|
||||
}
|
||||
return heap
|
||||
}
|
||||
|
||||
function siftUp<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
|
||||
let current = index
|
||||
while (current > 0) {
|
||||
const parent = Math.floor((current - 1) / 2)
|
||||
if (compare(heap[current], heap[parent]) >= 0) break
|
||||
;[heap[current], heap[parent]] = [heap[parent], heap[current]]
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
function siftDown<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
|
||||
let current = index
|
||||
const length = heap.length
|
||||
while (true) {
|
||||
const left = current * 2 + 1
|
||||
const right = current * 2 + 2
|
||||
let smallest = current
|
||||
if (left < length && compare(heap[left], heap[smallest]) < 0) smallest = left
|
||||
if (right < length && compare(heap[right], heap[smallest]) < 0) smallest = right
|
||||
if (smallest === current) break
|
||||
;[heap[current], heap[smallest]] = [heap[smallest], heap[current]]
|
||||
current = smallest
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { MutationCtx } from '../_generated/server'
|
||||
import { toDayKey } from './leaderboards'
|
||||
|
||||
type SkillStatDeltas = {
|
||||
downloads?: number
|
||||
stars?: number
|
||||
installsCurrent?: number
|
||||
installsAllTime?: number
|
||||
}
|
||||
|
||||
export function applySkillStatDeltas(skill: Doc<'skills'>, deltas: SkillStatDeltas) {
|
||||
const currentDownloads =
|
||||
typeof skill.statsDownloads === 'number' ? skill.statsDownloads : skill.stats.downloads
|
||||
const currentStars = typeof skill.statsStars === 'number' ? skill.statsStars : skill.stats.stars
|
||||
const currentInstallsCurrent =
|
||||
typeof skill.statsInstallsCurrent === 'number'
|
||||
? skill.statsInstallsCurrent
|
||||
: (skill.stats.installsCurrent ?? 0)
|
||||
const currentInstallsAllTime =
|
||||
typeof skill.statsInstallsAllTime === 'number'
|
||||
? skill.statsInstallsAllTime
|
||||
: (skill.stats.installsAllTime ?? 0)
|
||||
|
||||
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0))
|
||||
const nextStars = Math.max(0, currentStars + (deltas.stars ?? 0))
|
||||
const nextInstallsCurrent = Math.max(0, currentInstallsCurrent + (deltas.installsCurrent ?? 0))
|
||||
const nextInstallsAllTime = Math.max(0, currentInstallsAllTime + (deltas.installsAllTime ?? 0))
|
||||
|
||||
return {
|
||||
statsDownloads: nextDownloads,
|
||||
statsStars: nextStars,
|
||||
statsInstallsCurrent: nextInstallsCurrent,
|
||||
statsInstallsAllTime: nextInstallsAllTime,
|
||||
stats: {
|
||||
...skill.stats,
|
||||
downloads: nextDownloads,
|
||||
stars: nextStars,
|
||||
installsCurrent: nextInstallsCurrent,
|
||||
installsAllTime: nextInstallsAllTime,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function bumpDailySkillStats(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
skillId: Id<'skills'>
|
||||
now: number
|
||||
downloads?: number
|
||||
installs?: number
|
||||
},
|
||||
) {
|
||||
const downloads = params.downloads ?? 0
|
||||
const installs = params.installs ?? 0
|
||||
if (downloads === 0 && installs === 0) return
|
||||
|
||||
const day = toDayKey(params.now)
|
||||
const existing = await ctx.db
|
||||
.query('skillDailyStats')
|
||||
.withIndex('by_skill_day', (q) => q.eq('skillId', params.skillId).eq('day', day))
|
||||
.unique()
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
downloads: Math.max(0, existing.downloads + downloads),
|
||||
installs: Math.max(0, existing.installs + installs),
|
||||
updatedAt: params.now,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.db.insert('skillDailyStats', {
|
||||
skillId: params.skillId,
|
||||
day,
|
||||
downloads: Math.max(0, downloads),
|
||||
installs: Math.max(0, installs),
|
||||
updatedAt: params.now,
|
||||
})
|
||||
}
|
||||
@@ -49,6 +49,10 @@ const skills = defineTable({
|
||||
),
|
||||
}),
|
||||
batch: v.optional(v.string()),
|
||||
statsDownloads: v.optional(v.number()),
|
||||
statsStars: v.optional(v.number()),
|
||||
statsInstallsCurrent: v.optional(v.number()),
|
||||
statsInstallsAllTime: v.optional(v.number()),
|
||||
stats: v.object({
|
||||
downloads: v.number(),
|
||||
installsCurrent: v.optional(v.number()),
|
||||
@@ -63,6 +67,10 @@ const skills = defineTable({
|
||||
.index('by_slug', ['slug'])
|
||||
.index('by_owner', ['ownerUserId'])
|
||||
.index('by_updated', ['updatedAt'])
|
||||
.index('by_stats_downloads', ['statsDownloads', 'updatedAt'])
|
||||
.index('by_stats_stars', ['statsStars', 'updatedAt'])
|
||||
.index('by_stats_installs_current', ['statsInstallsCurrent', 'updatedAt'])
|
||||
.index('by_stats_installs_all_time', ['statsInstallsAllTime', 'updatedAt'])
|
||||
.index('by_batch', ['batch'])
|
||||
|
||||
const souls = defineTable({
|
||||
@@ -177,6 +185,38 @@ const skillEmbeddings = defineTable({
|
||||
filterFields: ['visibility'],
|
||||
})
|
||||
|
||||
const skillDailyStats = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
day: v.number(),
|
||||
downloads: v.number(),
|
||||
installs: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_skill_day', ['skillId', 'day'])
|
||||
.index('by_day', ['day'])
|
||||
|
||||
const skillLeaderboards = defineTable({
|
||||
kind: v.string(),
|
||||
generatedAt: v.number(),
|
||||
rangeStartDay: v.number(),
|
||||
rangeEndDay: v.number(),
|
||||
items: v.array(
|
||||
v.object({
|
||||
skillId: v.id('skills'),
|
||||
score: v.number(),
|
||||
installs: v.number(),
|
||||
downloads: v.number(),
|
||||
}),
|
||||
),
|
||||
}).index('by_kind', ['kind', 'generatedAt'])
|
||||
|
||||
const skillStatBackfillState = defineTable({
|
||||
key: v.string(),
|
||||
cursor: v.optional(v.string()),
|
||||
doneAt: v.optional(v.number()),
|
||||
updatedAt: v.number(),
|
||||
}).index('by_key', ['key'])
|
||||
|
||||
const soulEmbeddings = defineTable({
|
||||
soulId: v.id('souls'),
|
||||
versionId: v.id('soulVersions'),
|
||||
@@ -323,6 +363,9 @@ export default defineSchema({
|
||||
soulVersionFingerprints,
|
||||
skillEmbeddings,
|
||||
soulEmbeddings,
|
||||
skillDailyStats,
|
||||
skillLeaderboards,
|
||||
skillStatBackfillState,
|
||||
comments,
|
||||
soulComments,
|
||||
stars,
|
||||
|
||||
+39
-11
@@ -9,6 +9,7 @@ type HydratedEntry = {
|
||||
embeddingId: Id<'skillEmbeddings'>
|
||||
skill: Doc<'skills'> | null
|
||||
version: Doc<'skillVersions'> | null
|
||||
ownerHandle: string | null
|
||||
}
|
||||
|
||||
type SearchResult = HydratedEntry & { score: number }
|
||||
@@ -29,7 +30,13 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
if (!query) return []
|
||||
const queryTokens = tokenize(query)
|
||||
if (queryTokens.length === 0) return []
|
||||
const vector = await generateEmbedding(query)
|
||||
let vector: number[]
|
||||
try {
|
||||
vector = await generateEmbedding(query)
|
||||
} catch (error) {
|
||||
console.warn('Search embedding generation failed', error)
|
||||
return []
|
||||
}
|
||||
const limit = args.limit ?? 10
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
|
||||
let candidateLimit = Math.max(limit * 3, 50)
|
||||
@@ -86,18 +93,33 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
export const hydrateResults = internalQuery({
|
||||
args: { embeddingIds: v.array(v.id('skillEmbeddings')) },
|
||||
handler: async (ctx, args): Promise<HydratedEntry[]> => {
|
||||
const entries: HydratedEntry[] = []
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
|
||||
for (const embeddingId of args.embeddingIds) {
|
||||
const embedding = await ctx.db.get(embeddingId)
|
||||
if (!embedding) continue
|
||||
const skill = await ctx.db.get(embedding.skillId)
|
||||
if (skill?.softDeletedAt) continue
|
||||
const version = await ctx.db.get(embedding.versionId)
|
||||
entries.push({ embeddingId, skill, version })
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = ctx.db
|
||||
.get(ownerUserId)
|
||||
.then((owner) => owner?.handle ?? owner?._id ?? null)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
}
|
||||
|
||||
return entries
|
||||
const entries = await Promise.all(
|
||||
args.embeddingIds.map(async (embeddingId) => {
|
||||
const embedding = await ctx.db.get(embeddingId)
|
||||
if (!embedding) return null
|
||||
const skill = await ctx.db.get(embedding.skillId)
|
||||
if (!skill || skill.softDeletedAt) return null
|
||||
const [version, ownerHandle] = await Promise.all([
|
||||
ctx.db.get(embedding.versionId),
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
])
|
||||
return { embeddingId, skill, version, ownerHandle }
|
||||
}),
|
||||
)
|
||||
|
||||
return entries.filter((entry): entry is HydratedEntry => entry !== null)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -119,7 +141,13 @@ export const searchSouls: ReturnType<typeof action> = action({
|
||||
if (!query) return []
|
||||
const queryTokens = tokenize(query)
|
||||
if (queryTokens.length === 0) return []
|
||||
const vector = await generateEmbedding(query)
|
||||
let vector: number[]
|
||||
try {
|
||||
vector = await generateEmbedding(query)
|
||||
} catch (error) {
|
||||
console.warn('Search embedding generation failed', error)
|
||||
return []
|
||||
}
|
||||
const limit = args.limit ?? 10
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
|
||||
let candidateLimit = Math.max(limit * 3, 50)
|
||||
|
||||
+124
-16
@@ -1,10 +1,11 @@
|
||||
import { ConvexError, v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { MutationCtx } from './_generated/server'
|
||||
import type { MutationCtx, QueryCtx } from './_generated/server'
|
||||
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
|
||||
import { assertRole, requireUser, requireUserFromAction } from './lib/access'
|
||||
import { generateChangelogPreview as buildChangelogPreview } from './lib/changelog'
|
||||
import { buildTrendingLeaderboard, getTrendingRange } from './lib/leaderboards'
|
||||
import {
|
||||
fetchText,
|
||||
type PublishResult,
|
||||
@@ -20,9 +21,43 @@ type FileTextResult = { path: string; text: string; size: number; sha256: string
|
||||
|
||||
const MAX_DIFF_FILE_BYTES = 200 * 1024
|
||||
const MAX_LIST_LIMIT = 50
|
||||
const MAX_PUBLIC_LIST_LIMIT = 200
|
||||
const MAX_LIST_BULK_LIMIT = 200
|
||||
const MAX_LIST_TAKE = 1000
|
||||
|
||||
async function resolveOwnerHandle(ctx: QueryCtx, ownerUserId: Id<'users'>) {
|
||||
const owner = await ctx.db.get(ownerUserId)
|
||||
return owner?.handle ?? owner?._id ?? null
|
||||
}
|
||||
|
||||
type PublicSkillEntry = {
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
ownerHandle: string | null
|
||||
}
|
||||
|
||||
async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = resolveOwnerHandle(ctx, ownerUserId)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
skills.map(async (skill) => {
|
||||
const [latestVersion, ownerHandle] = await Promise.all([
|
||||
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
])
|
||||
return { skill, latestVersion, ownerHandle }
|
||||
}),
|
||||
) satisfies Promise<PublicSkillEntry[]>
|
||||
}
|
||||
|
||||
export const getBySlug = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -154,30 +189,99 @@ export const listPublicPage = query({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
limit: v.optional(v.number()),
|
||||
sort: v.optional(
|
||||
v.union(
|
||||
v.literal('updated'),
|
||||
v.literal('downloads'),
|
||||
v.literal('stars'),
|
||||
v.literal('installsCurrent'),
|
||||
v.literal('installsAllTime'),
|
||||
v.literal('trending'),
|
||||
),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 24, 1, MAX_LIST_LIMIT)
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_updated', (q) => q)
|
||||
.order('desc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: limit })
|
||||
const sort = args.sort ?? 'updated'
|
||||
const limit = clampInt(args.limit ?? 24, 1, MAX_PUBLIC_LIST_LIMIT)
|
||||
|
||||
const items: Array<{
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
}> = []
|
||||
if (sort === 'updated') {
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_updated', (q) => q)
|
||||
.order('desc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: limit })
|
||||
|
||||
for (const skill of page) {
|
||||
if (skill.softDeletedAt) continue
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
items.push({ skill, latestVersion })
|
||||
const skills = page.filter((skill) => !skill.softDeletedAt)
|
||||
const items = await buildPublicSkillEntries(ctx, skills)
|
||||
|
||||
return { items, nextCursor: isDone ? null : continueCursor }
|
||||
}
|
||||
|
||||
return { items, nextCursor: isDone ? null : continueCursor }
|
||||
if (sort === 'trending') {
|
||||
const entries = await getTrendingEntries(ctx, limit)
|
||||
const skills: Doc<'skills'>[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const skill = await ctx.db.get(entry.skillId)
|
||||
if (!skill || skill.softDeletedAt) continue
|
||||
skills.push(skill)
|
||||
if (skills.length >= limit) break
|
||||
}
|
||||
|
||||
const items = await buildPublicSkillEntries(ctx, skills)
|
||||
return { items, nextCursor: null }
|
||||
}
|
||||
|
||||
const index = sortToIndex(sort)
|
||||
const page = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex(index, (q) => q)
|
||||
.order('desc')
|
||||
.take(Math.min(limit * 5, MAX_LIST_TAKE))
|
||||
|
||||
const filtered = page.filter((skill) => !skill.softDeletedAt).slice(0, limit)
|
||||
const items = await buildPublicSkillEntries(ctx, filtered)
|
||||
return { items, nextCursor: null }
|
||||
},
|
||||
})
|
||||
|
||||
function sortToIndex(
|
||||
sort: 'downloads' | 'stars' | 'installsCurrent' | 'installsAllTime',
|
||||
):
|
||||
| 'by_stats_downloads'
|
||||
| 'by_stats_stars'
|
||||
| 'by_stats_installs_current'
|
||||
| 'by_stats_installs_all_time' {
|
||||
switch (sort) {
|
||||
case 'downloads':
|
||||
return 'by_stats_downloads'
|
||||
case 'stars':
|
||||
return 'by_stats_stars'
|
||||
case 'installsCurrent':
|
||||
return 'by_stats_installs_current'
|
||||
case 'installsAllTime':
|
||||
return 'by_stats_installs_all_time'
|
||||
}
|
||||
}
|
||||
|
||||
async function getTrendingEntries(ctx: QueryCtx, limit: number) {
|
||||
const now = Date.now()
|
||||
const { startDay, endDay } = getTrendingRange(now)
|
||||
const latest = await ctx.db
|
||||
.query('skillLeaderboards')
|
||||
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
|
||||
.order('desc')
|
||||
.take(1)
|
||||
|
||||
const leaderboard = latest[0]
|
||||
if (leaderboard && leaderboard.rangeStartDay === startDay && leaderboard.rangeEndDay === endDay) {
|
||||
return leaderboard.items.slice(0, limit)
|
||||
}
|
||||
|
||||
const fallback = await buildTrendingLeaderboard(ctx, { limit, now })
|
||||
return fallback.items
|
||||
}
|
||||
|
||||
export const listVersions = query({
|
||||
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -587,6 +691,10 @@ export const insertVersion = internalMutation({
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
badges: { redactionApproved: undefined },
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
|
||||
+6
-4
@@ -2,6 +2,7 @@ import { v } from 'convex/values'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import { internalMutation, mutation, query } from './_generated/server'
|
||||
import { requireUser } from './lib/access'
|
||||
import { applySkillStatDeltas } from './lib/skillStats'
|
||||
|
||||
export const isStarred = query({
|
||||
args: { skillId: v.id('skills') },
|
||||
@@ -29,8 +30,9 @@ export const toggle = mutation({
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.delete(existing._id)
|
||||
const patch = applySkillStatDeltas(skill, { stars: -1 })
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, stars: Math.max(0, skill.stats.stars - 1) },
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return { starred: false }
|
||||
@@ -43,7 +45,7 @@ export const toggle = mutation({
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, stars: skill.stats.stars + 1 },
|
||||
...applySkillStatDeltas(skill, { stars: 1 }),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
@@ -87,7 +89,7 @@ export const addStarInternal = internalMutation({
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, stars: skill.stats.stars + 1 },
|
||||
...applySkillStatDeltas(skill, { stars: 1 }),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
@@ -108,7 +110,7 @@ export const removeStarInternal = internalMutation({
|
||||
|
||||
await ctx.db.delete(existing._id)
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, stars: Math.max(0, skill.stats.stars - 1) },
|
||||
...applySkillStatDeltas(skill, { stars: -1 }),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import { internalAction, internalMutation, internalQuery } from './_generated/server'
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 200
|
||||
const MAX_BATCH_SIZE = 1000
|
||||
const DEFAULT_MAX_BATCHES = 5
|
||||
const MAX_MAX_BATCHES = 50
|
||||
const BACKFILL_STATE_KEY = 'default'
|
||||
|
||||
export const backfillSkillStatFieldsInternal = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('skills')
|
||||
.order('asc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
|
||||
|
||||
let patched = 0
|
||||
for (const skill of page) {
|
||||
const next = buildSkillStatPatch(skill)
|
||||
if (!next) continue
|
||||
await ctx.db.patch(skill._id, next)
|
||||
patched += 1
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
scanned: page.length,
|
||||
patched,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type BackfillState = {
|
||||
cursor: string | null
|
||||
doneAt?: number
|
||||
}
|
||||
|
||||
export const getSkillStatBackfillStateInternal = internalQuery({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<BackfillState> => {
|
||||
const state = await ctx.db
|
||||
.query('skillStatBackfillState')
|
||||
.withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
|
||||
.unique()
|
||||
return { cursor: state?.cursor ?? null, doneAt: state?.doneAt }
|
||||
},
|
||||
})
|
||||
|
||||
export const setSkillStatBackfillStateInternal = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
doneAt: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now()
|
||||
const state = await ctx.db
|
||||
.query('skillStatBackfillState')
|
||||
.withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
|
||||
.unique()
|
||||
|
||||
if (!state) {
|
||||
await ctx.db.insert('skillStatBackfillState', {
|
||||
key: BACKFILL_STATE_KEY,
|
||||
cursor: args.cursor,
|
||||
doneAt: args.doneAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { ok: true as const }
|
||||
}
|
||||
|
||||
await ctx.db.patch(state._id, {
|
||||
cursor: args.cursor,
|
||||
doneAt: args.doneAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
|
||||
export const runSkillStatBackfillInternal = internalAction({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
resetCursor: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
|
||||
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
|
||||
|
||||
if (args.resetCursor) {
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: undefined,
|
||||
doneAt: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const state = await ctx.runQuery(
|
||||
internal.statsMaintenance.getSkillStatBackfillStateInternal,
|
||||
{},
|
||||
)
|
||||
if (state.doneAt && !args.resetCursor) {
|
||||
return {
|
||||
ok: true as const,
|
||||
isDone: true,
|
||||
cursor: null,
|
||||
stats: { scanned: 0, patched: 0, batches: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
let cursor = state.cursor ?? null
|
||||
const stats = { scanned: 0, patched: 0, batches: 0 }
|
||||
|
||||
for (let i = 0; i < maxBatches; i += 1) {
|
||||
const result = await ctx.runMutation(
|
||||
internal.statsMaintenance.backfillSkillStatFieldsInternal,
|
||||
{
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
},
|
||||
)
|
||||
stats.scanned += result.scanned
|
||||
stats.patched += result.patched
|
||||
stats.batches += 1
|
||||
cursor = result.cursor
|
||||
|
||||
if (result.isDone) {
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: undefined,
|
||||
doneAt: Date.now(),
|
||||
})
|
||||
return { ok: true as const, isDone: true, cursor: null, stats }
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: cursor ?? undefined,
|
||||
doneAt: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true as const, isDone: false, cursor, stats }
|
||||
},
|
||||
})
|
||||
|
||||
function buildSkillStatPatch(skill: Doc<'skills'>) {
|
||||
const stats = skill.stats
|
||||
const nextDownloads = stats.downloads
|
||||
const nextStars = stats.stars
|
||||
const nextInstallsCurrent = stats.installsCurrent ?? 0
|
||||
const nextInstallsAllTime = stats.installsAllTime ?? 0
|
||||
|
||||
if (
|
||||
skill.statsDownloads === nextDownloads &&
|
||||
skill.statsStars === nextStars &&
|
||||
skill.statsInstallsCurrent === nextInstallsCurrent &&
|
||||
skill.statsInstallsAllTime === nextInstallsAllTime
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
statsDownloads: nextDownloads,
|
||||
statsStars: nextStars,
|
||||
statsInstallsCurrent: nextInstallsCurrent,
|
||||
statsInstallsAllTime: nextInstallsAllTime,
|
||||
}
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
+21
-30
@@ -4,6 +4,7 @@ import type { Id } from './_generated/dataModel'
|
||||
import type { MutationCtx, QueryCtx } from './_generated/server'
|
||||
import { internalMutation, mutation, query } from './_generated/server'
|
||||
import { requireUser } from './lib/access'
|
||||
import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
|
||||
|
||||
const TELEMETRY_STALE_MS = 120 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -157,23 +158,12 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<'use
|
||||
await ctx.db.delete(entry._id)
|
||||
continue
|
||||
}
|
||||
const stats = skill.stats as {
|
||||
downloads: number
|
||||
installsCurrent?: number
|
||||
installsAllTime?: number
|
||||
stars: number
|
||||
versions: number
|
||||
comments: number
|
||||
}
|
||||
const patch = applySkillStatDeltas(skill, {
|
||||
installsCurrent: entry.activeRoots > 0 ? -1 : 0,
|
||||
installsAllTime: -1,
|
||||
})
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: {
|
||||
...stats,
|
||||
installsCurrent: Math.max(
|
||||
0,
|
||||
(stats.installsCurrent ?? 0) - (entry.activeRoots > 0 ? 1 : 0),
|
||||
),
|
||||
installsAllTime: Math.max(0, (stats.installsAllTime ?? 0) - 1),
|
||||
},
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
await ctx.db.delete(entry._id)
|
||||
@@ -383,23 +373,24 @@ async function bumpSkillInstallCounts(
|
||||
) {
|
||||
const skill = await ctx.db.get(params.skillId)
|
||||
if (!skill) return
|
||||
const stats = skill.stats as {
|
||||
downloads: number
|
||||
installsCurrent?: number
|
||||
installsAllTime?: number
|
||||
stars: number
|
||||
versions: number
|
||||
comments: number
|
||||
}
|
||||
const now = Date.now()
|
||||
const patch = applySkillStatDeltas(skill, {
|
||||
installsAllTime: params.deltaAllTime,
|
||||
installsCurrent: params.deltaCurrent,
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: {
|
||||
...stats,
|
||||
installsAllTime: Math.max(0, (stats.installsAllTime ?? 0) + params.deltaAllTime),
|
||||
installsCurrent: Math.max(0, (stats.installsCurrent ?? 0) + params.deltaCurrent),
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
...patch,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (params.deltaAllTime > 0) {
|
||||
await bumpDailySkillStats(ctx, {
|
||||
skillId: params.skillId,
|
||||
now,
|
||||
installs: params.deltaAllTime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function expireStaleRoots(
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Ret
|
||||
Public read:
|
||||
|
||||
- `GET /api/v1/search?q=...`
|
||||
- `GET /api/v1/skills?limit=&cursor=`
|
||||
- `GET /api/v1/skills?limit=&cursor=&sort=`
|
||||
- `sort`: `updated` (default), `downloads`, `stars` (`rating`), `installsCurrent` (`installs`), `installsAllTime`, `trending`
|
||||
- `GET /api/v1/skills/{slug}`
|
||||
- `GET /api/v1/skills/{slug}/versions?limit=&cursor=`
|
||||
- `GET /api/v1/skills/{slug}/versions/{version}`
|
||||
|
||||
+3
-1
@@ -61,7 +61,9 @@ Stores your API token + cached registry URL.
|
||||
|
||||
- Lists latest updated skills via `/api/v1/skills?limit=...` (sorted by `updatedAt` desc).
|
||||
- Flags:
|
||||
- `--limit <n>` (1–50, default: 25)
|
||||
- `--limit <n>` (1–200, default: 25)
|
||||
- `--sort newest|downloads|rating|installs|installsAllTime|trending` (default: newest)
|
||||
- `--json` (machine-readable output)
|
||||
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
|
||||
|
||||
### `install <slug>`
|
||||
|
||||
+7
-2
@@ -44,8 +44,13 @@ Response:
|
||||
|
||||
Query params:
|
||||
|
||||
- `limit` (optional): integer
|
||||
- `cursor` (optional): pagination cursor
|
||||
- `limit` (optional): integer (1–200)
|
||||
- `cursor` (optional): pagination cursor (only for `sort=updated`)
|
||||
- `sort` (optional): `updated` (default), `downloads`, `stars` (alias: `rating`), `installsCurrent` (alias: `installs`), `installsAllTime`, `trending`
|
||||
|
||||
Notes:
|
||||
|
||||
- `trending` ranks by installs in the last 7 days (telemetry-based).
|
||||
|
||||
Response:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdhub",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"description": "ClawdHub CLI \\u2014 install, update, search, and publish agent skills.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -28,7 +28,8 @@
|
||||
"mime": "^4.1.0",
|
||||
"ora": "^9.0.0",
|
||||
"p-retry": "^7.1.1",
|
||||
"semver": "^7.7.3"
|
||||
"semver": "^7.7.3",
|
||||
"undici": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.9",
|
||||
|
||||
@@ -190,15 +190,21 @@ program
|
||||
.description('Browse latest updated skills from the registry')
|
||||
.option(
|
||||
'--limit <n>',
|
||||
'Number of skills to show (max 50)',
|
||||
'Number of skills to show (max 200)',
|
||||
(value) => Number.parseInt(value, 10),
|
||||
25,
|
||||
)
|
||||
.option(
|
||||
'--sort <order>',
|
||||
'Sort by newest, downloads, rating, installs, installsAllTime, or trending',
|
||||
'newest',
|
||||
)
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
const limit =
|
||||
typeof options.limit === 'number' && Number.isFinite(options.limit) ? options.limit : 25
|
||||
await cmdExplore(opts, limit)
|
||||
await cmdExplore(opts, { limit, sort: options.sort, json: options.json })
|
||||
})
|
||||
|
||||
program
|
||||
|
||||
@@ -43,7 +43,9 @@ describe('explore helpers', () => {
|
||||
expect(clampLimit(0)).toBe(1)
|
||||
expect(clampLimit(1)).toBe(1)
|
||||
expect(clampLimit(50)).toBe(50)
|
||||
expect(clampLimit(99)).toBe(50)
|
||||
expect(clampLimit(99)).toBe(99)
|
||||
expect(clampLimit(200)).toBe(200)
|
||||
expect(clampLimit(250)).toBe(200)
|
||||
expect(clampLimit(Number.NaN)).toBe(25)
|
||||
expect(clampLimit(Number.POSITIVE_INFINITY)).toBe(25)
|
||||
expect(clampLimit(Number.NaN, 10)).toBe(10)
|
||||
@@ -68,7 +70,7 @@ describe('cmdExplore', () => {
|
||||
it('clamps limit and handles empty results', async () => {
|
||||
mockApiRequest.mockResolvedValue({ items: [] })
|
||||
|
||||
await cmdExplore(makeOpts(), 0)
|
||||
await cmdExplore(makeOpts(), { limit: 0 })
|
||||
|
||||
const [, args] = mockApiRequest.mock.calls[0] ?? []
|
||||
const url = new URL(String(args?.url))
|
||||
@@ -87,12 +89,37 @@ describe('cmdExplore', () => {
|
||||
}
|
||||
mockApiRequest.mockResolvedValue({ items: [item] })
|
||||
|
||||
await cmdExplore(makeOpts(), 100)
|
||||
await cmdExplore(makeOpts(), { limit: 250 })
|
||||
|
||||
const [, args] = mockApiRequest.mock.calls[0] ?? []
|
||||
const url = new URL(String(args?.url))
|
||||
expect(url.searchParams.get('limit')).toBe('50')
|
||||
expect(url.searchParams.get('limit')).toBe('200')
|
||||
expect(mockLog).toHaveBeenCalledWith(formatExploreLine(item))
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('supports sort and json output', async () => {
|
||||
const payload = { items: [], nextCursor: null }
|
||||
mockApiRequest.mockResolvedValue(payload)
|
||||
|
||||
await cmdExplore(makeOpts(), { limit: 10, sort: 'installs', json: true })
|
||||
|
||||
const [, args] = mockApiRequest.mock.calls[0] ?? []
|
||||
const url = new URL(String(args?.url))
|
||||
expect(url.searchParams.get('limit')).toBe('10')
|
||||
expect(url.searchParams.get('sort')).toBe('installsCurrent')
|
||||
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(payload, null, 2))
|
||||
})
|
||||
|
||||
it('supports all-time installs and trending sorts', async () => {
|
||||
mockApiRequest.mockResolvedValue({ items: [], nextCursor: null })
|
||||
|
||||
await cmdExplore(makeOpts(), { limit: 5, sort: 'installsAllTime' })
|
||||
await cmdExplore(makeOpts(), { limit: 5, sort: 'trending' })
|
||||
|
||||
const first = new URL(String(mockApiRequest.mock.calls[0]?.[1]?.url))
|
||||
const second = new URL(String(mockApiRequest.mock.calls[1]?.[1]?.url))
|
||||
expect(first.searchParams.get('sort')).toBe('installsAllTime')
|
||||
expect(second.searchParams.get('sort')).toBe('trending')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -242,13 +242,27 @@ export async function cmdList(opts: GlobalOpts) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdExplore(opts: GlobalOpts, limit = 25) {
|
||||
type ExploreSort = 'newest' | 'downloads' | 'rating' | 'installs' | 'installsAllTime' | 'trending'
|
||||
type ApiExploreSort =
|
||||
| 'updated'
|
||||
| 'downloads'
|
||||
| 'stars'
|
||||
| 'installsCurrent'
|
||||
| 'installsAllTime'
|
||||
| 'trending'
|
||||
|
||||
export async function cmdExplore(
|
||||
opts: GlobalOpts,
|
||||
options: { limit?: number; sort?: string; json?: boolean } = {},
|
||||
) {
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner('Fetching latest skills')
|
||||
try {
|
||||
const url = new URL(ApiRoutes.skills, registry)
|
||||
const boundedLimit = clampLimit(limit)
|
||||
const boundedLimit = clampLimit(options.limit ?? 25)
|
||||
const { apiSort } = resolveExploreSort(options.sort)
|
||||
url.searchParams.set('limit', String(boundedLimit))
|
||||
if (apiSort !== 'updated') url.searchParams.set('sort', apiSort)
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
@@ -256,6 +270,10 @@ export async function cmdExplore(opts: GlobalOpts, limit = 25) {
|
||||
)
|
||||
|
||||
spinner.stop()
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
return
|
||||
}
|
||||
if (result.items.length === 0) {
|
||||
console.log('No skills found.')
|
||||
return
|
||||
@@ -284,7 +302,7 @@ export function formatExploreLine(item: {
|
||||
|
||||
export function clampLimit(limit: number, fallback = 25) {
|
||||
if (!Number.isFinite(limit)) return fallback
|
||||
return Math.min(Math.max(1, limit), 50)
|
||||
return Math.min(Math.max(1, limit), 200)
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
@@ -310,6 +328,37 @@ function truncate(str: string, maxLen: number): string {
|
||||
return `${str.slice(0, maxLen - 1)}…`
|
||||
}
|
||||
|
||||
function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExploreSort } {
|
||||
const normalized = raw?.trim().toLowerCase()
|
||||
if (!normalized || normalized === 'newest' || normalized === 'updated') {
|
||||
return { sort: 'newest', apiSort: 'updated' }
|
||||
}
|
||||
if (normalized === 'downloads' || normalized === 'download') {
|
||||
return { sort: 'downloads', apiSort: 'downloads' }
|
||||
}
|
||||
if (normalized === 'rating' || normalized === 'stars' || normalized === 'star') {
|
||||
return { sort: 'rating', apiSort: 'stars' }
|
||||
}
|
||||
if (
|
||||
normalized === 'installs' ||
|
||||
normalized === 'install' ||
|
||||
normalized === 'installscurrent' ||
|
||||
normalized === 'installs-current' ||
|
||||
normalized === 'current'
|
||||
) {
|
||||
return { sort: 'installs', apiSort: 'installsCurrent' }
|
||||
}
|
||||
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
|
||||
return { sort: 'installsAllTime', apiSort: 'installsAllTime' }
|
||||
}
|
||||
if (normalized === 'trending') {
|
||||
return { sort: 'trending', apiSort: 'trending' }
|
||||
}
|
||||
fail(
|
||||
`Invalid sort "${raw}". Use newest, downloads, rating, installs, installsAllTime, or trending.`,
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
|
||||
const url = new URL(ApiRoutes.resolve, registry)
|
||||
url.searchParams.set('slug', slug)
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock('@tanstack/react-router', () => ({
|
||||
import { Route } from '../routes/search'
|
||||
|
||||
describe('search route', () => {
|
||||
it('redirects to home with search mode enabled', () => {
|
||||
it('redirects to the skills index', () => {
|
||||
const beforeLoad = Route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
}) => void
|
||||
@@ -22,18 +22,17 @@ describe('search route', () => {
|
||||
|
||||
expect(thrown).toEqual({
|
||||
redirect: {
|
||||
to: '/',
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: 'crab',
|
||||
highlighted: true,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to home with search flag even without query', () => {
|
||||
it('redirects to the skills index without query', () => {
|
||||
const beforeLoad = Route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
}) => void
|
||||
@@ -47,11 +46,10 @@ describe('search route', () => {
|
||||
|
||||
expect(thrown).toEqual({
|
||||
redirect: {
|
||||
to: '/',
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: undefined,
|
||||
highlighted: undefined,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
|
||||
@@ -8,11 +8,14 @@ type SkillCardProps = {
|
||||
chip?: string
|
||||
summaryFallback: string
|
||||
meta: ReactNode
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function SkillCard({ skill, badge, chip, summaryFallback, meta }: SkillCardProps) {
|
||||
export function SkillCard({ skill, badge, chip, summaryFallback, meta, href }: SkillCardProps) {
|
||||
const link = href ?? `/skills/${skill.slug}`
|
||||
|
||||
return (
|
||||
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="card skill-card">
|
||||
<Link to={link} className="card skill-card">
|
||||
{badge || chip ? (
|
||||
<div className="skill-card-tags">
|
||||
{badge ? <div className="tag">{badge}</div> : null}
|
||||
|
||||
+59
-221
@@ -29,86 +29,13 @@ function Home() {
|
||||
}
|
||||
|
||||
function SkillsHome() {
|
||||
const navigate = Route.useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const searchSkills = useAction(api.search.searchSkills)
|
||||
const highlighted =
|
||||
(useQuery(api.skills.list, { batch: 'highlighted', limit: 6 }) as Doc<'skills'>[]) ?? []
|
||||
const latest = (useQuery(api.skills.list, { limit: 12 }) as Doc<'skills'>[]) ?? []
|
||||
const [query, setQuery] = useState(search.q ?? '')
|
||||
const [highlightedOnly, setHighlightedOnly] = useState(search.highlighted ?? false)
|
||||
const [results, setResults] = useState<
|
||||
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
|
||||
>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchMode, setSearchMode] = useState(
|
||||
Boolean(search.q || search.highlighted || search.search),
|
||||
)
|
||||
const searchRequest = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const trimmedQuery = useMemo(() => query.trim(), [query])
|
||||
const hasQuery = trimmedQuery.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? '')
|
||||
setHighlightedOnly(search.highlighted ?? false)
|
||||
if (search.q || search.highlighted || search.search) {
|
||||
setSearchMode(true)
|
||||
} else {
|
||||
setSearchMode(false)
|
||||
}
|
||||
}, [search.highlighted, search.q, search.search])
|
||||
|
||||
useEffect(() => {
|
||||
void navigate({
|
||||
search: () => ({
|
||||
q: trimmedQuery || undefined,
|
||||
highlighted: highlightedOnly ? true : undefined,
|
||||
search: searchMode && !trimmedQuery && !highlightedOnly ? true : undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
}, [highlightedOnly, navigate, searchMode, trimmedQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchMode && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [searchMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trimmedQuery) {
|
||||
setResults([])
|
||||
setIsSearching(false)
|
||||
return
|
||||
}
|
||||
searchRequest.current += 1
|
||||
const requestId = searchRequest.current
|
||||
setIsSearching(true)
|
||||
const handle = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const data = (await searchSkills({ query: trimmedQuery, highlightedOnly })) as Array<{
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
}>
|
||||
if (requestId === searchRequest.current) {
|
||||
setResults(data)
|
||||
}
|
||||
} finally {
|
||||
if (requestId === searchRequest.current) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, 220)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [highlightedOnly, searchSkills, trimmedQuery])
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className={`hero${searchMode ? ' search-mode' : ''}`}>
|
||||
<section className="hero">
|
||||
<div className="hero-inner">
|
||||
<div className="hero-copy fade-up" data-delay="1">
|
||||
<span className="hero-badge">Lobster-light. Agent-right.</span>
|
||||
@@ -121,162 +48,73 @@ function SkillsHome() {
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish a skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: true }}
|
||||
className="btn"
|
||||
>
|
||||
Explore search
|
||||
<Link to="/skills" className="btn">
|
||||
Browse skills
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-card hero-search-card fade-up" data-delay="2">
|
||||
<form
|
||||
className="search-bar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!searchMode) setSearchMode(true)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<span className="mono">/</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="search-input"
|
||||
placeholder="Search skills, tags, or capabilities"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onFocus={() => setSearchMode(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !trimmedQuery) {
|
||||
setSearchMode(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="search-filter-button"
|
||||
type="button"
|
||||
aria-pressed={highlightedOnly}
|
||||
onClick={() => {
|
||||
setHighlightedOnly((value) => !value)
|
||||
setSearchMode(true)
|
||||
}}
|
||||
>
|
||||
Highlighted
|
||||
</button>
|
||||
</form>
|
||||
{!searchMode ? (
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="stat">Search skills. Versioned, rollback-ready.</div>
|
||||
<InstallSwitcher exampleSlug="sonoscli" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="stat">Search skills. Versioned, rollback-ready.</div>
|
||||
<InstallSwitcher exampleSlug="sonoscli" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{searchMode ? (
|
||||
<section className="section">
|
||||
<h2 className="section-title">Search results</h2>
|
||||
<p className="section-subtitle">
|
||||
{isSearching ? 'Searching now.' : 'Instant results as you type.'}
|
||||
</p>
|
||||
<div className="grid">
|
||||
{!hasQuery ? (
|
||||
<div className="card">Start typing to search.</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="card">No results yet. Try a different prompt.</div>
|
||||
) : (
|
||||
results.map((result) => (
|
||||
<Link
|
||||
key={result.skill._id}
|
||||
to="/skills/$slug"
|
||||
params={{ slug: result.skill.slug }}
|
||||
className="card"
|
||||
>
|
||||
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
|
||||
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
{result.skill.displayName}
|
||||
</h3>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
{result.skill.summary ?? 'Skill pack'}
|
||||
</p>
|
||||
{result.skill.batch === 'highlighted' ? (
|
||||
<div className="tag">Highlighted</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="section">
|
||||
<h2 className="section-title">Highlighted batch</h2>
|
||||
<p className="section-subtitle">Curated signal — highlighted for quick trust.</p>
|
||||
<div className="grid">
|
||||
{highlighted.length === 0 ? (
|
||||
<div className="card">No highlighted skills yet.</div>
|
||||
) : (
|
||||
highlighted.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge="Highlighted"
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="section">
|
||||
<h2 className="section-title">Highlighted batch</h2>
|
||||
<p className="section-subtitle">Curated signal — highlighted for quick trust.</p>
|
||||
<div className="grid">
|
||||
{highlighted.length === 0 ? (
|
||||
<div className="card">No highlighted skills yet.</div>
|
||||
) : (
|
||||
highlighted.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge="Highlighted"
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title">Latest drops</h2>
|
||||
<p className="section-subtitle">Newest uploads across the registry.</p>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<div className="card">No skills yet. Be the first.</div>
|
||||
) : (
|
||||
latest.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
{skill.stats.versions} versions · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="section-cta">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}}
|
||||
className="btn"
|
||||
>
|
||||
See all skills
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
<section className="section">
|
||||
<h2 className="section-title">Latest drops</h2>
|
||||
<p className="section-subtitle">Newest uploads across the registry.</p>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<div className="card">No skills yet. Be the first.</div>
|
||||
) : (
|
||||
latest.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
{skill.stats.versions} versions · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="section-cta">
|
||||
<Link to="/skills" className="btn">
|
||||
See all skills
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ export const Route = createFileRoute('/search')({
|
||||
}),
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({
|
||||
to: '/',
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: search.q || undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
|
||||
+31
-19
@@ -21,13 +21,34 @@ function parseDir(value: unknown, sort: SortKey): SortDir {
|
||||
return sort === 'name' ? 'asc' : 'desc'
|
||||
}
|
||||
|
||||
type SkillListEntry = {
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
ownerHandle?: string | null
|
||||
}
|
||||
|
||||
type SkillSearchEntry = {
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
ownerHandle?: string | null
|
||||
}
|
||||
|
||||
function buildSkillHref(skill: Doc<'skills'>, ownerHandle?: string | null) {
|
||||
const owner = ownerHandle ?? 'unknown'
|
||||
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/skills/')({
|
||||
validateSearch: (search) => {
|
||||
return {
|
||||
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
|
||||
sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
|
||||
dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
|
||||
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
|
||||
highlighted:
|
||||
search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true
|
||||
? true
|
||||
: undefined,
|
||||
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
|
||||
}
|
||||
},
|
||||
@@ -43,14 +64,10 @@ export function SkillsIndex() {
|
||||
const highlightedOnly = search.highlighted ?? false
|
||||
const [query, setQuery] = useState(search.q ?? '')
|
||||
const searchSkills = useAction(api.search.searchSkills)
|
||||
const [pages, setPages] = useState<
|
||||
Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
|
||||
>([])
|
||||
const [pages, setPages] = useState<Array<SkillListEntry>>([])
|
||||
const [cursor, setCursor] = useState<string | null>(null)
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
|
||||
>([])
|
||||
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([])
|
||||
const [searchLimit, setSearchLimit] = useState(pageSize)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const searchRequest = useRef(0)
|
||||
@@ -65,7 +82,7 @@ export function SkillsIndex() {
|
||||
hasQuery ? 'skip' : { cursor: cursor ?? undefined, limit: pageSize },
|
||||
) as
|
||||
| {
|
||||
items: Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
|
||||
items: Array<SkillListEntry>
|
||||
nextCursor: string | null
|
||||
}
|
||||
| undefined
|
||||
@@ -110,11 +127,7 @@ export function SkillsIndex() {
|
||||
query: trimmedQuery,
|
||||
highlightedOnly,
|
||||
limit: searchLimit,
|
||||
})) as Array<{
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
}>
|
||||
})) as Array<SkillSearchEntry>
|
||||
if (requestId === searchRequest.current) {
|
||||
setSearchResults(data)
|
||||
}
|
||||
@@ -133,6 +146,7 @@ export function SkillsIndex() {
|
||||
return searchResults.map((entry) => ({
|
||||
skill: entry.skill,
|
||||
latestVersion: entry.version,
|
||||
ownerHandle: entry.ownerHandle ?? null,
|
||||
}))
|
||||
}
|
||||
return pages
|
||||
@@ -322,10 +336,12 @@ export function SkillsIndex() {
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill
|
||||
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
|
||||
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
||||
return (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
href={skillHref}
|
||||
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
|
||||
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
@@ -344,13 +360,9 @@ export function SkillsIndex() {
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill
|
||||
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
|
||||
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
||||
return (
|
||||
<Link
|
||||
key={skill._id}
|
||||
className="skills-row"
|
||||
to="/skills/$slug"
|
||||
params={{ slug: skill.slug }}
|
||||
>
|
||||
<Link key={skill._id} className="skills-row" to={skillHref}>
|
||||
<div className="skills-row-main">
|
||||
<div className="skills-row-title">
|
||||
<span>{skill.displayName}</span>
|
||||
|
||||
Reference in New Issue
Block a user