diff --git a/packages/scout-agent/.gitignore b/packages/scout-agent/.gitignore index a6dec3a..50872e7 100644 --- a/packages/scout-agent/.gitignore +++ b/packages/scout-agent/.gitignore @@ -1 +1,5 @@ .blink +**/.claude +dist +node_modules +*.tgz \ No newline at end of file diff --git a/packages/scout-agent/README.md b/packages/scout-agent/README.md index f9414f3..1aafefd 100644 --- a/packages/scout-agent/README.md +++ b/packages/scout-agent/README.md @@ -66,14 +66,14 @@ All integrations are optional - include only what you need. ### ScoutOptions -| Option | Type | Required | Description | -| ----------- | ---------------------- | -------- | --------------------------------------- | -| `agent` | `blink.Agent` | Yes | Blink agent instance | -| `github` | `GitHubConfig` | No | GitHub App configuration | -| `slack` | `SlackConfig` | No | Slack App configuration | -| `webSearch` | `WebSearchConfig` | No | Exa web search configuration | -| `compute` | `ComputeConfig` | No | Docker or Daytona compute configuration | -| `logger` | `Logger` | No | Custom logger instance | +| Option | Type | Required | Description | +| ----------- | ---------------------- | -------- | ----------------------------------------------- | +| `agent` | `blink.Agent` | Yes | Blink agent instance | +| `github` | `GitHubConfig` | No | GitHub App configuration | +| `slack` | `SlackConfig` | No | Slack App configuration | +| `webSearch` | `WebSearchConfig` | No | Exa web search configuration | +| `compute` | `ComputeConfig` | No | Docker, Daytona, or Coder compute configuration | +| `logger` | `Logger` | No | Custom logger instance | ### GitHub @@ -151,6 +151,23 @@ Execute code in isolated environments: } ``` +**Coder (self-hosted workspaces):** + +```typescript +{ + type: "coder", + options: { + coderUrl: string // Coder deployment URL (e.g., https://coder.example.com) + sessionToken: string // Coder session token for authentication + template: string // Template name to create workspaces from + computeServerPort?: number // Port for compute server (default: 22137) + presetName?: string // Optional preset name for workspace creation + richParameters?: Array<{ name: string; value: string }> // Optional template parameters + startTimeoutSeconds?: number // Workspace start timeout (default: 300) + } +} +``` + ## API Reference ### Scout Class diff --git a/packages/scout-agent/agent.ts b/packages/scout-agent/agent.ts index f59da0f..42d278a 100644 --- a/packages/scout-agent/agent.ts +++ b/packages/scout-agent/agent.ts @@ -5,6 +5,13 @@ import { type Message, Scout } from "./lib"; export const agent = new blink.Agent(); +const ensure = (value: string | undefined): string => { + if (value === undefined) { + throw new Error("value is undefined"); + } + return value; +}; + const scout = new Scout({ agent, github: { @@ -20,7 +27,13 @@ const scout = new Scout({ exaApiKey: process.env.EXA_API_KEY, }, compute: { - type: "docker", + type: "coder", + options: { + url: ensure(process.env.CODER_URL), + sessionToken: ensure(process.env.CODER_SESSION_TOKEN), + template: ensure(process.env.CODER_TEMPLATE), + presetName: ensure(process.env.CODER_PRESET_NAME), + }, }, }); @@ -39,7 +52,7 @@ agent.on("chat", async ({ id, messages }) => { const params = await scout.buildStreamTextParams({ messages, chatID: id, - model: "anthropic/claude-sonnet-4.5", + model: "anthropic/claude-opus-4.5", providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, tools: { get_favorite_color: tool({ diff --git a/packages/scout-agent/lib/compute/coder/index.test.ts b/packages/scout-agent/lib/compute/coder/index.test.ts new file mode 100644 index 0000000..600b9ed --- /dev/null +++ b/packages/scout-agent/lib/compute/coder/index.test.ts @@ -0,0 +1,675 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + baseCoderTestOptions, + createMockCoderClient, + createMockComputeServer, + mockCoderPreset, + mockCoderWorkspace, + mockCoderWorkspaceBuild, + noopLogger, +} from "../test-utils"; +import { getCoderWorkspaceClient, initializeCoderWorkspace } from "./index"; + +describe("initializeCoderWorkspace", () => { + describe("existing workspace - running state", () => { + test("reuses running workspace with connected agent", async () => { + using computeServer = createMockComputeServer(); + + const mockClient = createMockCoderClient({ + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + const result = await initializeCoderWorkspace( + noopLogger, + { ...baseCoderTestOptions, client: mockClient }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + expect(result.message).toContain("already initialized"); + expect(result.workspaceInfo.workspaceId).toBe("ws-123"); + expect(result.workspaceInfo.agentName).toBe("main"); + expect(mockClient.createWorkspaceBuild).not.toHaveBeenCalled(); + expect(mockClient.createWorkspace).not.toHaveBeenCalled(); + }); + + test("falls through to create new when agent not connected", async () => { + using computeServer = createMockComputeServer(); + + let getWorkspaceCallCount = 0; + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => { + getWorkspaceCallCount++; + // First call: existing workspace with disconnected agent + if (getWorkspaceCallCount === 1) { + return Promise.resolve( + mockCoderWorkspace({ + latest_build: mockCoderWorkspaceBuild({ + status: "running", + resources: [ + { + id: "res-123", + name: "main", + type: "docker_container", + agents: [ + { + id: "agent-123", + name: "main", + status: "disconnected", + }, + ], + }, + ], + }), + }) + ); + } + // Subsequent calls: newly created workspace is running with connected agent + return Promise.resolve(mockCoderWorkspace({ id: "ws-new" })); + }), + createWorkspace: mock(() => + Promise.resolve(mockCoderWorkspace({ id: "ws-new" })) + ), + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + const result = await initializeCoderWorkspace( + noopLogger, + { + ...baseCoderTestOptions, + template: "test-template", + client: mockClient, + }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + expect(result.message).toBe( + 'Workspace "testuser/test-workspace" initialized.' + ); + expect(mockClient.createWorkspace).toHaveBeenCalled(); + }); + }); + + describe("existing workspace - stopped/stopping state", () => { + test("starts stopped workspace", async () => { + using computeServer = createMockComputeServer(); + + let getWorkspaceCallCount = 0; + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => { + getWorkspaceCallCount++; + // First call returns stopped, subsequent calls return running + if (getWorkspaceCallCount === 1) { + return Promise.resolve( + mockCoderWorkspace({ + latest_build: mockCoderWorkspaceBuild({ status: "stopped" }), + }) + ); + } + return Promise.resolve(mockCoderWorkspace()); + }), + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + const result = await initializeCoderWorkspace( + noopLogger, + { ...baseCoderTestOptions, client: mockClient }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + expect(result.message).toBe( + 'Workspace "testuser/test-workspace" started and initialized.' + ); + expect(mockClient.createWorkspaceBuild).toHaveBeenCalledWith("ws-123", { + transition: "start", + }); + }); + + test("starts stopping workspace", async () => { + using computeServer = createMockComputeServer(); + + let getWorkspaceCallCount = 0; + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => { + getWorkspaceCallCount++; + if (getWorkspaceCallCount === 1) { + return Promise.resolve( + mockCoderWorkspace({ + latest_build: mockCoderWorkspaceBuild({ status: "stopping" }), + }) + ); + } + return Promise.resolve(mockCoderWorkspace()); + }), + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + const result = await initializeCoderWorkspace( + noopLogger, + { ...baseCoderTestOptions, client: mockClient }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + expect(result.message).toBe( + 'Workspace "testuser/test-workspace" started and initialized.' + ); + expect(mockClient.createWorkspaceBuild).toHaveBeenCalled(); + }); + }); + + describe("existing workspace - starting/pending state", () => { + test("waits for starting workspace", async () => { + using computeServer = createMockComputeServer(); + + let getWorkspaceCallCount = 0; + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => { + getWorkspaceCallCount++; + if (getWorkspaceCallCount === 1) { + return Promise.resolve( + mockCoderWorkspace({ + latest_build: mockCoderWorkspaceBuild({ status: "starting" }), + }) + ); + } + return Promise.resolve(mockCoderWorkspace()); + }), + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + const result = await initializeCoderWorkspace( + noopLogger, + { ...baseCoderTestOptions, client: mockClient }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + expect(result.message).toBe( + 'Workspace "testuser/test-workspace" initialized.' + ); + expect(mockClient.createWorkspaceBuild).not.toHaveBeenCalled(); + }); + + test("waits for pending workspace", async () => { + using computeServer = createMockComputeServer(); + + let getWorkspaceCallCount = 0; + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => { + getWorkspaceCallCount++; + if (getWorkspaceCallCount === 1) { + return Promise.resolve( + mockCoderWorkspace({ + latest_build: mockCoderWorkspaceBuild({ status: "pending" }), + }) + ); + } + return Promise.resolve(mockCoderWorkspace()); + }), + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + const result = await initializeCoderWorkspace( + noopLogger, + { ...baseCoderTestOptions, client: mockClient }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + expect(result.message).toBe( + 'Workspace "testuser/test-workspace" initialized.' + ); + expect(mockClient.createWorkspaceBuild).not.toHaveBeenCalled(); + }); + }); + + describe("existing workspace - terminal states (fall through to create new)", () => { + test.each([ + "failed", + "canceled", + "canceling", + "deleted", + "deleting", + ] as const)("creates new workspace when existing is %s", async (status) => { + using computeServer = createMockComputeServer(); + + let getWorkspaceCallCount = 0; + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => { + getWorkspaceCallCount++; + // First call: existing workspace in terminal state + if (getWorkspaceCallCount === 1) { + return Promise.resolve( + mockCoderWorkspace({ + latest_build: mockCoderWorkspaceBuild({ status }), + }) + ); + } + // Subsequent calls: newly created workspace is running + return Promise.resolve(mockCoderWorkspace({ id: "ws-new" })); + }), + createWorkspace: mock(() => + Promise.resolve(mockCoderWorkspace({ id: "ws-new" })) + ), + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + const result = await initializeCoderWorkspace( + noopLogger, + { + ...baseCoderTestOptions, + template: "test-template", + client: mockClient, + }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + expect(result.message).toBe( + 'Workspace "testuser/test-workspace" initialized.' + ); + expect(mockClient.createWorkspace).toHaveBeenCalled(); + }); + }); + + describe("existing workspace - error handling", () => { + test("creates new workspace when getWorkspace throws", async () => { + using computeServer = createMockComputeServer(); + + const warnLogs: unknown[] = []; + const logger = { + ...noopLogger, + warn: (...args: unknown[]) => warnLogs.push(args), + }; + + let getWorkspaceCallCount = 0; + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => { + getWorkspaceCallCount++; + // First call: throws error (existing workspace check fails) + if (getWorkspaceCallCount === 1) { + return Promise.reject(new Error("Workspace not found")); + } + // Subsequent calls: newly created workspace is running + return Promise.resolve(mockCoderWorkspace({ id: "ws-new" })); + }), + createWorkspace: mock(() => + Promise.resolve(mockCoderWorkspace({ id: "ws-new" })) + ), + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + const result = await initializeCoderWorkspace( + logger, + { + ...baseCoderTestOptions, + template: "test-template", + client: mockClient, + }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + expect(result.message).toBe( + 'Workspace "testuser/test-workspace" initialized.' + ); + expect(mockClient.createWorkspace).toHaveBeenCalled(); + expect(warnLogs.length).toBeGreaterThan(0); + }); + }); + + describe("new workspace creation", () => { + test("creates new workspace when no existing workspace", async () => { + using computeServer = createMockComputeServer(); + + const mockClient = createMockCoderClient({ + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + const result = await initializeCoderWorkspace( + noopLogger, + { + ...baseCoderTestOptions, + template: "test-template", + client: mockClient, + }, + undefined + ); + + expect(result.message).toBe( + 'Workspace "testuser/test-workspace" initialized.' + ); + expect(mockClient.createWorkspace).toHaveBeenCalled(); + expect(mockClient.getTemplateByName).toHaveBeenCalledWith( + "org-123", + "test-template" + ); + }); + + test("throws error when template option is missing", async () => { + const mockClient = createMockCoderClient(); + + await expect( + initializeCoderWorkspace( + noopLogger, + { ...baseCoderTestOptions, client: mockClient }, + undefined + ) + ).rejects.toThrow("Template is required"); + }); + + test("throws error when template not found", async () => { + const mockClient = createMockCoderClient({ + getTemplateByName: mock(() => Promise.resolve(undefined)), + }); + + await expect( + initializeCoderWorkspace( + noopLogger, + { + ...baseCoderTestOptions, + template: "nonexistent-template", + client: mockClient, + }, + undefined + ) + ).rejects.toThrow("not found"); + }); + + test("creates workspace with preset", async () => { + using computeServer = createMockComputeServer(); + + const mockClient = createMockCoderClient({ + getTemplateVersionPresets: mock(() => + Promise.resolve([ + mockCoderPreset({ ID: "preset-abc", Name: "my-preset" }), + mockCoderPreset({ ID: "preset-def", Name: "other-preset" }), + ]) + ), + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + await initializeCoderWorkspace( + noopLogger, + { + ...baseCoderTestOptions, + template: "test-template", + presetName: "my-preset", + client: mockClient, + }, + undefined + ); + + expect(mockClient.createWorkspace).toHaveBeenCalledWith( + "org-123", + expect.objectContaining({ + template_version_preset_id: "preset-abc", + }) + ); + }); + + test("throws error when preset not found", async () => { + const mockClient = createMockCoderClient({ + getTemplateVersionPresets: mock(() => + Promise.resolve([mockCoderPreset({ Name: "other-preset" })]) + ), + }); + + await expect( + initializeCoderWorkspace( + noopLogger, + { + ...baseCoderTestOptions, + template: "test-template", + presetName: "nonexistent-preset", + client: mockClient, + }, + undefined + ) + ).rejects.toThrow("Preset 'nonexistent-preset' not found"); + }); + + test("passes rich parameters to createWorkspace", async () => { + using computeServer = createMockComputeServer(); + + const mockClient = createMockCoderClient({ + getAppHost: mock(() => + Promise.resolve(`localhost:${computeServer.port}`) + ), + }); + + await initializeCoderWorkspace( + noopLogger, + { + ...baseCoderTestOptions, + template: "test-template", + richParameters: [ + { name: "cpu", value: "4" }, + { name: "memory", value: "8GB" }, + ], + client: mockClient, + }, + undefined + ); + + expect(mockClient.createWorkspace).toHaveBeenCalledWith( + "org-123", + expect.objectContaining({ + rich_parameter_values: [ + { name: "cpu", value: "4" }, + { name: "memory", value: "8GB" }, + ], + }) + ); + }); + }); +}); + +describe("getCoderWorkspaceClient", () => { + test("throws when workspace not running", async () => { + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => + Promise.resolve( + mockCoderWorkspace({ + latest_build: mockCoderWorkspaceBuild({ status: "stopped" }), + }) + ) + ), + }); + + await expect( + getCoderWorkspaceClient( + { ...baseCoderTestOptions, client: mockClient }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ) + ).rejects.toThrow("not running"); + }); + + test("throws when agent not found", async () => { + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => + Promise.resolve( + mockCoderWorkspace({ + latest_build: mockCoderWorkspaceBuild({ + resources: [ + { + id: "res-123", + name: "main", + type: "docker_container", + agents: [ + { + id: "agent-other", + name: "other-agent", + status: "connected", + }, + ], + }, + ], + }), + }) + ) + ), + }); + + await expect( + getCoderWorkspaceClient( + { ...baseCoderTestOptions, client: mockClient }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "nonexistent-agent", + } + ) + ).rejects.toThrow("Agent not found"); + }); + + test("throws when agent not connected", async () => { + const mockClient = createMockCoderClient({ + getWorkspace: mock(() => + Promise.resolve( + mockCoderWorkspace({ + latest_build: mockCoderWorkspaceBuild({ + resources: [ + { + id: "res-123", + name: "main", + type: "docker_container", + agents: [ + { + id: "agent-123", + name: "main", + status: "disconnected", + }, + ], + }, + ], + }), + }) + ) + ), + }); + + await expect( + getCoderWorkspaceClient( + { ...baseCoderTestOptions, client: mockClient }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ) + ).rejects.toThrow("not connected"); + }); + + test("connects to running workspace via WebSocket", async () => { + using wsServer = createMockComputeServer(); + + const mockClient = createMockCoderClient({ + getAppHost: mock(() => Promise.resolve(`localhost:${wsServer.port}`)), + }); + + const client = await getCoderWorkspaceClient( + { + ...baseCoderTestOptions, + client: mockClient, + }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + expect(client).toBeDefined(); + }); + + test("sends auth headers in WebSocket connection", async () => { + using wsServer = createMockComputeServer(); + + const mockClient = createMockCoderClient({ + getAppHost: mock(() => Promise.resolve(`localhost:${wsServer.port}`)), + }); + + await getCoderWorkspaceClient( + { + coderUrl: "http://coder.example.com", + sessionToken: "my-secret-token", + computeServerPort: 22137, + client: mockClient, + }, + { + workspaceId: "ws-123", + workspaceName: "test-workspace", + ownerName: "testuser", + agentName: "main", + } + ); + + const headers = wsServer.getReceivedHeaders(); + expect(headers["coder-session-token"]).toBe("my-secret-token"); + expect(headers.cookie).toContain("coder_session_token=my-secret-token"); + }); +}); diff --git a/packages/scout-agent/lib/compute/coder/index.ts b/packages/scout-agent/lib/compute/coder/index.ts new file mode 100644 index 0000000..3386ffa --- /dev/null +++ b/packages/scout-agent/lib/compute/coder/index.ts @@ -0,0 +1,880 @@ +import type { Client } from "@blink-sdk/compute-protocol/client"; +import { WebSocket } from "ws"; +import { z } from "zod"; +import type { Logger } from "../../types"; +import { newComputeClient } from "../common"; + +const WorkspaceStatusSchema = z.enum([ + "pending", + "starting", + "running", + "stopping", + "stopped", + "failed", + "canceling", + "canceled", + "deleting", + "deleted", +]); + +const AgentStatusSchema = z.enum([ + "connecting", + "connected", + "disconnected", + "timeout", +]); + +const WorkspaceAgentSchema = z.object({ + id: z.string(), + name: z.string(), + status: AgentStatusSchema, +}); + +const WorkspaceResourceSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + agents: z.array(WorkspaceAgentSchema).optional(), +}); + +const WorkspaceBuildSchema = z.object({ + id: z.string(), + status: WorkspaceStatusSchema, + resources: z.array(WorkspaceResourceSchema), +}); + +const WorkspaceSchema = z.object({ + id: z.string(), + name: z.string(), + owner_name: z.string(), + template_id: z.string(), + template_name: z.string(), + latest_build: WorkspaceBuildSchema, +}); + +const TemplateSchema = z.object({ + id: z.string(), + name: z.string(), + organization_id: z.string(), + active_version_id: z.string(), +}); + +const PresetSchema = z.object({ + ID: z.string(), + Name: z.string(), + Default: z.boolean(), + Description: z.string().optional(), + Icon: z.string().optional(), +}); + +const UserSchema = z.object({ + id: z.string(), + username: z.string(), +}); + +const OrganizationSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +const AppHostSchema = z.object({ + host: z.string(), +}); + +const CoderApiErrorSchema = z.object({ + message: z.string(), + detail: z.string().optional(), +}); + +// The types below are not inferred from the schemas above due to typescript's isolatedDeclarations feature. + +type WorkspaceStatus = + | "pending" + | "starting" + | "running" + | "stopping" + | "stopped" + | "failed" + | "canceling" + | "canceled" + | "deleting" + | "deleted"; + +type WorkspaceTransition = "start" | "stop" | "delete"; + +type AgentStatus = "connecting" | "connected" | "disconnected" | "timeout"; + +interface WorkspaceAgent { + id: string; + name: string; + status: AgentStatus; +} + +interface WorkspaceResource { + id: string; + name: string; + type: string; + agents?: WorkspaceAgent[]; +} + +interface WorkspaceBuild { + id: string; + status: WorkspaceStatus; + resources: WorkspaceResource[]; +} + +interface Workspace { + id: string; + name: string; + owner_name: string; + template_id: string; + template_name: string; + latest_build: WorkspaceBuild; +} + +interface Template { + id: string; + name: string; + organization_id: string; + active_version_id: string; +} + +interface Preset { + ID: string; + Name: string; + Default: boolean; + Description?: string; + Icon?: string; +} + +interface User { + id: string; + username: string; +} + +interface Organization { + id: string; + name: string; +} + +// Request types (not validated, these are what we send) +interface WorkspaceBuildParameter { + name: string; + value: string; +} + +interface CreateWorkspaceRequest { + template_id?: string; + template_version_id?: string; + name: string; + rich_parameter_values?: WorkspaceBuildParameter[]; + template_version_preset_id?: string; +} + +interface CreateWorkspaceBuildRequest { + transition: WorkspaceTransition; + rich_parameter_values?: WorkspaceBuildParameter[]; +} + +export class CoderApiClient { + private readonly baseUrl: string; + readonly sessionToken: string; + + constructor(baseUrl: string, sessionToken: string) { + // Remove trailing slash if present + this.baseUrl = baseUrl.replace(/\/$/, ""); + this.sessionToken = sessionToken; + } + + private async request( + method: string, + path: string, + schema: z.ZodType, + body?: unknown + ): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + "Coder-Session-Token": this.sessionToken, + Accept: "application/json", + }; + + if (body) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorBody = CoderApiErrorSchema.safeParse(await response.json()); + if (errorBody.success) { + errorMessage = errorBody.data.message || errorMessage; + if (errorBody.data.detail) { + errorMessage += ` - ${errorBody.data.detail}`; + } + } + } catch { + // Ignore JSON parse errors, use default message + } + throw new Error(errorMessage); + } + + // Handle empty responses (204 No Content, etc.) + const contentType = response.headers.get("content-type"); + if (response.status === 204 || !contentType?.includes("application/json")) { + return schema.parse({}); + } + + const json = await response.json(); + return schema.parse(json); + } + + // Get current authenticated user + async getMe(): Promise { + return this.request("GET", "/api/v2/users/me", UserSchema); + } + + // Get workspace by owner and name + async getWorkspaceByOwnerAndName( + owner: string, + name: string + ): Promise { + try { + return await this.request( + "GET", + `/api/v2/users/${encodeURIComponent(owner)}/workspace/${encodeURIComponent(name)}`, + WorkspaceSchema + ); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + return undefined; + } + throw err; + } + } + + // Get workspace by ID + async getWorkspace(workspaceId: string): Promise { + return this.request( + "GET", + `/api/v2/workspaces/${encodeURIComponent(workspaceId)}`, + WorkspaceSchema + ); + } + + // Get template by name in organization + async getTemplateByName( + organizationId: string, + templateName: string + ): Promise