Skip to content

Commit d9108b0

Browse files
njoylabsteipete
andauthored
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>
1 parent d7a017e commit d9108b0

File tree

16 files changed

+284
-153
lines changed

16 files changed

+284
-153
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
### Added
6+
- Web: show published skills on user profiles (thanks @njoylab, #20).
7+
58
### Fixed
69
- Registry: drop missing skills during search hydration (thanks @aaronn, #28).
710
- CLI: use path-based skill metadata lookup for updates (thanks @daveonkels, #22).

convex/devSeed.ts

Lines changed: 63 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { v } from 'convex/values'
22
import { internal } from './_generated/api'
3+
import type { ActionCtx } from './_generated/server'
34
import { internalAction, internalMutation } from './_generated/server'
45
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
56
import { parseClawdisMetadata, parseFrontmatter } from './lib/skills'
@@ -13,6 +14,17 @@ type SeedSkillSpec = {
1314
rawSkillMd: string
1415
}
1516

17+
type SeedActionArgs = {
18+
reset?: boolean
19+
}
20+
21+
type SeedActionResult = {
22+
ok: true
23+
results: Array<Record<string, unknown> & { slug: string }>
24+
}
25+
26+
type SeedMutationResult = Record<string, unknown>
27+
1628
const SEED_SKILLS: SeedSkillSpec[] = [
1729
{
1830
slug: 'padel',
@@ -237,53 +249,19 @@ function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
237249
)}${rawSkillMd.slice(frontmatterEnd)}`
238250
}
239251

240-
export const seedNixSkills = internalAction({
241-
args: {
242-
reset: v.optional(v.boolean()),
243-
},
244-
handler: async (ctx, args) => {
245-
const results = []
246-
247-
for (const spec of SEED_SKILLS) {
248-
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
249-
const frontmatter = parseFrontmatter(skillMd)
250-
const clawdis = parseClawdisMetadata(frontmatter)
251-
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
252-
253-
const result = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
254-
reset: args.reset,
255-
storageId,
256-
metadata: spec.metadata,
257-
frontmatter,
258-
clawdis,
259-
skillMd,
260-
slug: spec.slug,
261-
displayName: spec.displayName,
262-
summary: spec.summary,
263-
version: spec.version,
264-
})
265-
266-
results.push({ slug: spec.slug, ...result })
267-
}
268-
269-
return { ok: true, results }
270-
},
271-
})
272-
273-
export const seedPadelSkill = internalAction({
274-
args: {
275-
reset: v.optional(v.boolean()),
276-
},
277-
handler: async (ctx, args) => {
278-
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
279-
if (!spec) throw new Error('padel seed spec missing')
252+
async function seedNixSkillsHandler(
253+
ctx: ActionCtx,
254+
args: SeedActionArgs,
255+
): Promise<SeedActionResult> {
256+
const results: Array<Record<string, unknown> & { slug: string }> = []
280257

258+
for (const spec of SEED_SKILLS) {
281259
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
282260
const frontmatter = parseFrontmatter(skillMd)
283261
const clawdis = parseClawdisMetadata(frontmatter)
284262
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
285263

286-
return ctx.runMutation(internal.devSeed.seedSkillMutation, {
264+
const result: SeedMutationResult = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
287265
reset: args.reset,
288266
storageId,
289267
metadata: spec.metadata,
@@ -295,7 +273,51 @@ export const seedPadelSkill = internalAction({
295273
summary: spec.summary,
296274
version: spec.version,
297275
})
276+
277+
results.push({ slug: spec.slug, ...result })
278+
}
279+
280+
return { ok: true, results }
281+
}
282+
283+
export const seedNixSkills: ReturnType<typeof internalAction> = internalAction({
284+
args: {
285+
reset: v.optional(v.boolean()),
286+
},
287+
handler: seedNixSkillsHandler,
288+
})
289+
290+
async function seedPadelSkillHandler(
291+
ctx: ActionCtx,
292+
args: SeedActionArgs,
293+
): Promise<SeedMutationResult> {
294+
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
295+
if (!spec) throw new Error('padel seed spec missing')
296+
297+
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
298+
const frontmatter = parseFrontmatter(skillMd)
299+
const clawdis = parseClawdisMetadata(frontmatter)
300+
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
301+
302+
return (await ctx.runMutation(internal.devSeed.seedSkillMutation, {
303+
reset: args.reset,
304+
storageId,
305+
metadata: spec.metadata,
306+
frontmatter,
307+
clawdis,
308+
skillMd,
309+
slug: spec.slug,
310+
displayName: spec.displayName,
311+
summary: spec.summary,
312+
version: spec.version,
313+
})) as SeedMutationResult
314+
}
315+
316+
export const seedPadelSkill: ReturnType<typeof internalAction> = internalAction({
317+
args: {
318+
reset: v.optional(v.boolean()),
298319
},
320+
handler: seedPadelSkillHandler,
299321
})
300322

301323
export const seedSkillMutation = internalMutation({

convex/httpApi.handlers.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ vi.mock('./skills', () => ({
1111

1212
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
1313
const { publishVersionForUser } = await import('./skills')
14-
const { __handlers, cliSkillDeleteHttp, cliSkillUndeleteHttp } = await import('./httpApi')
14+
const { __handlers } = await import('./httpApi')
1515
const { hashSkillFiles } = await import('./lib/skills')
1616

1717
function makeCtx(partial: Record<string, unknown>) {
@@ -416,13 +416,14 @@ describe('httpApi handlers', () => {
416416
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
417417
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
418418
const runMutation = vi.fn().mockResolvedValue({ ok: true })
419-
const response = await cliSkillUndeleteHttp(
419+
const response = await __handlers.cliSkillDeleteHandler(
420420
makeCtx({ runMutation }),
421421
new Request('https://x/api/cli/skill/undelete', {
422422
method: 'POST',
423423
headers: { 'Content-Type': 'application/json' },
424424
body: JSON.stringify({ slug: 'demo' }),
425425
}),
426+
false,
426427
)
427428
expect(response.status).toBe(200)
428429
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
@@ -437,13 +438,14 @@ describe('httpApi handlers', () => {
437438
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
438439
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
439440
const runMutation = vi.fn().mockResolvedValue({ ok: true })
440-
const response = await cliSkillDeleteHttp(
441+
const response = await __handlers.cliSkillDeleteHandler(
441442
makeCtx({ runMutation }),
442443
new Request('https://x/api/cli/skill/delete', {
443444
method: 'POST',
444445
headers: { 'Content-Type': 'application/json' },
445446
body: JSON.stringify({ slug: 'demo' }),
446447
}),
448+
true,
447449
)
448450
expect(response.status).toBe(200)
449451
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {

convex/httpApiV1.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ type ListSkillsResult = {
4444
nextCursor: string | null
4545
}
4646

47+
type SkillFile = Doc<'skillVersions'>['files'][number]
48+
type SoulFile = Doc<'soulVersions'>['files'][number]
49+
4750
type GetBySlugResult = {
4851
skill: {
4952
_id: Id<'skills'>
@@ -318,7 +321,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
318321
createdAt: version.createdAt,
319322
changelog: version.changelog,
320323
changelogSource: version.changelogSource ?? null,
321-
files: version.files.map((file) => ({
324+
files: version.files.map((file: SkillFile) => ({
322325
path: file.path,
323326
size: file.size,
324327
sha256: file.sha256,
@@ -784,8 +787,8 @@ function parseListSort(value: string | null): SkillListSort {
784787
}
785788

786789
async function sha256Hex(bytes: Uint8Array) {
787-
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
788-
const digest = await crypto.subtle.digest('SHA-256', buffer)
790+
const data = new Uint8Array(bytes)
791+
const digest = await crypto.subtle.digest('SHA-256', data)
789792
return toHex(new Uint8Array(digest))
790793
}
791794

@@ -925,7 +928,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
925928
createdAt: version.createdAt,
926929
changelog: version.changelog,
927930
changelogSource: version.changelogSource ?? null,
928-
files: version.files.map((file) => ({
931+
files: version.files.map((file: SoulFile) => ({
929932
path: file.path,
930933
size: file.size,
931934
sha256: file.sha256,

convex/lib/skillPublish.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,20 @@ export async function publishVersionForUser(
7575
if (sanitizedFiles.some((file) => !file.path)) {
7676
throw new ConvexError('Invalid file paths')
7777
}
78-
if (sanitizedFiles.some((file) => !isTextFile(file.path ?? '', file.contentType ?? undefined))) {
78+
const safeFiles = sanitizedFiles.map((file) => ({
79+
...file,
80+
path: file.path as string,
81+
}))
82+
if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
7983
throw new ConvexError('Only text-based files are allowed')
8084
}
8185

82-
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
86+
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
8387
if (totalBytes > MAX_TOTAL_BYTES) {
8488
throw new ConvexError('Skill bundle exceeds 50MB limit')
8589
}
8690

87-
const readmeFile = sanitizedFiles.find(
91+
const readmeFile = safeFiles.find(
8892
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
8993
)
9094
if (!readmeFile) throw new ConvexError('SKILL.md is required')
@@ -95,7 +99,7 @@ export async function publishVersionForUser(
9599
const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
96100

97101
const otherFiles = [] as Array<{ path: string; content: string }>
98-
for (const file of sanitizedFiles) {
102+
for (const file of safeFiles) {
99103
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
100104
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
101105
const content = await fetchText(ctx, file.storageId)
@@ -110,7 +114,7 @@ export async function publishVersionForUser(
110114
})
111115

112116
const fingerprintPromise = hashSkillFiles(
113-
sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
117+
safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
114118
)
115119

116120
const changelogPromise =
@@ -120,7 +124,7 @@ export async function publishVersionForUser(
120124
slug,
121125
version,
122126
readmeText,
123-
files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
127+
files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
124128
})
125129

126130
const embeddingPromise = generateEmbedding(embeddingText)
@@ -148,9 +152,9 @@ export async function publishVersionForUser(
148152
version: args.forkOf.version?.trim() || undefined,
149153
}
150154
: undefined,
151-
files: sanitizedFiles.map((file) => ({
155+
files: safeFiles.map((file) => ({
152156
...file,
153-
path: file.path ?? '',
157+
path: file.path,
154158
})),
155159
parsed: {
156160
frontmatter,
@@ -169,7 +173,7 @@ export async function publishVersionForUser(
169173
version,
170174
displayName,
171175
ownerHandle,
172-
files: sanitizedFiles,
176+
files: safeFiles,
173177
publishedAt: Date.now(),
174178
})
175179
.catch((error) => {

convex/seed.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,8 @@ export const ensureSeedUserInternal = internalMutation({
242242
})
243243

244244
async function sha256Hex(bytes: Uint8Array) {
245-
const digest = await crypto.subtle.digest('SHA-256', bytes)
245+
const data = new Uint8Array(bytes)
246+
const digest = await crypto.subtle.digest('SHA-256', data)
246247
return toHex(new Uint8Array(digest))
247248
}
248249

convex/skills.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,10 @@ export const listWithLatest = query({
165165
.order('desc')
166166
.take(takeLimit)
167167
} else if (args.ownerUserId) {
168+
const ownerUserId = args.ownerUserId
168169
entries = await ctx.db
169170
.query('skills')
170-
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
171+
.withIndex('by_owner', (q) => q.eq('ownerUserId', ownerUserId))
171172
.order('desc')
172173
.take(takeLimit)
173174
} else {

convex/souls.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ export const insertVersion = internalMutation({
377377
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
378378
.order('desc')
379379
.take(2)
380-
let soul = soulMatches[0] ?? null
380+
let soul: Doc<'souls'> | null = soulMatches[0] ?? null
381381

382382
if (soul && soul.ownerUserId !== userId) {
383383
throw new Error('Only the owner can publish updates')

0 commit comments

Comments
 (0)