Compare 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
24 changed files with 228 additions and 548 deletions
-5
View File
@@ -2,13 +2,8 @@
## 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
-76
View File
@@ -1,76 +0,0 @@
/* @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()
})
})
+55 -68
View File
@@ -3,67 +3,6 @@ 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) => {
@@ -86,15 +25,63 @@ export const listBySkill = query({
export const add = mutation({
args: { skillId: v.id('skills'), body: v.string() },
handler: addHandler,
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const body = args.body.trim()
if (!body) throw new Error('Comment body required')
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await ctx.db.insert('comments', {
skillId: args.skillId,
userId,
body,
createdAt: Date.now(),
softDeletedAt: undefined,
deletedBy: undefined,
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
updatedAt: Date.now(),
})
},
})
export const remove = mutation({
args: { commentId: v.id('comments') },
handler: removeHandler,
})
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
export const __test = {
addHandler,
removeHandler,
}
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(),
})
},
})
+41 -63
View File
@@ -1,6 +1,5 @@
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'
@@ -14,17 +13,6 @@ 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',
@@ -249,19 +237,53 @@ function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
)}${rawSkillMd.slice(frontmatterEnd)}`
}
async function seedNixSkillsHandler(
ctx: ActionCtx,
args: SeedActionArgs,
): Promise<SeedActionResult> {
const results: Array<Record<string, unknown> & { slug: string }> = []
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')
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: SeedMutationResult = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
return ctx.runMutation(internal.devSeed.seedSkillMutation, {
reset: args.reset,
storageId,
metadata: spec.metadata,
@@ -273,51 +295,7 @@ async function seedNixSkillsHandler(
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({
+7 -22
View File
@@ -11,7 +11,7 @@ vi.mock('./skills', () => ({
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
const { publishVersionForUser } = await import('./skills')
const { __handlers } = await import('./httpApi')
const { __handlers, cliSkillDeleteHttp, cliSkillUndeleteHttp } = 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 (approvedOnly alias)', async () => {
it('searchSkillsHttp forwards args', async () => {
const runAction = vi.fn().mockResolvedValue([
{
score: 1,
@@ -48,27 +48,14 @@ describe('httpApi handlers', () => {
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: 5,
highlightedOnly: true,
approvedOnly: true,
})
expect(response.status).toBe(200)
const json = await response.json()
expect(json.results[0].slug).toBe('a')
})
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 () => {
it('searchSkillsHttp omits approvedOnly when false', async () => {
const runAction = vi.fn().mockResolvedValue([])
await __handlers.searchSkillsHandler(
makeCtx({ runAction }),
@@ -77,7 +64,7 @@ describe('httpApi handlers', () => {
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
query: 'test',
limit: undefined,
highlightedOnly: undefined,
approvedOnly: undefined,
})
})
@@ -429,14 +416,13 @@ 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 __handlers.cliSkillDeleteHandler(
const response = await cliSkillUndeleteHttp(
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(), {
@@ -451,14 +437,13 @@ 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 __handlers.cliSkillDeleteHandler(
const response = await cliSkillDeleteHttp(
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(), {
+1 -2
View File
@@ -44,14 +44,13 @@ 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,
highlightedOnly: highlightedOnly || undefined,
approvedOnly: approvedOnly || undefined,
})) as SearchSkillEntry[]
return json({
+4 -7
View File
@@ -44,9 +44,6 @@ type ListSkillsResult = {
nextCursor: string | null
}
type SkillFile = Doc<'skillVersions'>['files'][number]
type SoulFile = Doc<'soulVersions'>['files'][number]
type GetBySlugResult = {
skill: {
_id: Id<'skills'>
@@ -321,7 +318,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file: SkillFile) => ({
files: version.files.map((file) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
@@ -787,8 +784,8 @@ function parseListSort(value: string | null): SkillListSort {
}
async function sha256Hex(bytes: Uint8Array) {
const data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
const digest = await crypto.subtle.digest('SHA-256', buffer)
return toHex(new Uint8Array(digest))
}
@@ -928,7 +925,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file: SoulFile) => ({
files: version.files.map((file) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
+2 -16
View File
@@ -13,26 +13,12 @@ describe('searchText', () => {
])
})
it('matchesExactTokens requires at least one query token to prefix-match', () => {
it('matchesExactTokens requires all query tokens', () => {
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(
true,
false,
)
// 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', () => {
+2 -4
View File
@@ -18,10 +18,8 @@ export function matchesExactTokens(
if (!text) return false
const textTokens = tokenize(text)
if (textTokens.length === 0) return false
// Require at least one token to prefix-match, allowing vector similarity to determine relevance
return queryTokens.some((queryToken) =>
textTokens.some((textToken) => textToken.includes(queryToken)),
)
const textSet = new Set(textTokens)
return queryTokens.every((token) => textSet.has(token))
}
export const __test = { normalize, tokenize, matchesExactTokens }
+9 -13
View File
@@ -75,20 +75,16 @@ export async function publishVersionForUser(
if (sanitizedFiles.some((file) => !file.path)) {
throw new ConvexError('Invalid file paths')
}
const safeFiles = sanitizedFiles.map((file) => ({
...file,
path: file.path as string,
}))
if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
if (sanitizedFiles.some((file) => !isTextFile(file.path ?? '', file.contentType ?? undefined))) {
throw new ConvexError('Only text-based files are allowed')
}
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
if (totalBytes > MAX_TOTAL_BYTES) {
throw new ConvexError('Skill bundle exceeds 50MB limit')
}
const readmeFile = safeFiles.find(
const readmeFile = sanitizedFiles.find(
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
)
if (!readmeFile) throw new ConvexError('SKILL.md is required')
@@ -99,7 +95,7 @@ export async function publishVersionForUser(
const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
const otherFiles = [] as Array<{ path: string; content: string }>
for (const file of safeFiles) {
for (const file of sanitizedFiles) {
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
const content = await fetchText(ctx, file.storageId)
@@ -114,7 +110,7 @@ export async function publishVersionForUser(
})
const fingerprintPromise = hashSkillFiles(
safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
)
const changelogPromise =
@@ -124,7 +120,7 @@ export async function publishVersionForUser(
slug,
version,
readmeText,
files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
})
const embeddingPromise = generateEmbedding(embeddingText)
@@ -152,9 +148,9 @@ export async function publishVersionForUser(
version: args.forkOf.version?.trim() || undefined,
}
: undefined,
files: safeFiles.map((file) => ({
files: sanitizedFiles.map((file) => ({
...file,
path: file.path,
path: file.path ?? '',
})),
parsed: {
frontmatter,
@@ -173,7 +169,7 @@ export async function publishVersionForUser(
version,
displayName,
ownerHandle,
files: safeFiles,
files: sanitizedFiles,
publishedAt: Date.now(),
})
.catch((error) => {
+4 -6
View File
@@ -38,9 +38,8 @@ export const searchSkills: ReturnType<typeof action> = action({
return []
}
const limit = args.limit ?? 10
// 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)
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
let hydrated: HydratedEntry[] = []
let scoreById = new Map<Id<'skillEmbeddings'>, number>()
let exactMatches: HydratedEntry[] = []
@@ -150,9 +149,8 @@ export const searchSouls: ReturnType<typeof action> = action({
return []
}
const limit = args.limit ?? 10
// 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)
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
let candidateLimit = Math.max(limit * 3, 50)
let hydrated: HydratedSoulEntry[] = []
let scoreById = new Map<Id<'soulEmbeddings'>, number>()
let exactMatches: HydratedSoulEntry[] = []
+1 -2
View File
@@ -242,8 +242,7 @@ export const ensureSeedUserInternal = internalMutation({
})
async function sha256Hex(bytes: Uint8Array) {
const data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
const digest = await crypto.subtle.digest('SHA-256', bytes)
return toHex(new Uint8Array(digest))
}
+1 -2
View File
@@ -165,10 +165,9 @@ 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', ownerUserId))
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
.order('desc')
.take(takeLimit)
} else {
+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: Doc<'souls'> | null = soulMatches[0] ?? null
let soul = soulMatches[0] ?? null
if (soul && soul.ownerUserId !== userId) {
throw new Error('Only the owner can publish updates')
+57 -82
View File
@@ -1,7 +1,6 @@
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
@@ -45,25 +44,6 @@ type BackfillState = {
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> => {
@@ -107,73 +87,68 @@ export const setSkillStatBackfillStateInternal = internalMutation({
},
})
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({
export const runSkillStatBackfillInternal = internalAction({
args: {
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
resetCursor: v.optional(v.boolean()),
},
handler: runSkillStatBackfillInternalHandler,
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'>) {
@@ -1,14 +1,11 @@
/* @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')
@@ -16,51 +13,13 @@ vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const mockSpinner = {
stop: vi.fn(),
fail: vi.fn(),
start: vi.fn(),
succeed: vi.fn(),
isSpinning: false,
text: '',
}
const mockSpinner = { stop: vi.fn(), fail: vi.fn() }
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),
}))
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 { clampLimit, cmdExplore, formatExploreLine } = await import('./skills')
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -164,28 +123,3 @@ describe('cmdExplore', () => {
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()
})
})
+1 -1
View File
@@ -153,7 +153,7 @@ export async function cmdUpdate(
} else {
const meta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
{ method: 'GET', url: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
ApiV1SkillResponseSchema,
)
resolveResult = { match: null, latestVersion: meta.latestVersion ?? null }
+2 -12
View File
@@ -9,12 +9,7 @@ import { Route } from '../routes/search'
describe('search route', () => {
it('redirects to the skills index', () => {
const route = Route as unknown as {
__config: {
beforeLoad?: (args: { search: { q?: string; highlighted?: boolean } }) => void
}
}
const beforeLoad = route.__config.beforeLoad as (args: {
const beforeLoad = Route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean }
}) => void
let thrown: unknown
@@ -38,12 +33,7 @@ describe('search route', () => {
})
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: {
const beforeLoad = Route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean }
}) => void
let thrown: unknown
+1 -2
View File
@@ -1,6 +1,5 @@
/* @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'
@@ -15,7 +14,7 @@ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
useSearch: () => searchMock,
}),
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
Link: (props: { children: unknown }) => <a href="/">{props.children}</a>,
}))
vi.mock('convex/react', () => ({
+32 -42
View File
@@ -59,27 +59,22 @@ export default function Header() {
</Link>
<nav className="nav-links">
{isSoulMode ? <a href={clawdHubUrl}>ClawdHub</a> : null}
{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={isSoulMode ? '/souls' : '/skills'}
search={
isSoulMode
? undefined
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}
}
>
{isSoulMode ? 'Souls' : 'Skills'}
</Link>
<Link to="/upload" search={{ updateSlug: undefined }}>
Upload
</Link>
@@ -105,27 +100,22 @@ export default function Header() {
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
{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={isSoulMode ? '/souls' : '/skills'}
search={
isSoulMode
? undefined
: {
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}
}
>
{isSoulMode ? 'Souls' : 'Skills'}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/upload" search={{ updateSlug: undefined }}>
+1 -3
View File
@@ -15,8 +15,6 @@ type SkillDetailPageProps = {
redirectToCanonical?: boolean
}
type SkillFile = Doc<'skillVersions'>['files'][number]
export function SkillDetailPage({
slug,
canonicalOwner,
@@ -124,7 +122,7 @@ export function SkillDetailPage({
if (!readme) return null
return stripFrontmatter(readme)
}, [readme])
const latestFiles: SkillFile[] = latestVersion?.files ?? []
const latestFiles = latestVersion?.files ?? []
useEffect(() => {
if (!latestVersion) return
+3 -6
View File
@@ -10,10 +10,7 @@ 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',
) as Doc<'skills'>[] | undefined
const mySkills = useQuery(api.skills.list, me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip')
if (!me) {
return (
@@ -32,7 +29,7 @@ function Dashboard() {
<h1 className="section-title" style={{ margin: 0 }}>
My Skills
</h1>
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
<Link to="/upload" className="btn btn-primary">
<Plus className="h-4 w-4" aria-hidden="true" />
Upload New Skill
</Link>
@@ -43,7 +40,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" search={{ updateSlug: undefined }} className="btn btn-primary">
<Link to="/upload" className="btn btn-primary">
<Upload className="h-4 w-4" aria-hidden="true" />
Upload a Skill
</Link>
+1 -11
View File
@@ -2,7 +2,6 @@ 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')({
@@ -13,16 +12,7 @@ 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) as
| Array<{
_id: Id<'apiTokens'>
label: string
prefix: string
createdAt: number
lastUsedAt?: number
revokedAt?: number
}>
| undefined
const tokens = useQuery(api.tokens.listMine)
const createToken = useMutation(api.tokens.create)
const revokeToken = useMutation(api.tokens.revoke)
const [displayName, setDisplayName] = useState('')
-34
View File
@@ -13,10 +13,6 @@ 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',
@@ -58,8 +54,6 @@ 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">
@@ -104,34 +98,6 @@ 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>