SPB Git

Repository Public MIT

index.ts 13.0 KB 311 lines typescript

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
Files main
Menu

Theme