/** * HTTP transport layer for the Huskies API client. * Provides the low-level `requestJson` helper, the `callMcpTool` function * for MCP JSON-RPC calls, the `resolveWsHost` utility, and the `api` * object exposing all REST endpoints. */ import type { AllTokenUsageResponse, AnthropicModelInfo, CommandOutput, FileEntry, OAuthStatus, SearchResult, TestResultsResponse, TokenCostResponse, WorkItemContent, } from "./types"; /** Base URL prefix for all REST API requests in production. */ export const DEFAULT_API_BASE = "/api"; /** * Resolve the WebSocket host to connect to. * In development, uses the injected port (or 3001); in production, uses the * current page's host so the socket connects to the same origin. */ export function resolveWsHost( isDev: boolean, envPort: string | undefined, locationHost: string, ): string { return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost; } function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { return `${baseUrl}${path}`; } async function requestJson( path: string, options: RequestInit = {}, baseUrl = DEFAULT_API_BASE, ): Promise { const res = await fetch(buildApiUrl(path, baseUrl), { headers: { "Content-Type": "application/json", ...(options.headers ?? {}), }, ...options, }); if (!res.ok) { const text = await res.text(); throw new Error(text || `Request failed (${res.status})`); } return res.json() as Promise; } /** * Invoke an MCP tool via the server's JSON-RPC `/mcp` endpoint. * Returns the first text content block from the tool result, or an empty * string if the result has no content. */ export async function callMcpTool( toolName: string, args: Record, ): Promise { const res = await fetch("/mcp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args }, }), }); const json = await res.json(); if (json.error) { throw new Error(json.error.message); } const text = json.result?.content?.[0]?.text ?? ""; return text; } /** Typed REST and MCP wrappers for all Huskies server endpoints. */ export const api = { getCurrentProject(baseUrl?: string) { return requestJson("/project", {}, baseUrl); }, getKnownProjects(baseUrl?: string) { return requestJson("/projects", {}, baseUrl); }, forgetKnownProject(path: string, baseUrl?: string) { return requestJson( "/projects/forget", { method: "POST", body: JSON.stringify({ path }) }, baseUrl, ); }, openProject(path: string, baseUrl?: string) { return requestJson( "/project", { method: "POST", body: JSON.stringify({ path }) }, baseUrl, ); }, closeProject(baseUrl?: string) { return requestJson("/project", { method: "DELETE" }, baseUrl); }, getModelPreference(baseUrl?: string) { return requestJson("/model", {}, baseUrl); }, setModelPreference(model: string, baseUrl?: string) { return requestJson( "/model", { method: "POST", body: JSON.stringify({ model }) }, baseUrl, ); }, getOllamaModels(baseUrlParam?: string, baseUrl?: string) { const url = new URL( buildApiUrl("/ollama/models", baseUrl), window.location.origin, ); if (baseUrlParam) { url.searchParams.set("base_url", baseUrlParam); } return requestJson(url.pathname + url.search, {}, ""); }, getAnthropicApiKeyExists(baseUrl?: string) { return requestJson("/anthropic/key/exists", {}, baseUrl); }, getAnthropicModels(baseUrl?: string) { return requestJson("/anthropic/models", {}, baseUrl); }, setAnthropicApiKey(api_key: string, baseUrl?: string) { return requestJson( "/anthropic/key", { method: "POST", body: JSON.stringify({ api_key }) }, baseUrl, ); }, readFile(path: string, baseUrl?: string) { return requestJson( "/fs/read", { method: "POST", body: JSON.stringify({ path }) }, baseUrl, ); }, writeFile(path: string, content: string, baseUrl?: string) { return requestJson( "/fs/write", { method: "POST", body: JSON.stringify({ path, content }) }, baseUrl, ); }, listDirectory(path: string, baseUrl?: string) { return requestJson( "/fs/list", { method: "POST", body: JSON.stringify({ path }) }, baseUrl, ); }, listDirectoryAbsolute(path: string, baseUrl?: string) { return requestJson( "/io/fs/list/absolute", { method: "POST", body: JSON.stringify({ path }) }, baseUrl, ); }, createDirectoryAbsolute(path: string, baseUrl?: string) { return requestJson( "/io/fs/create/absolute", { method: "POST", body: JSON.stringify({ path }) }, baseUrl, ); }, getHomeDirectory(baseUrl?: string) { return requestJson("/io/fs/home", {}, baseUrl); }, listProjectFiles(baseUrl?: string) { return requestJson("/io/fs/files", {}, baseUrl); }, searchFiles(query: string, baseUrl?: string) { return requestJson( "/fs/search", { method: "POST", body: JSON.stringify({ query }) }, baseUrl, ); }, execShell(command: string, args: string[], baseUrl?: string) { return requestJson( "/shell/exec", { method: "POST", body: JSON.stringify({ command, args }) }, baseUrl, ); }, cancelChat(baseUrl?: string) { return requestJson("/chat/cancel", { method: "POST" }, baseUrl); }, getWorkItemContent(storyId: string, baseUrl?: string) { return requestJson( `/work-items/${encodeURIComponent(storyId)}`, {}, baseUrl, ); }, getTestResults(storyId: string, baseUrl?: string) { return requestJson( `/work-items/${encodeURIComponent(storyId)}/test-results`, {}, baseUrl, ); }, getTokenCost(storyId: string, baseUrl?: string) { return requestJson( `/work-items/${encodeURIComponent(storyId)}/token-cost`, {}, baseUrl, ); }, getAllTokenUsage(baseUrl?: string) { return requestJson("/token-usage", {}, baseUrl); }, /** Trigger a server rebuild and restart. */ rebuildAndRestart() { return callMcpTool("rebuild_and_restart", {}); }, /** Approve a story in QA, moving it to merge. */ approveQa(storyId: string) { return callMcpTool("approve_qa", { story_id: storyId }); }, /** Reject a story in QA, moving it back to current with notes. */ rejectQa(storyId: string, notes: string) { return callMcpTool("reject_qa", { story_id: storyId, notes }); }, /** Launch the QA app for a story's worktree. */ launchQaApp(storyId: string) { return callMcpTool("launch_qa_app", { story_id: storyId }); }, /** Delete a story from the pipeline, stopping any running agent and removing the worktree. */ deleteStory(storyId: string) { return callMcpTool("delete_story", { story_id: storyId }); }, /** Fetch OAuth status from the server. */ getOAuthStatus() { return requestJson("/oauth/status", {}, ""); }, /** Execute a bot slash command without LLM invocation. Returns markdown response text. */ botCommand(command: string, args: string, baseUrl?: string) { return requestJson<{ response: string }>( "/bot/command", { method: "POST", body: JSON.stringify({ command, args }) }, baseUrl, ); }, };