feat: add v1 public api

This commit is contained in:
Peter Steinberger 2026-01-07 18:28:51 +01:00
parent 9f83114dee
commit ba7e82ba02
43 changed files with 1955 additions and 278 deletions

View File

@ -1,5 +1,15 @@
# Changelog
## 0.0.6 - 2026-01-07
### Added
- API: v1 public REST endpoints with rate limits, raw file fetch, and OpenAPI spec.
- Docs: `docs/api.md` and `DEPRECATIONS.md` for the v1 cutover plan.
### Changed
- CLI: publish now uses single multipart `POST /api/v1/skills`.
- Registry: legacy `/api/*` + `/api/cli/*` marked for deprecation (kept for now).
## 0.0.5 - 2026-01-06
### Added

7
DEPRECATIONS.md Normal file
View File

@ -0,0 +1,7 @@
# Deprecations
## Legacy /api routes (pre-v1)
- Deprecated: 2026-01-07
- TODO: remove legacy `/api/*` and `/api/cli/*` routes after clients migrate to `/api/v1`.
- Legacy handlers live in `convex/http.ts` and `convex/httpApi.ts`.

View File

@ -13,6 +13,7 @@ import type * as comments from "../comments.js";
import type * as downloads from "../downloads.js";
import type * as http from "../http.js";
import type * as httpApi from "../httpApi.js";
import type * as httpApiV1 from "../httpApiV1.js";
import type * as lib_access from "../lib/access.js";
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
import type * as lib_changelog from "../lib/changelog.js";
@ -23,6 +24,7 @@ import type * as lib_skills from "../lib/skills.js";
import type * as lib_tokens from "../lib/tokens.js";
import type * as lib_webhooks from "../lib/webhooks.js";
import type * as maintenance from "../maintenance.js";
import type * as rateLimits from "../rateLimits.js";
import type * as search from "../search.js";
import type * as skills from "../skills.js";
import type * as stars from "../stars.js";
@ -44,6 +46,7 @@ declare const fullApi: ApiFromModules<{
downloads: typeof downloads;
http: typeof http;
httpApi: typeof httpApi;
httpApiV1: typeof httpApiV1;
"lib/access": typeof lib_access;
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
"lib/changelog": typeof lib_changelog;
@ -54,6 +57,7 @@ declare const fullApi: ApiFromModules<{
"lib/tokens": typeof lib_tokens;
"lib/webhooks": typeof lib_webhooks;
maintenance: typeof maintenance;
rateLimits: typeof rateLimits;
search: typeof search;
skills: typeof skills;
stars: typeof stars;

View File

@ -1,4 +1,4 @@
import { ApiRoutes } from 'clawdhub-schema'
import { ApiRoutes, LegacyApiRoutes } from 'clawdhub-schema'
import { httpRouter } from 'convex/server'
import { auth } from './auth'
import { downloadZip } from './downloads'
@ -13,6 +13,16 @@ import {
resolveSkillVersionHttp,
searchSkillsHttp,
} from './httpApi'
import {
listSkillsV1Http,
publishSkillV1Http,
resolveSkillVersionV1Http,
searchSkillsV1Http,
skillsDeleteRouterV1Http,
skillsGetRouterV1Http,
skillsPostRouterV1Http,
whoamiV1Http,
} from './httpApiV1'
const http = httpRouter()
@ -27,53 +37,107 @@ http.route({
http.route({
path: ApiRoutes.search,
method: 'GET',
handler: searchSkillsV1Http,
})
http.route({
path: ApiRoutes.resolve,
method: 'GET',
handler: resolveSkillVersionV1Http,
})
http.route({
path: ApiRoutes.skills,
method: 'GET',
handler: listSkillsV1Http,
})
http.route({
pathPrefix: `${ApiRoutes.skills}/`,
method: 'GET',
handler: skillsGetRouterV1Http,
})
http.route({
path: ApiRoutes.skills,
method: 'POST',
handler: publishSkillV1Http,
})
http.route({
pathPrefix: `${ApiRoutes.skills}/`,
method: 'POST',
handler: skillsPostRouterV1Http,
})
http.route({
pathPrefix: `${ApiRoutes.skills}/`,
method: 'DELETE',
handler: skillsDeleteRouterV1Http,
})
http.route({
path: ApiRoutes.whoami,
method: 'GET',
handler: whoamiV1Http,
})
// TODO: remove legacy /api routes after deprecation window.
http.route({
path: LegacyApiRoutes.download,
method: 'GET',
handler: downloadZip,
})
http.route({
path: LegacyApiRoutes.search,
method: 'GET',
handler: searchSkillsHttp,
})
http.route({
path: ApiRoutes.skill,
path: LegacyApiRoutes.skill,
method: 'GET',
handler: getSkillHttp,
})
http.route({
path: ApiRoutes.skillResolve,
path: LegacyApiRoutes.skillResolve,
method: 'GET',
handler: resolveSkillVersionHttp,
})
http.route({
path: ApiRoutes.cliWhoami,
path: LegacyApiRoutes.cliWhoami,
method: 'GET',
handler: cliWhoamiHttp,
})
http.route({
path: ApiRoutes.cliUploadUrl,
path: LegacyApiRoutes.cliUploadUrl,
method: 'POST',
handler: cliUploadUrlHttp,
})
http.route({
path: ApiRoutes.cliPublish,
path: LegacyApiRoutes.cliPublish,
method: 'POST',
handler: cliPublishHttp,
})
http.route({
path: ApiRoutes.cliTelemetrySync,
path: LegacyApiRoutes.cliTelemetrySync,
method: 'POST',
handler: cliTelemetrySyncHttp,
})
http.route({
path: ApiRoutes.cliSkillDelete,
path: LegacyApiRoutes.cliSkillDelete,
method: 'POST',
handler: cliSkillDeleteHttp,
})
http.route({
path: ApiRoutes.cliSkillUndelete,
path: LegacyApiRoutes.cliSkillUndelete,
method: 'POST',
handler: cliSkillUndeleteHttp,
})

662
convex/httpApiV1.ts Normal file
View File

@ -0,0 +1,662 @@
import { CliPublishRequestSchema, parseArk } from 'clawdhub-schema'
import { api, internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { ActionCtx } from './_generated/server'
import { httpAction } from './_generated/server'
import { requireApiTokenUser } from './lib/apiTokenAuth'
import { hashToken } from './lib/tokens'
import { publishVersionForUser } from './skills'
const RATE_LIMIT_WINDOW_MS = 60_000
const RATE_LIMITS = {
read: { ip: 120, key: 600 },
write: { ip: 30, key: 120 },
} as const
const MAX_RAW_FILE_BYTES = 200 * 1024
type SearchSkillEntry = {
score: number
skill: {
slug?: string
displayName?: string
summary?: string | null
updatedAt?: number
} | null
version: { version?: string; createdAt?: number } | null
}
type ListSkillsResult = {
items: Array<{
skill: {
_id: Id<'skills'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'skillVersions'>>
stats: unknown
createdAt: number
updatedAt: number
latestVersionId?: Id<'skillVersions'>
}
latestVersion: { version: string; createdAt: number; changelog: string } | null
}>
nextCursor: string | null
}
type GetBySlugResult = {
skill: {
_id: Id<'skills'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'skillVersions'>>
stats: unknown
createdAt: number
updatedAt: number
} | null
latestVersion: Doc<'skillVersions'> | null
owner: { handle?: string; displayName?: string; image?: string } | null
} | null
type ListVersionsResult = {
items: Array<{
version: string
createdAt: number
changelog: string
changelogSource?: 'auto' | 'user'
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
softDeletedAt?: number
}>
nextCursor: string | null
}
export const searchSkillsV1Http = httpAction(async (ctx, request) => {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const query = url.searchParams.get('q')?.trim() ?? ''
const limit = toOptionalNumber(url.searchParams.get('limit'))
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true'
if (!query) return json({ results: [] }, 200, rate.headers)
const results = (await ctx.runAction(api.search.searchSkills, {
query,
limit,
highlightedOnly: highlightedOnly || undefined,
})) as SearchSkillEntry[]
return json(
{
results: results.map((result) => ({
score: result.score,
slug: result.skill?.slug,
displayName: result.skill?.displayName,
summary: result.skill?.summary ?? null,
version: result.version?.version ?? null,
updatedAt: result.skill?.updatedAt,
})),
},
200,
rate.headers,
)
})
export const resolveSkillVersionV1Http = httpAction(async (ctx, request) => {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const slug = url.searchParams.get('slug')?.trim().toLowerCase()
const hash = url.searchParams.get('hash')?.trim().toLowerCase()
if (!slug || !hash) return text('Missing slug or hash', 400, rate.headers)
if (!/^[a-f0-9]{64}$/.test(hash)) return text('Invalid hash', 400, rate.headers)
const resolved = await ctx.runQuery(api.skills.resolveVersionByHash, { slug, hash })
if (!resolved) return text('Skill not found', 404, rate.headers)
return json(
{ slug, match: resolved.match, latestVersion: resolved.latestVersion },
200,
rate.headers,
)
})
export const listSkillsV1Http = httpAction(async (ctx, request) => {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const result = (await ctx.runQuery(api.skills.listPublicPage, {
limit,
cursor,
})) as ListSkillsResult
const items = await Promise.all(
result.items.map(async (item) => {
const tags = await resolveTags(ctx, item.skill.tags)
return {
slug: item.skill.slug,
displayName: item.skill.displayName,
summary: item.skill.summary ?? null,
tags,
stats: item.skill.stats,
createdAt: item.skill.createdAt,
updatedAt: item.skill.updatedAt,
latestVersion: item.latestVersion
? {
version: item.latestVersion.version,
createdAt: item.latestVersion.createdAt,
changelog: item.latestVersion.changelog,
}
: null,
}
}),
)
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
})
export const skillsGetRouterV1Http = httpAction(async (ctx, request) => {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length === 0) return text('Missing slug', 400, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
const second = segments[1]
const third = segments[2]
if (segments.length === 1) {
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
if (!result?.skill) return text('Skill not found', 404, rate.headers)
const tags = await resolveTags(ctx, result.skill.tags)
return json(
{
skill: {
slug: result.skill.slug,
displayName: result.skill.displayName,
summary: result.skill.summary ?? null,
tags,
stats: result.skill.stats,
createdAt: result.skill.createdAt,
updatedAt: result.skill.updatedAt,
},
latestVersion: result.latestVersion
? {
version: result.latestVersion.version,
createdAt: result.latestVersion.createdAt,
changelog: result.latestVersion.changelog,
}
: null,
owner: result.owner
? {
handle: result.owner.handle ?? null,
displayName: result.owner.displayName ?? null,
image: result.owner.image ?? null,
}
: null,
},
200,
rate.headers,
)
}
if (second === 'versions' && segments.length === 2) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const result = (await ctx.runQuery(api.skills.listVersionsPage, {
skillId: skill._id,
limit,
cursor,
})) as ListVersionsResult
const items = result.items
.filter((version) => !version.softDeletedAt)
.map((version) => ({
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
}))
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
}
if (second === 'versions' && third && segments.length === 3) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
const version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skill._id,
version: third,
})
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
return json(
{
skill: { slug: skill.slug, displayName: skill.displayName },
version: {
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: file.contentType ?? null,
})),
},
},
200,
rate.headers,
)
}
if (second === 'file' && segments.length === 2) {
const url = new URL(request.url)
const path = url.searchParams.get('path')?.trim()
if (!path) return text('Missing path', 400, rate.headers)
const versionParam = url.searchParams.get('version')?.trim()
const tagParam = url.searchParams.get('tag')?.trim()
const skillResult = (await ctx.runQuery(api.skills.getBySlug, {
slug,
})) as GetBySlugResult
if (!skillResult?.skill) return text('Skill not found', 404, rate.headers)
let version = skillResult.latestVersion
if (versionParam) {
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skillResult.skill._id,
version: versionParam,
})
} else if (tagParam) {
const versionId = skillResult.skill.tags[tagParam]
if (versionId) {
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
}
}
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
const normalized = path.trim()
const normalizedLower = normalized.toLowerCase()
const file =
version.files.find((entry) => entry.path === normalized) ??
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower)
if (!file) return text('File not found', 404, rate.headers)
if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers)
const blob = await ctx.storage.get(file.storageId)
if (!blob) return text('File missing in storage', 410, rate.headers)
const textContent = await blob.text()
const headers = mergeHeaders(rate.headers, {
'Content-Type': file.contentType
? `${file.contentType}; charset=utf-8`
: 'text/plain; charset=utf-8',
'Cache-Control': 'private, max-age=60',
ETag: file.sha256,
'X-Content-SHA256': file.sha256,
'X-Content-Size': String(file.size),
})
return new Response(textContent, { status: 200, headers })
}
return text('Not found', 404, rate.headers)
})
export const publishSkillV1Http = httpAction(async (ctx, request) => {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
try {
if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
const { userId } = await requireApiTokenUser(ctx, request)
const contentType = request.headers.get('content-type') ?? ''
try {
if (contentType.includes('application/json')) {
const body = await request.json()
const payload = parsePublishBody(body)
const result = await publishVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
if (contentType.includes('multipart/form-data')) {
const payload = await parseMultipartPublish(ctx, request)
const result = await publishVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Publish failed'
return text(message, 400, rate.headers)
}
return text('Unsupported content type', 415, rate.headers)
})
export const skillsPostRouterV1Http = httpAction(async (ctx, request) => {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length !== 2 || segments[1] !== 'undelete') {
return text('Not found', 404, rate.headers)
}
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: false,
})
return json({ ok: true }, 200, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
})
export const skillsDeleteRouterV1Http = httpAction(async (ctx, request) => {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: true,
})
return json({ ok: true }, 200, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
})
export const whoamiV1Http = httpAction(async (ctx, request) => {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
try {
const { user } = await requireApiTokenUser(ctx, request)
return json(
{
user: {
handle: user.handle ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
},
},
200,
rate.headers,
)
} catch {
return text('Unauthorized', 401, rate.headers)
}
})
async function parseMultipartPublish(
ctx: ActionCtx,
request: Request,
): Promise<{
slug: string
displayName: string
version: string
changelog: string
tags?: string[]
forkOf?: { slug: string; version?: string }
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
}> {
const form = await request.formData()
const payloadRaw = form.get('payload')
if (!payloadRaw || typeof payloadRaw !== 'string') {
throw new Error('Missing payload')
}
let payload: Record<string, unknown>
try {
payload = JSON.parse(payloadRaw) as Record<string, unknown>
} catch {
throw new Error('Invalid JSON payload')
}
const files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}> = []
for (const entry of form.getAll('files')) {
if (!(entry instanceof File)) continue
const path = entry.name
const size = entry.size
const contentType = entry.type || undefined
const buffer = new Uint8Array(await entry.arrayBuffer())
const sha256 = await sha256Hex(buffer)
const storageId = await ctx.storage.store(entry)
files.push({ path, size, storageId, sha256, contentType })
}
const body = {
slug: payload.slug,
displayName: payload.displayName,
version: payload.version,
changelog: typeof payload.changelog === 'string' ? payload.changelog : '',
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
forkOf: payload.forkOf,
files,
}
return parsePublishBody(body)
}
function parsePublishBody(body: unknown) {
const parsed = parseArk(CliPublishRequestSchema, body, 'Publish payload')
if (parsed.files.length === 0) throw new Error('files required')
const tags = parsed.tags && parsed.tags.length > 0 ? parsed.tags : undefined
return {
slug: parsed.slug,
displayName: parsed.displayName,
version: parsed.version,
changelog: parsed.changelog,
tags,
forkOf: parsed.forkOf
? {
slug: parsed.forkOf.slug,
version: parsed.forkOf.version ?? undefined,
}
: undefined,
files: parsed.files.map((file) => ({
...file,
storageId: file.storageId as Id<'_storage'>,
})),
}
}
async function resolveTags(
ctx: ActionCtx,
tags: Record<string, Id<'skillVersions'>>,
): Promise<Record<string, string>> {
const resolved: Record<string, string> = {}
for (const [tag, versionId] of Object.entries(tags)) {
const version = await ctx.runQuery(api.skills.getVersionById, { versionId })
if (version && !version.softDeletedAt) {
resolved[tag] = version.version
}
}
return resolved
}
async function applyRateLimit(
ctx: ActionCtx,
request: Request,
kind: 'read' | 'write',
): Promise<{ ok: true; headers: HeadersInit } | { ok: false; response: Response }> {
const ip = getClientIp(request) ?? 'unknown'
const ipResult = await checkRateLimit(ctx, `ip:${ip}`, RATE_LIMITS[kind].ip)
const token = parseBearerToken(request)
const keyResult = token
? await checkRateLimit(ctx, `key:${await hashToken(token)}`, RATE_LIMITS[kind].key)
: null
const chosen = pickMostRestrictive(ipResult, keyResult)
const headers = rateHeaders(chosen)
if (!ipResult.allowed || (keyResult && !keyResult.allowed)) {
return {
ok: false,
response: text('Rate limit exceeded', 429, headers),
}
}
return { ok: true, headers }
}
type RateLimitResult = {
allowed: boolean
remaining: number
limit: number
resetAt: number
}
async function checkRateLimit(
ctx: ActionCtx,
key: string,
limit: number,
): Promise<RateLimitResult> {
return (await ctx.runMutation(internal.rateLimits.checkRateLimitInternal, {
key,
limit,
windowMs: RATE_LIMIT_WINDOW_MS,
})) as RateLimitResult
}
function pickMostRestrictive(primary: RateLimitResult, secondary: RateLimitResult | null) {
if (!secondary) return primary
if (!primary.allowed) return primary
if (!secondary.allowed) return secondary
return secondary.remaining < primary.remaining ? secondary : primary
}
function rateHeaders(result: RateLimitResult): HeadersInit {
const resetSeconds = Math.ceil(result.resetAt / 1000)
return {
'X-RateLimit-Limit': String(result.limit),
'X-RateLimit-Remaining': String(result.remaining),
'X-RateLimit-Reset': String(resetSeconds),
...(result.allowed ? {} : { 'Retry-After': String(resetSeconds) }),
}
}
function getClientIp(request: Request) {
const header =
request.headers.get('cf-connecting-ip') ??
request.headers.get('x-real-ip') ??
request.headers.get('x-forwarded-for') ??
request.headers.get('fly-client-ip')
if (!header) return null
if (header.includes(',')) return header.split(',')[0]?.trim() || null
return header.trim()
}
function parseBearerToken(request: Request) {
const header = request.headers.get('authorization') ?? request.headers.get('Authorization')
if (!header) return null
const trimmed = header.trim()
if (!trimmed.toLowerCase().startsWith('bearer ')) return null
const token = trimmed.slice(7).trim()
return token || null
}
function json(value: unknown, status = 200, headers?: HeadersInit) {
return new Response(JSON.stringify(value), {
status,
headers: mergeHeaders(
{
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
headers,
),
})
}
function text(value: string, status: number, headers?: HeadersInit) {
return new Response(value, {
status,
headers: mergeHeaders(
{
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
headers,
),
})
}
function mergeHeaders(base: HeadersInit, extra?: HeadersInit) {
return { ...(base as Record<string, string>), ...(extra as Record<string, string>) }
}
function getPathSegments(request: Request, prefix: string) {
const pathname = new URL(request.url).pathname
if (!pathname.startsWith(prefix)) return []
const rest = pathname.slice(prefix.length)
return rest
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.map((segment) => decodeURIComponent(segment))
}
function toOptionalNumber(value: string | null) {
if (!value) return undefined
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : undefined
}
async function sha256Hex(bytes: Uint8Array) {
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
const digest = await crypto.subtle.digest('SHA-256', buffer)
return toHex(new Uint8Array(digest))
}
function toHex(bytes: Uint8Array) {
let out = ''
for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
return out
}

50
convex/rateLimits.ts Normal file
View File

@ -0,0 +1,50 @@
import { v } from 'convex/values'
import { internalMutation } from './_generated/server'
export const checkRateLimitInternal = internalMutation({
args: {
key: v.string(),
limit: v.number(),
windowMs: v.number(),
},
handler: async (ctx, args) => {
const now = Date.now()
const windowStart = Math.floor(now / args.windowMs) * args.windowMs
const resetAt = windowStart + args.windowMs
if (args.limit <= 0) {
return { allowed: false, remaining: 0, limit: args.limit, resetAt }
}
const existing = await ctx.db
.query('rateLimits')
.withIndex('by_key_window', (q) => q.eq('key', args.key).eq('windowStart', windowStart))
.unique()
if (!existing) {
await ctx.db.insert('rateLimits', {
key: args.key,
windowStart,
count: 1,
limit: args.limit,
updatedAt: now,
})
return { allowed: true, remaining: Math.max(0, args.limit - 1), limit: args.limit, resetAt }
}
if (existing.count >= args.limit) {
return { allowed: false, remaining: 0, limit: args.limit, resetAt }
}
await ctx.db.patch(existing._id, {
count: existing.count + 1,
limit: args.limit,
updatedAt: now,
})
return {
allowed: true,
remaining: Math.max(0, args.limit - existing.count - 1),
limit: args.limit,
resetAt,
}
},
})

View File

@ -163,6 +163,16 @@ const apiTokens = defineTable({
.index('by_user', ['userId'])
.index('by_hash', ['tokenHash'])
const rateLimits = defineTable({
key: v.string(),
windowStart: v.number(),
count: v.number(),
limit: v.number(),
updatedAt: v.number(),
})
.index('by_key_window', ['key', 'windowStart'])
.index('by_key', ['key'])
const userSyncRoots = defineTable({
userId: v.id('users'),
rootId: v.string(),
@ -212,6 +222,7 @@ export default defineSchema({
stars,
auditLogs,
apiTokens,
rateLimits,
userSyncRoots,
userSkillInstalls,
userSkillRootInstalls,

View File

@ -19,6 +19,7 @@ type ReadmeResult = { path: string; text: string }
type FileTextResult = { path: string; text: string; size: number; sha256: string }
const MAX_DIFF_FILE_BYTES = 200 * 1024
const MAX_LIST_LIMIT = 50
export const getBySlug = query({
args: { slug: v.string() },
@ -112,6 +113,34 @@ export const list = query({
},
})
export const listPublicPage = query({
args: {
cursor: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 24, 1, MAX_LIST_LIMIT)
const { page, isDone, continueCursor } = await ctx.db
.query('skills')
.withIndex('by_updated', (q) => q)
.order('desc')
.paginate({ cursor: args.cursor ?? null, numItems: limit })
const items: Array<{
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
}> = []
for (const skill of page) {
if (skill.softDeletedAt) continue
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
items.push({ skill, latestVersion })
}
return { items, nextCursor: isDone ? null : continueCursor }
},
})
export const listVersions = query({
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
@ -124,6 +153,24 @@ export const listVersions = query({
},
})
export const listVersionsPage = query({
args: {
skillId: v.id('skills'),
cursor: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 20, 1, MAX_LIST_LIMIT)
const { page, isDone, continueCursor } = await ctx.db
.query('skillVersions')
.withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
.order('desc')
.paginate({ cursor: args.cursor ?? null, numItems: limit })
const items = page.filter((version) => !version.softDeletedAt)
return { items, nextCursor: isDone ? null : continueCursor }
},
})
export const getVersionById = query({
args: { versionId: v.id('skillVersions') },
handler: async (ctx, args) => ctx.db.get(args.versionId),
@ -656,6 +703,11 @@ function visibilityFor(isLatest: boolean, isApproved: boolean) {
return 'archived'
}
function clampInt(value: number, min: number, max: number) {
const rounded = Number.isFinite(value) ? Math.round(value) : min
return Math.min(max, Math.max(min, rounded))
}
async function findCanonicalSkillForFingerprint(
ctx: { db: MutationCtx['db'] },
fingerprint: string,

50
docs/api.md Normal file
View File

@ -0,0 +1,50 @@
---
summary: 'Public REST API (v1) overview and conventions.'
read_when:
- Building API clients
- Adding endpoints or schemas
---
# API v1
Base: `https://clawdhub.com`
OpenAPI: `/api/v1/openapi.json`
## Auth
- Public read: no token required.
- Write + account: `Authorization: Bearer clh_...`.
## Rate limits
Per IP + per API key:
- Read: 120/min per IP, 600/min per key
- Write: 30/min per IP, 120/min per key
Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` (on 429).
## Endpoints
Public read:
- `GET /api/v1/search?q=...`
- `GET /api/v1/skills?limit=&cursor=`
- `GET /api/v1/skills/{slug}`
- `GET /api/v1/skills/{slug}/versions?limit=&cursor=`
- `GET /api/v1/skills/{slug}/versions/{version}`
- `GET /api/v1/skills/{slug}/file?path=&version=&tag=`
- `GET /api/v1/resolve?slug=&hash=`
- `GET /api/v1/download?slug=&version=&tag=`
Auth required:
- `POST /api/v1/skills` (publish, multipart preferred)
- `DELETE /api/v1/skills/{slug}`
- `POST /api/v1/skills/{slug}/undelete`
- `GET /api/v1/whoami`
## Legacy
Legacy `/api/*` and `/api/cli/*` still available. See `DEPRECATIONS.md`.

View File

@ -29,13 +29,13 @@ read_when:
### Search (HTTP)
- `/api/search?q=...` routes to Convex action for vector search.
- `/api/v1/search?q=...` routes to Convex action for vector search.
- Embeddings currently generated during publish.
### Install (CLI)
- Resolve latest version via `/api/skill?slug=...`.
- Download zip via `/api/download?slug=...&version=...`.
- Resolve latest version via `/api/v1/skills/<slug>`.
- Download zip via `/api/v1/download?slug=...&version=...`.
- Extract into `./skills/<slug>` (default).
- Persist install state:
- `./.clawdhub/lock.json` (per workdir)
@ -43,7 +43,7 @@ read_when:
### Update (CLI)
- Hash local files, call `/api/skill/resolve?slug=...&hash=<sha256>`.
- Hash local files, call `/api/v1/resolve?slug=...&hash=<sha256>`.
- If local matches a known version → use that for “current”.
- If local doesnt match:
- refuse by default
@ -51,8 +51,7 @@ read_when:
### Publish (CLI)
- Upload each text file via `/api/cli/upload-url` (Convex upload URL).
- Publish metadata via `/api/cli/publish` (requires Bearer token).
- Publish via `POST /api/v1/skills` (multipart; requires Bearer token).
### Sync (CLI)

View File

@ -44,16 +44,16 @@ Stores your API token + cached registry URL.
### `whoami`
- Verifies the stored token via `/api/cli/whoami`.
- Verifies the stored token via `/api/v1/whoami`.
### `search <query...>`
- Calls `/api/search?q=...`.
- Calls `/api/v1/search?q=...`.
### `install <slug>`
- Resolves latest version via `/api/skill?slug=...`.
- Downloads zip via `/api/download`.
- Resolves latest version via `/api/v1/skills/<slug>`.
- Downloads zip via `/api/v1/download`.
- Extracts into `<workdir>/<dir>/<slug>`.
- Writes:
- `<workdir>/.clawdhub/lock.json`
@ -73,8 +73,7 @@ Stores your API token + cached registry URL.
### `publish <path>`
- Uploads each file via `/api/cli/upload-url`.
- Publishes via `/api/cli/publish`.
- Publishes via `POST /api/v1/skills` (multipart).
- Requires semver: `--version 1.2.3`.
### `sync`

View File

@ -66,8 +66,8 @@ export CLAWDHUB_REGISTRY=https://your-site.example
## 5) Post-deploy checks
```bash
curl -i "https://<site>/api/search?q=test"
curl -i "https://<site>/api/skill?slug=gifgrep"
curl -i "https://<site>/api/v1/search?q=test"
curl -i "https://<site>/api/v1/skills/gifgrep"
```
Then:

View File

@ -9,17 +9,30 @@ read_when:
Base URL: `https://clawdhub.com` (default).
All paths below are under `/api/...` and implemented by Convex HTTP routes (`convex/http.ts`).
All v1 paths are under `/api/v1/...` and implemented by Convex HTTP routes (`convex/http.ts`).
Legacy `/api/...` and `/api/cli/...` remain for compatibility (see `DEPRECATIONS.md`).
OpenAPI: `/api/v1/openapi.json`.
## Rate limits
Enforced per IP + per API key:
- Read: 120/min per IP, 600/min per key
- Write: 30/min per IP, 120/min per key
Headers:
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` (when limited)
## Public endpoints (no auth)
### `GET /api/search`
### `GET /api/v1/search`
Query params:
- `q` (required): query string
- `limit` (optional): integer
- `approvedOnly` (optional): `true` to filter to approved-only skills (server may treat as “approved”/badged)
- `highlightedOnly` (optional): `true` to filter to highlighted skills
Response:
@ -27,19 +40,54 @@ Response:
{ "results": [{ "score": 0.123, "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "version": "1.2.3", "updatedAt": 1730000000000 }] }
```
### `GET /api/skill`
### `GET /api/v1/skills`
Query params:
- `slug` (required)
- `limit` (optional): integer
- `cursor` (optional): pagination cursor
Response (shape is stable; contents may expand):
Response:
```json
{ "skill": { "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "" }, "stats": {}, "createdAt": 0, "updatedAt": 0 }, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" }, "owner": { "handle": "steipete", "displayName": "Peter", "image": null } }
{ "items": [{ "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, "updatedAt": 0, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" } }], "nextCursor": null }
```
### `GET /api/skill/resolve`
### `GET /api/v1/skills/{slug}`
Response:
```json
{ "skill": { "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, "updatedAt": 0 }, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" }, "owner": { "handle": "steipete", "displayName": "Peter", "image": null } }
```
### `GET /api/v1/skills/{slug}/versions`
Query params:
- `limit` (optional): integer
- `cursor` (optional): pagination cursor
### `GET /api/v1/skills/{slug}/versions/{version}`
Returns version metadata + files list.
### `GET /api/v1/skills/{slug}/file`
Returns raw text content.
Query params:
- `path` (required)
- `version` (optional)
- `tag` (optional)
Notes:
- Defaults to latest version.
- File size limit: 200KB.
### `GET /api/v1/resolve`
Used by the CLI to map a local fingerprint to a known version.
@ -54,7 +102,7 @@ Response:
{ "slug": "gifgrep", "match": { "version": "1.2.2" }, "latestVersion": { "version": "1.2.3" } }
```
### `GET /api/download`
### `GET /api/v1/download`
Downloads a zip of a skill version.
@ -69,45 +117,42 @@ Notes:
- If neither `version` nor `tag` is provided, the latest version is used.
- Soft-deleted versions return `410`.
## CLI endpoints (Bearer token)
## Auth endpoints (Bearer token)
All CLI endpoints require:
All endpoints require:
```
Authorization: Bearer clh_...
```
### `GET /api/cli/whoami`
### `GET /api/v1/whoami`
Validates token and returns the user handle.
### `POST /api/cli/upload-url`
### `POST /api/v1/skills`
Returns a Convex upload URL for a single file upload.
Publishes a new version.
Response:
- Preferred: `multipart/form-data` with `payload` JSON + `files[]` blobs.
- JSON body with `files` (storageId-based) is also accepted.
```json
{ "uploadUrl": "https://..." }
```
### `POST /api/cli/publish`
Publishes a new version from uploaded files.
- Validates semver, slug, size limits, text-only files, and `SKILL.md`.
- Generates embeddings (requires `OPENAI_API_KEY` server-side).
### `POST /api/cli/telemetry/sync`
Used by `clawdhub sync` to report install telemetry.
Details: `docs/telemetry.md`.
### `POST /api/cli/skill/delete` / `POST /api/cli/skill/undelete`
### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete`
Soft-delete / restore a skill (owner/admin only).
## Legacy CLI endpoints (deprecated)
Still supported for older CLI versions:
- `GET /api/cli/whoami`
- `POST /api/cli/upload-url`
- `POST /api/cli/publish`
- `POST /api/cli/telemetry/sync`
- `POST /api/cli/skill/delete`
- `POST /api/cli/skill/undelete`
See `DEPRECATIONS.md` for removal plan.
## Registry discovery (`/.well-known/clawdhub.json`)
The CLI can discover registry/auth settings from the site:

View File

@ -40,7 +40,7 @@ read_when:
## Delete / undelete (owner/admin)
- `bun clawdhub delete clawdhub-manual-<ts> --yes`
- Verify hidden:
- `curl -i "https://clawdhub.com/api/skill?slug=clawdhub-manual-<ts>"`
- `curl -i "https://clawdhub.com/api/v1/skills/clawdhub-manual-<ts>"`
- Restore:
- `bun clawdhub undelete clawdhub-manual-<ts> --yes`
- Cleanup:

View File

@ -5,9 +5,9 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
ApiCliWhoamiResponseSchema,
ApiRoutes,
ApiSearchResponseSchema,
ApiV1SearchResponseSchema,
ApiV1WhoamiResponseSchema,
parseArk,
} from 'clawdhub-schema'
import { unzipSync } from 'fflate'
@ -50,7 +50,7 @@ describe('clawdhub e2e', () => {
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } })
expect(response.ok).toBe(true)
const json = (await response.json()) as unknown
const parsed = parseArk(ApiSearchResponseSchema, json, 'API response')
const parsed = parseArk(ApiV1SearchResponseSchema, json, 'API response')
expect(Array.isArray(parsed.results)).toBe(true)
})
@ -102,13 +102,13 @@ describe('clawdhub e2e', () => {
const cfg = await makeTempConfig(registry, token)
try {
const whoamiUrl = new URL(ApiRoutes.cliWhoami, registry)
const whoamiUrl = new URL(ApiRoutes.whoami, registry)
const whoamiRes = await fetch(whoamiUrl.toString(), {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
})
expect(whoamiRes.ok).toBe(true)
const whoami = parseArk(
ApiCliWhoamiResponseSchema,
ApiV1WhoamiResponseSchema,
(await whoamiRes.json()) as unknown,
'Whoami',
)
@ -319,9 +319,9 @@ describe('clawdhub e2e', () => {
)
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' } })
const metaRes = await fetch(`${registry}${ApiRoutes.skills}/${slug}`, {
headers: { Accept: 'application/json' },
})
expect(metaRes.status).toBe(200)
const del = spawnSync(

View File

@ -2,7 +2,7 @@ import { buildCliAuthUrl, startLoopbackAuthServer } from '../../browserAuth.js'
import { readGlobalConfig, writeGlobalConfig } from '../../config.js'
import { discoverRegistryFromSite } from '../../discovery.js'
import { apiRequest } from '../../http.js'
import { ApiCliWhoamiResponseSchema, ApiRoutes } from '../../schema/index.js'
import { ApiRoutes, ApiV1WhoamiResponseSchema } from '../../schema/index.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, openInBrowser, promptHidden } from '../ui.js'
@ -55,8 +55,8 @@ export async function cmdLogin(
try {
const whoami = await apiRequest(
registry,
{ method: 'GET', path: ApiRoutes.cliWhoami, token },
ApiCliWhoamiResponseSchema,
{ method: 'GET', path: ApiRoutes.whoami, token },
ApiV1WhoamiResponseSchema,
)
if (!whoami.user) fail('Login failed')
@ -86,8 +86,8 @@ export async function cmdWhoami(opts: GlobalOpts) {
try {
const whoami = await apiRequest(
registry,
{ method: 'GET', path: ApiRoutes.cliWhoami, token },
ApiCliWhoamiResponseSchema,
{ method: 'GET', path: ApiRoutes.whoami, token },
ApiV1WhoamiResponseSchema,
)
spinner.succeed(whoami.user.handle ?? 'unknown')
} catch (error) {

View File

@ -56,7 +56,7 @@ describe('delete/undelete', () => {
await cmdDeleteSkill(makeOpts(), 'demo', { yes: true }, false)
expect(mockApiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: 'POST', path: '/api/cli/skill/delete' }),
expect.objectContaining({ method: 'DELETE', path: '/api/v1/skills/demo' }),
expect.anything(),
)
})
@ -66,7 +66,7 @@ describe('delete/undelete', () => {
await cmdUndeleteSkill(makeOpts(), 'demo', { yes: true }, false)
expect(mockApiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: 'POST', path: '/api/cli/skill/undelete' }),
expect.objectContaining({ method: 'POST', path: '/api/v1/skills/demo/undelete' }),
expect.anything(),
)
})

View File

@ -1,6 +1,6 @@
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import { ApiCliSkillDeleteResponseSchema, ApiRoutes, parseArk } from '../../schema/index.js'
import { ApiRoutes, ApiV1DeleteResponseSchema, parseArk } from '../../schema/index.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
@ -32,14 +32,13 @@ export async function cmdDeleteSkill(
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,
{ method: 'DELETE', path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}`, token },
ApiV1DeleteResponseSchema,
)
spinner.succeed(`OK. Deleted ${slug}`)
return parseArk(ApiCliSkillDeleteResponseSchema, result, 'Delete response')
return parseArk(ApiV1DeleteResponseSchema, result, 'Delete response')
} catch (error) {
spinner.fail(formatError(error))
throw error
@ -66,14 +65,17 @@ export async function cmdUndeleteSkill(
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,
{
method: 'POST',
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/undelete`,
token,
},
ApiV1DeleteResponseSchema,
)
spinner.succeed(`OK. Undeleted ${slug}`)
return parseArk(ApiCliSkillDeleteResponseSchema, result, 'Undelete response')
return parseArk(ApiV1DeleteResponseSchema, result, 'Undelete response')
} catch (error) {
spinner.fail(formatError(error))
throw error

View File

@ -4,7 +4,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { sha256Hex } from '../../skills'
import type { GlobalOpts } from '../types'
vi.mock('../../config.js', () => ({
@ -16,10 +15,10 @@ vi.mock('../registry.js', () => ({
getRegistry: (opts: unknown, params?: unknown) => mockGetRegistry(opts, params),
}))
const mockApiRequest = vi.fn()
const mockApiRequestForm = vi.fn()
vi.mock('../../http.js', () => ({
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
mockApiRequest(registry, args, schema),
apiRequestForm: (registry: unknown, args: unknown, schema?: unknown) =>
mockApiRequestForm(registry, args, schema),
}))
const mockFail = vi.fn((message: string) => {
@ -65,37 +64,7 @@ describe('cmdPublish', () => {
await writeFile(join(folder, 'SKILL.md'), skillContent, 'utf8')
await writeFile(join(folder, 'notes.md'), notesContent, 'utf8')
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: null, latestVersion: { version: '9.9.9' } }
}
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_1' }
}
throw new Error(`Unexpected apiRequest: ${args.method} ${args.path}`)
},
)
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
expect(url).toMatch(/^https:\/\/upload\.example\/\d+$/)
expect(init?.method).toBe('POST')
expect((init?.headers as Record<string, string>)?.['Content-Type']).toMatch(
/text\/(markdown|plain)/,
)
return new Response(JSON.stringify({ storageId: `st_${String(url).split('/').pop()}` }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as unknown as typeof fetch,
)
mockApiRequestForm.mockResolvedValueOnce({ ok: true, skillId: 'skill_1', versionId: 'ver_1' })
await cmdPublish(makeOpts(workdir), 'my-skill', {
slug: 'my-skill',
@ -105,30 +74,20 @@ describe('cmdPublish', () => {
tags: 'latest',
})
const publishCall = mockApiRequest.mock.calls.find((call) => {
const publishCall = mockApiRequestForm.mock.calls.find((call) => {
const req = call[1] as { path?: string } | undefined
return req?.path === '/api/cli/publish'
return req?.path === '/api/v1/skills'
})
if (!publishCall) throw new Error('Missing publish call')
const publishBody = (publishCall[1] as { body?: unknown }).body as {
slug: string
displayName: string
version: string
changelog: string
tags: string[]
files: Array<{ path: string; sha256: string; storageId: string }>
}
expect(publishBody.slug).toBe('my-skill')
expect(publishBody.displayName).toBe('My Skill')
expect(publishBody.version).toBe('1.0.0')
expect(publishBody.changelog).toBe('')
expect(publishBody.tags).toEqual(['latest'])
const byPath = Object.fromEntries(publishBody.files.map((f) => [f.path, f]))
expect(Object.keys(byPath).sort()).toEqual(['SKILL.md', 'notes.md'])
expect(byPath['SKILL.md']?.sha256).toBe(sha256Hex(new TextEncoder().encode(skillContent)))
expect(byPath['notes.md']?.sha256).toBe(sha256Hex(new TextEncoder().encode(notesContent)))
const publishForm = (publishCall[1] as { form?: FormData }).form as FormData
const payload = JSON.parse(String(publishForm.get('payload')))
expect(payload.slug).toBe('my-skill')
expect(payload.displayName).toBe('My Skill')
expect(payload.version).toBe('1.0.0')
expect(payload.changelog).toBe('')
expect(payload.tags).toEqual(['latest'])
const files = publishForm.getAll('files') as Array<Blob & { name?: string }>
expect(files.map((file) => String(file.name ?? '')).sort()).toEqual(['SKILL.md', 'notes.md'])
} finally {
await rm(workdir, { recursive: true, force: true })
}
@ -141,28 +100,7 @@ describe('cmdPublish', () => {
await mkdir(folder, { recursive: true })
await writeFile(join(folder, 'SKILL.md'), '# Skill\n', 'utf8')
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(JSON.stringify({ storageId: 'st_1' }), { status: 200 }),
) as unknown as typeof fetch,
)
mockApiRequestForm.mockResolvedValueOnce({ ok: true, skillId: 'skill_1', versionId: 'ver_2' })
await cmdPublish(makeOpts(workdir), 'existing-skill', {
version: '1.0.1',
@ -170,9 +108,9 @@ describe('cmdPublish', () => {
tags: 'latest',
})
expect(mockApiRequest).toHaveBeenCalledWith(
expect(mockApiRequestForm).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ path: '/api/cli/publish', method: 'POST' }),
expect.objectContaining({ path: '/api/v1/skills', method: 'POST' }),
expect.anything(),
)
} finally {

View File

@ -2,16 +2,9 @@ import { stat } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import semver from 'semver'
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import {
ApiCliPublishResponseSchema,
ApiCliUploadUrlResponseSchema,
ApiRoutes,
ApiUploadFileResponseSchema,
CliPublishRequestSchema,
parseArk,
} from '../../schema/index.js'
import { listTextFiles, sha256Hex } from '../../skills.js'
import { apiRequestForm } from '../../http.js'
import { ApiRoutes, ApiV1PublishResponseSchema } from '../../schema/index.js'
import { listTextFiles } from '../../skills.js'
import { getRegistry } from '../registry.js'
import { sanitizeSlug, titleCase } from '../slug.js'
import type { GlobalOpts } from '../types.js'
@ -69,50 +62,32 @@ export async function cmdPublish(
fail('SKILL.md required')
}
const uploaded: Array<{
path: string
size: number
storageId: string
sha256: string
contentType?: string
}> = []
const form = new FormData()
form.set(
'payload',
JSON.stringify({
slug,
displayName,
version,
changelog,
tags,
...(forkOf ? { forkOf } : {}),
}),
)
let index = 0
for (const file of filesOnDisk) {
index += 1
spinner.text = `Uploading ${file.relPath} (${index}/${filesOnDisk.length})`
const { uploadUrl } = await apiRequest(
registry,
{ method: 'POST', path: ApiRoutes.cliUploadUrl, token },
ApiCliUploadUrlResponseSchema,
)
const storageId = await uploadFile(uploadUrl, file.bytes, file.contentType ?? 'text/plain')
const sha256 = sha256Hex(file.bytes)
uploaded.push({
path: file.relPath,
size: file.bytes.byteLength,
storageId,
sha256,
contentType: file.contentType ?? undefined,
})
const blob = new Blob([Buffer.from(file.bytes)], { type: file.contentType ?? 'text/plain' })
form.append('files', blob, file.relPath)
}
spinner.text = `Publishing ${slug}@${version}`
const publishPayload = {
slug,
displayName,
version,
changelog,
tags,
files: uploaded,
...(forkOf ? { forkOf } : {}),
}
const body = parseArk(CliPublishRequestSchema, publishPayload, 'Publish payload')
const result = await apiRequest(
const result = await apiRequestForm(
registry,
{ method: 'POST', path: ApiRoutes.cliPublish, token, body },
ApiCliPublishResponseSchema,
{ method: 'POST', path: ApiRoutes.skills, token, form },
ApiV1PublishResponseSchema,
)
spinner.succeed(`OK. Published ${slug}@${version} (${result.versionId})`)
@ -131,20 +106,3 @@ function parseForkOf(value: string) {
if (version && !semver.valid(version)) fail('--fork-of version must be valid semver')
return { slug, version: version || undefined }
}
async function uploadFile(uploadUrl: string, bytes: Uint8Array, contentType: string) {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': contentType || 'application/octet-stream' },
body: Buffer.from(bytes),
})
if (!response.ok) {
throw new Error(`Upload failed: ${await response.text()}`)
}
const payload = parseArk(
ApiUploadFileResponseSchema,
(await response.json()) as unknown,
'Upload response',
)
return payload.storageId
}

View File

@ -4,9 +4,9 @@ import semver from 'semver'
import { apiRequest, downloadZip } from '../../http.js'
import {
ApiRoutes,
ApiSearchResponseSchema,
ApiSkillMetaResponseSchema,
ApiSkillResolveResponseSchema,
ApiV1SearchResponseSchema,
ApiV1SkillResolveResponseSchema,
ApiV1SkillResponseSchema,
} from '../../schema/index.js'
import {
extractZipToDir,
@ -35,7 +35,7 @@ export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number)
const result = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
ApiSearchResponseSchema,
ApiV1SearchResponseSchema,
)
spinner.stop()
@ -77,8 +77,8 @@ export async function cmdInstall(
(
await apiRequest(
registry,
{ method: 'GET', path: `/api/skill?slug=${encodeURIComponent(trimmed)}` },
ApiSkillMetaResponseSchema,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
ApiV1SkillResponseSchema,
)
).latestVersion?.version ??
null
@ -150,12 +150,10 @@ export async function cmdUpdate(
if (localFingerprint) {
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint)
} else {
const url = new URL(ApiRoutes.skill, registry)
url.searchParams.set('slug', entry)
const meta = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
ApiSkillMetaResponseSchema,
{ method: 'GET', url: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
ApiV1SkillResponseSchema,
)
resolveResult = { match: null, latestVersion: meta.latestVersion ?? null }
}
@ -244,10 +242,14 @@ export async function cmdList(opts: GlobalOpts) {
}
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
const url = new URL(ApiRoutes.skillResolve, registry)
const url = new URL(ApiRoutes.resolve, registry)
url.searchParams.set('slug', slug)
url.searchParams.set('hash', hash)
return apiRequest(registry, { method: 'GET', url: url.toString() }, ApiSkillResolveResponseSchema)
return apiRequest(
registry,
{ method: 'GET', url: url.toString() },
ApiV1SkillResolveResponseSchema,
)
}
async function fileExists(path: string) {

View File

@ -98,9 +98,9 @@ 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 === '/api/v1/whoami') return { user: { handle: 'steipete' } }
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
if (args.path.startsWith('/api/skill/resolve?')) {
if (args.path.startsWith('/api/v1/resolve?')) {
const u = new URL(`https://x.test${args.path}`)
const slug = u.searchParams.get('slug')
if (slug === 'new-skill') {
@ -135,9 +135,9 @@ describe('cmdSync', () => {
return initialValues
})
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
if (args.path.startsWith('/api/skill/resolve?')) {
if (args.path.startsWith('/api/v1/resolve?')) {
const u = new URL(`https://x.test${args.path}`)
const slug = u.searchParams.get('slug')
if (slug === 'new-skill') {
@ -171,9 +171,9 @@ describe('cmdSync', () => {
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 === '/api/v1/whoami') return { user: { handle: 'steipete' } }
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
if (args.path.startsWith('/api/skill/resolve?')) {
if (args.path.startsWith('/api/v1/resolve?')) {
return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } }
}
throw new Error(`Unexpected apiRequest: ${args.path}`)
@ -203,9 +203,9 @@ describe('cmdSync', () => {
})
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
if (args.path.startsWith('/api/skill/resolve?')) {
if (args.path.startsWith('/api/v1/resolve?')) {
return { match: null, latestVersion: null }
}
throw new Error(`Unexpected apiRequest: ${args.path}`)
@ -222,9 +222,9 @@ describe('cmdSync', () => {
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 === '/api/v1/whoami') return { user: { handle: 'steipete' } }
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
if (args.path.startsWith('/api/skill/resolve?')) {
if (args.path.startsWith('/api/v1/resolve?')) {
const u = new URL(`https://x.test${args.path}`)
const slug = u.searchParams.get('slug')
if (slug === 'new-skill') {
@ -254,8 +254,8 @@ describe('cmdSync', () => {
interactive = false
process.env.CLAWDHUB_DISABLE_TELEMETRY = '1'
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
if (args.path.startsWith('/api/skill/resolve?')) {
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
if (args.path.startsWith('/api/v1/resolve?')) {
return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } }
}
throw new Error(`Unexpected apiRequest: ${args.path}`)

View File

@ -7,10 +7,11 @@ import semver from 'semver'
import { apiRequest, downloadZip } from '../../http.js'
import {
ApiCliTelemetrySyncResponseSchema,
ApiCliWhoamiResponseSchema,
ApiRoutes,
ApiSkillMetaResponseSchema,
ApiSkillResolveResponseSchema,
ApiV1SkillResolveResponseSchema,
ApiV1SkillResponseSchema,
ApiV1WhoamiResponseSchema,
LegacyApiRoutes,
} from '../../schema/index.js'
import { hashSkillZip } from '../../skills.js'
import { getRegistry } from '../registry.js'
@ -45,7 +46,7 @@ export async function reportTelemetryIfEnabled(params: {
params.registry,
{
method: 'POST',
path: ApiRoutes.cliTelemetrySync,
path: LegacyApiRoutes.cliTelemetrySync,
token: params.token,
body: { roots },
},
@ -106,9 +107,9 @@ export async function checkRegistrySyncState(
registry,
{
method: 'GET',
path: `${ApiRoutes.skillResolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`,
path: `${ApiRoutes.resolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`,
},
ApiSkillResolveResponseSchema,
ApiV1SkillResolveResponseSchema,
)
resolveSupport.value = true
const latestVersion = resolved.latestVersion?.version ?? null
@ -148,8 +149,8 @@ export async function checkRegistrySyncState(
const meta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skill}?slug=${encodeURIComponent(skill.slug)}` },
ApiSkillMetaResponseSchema,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}` },
ApiV1SkillResponseSchema,
).catch(() => null)
const latestVersion = meta?.latestVersion?.version ?? null
@ -290,8 +291,8 @@ export async function getRegistryWithAuth(opts: GlobalOpts, token: string) {
const registry = await getRegistry(opts, { cache: true })
await apiRequest(
registry,
{ method: 'GET', path: ApiRoutes.cliWhoami, token },
ApiCliWhoamiResponseSchema,
{ method: 'GET', path: ApiRoutes.whoami, token },
ApiV1WhoamiResponseSchema,
)
return registry
}

View File

@ -2,7 +2,7 @@
import { describe, expect, it, vi } from 'vitest'
import { apiRequest, downloadZip } from './http'
import { ApiCliWhoamiResponseSchema } from './schema/index.js'
import { ApiV1WhoamiResponseSchema } from './schema/index.js'
describe('apiRequest', () => {
it('adds bearer token and parses json', async () => {
@ -14,7 +14,7 @@ describe('apiRequest', () => {
const result = await apiRequest(
'https://example.com',
{ method: 'GET', path: '/x', token: 'clh_token' },
ApiCliWhoamiResponseSchema,
ApiV1WhoamiResponseSchema,
)
expect(result.user.handle).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(1)

View File

@ -3,8 +3,8 @@ import type { ArkValidator } from './schema/index.js'
import { ApiRoutes, parseArk } from './schema/index.js'
type RequestArgs =
| { method: 'GET' | 'POST'; path: string; token?: string; body?: unknown }
| { method: 'GET' | 'POST'; url: string; token?: string; body?: unknown }
| { method: 'GET' | 'POST' | 'DELETE'; path: string; token?: string; body?: unknown }
| { method: 'GET' | 'POST' | 'DELETE'; url: string; token?: string; body?: unknown }
export async function apiRequest<T>(registry: string, args: RequestArgs): Promise<T>
export async function apiRequest<T>(
@ -44,6 +44,43 @@ export async function apiRequest<T>(
return json as T
}
type FormRequestArgs =
| { method: 'POST'; path: string; token?: string; form: FormData }
| { method: 'POST'; url: string; token?: string; form: FormData }
export async function apiRequestForm<T>(registry: string, args: FormRequestArgs): Promise<T>
export async function apiRequestForm<T>(
registry: string,
args: FormRequestArgs,
schema: ArkValidator<T>,
): Promise<T>
export async function apiRequestForm<T>(
registry: string,
args: FormRequestArgs,
schema?: ArkValidator<T>,
): Promise<T> {
const url = 'url' in args ? args.url : new URL(args.path, registry).toString()
const json = await pRetry(
async () => {
const headers: Record<string, string> = { Accept: 'application/json' }
if (args.token) headers.Authorization = `Bearer ${args.token}`
const response = await fetch(url, { method: args.method, headers, body: args.form })
if (!response.ok) {
const text = await response.text().catch(() => '')
const message = text || `HTTP ${response.status}`
if (response.status === 429 || response.status >= 500) {
throw new Error(message)
}
throw new AbortError(message)
}
return (await response.json()) as unknown
},
{ retries: 2 },
)
if (schema) return parseArk(schema, json, 'API response')
return json as T
}
export async function downloadZip(registry: string, args: { slug: string; version?: string }) {
const url = new URL(ApiRoutes.download, registry)
url.searchParams.set('slug', args.slug)

View File

@ -1,5 +1,5 @@
export type { ArkValidator } from './ark.js'
export { formatArkErrors, parseArk } from './ark.js'
export { ApiRoutes } from './routes.js'
export { ApiRoutes, LegacyApiRoutes } from './routes.js'
export * from './schemas.js'
export * from './textFiles.js'

View File

@ -1,4 +1,4 @@
export const ApiRoutes = {
export const LegacyApiRoutes = {
download: '/api/download',
search: '/api/search',
skill: '/api/skill',
@ -10,3 +10,11 @@ export const ApiRoutes = {
cliSkillDelete: '/api/cli/skill/delete',
cliSkillUndelete: '/api/cli/skill/undelete',
} as const
export const ApiRoutes = {
search: '/api/v1/search',
resolve: '/api/v1/resolve',
download: '/api/v1/download',
skills: '/api/v1/skills',
whoami: '/api/v1/whoami',
} as const

View File

@ -117,6 +117,104 @@ export const ApiCliTelemetrySyncResponseSchema = type({
ok: 'true',
})
export const ApiV1WhoamiResponseSchema = type({
user: {
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
},
})
export const ApiV1SearchResponseSchema = type({
results: type({
slug: 'string?',
displayName: 'string?',
summary: 'string|null?',
version: 'string|null?',
score: 'number',
updatedAt: 'number?',
}).array(),
})
export const ApiV1SkillListResponseSchema = type({
items: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
}).optional(),
}).array(),
nextCursor: 'string|null',
})
export const ApiV1SkillResponseSchema = type({
skill: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
}).or('null'),
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
}).or('null'),
owner: type({
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
}).or('null'),
})
export const ApiV1SkillVersionListResponseSchema = type({
items: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
changelogSource: '"auto"|"user"|null?',
}).array(),
nextCursor: 'string|null',
})
export const ApiV1SkillVersionResponseSchema = type({
version: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
changelogSource: '"auto"|"user"|null?',
files: 'unknown?',
}).or('null'),
skill: type({
slug: 'string',
displayName: 'string',
}).or('null'),
})
export const ApiV1SkillResolveResponseSchema = type({
match: type({ version: 'string' }).or('null'),
latestVersion: type({ version: 'string' }).or('null'),
})
export const ApiV1PublishResponseSchema = type({
ok: 'true',
skillId: 'string',
versionId: 'string',
})
export const ApiV1DeleteResponseSchema = type({
ok: 'true',
})
export const SkillInstallSpecSchema = type({
id: 'string?',
kind: '"brew"|"node"|"go"|"uv"',

View File

@ -1,5 +1,5 @@
export type { ArkValidator } from './ark.js';
export { formatArkErrors, parseArk } from './ark.js';
export { ApiRoutes } from './routes.js';
export { ApiRoutes, LegacyApiRoutes } from './routes.js';
export * from './schemas.js';
export * from './textFiles.js';

View File

@ -1,5 +1,5 @@
export { formatArkErrors, parseArk } from './ark.js';
export { ApiRoutes } from './routes.js';
export { ApiRoutes, LegacyApiRoutes } from './routes.js';
export * from './schemas.js';
export * from './textFiles.js';
//# sourceMappingURL=index.js.map

View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AACpD,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACxD,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA"}

View File

@ -1,4 +1,4 @@
export declare const ApiRoutes: {
export declare const LegacyApiRoutes: {
readonly download: "/api/download";
readonly search: "/api/search";
readonly skill: "/api/skill";
@ -10,3 +10,10 @@ export declare const ApiRoutes: {
readonly cliSkillDelete: "/api/cli/skill/delete";
readonly cliSkillUndelete: "/api/cli/skill/undelete";
};
export declare const ApiRoutes: {
readonly search: "/api/v1/search";
readonly resolve: "/api/v1/resolve";
readonly download: "/api/v1/download";
readonly skills: "/api/v1/skills";
readonly whoami: "/api/v1/whoami";
};

View File

@ -1,4 +1,4 @@
export const ApiRoutes = {
export const LegacyApiRoutes = {
download: '/api/download',
search: '/api/search',
skill: '/api/skill',
@ -10,4 +10,11 @@ export const ApiRoutes = {
cliSkillDelete: '/api/cli/skill/delete',
cliSkillUndelete: '/api/cli/skill/undelete',
};
export const ApiRoutes = {
search: '/api/v1/search',
resolve: '/api/v1/resolve',
download: '/api/v1/download',
skills: '/api/v1/skills',
whoami: '/api/v1/whoami',
};
//# sourceMappingURL=routes.js.map

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;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA"}
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,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,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}

View File

@ -110,6 +110,99 @@ export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inf
export declare const ApiCliTelemetrySyncResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
}, {}>;
export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
user: {
handle: string | null;
displayName?: string | null | undefined;
image?: string | null | undefined;
};
}, {}>;
export declare const ApiV1SearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
results: {
score: number;
slug?: string | undefined;
displayName?: string | undefined;
summary?: string | null | undefined;
version?: string | null | undefined;
updatedAt?: number | undefined;
}[];
}, {}>;
export declare const ApiV1SkillListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: {
slug: string;
displayName: string;
tags: unknown;
stats: unknown;
createdAt: number;
updatedAt: number;
summary?: string | null | undefined;
latestVersion?: {
version: string;
createdAt: number;
changelog: string;
} | undefined;
}[];
nextCursor: string | null;
}, {}>;
export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
skill: {
slug: string;
displayName: string;
tags: unknown;
stats: unknown;
createdAt: number;
updatedAt: number;
summary?: string | null | undefined;
} | null;
latestVersion: {
version: string;
createdAt: number;
changelog: string;
} | null;
owner: {
handle: string | null;
displayName?: string | null | undefined;
image?: string | null | undefined;
} | null;
}, {}>;
export declare const ApiV1SkillVersionListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: {
version: string;
createdAt: number;
changelog: string;
changelogSource?: "user" | "auto" | null | undefined;
}[];
nextCursor: string | null;
}, {}>;
export declare const ApiV1SkillVersionResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
version: {
version: string;
createdAt: number;
changelog: string;
changelogSource?: "user" | "auto" | null | undefined;
files?: unknown;
} | null;
skill: {
slug: string;
displayName: string;
} | null;
}, {}>;
export declare const ApiV1SkillResolveResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
match: {
version: string;
} | null;
latestVersion: {
version: string;
} | null;
}, {}>;
export declare const ApiV1PublishResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
skillId: string;
versionId: string;
}, {}>;
export declare const ApiV1DeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
}, {}>;
export declare const SkillInstallSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{
kind: "brew" | "node" | "go" | "uv";
id?: string | undefined;

View File

@ -93,6 +93,95 @@ export const CliTelemetrySyncRequestSchema = type({
export const ApiCliTelemetrySyncResponseSchema = type({
ok: 'true',
});
export const ApiV1WhoamiResponseSchema = type({
user: {
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
},
});
export const ApiV1SearchResponseSchema = type({
results: type({
slug: 'string?',
displayName: 'string?',
summary: 'string|null?',
version: 'string|null?',
score: 'number',
updatedAt: 'number?',
}).array(),
});
export const ApiV1SkillListResponseSchema = type({
items: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
}).optional(),
}).array(),
nextCursor: 'string|null',
});
export const ApiV1SkillResponseSchema = type({
skill: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
}).or('null'),
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
}).or('null'),
owner: type({
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
}).or('null'),
});
export const ApiV1SkillVersionListResponseSchema = type({
items: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
changelogSource: '"auto"|"user"|null?',
}).array(),
nextCursor: 'string|null',
});
export const ApiV1SkillVersionResponseSchema = type({
version: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
changelogSource: '"auto"|"user"|null?',
files: 'unknown?',
}).or('null'),
skill: type({
slug: 'string',
displayName: 'string',
}).or('null'),
});
export const ApiV1SkillResolveResponseSchema = type({
match: type({ version: 'string' }).or('null'),
latestVersion: type({ version: 'string' }).or('null'),
});
export const ApiV1PublishResponseSchema = type({
ok: 'true',
skillId: 'string',
versionId: 'string',
});
export const ApiV1DeleteResponseSchema = type({
ok: 'true',
});
export const SkillInstallSpecSchema = type({
id: 'string?',
kind: '"brew"|"node"|"go"|"uv"',

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
export type { ArkValidator } from './ark.js'
export { formatArkErrors, parseArk } from './ark.js'
export { ApiRoutes } from './routes.js'
export { ApiRoutes, LegacyApiRoutes } from './routes.js'
export * from './schemas.js'
export * from './textFiles.js'

View File

@ -1,4 +1,4 @@
export const ApiRoutes = {
export const LegacyApiRoutes = {
download: '/api/download',
search: '/api/search',
skill: '/api/skill',
@ -10,3 +10,11 @@ export const ApiRoutes = {
cliSkillDelete: '/api/cli/skill/delete',
cliSkillUndelete: '/api/cli/skill/undelete',
} as const
export const ApiRoutes = {
search: '/api/v1/search',
resolve: '/api/v1/resolve',
download: '/api/v1/download',
skills: '/api/v1/skills',
whoami: '/api/v1/whoami',
} as const

View File

@ -117,6 +117,104 @@ export const ApiCliTelemetrySyncResponseSchema = type({
ok: 'true',
})
export const ApiV1WhoamiResponseSchema = type({
user: {
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
},
})
export const ApiV1SearchResponseSchema = type({
results: type({
slug: 'string?',
displayName: 'string?',
summary: 'string|null?',
version: 'string|null?',
score: 'number',
updatedAt: 'number?',
}).array(),
})
export const ApiV1SkillListResponseSchema = type({
items: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
}).optional(),
}).array(),
nextCursor: 'string|null',
})
export const ApiV1SkillResponseSchema = type({
skill: type({
slug: 'string',
displayName: 'string',
summary: 'string|null?',
tags: 'unknown',
stats: 'unknown',
createdAt: 'number',
updatedAt: 'number',
}).or('null'),
latestVersion: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
}).or('null'),
owner: type({
handle: 'string|null',
displayName: 'string|null?',
image: 'string|null?',
}).or('null'),
})
export const ApiV1SkillVersionListResponseSchema = type({
items: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
changelogSource: '"auto"|"user"|null?',
}).array(),
nextCursor: 'string|null',
})
export const ApiV1SkillVersionResponseSchema = type({
version: type({
version: 'string',
createdAt: 'number',
changelog: 'string',
changelogSource: '"auto"|"user"|null?',
files: 'unknown?',
}).or('null'),
skill: type({
slug: 'string',
displayName: 'string',
}).or('null'),
})
export const ApiV1SkillResolveResponseSchema = type({
match: type({ version: 'string' }).or('null'),
latestVersion: type({ version: 'string' }).or('null'),
})
export const ApiV1PublishResponseSchema = type({
ok: 'true',
skillId: 'string',
versionId: 'string',
})
export const ApiV1DeleteResponseSchema = type({
ok: 'true',
})
export const SkillInstallSpecSchema = type({
id: 'string?',
kind: '"brew"|"node"|"go"|"uv"',

379
public/api/v1/openapi.json Normal file
View File

@ -0,0 +1,379 @@
{
"openapi": "3.1.0",
"info": {
"title": "ClawdHub API",
"version": "1.0.0",
"description": "Public REST API for skills. Rate limits: read 120/min per IP + 600/min per key; write 30/min per IP + 120/min per key."
},
"servers": [{ "url": "https://clawdhub.com" }],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "API token"
}
},
"schemas": {
"SearchResult": {
"type": "object",
"properties": {
"score": { "type": "number" },
"slug": { "type": ["string", "null"] },
"displayName": { "type": ["string", "null"] },
"summary": { "type": ["string", "null"] },
"version": { "type": ["string", "null"] },
"updatedAt": { "type": ["number", "null"] }
}
},
"SearchResponse": {
"type": "object",
"properties": {
"results": { "type": "array", "items": { "$ref": "#/components/schemas/SearchResult" } }
}
},
"TagMap": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"Stats": {
"type": "object",
"additionalProperties": true
},
"SkillListItem": {
"type": "object",
"properties": {
"slug": { "type": "string" },
"displayName": { "type": "string" },
"summary": { "type": ["string", "null"] },
"tags": { "$ref": "#/components/schemas/TagMap" },
"stats": { "$ref": "#/components/schemas/Stats" },
"createdAt": { "type": "number" },
"updatedAt": { "type": "number" },
"latestVersion": {
"type": ["object", "null"],
"properties": {
"version": { "type": "string" },
"createdAt": { "type": "number" },
"changelog": { "type": "string" }
}
}
}
},
"SkillListResponse": {
"type": "object",
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/SkillListItem" } },
"nextCursor": { "type": ["string", "null"] }
}
},
"Owner": {
"type": ["object", "null"],
"properties": {
"handle": { "type": ["string", "null"] },
"displayName": { "type": ["string", "null"] },
"image": { "type": ["string", "null"] }
}
},
"Skill": {
"type": ["object", "null"],
"properties": {
"slug": { "type": "string" },
"displayName": { "type": "string" },
"summary": { "type": ["string", "null"] },
"tags": { "$ref": "#/components/schemas/TagMap" },
"stats": { "$ref": "#/components/schemas/Stats" },
"createdAt": { "type": "number" },
"updatedAt": { "type": "number" }
}
},
"SkillResponse": {
"type": "object",
"properties": {
"skill": { "$ref": "#/components/schemas/Skill" },
"latestVersion": {
"type": ["object", "null"],
"properties": {
"version": { "type": "string" },
"createdAt": { "type": "number" },
"changelog": { "type": "string" }
}
},
"owner": { "$ref": "#/components/schemas/Owner" }
}
},
"SkillVersion": {
"type": "object",
"properties": {
"version": { "type": "string" },
"createdAt": { "type": "number" },
"changelog": { "type": "string" },
"changelogSource": { "type": ["string", "null"], "enum": ["auto", "user", null] }
}
},
"SkillVersionListResponse": {
"type": "object",
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/SkillVersion" } },
"nextCursor": { "type": ["string", "null"] }
}
},
"SkillVersionResponse": {
"type": "object",
"properties": {
"skill": {
"type": ["object", "null"],
"properties": {
"slug": { "type": "string" },
"displayName": { "type": "string" }
}
},
"version": {
"type": ["object", "null"],
"properties": {
"version": { "type": "string" },
"createdAt": { "type": "number" },
"changelog": { "type": "string" },
"changelogSource": { "type": ["string", "null"], "enum": ["auto", "user", null] },
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string" },
"size": { "type": "number" },
"sha256": { "type": "string" },
"contentType": { "type": ["string", "null"] }
}
}
}
}
}
}
},
"ResolveResponse": {
"type": "object",
"properties": {
"match": { "type": ["object", "null"], "properties": { "version": { "type": "string" } } },
"latestVersion": { "type": ["object", "null"], "properties": { "version": { "type": "string" } } }
}
},
"PublishResponse": {
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"skillId": { "type": "string" },
"versionId": { "type": "string" }
}
},
"DeleteResponse": {
"type": "object",
"properties": { "ok": { "type": "boolean" } }
},
"WhoamiResponse": {
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"handle": { "type": ["string", "null"] },
"displayName": { "type": ["string", "null"] },
"image": { "type": ["string", "null"] }
}
}
}
}
}
},
"paths": {
"/api/v1/search": {
"get": {
"summary": "Search skills",
"parameters": [
{ "name": "q", "in": "query", "required": true, "schema": { "type": "string" } },
{ "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } },
{ "name": "highlightedOnly", "in": "query", "required": false, "schema": { "type": "boolean" } }
],
"responses": {
"200": {
"description": "Search results",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchResponse" } } }
}
}
}
},
"/api/v1/resolve": {
"get": {
"summary": "Resolve version by hash",
"parameters": [
{ "name": "slug", "in": "query", "required": true, "schema": { "type": "string" } },
{ "name": "hash", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Resolved", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ResolveResponse" } } } },
"404": { "description": "Not found" }
}
}
},
"/api/v1/skills": {
"get": {
"summary": "List skills",
"parameters": [
{ "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } },
{ "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Skills", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillListResponse" } } } }
}
},
"post": {
"summary": "Publish a skill version",
"security": [{ "bearerAuth": [] }],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"payload": { "type": "string" },
"files": { "type": "array", "items": { "type": "string", "format": "binary" } }
},
"required": ["payload", "files"]
}
},
"application/json": {
"schema": {
"type": "object",
"properties": {
"slug": { "type": "string" },
"displayName": { "type": "string" },
"version": { "type": "string" },
"changelog": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"forkOf": {
"type": "object",
"properties": { "slug": { "type": "string" }, "version": { "type": "string" } }
},
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string" },
"size": { "type": "number" },
"storageId": { "type": "string" },
"sha256": { "type": "string" },
"contentType": { "type": "string" }
}
}
}
},
"required": ["slug", "displayName", "version", "changelog", "files"]
}
}
}
},
"responses": {
"200": { "description": "Published", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublishResponse" } } } }
}
}
},
"/api/v1/skills/{slug}": {
"get": {
"summary": "Get skill",
"parameters": [
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Skill", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillResponse" } } } },
"404": { "description": "Not found" }
}
},
"delete": {
"summary": "Soft delete skill",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } }
}
}
},
"/api/v1/skills/{slug}/undelete": {
"post": {
"summary": "Undelete skill",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Undeleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } }
}
}
},
"/api/v1/skills/{slug}/versions": {
"get": {
"summary": "List versions",
"parameters": [
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } },
{ "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Versions", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillVersionListResponse" } } } }
}
}
},
"/api/v1/skills/{slug}/versions/{version}": {
"get": {
"summary": "Get version",
"parameters": [
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "version", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Version", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillVersionResponse" } } } }
}
}
},
"/api/v1/skills/{slug}/file": {
"get": {
"summary": "Fetch raw file",
"parameters": [
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "path", "in": "query", "required": true, "schema": { "type": "string" } },
{ "name": "version", "in": "query", "required": false, "schema": { "type": "string" } },
{ "name": "tag", "in": "query", "required": false, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "File contents", "content": { "text/plain": { "schema": { "type": "string" } } } },
"404": { "description": "Not found" }
}
}
},
"/api/v1/download": {
"get": {
"summary": "Download zip",
"parameters": [
{ "name": "slug", "in": "query", "required": true, "schema": { "type": "string" } },
{ "name": "version", "in": "query", "required": false, "schema": { "type": "string" } },
{ "name": "tag", "in": "query", "required": false, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Zip", "content": { "application/zip": { "schema": { "type": "string", "format": "binary" } } } }
}
}
},
"/api/v1/whoami": {
"get": {
"summary": "Current user",
"security": [{ "bearerAuth": [] }],
"responses": {
"200": { "description": "User", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WhoamiResponse" } } } }
}
}
}
}
}

View File

@ -220,7 +220,7 @@ export function SkillDetailPage({
</div>
<a
className="btn btn-primary"
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/download?slug=${skill.slug}`}
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/v1/download?slug=${skill.slug}`}
>
Download zip
</a>
@ -445,7 +445,7 @@ export function SkillDetailPage({
<div className="version-actions">
<a
className="btn version-zip"
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/download?slug=${skill.slug}&version=${version.version}`}
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/v1/download?slug=${skill.slug}&version=${version.version}`}
>
Zip
</a>

View File

@ -27,13 +27,12 @@ export function getApiBase() {
export async function fetchSkillMeta(slug: string) {
try {
const apiBase = getApiBase()
const url = new URL('/api/skill', apiBase)
url.searchParams.set('slug', slug)
const url = new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, apiBase)
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } })
if (!response.ok) return null
const payload = (await response.json()) as {
skill?: { displayName?: string; summary?: string | null }
owner?: { handle?: string | null }
skill?: { displayName?: string; summary?: string | null } | null
owner?: { handle?: string | null } | null
}
return {
displayName: payload.skill?.displayName ?? null,