mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
541f96cbbf | ||
|
|
3af9cdfc0c | ||
|
|
b974bc7c0b | ||
|
|
91233dfe57 | ||
|
|
2a2749f852 | ||
|
|
56230aa329 | ||
|
|
b93bb5830b |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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'>,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -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";
|
||||
};
|
||||
|
||||
Vendored
+2
@@ -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
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+7
@@ -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;
|
||||
|
||||
Vendored
+6
@@ -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'),
|
||||
|
||||
Vendored
+1
-1
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdhub-schema",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user