mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: add v1 public api
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
## 0.0.5 - 2026-01-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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`.
|
||||||
Vendored
+4
@@ -13,6 +13,7 @@ import type * as comments from "../comments.js";
|
|||||||
import type * as downloads from "../downloads.js";
|
import type * as downloads from "../downloads.js";
|
||||||
import type * as http from "../http.js";
|
import type * as http from "../http.js";
|
||||||
import type * as httpApi from "../httpApi.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_access from "../lib/access.js";
|
||||||
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
|
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
|
||||||
import type * as lib_changelog from "../lib/changelog.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_tokens from "../lib/tokens.js";
|
||||||
import type * as lib_webhooks from "../lib/webhooks.js";
|
import type * as lib_webhooks from "../lib/webhooks.js";
|
||||||
import type * as maintenance from "../maintenance.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 search from "../search.js";
|
||||||
import type * as skills from "../skills.js";
|
import type * as skills from "../skills.js";
|
||||||
import type * as stars from "../stars.js";
|
import type * as stars from "../stars.js";
|
||||||
@@ -44,6 +46,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
downloads: typeof downloads;
|
downloads: typeof downloads;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
httpApi: typeof httpApi;
|
httpApi: typeof httpApi;
|
||||||
|
httpApiV1: typeof httpApiV1;
|
||||||
"lib/access": typeof lib_access;
|
"lib/access": typeof lib_access;
|
||||||
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
|
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
|
||||||
"lib/changelog": typeof lib_changelog;
|
"lib/changelog": typeof lib_changelog;
|
||||||
@@ -54,6 +57,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
"lib/tokens": typeof lib_tokens;
|
"lib/tokens": typeof lib_tokens;
|
||||||
"lib/webhooks": typeof lib_webhooks;
|
"lib/webhooks": typeof lib_webhooks;
|
||||||
maintenance: typeof maintenance;
|
maintenance: typeof maintenance;
|
||||||
|
rateLimits: typeof rateLimits;
|
||||||
search: typeof search;
|
search: typeof search;
|
||||||
skills: typeof skills;
|
skills: typeof skills;
|
||||||
stars: typeof stars;
|
stars: typeof stars;
|
||||||
|
|||||||
+73
-9
@@ -1,4 +1,4 @@
|
|||||||
import { ApiRoutes } from 'clawdhub-schema'
|
import { ApiRoutes, LegacyApiRoutes } from 'clawdhub-schema'
|
||||||
import { httpRouter } from 'convex/server'
|
import { httpRouter } from 'convex/server'
|
||||||
import { auth } from './auth'
|
import { auth } from './auth'
|
||||||
import { downloadZip } from './downloads'
|
import { downloadZip } from './downloads'
|
||||||
@@ -13,6 +13,16 @@ import {
|
|||||||
resolveSkillVersionHttp,
|
resolveSkillVersionHttp,
|
||||||
searchSkillsHttp,
|
searchSkillsHttp,
|
||||||
} from './httpApi'
|
} from './httpApi'
|
||||||
|
import {
|
||||||
|
listSkillsV1Http,
|
||||||
|
publishSkillV1Http,
|
||||||
|
resolveSkillVersionV1Http,
|
||||||
|
searchSkillsV1Http,
|
||||||
|
skillsDeleteRouterV1Http,
|
||||||
|
skillsGetRouterV1Http,
|
||||||
|
skillsPostRouterV1Http,
|
||||||
|
whoamiV1Http,
|
||||||
|
} from './httpApiV1'
|
||||||
|
|
||||||
const http = httpRouter()
|
const http = httpRouter()
|
||||||
|
|
||||||
@@ -27,53 +37,107 @@ http.route({
|
|||||||
http.route({
|
http.route({
|
||||||
path: ApiRoutes.search,
|
path: ApiRoutes.search,
|
||||||
method: 'GET',
|
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,
|
handler: searchSkillsHttp,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.route({
|
http.route({
|
||||||
path: ApiRoutes.skill,
|
path: LegacyApiRoutes.skill,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
handler: getSkillHttp,
|
handler: getSkillHttp,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.route({
|
http.route({
|
||||||
path: ApiRoutes.skillResolve,
|
path: LegacyApiRoutes.skillResolve,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
handler: resolveSkillVersionHttp,
|
handler: resolveSkillVersionHttp,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.route({
|
http.route({
|
||||||
path: ApiRoutes.cliWhoami,
|
path: LegacyApiRoutes.cliWhoami,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
handler: cliWhoamiHttp,
|
handler: cliWhoamiHttp,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.route({
|
http.route({
|
||||||
path: ApiRoutes.cliUploadUrl,
|
path: LegacyApiRoutes.cliUploadUrl,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
handler: cliUploadUrlHttp,
|
handler: cliUploadUrlHttp,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.route({
|
http.route({
|
||||||
path: ApiRoutes.cliPublish,
|
path: LegacyApiRoutes.cliPublish,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
handler: cliPublishHttp,
|
handler: cliPublishHttp,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.route({
|
http.route({
|
||||||
path: ApiRoutes.cliTelemetrySync,
|
path: LegacyApiRoutes.cliTelemetrySync,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
handler: cliTelemetrySyncHttp,
|
handler: cliTelemetrySyncHttp,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.route({
|
http.route({
|
||||||
path: ApiRoutes.cliSkillDelete,
|
path: LegacyApiRoutes.cliSkillDelete,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
handler: cliSkillDeleteHttp,
|
handler: cliSkillDeleteHttp,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.route({
|
http.route({
|
||||||
path: ApiRoutes.cliSkillUndelete,
|
path: LegacyApiRoutes.cliSkillUndelete,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
handler: cliSkillUndeleteHttp,
|
handler: cliSkillUndeleteHttp,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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_user', ['userId'])
|
||||||
.index('by_hash', ['tokenHash'])
|
.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({
|
const userSyncRoots = defineTable({
|
||||||
userId: v.id('users'),
|
userId: v.id('users'),
|
||||||
rootId: v.string(),
|
rootId: v.string(),
|
||||||
@@ -212,6 +222,7 @@ export default defineSchema({
|
|||||||
stars,
|
stars,
|
||||||
auditLogs,
|
auditLogs,
|
||||||
apiTokens,
|
apiTokens,
|
||||||
|
rateLimits,
|
||||||
userSyncRoots,
|
userSyncRoots,
|
||||||
userSkillInstalls,
|
userSkillInstalls,
|
||||||
userSkillRootInstalls,
|
userSkillRootInstalls,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type ReadmeResult = { path: string; text: string }
|
|||||||
type FileTextResult = { path: string; text: string; size: number; sha256: string }
|
type FileTextResult = { path: string; text: string; size: number; sha256: string }
|
||||||
|
|
||||||
const MAX_DIFF_FILE_BYTES = 200 * 1024
|
const MAX_DIFF_FILE_BYTES = 200 * 1024
|
||||||
|
const MAX_LIST_LIMIT = 50
|
||||||
|
|
||||||
export const getBySlug = query({
|
export const getBySlug = query({
|
||||||
args: { slug: v.string() },
|
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({
|
export const listVersions = query({
|
||||||
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
|
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
|
||||||
handler: async (ctx, args) => {
|
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({
|
export const getVersionById = query({
|
||||||
args: { versionId: v.id('skillVersions') },
|
args: { versionId: v.id('skillVersions') },
|
||||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||||
@@ -656,6 +703,11 @@ function visibilityFor(isLatest: boolean, isApproved: boolean) {
|
|||||||
return 'archived'
|
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(
|
async function findCanonicalSkillForFingerprint(
|
||||||
ctx: { db: MutationCtx['db'] },
|
ctx: { db: MutationCtx['db'] },
|
||||||
fingerprint: string,
|
fingerprint: string,
|
||||||
|
|||||||
+50
@@ -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)
|
### 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.
|
- Embeddings currently generated during publish.
|
||||||
|
|
||||||
### Install (CLI)
|
### Install (CLI)
|
||||||
|
|
||||||
- Resolve latest version via `/api/skill?slug=...`.
|
- Resolve latest version via `/api/v1/skills/<slug>`.
|
||||||
- Download zip via `/api/download?slug=...&version=...`.
|
- Download zip via `/api/v1/download?slug=...&version=...`.
|
||||||
- Extract into `./skills/<slug>` (default).
|
- Extract into `./skills/<slug>` (default).
|
||||||
- Persist install state:
|
- Persist install state:
|
||||||
- `./.clawdhub/lock.json` (per workdir)
|
- `./.clawdhub/lock.json` (per workdir)
|
||||||
@@ -43,7 +43,7 @@ read_when:
|
|||||||
|
|
||||||
### Update (CLI)
|
### 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 matches a known version → use that for “current”.
|
||||||
- If local doesn’t match:
|
- If local doesn’t match:
|
||||||
- refuse by default
|
- refuse by default
|
||||||
@@ -51,8 +51,7 @@ read_when:
|
|||||||
|
|
||||||
### Publish (CLI)
|
### Publish (CLI)
|
||||||
|
|
||||||
- Upload each text file via `/api/cli/upload-url` (Convex upload URL).
|
- Publish via `POST /api/v1/skills` (multipart; requires Bearer token).
|
||||||
- Publish metadata via `/api/cli/publish` (requires Bearer token).
|
|
||||||
|
|
||||||
### Sync (CLI)
|
### Sync (CLI)
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -44,16 +44,16 @@ Stores your API token + cached registry URL.
|
|||||||
|
|
||||||
### `whoami`
|
### `whoami`
|
||||||
|
|
||||||
- Verifies the stored token via `/api/cli/whoami`.
|
- Verifies the stored token via `/api/v1/whoami`.
|
||||||
|
|
||||||
### `search <query...>`
|
### `search <query...>`
|
||||||
|
|
||||||
- Calls `/api/search?q=...`.
|
- Calls `/api/v1/search?q=...`.
|
||||||
|
|
||||||
### `install <slug>`
|
### `install <slug>`
|
||||||
|
|
||||||
- Resolves latest version via `/api/skill?slug=...`.
|
- Resolves latest version via `/api/v1/skills/<slug>`.
|
||||||
- Downloads zip via `/api/download`.
|
- Downloads zip via `/api/v1/download`.
|
||||||
- Extracts into `<workdir>/<dir>/<slug>`.
|
- Extracts into `<workdir>/<dir>/<slug>`.
|
||||||
- Writes:
|
- Writes:
|
||||||
- `<workdir>/.clawdhub/lock.json`
|
- `<workdir>/.clawdhub/lock.json`
|
||||||
@@ -73,8 +73,7 @@ Stores your API token + cached registry URL.
|
|||||||
|
|
||||||
### `publish <path>`
|
### `publish <path>`
|
||||||
|
|
||||||
- Uploads each file via `/api/cli/upload-url`.
|
- Publishes via `POST /api/v1/skills` (multipart).
|
||||||
- Publishes via `/api/cli/publish`.
|
|
||||||
- Requires semver: `--version 1.2.3`.
|
- Requires semver: `--version 1.2.3`.
|
||||||
|
|
||||||
### `sync`
|
### `sync`
|
||||||
|
|||||||
+2
-2
@@ -66,8 +66,8 @@ export CLAWDHUB_REGISTRY=https://your-site.example
|
|||||||
## 5) Post-deploy checks
|
## 5) Post-deploy checks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -i "https://<site>/api/search?q=test"
|
curl -i "https://<site>/api/v1/search?q=test"
|
||||||
curl -i "https://<site>/api/skill?slug=gifgrep"
|
curl -i "https://<site>/api/v1/skills/gifgrep"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|||||||
+78
-33
@@ -9,17 +9,30 @@ read_when:
|
|||||||
|
|
||||||
Base URL: `https://clawdhub.com` (default).
|
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)
|
## Public endpoints (no auth)
|
||||||
|
|
||||||
### `GET /api/search`
|
### `GET /api/v1/search`
|
||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
|
|
||||||
- `q` (required): query string
|
- `q` (required): query string
|
||||||
- `limit` (optional): integer
|
- `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:
|
Response:
|
||||||
|
|
||||||
@@ -27,19 +40,54 @@ Response:
|
|||||||
{ "results": [{ "score": 0.123, "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "version": "1.2.3", "updatedAt": 1730000000000 }] }
|
{ "results": [{ "score": 0.123, "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "version": "1.2.3", "updatedAt": 1730000000000 }] }
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/skill`
|
### `GET /api/v1/skills`
|
||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
|
|
||||||
- `slug` (required)
|
- `limit` (optional): integer
|
||||||
|
- `cursor` (optional): pagination cursor
|
||||||
|
|
||||||
Response (shape is stable; contents may expand):
|
Response:
|
||||||
|
|
||||||
```json
|
```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.
|
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" } }
|
{ "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.
|
Downloads a zip of a skill version.
|
||||||
|
|
||||||
@@ -69,45 +117,42 @@ Notes:
|
|||||||
- If neither `version` nor `tag` is provided, the latest version is used.
|
- If neither `version` nor `tag` is provided, the latest version is used.
|
||||||
- Soft-deleted versions return `410`.
|
- Soft-deleted versions return `410`.
|
||||||
|
|
||||||
## CLI endpoints (Bearer token)
|
## Auth endpoints (Bearer token)
|
||||||
|
|
||||||
All CLI endpoints require:
|
All endpoints require:
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer clh_...
|
Authorization: Bearer clh_...
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/cli/whoami`
|
### `GET /api/v1/whoami`
|
||||||
|
|
||||||
Validates token and returns the user handle.
|
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
|
### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete`
|
||||||
{ "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`
|
|
||||||
|
|
||||||
Soft-delete / restore a skill (owner/admin only).
|
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`)
|
## Registry discovery (`/.well-known/clawdhub.json`)
|
||||||
|
|
||||||
The CLI can discover registry/auth settings from the site:
|
The CLI can discover registry/auth settings from the site:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ read_when:
|
|||||||
## Delete / undelete (owner/admin)
|
## Delete / undelete (owner/admin)
|
||||||
- `bun clawdhub delete clawdhub-manual-<ts> --yes`
|
- `bun clawdhub delete clawdhub-manual-<ts> --yes`
|
||||||
- Verify hidden:
|
- 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:
|
- Restore:
|
||||||
- `bun clawdhub undelete clawdhub-manual-<ts> --yes`
|
- `bun clawdhub undelete clawdhub-manual-<ts> --yes`
|
||||||
- Cleanup:
|
- Cleanup:
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
ApiCliWhoamiResponseSchema,
|
|
||||||
ApiRoutes,
|
ApiRoutes,
|
||||||
ApiSearchResponseSchema,
|
ApiV1SearchResponseSchema,
|
||||||
|
ApiV1WhoamiResponseSchema,
|
||||||
parseArk,
|
parseArk,
|
||||||
} from 'clawdhub-schema'
|
} from 'clawdhub-schema'
|
||||||
import { unzipSync } from 'fflate'
|
import { unzipSync } from 'fflate'
|
||||||
@@ -50,7 +50,7 @@ describe('clawdhub e2e', () => {
|
|||||||
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } })
|
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } })
|
||||||
expect(response.ok).toBe(true)
|
expect(response.ok).toBe(true)
|
||||||
const json = (await response.json()) as unknown
|
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)
|
expect(Array.isArray(parsed.results)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,13 +102,13 @@ describe('clawdhub e2e', () => {
|
|||||||
|
|
||||||
const cfg = await makeTempConfig(registry, token)
|
const cfg = await makeTempConfig(registry, token)
|
||||||
try {
|
try {
|
||||||
const whoamiUrl = new URL(ApiRoutes.cliWhoami, registry)
|
const whoamiUrl = new URL(ApiRoutes.whoami, registry)
|
||||||
const whoamiRes = await fetch(whoamiUrl.toString(), {
|
const whoamiRes = await fetch(whoamiUrl.toString(), {
|
||||||
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
|
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
expect(whoamiRes.ok).toBe(true)
|
expect(whoamiRes.ok).toBe(true)
|
||||||
const whoami = parseArk(
|
const whoami = parseArk(
|
||||||
ApiCliWhoamiResponseSchema,
|
ApiV1WhoamiResponseSchema,
|
||||||
(await whoamiRes.json()) as unknown,
|
(await whoamiRes.json()) as unknown,
|
||||||
'Whoami',
|
'Whoami',
|
||||||
)
|
)
|
||||||
@@ -319,9 +319,9 @@ describe('clawdhub e2e', () => {
|
|||||||
)
|
)
|
||||||
expect(update.status).toBe(0)
|
expect(update.status).toBe(0)
|
||||||
|
|
||||||
const metaUrl = new URL(ApiRoutes.skill, registry)
|
const metaRes = await fetch(`${registry}${ApiRoutes.skills}/${slug}`, {
|
||||||
metaUrl.searchParams.set('slug', slug)
|
headers: { Accept: 'application/json' },
|
||||||
const metaRes = await fetch(metaUrl.toString(), { headers: { Accept: 'application/json' } })
|
})
|
||||||
expect(metaRes.status).toBe(200)
|
expect(metaRes.status).toBe(200)
|
||||||
|
|
||||||
const del = spawnSync(
|
const del = spawnSync(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { buildCliAuthUrl, startLoopbackAuthServer } from '../../browserAuth.js'
|
|||||||
import { readGlobalConfig, writeGlobalConfig } from '../../config.js'
|
import { readGlobalConfig, writeGlobalConfig } from '../../config.js'
|
||||||
import { discoverRegistryFromSite } from '../../discovery.js'
|
import { discoverRegistryFromSite } from '../../discovery.js'
|
||||||
import { apiRequest } from '../../http.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 { getRegistry } from '../registry.js'
|
||||||
import type { GlobalOpts } from '../types.js'
|
import type { GlobalOpts } from '../types.js'
|
||||||
import { createSpinner, fail, formatError, openInBrowser, promptHidden } from '../ui.js'
|
import { createSpinner, fail, formatError, openInBrowser, promptHidden } from '../ui.js'
|
||||||
@@ -55,8 +55,8 @@ export async function cmdLogin(
|
|||||||
try {
|
try {
|
||||||
const whoami = await apiRequest(
|
const whoami = await apiRequest(
|
||||||
registry,
|
registry,
|
||||||
{ method: 'GET', path: ApiRoutes.cliWhoami, token },
|
{ method: 'GET', path: ApiRoutes.whoami, token },
|
||||||
ApiCliWhoamiResponseSchema,
|
ApiV1WhoamiResponseSchema,
|
||||||
)
|
)
|
||||||
if (!whoami.user) fail('Login failed')
|
if (!whoami.user) fail('Login failed')
|
||||||
|
|
||||||
@@ -86,8 +86,8 @@ export async function cmdWhoami(opts: GlobalOpts) {
|
|||||||
try {
|
try {
|
||||||
const whoami = await apiRequest(
|
const whoami = await apiRequest(
|
||||||
registry,
|
registry,
|
||||||
{ method: 'GET', path: ApiRoutes.cliWhoami, token },
|
{ method: 'GET', path: ApiRoutes.whoami, token },
|
||||||
ApiCliWhoamiResponseSchema,
|
ApiV1WhoamiResponseSchema,
|
||||||
)
|
)
|
||||||
spinner.succeed(whoami.user.handle ?? 'unknown')
|
spinner.succeed(whoami.user.handle ?? 'unknown')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe('delete/undelete', () => {
|
|||||||
await cmdDeleteSkill(makeOpts(), 'demo', { yes: true }, false)
|
await cmdDeleteSkill(makeOpts(), 'demo', { yes: true }, false)
|
||||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({ method: 'POST', path: '/api/cli/skill/delete' }),
|
expect.objectContaining({ method: 'DELETE', path: '/api/v1/skills/demo' }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -66,7 +66,7 @@ describe('delete/undelete', () => {
|
|||||||
await cmdUndeleteSkill(makeOpts(), 'demo', { yes: true }, false)
|
await cmdUndeleteSkill(makeOpts(), 'demo', { yes: true }, false)
|
||||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({ method: 'POST', path: '/api/cli/skill/undelete' }),
|
expect.objectContaining({ method: 'POST', path: '/api/v1/skills/demo/undelete' }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readGlobalConfig } from '../../config.js'
|
import { readGlobalConfig } from '../../config.js'
|
||||||
import { apiRequest } from '../../http.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 { getRegistry } from '../registry.js'
|
||||||
import type { GlobalOpts } from '../types.js'
|
import type { GlobalOpts } from '../types.js'
|
||||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.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 registry = await getRegistry(opts, { cache: true })
|
||||||
const spinner = createSpinner(`Deleting ${slug}`)
|
const spinner = createSpinner(`Deleting ${slug}`)
|
||||||
try {
|
try {
|
||||||
const body = { slug }
|
|
||||||
const result = await apiRequest(
|
const result = await apiRequest(
|
||||||
registry,
|
registry,
|
||||||
{ method: 'POST', path: ApiRoutes.cliSkillDelete, token, body },
|
{ method: 'DELETE', path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}`, token },
|
||||||
ApiCliSkillDeleteResponseSchema,
|
ApiV1DeleteResponseSchema,
|
||||||
)
|
)
|
||||||
spinner.succeed(`OK. Deleted ${slug}`)
|
spinner.succeed(`OK. Deleted ${slug}`)
|
||||||
return parseArk(ApiCliSkillDeleteResponseSchema, result, 'Delete response')
|
return parseArk(ApiV1DeleteResponseSchema, result, 'Delete response')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
spinner.fail(formatError(error))
|
spinner.fail(formatError(error))
|
||||||
throw error
|
throw error
|
||||||
@@ -66,14 +65,17 @@ export async function cmdUndeleteSkill(
|
|||||||
const registry = await getRegistry(opts, { cache: true })
|
const registry = await getRegistry(opts, { cache: true })
|
||||||
const spinner = createSpinner(`Undeleting ${slug}`)
|
const spinner = createSpinner(`Undeleting ${slug}`)
|
||||||
try {
|
try {
|
||||||
const body = { slug }
|
|
||||||
const result = await apiRequest(
|
const result = await apiRequest(
|
||||||
registry,
|
registry,
|
||||||
{ method: 'POST', path: ApiRoutes.cliSkillUndelete, token, body },
|
{
|
||||||
ApiCliSkillDeleteResponseSchema,
|
method: 'POST',
|
||||||
|
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/undelete`,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
ApiV1DeleteResponseSchema,
|
||||||
)
|
)
|
||||||
spinner.succeed(`OK. Undeleted ${slug}`)
|
spinner.succeed(`OK. Undeleted ${slug}`)
|
||||||
return parseArk(ApiCliSkillDeleteResponseSchema, result, 'Undelete response')
|
return parseArk(ApiV1DeleteResponseSchema, result, 'Undelete response')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
spinner.fail(formatError(error))
|
spinner.fail(formatError(error))
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { sha256Hex } from '../../skills'
|
|
||||||
import type { GlobalOpts } from '../types'
|
import type { GlobalOpts } from '../types'
|
||||||
|
|
||||||
vi.mock('../../config.js', () => ({
|
vi.mock('../../config.js', () => ({
|
||||||
@@ -16,10 +15,10 @@ vi.mock('../registry.js', () => ({
|
|||||||
getRegistry: (opts: unknown, params?: unknown) => mockGetRegistry(opts, params),
|
getRegistry: (opts: unknown, params?: unknown) => mockGetRegistry(opts, params),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockApiRequest = vi.fn()
|
const mockApiRequestForm = vi.fn()
|
||||||
vi.mock('../../http.js', () => ({
|
vi.mock('../../http.js', () => ({
|
||||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
apiRequestForm: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||||
mockApiRequest(registry, args, schema),
|
mockApiRequestForm(registry, args, schema),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockFail = vi.fn((message: string) => {
|
const mockFail = vi.fn((message: string) => {
|
||||||
@@ -65,37 +64,7 @@ describe('cmdPublish', () => {
|
|||||||
await writeFile(join(folder, 'SKILL.md'), skillContent, 'utf8')
|
await writeFile(join(folder, 'SKILL.md'), skillContent, 'utf8')
|
||||||
await writeFile(join(folder, 'notes.md'), notesContent, 'utf8')
|
await writeFile(join(folder, 'notes.md'), notesContent, 'utf8')
|
||||||
|
|
||||||
let uploadIndex = 0
|
mockApiRequestForm.mockResolvedValueOnce({ ok: true, skillId: 'skill_1', versionId: 'ver_1' })
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
await cmdPublish(makeOpts(workdir), 'my-skill', {
|
await cmdPublish(makeOpts(workdir), 'my-skill', {
|
||||||
slug: 'my-skill',
|
slug: 'my-skill',
|
||||||
@@ -105,30 +74,20 @@ describe('cmdPublish', () => {
|
|||||||
tags: 'latest',
|
tags: 'latest',
|
||||||
})
|
})
|
||||||
|
|
||||||
const publishCall = mockApiRequest.mock.calls.find((call) => {
|
const publishCall = mockApiRequestForm.mock.calls.find((call) => {
|
||||||
const req = call[1] as { path?: string } | undefined
|
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')
|
if (!publishCall) throw new Error('Missing publish call')
|
||||||
const publishBody = (publishCall[1] as { body?: unknown }).body as {
|
const publishForm = (publishCall[1] as { form?: FormData }).form as FormData
|
||||||
slug: string
|
const payload = JSON.parse(String(publishForm.get('payload')))
|
||||||
displayName: string
|
expect(payload.slug).toBe('my-skill')
|
||||||
version: string
|
expect(payload.displayName).toBe('My Skill')
|
||||||
changelog: string
|
expect(payload.version).toBe('1.0.0')
|
||||||
tags: string[]
|
expect(payload.changelog).toBe('')
|
||||||
files: Array<{ path: string; sha256: string; storageId: string }>
|
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'])
|
||||||
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)))
|
|
||||||
} finally {
|
} finally {
|
||||||
await rm(workdir, { recursive: true, force: true })
|
await rm(workdir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@@ -141,28 +100,7 @@ describe('cmdPublish', () => {
|
|||||||
await mkdir(folder, { recursive: true })
|
await mkdir(folder, { recursive: true })
|
||||||
await writeFile(join(folder, 'SKILL.md'), '# Skill\n', 'utf8')
|
await writeFile(join(folder, 'SKILL.md'), '# Skill\n', 'utf8')
|
||||||
|
|
||||||
let uploadIndex = 0
|
mockApiRequestForm.mockResolvedValueOnce({ ok: true, skillId: 'skill_1', versionId: 'ver_2' })
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
await cmdPublish(makeOpts(workdir), 'existing-skill', {
|
await cmdPublish(makeOpts(workdir), 'existing-skill', {
|
||||||
version: '1.0.1',
|
version: '1.0.1',
|
||||||
@@ -170,9 +108,9 @@ describe('cmdPublish', () => {
|
|||||||
tags: 'latest',
|
tags: 'latest',
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
expect(mockApiRequestForm).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({ path: '/api/cli/publish', method: 'POST' }),
|
expect.objectContaining({ path: '/api/v1/skills', method: 'POST' }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,16 +2,9 @@ import { stat } from 'node:fs/promises'
|
|||||||
import { basename, resolve } from 'node:path'
|
import { basename, resolve } from 'node:path'
|
||||||
import semver from 'semver'
|
import semver from 'semver'
|
||||||
import { readGlobalConfig } from '../../config.js'
|
import { readGlobalConfig } from '../../config.js'
|
||||||
import { apiRequest } from '../../http.js'
|
import { apiRequestForm } from '../../http.js'
|
||||||
import {
|
import { ApiRoutes, ApiV1PublishResponseSchema } from '../../schema/index.js'
|
||||||
ApiCliPublishResponseSchema,
|
import { listTextFiles } from '../../skills.js'
|
||||||
ApiCliUploadUrlResponseSchema,
|
|
||||||
ApiRoutes,
|
|
||||||
ApiUploadFileResponseSchema,
|
|
||||||
CliPublishRequestSchema,
|
|
||||||
parseArk,
|
|
||||||
} from '../../schema/index.js'
|
|
||||||
import { listTextFiles, sha256Hex } from '../../skills.js'
|
|
||||||
import { getRegistry } from '../registry.js'
|
import { getRegistry } from '../registry.js'
|
||||||
import { sanitizeSlug, titleCase } from '../slug.js'
|
import { sanitizeSlug, titleCase } from '../slug.js'
|
||||||
import type { GlobalOpts } from '../types.js'
|
import type { GlobalOpts } from '../types.js'
|
||||||
@@ -69,50 +62,32 @@ export async function cmdPublish(
|
|||||||
fail('SKILL.md required')
|
fail('SKILL.md required')
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploaded: Array<{
|
const form = new FormData()
|
||||||
path: string
|
form.set(
|
||||||
size: number
|
'payload',
|
||||||
storageId: string
|
JSON.stringify({
|
||||||
sha256: string
|
slug,
|
||||||
contentType?: string
|
displayName,
|
||||||
}> = []
|
version,
|
||||||
|
changelog,
|
||||||
|
tags,
|
||||||
|
...(forkOf ? { forkOf } : {}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
let index = 0
|
let index = 0
|
||||||
for (const file of filesOnDisk) {
|
for (const file of filesOnDisk) {
|
||||||
index += 1
|
index += 1
|
||||||
spinner.text = `Uploading ${file.relPath} (${index}/${filesOnDisk.length})`
|
spinner.text = `Uploading ${file.relPath} (${index}/${filesOnDisk.length})`
|
||||||
const { uploadUrl } = await apiRequest(
|
const blob = new Blob([Buffer.from(file.bytes)], { type: file.contentType ?? 'text/plain' })
|
||||||
registry,
|
form.append('files', blob, file.relPath)
|
||||||
{ 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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
spinner.text = `Publishing ${slug}@${version}`
|
spinner.text = `Publishing ${slug}@${version}`
|
||||||
const publishPayload = {
|
const result = await apiRequestForm(
|
||||||
slug,
|
|
||||||
displayName,
|
|
||||||
version,
|
|
||||||
changelog,
|
|
||||||
tags,
|
|
||||||
files: uploaded,
|
|
||||||
...(forkOf ? { forkOf } : {}),
|
|
||||||
}
|
|
||||||
const body = parseArk(CliPublishRequestSchema, publishPayload, 'Publish payload')
|
|
||||||
const result = await apiRequest(
|
|
||||||
registry,
|
registry,
|
||||||
{ method: 'POST', path: ApiRoutes.cliPublish, token, body },
|
{ method: 'POST', path: ApiRoutes.skills, token, form },
|
||||||
ApiCliPublishResponseSchema,
|
ApiV1PublishResponseSchema,
|
||||||
)
|
)
|
||||||
|
|
||||||
spinner.succeed(`OK. Published ${slug}@${version} (${result.versionId})`)
|
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')
|
if (version && !semver.valid(version)) fail('--fork-of version must be valid semver')
|
||||||
return { slug, version: version || undefined }
|
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 { apiRequest, downloadZip } from '../../http.js'
|
||||||
import {
|
import {
|
||||||
ApiRoutes,
|
ApiRoutes,
|
||||||
ApiSearchResponseSchema,
|
ApiV1SearchResponseSchema,
|
||||||
ApiSkillMetaResponseSchema,
|
ApiV1SkillResolveResponseSchema,
|
||||||
ApiSkillResolveResponseSchema,
|
ApiV1SkillResponseSchema,
|
||||||
} from '../../schema/index.js'
|
} from '../../schema/index.js'
|
||||||
import {
|
import {
|
||||||
extractZipToDir,
|
extractZipToDir,
|
||||||
@@ -35,7 +35,7 @@ export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number)
|
|||||||
const result = await apiRequest(
|
const result = await apiRequest(
|
||||||
registry,
|
registry,
|
||||||
{ method: 'GET', url: url.toString() },
|
{ method: 'GET', url: url.toString() },
|
||||||
ApiSearchResponseSchema,
|
ApiV1SearchResponseSchema,
|
||||||
)
|
)
|
||||||
|
|
||||||
spinner.stop()
|
spinner.stop()
|
||||||
@@ -77,8 +77,8 @@ export async function cmdInstall(
|
|||||||
(
|
(
|
||||||
await apiRequest(
|
await apiRequest(
|
||||||
registry,
|
registry,
|
||||||
{ method: 'GET', path: `/api/skill?slug=${encodeURIComponent(trimmed)}` },
|
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
|
||||||
ApiSkillMetaResponseSchema,
|
ApiV1SkillResponseSchema,
|
||||||
)
|
)
|
||||||
).latestVersion?.version ??
|
).latestVersion?.version ??
|
||||||
null
|
null
|
||||||
@@ -150,12 +150,10 @@ export async function cmdUpdate(
|
|||||||
if (localFingerprint) {
|
if (localFingerprint) {
|
||||||
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint)
|
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint)
|
||||||
} else {
|
} else {
|
||||||
const url = new URL(ApiRoutes.skill, registry)
|
|
||||||
url.searchParams.set('slug', entry)
|
|
||||||
const meta = await apiRequest(
|
const meta = await apiRequest(
|
||||||
registry,
|
registry,
|
||||||
{ method: 'GET', url: url.toString() },
|
{ method: 'GET', url: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
|
||||||
ApiSkillMetaResponseSchema,
|
ApiV1SkillResponseSchema,
|
||||||
)
|
)
|
||||||
resolveResult = { match: null, latestVersion: meta.latestVersion ?? null }
|
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) {
|
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('slug', slug)
|
||||||
url.searchParams.set('hash', hash)
|
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) {
|
async function fileExists(path: string) {
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ describe('cmdSync', () => {
|
|||||||
it('classifies skills as new/update/synced (dry-run, mocked HTTP)', async () => {
|
it('classifies skills as new/update/synced (dry-run, mocked HTTP)', async () => {
|
||||||
interactive = false
|
interactive = false
|
||||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
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 === '/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 u = new URL(`https://x.test${args.path}`)
|
||||||
const slug = u.searchParams.get('slug')
|
const slug = u.searchParams.get('slug')
|
||||||
if (slug === 'new-skill') {
|
if (slug === 'new-skill') {
|
||||||
@@ -135,9 +135,9 @@ describe('cmdSync', () => {
|
|||||||
return initialValues
|
return initialValues
|
||||||
})
|
})
|
||||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
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 === '/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 u = new URL(`https://x.test${args.path}`)
|
||||||
const slug = u.searchParams.get('slug')
|
const slug = u.searchParams.get('slug')
|
||||||
if (slug === 'new-skill') {
|
if (slug === 'new-skill') {
|
||||||
@@ -171,9 +171,9 @@ describe('cmdSync', () => {
|
|||||||
it('shows condensed synced list when nothing to sync', async () => {
|
it('shows condensed synced list when nothing to sync', async () => {
|
||||||
interactive = false
|
interactive = false
|
||||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
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 === '/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' } }
|
return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } }
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
||||||
@@ -203,9 +203,9 @@ describe('cmdSync', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
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 === '/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 }
|
return { match: null, latestVersion: null }
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
||||||
@@ -222,9 +222,9 @@ describe('cmdSync', () => {
|
|||||||
it('allows empty changelog for updates (interactive)', async () => {
|
it('allows empty changelog for updates (interactive)', async () => {
|
||||||
interactive = true
|
interactive = true
|
||||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
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 === '/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 u = new URL(`https://x.test${args.path}`)
|
||||||
const slug = u.searchParams.get('slug')
|
const slug = u.searchParams.get('slug')
|
||||||
if (slug === 'new-skill') {
|
if (slug === 'new-skill') {
|
||||||
@@ -254,8 +254,8 @@ describe('cmdSync', () => {
|
|||||||
interactive = false
|
interactive = false
|
||||||
process.env.CLAWDHUB_DISABLE_TELEMETRY = '1'
|
process.env.CLAWDHUB_DISABLE_TELEMETRY = '1'
|
||||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
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.startsWith('/api/skill/resolve?')) {
|
if (args.path.startsWith('/api/v1/resolve?')) {
|
||||||
return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } }
|
return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } }
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import semver from 'semver'
|
|||||||
import { apiRequest, downloadZip } from '../../http.js'
|
import { apiRequest, downloadZip } from '../../http.js'
|
||||||
import {
|
import {
|
||||||
ApiCliTelemetrySyncResponseSchema,
|
ApiCliTelemetrySyncResponseSchema,
|
||||||
ApiCliWhoamiResponseSchema,
|
|
||||||
ApiRoutes,
|
ApiRoutes,
|
||||||
ApiSkillMetaResponseSchema,
|
ApiV1SkillResolveResponseSchema,
|
||||||
ApiSkillResolveResponseSchema,
|
ApiV1SkillResponseSchema,
|
||||||
|
ApiV1WhoamiResponseSchema,
|
||||||
|
LegacyApiRoutes,
|
||||||
} from '../../schema/index.js'
|
} from '../../schema/index.js'
|
||||||
import { hashSkillZip } from '../../skills.js'
|
import { hashSkillZip } from '../../skills.js'
|
||||||
import { getRegistry } from '../registry.js'
|
import { getRegistry } from '../registry.js'
|
||||||
@@ -45,7 +46,7 @@ export async function reportTelemetryIfEnabled(params: {
|
|||||||
params.registry,
|
params.registry,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: ApiRoutes.cliTelemetrySync,
|
path: LegacyApiRoutes.cliTelemetrySync,
|
||||||
token: params.token,
|
token: params.token,
|
||||||
body: { roots },
|
body: { roots },
|
||||||
},
|
},
|
||||||
@@ -106,9 +107,9 @@ export async function checkRegistrySyncState(
|
|||||||
registry,
|
registry,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
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
|
resolveSupport.value = true
|
||||||
const latestVersion = resolved.latestVersion?.version ?? null
|
const latestVersion = resolved.latestVersion?.version ?? null
|
||||||
@@ -148,8 +149,8 @@ export async function checkRegistrySyncState(
|
|||||||
|
|
||||||
const meta = await apiRequest(
|
const meta = await apiRequest(
|
||||||
registry,
|
registry,
|
||||||
{ method: 'GET', path: `${ApiRoutes.skill}?slug=${encodeURIComponent(skill.slug)}` },
|
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}` },
|
||||||
ApiSkillMetaResponseSchema,
|
ApiV1SkillResponseSchema,
|
||||||
).catch(() => null)
|
).catch(() => null)
|
||||||
|
|
||||||
const latestVersion = meta?.latestVersion?.version ?? 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 })
|
const registry = await getRegistry(opts, { cache: true })
|
||||||
await apiRequest(
|
await apiRequest(
|
||||||
registry,
|
registry,
|
||||||
{ method: 'GET', path: ApiRoutes.cliWhoami, token },
|
{ method: 'GET', path: ApiRoutes.whoami, token },
|
||||||
ApiCliWhoamiResponseSchema,
|
ApiV1WhoamiResponseSchema,
|
||||||
)
|
)
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { apiRequest, downloadZip } from './http'
|
import { apiRequest, downloadZip } from './http'
|
||||||
import { ApiCliWhoamiResponseSchema } from './schema/index.js'
|
import { ApiV1WhoamiResponseSchema } from './schema/index.js'
|
||||||
|
|
||||||
describe('apiRequest', () => {
|
describe('apiRequest', () => {
|
||||||
it('adds bearer token and parses json', async () => {
|
it('adds bearer token and parses json', async () => {
|
||||||
@@ -14,7 +14,7 @@ describe('apiRequest', () => {
|
|||||||
const result = await apiRequest(
|
const result = await apiRequest(
|
||||||
'https://example.com',
|
'https://example.com',
|
||||||
{ method: 'GET', path: '/x', token: 'clh_token' },
|
{ method: 'GET', path: '/x', token: 'clh_token' },
|
||||||
ApiCliWhoamiResponseSchema,
|
ApiV1WhoamiResponseSchema,
|
||||||
)
|
)
|
||||||
expect(result.user.handle).toBeNull()
|
expect(result.user.handle).toBeNull()
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { ArkValidator } from './schema/index.js'
|
|||||||
import { ApiRoutes, parseArk } from './schema/index.js'
|
import { ApiRoutes, parseArk } from './schema/index.js'
|
||||||
|
|
||||||
type RequestArgs =
|
type RequestArgs =
|
||||||
| { method: 'GET' | 'POST'; path: string; token?: string; body?: unknown }
|
| { method: 'GET' | 'POST' | 'DELETE'; path: string; token?: string; body?: unknown }
|
||||||
| { method: 'GET' | 'POST'; url: 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>(registry: string, args: RequestArgs): Promise<T>
|
||||||
export async function apiRequest<T>(
|
export async function apiRequest<T>(
|
||||||
@@ -44,6 +44,43 @@ export async function apiRequest<T>(
|
|||||||
return json as 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 }) {
|
export async function downloadZip(registry: string, args: { slug: string; version?: string }) {
|
||||||
const url = new URL(ApiRoutes.download, registry)
|
const url = new URL(ApiRoutes.download, registry)
|
||||||
url.searchParams.set('slug', args.slug)
|
url.searchParams.set('slug', args.slug)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type { ArkValidator } from './ark.js'
|
export type { ArkValidator } from './ark.js'
|
||||||
export { formatArkErrors, parseArk } 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 './schemas.js'
|
||||||
export * from './textFiles.js'
|
export * from './textFiles.js'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const ApiRoutes = {
|
export const LegacyApiRoutes = {
|
||||||
download: '/api/download',
|
download: '/api/download',
|
||||||
search: '/api/search',
|
search: '/api/search',
|
||||||
skill: '/api/skill',
|
skill: '/api/skill',
|
||||||
@@ -10,3 +10,11 @@ export const ApiRoutes = {
|
|||||||
cliSkillDelete: '/api/cli/skill/delete',
|
cliSkillDelete: '/api/cli/skill/delete',
|
||||||
cliSkillUndelete: '/api/cli/skill/undelete',
|
cliSkillUndelete: '/api/cli/skill/undelete',
|
||||||
} as const
|
} 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',
|
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({
|
export const SkillInstallSpecSchema = type({
|
||||||
id: 'string?',
|
id: 'string?',
|
||||||
kind: '"brew"|"node"|"go"|"uv"',
|
kind: '"brew"|"node"|"go"|"uv"',
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
export type { ArkValidator } from './ark.js';
|
export type { ArkValidator } from './ark.js';
|
||||||
export { formatArkErrors, parseArk } 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 './schemas.js';
|
||||||
export * from './textFiles.js';
|
export * from './textFiles.js';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
export { formatArkErrors, parseArk } 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 './schemas.js';
|
||||||
export * from './textFiles.js';
|
export * from './textFiles.js';
|
||||||
//# sourceMappingURL=index.js.map
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
-1
@@ -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"}
|
||||||
Vendored
+8
-1
@@ -1,4 +1,4 @@
|
|||||||
export declare const ApiRoutes: {
|
export declare const LegacyApiRoutes: {
|
||||||
readonly download: "/api/download";
|
readonly download: "/api/download";
|
||||||
readonly search: "/api/search";
|
readonly search: "/api/search";
|
||||||
readonly skill: "/api/skill";
|
readonly skill: "/api/skill";
|
||||||
@@ -10,3 +10,10 @@ export declare const ApiRoutes: {
|
|||||||
readonly cliSkillDelete: "/api/cli/skill/delete";
|
readonly cliSkillDelete: "/api/cli/skill/delete";
|
||||||
readonly cliSkillUndelete: "/api/cli/skill/undelete";
|
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";
|
||||||
|
};
|
||||||
|
|||||||
Vendored
+8
-1
@@ -1,4 +1,4 @@
|
|||||||
export const ApiRoutes = {
|
export const LegacyApiRoutes = {
|
||||||
download: '/api/download',
|
download: '/api/download',
|
||||||
search: '/api/search',
|
search: '/api/search',
|
||||||
skill: '/api/skill',
|
skill: '/api/skill',
|
||||||
@@ -10,4 +10,11 @@ export const ApiRoutes = {
|
|||||||
cliSkillDelete: '/api/cli/skill/delete',
|
cliSkillDelete: '/api/cli/skill/delete',
|
||||||
cliSkillUndelete: '/api/cli/skill/undelete',
|
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
|
//# sourceMappingURL=routes.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;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"}
|
||||||
Vendored
+93
@@ -110,6 +110,99 @@ export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inf
|
|||||||
export declare const ApiCliTelemetrySyncResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
export declare const ApiCliTelemetrySyncResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||||
ok: true;
|
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<{
|
export declare const SkillInstallSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||||
kind: "brew" | "node" | "go" | "uv";
|
kind: "brew" | "node" | "go" | "uv";
|
||||||
id?: string | undefined;
|
id?: string | undefined;
|
||||||
|
|||||||
Vendored
+89
@@ -93,6 +93,95 @@ export const CliTelemetrySyncRequestSchema = type({
|
|||||||
export const ApiCliTelemetrySyncResponseSchema = type({
|
export const ApiCliTelemetrySyncResponseSchema = type({
|
||||||
ok: 'true',
|
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({
|
export const SkillInstallSpecSchema = type({
|
||||||
id: 'string?',
|
id: 'string?',
|
||||||
kind: '"brew"|"node"|"go"|"uv"',
|
kind: '"brew"|"node"|"go"|"uv"',
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
export type { ArkValidator } from './ark.js'
|
export type { ArkValidator } from './ark.js'
|
||||||
export { formatArkErrors, parseArk } 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 './schemas.js'
|
||||||
export * from './textFiles.js'
|
export * from './textFiles.js'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const ApiRoutes = {
|
export const LegacyApiRoutes = {
|
||||||
download: '/api/download',
|
download: '/api/download',
|
||||||
search: '/api/search',
|
search: '/api/search',
|
||||||
skill: '/api/skill',
|
skill: '/api/skill',
|
||||||
@@ -10,3 +10,11 @@ export const ApiRoutes = {
|
|||||||
cliSkillDelete: '/api/cli/skill/delete',
|
cliSkillDelete: '/api/cli/skill/delete',
|
||||||
cliSkillUndelete: '/api/cli/skill/undelete',
|
cliSkillUndelete: '/api/cli/skill/undelete',
|
||||||
} as const
|
} 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',
|
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({
|
export const SkillInstallSpecSchema = type({
|
||||||
id: 'string?',
|
id: 'string?',
|
||||||
kind: '"brew"|"node"|"go"|"uv"',
|
kind: '"brew"|"node"|"go"|"uv"',
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
<a
|
<a
|
||||||
className="btn btn-primary"
|
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
|
Download zip
|
||||||
</a>
|
</a>
|
||||||
@@ -445,7 +445,7 @@ export function SkillDetailPage({
|
|||||||
<div className="version-actions">
|
<div className="version-actions">
|
||||||
<a
|
<a
|
||||||
className="btn version-zip"
|
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
|
Zip
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+3
-4
@@ -27,13 +27,12 @@ export function getApiBase() {
|
|||||||
export async function fetchSkillMeta(slug: string) {
|
export async function fetchSkillMeta(slug: string) {
|
||||||
try {
|
try {
|
||||||
const apiBase = getApiBase()
|
const apiBase = getApiBase()
|
||||||
const url = new URL('/api/skill', apiBase)
|
const url = new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, apiBase)
|
||||||
url.searchParams.set('slug', slug)
|
|
||||||
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } })
|
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } })
|
||||||
if (!response.ok) return null
|
if (!response.ok) return null
|
||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
skill?: { displayName?: string; summary?: string | null }
|
skill?: { displayName?: string; summary?: string | null } | null
|
||||||
owner?: { handle?: string | null }
|
owner?: { handle?: string | null } | null
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
displayName: payload.skill?.displayName ?? null,
|
displayName: payload.skill?.displayName ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user