Compare commits

...
7 Commits
Author SHA1 Message Date
Peter Steinberger 541f96cbbf chore: release 0.0.2 2026-01-04 18:16:44 +01:00
Peter Steinberger 3af9cdfc0c fix: dedupe sync list and add tests 2026-01-04 17:57:40 +01:00
Peter Steinberger b974bc7c0b fix: add parsing progress for sync 2026-01-04 17:47:59 +01:00
Peter Steinberger 91233dfe57 fix: improve sync selection output 2026-01-04 17:30:03 +01:00
Peter Steinberger 2a2749f852 fix: improve zip upload handling 2026-01-04 16:45:56 +01:00
Peter Steinberger 56230aa329 test: expand cli coverage
- add e2e coverage for install/update + --cli-version
- add unit coverage for sync optional changelog
- add handler tests for delete/undelete
2026-01-04 15:55:49 +01:00
Peter Steinberger b93bb5830b 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
2026-01-04 15:50:09 +01:00
34 changed files with 1253 additions and 151 deletions
+27
View File
@@ -0,0 +1,27 @@
# Changelog
## 0.0.2 - 2026-01-04
### Added
- CLI: delete/undelete commands for soft-deleted skills (owner/admin).
### Fixed
- CLI sync: dedupe duplicate slugs across scan roots; skip duplicates to avoid double-publish errors.
- CLI sync: show parsing progress while hashing local skills.
- CLI sync: prompt only actionable skills; preselect all by default; list synced separately; condensed synced summary when nothing to sync.
- CLI sync: cap long status lists to avoid massive terminal boxes.
- CLI publish/sync: allow empty changelog on updates; registry accepts empty changelog for updates.
- CLI: use `--cli-version` to avoid conflict with skill `--version` flags.
- Registry: hide soft-deleted skills from search/skill/download unless restored.
- Tests: add delete/undelete coverage (unit + e2e).
## 0.0.1 - 2026-01-04
### Features
- CLI auth: login/logout/whoami; browser loopback auth; token storage; site/registry discovery; config overrides.
- CLI workflow: search, install, update (single/all), list, publish, sync (scan workdir + legacy roots), dry-run, version bumping, tags.
- Registry/API: skills + versions with semver; tags (latest + custom); changelog per version; SKILL.md frontmatter parsing; text-only validation; zip download; hash resolve; stats (downloads/stars/versions/comments).
- Web app: home (highlighted + latest), search, skill detail (README, versions, tags, stats, files), upload UI, user profiles, stars, settings (profile + API tokens + delete account).
- Social: stars + comments with moderation hooks; admin console for roles + highlighted curation.
- Search: semantic/vector search over skill content with limit/approved filters.
- Security: GitHub OAuth; role-based access (admin/moderator/user); audit logging for admin actions.
+14
View File
@@ -4,6 +4,8 @@ import { auth } from './auth'
import { downloadZip } from './downloads'
import {
cliPublishHttp,
cliSkillDeleteHttp,
cliSkillUndeleteHttp,
cliUploadUrlHttp,
cliWhoamiHttp,
getSkillHttp,
@@ -57,4 +59,16 @@ http.route({
handler: cliPublishHttp,
})
http.route({
path: ApiRoutes.cliSkillDelete,
method: 'POST',
handler: cliSkillDeleteHttp,
})
http.route({
path: ApiRoutes.cliSkillUndelete,
method: 'POST',
handler: cliSkillUndeleteHttp,
})
export default http
+67
View File
@@ -242,4 +242,71 @@ describe('httpApi handlers', () => {
expect(json.ok).toBe(true)
expect(json.skillId).toBe('s')
})
it('cliSkillDeleteHandler returns 401 when unauthorized', async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
const request = new Request('https://x/api/cli/skill/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'demo' }),
})
const response = await __handlers.cliSkillDeleteHandler({} as never, request, true)
expect(response.status).toBe(401)
})
it('cliSkillDeleteHandler calls mutation and returns ok', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const runMutation = vi.fn().mockResolvedValue({ ok: true })
const request = new Request('https://x/api/cli/skill/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'demo' }),
})
const response = await __handlers.cliSkillDeleteHandler({ runMutation } as never, request, true)
expect(response.status).toBe(200)
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
userId: 'user1',
slug: 'demo',
deleted: true,
})
expect(await response.json()).toEqual({ ok: true })
})
it('cliSkillDeleteHandler supports undelete', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const runMutation = vi.fn().mockResolvedValue({ ok: true })
const request = new Request('https://x/api/cli/skill/undelete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'demo' }),
})
const response = await __handlers.cliSkillDeleteHandler(
{ runMutation } as never,
request,
false,
)
expect(response.status).toBe(200)
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
userId: 'user1',
slug: 'demo',
deleted: false,
})
})
it('cliSkillDeleteHandler returns 400 on invalid json', async () => {
const request = new Request('https://x/api/cli/skill/delete', { method: 'POST', body: '{' })
const response = await __handlers.cliSkillDeleteHandler({} as never, request, true)
expect(response.status).toBe(400)
})
it('cliSkillDeleteHandler returns 400 on invalid payload', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
const request = new Request('https://x/api/cli/skill/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const response = await __handlers.cliSkillDeleteHandler({} as never, request, true)
expect(response.status).toBe(400)
})
})
+39 -1
View File
@@ -1,4 +1,9 @@
import { CliPublishRequestSchema, parseArk } from 'clawdhub-schema'
import {
ApiCliSkillDeleteResponseSchema,
CliPublishRequestSchema,
CliSkillDeleteRequestSchema,
parseArk,
} from 'clawdhub-schema'
import { api, internal } from './_generated/api'
import type { Id } from './_generated/dataModel'
import { httpAction } from './_generated/server'
@@ -188,6 +193,38 @@ async function cliPublishHandler(ctx: HttpCtx, request: Request) {
export const cliPublishHttp = httpAction(cliPublishHandler)
async function cliSkillDeleteHandler(ctx: HttpCtx, request: Request, deleted: boolean) {
let body: unknown
try {
body = await request.json()
} catch {
return text('Invalid JSON', 400)
}
try {
const { userId } = await requireApiTokenUser(ctx, request)
const args = parseArk(CliSkillDeleteRequestSchema, body, 'Delete payload')
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug: args.slug,
deleted,
})
const ok = parseArk(ApiCliSkillDeleteResponseSchema, { ok: true }, 'Delete response')
return json(ok)
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed'
if (message.toLowerCase().includes('unauthorized')) return text('Unauthorized', 401)
return text(message, 400)
}
}
export const cliSkillDeleteHttp = httpAction((ctx, request) =>
cliSkillDeleteHandler(ctx, request, true),
)
export const cliSkillUndeleteHttp = httpAction((ctx, request) =>
cliSkillDeleteHandler(ctx, request, false),
)
function json(value: unknown, status = 200) {
return new Response(JSON.stringify(value), {
status,
@@ -243,4 +280,5 @@ export const __handlers = {
cliWhoamiHandler,
cliUploadUrlHandler,
cliPublishHandler,
cliSkillDeleteHandler,
}
+1
View File
@@ -30,6 +30,7 @@ const skills = defineTable({
ownerUserId: v.id('users'),
latestVersionId: v.optional(v.id('skillVersions')),
tags: v.record(v.string(), v.id('skillVersions')),
softDeletedAt: v.optional(v.number()),
badges: v.object({
redactionApproved: v.optional(
v.object({
+1
View File
@@ -58,6 +58,7 @@ export const hydrateResults = internalQuery({
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 })
}
+69 -10
View File
@@ -48,7 +48,7 @@ export const getBySlug = query({
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (!skill) return null
if (!skill || skill.softDeletedAt) return null
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
const owner = await ctx.db.get(skill.ownerUserId)
return { skill, latestVersion, owner }
@@ -74,21 +74,27 @@ export const list = query({
handler: async (ctx, args) => {
const limit = args.limit ?? 24
if (args.batch) {
return ctx.db
const entries = await ctx.db
.query('skills')
.withIndex('by_batch', (q) => q.eq('batch', args.batch))
.order('desc')
.take(limit)
.take(limit * 5)
return entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
}
const ownerUserId = args.ownerUserId
if (ownerUserId) {
return ctx.db
const entries = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', ownerUserId))
.order('desc')
.take(limit)
.take(limit * 5)
return entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
}
return ctx.db.query('skills').order('desc').take(limit)
const entries = await ctx.db
.query('skills')
.order('desc')
.take(limit * 5)
return entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
},
})
@@ -164,11 +170,7 @@ export async function publishVersionForUser(
if (!semver.valid(version)) {
throw new ConvexError('Version must be valid semver')
}
const existingSkill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
const changelogText = args.changelog.trim()
if (existingSkill && !changelogText) {
throw new ConvexError('Changelog is required for updates')
}
const sanitizedFiles = args.files.map((file) => ({
...file,
@@ -418,6 +420,7 @@ export const insertVersion = internalMutation({
ownerUserId: userId,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
stats: { downloads: 0, stars: 0, versions: 0, comments: 0 },
createdAt: now,
@@ -461,6 +464,7 @@ export const insertVersion = internalMutation({
latestVersionId: versionId,
tags: nextTags,
stats: { ...skill.stats, versions: skill.stats.versions + 1 },
softDeletedAt: undefined,
updatedAt: now,
})
@@ -493,6 +497,61 @@ export const insertVersion = internalMutation({
},
})
export const setSkillSoftDeletedInternal = internalMutation({
args: {
userId: v.id('users'),
slug: v.string(),
deleted: v.boolean(),
},
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId)
if (!user || user.deletedAt) throw new Error('User not found')
const slug = args.slug.trim().toLowerCase()
if (!slug) throw new Error('Slug required')
const skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (!skill) throw new Error('Skill not found')
if (skill.ownerUserId !== args.userId) {
assertRole(user, ['admin', 'moderator'])
}
const now = Date.now()
await ctx.db.patch(skill._id, {
softDeletedAt: args.deleted ? now : undefined,
updatedAt: now,
})
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
visibility: args.deleted
? 'deleted'
: visibilityFor(embedding.isLatest, embedding.isApproved),
updatedAt: now,
})
}
await ctx.db.insert('auditLogs', {
actorUserId: args.userId,
action: args.deleted ? 'skill.delete' : 'skill.undelete',
targetType: 'skill',
targetId: skill._id,
metadata: { slug, softDeletedAt: args.deleted ? now : null },
createdAt: now,
})
return { ok: true as const }
},
})
async function fetchText(
ctx: { storage: { get: (id: Id<'_storage'>) => Promise<Blob | null> } },
storageId: Id<'_storage'>,
+43
View File
@@ -0,0 +1,43 @@
# Manual testing (CLI)
## Setup
- Ensure logged in: `bun clawdhub whoami` (or `bun clawdhub login`).
- Optional: set env
- `CLAWDHUB_SITE=https://clawdhub.com`
- `CLAWDHUB_REGISTRY=https://clawdhub.com`
## Smoke
- `bun clawdhub --help`
- `bun clawdhub --cli-version`
- `bun clawdhub whoami`
## Search
- `bun clawdhub search gif --limit 5`
## Install / list / update
- `mkdir -p /tmp/clawdhub-manual && cd /tmp/clawdhub-manual`
- `bunx clawdhub@beta install gifgrep --force`
- `bunx clawdhub@beta list`
- `bunx clawdhub@beta update gifgrep --force`
## Publish (changelog optional)
- `mkdir -p /tmp/clawdhub-skill-demo/SKILL && cd /tmp/clawdhub-skill-demo`
- Create files:
- `SKILL.md`
- `notes.md`
- Publish:
- `bun clawdhub publish . --slug clawdhub-manual-<ts> --name "Manual <ts>" --version 1.0.0 --tags latest`
- Publish update with empty changelog:
- `bun clawdhub publish . --slug clawdhub-manual-<ts> --name "Manual <ts>" --version 1.0.1 --tags latest`
## Delete / undelete (owner/admin)
- `bun clawdhub delete clawdhub-manual-<ts> --yes`
- Verify hidden:
- `curl -i "https://clawdhub.com/api/skill?slug=clawdhub-manual-<ts>"`
- Restore:
- `bun clawdhub undelete clawdhub-manual-<ts> --yes`
- Cleanup:
- `bun clawdhub delete clawdhub-manual-<ts> --yes`
## Sync
- `bun clawdhub sync --dry-run --all`
+1 -1
View File
@@ -97,7 +97,7 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- Each upload is a new `SkillVersion`.
- `latest` tag always points to most recent version unless user re-tags.
- Rollback: move `latest` (and optionally other tags) to an older version.
- Changelog required for any update.
- Changelog is optional.
## Search
- Vector search over: SKILL.md + other text files + metadata summary.
+246
View File
@@ -10,6 +10,7 @@ import {
ApiSearchResponseSchema,
parseArk,
} from 'clawdhub-schema'
import { unzipSync } from 'fflate'
import { describe, expect, it } from 'vitest'
import { readGlobalConfig } from '../packages/clawdhub/src/config'
@@ -31,6 +32,15 @@ async function makeTempConfig(registry: string, token: string | null) {
}
describe('clawdhub e2e', () => {
it('prints CLI version via --cli-version', async () => {
const result = spawnSync('bun', ['clawdhub', '--cli-version'], {
cwd: process.cwd(),
encoding: 'utf8',
})
expect(result.status).toBe(0)
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/)
})
it('search endpoint returns a results array (schema parse)', async () => {
const registry = process.env.CLAWDHUB_REGISTRY?.trim() || 'https://clawdhub.com'
const url = new URL(ApiRoutes.search, registry)
@@ -163,4 +173,240 @@ describe('clawdhub e2e', () => {
await rm(cfg.dir, { recursive: true, force: true })
}
})
it('publishes, deletes, and undeletes a skill (logged-in)', async () => {
const registry = process.env.CLAWDHUB_REGISTRY?.trim() || 'https://clawdhub.com'
const site = process.env.CLAWDHUB_SITE?.trim() || 'https://clawdhub.com'
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
if (!token) {
throw new Error('Missing token. Set CLAWDHUB_E2E_TOKEN or run: bun clawdhub auth login')
}
const cfg = await makeTempConfig(registry, token)
const workdir = await mkdtemp(join(tmpdir(), 'clawdhub-e2e-publish-'))
const installWorkdir = await mkdtemp(join(tmpdir(), 'clawdhub-e2e-install-'))
const slug = `e2e-${Date.now()}`
const skillDir = join(workdir, slug)
try {
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), `# ${slug}\n\nHello.\n`, 'utf8')
const publish1 = spawnSync(
'bun',
[
'clawdhub',
'publish',
skillDir,
'--slug',
slug,
'--name',
`E2E ${slug}`,
'--version',
'1.0.0',
'--tags',
'latest',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
encoding: 'utf8',
},
)
expect(publish1.status).toBe(0)
expect(publish1.stderr).not.toMatch(/changelog required/i)
const publish2 = spawnSync(
'bun',
[
'clawdhub',
'publish',
skillDir,
'--slug',
slug,
'--name',
`E2E ${slug}`,
'--version',
'1.0.1',
'--tags',
'latest',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
encoding: 'utf8',
},
)
expect(publish2.status).toBe(0)
expect(publish2.stderr).not.toMatch(/changelog required/i)
const downloadUrl = new URL(ApiRoutes.download, registry)
downloadUrl.searchParams.set('slug', slug)
downloadUrl.searchParams.set('version', '1.0.1')
const zipRes = await fetch(downloadUrl.toString())
expect(zipRes.ok).toBe(true)
const zipBytes = new Uint8Array(await zipRes.arrayBuffer())
const unzipped = unzipSync(zipBytes)
expect(Object.keys(unzipped)).toContain('SKILL.md')
const install = spawnSync(
'bun',
[
'clawdhub',
'install',
slug,
'--version',
'1.0.0',
'--force',
'--site',
site,
'--registry',
registry,
'--workdir',
installWorkdir,
],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
encoding: 'utf8',
},
)
expect(install.status).toBe(0)
const list = spawnSync(
'bun',
['clawdhub', 'list', '--site', site, '--registry', registry, '--workdir', installWorkdir],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
encoding: 'utf8',
},
)
expect(list.status).toBe(0)
expect(list.stdout).toMatch(new RegExp(`${slug}\\s+1\\.0\\.0`))
const update = spawnSync(
'bun',
[
'clawdhub',
'update',
slug,
'--force',
'--site',
site,
'--registry',
registry,
'--workdir',
installWorkdir,
],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
encoding: 'utf8',
},
)
expect(update.status).toBe(0)
const metaUrl = new URL(ApiRoutes.skill, registry)
metaUrl.searchParams.set('slug', slug)
const metaRes = await fetch(metaUrl.toString(), { headers: { Accept: 'application/json' } })
expect(metaRes.status).toBe(200)
const del = spawnSync(
'bun',
[
'clawdhub',
'delete',
slug,
'--yes',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
encoding: 'utf8',
},
)
expect(del.status).toBe(0)
const metaAfterDelete = await fetch(metaUrl.toString(), {
headers: { Accept: 'application/json' },
})
expect(metaAfterDelete.status).toBe(404)
const downloadAfterDelete = await fetch(downloadUrl.toString())
expect(downloadAfterDelete.status).toBe(404)
const undelete = spawnSync(
'bun',
[
'clawdhub',
'undelete',
slug,
'--yes',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
encoding: 'utf8',
},
)
expect(undelete.status).toBe(0)
const metaAfterUndelete = await fetch(metaUrl.toString(), {
headers: { Accept: 'application/json' },
})
expect(metaAfterUndelete.status).toBe(200)
} finally {
const cleanup = spawnSync(
'bun',
[
'clawdhub',
'delete',
slug,
'--yes',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
encoding: 'utf8',
},
)
if (cleanup.status !== 0) {
// best-effort cleanup
}
await rm(workdir, { recursive: true, force: true })
await rm(installWorkdir, { recursive: true, force: true })
await rm(cfg.dir, { recursive: true, force: true })
}
}, 180_000)
})
+1 -1
View File
@@ -21,7 +21,7 @@
},
"dependencies": {
"@auth/core": "^0.41.1",
"clawdhub-schema": "^0.0.1",
"clawdhub-schema": "^0.0.2",
"@convex-dev/auth": "^0.0.90",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "clawdhub",
"version": "0.0.1",
"version": "0.0.2",
"description": "ClawdHub CLI — install, update, search, and publish agent skills.",
"license": "MIT",
"type": "module",
@@ -20,7 +20,7 @@
},
"dependencies": {
"@clack/prompts": "^0.11.0",
"clawdhub-schema": "^0.0.1",
"clawdhub-schema": "^0.0.2",
"commander": "^14.0.2",
"fflate": "^0.8.2",
"ignore": "^7.0.5",
+22 -1
View File
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
import { Command } from 'commander'
import { getCliBuildLabel, getCliVersion } from './cli/buildInfo.js'
import { cmdLoginFlow, cmdLogout, cmdWhoami } from './cli/commands/auth.js'
import { cmdDeleteSkill, cmdUndeleteSkill } from './cli/commands/delete.js'
import { cmdPublish } from './cli/commands/publish.js'
import { cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
import { cmdSync } from './cli/commands/sync.js'
@@ -19,7 +20,7 @@ const program = new Command()
'install, update, search, and publish agent skills.',
)}`,
)
.version(getCliVersion(), '-V, --version', 'Show version')
.version(getCliVersion(), '-V, --cli-version', 'Show CLI version')
.option('--workdir <dir>', 'Working directory (default: cwd)')
.option('--dir <dir>', 'Skills directory (relative to workdir, default: skills)')
.option('--site <url>', 'Site base URL (for browser login)')
@@ -161,6 +162,26 @@ program
await cmdPublish(opts, folder, options)
})
program
.command('delete')
.description('Soft-delete a skill (owner/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
const opts = resolveGlobalOpts()
await cmdDeleteSkill(opts, slug, options, isInputAllowed())
})
program
.command('undelete')
.description('Restore a soft-deleted skill (owner/admin only)')
.argument('<slug>', 'Skill slug')
.option('--yes', 'Skip confirmation')
.action(async (slug, options) => {
const opts = resolveGlobalOpts()
await cmdUndeleteSkill(opts, slug, options, isInputAllowed())
})
program
.command('sync')
.description('Scan local skills and publish new/updated ones')
@@ -0,0 +1,72 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalOpts } from '../types'
vi.mock('../../config.js', () => ({
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawdhub.com', token: 'tkn' })),
}))
vi.mock('../registry.js', () => ({
getRegistry: vi.fn(async () => 'https://clawdhub.com'),
}))
const mockApiRequest = vi.fn()
vi.mock('../../http.js', () => ({
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
mockApiRequest(registry, args, schema),
}))
const mockFail = vi.fn((message: string) => {
throw new Error(message)
})
vi.mock('../ui.js', () => ({
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn() })),
fail: (message: string) => mockFail(message),
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
isInteractive: () => false,
promptConfirm: vi.fn(async () => true),
}))
const { cmdDeleteSkill, cmdUndeleteSkill } = await import('./delete')
function makeOpts(): GlobalOpts {
return {
workdir: '/work',
dir: '/work/skills',
site: 'https://clawdhub.com',
registry: 'https://clawdhub.com',
}
}
afterEach(() => {
vi.clearAllMocks()
})
describe('delete/undelete', () => {
it('requires --yes when input is disabled', async () => {
await expect(cmdDeleteSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
await expect(cmdUndeleteSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
})
it('calls delete endpoint with --yes', async () => {
mockApiRequest.mockResolvedValueOnce({ ok: true })
await cmdDeleteSkill(makeOpts(), 'demo', { yes: true }, false)
expect(mockApiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: 'POST', path: '/api/cli/skill/delete' }),
expect.anything(),
)
})
it('calls undelete endpoint with --yes', async () => {
mockApiRequest.mockResolvedValueOnce({ ok: true })
await cmdUndeleteSkill(makeOpts(), 'demo', { yes: true }, false)
expect(mockApiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: 'POST', path: '/api/cli/skill/undelete' }),
expect.anything(),
)
})
})
@@ -0,0 +1,81 @@
import { ApiCliSkillDeleteResponseSchema, ApiRoutes, parseArk } from 'clawdhub-schema'
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
async function requireToken() {
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawdhub login')
return token
}
export async function cmdDeleteSkill(
opts: GlobalOpts,
slugArg: string,
options: { yes?: boolean },
inputAllowed: boolean,
) {
const slug = slugArg.trim().toLowerCase()
if (!slug) fail('Slug required')
const allowPrompt = isInteractive() && inputAllowed !== false
if (!options.yes) {
if (!allowPrompt) fail('Pass --yes (no input)')
const ok = await promptConfirm(`Delete ${slug}? (soft delete)`)
if (!ok) return
}
const token = await requireToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`Deleting ${slug}`)
try {
const body = { slug }
const result = await apiRequest(
registry,
{ method: 'POST', path: ApiRoutes.cliSkillDelete, token, body },
ApiCliSkillDeleteResponseSchema,
)
spinner.succeed(`OK. Deleted ${slug}`)
return parseArk(ApiCliSkillDeleteResponseSchema, result, 'Delete response')
} catch (error) {
spinner.fail(formatError(error))
throw error
}
}
export async function cmdUndeleteSkill(
opts: GlobalOpts,
slugArg: string,
options: { yes?: boolean },
inputAllowed: boolean,
) {
const slug = slugArg.trim().toLowerCase()
if (!slug) fail('Slug required')
const allowPrompt = isInteractive() && inputAllowed !== false
if (!options.yes) {
if (!allowPrompt) fail('Pass --yes (no input)')
const ok = await promptConfirm(`Undelete ${slug}?`)
if (!ok) return
}
const token = await requireToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`Undeleting ${slug}`)
try {
const body = { slug }
const result = await apiRequest(
registry,
{ method: 'POST', path: ApiRoutes.cliSkillUndelete, token, body },
ApiCliSkillDeleteResponseSchema,
)
spinner.succeed(`OK. Undeleted ${slug}`)
return parseArk(ApiCliSkillDeleteResponseSchema, result, 'Undelete response')
} catch (error) {
spinner.fail(formatError(error))
throw error
}
}
@@ -133,22 +133,47 @@ describe('cmdPublish', () => {
}
})
it('requires --changelog when updating an existing skill', async () => {
it('allows empty changelog when updating an existing skill', async () => {
const workdir = await makeTmpWorkdir()
try {
const folder = join(workdir, 'existing-skill')
await mkdir(folder, { recursive: true })
await writeFile(join(folder, 'SKILL.md'), '# Skill\n', 'utf8')
mockApiRequest.mockImplementation(async () => ({ skill: { slug: 'existing-skill' } }))
let uploadIndex = 0
mockApiRequest.mockImplementation(
async (_registry: string, args: { method: string; path: string }) => {
if (args.method === 'GET' && args.path.startsWith('/api/skill?slug=')) {
return { skill: { slug: 'existing-skill' }, latestVersion: { version: '1.0.0' } }
}
if (args.method === 'POST' && args.path === '/api/cli/upload-url') {
uploadIndex += 1
return { uploadUrl: `https://upload.example/${uploadIndex}` }
}
if (args.method === 'POST' && args.path === '/api/cli/publish') {
return { ok: true, skillId: 'skill_1', versionId: 'ver_2' }
}
throw new Error(`Unexpected apiRequest: ${args.method} ${args.path}`)
},
)
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('{}', { status: 200 })) as unknown as typeof fetch,
vi.fn(
async () => new Response(JSON.stringify({ storageId: 'st_1' }), { status: 200 }),
) as unknown as typeof fetch,
)
await expect(
cmdPublish(makeOpts(workdir), 'existing-skill', { version: '1.0.0', changelog: '' }),
).rejects.toThrow(/changelog/i)
await cmdPublish(makeOpts(workdir), 'existing-skill', {
version: '1.0.1',
changelog: '',
tags: 'latest',
})
expect(mockApiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ path: '/api/cli/publish', method: 'POST' }),
expect.anything(),
)
} finally {
await rm(workdir, { recursive: true, force: true })
}
@@ -4,7 +4,6 @@ import {
ApiCliPublishResponseSchema,
ApiCliUploadUrlResponseSchema,
ApiRoutes,
ApiSkillMetaResponseSchema,
ApiUploadFileResponseSchema,
CliPublishRequestSchema,
parseArk,
@@ -49,14 +48,6 @@ export async function cmdPublish(
const spinner = createSpinner(`Preparing ${slug}@${version}`)
try {
const meta = await apiRequest(
registry,
{ method: 'GET', path: `/api/skill?slug=${encodeURIComponent(slug)}` },
ApiSkillMetaResponseSchema,
).catch(() => null)
const exists = Boolean(meta?.skill)
if (exists && !changelog.trim()) fail('--changelog required for updates')
const filesOnDisk = await listTextFiles(folder)
if (filesOnDisk.length === 0) fail('No files found')
if (
+146 -11
View File
@@ -6,12 +6,23 @@ import type { GlobalOpts } from '../types'
const mockIntro = vi.fn()
const mockOutro = vi.fn()
const mockNote = vi.fn()
const mockMultiselect = vi.fn<Promise<string[]>, [unknown?]>(async () => [])
let interactive = false
const defaultFindSkillFolders = async (root: string) => {
if (!root.endsWith('/scan')) return []
return [
{ folder: '/scan/new-skill', slug: 'new-skill', displayName: 'New Skill' },
{ folder: '/scan/synced-skill', slug: 'synced-skill', displayName: 'Synced Skill' },
{ folder: '/scan/update-skill', slug: 'update-skill', displayName: 'Update Skill' },
]
}
vi.mock('@clack/prompts', () => ({
intro: (value: string) => mockIntro(value),
outro: (value: string) => mockOutro(value),
note: (message: string, body?: string) => mockNote(message, body),
multiselect: vi.fn(async () => []),
multiselect: (args: unknown) => mockMultiselect(args),
text: vi.fn(async () => ''),
isCancel: () => false,
}))
@@ -39,18 +50,11 @@ vi.mock('../ui.js', () => ({
createSpinner: vi.fn(() => mockSpinner),
fail: (message: string) => mockFail(message),
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
isInteractive: () => false,
isInteractive: () => interactive,
}))
vi.mock('../scanSkills.js', () => ({
findSkillFolders: vi.fn(async (root: string) => {
if (!root.endsWith('/scan')) return []
return [
{ folder: '/scan/new-skill', slug: 'new-skill', displayName: 'New Skill' },
{ folder: '/scan/synced-skill', slug: 'synced-skill', displayName: 'Synced Skill' },
{ folder: '/scan/update-skill', slug: 'update-skill', displayName: 'Update Skill' },
]
}),
findSkillFolders: vi.fn(defaultFindSkillFolders),
getFallbackSkillRoots: vi.fn(() => []),
}))
@@ -80,12 +84,15 @@ function makeOpts(): GlobalOpts {
}
}
afterEach(() => {
afterEach(async () => {
vi.clearAllMocks()
const { findSkillFolders } = await import('../scanSkills.js')
vi.mocked(findSkillFolders).mockImplementation(defaultFindSkillFolders)
})
describe('cmdSync', () => {
it('classifies skills as new/update/synced (dry-run, mocked HTTP)', async () => {
interactive = false
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
if (args.path.startsWith('/api/skill?slug=')) {
@@ -117,4 +124,132 @@ describe('cmdSync', () => {
const dryRunOutro = mockOutro.mock.calls.at(-1)?.[0]
expect(String(dryRunOutro)).toMatch(/Dry run: would upload 2 skill/)
})
it('prints bullet lists and selects all actionable by default', async () => {
interactive = true
mockMultiselect.mockImplementation(async (args?: unknown) => {
const { initialValues } = args as { initialValues: string[] }
return initialValues
})
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
if (args.path.startsWith('/api/skill?slug=')) {
const slug = new URL(`https://x.test${args.path}`).searchParams.get('slug')
if (slug === 'new-skill') return { latestVersion: undefined, skill: null }
if (slug === 'synced-skill') return { latestVersion: { version: '1.2.3' }, skill: {} }
if (slug === 'update-skill') return { latestVersion: { version: '1.0.0' }, skill: {} }
}
if (args.path.startsWith('/api/skill/resolve?')) {
const u = new URL(`https://x.test${args.path}`)
const slug = u.searchParams.get('slug')
if (slug === 'synced-skill') {
return { match: { version: '1.2.3' }, latestVersion: { version: '1.2.3' } }
}
if (slug === 'update-skill') {
return { match: null, latestVersion: { version: '1.0.0' } }
}
}
throw new Error(`Unexpected apiRequest: ${args.path}`)
})
await cmdSync(makeOpts(), { root: ['/scan'], all: false, dryRun: false, bump: 'patch' }, true)
const toSyncNote = mockNote.mock.calls.find((call) => call[0] === 'To sync')
expect(toSyncNote?.[1]).toMatch(/- new-skill/)
expect(toSyncNote?.[1]).toMatch(/- update-skill/)
const syncedNote = mockNote.mock.calls.find((call) => call[0] === 'Already synced')
expect(syncedNote?.[1]).toMatch(/- synced-skill/)
const lastCall = mockMultiselect.mock.calls.at(-1)
const promptArgs = lastCall ? (lastCall[0] as { initialValues: string[] }) : undefined
expect(promptArgs?.initialValues.length).toBe(2)
expect(mockCmdPublish).toHaveBeenCalledTimes(2)
})
it('shows condensed synced list when nothing to sync', async () => {
interactive = false
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
if (args.path.startsWith('/api/skill?slug=')) {
return { latestVersion: { version: '1.0.0' }, skill: {} }
}
if (args.path.startsWith('/api/skill/resolve?')) {
return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } }
}
throw new Error(`Unexpected apiRequest: ${args.path}`)
})
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: false }, true)
const syncedNote = mockNote.mock.calls.find((call) => call[0] === 'Already synced')
expect(syncedNote?.[1]).toMatch(/new-skill@1.0.0/)
expect(syncedNote?.[1]).toMatch(/synced-skill@1.0.0/)
expect(String(syncedNote?.[1])).not.toMatch(/\n-/)
const outro = mockOutro.mock.calls.at(-1)?.[0]
expect(String(outro)).toMatch(/Nothing to sync/)
})
it('dedupes duplicate slugs before publishing', async () => {
interactive = false
const { findSkillFolders } = await import('../scanSkills.js')
vi.mocked(findSkillFolders).mockImplementation(async (root: string) => {
if (!root.endsWith('/scan')) return []
return [
{ folder: '/scan/dup-skill', slug: 'dup-skill', displayName: 'Dup Skill' },
{ folder: '/scan/dup-skill-copy', slug: 'dup-skill', displayName: 'Dup Skill' },
]
})
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
if (args.path.startsWith('/api/skill?slug=')) {
return { latestVersion: undefined, skill: null }
}
if (args.path.startsWith('/api/skill/resolve?')) {
return { match: null, latestVersion: null }
}
throw new Error(`Unexpected apiRequest: ${args.path}`)
})
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: false }, true)
expect(mockCmdPublish).toHaveBeenCalledTimes(1)
const duplicateNote = mockNote.mock.calls.find((call) => call[0] === 'Skipped duplicate slugs')
expect(duplicateNote?.[1]).toMatch(/dup-skill/)
})
it('allows empty changelog for updates (interactive)', async () => {
interactive = true
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
if (args.path.startsWith('/api/skill?slug=')) {
const slug = new URL(`https://x.test${args.path}`).searchParams.get('slug')
if (slug === 'new-skill') return { latestVersion: undefined, skill: null }
if (slug === 'synced-skill') return { latestVersion: { version: '1.2.3' }, skill: {} }
if (slug === 'update-skill') return { latestVersion: { version: '1.0.0' }, skill: {} }
}
if (args.path.startsWith('/api/skill/resolve?')) {
const u = new URL(`https://x.test${args.path}`)
const slug = u.searchParams.get('slug')
if (slug === 'synced-skill') {
return { match: { version: '1.2.3' }, latestVersion: { version: '1.2.3' } }
}
if (slug === 'update-skill') {
return { match: null, latestVersion: { version: '1.0.0' } }
}
}
throw new Error(`Unexpected apiRequest: ${args.path}`)
})
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: false, bump: 'patch' }, true)
const calls = mockCmdPublish.mock.calls.map(
(call) => call[2] as { slug: string; changelog: string },
)
const update = calls.find((c) => c.slug === 'update-skill')
if (!update) throw new Error('Missing update-skill publish')
expect(update.changelog).toBe('')
})
})
+134 -67
View File
@@ -1,3 +1,4 @@
import { realpath } from 'node:fs/promises'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { intro, isCancel, multiselect, note, outro, text } from '@clack/prompts'
@@ -34,6 +35,11 @@ type Candidate = SkillFolder & {
latestVersion: string | null
}
type LocalSkill = SkillFolder & {
fingerprint: string
fileCount: number
}
export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) {
const allowPrompt = isInteractive() && inputAllowed !== false
intro('ClawdHub sync')
@@ -60,25 +66,41 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
} else {
spinner.stop()
}
let skills = scan.skills
skills = await maybeSelectLocalSkills(skills, {
allowPrompt,
all: Boolean(options.all),
})
if (skills.length === 0) {
outro('Nothing selected.')
return
const deduped = dedupeSkillsBySlug(scan.skills)
const skills = deduped.skills
if (deduped.duplicates.length > 0) {
note('Skipped duplicate slugs', formatCommaList(deduped.duplicates, 16))
}
const parsingSpinner = createSpinner('Parsing local skills')
const locals: LocalSkill[] = []
try {
let index = 0
for (const skill of skills) {
index += 1
parsingSpinner.text = `Parsing local skills ${index}/${skills.length}`
const filesOnDisk = await listTextFiles(skill.folder)
const hashed = hashSkillFiles(filesOnDisk)
locals.push({
...skill,
fingerprint: hashed.fingerprint,
fileCount: filesOnDisk.length,
})
}
} catch (error) {
parsingSpinner.fail(formatError(error))
throw error
} finally {
parsingSpinner.stop()
}
const candidatesSpinner = createSpinner('Checking registry sync state')
const candidates: Candidate[] = []
let supportsResolve: boolean | null = null
try {
for (const skill of skills) {
const filesOnDisk = await listTextFiles(skill.folder)
const hashed = hashSkillFiles(filesOnDisk)
const fingerprint = hashed.fingerprint
let index = 0
for (const skill of locals) {
index += 1
candidatesSpinner.text = `Checking registry sync state ${index}/${locals.length}`
const meta = await apiRequest(
registry,
@@ -90,8 +112,6 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
if (!latestVersion) {
candidates.push({
...skill,
fingerprint,
fileCount: filesOnDisk.length,
status: 'new',
matchVersion: null,
latestVersion: null,
@@ -106,7 +126,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
registry,
{
method: 'GET',
path: `${ApiRoutes.skillResolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(fingerprint)}`,
path: `${ApiRoutes.skillResolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`,
},
ApiSkillResolveResponseSchema,
)
@@ -127,13 +147,11 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
if (supportsResolve === false) {
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion })
const remote = hashSkillZip(zip).fingerprint
matchVersion = remote === fingerprint ? latestVersion : null
matchVersion = remote === skill.fingerprint ? latestVersion : null
}
candidates.push({
...skill,
fingerprint,
fileCount: filesOnDisk.length,
status: matchVersion ? 'synced' : 'update',
matchVersion,
latestVersion,
@@ -147,23 +165,32 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
}
const synced = candidates.filter((candidate) => candidate.status === 'synced')
if (synced.length > 0) {
const lines = synced
.map((candidate) => `${candidate.slug} synced (${candidate.matchVersion ?? 'unknown'})`)
.join('\n')
note('Already synced', lines)
const actionable = candidates.filter((candidate) => candidate.status !== 'synced')
const bump = options.bump ?? 'patch'
if (actionable.length === 0) {
if (synced.length > 0) {
note('Already synced', formatCommaList(synced.map(formatSyncedSummary), 16))
}
outro('Nothing to sync.')
return
}
const actionable = candidates.filter((candidate) => candidate.status !== 'synced')
if (actionable.length === 0) {
outro('Everything is already synced.')
return
note(
'To sync',
formatBulletList(
actionable.map((candidate) => formatActionableLine(candidate, bump)),
20,
),
)
if (synced.length > 0) {
note('Already synced', formatSyncedDisplay(synced))
}
const selected = await selectToUpload(actionable, {
allowPrompt,
all: Boolean(options.all),
bump: options.bump ?? 'patch',
bump,
})
if (selected.length === 0) {
outro('Nothing selected.')
@@ -175,7 +202,6 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
return
}
const bump = options.bump ?? 'patch'
const tags = options.tags ?? 'latest'
for (const skill of selected) {
@@ -204,7 +230,8 @@ function buildScanRoots(opts: GlobalOpts, extraRoots: string[] | undefined) {
async function scanRoots(roots: string[]) {
const all: SkillFolder[] = []
const rootsWithSkills: string[] = []
for (const root of roots) {
const uniqueRoots = await dedupeRoots(roots)
for (const root of uniqueRoots) {
const found = await findSkillFolders(root)
if (found.length > 0) rootsWithSkills.push(root)
all.push(...found)
@@ -216,32 +243,18 @@ async function scanRoots(roots: string[]) {
return { skills: Array.from(byFolder.values()), rootsWithSkills }
}
async function maybeSelectLocalSkills(
skills: SkillFolder[],
params: { allowPrompt: boolean; all: boolean },
): Promise<SkillFolder[]> {
if (params.all || !params.allowPrompt) return skills
if (skills.length <= 30) return skills
const valueByKey = new Map<string, SkillFolder>()
const choices = skills.map((skill) => {
const key = skill.folder
valueByKey.set(key, skill)
return {
value: key,
label: skill.slug,
hint: abbreviatePath(skill.folder),
}
})
const picked = await multiselect({
message: `Found ${skills.length} local skills — select what to sync`,
options: choices,
initialValues: [],
required: false,
})
if (isCancel(picked)) fail('Canceled')
return picked.map((key) => valueByKey.get(String(key))).filter(Boolean) as SkillFolder[]
async function dedupeRoots(roots: string[]) {
const seen = new Set<string>()
const unique: string[] = []
for (const root of roots) {
const resolved = resolve(root)
const canonical = await realpath(resolved).catch(() => null)
const key = canonical ?? resolved
if (seen.has(key)) continue
seen.add(key)
unique.push(key)
}
return unique
}
async function selectToUpload(
@@ -254,21 +267,17 @@ async function selectToUpload(
const choices = candidates.map((candidate) => {
const key = candidate.folder
valueByKey.set(key, candidate)
const latest = candidate.latestVersion
const next = latest ? semver.inc(latest, params.bump) : null
const status =
candidate.status === 'new' ? 'NEW' : latest && next ? `UPDATE ${latest}${next}` : 'UPDATE'
return {
value: key,
label: `${candidate.slug} ${status}`,
hint: candidate.folder,
label: `${candidate.slug} ${formatActionableStatus(candidate, params.bump)}`,
hint: `${abbreviatePath(candidate.folder)} | ${candidate.fileCount} files`,
}
})
const picked = await multiselect({
message: 'Select skills to upload',
options: choices,
initialValues: candidates.length <= 10 ? choices.map((choice) => choice.value) : [],
initialValues: choices.map((choice) => choice.value),
required: false,
})
if (isCancel(picked)) fail('Canceled')
@@ -297,13 +306,12 @@ async function resolvePublishMeta(
}
const entered = await text({
message: `Changelog for ${skill.slug}@${publishVersion}`,
message: `Changelog (optional) for ${skill.slug}@${publishVersion}`,
placeholder: 'What changed?',
defaultValue: 'Sync update',
defaultValue: '',
})
if (isCancel(entered)) fail('Canceled')
const changelog = String(entered ?? '').trim()
if (!changelog) fail('--changelog required for updates')
return { publishVersion, changelog }
}
@@ -331,3 +339,62 @@ function abbreviatePath(value: string) {
if (value.startsWith(home)) return `~${value.slice(home.length)}`
return value
}
function dedupeSkillsBySlug(skills: SkillFolder[]) {
const bySlug = new Map<string, SkillFolder[]>()
for (const skill of skills) {
const existing = bySlug.get(skill.slug)
if (existing) existing.push(skill)
else bySlug.set(skill.slug, [skill])
}
const unique: SkillFolder[] = []
const duplicates: string[] = []
for (const [slug, entries] of bySlug.entries()) {
unique.push(entries[0] as SkillFolder)
if (entries.length > 1) duplicates.push(`${slug} (${entries.length})`)
}
return { skills: unique, duplicates }
}
function formatActionableStatus(candidate: Candidate, bump: 'patch' | 'minor' | 'major'): string {
if (candidate.status === 'new') return 'NEW'
const latest = candidate.latestVersion
const next = latest ? semver.inc(latest, bump) : null
if (latest && next) return `UPDATE ${latest}${next}`
return 'UPDATE'
}
function formatActionableLine(candidate: Candidate, bump: 'patch' | 'minor' | 'major'): string {
return `${candidate.slug} ${formatActionableStatus(candidate, bump)} (${candidate.fileCount} files)`
}
function formatSyncedLine(candidate: Candidate): string {
const version = candidate.matchVersion ?? candidate.latestVersion ?? 'unknown'
return `${candidate.slug} synced (${version})`
}
function formatSyncedSummary(candidate: Candidate): string {
const version = candidate.matchVersion ?? candidate.latestVersion
return version ? `${candidate.slug}@${version}` : candidate.slug
}
function formatBulletList(lines: string[], max: number): string {
if (lines.length <= max) return lines.map((line) => `- ${line}`).join('\n')
const head = lines.slice(0, max)
const rest = lines.length - head.length
return [...head, `... +${rest} more`].map((line) => `- ${line}`).join('\n')
}
function formatSyncedDisplay(synced: Candidate[]) {
const lines = synced.map(formatSyncedLine)
if (lines.length <= 12) return formatBulletList(lines, 12)
return formatCommaList(synced.map(formatSyncedSummary), 24)
}
function formatCommaList(values: string[], max: number) {
if (values.length === 0) return ''
if (values.length <= max) return values.join(', ')
const head = values.slice(0, Math.max(1, max - 1))
const rest = values.length - head.length
return `${head.join(', ')}, ... +${rest} more`
}
+2
View File
@@ -6,4 +6,6 @@ export declare const ApiRoutes: {
readonly cliWhoami: "/api/cli/whoami";
readonly cliUploadUrl: "/api/cli/upload-url";
readonly cliPublish: "/api/cli/publish";
readonly cliSkillDelete: "/api/cli/skill/delete";
readonly cliSkillUndelete: "/api/cli/skill/undelete";
};
+2
View File
@@ -6,5 +6,7 @@ export const ApiRoutes = {
cliWhoami: '/api/cli/whoami',
cliUploadUrl: '/api/cli/upload-url',
cliPublish: '/api/cli/publish',
cliSkillDelete: '/api/cli/skill/delete',
cliSkillUndelete: '/api/cli/skill/undelete',
};
//# sourceMappingURL=routes.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;CACtB,CAAA"}
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA"}
+7
View File
@@ -77,6 +77,13 @@ export declare const ApiCliPublishResponseSchema: import("arktype/internal/varia
skillId: string;
versionId: string;
}, {}>;
export declare const CliSkillDeleteRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
slug: string;
}, {}>;
export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred];
export declare const ApiCliSkillDeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
}, {}>;
export declare const ApiSkillResolveResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
match: {
version: string;
+6
View File
@@ -66,6 +66,12 @@ export const ApiCliPublishResponseSchema = type({
skillId: 'string',
versionId: 'string',
});
export const CliSkillDeleteRequestSchema = type({
slug: 'string',
});
export const ApiCliSkillDeleteResponseSchema = type({
ok: 'true',
});
export const ApiSkillResolveResponseSchema = type({
match: type({ version: 'string' }).or('null'),
latestVersion: type({ version: 'string' }).or('null'),
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"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"}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawdhub-schema",
"version": "0.0.1",
"version": "0.0.2",
"type": "module",
"exports": {
".": {
+2
View File
@@ -6,4 +6,6 @@ export const ApiRoutes = {
cliWhoami: '/api/cli/whoami',
cliUploadUrl: '/api/cli/upload-url',
cliPublish: '/api/cli/publish',
cliSkillDelete: '/api/cli/skill/delete',
cliSkillUndelete: '/api/cli/skill/undelete',
} as const
+7
View File
@@ -5,6 +5,7 @@ import { parseArk } from './ark'
import {
ApiSearchResponseSchema,
CliPublishRequestSchema,
CliSkillDeleteRequestSchema,
LockfileSchema,
WellKnownConfigSchema,
} from './schemas'
@@ -89,4 +90,10 @@ describe('clawdhub-schema', () => {
expect(parsed.results).toHaveLength(2)
expect(parsed.results[0]?.slug).toBe('a')
})
it('parses delete request payload', () => {
expect(parseArk(CliSkillDeleteRequestSchema, { slug: 'demo' }, 'Delete')).toEqual({
slug: 'demo',
})
})
})
+9
View File
@@ -83,6 +83,15 @@ export const ApiCliPublishResponseSchema = type({
versionId: 'string',
})
export const CliSkillDeleteRequestSchema = type({
slug: 'string',
})
export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred]
export const ApiCliSkillDeleteResponseSchema = type({
ok: 'true',
})
export const ApiSkillResolveResponseSchema = type({
match: type({ version: 'string' }).or('null'),
latestVersion: type({ version: 'string' }).or('null'),
+59 -1
View File
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { strToU8, zipSync } from 'fflate'
import { vi } from 'vitest'
import { Upload } from '../routes/upload'
@@ -16,7 +17,6 @@ vi.mock('convex/react', () => ({
useConvexAuth: () => ({ isAuthenticated: true }),
useMutation: () => generateUploadUrl,
useAction: () => publishVersion,
useQuery: () => null,
}))
describe('Upload route', () => {
@@ -83,6 +83,64 @@ describe('Upload route', () => {
expect(await screen.findByText(/Add at least one file/i)).toBeTruthy()
})
it('extracts zip uploads and unwraps top-level folders', async () => {
render(<Upload />)
fireEvent.change(screen.getByPlaceholderText('my-skill-pack'), {
target: { value: 'cool-skill' },
})
fireEvent.change(screen.getByPlaceholderText('My Skill Pack'), {
target: { value: 'Cool Skill' },
})
fireEvent.change(screen.getByPlaceholderText('1.0.0'), {
target: { value: '1.2.3' },
})
fireEvent.change(screen.getByPlaceholderText('latest, beta'), {
target: { value: 'latest' },
})
const zip = zipSync({
'hetzner-cloud-skill/SKILL.md': new Uint8Array(strToU8('hello')),
'hetzner-cloud-skill/notes.txt': new Uint8Array(strToU8('notes')),
})
const zipBytes = zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength)
const zipFile = new File([zipBytes], 'bundle.zip', { type: 'application/zip' })
const input = screen.getByTestId('upload-input') as HTMLInputElement
fireEvent.change(input, { target: { files: [zipFile] } })
expect(await screen.findByText('notes.txt', {}, { timeout: 3000 })).toBeTruthy()
expect(screen.getByText('SKILL.md')).toBeTruthy()
expect(await screen.findByText(/Ready to publish/i, {}, { timeout: 3000 })).toBeTruthy()
})
it('blocks non-text folder uploads (png)', async () => {
render(<Upload />)
fireEvent.change(screen.getByPlaceholderText('my-skill-pack'), {
target: { value: 'cool-skill' },
})
fireEvent.change(screen.getByPlaceholderText('My Skill Pack'), {
target: { value: 'Cool Skill' },
})
fireEvent.change(screen.getByPlaceholderText('1.0.0'), {
target: { value: '1.2.3' },
})
fireEvent.change(screen.getByPlaceholderText('latest, beta'), {
target: { value: 'latest' },
})
const skill = new File(['hello'], 'SKILL.md', { type: 'text/markdown' })
const png = new File([new Uint8Array([137, 80, 78, 71]).buffer], 'screenshot.png', {
type: 'image/png',
})
const input = screen.getByTestId('upload-input') as HTMLInputElement
fireEvent.change(input, { target: { files: [skill, png] } })
expect(await screen.findByText('screenshot.png')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /publish/i }))
expect(await screen.findByText(/Remove non-text files: screenshot\.png/i)).toBeTruthy()
expect(screen.getByText('screenshot.png')).toBeTruthy()
})
it('surfaces publish errors and stays on page', async () => {
publishVersion.mockRejectedValueOnce(new Error('Changelog is required'))
generateUploadUrl.mockResolvedValue('https://upload.local')
+33
View File
@@ -0,0 +1,33 @@
import { strToU8, unzipSync, zipSync } from 'fflate'
import { describe, expect, it } from 'vitest'
import { expandFiles } from './uploadFiles'
function readWithFileReader(blob: Blob) {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error ?? new Error('Could not read blob.'))
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.readAsArrayBuffer(blob)
})
}
describe('expandFiles (jsdom)', () => {
it('expands zip archives using FileReader fallback', async () => {
const zip = zipSync({
'hetzner-cloud-skill/SKILL.md': new Uint8Array(strToU8('hello')),
'hetzner-cloud-skill/notes.txt': new Uint8Array(strToU8('notes')),
})
const zipBytes = zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength)
const zipFile = new File([zipBytes], 'bundle.zip', { type: 'application/zip' })
const readerBuffer = await readWithFileReader(zipFile)
const entries = unzipSync(new Uint8Array(readerBuffer))
expect(Object.keys(entries)).toEqual(
expect.arrayContaining(['hetzner-cloud-skill/SKILL.md', 'hetzner-cloud-skill/notes.txt']),
)
const expanded = await expandFiles([zipFile])
expect(expanded.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
})
})
+26
View File
@@ -71,6 +71,21 @@ describe('expandFiles', () => {
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'docs/readme.txt'])
})
it('unwraps top-level folders in zip archives', async () => {
const zip = zipSync({
'hetzner-cloud-skill/SKILL.md': strToU8('hello'),
'hetzner-cloud-skill/docs/readme.txt': strToU8('doc'),
'__MACOSX/._SKILL.md': strToU8('junk'),
'hetzner-cloud-skill/.DS_Store': strToU8('junk2'),
'hetzner-cloud-skill/screenshot.png': strToU8('not-really-a-png'),
})
const zipFile = new File([zip.buffer], 'pack.zip', { type: 'application/zip' })
const result = await expandFiles([zipFile])
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'docs/readme.txt'])
const png = result.find((file) => file.name.endsWith('.png'))
expect(png).toBeUndefined()
})
it('expands gzipped tar archives into files', async () => {
const tar = buildTar([
{ name: 'SKILL.md', content: 'hi' },
@@ -82,6 +97,17 @@ describe('expandFiles', () => {
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
})
it('unwraps top-level folders in tar.gz archives', async () => {
const tar = buildTar([
{ name: 'skill-folder/SKILL.md', content: 'hi' },
{ name: 'skill-folder/notes.txt', content: 'yo' },
])
const tgz = gzipSync(tar)
const tgzFile = new File([tgz.buffer], 'bundle.tgz', { type: 'application/gzip' })
const result = await expandFiles([tgzFile])
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
})
it('expands .gz single files', async () => {
const gz = gzipSync(strToU8('content'))
const gzFile = new File([gz.buffer], 'skill.md.gz', { type: 'application/gzip' })
+77 -20
View File
@@ -1,3 +1,4 @@
import { TEXT_FILE_EXTENSION_SET } from 'clawdhub-schema'
import { gunzipSync, unzipSync } from 'fflate'
const TEXT_TYPES = new Map([
@@ -23,31 +24,21 @@ export async function expandFiles(selected: File[]) {
const lower = file.name.toLowerCase()
if (lower.endsWith('.zip')) {
const entries = unzipSync(new Uint8Array(await readArrayBuffer(file)))
for (const [path, data] of Object.entries(entries)) {
if (!path || path.endsWith('/')) continue
expanded.push(
new File([data.buffer], normalizePath(path), {
type: guessContentType(path),
}),
)
}
pushArchiveEntries(
expanded,
Object.entries(entries).map(([path, data]) => ({ path, data })),
)
continue
}
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) {
const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file)))
for (const entry of untar(unpacked)) {
expanded.push(
new File([entry.data.buffer], normalizePath(entry.path), {
type: guessContentType(entry.path),
}),
)
}
pushArchiveEntries(expanded, untar(unpacked))
continue
}
if (lower.endsWith('.gz')) {
const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file)))
const name = file.name.replace(/\.gz$/i, '')
expanded.push(new File([unpacked.buffer], name, { type: guessContentType(name) }))
expanded.push(new File([unpacked], name, { type: guessContentType(name) }))
continue
}
expanded.push(file)
@@ -55,21 +46,55 @@ export async function expandFiles(selected: File[]) {
return expanded
}
function pushArchiveEntries(target: File[], entries: Array<{ path: string; data: Uint8Array }>) {
const normalized = entries
.map((entry) => ({ ...entry, path: normalizePath(entry.path) }))
.filter((entry) => entry.path && !entry.path.endsWith('/'))
.filter((entry) => !isJunkPath(entry.path))
.filter((entry) => isTextPath(entry.path))
const unwrapped = unwrapSingleTopLevelFolder(normalized)
for (const entry of unwrapped) {
target.push(
new File([entry.data], entry.path, {
type: guessContentType(entry.path),
}),
)
}
}
async function readArrayBuffer(file: Blob) {
if (typeof file.arrayBuffer === 'function') {
return file.arrayBuffer()
}
return new Response(file).arrayBuffer()
if (typeof FileReader !== 'undefined') {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error ?? new Error('Could not read file.'))
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.readAsArrayBuffer(file)
})
}
return new Response(file as BodyInit).arrayBuffer()
}
function guessContentType(path: string) {
const ext = path.split('.').pop()?.toLowerCase()
if (!ext) return 'text/plain'
return TEXT_TYPES.get(ext) ?? 'text/plain'
if (!ext) return 'application/octet-stream'
const known = TEXT_TYPES.get(ext)
if (known) return known
if (TEXT_FILE_EXTENSION_SET.has(ext)) return 'text/plain'
return 'application/octet-stream'
}
function normalizePath(path: string) {
return path.replace(/^\.\/+/, '').replace(/^\/+/, '')
return path
.replaceAll('\u0000', '')
.replaceAll('\\', '/')
.trim()
.replace(/^\.\/+/, '')
.replace(/^\/+/, '')
}
function untar(bytes: Uint8Array) {
@@ -100,3 +125,35 @@ function readOctal(bytes: Uint8Array) {
const raw = readString(bytes)
return raw ? Number.parseInt(raw, 8) : 0
}
function unwrapSingleTopLevelFolder<T extends { path: string }>(entries: T[]) {
if (entries.length === 0) return entries
const segments = entries.map((entry) => entry.path.split('/').filter(Boolean))
if (segments.some((parts) => parts.length < 2)) return entries
const first = segments[0]?.[0]
if (!first) return entries
if (!segments.every((parts) => parts[0] === first)) return entries
return entries.map((entry) => ({
...entry,
path: entry.path.split('/').slice(1).join('/'),
}))
}
function isJunkPath(path: string) {
const normalized = path.toLowerCase()
if (normalized.startsWith('__macosx/')) return true
if (normalized.endsWith('/.ds_store')) return true
if (normalized === '.ds_store') return true
return false
}
function isTextPath(path: string) {
const normalized = path.trim().toLowerCase()
const parts = normalized.split('.')
const extension = parts.length > 1 ? (parts.at(-1) ?? '') : ''
if (!extension) return false
return TEXT_FILE_EXTENSION_SET.has(extension)
}
+23 -18
View File
@@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from 'clawdhub-schema'
import { useAction, useConvexAuth, useMutation } from 'convex/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import semver from 'semver'
import { api } from '../../convex/_generated/api'
@@ -41,8 +42,6 @@ export function Upload() {
const trimmedSlug = slug.trim()
const trimmedName = displayName.trim()
const trimmedChangelog = changelog.trim()
const lookupSlug = trimmedSlug && SLUG_PATTERN.test(trimmedSlug) ? trimmedSlug : ''
const existingSkill = useQuery(api.skills.getBySlug, lookupSlug ? { slug: lookupSlug } : 'skip')
const parsedTags = useMemo(
() =>
tags
@@ -67,15 +66,21 @@ export function Upload() {
if (parsedTags.length === 0) {
issues.push('At least one tag is required.')
}
if (existingSkill && !trimmedChangelog) {
issues.push('Changelog is required for updates.')
}
if (files.length === 0) {
issues.push('Add at least one file.')
}
if (!hasSkillFile) {
issues.push('SKILL.md is required.')
}
const invalidFiles = files.filter((file) => !isTextFile(file))
if (invalidFiles.length > 0) {
issues.push(
`Remove non-text files: ${invalidFiles
.slice(0, 3)
.map((file) => file.name)
.join(', ')}`,
)
}
if (totalBytes > maxBytes) {
issues.push('Total file size exceeds 50MB.')
}
@@ -83,17 +88,7 @@ export function Upload() {
issues,
ready: issues.length === 0,
}
}, [
trimmedSlug,
trimmedName,
version,
parsedTags.length,
trimmedChangelog,
existingSkill,
files.length,
hasSkillFile,
totalBytes,
])
}, [trimmedSlug, trimmedName, version, parsedTags.length, files, hasSkillFile, totalBytes])
useEffect(() => {
if (!fileInputRef.current) return
@@ -353,7 +348,7 @@ export function Upload() {
<ul>
<li>Include SKILL.md</li>
<li>50 MB max per version</li>
<li>Changelog required for updates</li>
<li>Changelog optional</li>
<li>Valid semver version</li>
</ul>
</div>
@@ -444,3 +439,13 @@ function formatPublishError(error: unknown) {
}
return 'Publish failed. Please try again.'
}
function isTextFile(file: File) {
const path = (file.webkitRelativePath || file.name).trim().toLowerCase()
if (!path) return false
const parts = path.split('.')
const extension = parts.length > 1 ? (parts.at(-1) ?? '') : ''
if (file.type && isTextContentType(file.type)) return true
if (extension && TEXT_FILE_EXTENSION_SET.has(extension)) return true
return false
}