feat: add v1 public api
This commit is contained in:
parent
9f83114dee
commit
ba7e82ba02
10
CHANGELOG.md
10
CHANGELOG.md
@ -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
7
DEPRECATIONS.md
Normal 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`.
|
||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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
662
convex/httpApiV1.ts
Normal 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
50
convex/rateLimits.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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
50
docs/api.md
Normal 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`.
|
||||
@ -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 doesn’t 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)
|
||||
|
||||
|
||||
11
docs/cli.md
11
docs/cli.md
@ -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`
|
||||
|
||||
@ -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:
|
||||
|
||||
111
docs/http-api.md
111
docs/http-api.md
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"',
|
||||
|
||||
2
packages/schema/dist/index.d.ts
vendored
2
packages/schema/dist/index.d.ts
vendored
@ -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';
|
||||
|
||||
2
packages/schema/dist/index.js
vendored
2
packages/schema/dist/index.js
vendored
@ -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
|
||||
2
packages/schema/dist/index.js.map
vendored
2
packages/schema/dist/index.js.map
vendored
@ -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"}
|
||||
9
packages/schema/dist/routes.d.ts
vendored
9
packages/schema/dist/routes.d.ts
vendored
@ -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";
|
||||
};
|
||||
|
||||
9
packages/schema/dist/routes.js
vendored
9
packages/schema/dist/routes.js
vendored
@ -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
|
||||
2
packages/schema/dist/routes.js.map
vendored
2
packages/schema/dist/routes.js.map
vendored
@ -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"}
|
||||
93
packages/schema/dist/schemas.d.ts
vendored
93
packages/schema/dist/schemas.d.ts
vendored
@ -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;
|
||||
|
||||
89
packages/schema/dist/schemas.js
vendored
89
packages/schema/dist/schemas.js
vendored
@ -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"',
|
||||
|
||||
2
packages/schema/dist/schemas.js.map
vendored
2
packages/schema/dist/schemas.js.map
vendored
File diff suppressed because one or more lines are too long
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
379
public/api/v1/openapi.json
Normal 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" } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user