From dd58c63609fa8598e0445d903b888a362fde448e Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Fri, 9 Jan 2026 06:21:37 +0000 Subject: [PATCH 1/4] Feat: Implement Experts API module for @perstack/api-client - Add types.ts with Zod schemas for Scope, Version, Draft, etc. - Add versions.ts with VersionsApi sub-module - Add drafts.ts with DraftsApi sub-module (CRUD + assignVersion) - Add api.ts with main ExpertsApi (list, get, getMeta, publish, unpublish, yank) - Add comprehensive MSW tests for all endpoints - Update client.ts to include experts API - Update index.ts to export all types and APIs Co-Authored-By: Claude Opus 4.5 --- packages/api-client/src/client.ts | 3 + packages/api-client/src/experts/api.test.ts | 426 ++++++++++++++++++ packages/api-client/src/experts/api.ts | 127 ++++++ .../api-client/src/experts/drafts.test.ts | 391 ++++++++++++++++ packages/api-client/src/experts/drafts.ts | 129 ++++++ packages/api-client/src/experts/index.ts | 20 + packages/api-client/src/experts/types.ts | 208 +++++++++ .../api-client/src/experts/versions.test.ts | 103 +++++ packages/api-client/src/experts/versions.ts | 26 ++ packages/api-client/src/index.ts | 22 + 10 files changed, 1455 insertions(+) create mode 100644 packages/api-client/src/experts/api.test.ts create mode 100644 packages/api-client/src/experts/api.ts create mode 100644 packages/api-client/src/experts/drafts.test.ts create mode 100644 packages/api-client/src/experts/drafts.ts create mode 100644 packages/api-client/src/experts/index.ts create mode 100644 packages/api-client/src/experts/types.ts create mode 100644 packages/api-client/src/experts/versions.test.ts create mode 100644 packages/api-client/src/experts/versions.ts diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 6bc0baa9..725a580f 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,3 +1,4 @@ +import { createExpertsApi, type ExpertsApi } from "./experts/api.js" import { createFetcher } from "./fetcher.js" import { createRegistryExpertsApi, type RegistryExpertsApi } from "./registry/experts.js" import { createStudioExpertJobsApi, type StudioExpertJobsApi } from "./studio/expert-jobs.js" @@ -6,6 +7,7 @@ import { createStudioWorkspaceApi, type StudioWorkspaceApi } from "./studio/work import type { ApiClientConfig } from "./types.js" export interface ApiClient { + experts: ExpertsApi registry: { experts: RegistryExpertsApi } @@ -20,6 +22,7 @@ export function createApiClient(config?: ApiClientConfig): ApiClient { const fetcher = createFetcher(config) return { + experts: createExpertsApi(fetcher), registry: { experts: createRegistryExpertsApi(fetcher), }, diff --git a/packages/api-client/src/experts/api.test.ts b/packages/api-client/src/experts/api.test.ts new file mode 100644 index 00000000..0660628e --- /dev/null +++ b/packages/api-client/src/experts/api.test.ts @@ -0,0 +1,426 @@ +import { HttpResponse, http } from "msw" +import { setupServer } from "msw/node" +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" +import { createFetcher } from "../fetcher.js" +import { createExpertsApi } from "./api.js" + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const BASE_URL = "https://api.perstack.ai" + +const createMockOwner = (overrides = {}) => ({ + name: "test-org", + organizationId: "org123456789012345678901", + createdAt: "2024-01-01T00:00:00Z", + ...overrides, +}) + +const createMockScope = (overrides = {}) => ({ + type: "scope" as const, + id: "scope-123", + name: "my-expert", + description: "Test expert scope", + category: "general" as const, + owner: createMockOwner(), + published: true, + publishedAt: "2024-01-01T00:00:00Z", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + ...overrides, +}) + +const createMockScopeMeta = (overrides = {}) => ({ + type: "scopeMeta" as const, + id: "scope-123", + name: "my-expert", + version: "1.0.0", + r2Path: "experts/my-expert/1.0.0/content.json", + public: true, + yanked: false, + ...overrides, +}) + +describe("createExpertsApi", () => { + const fetcher = createFetcher() + const api = createExpertsApi(fetcher) + + describe("list", () => { + it("returns scopes on success", async () => { + const mockScopes = [createMockScope()] + server.use( + http.get(`${BASE_URL}/api/experts/v1/`, () => { + return HttpResponse.json({ + data: { scopes: mockScopes }, + meta: { total: 1, limit: 20, offset: 0 }, + }) + }), + ) + + const result = await api.list() + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.data).toHaveLength(1) + expect(result.data.meta.total).toBe(1) + expect(result.data.data[0].createdAt).toBeInstanceOf(Date) + } + }) + + it("includes query parameters when provided", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ + data: { scopes: [] }, + meta: { total: 0, limit: 10, offset: 5 }, + }) + }), + ) + + await api.list({ + filter: "test", + category: "coding", + includeDrafts: true, + limit: 10, + offset: 5, + }) + + expect(capturedUrl).toContain("filter=test") + expect(capturedUrl).toContain("category=coding") + expect(capturedUrl).toContain("includeDrafts=true") + expect(capturedUrl).toContain("limit=10") + expect(capturedUrl).toContain("offset=5") + }) + + it("omits query parameters when not provided", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ + data: { scopes: [] }, + meta: { total: 0, limit: 20, offset: 0 }, + }) + }), + ) + + await api.list() + expect(capturedUrl).not.toContain("filter=") + expect(capturedUrl).not.toContain("category=") + }) + + it("returns error on failure", async () => { + server.use( + http.get(`${BASE_URL}/api/experts/v1/`, () => { + return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }) + }), + ) + + const result = await api.list() + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.code).toBe(401) + } + }) + }) + + describe("get", () => { + it("returns scope on success", async () => { + const mockScope = createMockScope() + server.use( + http.get(`${BASE_URL}/api/experts/v1/:key`, () => { + return HttpResponse.json({ data: { scope: mockScope } }) + }), + ) + + const result = await api.get("my-expert") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.name).toBe("my-expert") + expect(result.data.createdAt).toBeInstanceOf(Date) + expect(result.data.publishedAt).toBeInstanceOf(Date) + } + }) + + it("properly encodes key in URL", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/:key`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ data: { scope: createMockScope() } }) + }), + ) + + await api.get("@perstack/base@1.0.0") + expect(capturedUrl).toContain("%40perstack%2Fbase%401.0.0") + }) + + it("handles scope with ref (version)", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/:key`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ data: { scope: createMockScope() } }) + }), + ) + + await api.get("my-expert@1.0.0") + expect(capturedUrl).toContain("my-expert%401.0.0") + }) + + it("handles scope with ref (tag)", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/:key`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ data: { scope: createMockScope() } }) + }), + ) + + await api.get("my-expert@latest") + expect(capturedUrl).toContain("my-expert%40latest") + }) + + it("returns error on failure", async () => { + server.use( + http.get(`${BASE_URL}/api/experts/v1/:key`, () => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }) + }), + ) + + const result = await api.get("not-found") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.code).toBe(404) + } + }) + }) + + describe("getMeta", () => { + it("returns scope meta on success", async () => { + const mockMeta = createMockScopeMeta() + server.use( + http.get(`${BASE_URL}/api/experts/v1/:key/meta`, () => { + return HttpResponse.json({ data: { meta: mockMeta } }) + }), + ) + + const result = await api.getMeta("my-expert@1.0.0") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.name).toBe("my-expert") + expect(result.data.version).toBe("1.0.0") + expect(result.data.r2Path).toContain("my-expert") + } + }) + + it("properly encodes key in URL", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/:key/meta`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ data: { meta: createMockScopeMeta() } }) + }), + ) + + await api.getMeta("@perstack/base@1.0.0") + expect(capturedUrl).toContain("%40perstack%2Fbase%401.0.0") + }) + + it("returns error on failure", async () => { + server.use( + http.get(`${BASE_URL}/api/experts/v1/:key/meta`, () => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }) + }), + ) + + const result = await api.getMeta("not-found@1.0.0") + expect(result.ok).toBe(false) + }) + }) + + describe("publish", () => { + it("publishes scope on success", async () => { + let capturedUrl = "" + let capturedBody: unknown + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/publish`, async ({ request }) => { + capturedUrl = request.url + capturedBody = await request.json() + return HttpResponse.json({}) + }), + ) + + const result = await api.publish("my-expert") + expect(capturedUrl).toContain("my-expert") + expect(capturedBody).toEqual({}) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data).toBeUndefined() + } + }) + + it("publishes scope with input", async () => { + let capturedBody: unknown + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/publish`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({}) + }), + ) + + const result = await api.publish("my-expert", { makeExistingVersionsPublic: true }) + expect(capturedBody).toEqual({ makeExistingVersionsPublic: true }) + expect(result.ok).toBe(true) + }) + + it("properly encodes scopeName in URL", async () => { + let capturedUrl = "" + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/publish`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({}) + }), + ) + + await api.publish("@perstack/base") + expect(capturedUrl).toContain("%40perstack%2Fbase") + }) + + it("returns error when no versions exist", async () => { + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/publish`, () => { + return HttpResponse.json( + { + error: "Bad Request", + reason: 'Cannot publish scope "my-expert": no versions exist.', + }, + { status: 400 }, + ) + }), + ) + + const result = await api.publish("my-expert") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.code).toBe(400) + } + }) + }) + + describe("unpublish", () => { + it("unpublishes scope on success", async () => { + let capturedUrl = "" + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/unpublish`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({}) + }), + ) + + const result = await api.unpublish("my-expert") + expect(capturedUrl).toContain("my-expert") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data).toBeUndefined() + } + }) + + it("returns error when scope not published", async () => { + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/unpublish`, () => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }) + }), + ) + + const result = await api.unpublish("my-expert") + expect(result.ok).toBe(false) + }) + }) + + describe("yank", () => { + it("yanks version on success", async () => { + let capturedUrl = "" + server.use( + http.delete(`${BASE_URL}/api/experts/v1/:key`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({}) + }), + ) + + const result = await api.yank("my-expert@1.0.0") + expect(capturedUrl).toContain("my-expert%401.0.0") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data).toBeUndefined() + } + }) + + it("properly encodes key in URL", async () => { + let capturedUrl = "" + server.use( + http.delete(`${BASE_URL}/api/experts/v1/:key`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({}) + }), + ) + + await api.yank("@perstack/base@1.0.0") + expect(capturedUrl).toContain("%40perstack%2Fbase%401.0.0") + }) + + it("returns error when already yanked", async () => { + server.use( + http.delete(`${BASE_URL}/api/experts/v1/:key`, () => { + return HttpResponse.json( + { error: "Bad Request", reason: "Version 1.0.0 is already yanked" }, + { status: 400 }, + ) + }), + ) + + const result = await api.yank("my-expert@1.0.0") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.code).toBe(400) + } + }) + + it("returns error on invalid format", async () => { + server.use( + http.delete(`${BASE_URL}/api/experts/v1/:key`, () => { + return HttpResponse.json( + { error: "Bad Request", reason: "Invalid format. Expected scopeName@version" }, + { status: 400 }, + ) + }), + ) + + const result = await api.yank("my-expert") + expect(result.ok).toBe(false) + }) + }) + + describe("versions sub-api", () => { + it("is accessible via api.versions", () => { + expect(api.versions).toBeDefined() + expect(typeof api.versions.list).toBe("function") + }) + }) + + describe("drafts sub-api", () => { + it("is accessible via api.drafts", () => { + expect(api.drafts).toBeDefined() + expect(typeof api.drafts.list).toBe("function") + expect(typeof api.drafts.get).toBe("function") + expect(typeof api.drafts.create).toBe("function") + expect(typeof api.drafts.update).toBe("function") + expect(typeof api.drafts.delete).toBe("function") + expect(typeof api.drafts.assignVersion).toBe("function") + }) + }) +}) diff --git a/packages/api-client/src/experts/api.ts b/packages/api-client/src/experts/api.ts new file mode 100644 index 00000000..a759e5e8 --- /dev/null +++ b/packages/api-client/src/experts/api.ts @@ -0,0 +1,127 @@ +/** + * Experts API + * + * @see docs/experts/api.md + */ +import type { Fetcher } from "../fetcher.js" +import type { ApiResult, PaginatedResult, RequestOptions } from "../types.js" +import { createDraftsApi, type DraftsApi } from "./drafts.js" +import { + getScopeMetaResponseSchema, + getScopeResponseSchema, + type ListScopesParams, + listScopesResponseSchema, + type PublishInput, + publishResponseSchema, + type Scope, + type ScopeMeta, + unpublishResponseSchema, + yankResponseSchema, +} from "./types.js" +import { createVersionsApi, type VersionsApi } from "./versions.js" + +export interface ExpertsApi { + list( + params?: ListScopesParams, + options?: RequestOptions, + ): Promise>> + get(key: string, options?: RequestOptions): Promise> + getMeta(key: string, options?: RequestOptions): Promise> + + versions: VersionsApi + drafts: DraftsApi + + publish( + scopeName: string, + input?: PublishInput, + options?: RequestOptions, + ): Promise> + unpublish(scopeName: string, options?: RequestOptions): Promise> + yank(key: string, options?: RequestOptions): Promise> +} + +export function createExpertsApi(fetcher: Fetcher): ExpertsApi { + return { + async list(params, options) { + const searchParams = new URLSearchParams() + if (params?.filter) searchParams.set("filter", params.filter) + if (params?.category) searchParams.set("category", params.category) + if (params?.includeDrafts !== undefined) + searchParams.set("includeDrafts", params.includeDrafts.toString()) + if (params?.limit !== undefined) searchParams.set("limit", params.limit.toString()) + if (params?.offset !== undefined) searchParams.set("offset", params.offset.toString()) + + const query = searchParams.toString() + const path = `/api/experts/v1/${query ? `?${query}` : ""}` + + const result = await fetcher.get(path, listScopesResponseSchema, options) + if (!result.ok) return result + return { + ok: true, + data: { + data: result.data.data.scopes, + meta: { + total: result.data.meta.total, + take: result.data.meta.limit, + skip: result.data.meta.offset, + }, + }, + } + }, + + async get(key, options) { + const result = await fetcher.get( + `/api/experts/v1/${encodeURIComponent(key)}`, + getScopeResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: result.data.data.scope } + }, + + async getMeta(key, options) { + const result = await fetcher.get( + `/api/experts/v1/${encodeURIComponent(key)}/meta`, + getScopeMetaResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: result.data.data.meta } + }, + + versions: createVersionsApi(fetcher), + drafts: createDraftsApi(fetcher), + + async publish(scopeName, input, options) { + const result = await fetcher.post( + `/api/experts/v1/${encodeURIComponent(scopeName)}/publish`, + input ?? {}, + publishResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: undefined } + }, + + async unpublish(scopeName, options) { + const result = await fetcher.post( + `/api/experts/v1/${encodeURIComponent(scopeName)}/unpublish`, + {}, + unpublishResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: undefined } + }, + + async yank(key, options) { + const result = await fetcher.delete( + `/api/experts/v1/${encodeURIComponent(key)}`, + yankResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: undefined } + }, + } +} diff --git a/packages/api-client/src/experts/drafts.test.ts b/packages/api-client/src/experts/drafts.test.ts new file mode 100644 index 00000000..0418b90b --- /dev/null +++ b/packages/api-client/src/experts/drafts.test.ts @@ -0,0 +1,391 @@ +import { HttpResponse, http } from "msw" +import { setupServer } from "msw/node" +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" +import { createFetcher } from "../fetcher.js" +import { createDraftsApi } from "./drafts.js" + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const BASE_URL = "https://api.perstack.ai" + +const createMockExpert = (overrides = {}) => ({ + key: "my-expert", + name: "My Expert", + minRuntimeVersion: "v1.0" as const, + description: "Test expert", + instruction: "Test instruction", + skills: {}, + delegates: [], + ...overrides, +}) + +const createMockDraftRef = (overrides = {}) => ({ + type: "draftRef" as const, + id: "draft-123", + scopeName: "my-expert", + applicationId: "app-123", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + ...overrides, +}) + +const createMockDraft = (overrides = {}) => ({ + type: "draft" as const, + id: "draft-123", + scopeName: "my-expert", + applicationId: "app-123", + experts: { + "my-expert": createMockExpert(), + }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + ...overrides, +}) + +const createMockVersion = (overrides = {}) => ({ + type: "version" as const, + id: "ver-123", + version: "1.0.0", + public: false, + yanked: false, + tags: ["latest"], + createdAt: "2024-01-01T00:00:00Z", + ...overrides, +}) + +describe("createDraftsApi", () => { + const fetcher = createFetcher() + const api = createDraftsApi(fetcher) + + describe("list", () => { + it("returns drafts on success", async () => { + const mockDrafts = [createMockDraftRef()] + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/drafts`, () => { + return HttpResponse.json({ + data: { drafts: mockDrafts }, + meta: { total: 1, limit: 20, offset: 0 }, + }) + }), + ) + + const result = await api.list("my-expert") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.data).toHaveLength(1) + expect(result.data.meta.total).toBe(1) + expect(result.data.data[0].createdAt).toBeInstanceOf(Date) + } + }) + + it("includes query parameters when provided", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/drafts`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ + data: { drafts: [] }, + meta: { total: 0, limit: 10, offset: 5 }, + }) + }), + ) + + await api.list("my-expert", { limit: 10, offset: 5 }) + expect(capturedUrl).toContain("limit=10") + expect(capturedUrl).toContain("offset=5") + }) + + it("properly encodes scopeName in URL", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/drafts`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ + data: { drafts: [] }, + meta: { total: 0, limit: 20, offset: 0 }, + }) + }), + ) + + await api.list("@perstack/base") + expect(capturedUrl).toContain("%40perstack%2Fbase") + }) + + it("returns error on failure", async () => { + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/drafts`, () => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }) + }), + ) + + const result = await api.list("not-found") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.code).toBe(404) + } + }) + }) + + describe("get", () => { + it("returns draft on success", async () => { + const mockDraft = createMockDraft() + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef`, () => { + return HttpResponse.json({ data: { draft: mockDraft } }) + }), + ) + + const result = await api.get("my-expert", "draft-123") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.id).toBe("draft-123") + expect(result.data.experts["my-expert"].name).toBe("My Expert") + expect(result.data.createdAt).toBeInstanceOf(Date) + } + }) + + it("properly encodes scopeName and draftRef in URL", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ data: { draft: createMockDraft() } }) + }), + ) + + await api.get("@perstack/base", "draft@123") + expect(capturedUrl).toContain("%40perstack%2Fbase") + expect(capturedUrl).toContain("draft%40123") + }) + + it("returns error on failure", async () => { + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef`, () => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }) + }), + ) + + const result = await api.get("my-expert", "not-found") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.code).toBe(404) + } + }) + }) + + describe("create", () => { + it("creates draft on success", async () => { + let capturedBody: unknown + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/drafts`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({ data: { draft: createMockDraft() } }) + }), + ) + + const input = { + applicationId: "app-123", + experts: { + "my-expert": { + name: "My Expert", + minRuntimeVersion: "v1.0" as const, + description: "Test", + instruction: "Test instruction", + skills: {}, + delegates: [], + }, + }, + } + + const result = await api.create("my-expert", input) + expect(capturedBody).toEqual(input) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.id).toBe("draft-123") + } + }) + + it("returns error on failure", async () => { + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/drafts`, () => { + return HttpResponse.json( + { error: "Bad Request", reason: "Invalid experts format" }, + { status: 400 }, + ) + }), + ) + + const result = await api.create("my-expert", { + applicationId: "app-123", + experts: {}, + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.code).toBe(400) + } + }) + }) + + describe("update", () => { + it("updates draft on success", async () => { + let capturedBody: unknown + const updatedDraft = createMockDraft({ + experts: { + "my-expert": createMockExpert({ description: "Updated description" }), + }, + }) + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({ data: { draft: updatedDraft } }) + }), + ) + + const input = { + experts: { + "my-expert": { + name: "My Expert", + minRuntimeVersion: "v1.0" as const, + description: "Updated description", + instruction: "Test instruction", + skills: {}, + delegates: [], + }, + }, + } + + const result = await api.update("my-expert", "draft-123", input) + expect(capturedBody).toEqual(input) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.experts["my-expert"].description).toBe("Updated description") + } + }) + + it("returns error on failure", async () => { + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef`, () => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }) + }), + ) + + const result = await api.update("my-expert", "not-found", { experts: {} }) + expect(result.ok).toBe(false) + }) + }) + + describe("delete", () => { + it("deletes draft on success", async () => { + let capturedUrl = "" + server.use( + http.delete(`${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({}) + }), + ) + + const result = await api.delete("my-expert", "draft-123") + expect(capturedUrl).toContain("my-expert") + expect(capturedUrl).toContain("draft-123") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data).toBeUndefined() + } + }) + + it("returns error on failure", async () => { + server.use( + http.delete(`${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef`, () => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }) + }), + ) + + const result = await api.delete("my-expert", "not-found") + expect(result.ok).toBe(false) + }) + }) + + describe("assignVersion", () => { + it("assigns version on success", async () => { + let capturedBody: unknown + server.use( + http.post( + `${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef/version`, + async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({ data: { version: createMockVersion() } }) + }, + ), + ) + + const input = { version: "1.0.0", tag: "stable" } + const result = await api.assignVersion("my-expert", "draft-123", input) + + expect(capturedBody).toEqual(input) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.version).toBe("1.0.0") + expect(result.data.createdAt).toBeInstanceOf(Date) + } + }) + + it("assigns version without tag", async () => { + let capturedBody: unknown + server.use( + http.post( + `${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef/version`, + async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({ + data: { version: createMockVersion({ tags: ["latest"] }) }, + }) + }, + ), + ) + + const input = { version: "1.0.0" } + const result = await api.assignVersion("my-expert", "draft-123", input) + + expect(capturedBody).toEqual(input) + expect(result.ok).toBe(true) + }) + + it("returns error on invalid version", async () => { + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef/version`, () => { + return HttpResponse.json( + { error: "Bad Request", reason: "Invalid version format: abc" }, + { status: 400 }, + ) + }), + ) + + const result = await api.assignVersion("my-expert", "draft-123", { version: "abc" }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.code).toBe(400) + } + }) + + it("returns error when version not greater than existing", async () => { + server.use( + http.post(`${BASE_URL}/api/experts/v1/:scopeName/drafts/:draftRef/version`, () => { + return HttpResponse.json( + { + error: "Bad Request", + reason: "Version 1.0.0 must be greater than existing version 2.0.0", + }, + { status: 400 }, + ) + }), + ) + + const result = await api.assignVersion("my-expert", "draft-123", { version: "1.0.0" }) + expect(result.ok).toBe(false) + }) + }) +}) diff --git a/packages/api-client/src/experts/drafts.ts b/packages/api-client/src/experts/drafts.ts new file mode 100644 index 00000000..7f9a77bb --- /dev/null +++ b/packages/api-client/src/experts/drafts.ts @@ -0,0 +1,129 @@ +/** + * Experts Drafts API + * + * @see docs/experts/api.md + */ +import type { Fetcher } from "../fetcher.js" +import type { ApiResult, PaginatedResult, RequestOptions } from "../types.js" +import { + type AssignVersionInput, + assignVersionResponseSchema, + type CreateDraftInput, + createDraftResponseSchema, + type Draft, + type DraftRef, + deleteDraftResponseSchema, + getDraftResponseSchema, + type ListDraftsParams, + listDraftsResponseSchema, + type UpdateDraftInput, + updateDraftResponseSchema, + type Version, +} from "./types.js" + +export interface DraftsApi { + list( + scopeName: string, + params?: ListDraftsParams, + options?: RequestOptions, + ): Promise>> + get(scopeName: string, draftRef: string, options?: RequestOptions): Promise> + create( + scopeName: string, + input: CreateDraftInput, + options?: RequestOptions, + ): Promise> + update( + scopeName: string, + draftRef: string, + input: UpdateDraftInput, + options?: RequestOptions, + ): Promise> + delete(scopeName: string, draftRef: string, options?: RequestOptions): Promise> + assignVersion( + scopeName: string, + draftRef: string, + input: AssignVersionInput, + options?: RequestOptions, + ): Promise> +} + +export function createDraftsApi(fetcher: Fetcher): DraftsApi { + return { + async list(scopeName, params, options) { + const searchParams = new URLSearchParams() + if (params?.limit !== undefined) searchParams.set("limit", params.limit.toString()) + if (params?.offset !== undefined) searchParams.set("offset", params.offset.toString()) + + const query = searchParams.toString() + const path = `/api/experts/v1/${encodeURIComponent(scopeName)}/drafts${query ? `?${query}` : ""}` + + const result = await fetcher.get(path, listDraftsResponseSchema, options) + if (!result.ok) return result + return { + ok: true, + data: { + data: result.data.data.drafts, + meta: { + total: result.data.meta.total, + take: result.data.meta.limit, + skip: result.data.meta.offset, + }, + }, + } + }, + + async get(scopeName, draftRef, options) { + const result = await fetcher.get( + `/api/experts/v1/${encodeURIComponent(scopeName)}/drafts/${encodeURIComponent(draftRef)}`, + getDraftResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: result.data.data.draft } + }, + + async create(scopeName, input, options) { + const result = await fetcher.post( + `/api/experts/v1/${encodeURIComponent(scopeName)}/drafts`, + input, + createDraftResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: result.data.data.draft } + }, + + async update(scopeName, draftRef, input, options) { + const result = await fetcher.post( + `/api/experts/v1/${encodeURIComponent(scopeName)}/drafts/${encodeURIComponent(draftRef)}`, + input, + updateDraftResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: result.data.data.draft } + }, + + async delete(scopeName, draftRef, options) { + const result = await fetcher.delete( + `/api/experts/v1/${encodeURIComponent(scopeName)}/drafts/${encodeURIComponent(draftRef)}`, + deleteDraftResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: undefined } + }, + + async assignVersion(scopeName, draftRef, input, options) { + const result = await fetcher.post( + `/api/experts/v1/${encodeURIComponent(scopeName)}/drafts/${encodeURIComponent(draftRef)}/version`, + input, + assignVersionResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: result.data.data.version } + }, + } +} diff --git a/packages/api-client/src/experts/index.ts b/packages/api-client/src/experts/index.ts new file mode 100644 index 00000000..1aa225c0 --- /dev/null +++ b/packages/api-client/src/experts/index.ts @@ -0,0 +1,20 @@ +export { createExpertsApi, type ExpertsApi } from "./api.js" +export { createDraftsApi, type DraftsApi } from "./drafts.js" +export type { + AssignVersionInput, + CreateDraftInput, + Draft, + DraftRef, + Expert, + ExpertInput, + ListDraftsParams, + ListScopesParams, + Owner, + PublishInput, + Scope, + ScopeCategory, + ScopeMeta, + UpdateDraftInput, + Version, +} from "./types.js" +export { createVersionsApi, type VersionsApi } from "./versions.js" diff --git a/packages/api-client/src/experts/types.ts b/packages/api-client/src/experts/types.ts new file mode 100644 index 00000000..aee6c328 --- /dev/null +++ b/packages/api-client/src/experts/types.ts @@ -0,0 +1,208 @@ +/** + * Experts API Types and Schemas + * + * @see docs/experts/api.md + */ +import type { Skill } from "@perstack/core" +import { z } from "zod" + +export const ownerSchema = z.object({ + name: z.string().optional(), + organizationId: z.string(), + createdAt: z.string().transform((s) => new Date(s)), +}) +export type Owner = z.infer + +export const scopeCategorySchema = z.enum([ + "general", + "coding", + "research", + "writing", + "data", + "automation", +]) +export type ScopeCategory = z.infer + +export const scopeSchema = z.object({ + type: z.literal("scope"), + id: z.string(), + name: z.string(), + description: z.string(), + category: scopeCategorySchema.optional(), + owner: ownerSchema, + published: z.boolean(), + publishedAt: z + .string() + .transform((s) => new Date(s)) + .optional() + .nullable(), + createdAt: z.string().transform((s) => new Date(s)), + updatedAt: z.string().transform((s) => new Date(s)), +}) +export type Scope = z.infer + +export const scopeMetaSchema = z.object({ + type: z.literal("scopeMeta"), + id: z.string(), + name: z.string(), + version: z.string(), + r2Path: z.string(), + public: z.boolean(), + yanked: z.boolean(), +}) +export type ScopeMeta = z.infer + +export const versionSchema = z.object({ + type: z.literal("version"), + id: z.string(), + version: z.string(), + public: z.boolean(), + yanked: z.boolean(), + tags: z.array(z.string()), + createdAt: z.string().transform((s) => new Date(s)), +}) +export type Version = z.infer + +export const expertSchema = z.object({ + key: z.string(), + name: z.string(), + minRuntimeVersion: z.literal("v1.0"), + description: z.string(), + instruction: z.string(), + skills: z.record(z.string(), z.unknown()).transform((s) => s as Record), + delegates: z.array(z.string()), +}) +export type Expert = z.infer + +export const draftSchema = z.object({ + type: z.literal("draft"), + id: z.string(), + scopeName: z.string(), + applicationId: z.string(), + experts: z.record(z.string(), expertSchema), + createdAt: z.string().transform((s) => new Date(s)), + updatedAt: z.string().transform((s) => new Date(s)), +}) +export type Draft = z.infer + +export const draftRefSchema = z.object({ + type: z.literal("draftRef"), + id: z.string(), + scopeName: z.string(), + applicationId: z.string(), + createdAt: z.string().transform((s) => new Date(s)), + updatedAt: z.string().transform((s) => new Date(s)), +}) +export type DraftRef = z.infer + +export const listScopesResponseSchema = z.object({ + data: z.object({ + scopes: z.array(scopeSchema), + }), + meta: z.object({ + total: z.number(), + limit: z.number(), + offset: z.number(), + }), +}) + +export const getScopeResponseSchema = z.object({ + data: z.object({ + scope: scopeSchema, + }), +}) + +export const getScopeMetaResponseSchema = z.object({ + data: z.object({ + meta: scopeMetaSchema, + }), +}) + +export const listVersionsResponseSchema = z.object({ + data: z.object({ + versions: z.array(versionSchema), + }), +}) + +export const listDraftsResponseSchema = z.object({ + data: z.object({ + drafts: z.array(draftRefSchema), + }), + meta: z.object({ + total: z.number(), + limit: z.number(), + offset: z.number(), + }), +}) + +export const getDraftResponseSchema = z.object({ + data: z.object({ + draft: draftSchema, + }), +}) + +export const createDraftResponseSchema = z.object({ + data: z.object({ + draft: draftSchema, + }), +}) + +export const updateDraftResponseSchema = z.object({ + data: z.object({ + draft: draftSchema, + }), +}) + +export const deleteDraftResponseSchema = z.object({}) + +export const assignVersionResponseSchema = z.object({ + data: z.object({ + version: versionSchema, + }), +}) + +export const publishResponseSchema = z.object({}) + +export const unpublishResponseSchema = z.object({}) + +export const yankResponseSchema = z.object({}) + +export interface ListScopesParams { + filter?: string + category?: ScopeCategory + includeDrafts?: boolean + limit?: number + offset?: number +} + +export interface ListDraftsParams { + limit?: number + offset?: number +} + +export interface CreateDraftInput { + applicationId: string + experts: Record +} + +export interface ExpertInput { + name: string + minRuntimeVersion: "v1.0" + description: string + instruction: string + skills: Record + delegates: string[] +} + +export interface UpdateDraftInput { + experts: Record +} + +export interface AssignVersionInput { + version: string + tag?: string +} + +export interface PublishInput { + makeExistingVersionsPublic?: boolean +} diff --git a/packages/api-client/src/experts/versions.test.ts b/packages/api-client/src/experts/versions.test.ts new file mode 100644 index 00000000..0ff0fea6 --- /dev/null +++ b/packages/api-client/src/experts/versions.test.ts @@ -0,0 +1,103 @@ +import { HttpResponse, http } from "msw" +import { setupServer } from "msw/node" +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" +import { createFetcher } from "../fetcher.js" +import { createVersionsApi } from "./versions.js" + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const BASE_URL = "https://api.perstack.ai" + +const createMockVersion = (overrides = {}) => ({ + type: "version" as const, + id: "ver-123", + version: "1.0.0", + public: true, + yanked: false, + tags: ["latest"], + createdAt: "2024-01-01T00:00:00Z", + ...overrides, +}) + +describe("createVersionsApi", () => { + const fetcher = createFetcher() + const api = createVersionsApi(fetcher) + + describe("list", () => { + it("returns versions on success", async () => { + const mockVersions = [createMockVersion()] + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/versions`, () => { + return HttpResponse.json({ + data: { versions: mockVersions }, + }) + }), + ) + + const result = await api.list("my-expert") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data).toHaveLength(1) + expect(result.data[0].version).toBe("1.0.0") + expect(result.data[0].createdAt).toBeInstanceOf(Date) + } + }) + + it("properly encodes scopeName in URL", async () => { + let capturedUrl = "" + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/versions`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json({ + data: { versions: [] }, + }) + }), + ) + + await api.list("@perstack/base") + expect(capturedUrl).toContain("%40perstack%2Fbase") + }) + + it("returns error on failure", async () => { + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/versions`, () => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }) + }), + ) + + const result = await api.list("not-found") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.code).toBe(404) + } + }) + + it("returns multiple versions sorted by version", async () => { + const mockVersions = [ + createMockVersion({ id: "ver-3", version: "3.0.0", tags: ["latest"] }), + createMockVersion({ id: "ver-2", version: "2.0.0", tags: [] }), + createMockVersion({ id: "ver-1", version: "1.0.0", tags: ["stable"] }), + ] + server.use( + http.get(`${BASE_URL}/api/experts/v1/:scopeName/versions`, () => { + return HttpResponse.json({ + data: { versions: mockVersions }, + }) + }), + ) + + const result = await api.list("my-expert") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data).toHaveLength(3) + expect(result.data[0].version).toBe("3.0.0") + expect(result.data[1].version).toBe("2.0.0") + expect(result.data[2].version).toBe("1.0.0") + } + }) + }) +}) diff --git a/packages/api-client/src/experts/versions.ts b/packages/api-client/src/experts/versions.ts new file mode 100644 index 00000000..84e2aa7e --- /dev/null +++ b/packages/api-client/src/experts/versions.ts @@ -0,0 +1,26 @@ +/** + * Experts Versions API + * + * @see docs/experts/api.md + */ +import type { Fetcher } from "../fetcher.js" +import type { ApiResult, RequestOptions } from "../types.js" +import { listVersionsResponseSchema, type Version } from "./types.js" + +export interface VersionsApi { + list(scopeName: string, options?: RequestOptions): Promise> +} + +export function createVersionsApi(fetcher: Fetcher): VersionsApi { + return { + async list(scopeName, options) { + const result = await fetcher.get( + `/api/experts/v1/${encodeURIComponent(scopeName)}/versions`, + listVersionsResponseSchema, + options, + ) + if (!result.ok) return result + return { ok: true, data: result.data.data.versions } + }, + } +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 4da20656..3745b60d 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,5 +1,27 @@ // Core exports export { type ApiClient, createApiClient } from "./client.js" +export type { + AssignVersionInput, + CreateDraftInput, + Draft, + DraftRef, + DraftsApi, + Expert, + ExpertInput, + ExpertsApi, + ListDraftsParams, + ListScopesParams, + Owner as ExpertsOwner, + PublishInput, + Scope, + ScopeCategory, + ScopeMeta, + UpdateDraftInput, + Version, + VersionsApi, +} from "./experts/index.js" +// Experts exports +export { createDraftsApi, createExpertsApi, createVersionsApi } from "./experts/index.js" export { createFetcher, type Fetcher } from "./fetcher.js" // Registry exports export { createRegistryExpertsApi, type RegistryExpertsApi } from "./registry/experts.js" From 63116dad9d6be423ccf02294181dfeecb916db11 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Fri, 9 Jan 2026 06:23:42 +0000 Subject: [PATCH 2/4] chore: Add changeset for Experts API module Co-Authored-By: Claude Opus 4.5 --- .changeset/experts-api-module.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/experts-api-module.md diff --git a/.changeset/experts-api-module.md b/.changeset/experts-api-module.md new file mode 100644 index 00000000..86fb07a0 --- /dev/null +++ b/.changeset/experts-api-module.md @@ -0,0 +1,5 @@ +--- +"@perstack/api-client": minor +--- + +Add Experts API module with full support for Scopes, Versions, Drafts, and Publish operations From 699de90e1e008d0bb4db483db885dc81f7805c45 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Fri, 9 Jan 2026 06:27:40 +0000 Subject: [PATCH 3/4] chore: Include core in changeset for version sync Co-Authored-By: Claude Opus 4.5 --- .changeset/experts-api-module.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/experts-api-module.md b/.changeset/experts-api-module.md index 86fb07a0..f68ffc29 100644 --- a/.changeset/experts-api-module.md +++ b/.changeset/experts-api-module.md @@ -1,5 +1,6 @@ --- "@perstack/api-client": minor +"@perstack/core": minor --- Add Experts API module with full support for Scopes, Versions, Drafts, and Publish operations From 264a202f2bebc38e9fc5dee86627f8c05b7dfd75 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Fri, 9 Jan 2026 06:30:47 +0000 Subject: [PATCH 4/4] chore: Use patch bump for independent release Co-Authored-By: Claude Opus 4.5 --- .changeset/experts-api-module.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/experts-api-module.md b/.changeset/experts-api-module.md index f68ffc29..e66d4d81 100644 --- a/.changeset/experts-api-module.md +++ b/.changeset/experts-api-module.md @@ -1,6 +1,5 @@ --- -"@perstack/api-client": minor -"@perstack/core": minor +"@perstack/api-client": patch --- Add Experts API module with full support for Scopes, Versions, Drafts, and Publish operations