Compare commits

...
5 Commits
Author SHA1 Message Date
Peter Steinberger e790c4d30a fix: skip missing skills in search hydration (#28) (thanks @aaronn) 2026-01-24 21:10:51 +00:00
Aaron bbd517e5b5 fix search 2026-01-24 12:03:14 -08:00
Shadow 54c793a660 fix: handle search embedding errors 2026-01-23 15:48:53 -06:00
Shadow 5d9a89a885 fix search 2026-01-23 15:24:26 -06:00
Peter Steinberger 31e9a57678 feat: add installs/trending sorts 2026-01-19 07:06:46 +00:00
29 changed files with 945 additions and 331 deletions
+10 -1
View File
@@ -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).
+2 -1
View File
@@ -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",
+8
View File
@@ -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;
+14
View File
@@ -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
+8
View File
@@ -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
View File
@@ -2,6 +2,7 @@ import { v } from 'convex/values'
import { zipSync } from 'fflate'
import { api } from './_generated/api'
import { httpAction, mutation } from './_generated/server'
import { 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 })
},
})
+25
View File
@@ -158,6 +158,31 @@ describe('httpApiV1 handlers', () => {
expect(json.items[0].tags.latest).toBe('1.0.0')
})
it('lists skills supports sort aliases', async () => {
const checks: Array<[string, string]> = [
['rating', 'stars'],
['installs', 'installsCurrent'],
['installs-all-time', 'installsAllTime'],
['trending', 'trending'],
]
for (const [input, expected] of checks) {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('sort' in args || 'cursor' in args || 'limit' in args) {
expect(args.sort).toBe(expected)
return { items: [], nextCursor: null }
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.listSkillsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(`https://example.com/api/v1/skills?sort=${input}`),
)
expect(response.status).toBe(200)
}
})
it('get skill returns 404 when missing', async () => {
const runQuery = vi.fn().mockResolvedValue(null)
const runMutation = vi.fn().mockResolvedValue(okRate())
+31 -1
View File
@@ -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)
+39
View File
@@ -0,0 +1,39 @@
import { v } from 'convex/values'
import { internalMutation } from './_generated/server'
import { buildTrendingLeaderboard } from './lib/leaderboards'
const MAX_TRENDING_LIMIT = 200
const KEEP_LEADERBOARD_ENTRIES = 3
export const rebuildTrendingLeaderboardInternal = internalMutation({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT)
const now = Date.now()
const { startDay, endDay, items } = await buildTrendingLeaderboard(ctx, { limit, now })
await ctx.db.insert('skillLeaderboards', {
kind: 'trending',
generatedAt: now,
rangeStartDay: startDay,
rangeEndDay: endDay,
items,
})
const recent = await ctx.db
.query('skillLeaderboards')
.withIndex('by_kind', (q) => q.eq('kind', 'trending'))
.order('desc')
.take(KEEP_LEADERBOARD_ENTRIES + 5)
for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) {
await ctx.db.delete(entry._id)
}
return { ok: true as const, count: items.length }
},
})
function clampInt(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
+103
View File
@@ -0,0 +1,103 @@
import type { Id } from '../_generated/dataModel'
import type { MutationCtx, QueryCtx } from '../_generated/server'
const DAY_MS = 24 * 60 * 60 * 1000
export const TRENDING_DAYS = 7
type LeaderboardEntry = {
skillId: Id<'skills'>
score: number
installs: number
downloads: number
}
export function toDayKey(timestamp: number) {
return Math.floor(timestamp / DAY_MS)
}
export function getTrendingRange(now: number) {
const endDay = toDayKey(now)
const startDay = endDay - (TRENDING_DAYS - 1)
return { startDay, endDay }
}
export async function buildTrendingLeaderboard(
ctx: QueryCtx | MutationCtx,
params: { limit: number; now?: number },
) {
const now = params.now ?? Date.now()
const { startDay, endDay } = getTrendingRange(now)
const rows = await ctx.db
.query('skillDailyStats')
.withIndex('by_day', (q) => q.gte('day', startDay).lte('day', endDay))
.collect()
const totals = new Map<Id<'skills'>, { installs: number; downloads: number }>()
for (const row of rows) {
const current = totals.get(row.skillId) ?? { installs: 0, downloads: 0 }
current.installs += row.installs
current.downloads += row.downloads
totals.set(row.skillId, current)
}
const entries = Array.from(totals, ([skillId, totalsEntry]) => ({
skillId,
installs: totalsEntry.installs,
downloads: totalsEntry.downloads,
score: totalsEntry.installs,
}))
const items = topN(entries, params.limit, compareTrendingEntries).sort((a, b) =>
compareTrendingEntries(b, a),
)
return { startDay, endDay, items }
}
function compareTrendingEntries(a: LeaderboardEntry, b: LeaderboardEntry) {
if (a.score !== b.score) return a.score - b.score
if (a.downloads !== b.downloads) return a.downloads - b.downloads
return 0
}
function topN<T>(entries: T[], limit: number, compare: (a: T, b: T) => number) {
if (entries.length <= limit) return entries.slice()
const heap: T[] = []
for (const entry of entries) {
if (heap.length < limit) {
heap.push(entry)
siftUp(heap, heap.length - 1, compare)
continue
}
if (compare(entry, heap[0]) <= 0) continue
heap[0] = entry
siftDown(heap, 0, compare)
}
return heap
}
function siftUp<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
let current = index
while (current > 0) {
const parent = Math.floor((current - 1) / 2)
if (compare(heap[current], heap[parent]) >= 0) break
;[heap[current], heap[parent]] = [heap[parent], heap[current]]
current = parent
}
}
function siftDown<T>(heap: T[], index: number, compare: (a: T, b: T) => number) {
let current = index
const length = heap.length
while (true) {
const left = current * 2 + 1
const right = current * 2 + 2
let smallest = current
if (left < length && compare(heap[left], heap[smallest]) < 0) smallest = left
if (right < length && compare(heap[right], heap[smallest]) < 0) smallest = right
if (smallest === current) break
;[heap[current], heap[smallest]] = [heap[smallest], heap[current]]
current = smallest
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { Doc, Id } from '../_generated/dataModel'
import type { MutationCtx } from '../_generated/server'
import { toDayKey } from './leaderboards'
type SkillStatDeltas = {
downloads?: number
stars?: number
installsCurrent?: number
installsAllTime?: number
}
export function applySkillStatDeltas(skill: Doc<'skills'>, deltas: SkillStatDeltas) {
const currentDownloads =
typeof skill.statsDownloads === 'number' ? skill.statsDownloads : skill.stats.downloads
const currentStars = typeof skill.statsStars === 'number' ? skill.statsStars : skill.stats.stars
const currentInstallsCurrent =
typeof skill.statsInstallsCurrent === 'number'
? skill.statsInstallsCurrent
: (skill.stats.installsCurrent ?? 0)
const currentInstallsAllTime =
typeof skill.statsInstallsAllTime === 'number'
? skill.statsInstallsAllTime
: (skill.stats.installsAllTime ?? 0)
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0))
const nextStars = Math.max(0, currentStars + (deltas.stars ?? 0))
const nextInstallsCurrent = Math.max(0, currentInstallsCurrent + (deltas.installsCurrent ?? 0))
const nextInstallsAllTime = Math.max(0, currentInstallsAllTime + (deltas.installsAllTime ?? 0))
return {
statsDownloads: nextDownloads,
statsStars: nextStars,
statsInstallsCurrent: nextInstallsCurrent,
statsInstallsAllTime: nextInstallsAllTime,
stats: {
...skill.stats,
downloads: nextDownloads,
stars: nextStars,
installsCurrent: nextInstallsCurrent,
installsAllTime: nextInstallsAllTime,
},
}
}
export async function bumpDailySkillStats(
ctx: MutationCtx,
params: {
skillId: Id<'skills'>
now: number
downloads?: number
installs?: number
},
) {
const downloads = params.downloads ?? 0
const installs = params.installs ?? 0
if (downloads === 0 && installs === 0) return
const day = toDayKey(params.now)
const existing = await ctx.db
.query('skillDailyStats')
.withIndex('by_skill_day', (q) => q.eq('skillId', params.skillId).eq('day', day))
.unique()
if (existing) {
await ctx.db.patch(existing._id, {
downloads: Math.max(0, existing.downloads + downloads),
installs: Math.max(0, existing.installs + installs),
updatedAt: params.now,
})
return
}
await ctx.db.insert('skillDailyStats', {
skillId: params.skillId,
day,
downloads: Math.max(0, downloads),
installs: Math.max(0, installs),
updatedAt: params.now,
})
}
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
})
+180
View File
@@ -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
View File
@@ -4,6 +4,7 @@ import type { Id } from './_generated/dataModel'
import type { MutationCtx, QueryCtx } from './_generated/server'
import { internalMutation, mutation, query } from './_generated/server'
import { requireUser } from './lib/access'
import { 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
View File
@@ -30,7 +30,8 @@ Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Ret
Public read:
- `GET /api/v1/search?q=...`
- `GET /api/v1/skills?limit=&cursor=`
- `GET /api/v1/skills?limit=&cursor=&sort=`
- `sort`: `updated` (default), `downloads`, `stars` (`rating`), `installsCurrent` (`installs`), `installsAllTime`, `trending`
- `GET /api/v1/skills/{slug}`
- `GET /api/v1/skills/{slug}/versions?limit=&cursor=`
- `GET /api/v1/skills/{slug}/versions/{version}`
+3 -1
View File
@@ -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>` (150, default: 25)
- `--limit <n>` (1200, default: 25)
- `--sort newest|downloads|rating|installs|installsAllTime|trending` (default: newest)
- `--json` (machine-readable output)
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
### `install <slug>`
+7 -2
View File
@@ -44,8 +44,13 @@ Response:
Query params:
- `limit` (optional): integer
- `cursor` (optional): pagination cursor
- `limit` (optional): integer (1200)
- `cursor` (optional): pagination cursor (only for `sort=updated`)
- `sort` (optional): `updated` (default), `downloads`, `stars` (alias: `rating`), `installsCurrent` (alias: `installs`), `installsAllTime`, `trending`
Notes:
- `trending` ranks by installs in the last 7 days (telemetry-based).
Response:
+3 -2
View File
@@ -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",
+8 -2
View File
@@ -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')
})
})
+52 -3
View File
@@ -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)
+4 -6
View File
@@ -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,
},
+5 -2
View File
@@ -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
View File
@@ -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>
)
}
+1 -2
View File
@@ -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
View File
@@ -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>