mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-16 09:52:03 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6754ffacc | ||
|
|
e292096767 | ||
|
|
2664c3aa5f | ||
|
|
349f74f54a |
@@ -2,6 +2,9 @@
|
||||
|
||||
## 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).
|
||||
|
||||
+63
-41
@@ -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({
|
||||
|
||||
@@ -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>) {
|
||||
@@ -416,13 +416,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 +438,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(), {
|
||||
|
||||
+7
-4
@@ -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'>
|
||||
@@ -318,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,
|
||||
@@ -784,8 +787,8 @@ function parseListSort(value: string | null): SkillListSort {
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -925,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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+2
-1
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -165,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 {
|
||||
|
||||
+1
-1
@@ -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')
|
||||
|
||||
+82
-57
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -44,6 +45,25 @@ 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> => {
|
||||
@@ -87,68 +107,73 @@ export const setSkillStatBackfillStateInternal = internalMutation({
|
||||
},
|
||||
})
|
||||
|
||||
export const runSkillStatBackfillInternal = internalAction({
|
||||
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: 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 }
|
||||
},
|
||||
handler: runSkillStatBackfillInternalHandler,
|
||||
})
|
||||
|
||||
function buildSkillStatPatch(skill: Doc<'skills'>) {
|
||||
|
||||
@@ -9,7 +9,12 @@ import { Route } from '../routes/search'
|
||||
|
||||
describe('search route', () => {
|
||||
it('redirects to the skills index', () => {
|
||||
const beforeLoad = Route.__config.beforeLoad as (args: {
|
||||
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
|
||||
@@ -33,7 +38,12 @@ describe('search route', () => {
|
||||
})
|
||||
|
||||
it('redirects to the skills index without query', () => {
|
||||
const beforeLoad = Route.__config.beforeLoad as (args: {
|
||||
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
|
||||
|
||||
@@ -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
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+11
-1
@@ -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('')
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user