Repository Public MIT
1import type { ArtifactType, Capability, Client, ExampleClient, Finding, InstallInstructions, Manifest, Scope } from "@skills/registry-core";23/** Typed client for the Skills registry REST API (/api/v1). Works in Node 20+ and browsers. */45export const DEFAULT_REGISTRY = "https://skills.spboucher.ai";67export class RegistryError extends Error {8 constructor(9 public status: number,10 public code: string,11 message: string,12 public details?: unknown,13 public requestId?: string,14 ) {15 super(message);16 this.name = "RegistryError";17 }18}1920export interface Artifact {21 id: string;22 name: string;23 namespace: string;24 slug: string;25 type: ArtifactType;26 summary: string;27 license: string | null;28 repository: string | null;29 homepage: string | null;30 issues: string | null;31 visibility: "public" | "unlisted" | "private";32 state: "draft" | "active" | "deprecated" | "archived" | "taken_down";33 deprecationReason: string | null;34 capabilities: Capability[];35 keywords: string[];36 latestVersion: string | null;37 latestPublishedAt: string | null;38 latestSecurityStatus: string | null;39 publisher: { namespace: string; displayName: string | null; identityVerified: boolean };40 sourceVerified: boolean;41 downloads30d: number;42 createdAt: string;43 updatedAt: string;44 url: string;45}46/** A screenshot of the package in use, supplied by the publisher and served from the release archive. */47export interface ReleaseExample {48 title: string;49 client: ExampleClient;50 image: string;51 alt: string;52 prompt: string | null;53 caption: string | null;54 capturedAt: string | null;55 url: string;56 /** Present on release detail only. */57 width?: number | null;58 height?: number | null;59 sizeBytes?: number | null;60 mime?: string | null;61}62export interface Release {63 id: string;64 version: string;65 state: "published" | "quarantined" | "revoked";66 securityStatus: "passed" | "flagged" | "failed" | "not_scanned";67 sha256: string;68 sizeBytes: number;69 unpackedBytes: number;70 fileCount: number;71 sourceCommit: string | null;72 releaseNotes: string | null;73 publishedAt: string;74 publishedBy: string | null;75 immutable: true;76 revokedAt: string | null;77 revocationReason: string | null;78 downloadUrl: string;79 examples: ReleaseExample[];80}81export interface ReleaseFile {82 path: string;83 sizeBytes: number;84 sha256: string;85 mime: string;86 executable: boolean;87 isText: boolean;88 previewable: boolean;89}90export interface ReleaseDetail extends Release {91 manifest: Manifest;92 readme: string | null;93 files: ReleaseFile[];94 dependencies: { name: string; version: string; optional: boolean }[];95 compatibility: { client: Client; platform: string; minVersion: string | null; installStrategy: string; verified: boolean }[];96 scan: { scannerVersion: string; completedAt: string; maxSeverity: string | null; findings: Finding[]; disclaimer: string } | null;97}98export interface ArtifactDetail extends Artifact {99 description: string | null;100 latestRelease: Release | null;101}102export interface Page<T> {103 items: T[];104 page: number;105 perPage: number;106 total: number;107 totalPages: number;108}109export interface Upload {110 uploadId: string;111 state: string;112 version: string;113 sha256: string;114 sizeBytes: number;115 expiresAt: string;116 uploadUrl: string;117 finalizeUrl: string;118 releaseId: string | null;119 findings?: Finding[];120}121export interface TokenInfo {122 id: string;123 name: string;124 prefix: string;125 scopes: Scope[];126 namespaces: string[];127 ipAllowlist: string[];128 elevated: boolean;129 expiresAt: string;130 lastUsedAt: string | null;131 revokedAt: string | null;132 rotatedFrom: string | null;133 createdAt: string;134 status: "active" | "revoked" | "expired";135}136export interface Me {137 user: { id: string; displayName: string; email: string; platformRole: string; emailVerified: boolean };138 credential: { kind: string; id?: string; name?: string; prefix?: string; scopes?: Scope[]; namespaces?: string[]; elevated?: boolean; expiresAt?: string };139 namespaces: string[];140}141export interface ListParams {142 q?: string;143 type?: string;144 keyword?: string;145 compatibility?: string;146 license?: string;147 namespace?: string;148 sort?: string;149 page?: number;150 perPage?: number;151 includePrivate?: boolean;152}153154export interface ClientOptions {155 baseUrl?: string;156 /** Bearer token. Never log it; never put it in a URL. */157 token?: string;158 fetch?: typeof fetch;159 userAgent?: string;160}161162export class RegistryClient {163 readonly baseUrl: string;164 private token?: string;165 private f: typeof fetch;166 private ua: string;167168 constructor(opts: ClientOptions = {}) {169 this.baseUrl = (opts.baseUrl ?? DEFAULT_REGISTRY).replace(/\/+$/, "");170 this.token = opts.token;171 this.f = opts.fetch ?? fetch;172 this.ua = opts.userAgent ?? "skills-sdk/0.1";173 }174175 private headers(extra: Record<string, string> = {}): Record<string, string> {176 return { Accept: "application/json", "User-Agent": this.ua, ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...extra };177 }178179 async raw(method: string, path: string, init: { body?: unknown; bytes?: Uint8Array; headers?: Record<string, string> } = {}): Promise<Response> {180 const headers = this.headers(init.headers);181 let body: string | Uint8Array | undefined;182 if (init.bytes) {183 body = init.bytes;184 headers["Content-Type"] = "application/gzip";185 } else if (init.body !== undefined) {186 body = JSON.stringify(init.body);187 headers["Content-Type"] = "application/json";188 }189 const res = await this.f(`${this.baseUrl}/api/v1${path}`, { method, headers, body: body as never, redirect: "error" });190 if (!res.ok && res.status !== 304) {191 let payload: { error?: { code?: string; message?: string; details?: unknown; requestId?: string } } = {};192 try { payload = (await res.json()) as typeof payload; } catch { /* non-JSON error body */ }193 const e = payload.error ?? {};194 throw new RegistryError(res.status, e.code ?? `http_${res.status}`, e.message ?? `Request failed with status ${res.status}`, e.details, e.requestId ?? res.headers.get("x-request-id") ?? undefined);195 }196 return res;197 }198 private async json<T>(method: string, path: string, init?: Parameters<RegistryClient["raw"]>[2]): Promise<T> {199 return (await this.raw(method, path, init)).json() as Promise<T>;200 }201 private static ref(name: string): string {202 const m = /^@?([a-z0-9-]+)\/([a-z0-9-]+)$/.exec(name);203 if (!m) throw new RegistryError(400, "bad_name", `"${name}" is not a valid artifact name (expected @namespace/slug).`);204 return `${m[1]}/${m[2]}`;205 }206207 // Discovery208 list(p: ListParams = {}): Promise<Page<Artifact>> {209 const q = new URLSearchParams();210 for (const [k, v] of Object.entries({ q: p.q, type: p.type, keyword: p.keyword, compatibility: p.compatibility, license: p.license, namespace: p.namespace, sort: p.sort, page: p.page, per_page: p.perPage, include_private: p.includePrivate ? "true" : undefined })) {211 if (v !== undefined && v !== "") q.set(k, String(v));212 }213 return this.json("GET", `/artifacts?${q}`);214 }215 search(query: string, p: Omit<ListParams, "q"> = {}): Promise<Page<Artifact>> {216 return this.list({ ...p, q: query, sort: "relevance" });217 }218 categories(): Promise<{ types: { type: ArtifactType; label: string; description: string; count: number }[]; totals: { artifacts: number; releases: number; publishers: number } }> {219 return this.json("GET", "/categories");220 }221 collections(category?: string): Promise<{ items: { slug: string; title: string; description: string; category: string; itemCount: number }[] }> {222 return this.json("GET", `/collections${category ? `?category=${encodeURIComponent(category)}` : ""}`);223 }224 collection(slug: string): Promise<{ slug: string; title: string; description: string; items: Artifact[] }> {225 return this.json("GET", `/collections/${encodeURIComponent(slug)}`);226 }227 artifact(name: string): Promise<ArtifactDetail> {228 return this.json("GET", `/artifacts/${RegistryClient.ref(name)}`);229 }230 versions(name: string): Promise<{ items: Release[] }> {231 return this.json("GET", `/artifacts/${RegistryClient.ref(name)}/versions`);232 }233 version(name: string, version = "latest"): Promise<ReleaseDetail> {234 return this.json("GET", `/artifacts/${RegistryClient.ref(name)}/versions/${encodeURIComponent(version)}`);235 }236 file(name: string, version: string, path: string): Promise<ReleaseFile & { content: string }> {237 return this.json("GET", `/artifacts/${RegistryClient.ref(name)}/versions/${encodeURIComponent(version)}/files?path=${encodeURIComponent(path)}`);238 }239 install(name: string, version = "latest", client?: string): Promise<{ artifact: string; version: string; sha256: string; instructions: InstallInstructions[] }> {240 return this.json("GET", `/artifacts/${RegistryClient.ref(name)}/versions/${encodeURIComponent(version)}/install${client ? `?client=${encodeURIComponent(client)}` : ""}`);241 }242 history(name: string): Promise<{ items: { at: string; action: string; target: string | null; actor: string | null }[] }> {243 return this.json("GET", `/artifacts/${RegistryClient.ref(name)}/history`);244 }245 /** Download archive bytes. Callers must verify the SHA-256 against release metadata before use. */246 async download(name: string, version: string): Promise<{ bytes: Uint8Array; headerSha256: string | null }> {247 const res = await this.raw("GET", `/artifacts/${RegistryClient.ref(name)}/versions/${encodeURIComponent(version)}/download`, { headers: { Accept: "application/gzip" } });248 return { bytes: new Uint8Array(await res.arrayBuffer()), headerSha256: res.headers.get("x-checksum-sha256") };249 }250251 // Publishing252 createArtifact(input: { namespace: string; slug: string; type: string; summary: string; visibility?: string; confirmPublic?: boolean; license?: string; repository?: string; homepage?: string; issues?: string; keywords?: string[] }, idempotencyKey?: string): Promise<Artifact> {253 return this.json("POST", "/artifacts", { body: input, headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {} });254 }255 updateArtifact(id: string, patch: Record<string, unknown>): Promise<Artifact> {256 return this.json("PATCH", `/artifacts/${id}`, { body: patch });257 }258 beginUpload(artifactId: string, input: { version: string; sha256: string; sizeBytes: number; releaseNotes?: string; sourceCommit?: string }, idempotencyKey?: string): Promise<Upload> {259 return this.json("POST", `/artifacts/${artifactId}/releases`, { body: input, headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {} });260 }261 sendUpload(uploadId: string, bytes: Uint8Array): Promise<Upload> {262 return this.json("PUT", `/uploads/${uploadId}`, { bytes });263 }264 finalizeUpload(uploadId: string, sha256: string): Promise<{ upload: Upload; release: Release; findings: Finding[]; replayed: boolean }> {265 return this.json("POST", `/uploads/${uploadId}/finalize`, { body: { sha256 }, headers: { "Idempotency-Key": `finalize-${uploadId}` } });266 }267 upload(uploadId: string): Promise<Upload> {268 return this.json("GET", `/uploads/${uploadId}`);269 }270 setState(id: string, action: "deprecate" | "undeprecate" | "archive", input: { reason?: string; confirm?: boolean } = {}): Promise<Artifact> {271 return this.json("POST", `/artifacts/${id}/${action}`, { body: input });272 }273 revokeRelease(releaseId: string, reason: string, confirm: boolean): Promise<Release> {274 return this.json("POST", `/releases/${releaseId}/revoke`, { body: { reason, confirm } });275 }276 updateReleaseNotes(releaseId: string, releaseNotes: string | null): Promise<Release> {277 return this.json("PATCH", `/releases/${releaseId}`, { body: { releaseNotes } });278 }279 validateManifest(manifest: unknown): Promise<{ valid: boolean; issues: { path: string; message: string }[] }> {280 return this.json("POST", "/validate-manifest", { body: manifest });281 }282283 // Account284 me(): Promise<Me> {285 return this.json("GET", "/me");286 }287 tokens(): Promise<{ items: TokenInfo[] }> {288 return this.json("GET", "/me/tokens");289 }290 createToken(input: { name: string; scopes: string[]; namespaces?: string[]; expiresInHours?: number; ipAllowlist?: string[] }): Promise<TokenInfo & { token: string }> {291 return this.json("POST", "/me/tokens", { body: input });292 }293 revokeToken(id: string): Promise<TokenInfo> {294 return this.json("DELETE", `/me/tokens/${id}`);295 }296 rotateToken(id: string): Promise<TokenInfo & { token: string }> {297 return this.json("POST", `/me/tokens/${id}/rotate`);298 }299 audit(p: { before?: number; limit?: number; action?: string } = {}): Promise<{ items: { id: number; at: string; actor: string; action: string; target: string | null; result: string; ip: string | null; requestId: string | null; detail: Record<string, unknown> }[]; nextBefore: number | null }> {300 const q = new URLSearchParams();301 for (const [k, v] of Object.entries(p)) if (v !== undefined) q.set(k, String(v));302 return this.json("GET", `/me/audit?${q}`);303 }304 report(input: { artifact: string; version?: string; category: string; details: string; contact?: string }): Promise<{ id: string; state: string }> {305 return this.json("POST", "/reports", { body: input });306 }307 health(): Promise<{ status: string }> {308 return this.json("GET", "/health/ready");309 }310}311