Compare commits

...
Author SHA1 Message Date
Peter Steinberger edd83fdf85 test(comments): add updatedAt invalidation regression coverage 2026-02-14 01:50:03 +01:00
Seth RaphaelandClaude Opus 4.5 d8c7250cf2 fix(comments): stop updating skills.updatedAt on comment add/remove
Comments are not content changes, so they shouldn't invalidate skill
list queries that depend on updatedAt. This reduces query invalidation
when users add or remove comments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:15:44 -08:00
Aaron NgandPeter Steinberger a2c46fbb5d Search Fixes (#30)
* more search fixes

* update tests

* comments

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

---------

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

* fix: stabilize publish paths and token types

* feat: show published skills on user profile

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

---------

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

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

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

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

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

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

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

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

* more inclusive token check

* Update convex/lib/searchText.ts

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

---------

Co-authored-by: Ahmed <ahmed.mire@kaluza.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
2026-01-24 21:23:03 +00:00
Peter Steinberger decce1d35c fix: skip missing skills in search hydration (#28) (thanks @aaronn) 2026-01-24 21:11:46 +00:00
Aaron Ng 468832af3f fix search (#28) 2026-01-24 21:11:06 +00:00
Shadow 54c793a660 fix: handle search embedding errors 2026-01-23 15:48:53 -06:00
Shadow 5d9a89a885 fix search 2026-01-23 15:24:26 -06:00
Peter Steinberger 31e9a57678 feat: add installs/trending sorts 2026-01-19 07:06:46 +00:00
44 changed files with 1436 additions and 502 deletions
+15 -1
View File
@@ -1,9 +1,23 @@
# Changelog
## 0.2.1 - Unreleased
## Unreleased
### Added
- Web: show published skills on user profiles (thanks @njoylab, #20).
### Fixed
- Registry: drop missing skills during search hydration (thanks @aaronn, #28).
- CLI: use path-based skill metadata lookup for updates (thanks @daveonkels, #22).
- Search: keep highlighted-only filtering and clamp vector candidates to Convex limits (thanks @aaronn, #30).
## 0.3.0 - 2026-01-19
### Added
- CLI: add `explore` command for latest updates, with limit clamping + tests/docs (thanks @jdrhyne, #14).
- CLI: `explore --json` output + new sorts (`installs`, `installsAllTime`, `trending`) and limit up to 200.
- API: `/api/v1/skills` supports installs + trending sorts (7-day installs).
- API: idempotent `POST/DELETE /api/v1/stars/{slug}` endpoints.
- Registry: trending leaderboard + daily stats backfill for installs-based sorts.
### Fixed
- Web: keep search mode navigation and state in sync (thanks @NACC96, #12).
+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;
+76
View File
@@ -0,0 +1,76 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('./lib/access', () => ({
assertRole: vi.fn(),
requireUser: vi.fn(),
}))
const { requireUser } = await import('./lib/access')
const { __test } = await import('./comments')
describe('comments mutations', () => {
afterEach(() => {
vi.mocked(requireUser).mockReset()
})
it('add updates comment count without touching updatedAt', async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: 'users:1',
user: { _id: 'users:1', role: 'user' },
} as never)
const get = vi.fn().mockResolvedValue({
_id: 'skills:1',
stats: { comments: 2 },
})
const insert = vi.fn()
const patch = vi.fn()
const ctx = { db: { get, insert, patch } } as never
await __test.addHandler(ctx, { skillId: 'skills:1', body: ' hello ' } as never)
expect(patch).toHaveBeenCalledTimes(1)
expect(patch).toHaveBeenCalledWith('skills:1', {
stats: { comments: 3 },
})
const skillPatch = vi.mocked(patch).mock.calls[0]?.[1] as Record<string, unknown>
expect(skillPatch.updatedAt).toBeUndefined()
})
it('remove updates comment count without touching updatedAt', async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: 'users:2',
user: { _id: 'users:2', role: 'moderator' },
} as never)
const comment = {
_id: 'comments:1',
skillId: 'skills:1',
userId: 'users:2',
softDeletedAt: undefined,
}
const skill = {
_id: 'skills:1',
stats: { comments: 4 },
}
const get = vi.fn(async (id: string) => {
if (id === 'comments:1') return comment
if (id === 'skills:1') return skill
return null
})
const insert = vi.fn()
const patch = vi.fn()
const ctx = { db: { get, insert, patch } } as never
await __test.removeHandler(ctx, { commentId: 'comments:1' } as never)
expect(patch).toHaveBeenCalledTimes(2)
expect(patch).toHaveBeenNthCalledWith(2, 'skills:1', {
stats: { comments: 3 },
})
const skillPatch = vi.mocked(patch).mock.calls[1]?.[1] as Record<string, unknown>
expect(skillPatch.updatedAt).toBeUndefined()
})
})
+68 -55
View File
@@ -3,6 +3,67 @@ import type { Doc } from './_generated/dataModel'
import { mutation, query } from './_generated/server'
import { assertRole, requireUser } from './lib/access'
async function addHandler(
ctx: import('./_generated/server').MutationCtx,
args: { skillId: import('./_generated/dataModel').Id<'skills'>; body: string },
) {
const { userId } = await requireUser(ctx)
const body = args.body.trim()
if (!body) throw new Error('Comment body required')
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await ctx.db.insert('comments', {
skillId: args.skillId,
userId,
body,
createdAt: Date.now(),
softDeletedAt: undefined,
deletedBy: undefined,
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
})
}
async function removeHandler(
ctx: import('./_generated/server').MutationCtx,
args: { commentId: import('./_generated/dataModel').Id<'comments'> },
) {
const { user } = await requireUser(ctx)
const comment = await ctx.db.get(args.commentId)
if (!comment) throw new Error('Comment not found')
if (comment.softDeletedAt) return
const isOwner = comment.userId === user._id
if (!isOwner) {
assertRole(user, ['admin', 'moderator'])
}
await ctx.db.patch(comment._id, {
softDeletedAt: Date.now(),
deletedBy: user._id,
})
const skill = await ctx.db.get(comment.skillId)
if (skill) {
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
})
}
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'comment.delete',
targetType: 'comment',
targetId: comment._id,
metadata: { skillId: comment.skillId },
createdAt: Date.now(),
})
}
export const listBySkill = query({
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
@@ -25,63 +86,15 @@ export const listBySkill = query({
export const add = mutation({
args: { skillId: v.id('skills'), body: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const body = args.body.trim()
if (!body) throw new Error('Comment body required')
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await ctx.db.insert('comments', {
skillId: args.skillId,
userId,
body,
createdAt: Date.now(),
softDeletedAt: undefined,
deletedBy: undefined,
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
updatedAt: Date.now(),
})
},
handler: addHandler,
})
export const remove = mutation({
args: { commentId: v.id('comments') },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
const comment = await ctx.db.get(args.commentId)
if (!comment) throw new Error('Comment not found')
if (comment.softDeletedAt) return
const isOwner = comment.userId === user._id
if (!isOwner) {
assertRole(user, ['admin', 'moderator'])
}
await ctx.db.patch(comment._id, {
softDeletedAt: Date.now(),
deletedBy: user._id,
})
const skill = await ctx.db.get(comment.skillId)
if (skill) {
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
updatedAt: Date.now(),
})
}
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'comment.delete',
targetType: 'comment',
targetId: comment._id,
metadata: { skillId: comment.skillId },
createdAt: Date.now(),
})
},
handler: removeHandler,
})
export const __test = {
addHandler,
removeHandler,
}
+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
+71 -41
View File
@@ -1,5 +1,6 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { ActionCtx } from './_generated/server'
import { internalAction, internalMutation } from './_generated/server'
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
import { parseClawdisMetadata, parseFrontmatter } from './lib/skills'
@@ -13,6 +14,17 @@ type SeedSkillSpec = {
rawSkillMd: string
}
type SeedActionArgs = {
reset?: boolean
}
type SeedActionResult = {
ok: true
results: Array<Record<string, unknown> & { slug: string }>
}
type SeedMutationResult = Record<string, unknown>
const SEED_SKILLS: SeedSkillSpec[] = [
{
slug: 'padel',
@@ -237,53 +249,19 @@ function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
)}${rawSkillMd.slice(frontmatterEnd)}`
}
export const seedNixSkills = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const results = []
for (const spec of SEED_SKILLS) {
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
const frontmatter = parseFrontmatter(skillMd)
const clawdis = parseClawdisMetadata(frontmatter)
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
const result = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
frontmatter,
clawdis,
skillMd,
slug: spec.slug,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
})
results.push({ slug: spec.slug, ...result })
}
return { ok: true, results }
},
})
export const seedPadelSkill = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
if (!spec) throw new Error('padel seed spec missing')
async function seedNixSkillsHandler(
ctx: ActionCtx,
args: SeedActionArgs,
): Promise<SeedActionResult> {
const results: Array<Record<string, unknown> & { slug: string }> = []
for (const spec of SEED_SKILLS) {
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
const frontmatter = parseFrontmatter(skillMd)
const clawdis = parseClawdisMetadata(frontmatter)
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
return ctx.runMutation(internal.devSeed.seedSkillMutation, {
const result: SeedMutationResult = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
@@ -295,7 +273,51 @@ export const seedPadelSkill = internalAction({
summary: spec.summary,
version: spec.version,
})
results.push({ slug: spec.slug, ...result })
}
return { ok: true, results }
}
export const seedNixSkills: ReturnType<typeof internalAction> = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: seedNixSkillsHandler,
})
async function seedPadelSkillHandler(
ctx: ActionCtx,
args: SeedActionArgs,
): Promise<SeedMutationResult> {
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
if (!spec) throw new Error('padel seed spec missing')
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
const frontmatter = parseFrontmatter(skillMd)
const clawdis = parseClawdisMetadata(frontmatter)
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
return (await ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
frontmatter,
clawdis,
skillMd,
slug: spec.slug,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
})) as SeedMutationResult
}
export const seedPadelSkill: ReturnType<typeof internalAction> = internalAction({
args: {
reset: v.optional(v.boolean()),
},
handler: seedPadelSkillHandler,
})
export const seedSkillMutation = internalMutation({
@@ -364,6 +386,10 @@ export const seedSkillMutation = internalMutation({
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: {
downloads: 0,
installsCurrent: 0,
@@ -413,6 +439,10 @@ export const seedSkillMutation = internalMutation({
await ctx.db.patch(skillId, {
latestVersionId: versionId,
tags: { latest: versionId },
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: {
downloads: 0,
installsCurrent: 0,
+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 })
},
})
+22 -7
View File
@@ -11,7 +11,7 @@ vi.mock('./skills', () => ({
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
const { publishVersionForUser } = await import('./skills')
const { __handlers, cliSkillDeleteHttp, cliSkillUndeleteHttp } = await import('./httpApi')
const { __handlers } = await import('./httpApi')
const { hashSkillFiles } = await import('./lib/skills')
function makeCtx(partial: Record<string, unknown>) {
@@ -33,7 +33,7 @@ describe('httpApi handlers', () => {
expect(await response.json()).toEqual({ results: [] })
})
it('searchSkillsHttp forwards args', async () => {
it('searchSkillsHttp forwards args (approvedOnly alias)', async () => {
const runAction = vi.fn().mockResolvedValue([
{
score: 1,
@@ -48,14 +48,27 @@ describe('httpApi handlers', () => {
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: 5,
approvedOnly: true,
highlightedOnly: true,
})
expect(response.status).toBe(200)
const json = await response.json()
expect(json.results[0].slug).toBe('a')
})
it('searchSkillsHttp omits approvedOnly when false', async () => {
it('searchSkillsHttp forwards highlightedOnly', async () => {
const runAction = vi.fn().mockResolvedValue([])
await __handlers.searchSkillsHandler(
makeCtx({ runAction }),
new Request('https://example.com/api/search?q=test&highlightedOnly=true'),
)
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: undefined,
highlightedOnly: true,
})
})
it('searchSkillsHttp omits highlightedOnly when approvedOnly is false', async () => {
const runAction = vi.fn().mockResolvedValue([])
await __handlers.searchSkillsHandler(
makeCtx({ runAction }),
@@ -64,7 +77,7 @@ describe('httpApi handlers', () => {
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: undefined,
approvedOnly: undefined,
highlightedOnly: undefined,
})
})
@@ -416,13 +429,14 @@ describe('httpApi handlers', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const runMutation = vi.fn().mockResolvedValue({ ok: true })
const response = await cliSkillUndeleteHttp(
const response = await __handlers.cliSkillDeleteHandler(
makeCtx({ runMutation }),
new Request('https://x/api/cli/skill/undelete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'demo' }),
}),
false,
)
expect(response.status).toBe(200)
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
@@ -437,13 +451,14 @@ describe('httpApi handlers', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const runMutation = vi.fn().mockResolvedValue({ ok: true })
const response = await cliSkillDeleteHttp(
const response = await __handlers.cliSkillDeleteHandler(
makeCtx({ runMutation }),
new Request('https://x/api/cli/skill/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'demo' }),
}),
true,
)
expect(response.status).toBe(200)
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
+2 -1
View File
@@ -44,13 +44,14 @@ async function searchSkillsHandler(ctx: ActionCtx, request: Request) {
const query = url.searchParams.get('q')?.trim() ?? ''
const limit = toOptionalNumber(url.searchParams.get('limit'))
const approvedOnly = url.searchParams.get('approvedOnly') === 'true'
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true' || approvedOnly
if (!query) return json({ results: [] })
const results = (await ctx.runAction(api.search.searchSkills, {
query,
limit,
approvedOnly: approvedOnly || undefined,
highlightedOnly: highlightedOnly || undefined,
})) as SearchSkillEntry[]
return json({
+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())
+38 -5
View File
@@ -44,6 +44,9 @@ type ListSkillsResult = {
nextCursor: string | null
}
type SkillFile = Doc<'skillVersions'>['files'][number]
type SoulFile = Doc<'soulVersions'>['files'][number]
type GetBySlugResult = {
skill: {
_id: Id<'skills'>
@@ -191,11 +194,14 @@ async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
const sort = parseListSort(url.searchParams.get('sort'))
const cursor = sort === 'updated' ? rawCursor : undefined
const result = (await ctx.runQuery(api.skills.listPublicPage, {
limit,
cursor,
sort,
})) as ListSkillsResult
const items = await Promise.all(
@@ -315,7 +321,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file) => ({
files: version.files.map((file: SkillFile) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
@@ -753,9 +759,36 @@ function toOptionalNumber(value: string | null) {
return Number.isFinite(parsed) ? parsed : undefined
}
type SkillListSort =
| 'updated'
| 'downloads'
| 'stars'
| 'installsCurrent'
| 'installsAllTime'
| 'trending'
function parseListSort(value: string | null): SkillListSort {
const normalized = value?.trim().toLowerCase()
if (normalized === 'downloads') return 'downloads'
if (normalized === 'stars' || normalized === 'rating') return 'stars'
if (
normalized === 'installs' ||
normalized === 'install' ||
normalized === 'installscurrent' ||
normalized === 'installs-current'
) {
return 'installsCurrent'
}
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
return 'installsAllTime'
}
if (normalized === 'trending') return 'trending'
return 'updated'
}
async function sha256Hex(bytes: Uint8Array) {
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
const digest = await crypto.subtle.digest('SHA-256', buffer)
const data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
return toHex(new Uint8Array(digest))
}
@@ -895,7 +928,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file) => ({
files: version.files.map((file: SoulFile) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
+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
}
}
+16 -2
View File
@@ -13,12 +13,26 @@ describe('searchText', () => {
])
})
it('matchesExactTokens requires all query tokens', () => {
it('matchesExactTokens requires at least one query token to prefix-match', () => {
const queryTokens = tokenize('Remind Me')
expect(matchesExactTokens(queryTokens, ['Remind Me', '/remind-me', 'Short summary'])).toBe(true)
// "Reminder" starts with "remind", so it matches with prefix matching
expect(matchesExactTokens(queryTokens, ['Reminder tool', '/reminder', 'Short summary'])).toBe(
false,
true,
)
// Matches because "remind" token is present
expect(matchesExactTokens(queryTokens, ['Remind tool', '/remind', 'Short summary'])).toBe(true)
// No matching tokens at all
expect(matchesExactTokens(queryTokens, ['Other tool', '/other', 'Short summary'])).toBe(false)
})
it('matchesExactTokens supports prefix matching for partial queries', () => {
// "go" should match "gohome" because "gohome" starts with "go"
expect(matchesExactTokens(['go'], ['GoHome', '/gohome', 'Navigate home'])).toBe(true)
// "pad" should match "padel"
expect(matchesExactTokens(['pad'], ['Padel', '/padel', 'Tennis-like sport'])).toBe(true)
// "xyz" should not match anything
expect(matchesExactTokens(['xyz'], ['GoHome', '/gohome', 'Navigate home'])).toBe(false)
})
it('matchesExactTokens ignores empty inputs', () => {
+4 -2
View File
@@ -18,8 +18,10 @@ export function matchesExactTokens(
if (!text) return false
const textTokens = tokenize(text)
if (textTokens.length === 0) return false
const textSet = new Set(textTokens)
return queryTokens.every((token) => textSet.has(token))
// Require at least one token to prefix-match, allowing vector similarity to determine relevance
return queryTokens.some((queryToken) =>
textTokens.some((textToken) => textToken.includes(queryToken)),
)
}
export const __test = { normalize, tokenize, matchesExactTokens }
+13 -9
View File
@@ -75,16 +75,20 @@ export async function publishVersionForUser(
if (sanitizedFiles.some((file) => !file.path)) {
throw new ConvexError('Invalid file paths')
}
if (sanitizedFiles.some((file) => !isTextFile(file.path ?? '', file.contentType ?? undefined))) {
const safeFiles = sanitizedFiles.map((file) => ({
...file,
path: file.path as string,
}))
if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
throw new ConvexError('Only text-based files are allowed')
}
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
if (totalBytes > MAX_TOTAL_BYTES) {
throw new ConvexError('Skill bundle exceeds 50MB limit')
}
const readmeFile = sanitizedFiles.find(
const readmeFile = safeFiles.find(
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
)
if (!readmeFile) throw new ConvexError('SKILL.md is required')
@@ -95,7 +99,7 @@ export async function publishVersionForUser(
const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
const otherFiles = [] as Array<{ path: string; content: string }>
for (const file of sanitizedFiles) {
for (const file of safeFiles) {
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
const content = await fetchText(ctx, file.storageId)
@@ -110,7 +114,7 @@ export async function publishVersionForUser(
})
const fingerprintPromise = hashSkillFiles(
sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
)
const changelogPromise =
@@ -120,7 +124,7 @@ export async function publishVersionForUser(
slug,
version,
readmeText,
files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
})
const embeddingPromise = generateEmbedding(embeddingText)
@@ -148,9 +152,9 @@ export async function publishVersionForUser(
version: args.forkOf.version?.trim() || undefined,
}
: undefined,
files: sanitizedFiles.map((file) => ({
files: safeFiles.map((file) => ({
...file,
path: file.path ?? '',
path: file.path,
})),
parsed: {
frontmatter,
@@ -169,7 +173,7 @@ export async function publishVersionForUser(
version,
displayName,
ownerHandle,
files: sanitizedFiles,
files: safeFiles,
publishedAt: Date.now(),
})
.catch((error) => {
+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,
+45 -15
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,10 +30,17 @@ export const searchSkills: ReturnType<typeof action> = action({
if (!query) return []
const queryTokens = tokenize(query)
if (queryTokens.length === 0) return []
const vector = await generateEmbedding(query)
let vector: number[]
try {
vector = await generateEmbedding(query)
} catch (error) {
console.warn('Search embedding generation failed', error)
return []
}
const limit = args.limit ?? 10
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
let hydrated: HydratedEntry[] = []
let scoreById = new Map<Id<'skillEmbeddings'>, number>()
let exactMatches: HydratedEntry[] = []
@@ -86,18 +94,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,10 +142,17 @@ export const searchSouls: ReturnType<typeof action> = action({
if (!query) return []
const queryTokens = tokenize(query)
if (queryTokens.length === 0) return []
const vector = await generateEmbedding(query)
let vector: number[]
try {
vector = await generateEmbedding(query)
} catch (error) {
console.warn('Search embedding generation failed', error)
return []
}
const limit = args.limit ?? 10
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
let hydrated: HydratedSoulEntry[] = []
let scoreById = new Map<Id<'soulEmbeddings'>, number>()
let exactMatches: HydratedSoulEntry[] = []
+2 -1
View File
@@ -242,7 +242,8 @@ export const ensureSeedUserInternal = internalMutation({
})
async function sha256Hex(bytes: Uint8Array) {
const digest = await crypto.subtle.digest('SHA-256', bytes)
const data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
return toHex(new Uint8Array(digest))
}
+126 -17
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) => {
@@ -130,9 +165,10 @@ export const listWithLatest = query({
.order('desc')
.take(takeLimit)
} else if (args.ownerUserId) {
const ownerUserId = args.ownerUserId
entries = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
.withIndex('by_owner', (q) => q.eq('ownerUserId', ownerUserId))
.order('desc')
.take(takeLimit)
} else {
@@ -154,30 +190,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 +692,10 @@ export const insertVersion = internalMutation({
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: {
downloads: 0,
installsCurrent: 0,
+1 -1
View File
@@ -377,7 +377,7 @@ export const insertVersion = internalMutation({
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.order('desc')
.take(2)
let soul = soulMatches[0] ?? null
let soul: Doc<'souls'> | null = soulMatches[0] ?? null
if (soul && soul.ownerUserId !== userId) {
throw new Error('Only the owner can publish updates')
+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(),
})
+205
View File
@@ -0,0 +1,205 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc } from './_generated/dataModel'
import type { ActionCtx } from './_generated/server'
import { internalAction, internalMutation, internalQuery } from './_generated/server'
const DEFAULT_BATCH_SIZE = 200
const MAX_BATCH_SIZE = 1000
const DEFAULT_MAX_BATCHES = 5
const MAX_MAX_BATCHES = 50
const BACKFILL_STATE_KEY = 'default'
export const backfillSkillStatFieldsInternal = internalMutation({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
const { page, isDone, continueCursor } = await ctx.db
.query('skills')
.order('asc')
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
let patched = 0
for (const skill of page) {
const next = buildSkillStatPatch(skill)
if (!next) continue
await ctx.db.patch(skill._id, next)
patched += 1
}
return {
ok: true as const,
scanned: page.length,
patched,
cursor: isDone ? null : continueCursor,
isDone,
}
},
})
type BackfillState = {
cursor: string | null
doneAt?: number
}
type BackfillActionArgs = {
batchSize?: number
maxBatches?: number
resetCursor?: boolean
}
type BackfillStats = {
scanned: number
patched: number
batches: number
}
type BackfillActionResult = {
ok: true
isDone: boolean
cursor: string | null
stats: BackfillStats
}
export const getSkillStatBackfillStateInternal = internalQuery({
args: {},
handler: async (ctx): Promise<BackfillState> => {
const state = await ctx.db
.query('skillStatBackfillState')
.withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
.unique()
return { cursor: state?.cursor ?? null, doneAt: state?.doneAt }
},
})
export const setSkillStatBackfillStateInternal = internalMutation({
args: {
cursor: v.optional(v.string()),
doneAt: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now()
const state = await ctx.db
.query('skillStatBackfillState')
.withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
.unique()
if (!state) {
await ctx.db.insert('skillStatBackfillState', {
key: BACKFILL_STATE_KEY,
cursor: args.cursor,
doneAt: args.doneAt,
updatedAt: now,
})
return { ok: true as const }
}
await ctx.db.patch(state._id, {
cursor: args.cursor,
doneAt: args.doneAt,
updatedAt: now,
})
return { ok: true as const }
},
})
async function runSkillStatBackfillInternalHandler(
ctx: ActionCtx,
args: BackfillActionArgs,
): Promise<BackfillActionResult> {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
if (args.resetCursor) {
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
cursor: undefined,
doneAt: undefined,
})
}
const state = (await ctx.runQuery(
internal.statsMaintenance.getSkillStatBackfillStateInternal,
{},
)) as BackfillState
if (state.doneAt && !args.resetCursor) {
return {
ok: true,
isDone: true,
cursor: null,
stats: { scanned: 0, patched: 0, batches: 0 },
}
}
let cursor: string | null = state.cursor ?? null
const stats: BackfillStats = { scanned: 0, patched: 0, batches: 0 }
for (let i = 0; i < maxBatches; i += 1) {
const result = (await ctx.runMutation(
internal.statsMaintenance.backfillSkillStatFieldsInternal,
{
cursor: cursor ?? undefined,
batchSize,
},
)) as { scanned: number; patched: number; cursor: string | null; isDone: boolean }
stats.scanned += result.scanned
stats.patched += result.patched
stats.batches += 1
cursor = result.cursor
if (result.isDone) {
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
cursor: undefined,
doneAt: Date.now(),
})
return { ok: true, isDone: true, cursor: null, stats }
}
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
cursor: cursor ?? undefined,
doneAt: undefined,
})
}
return { ok: true, isDone: false, cursor, stats }
}
export const runSkillStatBackfillInternal: ReturnType<typeof internalAction> = internalAction({
args: {
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
resetCursor: v.optional(v.boolean()),
},
handler: runSkillStatBackfillInternalHandler,
})
function buildSkillStatPatch(skill: Doc<'skills'>) {
const stats = skill.stats
const nextDownloads = stats.downloads
const nextStars = stats.stars
const nextInstallsCurrent = stats.installsCurrent ?? 0
const nextInstallsAllTime = stats.installsAllTime ?? 0
if (
skill.statsDownloads === nextDownloads &&
skill.statsStars === nextStars &&
skill.statsInstallsCurrent === nextInstallsCurrent &&
skill.statsInstallsAllTime === nextInstallsAllTime
) {
return null
}
return {
statsDownloads: nextDownloads,
statsStars: nextStars,
statsInstallsCurrent: nextInstallsCurrent,
statsInstallsAllTime: nextInstallsAllTime,
}
}
function clampInt(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
+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
@@ -1,11 +1,14 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiRoutes } from '../../schema/index.js'
import type { GlobalOpts } from '../types'
const mockApiRequest = vi.fn()
const mockDownloadZip = vi.fn()
vi.mock('../../http.js', () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
downloadZip: (...args: unknown[]) => mockDownloadZip(...args),
}))
const mockGetRegistry = vi.fn(async () => 'https://clawdhub.com')
@@ -13,13 +16,51 @@ vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const mockSpinner = { stop: vi.fn(), fail: vi.fn() }
const mockSpinner = {
stop: vi.fn(),
fail: vi.fn(),
start: vi.fn(),
succeed: vi.fn(),
isSpinning: false,
text: '',
}
vi.mock('../ui.js', () => ({
createSpinner: vi.fn(() => mockSpinner),
fail: (message: string) => {
throw new Error(message)
},
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
isInteractive: () => false,
promptConfirm: vi.fn(async () => false),
}))
const { clampLimit, cmdExplore, formatExploreLine } = await import('./skills')
vi.mock('../../skills.js', () => ({
extractZipToDir: vi.fn(),
hashSkillFiles: vi.fn(),
listTextFiles: vi.fn(),
readLockfile: vi.fn(),
readSkillOrigin: vi.fn(),
writeLockfile: vi.fn(),
writeSkillOrigin: vi.fn(),
}))
vi.mock('node:fs/promises', () => ({
mkdir: vi.fn(),
rm: vi.fn(),
stat: vi.fn(),
}))
const { clampLimit, cmdExplore, cmdUpdate, formatExploreLine } = await import('./skills')
const {
extractZipToDir,
hashSkillFiles,
listTextFiles,
readLockfile,
readSkillOrigin,
writeLockfile,
writeSkillOrigin,
} = await import('../../skills.js')
const { rm, stat } = await import('node:fs/promises')
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -43,7 +84,9 @@ describe('explore helpers', () => {
expect(clampLimit(0)).toBe(1)
expect(clampLimit(1)).toBe(1)
expect(clampLimit(50)).toBe(50)
expect(clampLimit(99)).toBe(50)
expect(clampLimit(99)).toBe(99)
expect(clampLimit(200)).toBe(200)
expect(clampLimit(250)).toBe(200)
expect(clampLimit(Number.NaN)).toBe(25)
expect(clampLimit(Number.POSITIVE_INFINITY)).toBe(25)
expect(clampLimit(Number.NaN, 10)).toBe(10)
@@ -68,7 +111,7 @@ describe('cmdExplore', () => {
it('clamps limit and handles empty results', async () => {
mockApiRequest.mockResolvedValue({ items: [] })
await cmdExplore(makeOpts(), 0)
await cmdExplore(makeOpts(), { limit: 0 })
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
@@ -87,12 +130,62 @@ describe('cmdExplore', () => {
}
mockApiRequest.mockResolvedValue({ items: [item] })
await cmdExplore(makeOpts(), 100)
await cmdExplore(makeOpts(), { limit: 250 })
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
expect(url.searchParams.get('limit')).toBe('50')
expect(url.searchParams.get('limit')).toBe('200')
expect(mockLog).toHaveBeenCalledWith(formatExploreLine(item))
nowSpy.mockRestore()
})
it('supports sort and json output', async () => {
const payload = { items: [], nextCursor: null }
mockApiRequest.mockResolvedValue(payload)
await cmdExplore(makeOpts(), { limit: 10, sort: 'installs', json: true })
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
expect(url.searchParams.get('limit')).toBe('10')
expect(url.searchParams.get('sort')).toBe('installsCurrent')
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(payload, null, 2))
})
it('supports all-time installs and trending sorts', async () => {
mockApiRequest.mockResolvedValue({ items: [], nextCursor: null })
await cmdExplore(makeOpts(), { limit: 5, sort: 'installsAllTime' })
await cmdExplore(makeOpts(), { limit: 5, sort: 'trending' })
const first = new URL(String(mockApiRequest.mock.calls[0]?.[1]?.url))
const second = new URL(String(mockApiRequest.mock.calls[1]?.[1]?.url))
expect(first.searchParams.get('sort')).toBe('installsAllTime')
expect(second.searchParams.get('sort')).toBe('trending')
})
})
describe('cmdUpdate', () => {
it('uses path-based skill lookup when no local fingerprint is available', async () => {
mockApiRequest.mockResolvedValue({ latestVersion: { version: '1.0.0' } })
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]))
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: { demo: { version: '0.1.0', installedAt: 123 } },
})
vi.mocked(writeLockfile).mockResolvedValue()
vi.mocked(readSkillOrigin).mockResolvedValue(null)
vi.mocked(writeSkillOrigin).mockResolvedValue()
vi.mocked(extractZipToDir).mockResolvedValue()
vi.mocked(listTextFiles).mockResolvedValue([])
vi.mocked(hashSkillFiles).mockReturnValue({ fingerprint: 'hash', files: [] })
vi.mocked(stat).mockRejectedValue(new Error('missing'))
vi.mocked(rm).mockResolvedValue()
await cmdUpdate(makeOpts(), 'demo', {}, false)
const [, args] = mockApiRequest.mock.calls[0] ?? []
expect(args?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent('demo')}`)
expect(args?.url).toBeUndefined()
})
})
+53 -4
View File
@@ -153,7 +153,7 @@ export async function cmdUpdate(
} else {
const meta = await apiRequest(
registry,
{ method: 'GET', url: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
ApiV1SkillResponseSchema,
)
resolveResult = { match: null, latestVersion: meta.latestVersion ?? null }
@@ -242,13 +242,27 @@ export async function cmdList(opts: GlobalOpts) {
}
}
export async function cmdExplore(opts: GlobalOpts, limit = 25) {
type ExploreSort = 'newest' | 'downloads' | 'rating' | 'installs' | 'installsAllTime' | 'trending'
type ApiExploreSort =
| 'updated'
| 'downloads'
| 'stars'
| 'installsCurrent'
| 'installsAllTime'
| 'trending'
export async function cmdExplore(
opts: GlobalOpts,
options: { limit?: number; sort?: string; json?: boolean } = {},
) {
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching latest skills')
try {
const url = new URL(ApiRoutes.skills, registry)
const boundedLimit = clampLimit(limit)
const boundedLimit = clampLimit(options.limit ?? 25)
const { apiSort } = resolveExploreSort(options.sort)
url.searchParams.set('limit', String(boundedLimit))
if (apiSort !== 'updated') url.searchParams.set('sort', apiSort)
const result = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
@@ -256,6 +270,10 @@ export async function cmdExplore(opts: GlobalOpts, limit = 25) {
)
spinner.stop()
if (options.json) {
console.log(JSON.stringify(result, null, 2))
return
}
if (result.items.length === 0) {
console.log('No skills found.')
return
@@ -284,7 +302,7 @@ export function formatExploreLine(item: {
export function clampLimit(limit: number, fallback = 25) {
if (!Number.isFinite(limit)) return fallback
return Math.min(Math.max(1, limit), 50)
return Math.min(Math.max(1, limit), 200)
}
function formatRelativeTime(timestamp: number): string {
@@ -310,6 +328,37 @@ function truncate(str: string, maxLen: number): string {
return `${str.slice(0, maxLen - 1)}`
}
function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExploreSort } {
const normalized = raw?.trim().toLowerCase()
if (!normalized || normalized === 'newest' || normalized === 'updated') {
return { sort: 'newest', apiSort: 'updated' }
}
if (normalized === 'downloads' || normalized === 'download') {
return { sort: 'downloads', apiSort: 'downloads' }
}
if (normalized === 'rating' || normalized === 'stars' || normalized === 'star') {
return { sort: 'rating', apiSort: 'stars' }
}
if (
normalized === 'installs' ||
normalized === 'install' ||
normalized === 'installscurrent' ||
normalized === 'installs-current' ||
normalized === 'current'
) {
return { sort: 'installs', apiSort: 'installsCurrent' }
}
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
return { sort: 'installsAllTime', apiSort: 'installsAllTime' }
}
if (normalized === 'trending') {
return { sort: 'trending', apiSort: 'trending' }
}
fail(
`Invalid sort "${raw}". Use newest, downloads, rating, installs, installsAllTime, or trending.`,
)
}
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
const url = new URL(ApiRoutes.resolve, registry)
url.searchParams.set('slug', slug)
+16 -8
View File
@@ -8,8 +8,13 @@ vi.mock('@tanstack/react-router', () => ({
import { Route } from '../routes/search'
describe('search route', () => {
it('redirects to home with search mode enabled', () => {
const beforeLoad = Route.__config.beforeLoad as (args: {
it('redirects to the skills index', () => {
const route = Route as unknown as {
__config: {
beforeLoad?: (args: { search: { q?: string; highlighted?: boolean } }) => void
}
}
const beforeLoad = route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean }
}) => void
let thrown: unknown
@@ -22,19 +27,23 @@ 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', () => {
const beforeLoad = Route.__config.beforeLoad as (args: {
it('redirects to the skills index without query', () => {
const route = Route as unknown as {
__config: {
beforeLoad?: (args: { search: { q?: string; highlighted?: boolean } }) => void
}
}
const beforeLoad = route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean }
}) => void
let thrown: unknown
@@ -47,11 +56,10 @@ describe('search route', () => {
expect(thrown).toEqual({
redirect: {
to: '/',
to: '/skills',
search: {
q: undefined,
highlighted: undefined,
search: true,
},
replace: true,
},
+2 -1
View File
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SkillsIndex } from '../routes/skills/index'
@@ -14,7 +15,7 @@ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
useSearch: () => searchMock,
}),
Link: (props: { children: unknown }) => <a href="/">{props.children}</a>,
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
}))
vi.mock('convex/react', () => ({
+42 -32
View File
@@ -59,22 +59,27 @@ export default function Header() {
</Link>
<nav className="nav-links">
{isSoulMode ? <a href={clawdHubUrl}>ClawdHub</a> : null}
<Link
to={isSoulMode ? '/souls' : '/skills'}
search={
isSoulMode
? undefined
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}
}
>
{isSoulMode ? 'Souls' : 'Skills'}
</Link>
{isSoulMode ? (
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}}
>
Skills
</Link>
)}
<Link to="/upload" search={{ updateSlug: undefined }}>
Upload
</Link>
@@ -100,22 +105,27 @@ export default function Header() {
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
<Link
to={isSoulMode ? '/souls' : '/skills'}
search={
isSoulMode
? undefined
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}
}
>
{isSoulMode ? 'Souls' : 'Skills'}
</Link>
{isSoulMode ? (
<Link
to="/souls"
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
>
Souls
</Link>
) : (
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}}
>
Skills
</Link>
)}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/upload" search={{ updateSlug: undefined }}>
+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}
+3 -1
View File
@@ -15,6 +15,8 @@ type SkillDetailPageProps = {
redirectToCanonical?: boolean
}
type SkillFile = Doc<'skillVersions'>['files'][number]
export function SkillDetailPage({
slug,
canonicalOwner,
@@ -122,7 +124,7 @@ export function SkillDetailPage({
if (!readme) return null
return stripFrontmatter(readme)
}, [readme])
const latestFiles = latestVersion?.files ?? []
const latestFiles: SkillFile[] = latestVersion?.files ?? []
useEffect(() => {
if (!latestVersion) return
+6 -3
View File
@@ -10,7 +10,10 @@ export const Route = createFileRoute('/dashboard')({
function Dashboard() {
const me = useQuery(api.users.me)
const mySkills = useQuery(api.skills.list, me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip')
const mySkills = useQuery(
api.skills.list,
me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip',
) as Doc<'skills'>[] | undefined
if (!me) {
return (
@@ -29,7 +32,7 @@ function Dashboard() {
<h1 className="section-title" style={{ margin: 0 }}>
My Skills
</h1>
<Link to="/upload" className="btn btn-primary">
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
<Plus className="h-4 w-4" aria-hidden="true" />
Upload New Skill
</Link>
@@ -40,7 +43,7 @@ function Dashboard() {
<Package className="dashboard-empty-icon" aria-hidden="true" />
<h2>No skills yet</h2>
<p>Upload your first skill to share it with the community.</p>
<Link to="/upload" className="btn btn-primary">
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
<Upload className="h-4 w-4" aria-hidden="true" />
Upload a Skill
</Link>
+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,
})
+11 -1
View File
@@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery } from 'convex/react'
import { useEffect, useState } from 'react'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
import { gravatarUrl } from '../lib/gravatar'
export const Route = createFileRoute('/settings')({
@@ -12,7 +13,16 @@ function Settings() {
const me = useQuery(api.users.me)
const updateProfile = useMutation(api.users.updateProfile)
const deleteAccount = useMutation(api.users.deleteAccount)
const tokens = useQuery(api.tokens.listMine)
const tokens = useQuery(api.tokens.listMine) as
| Array<{
_id: Id<'apiTokens'>
label: string
prefix: string
createdAt: number
lastUsedAt?: number
revokedAt?: number
}>
| undefined
const createToken = useMutation(api.tokens.create)
const revokeToken = useMutation(api.tokens.revoke)
const [displayName, setDisplayName] = useState('')
+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>
+34
View File
@@ -13,6 +13,10 @@ function UserProfile() {
const { handle } = Route.useParams()
const me = useQuery(api.users.me)
const user = useQuery(api.users.getByHandle, { handle }) as Doc<'users'> | null | undefined
const publishedSkills = useQuery(
api.skills.list,
user ? { ownerUserId: user._id, limit: 50 } : 'skip',
) as Doc<'skills'>[] | undefined
const starredSkills = useQuery(
api.stars.listByUser,
user ? { userId: user._id, limit: 50 } : 'skip',
@@ -54,6 +58,8 @@ function UserProfile() {
const initial = displayName.charAt(0).toUpperCase()
const isLoadingSkills = starredSkills === undefined
const skills = starredSkills ?? []
const isLoadingPublished = publishedSkills === undefined
const published = publishedSkills ?? []
return (
<main className="section">
@@ -98,6 +104,34 @@ function UserProfile() {
/>
) : (
<>
<h2 className="section-title" style={{ fontSize: '1.3rem' }}>
Published
</h2>
<p className="section-subtitle">Skills published by this user.</p>
{isLoadingPublished ? (
<div className="card">
<div className="loading-indicator">Loading published skills</div>
</div>
) : published.length > 0 ? (
<div className="grid" style={{ marginBottom: 18 }}>
{published.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))}
</div>
) : null}
<h2 className="section-title" style={{ fontSize: '1.3rem' }}>
Stars
</h2>