Skip to content

Commit b93bb58

Browse files
committed
feat(clawdhub): soft delete skills
- make changelog optional for publish/sync - add CLI delete/undelete + API routes - hide deleted skills from search/download - add e2e + manual testing doc + changelog
1 parent 6d1a376 commit b93bb58

25 files changed

+618
-34
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Changelog
2+
3+
## 0.0.2 — Unreleased
4+
- CLI: make changelog optional for updates (`publish`, `sync`).
5+
- Registry: allow empty changelog on updated versions.
6+
- CLI: use `--cli-version` (free `--version` for skill semver flags).
7+
- CLI: add `delete` / `undelete` (owner/admin) for soft deletion.
8+
- Registry: hide soft-deleted skills from `search`, `skill`, `download` (unless restored).
9+
- Tests: add delete/undelete coverage (unit + e2e).
10+
11+
## 0.0.1 — 2026-01-04
12+
- Initial beta release of `clawdhub` CLI + `clawdhub-schema`.

convex/http.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { auth } from './auth'
44
import { downloadZip } from './downloads'
55
import {
66
cliPublishHttp,
7+
cliSkillDeleteHttp,
8+
cliSkillUndeleteHttp,
79
cliUploadUrlHttp,
810
cliWhoamiHttp,
911
getSkillHttp,
@@ -57,4 +59,16 @@ http.route({
5759
handler: cliPublishHttp,
5860
})
5961

62+
http.route({
63+
path: ApiRoutes.cliSkillDelete,
64+
method: 'POST',
65+
handler: cliSkillDeleteHttp,
66+
})
67+
68+
http.route({
69+
path: ApiRoutes.cliSkillUndelete,
70+
method: 'POST',
71+
handler: cliSkillUndeleteHttp,
72+
})
73+
6074
export default http

convex/httpApi.handlers.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,33 @@ describe('httpApi handlers', () => {
242242
expect(json.ok).toBe(true)
243243
expect(json.skillId).toBe('s')
244244
})
245+
246+
it('cliSkillDeleteHandler returns 401 when unauthorized', async () => {
247+
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
248+
const request = new Request('https://x/api/cli/skill/delete', {
249+
method: 'POST',
250+
headers: { 'Content-Type': 'application/json' },
251+
body: JSON.stringify({ slug: 'demo' }),
252+
})
253+
const response = await __handlers.cliSkillDeleteHandler({} as never, request, true)
254+
expect(response.status).toBe(401)
255+
})
256+
257+
it('cliSkillDeleteHandler calls mutation and returns ok', async () => {
258+
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
259+
const runMutation = vi.fn().mockResolvedValue({ ok: true })
260+
const request = new Request('https://x/api/cli/skill/delete', {
261+
method: 'POST',
262+
headers: { 'Content-Type': 'application/json' },
263+
body: JSON.stringify({ slug: 'demo' }),
264+
})
265+
const response = await __handlers.cliSkillDeleteHandler({ runMutation } as never, request, true)
266+
expect(response.status).toBe(200)
267+
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
268+
userId: 'user1',
269+
slug: 'demo',
270+
deleted: true,
271+
})
272+
expect(await response.json()).toEqual({ ok: true })
273+
})
245274
})

convex/httpApi.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { CliPublishRequestSchema, parseArk } from 'clawdhub-schema'
1+
import {
2+
ApiCliSkillDeleteResponseSchema,
3+
CliPublishRequestSchema,
4+
CliSkillDeleteRequestSchema,
5+
parseArk,
6+
} from 'clawdhub-schema'
27
import { api, internal } from './_generated/api'
38
import type { Id } from './_generated/dataModel'
49
import { httpAction } from './_generated/server'
@@ -188,6 +193,38 @@ async function cliPublishHandler(ctx: HttpCtx, request: Request) {
188193

189194
export const cliPublishHttp = httpAction(cliPublishHandler)
190195

196+
async function cliSkillDeleteHandler(ctx: HttpCtx, request: Request, deleted: boolean) {
197+
let body: unknown
198+
try {
199+
body = await request.json()
200+
} catch {
201+
return text('Invalid JSON', 400)
202+
}
203+
204+
try {
205+
const { userId } = await requireApiTokenUser(ctx, request)
206+
const args = parseArk(CliSkillDeleteRequestSchema, body, 'Delete payload')
207+
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
208+
userId,
209+
slug: args.slug,
210+
deleted,
211+
})
212+
const ok = parseArk(ApiCliSkillDeleteResponseSchema, { ok: true }, 'Delete response')
213+
return json(ok)
214+
} catch (error) {
215+
const message = error instanceof Error ? error.message : 'Delete failed'
216+
if (message.toLowerCase().includes('unauthorized')) return text('Unauthorized', 401)
217+
return text(message, 400)
218+
}
219+
}
220+
221+
export const cliSkillDeleteHttp = httpAction((ctx, request) =>
222+
cliSkillDeleteHandler(ctx, request, true),
223+
)
224+
export const cliSkillUndeleteHttp = httpAction((ctx, request) =>
225+
cliSkillDeleteHandler(ctx, request, false),
226+
)
227+
191228
function json(value: unknown, status = 200) {
192229
return new Response(JSON.stringify(value), {
193230
status,
@@ -243,4 +280,5 @@ export const __handlers = {
243280
cliWhoamiHandler,
244281
cliUploadUrlHandler,
245282
cliPublishHandler,
283+
cliSkillDeleteHandler,
246284
}

convex/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const skills = defineTable({
3030
ownerUserId: v.id('users'),
3131
latestVersionId: v.optional(v.id('skillVersions')),
3232
tags: v.record(v.string(), v.id('skillVersions')),
33+
softDeletedAt: v.optional(v.number()),
3334
badges: v.object({
3435
redactionApproved: v.optional(
3536
v.object({

convex/search.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const hydrateResults = internalQuery({
5858
const embedding = await ctx.db.get(embeddingId)
5959
if (!embedding) continue
6060
const skill = await ctx.db.get(embedding.skillId)
61+
if (skill?.softDeletedAt) continue
6162
const version = await ctx.db.get(embedding.versionId)
6263
entries.push({ embeddingId, skill, version })
6364
}

convex/skills.ts

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const getBySlug = query({
4848
.query('skills')
4949
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
5050
.unique()
51-
if (!skill) return null
51+
if (!skill || skill.softDeletedAt) return null
5252
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
5353
const owner = await ctx.db.get(skill.ownerUserId)
5454
return { skill, latestVersion, owner }
@@ -74,21 +74,27 @@ export const list = query({
7474
handler: async (ctx, args) => {
7575
const limit = args.limit ?? 24
7676
if (args.batch) {
77-
return ctx.db
77+
const entries = await ctx.db
7878
.query('skills')
7979
.withIndex('by_batch', (q) => q.eq('batch', args.batch))
8080
.order('desc')
81-
.take(limit)
81+
.take(limit * 5)
82+
return entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
8283
}
8384
const ownerUserId = args.ownerUserId
8485
if (ownerUserId) {
85-
return ctx.db
86+
const entries = await ctx.db
8687
.query('skills')
8788
.withIndex('by_owner', (q) => q.eq('ownerUserId', ownerUserId))
8889
.order('desc')
89-
.take(limit)
90+
.take(limit * 5)
91+
return entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
9092
}
91-
return ctx.db.query('skills').order('desc').take(limit)
93+
const entries = await ctx.db
94+
.query('skills')
95+
.order('desc')
96+
.take(limit * 5)
97+
return entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
9298
},
9399
})
94100

@@ -164,11 +170,7 @@ export async function publishVersionForUser(
164170
if (!semver.valid(version)) {
165171
throw new ConvexError('Version must be valid semver')
166172
}
167-
const existingSkill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
168173
const changelogText = args.changelog.trim()
169-
if (existingSkill && !changelogText) {
170-
throw new ConvexError('Changelog is required for updates')
171-
}
172174

173175
const sanitizedFiles = args.files.map((file) => ({
174176
...file,
@@ -418,6 +420,7 @@ export const insertVersion = internalMutation({
418420
ownerUserId: userId,
419421
latestVersionId: undefined,
420422
tags: {},
423+
softDeletedAt: undefined,
421424
badges: { redactionApproved: undefined },
422425
stats: { downloads: 0, stars: 0, versions: 0, comments: 0 },
423426
createdAt: now,
@@ -461,6 +464,7 @@ export const insertVersion = internalMutation({
461464
latestVersionId: versionId,
462465
tags: nextTags,
463466
stats: { ...skill.stats, versions: skill.stats.versions + 1 },
467+
softDeletedAt: undefined,
464468
updatedAt: now,
465469
})
466470

@@ -493,6 +497,61 @@ export const insertVersion = internalMutation({
493497
},
494498
})
495499

500+
export const setSkillSoftDeletedInternal = internalMutation({
501+
args: {
502+
userId: v.id('users'),
503+
slug: v.string(),
504+
deleted: v.boolean(),
505+
},
506+
handler: async (ctx, args) => {
507+
const user = await ctx.db.get(args.userId)
508+
if (!user || user.deletedAt) throw new Error('User not found')
509+
510+
const slug = args.slug.trim().toLowerCase()
511+
if (!slug) throw new Error('Slug required')
512+
513+
const skill = await ctx.db
514+
.query('skills')
515+
.withIndex('by_slug', (q) => q.eq('slug', slug))
516+
.unique()
517+
if (!skill) throw new Error('Skill not found')
518+
519+
if (skill.ownerUserId !== args.userId) {
520+
assertRole(user, ['admin', 'moderator'])
521+
}
522+
523+
const now = Date.now()
524+
await ctx.db.patch(skill._id, {
525+
softDeletedAt: args.deleted ? now : undefined,
526+
updatedAt: now,
527+
})
528+
529+
const embeddings = await ctx.db
530+
.query('skillEmbeddings')
531+
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
532+
.collect()
533+
for (const embedding of embeddings) {
534+
await ctx.db.patch(embedding._id, {
535+
visibility: args.deleted
536+
? 'deleted'
537+
: visibilityFor(embedding.isLatest, embedding.isApproved),
538+
updatedAt: now,
539+
})
540+
}
541+
542+
await ctx.db.insert('auditLogs', {
543+
actorUserId: args.userId,
544+
action: args.deleted ? 'skill.delete' : 'skill.undelete',
545+
targetType: 'skill',
546+
targetId: skill._id,
547+
metadata: { slug, softDeletedAt: args.deleted ? now : null },
548+
createdAt: now,
549+
})
550+
551+
return { ok: true as const }
552+
},
553+
})
554+
496555
async function fetchText(
497556
ctx: { storage: { get: (id: Id<'_storage'>) => Promise<Blob | null> } },
498557
storageId: Id<'_storage'>,

docs/manual-testing.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Manual testing (CLI)
2+
3+
## Setup
4+
- Ensure logged in: `bun clawdhub whoami` (or `bun clawdhub login`).
5+
- Optional: set env
6+
- `CLAWDHUB_SITE=https://clawdhub.com`
7+
- `CLAWDHUB_REGISTRY=https://clawdhub.com`
8+
9+
## Smoke
10+
- `bun clawdhub --help`
11+
- `bun clawdhub --cli-version`
12+
- `bun clawdhub whoami`
13+
14+
## Search
15+
- `bun clawdhub search gif --limit 5`
16+
17+
## Install / list / update
18+
- `mkdir -p /tmp/clawdhub-manual && cd /tmp/clawdhub-manual`
19+
- `bunx clawdhub@beta install gifgrep --force`
20+
- `bunx clawdhub@beta list`
21+
- `bunx clawdhub@beta update gifgrep --force`
22+
23+
## Publish (changelog optional)
24+
- `mkdir -p /tmp/clawdhub-skill-demo/SKILL && cd /tmp/clawdhub-skill-demo`
25+
- Create files:
26+
- `SKILL.md`
27+
- `notes.md`
28+
- Publish:
29+
- `bun clawdhub publish . --slug clawdhub-manual-<ts> --name "Manual <ts>" --version 1.0.0 --tags latest`
30+
- Publish update with empty changelog:
31+
- `bun clawdhub publish . --slug clawdhub-manual-<ts> --name "Manual <ts>" --version 1.0.1 --tags latest`
32+
33+
## Delete / undelete (owner/admin)
34+
- `bun clawdhub delete clawdhub-manual-<ts> --yes`
35+
- Verify hidden:
36+
- `curl -i "https://clawdhub.com/api/skill?slug=clawdhub-manual-<ts>"`
37+
- Restore:
38+
- `bun clawdhub undelete clawdhub-manual-<ts> --yes`
39+
- Cleanup:
40+
- `bun clawdhub delete clawdhub-manual-<ts> --yes`
41+
42+
## Sync
43+
- `bun clawdhub sync --dry-run --all`

docs/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
9797
- Each upload is a new `SkillVersion`.
9898
- `latest` tag always points to most recent version unless user re-tags.
9999
- Rollback: move `latest` (and optionally other tags) to an older version.
100-
- Changelog required for any update.
100+
- Changelog is optional.
101101

102102
## Search
103103
- Vector search over: SKILL.md + other text files + metadata summary.

0 commit comments

Comments
 (0)