/** * 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 { rpcCall } from "../rpc"; import type { OkResult, OpenProjectResult, SetAnthropicApiKeyParams, SetModelPreferenceParams, } from "../rpcContract"; 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 rpcCall("project.current"); }, getKnownProjects(_baseUrl?: string) { return rpcCall("project.known"); }, async forgetKnownProject(path: string, _baseUrl?: string) { const r = await rpcCall("project.forget", { path }); return r.ok; }, async openProject(path: string, _baseUrl?: string) { const r = await rpcCall("project.open", { path }); return r.path; }, async closeProject(_baseUrl?: string) { const r = await rpcCall("project.close"); return r.ok; }, getModelPreference(_baseUrl?: string) { return rpcCall("model.get_preference"); }, async setModelPreference(model: string, _baseUrl?: string) { const params: SetModelPreferenceParams = { model }; const r = await rpcCall("model.set_preference", params); return r.ok; }, getOllamaModels(baseUrlParam?: string, _baseUrl?: string) { return rpcCall( "ollama.list_models", baseUrlParam ? { base_url: baseUrlParam } : {}, ); }, getAnthropicApiKeyExists(_baseUrl?: string) { return rpcCall("anthropic.key_exists"); }, getAnthropicModels(_baseUrl?: string) { return rpcCall("anthropic.list_models"); }, async setAnthropicApiKey(api_key: string, _baseUrl?: string) { const params: SetAnthropicApiKeyParams = { api_key }; const r = await rpcCall("anthropic.set_api_key", params); return r.ok; }, 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 rpcCall("io.home_directory"); }, listProjectFiles(_baseUrl?: string) { return rpcCall("io.list_project_files"); }, 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, ); }, async cancelChat(_baseUrl?: string) { const r = await rpcCall("chat.cancel"); return r.ok; }, getWorkItemContent(storyId: string, _baseUrl?: string) { return rpcCall("work_items.get", { story_id: storyId }); }, getTestResults(storyId: string, _baseUrl?: string) { return rpcCall("work_items.test_results", { story_id: storyId, }); }, getTokenCost(storyId: string, _baseUrl?: string) { return rpcCall("work_items.token_cost", { story_id: storyId, }); }, getAllTokenUsage(_baseUrl?: string) { return rpcCall("token_usage.all"); }, /** 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 rpcCall("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, ); }, };