huskies: merge 840
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
): Promise<string> {
|
||||
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<string | null>("/project", {}, baseUrl);
|
||||
},
|
||||
getKnownProjects(baseUrl?: string) {
|
||||
return requestJson<string[]>("/projects", {}, baseUrl);
|
||||
},
|
||||
forgetKnownProject(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/projects/forget",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
openProject(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/project",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
closeProject(baseUrl?: string) {
|
||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||
},
|
||||
getModelPreference(baseUrl?: string) {
|
||||
return requestJson<string | null>("/model", {}, baseUrl);
|
||||
},
|
||||
setModelPreference(model: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/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<string[]>(url.pathname + url.search, {}, "");
|
||||
},
|
||||
getAnthropicApiKeyExists(baseUrl?: string) {
|
||||
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
||||
},
|
||||
getAnthropicModels(baseUrl?: string) {
|
||||
return requestJson<AnthropicModelInfo[]>("/anthropic/models", {}, baseUrl);
|
||||
},
|
||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/anthropic/key",
|
||||
{ method: "POST", body: JSON.stringify({ api_key }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
readFile(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/fs/read",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
writeFile(path: string, content: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/fs/write",
|
||||
{ method: "POST", body: JSON.stringify({ path, content }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
listDirectory(path: string, baseUrl?: string) {
|
||||
return requestJson<FileEntry[]>(
|
||||
"/fs/list",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
listDirectoryAbsolute(path: string, baseUrl?: string) {
|
||||
return requestJson<FileEntry[]>(
|
||||
"/io/fs/list/absolute",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
createDirectoryAbsolute(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/io/fs/create/absolute",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getHomeDirectory(baseUrl?: string) {
|
||||
return requestJson<string>("/io/fs/home", {}, baseUrl);
|
||||
},
|
||||
listProjectFiles(baseUrl?: string) {
|
||||
return requestJson<string[]>("/io/fs/files", {}, baseUrl);
|
||||
},
|
||||
searchFiles(query: string, baseUrl?: string) {
|
||||
return requestJson<SearchResult[]>(
|
||||
"/fs/search",
|
||||
{ method: "POST", body: JSON.stringify({ query }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
execShell(command: string, args: string[], baseUrl?: string) {
|
||||
return requestJson<CommandOutput>(
|
||||
"/shell/exec",
|
||||
{ method: "POST", body: JSON.stringify({ command, args }) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
cancelChat(baseUrl?: string) {
|
||||
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
||||
},
|
||||
getWorkItemContent(storyId: string, baseUrl?: string) {
|
||||
return requestJson<WorkItemContent>(
|
||||
`/work-items/${encodeURIComponent(storyId)}`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getTestResults(storyId: string, baseUrl?: string) {
|
||||
return requestJson<TestResultsResponse | null>(
|
||||
`/work-items/${encodeURIComponent(storyId)}/test-results`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getTokenCost(storyId: string, baseUrl?: string) {
|
||||
return requestJson<TokenCostResponse>(
|
||||
`/work-items/${encodeURIComponent(storyId)}/token-cost`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getAllTokenUsage(baseUrl?: string) {
|
||||
return requestJson<AllTokenUsageResponse>("/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<OAuthStatus>("/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,
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user