342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
import { realpathSync } from 'node:fs';
|
|
import { logger } from '../utils/logger';
|
|
|
|
export interface DecorateRequestInput {
|
|
baseUrl: string;
|
|
path: string;
|
|
directory: string;
|
|
}
|
|
|
|
export interface DecoratedRequest {
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
}
|
|
|
|
export interface OpencodeClientOptions {
|
|
baseUrl: string;
|
|
directory: string;
|
|
fetchImpl?: typeof fetch;
|
|
}
|
|
|
|
export interface OpencodeModelReference {
|
|
providerID: string;
|
|
modelID: string;
|
|
}
|
|
|
|
export interface OpencodeFilePartInput {
|
|
type: 'file';
|
|
mime: string;
|
|
filename?: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface OpencodeCommandInfo {
|
|
name: string;
|
|
description?: string;
|
|
agent?: string;
|
|
model?: string;
|
|
source?: 'command' | 'mcp' | 'skill';
|
|
subtask?: boolean;
|
|
hints: string[];
|
|
template?: unknown;
|
|
}
|
|
|
|
export interface ForkOpencodeSessionInput {
|
|
messageID?: string;
|
|
}
|
|
|
|
export interface SummarizeOpencodeSessionInput {
|
|
providerID: string;
|
|
modelID: string;
|
|
}
|
|
|
|
export interface OpencodeCommandTextPartInput {
|
|
type: 'text';
|
|
text: string;
|
|
synthetic: true;
|
|
}
|
|
|
|
export type OpencodeCommandPartInput =
|
|
| OpencodeCommandTextPartInput
|
|
| OpencodeFilePartInput;
|
|
|
|
export interface ExecuteOpencodeSessionCommandInput {
|
|
command: string;
|
|
arguments: string;
|
|
agent?: string;
|
|
model?: string;
|
|
variant?: string;
|
|
parts?: OpencodeCommandPartInput[];
|
|
}
|
|
|
|
export interface SendOpencodeSessionMessageInput {
|
|
text: string;
|
|
system?: string;
|
|
agent?: string;
|
|
files?: OpencodeFilePartInput[];
|
|
model?: OpencodeModelReference;
|
|
}
|
|
|
|
export interface FindOpencodeFilesOptions {
|
|
limit?: number;
|
|
}
|
|
|
|
export interface UpdateOpencodeSessionInput {
|
|
title?: string;
|
|
}
|
|
|
|
export interface OpencodeSkillInfo {
|
|
name: string;
|
|
description?: string;
|
|
location: string;
|
|
content: string;
|
|
}
|
|
|
|
export interface RevertOpencodeSessionMessageInput {
|
|
messageID: string;
|
|
partID?: string;
|
|
}
|
|
|
|
export type OpencodePermissionReply = 'once' | 'always' | 'reject';
|
|
|
|
type OpencodeTextPartInput = { type: 'text'; text: string };
|
|
type OpencodeMessagePartInput = OpencodeTextPartInput | OpencodeFilePartInput;
|
|
type OpencodeTextPartsPayload = {
|
|
parts: OpencodeMessagePartInput[];
|
|
model?: OpencodeModelReference;
|
|
system?: string;
|
|
agent?: string;
|
|
};
|
|
|
|
function buildTextPartsPayload(input: SendOpencodeSessionMessageInput) {
|
|
const payload: OpencodeTextPartsPayload = {
|
|
parts: [
|
|
{
|
|
type: 'text',
|
|
text: input.text,
|
|
},
|
|
...(input.files ?? []),
|
|
],
|
|
};
|
|
if (input.model) {
|
|
payload.model = input.model;
|
|
}
|
|
if (input.system) {
|
|
payload.system = input.system;
|
|
}
|
|
if (input.agent) {
|
|
payload.agent = input.agent;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function summarizePromptPayload(sessionID: string, payload: OpencodeTextPartsPayload) {
|
|
const fileParts = payload.parts.filter((part): part is OpencodeFilePartInput => part.type === 'file');
|
|
const textParts = payload.parts.filter((part): part is OpencodeTextPartInput => part.type === 'text');
|
|
return {
|
|
sessionID,
|
|
model: payload.model ? `${payload.model.providerID}/${payload.model.modelID}` : null,
|
|
textLength: textParts.reduce((sum, part) => sum + part.text.length, 0),
|
|
partCount: payload.parts.length,
|
|
filePartCount: fileParts.length,
|
|
fileMimes: fileParts.map((part) => part.mime),
|
|
};
|
|
}
|
|
|
|
type ResolveRealPath = (directory: string) => string;
|
|
|
|
export function canonicalizeOpencodeDirectory(
|
|
directory: string,
|
|
resolveRealPath: ResolveRealPath = realpathSync.native,
|
|
): string {
|
|
try {
|
|
return resolveRealPath(directory);
|
|
} catch {
|
|
return directory;
|
|
}
|
|
}
|
|
|
|
export function decorateOpencodeRequest(
|
|
input: DecorateRequestInput,
|
|
canonicalizeDirectory: (directory: string) => string = canonicalizeOpencodeDirectory,
|
|
): DecoratedRequest {
|
|
const url = new URL(input.path, input.baseUrl);
|
|
const directory = canonicalizeDirectory(input.directory);
|
|
const encodedDirectory = encodeURIComponent(directory);
|
|
if (!url.searchParams.has('directory')) {
|
|
url.searchParams.set('directory', directory);
|
|
}
|
|
|
|
return {
|
|
url: url.toString(),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodedDirectory,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
|
if (!response.ok) {
|
|
throw new Error(`Runtime request failed: ${response.status} ${response.statusText}`);
|
|
}
|
|
if (response.status === 204) return undefined as T;
|
|
return await response.json() as T;
|
|
}
|
|
|
|
export function createOpencodeClient(options: OpencodeClientOptions) {
|
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
|
const decorated = decorateOpencodeRequest({
|
|
baseUrl: options.baseUrl,
|
|
path,
|
|
directory: options.directory,
|
|
});
|
|
const requestUrl = new URL(decorated.url);
|
|
logger.info('[opencode-client] Forwarding request to runtime', {
|
|
method: init?.method ?? 'GET',
|
|
pathname: requestUrl.pathname,
|
|
});
|
|
|
|
return await parseJsonResponse<T>(await fetchImpl(decorated.url, {
|
|
...init,
|
|
headers: {
|
|
...decorated.headers,
|
|
...(init?.headers ?? {}),
|
|
},
|
|
}));
|
|
};
|
|
|
|
const findFilesPath = (query: string, options?: FindOpencodeFilesOptions) => {
|
|
const params = new URLSearchParams({ query });
|
|
if (typeof options?.limit === 'number') {
|
|
params.set('limit', String(options.limit));
|
|
}
|
|
return `/find/file?${params.toString()}`;
|
|
};
|
|
|
|
const fileContentPath = (filePath: string) => {
|
|
const params = new URLSearchParams({ path: filePath });
|
|
return `/file/content?${params.toString()}`;
|
|
};
|
|
|
|
const searchTextPath = (pattern: string) => {
|
|
const params = new URLSearchParams({ pattern });
|
|
return `/find?${params.toString()}`;
|
|
};
|
|
|
|
const opencodeCommandMethods = {
|
|
listCommands: (): Promise<OpencodeCommandInfo[]> =>
|
|
request<OpencodeCommandInfo[]>('/command'),
|
|
forkSession: (
|
|
sessionID: string,
|
|
payload: ForkOpencodeSessionInput = {},
|
|
): Promise<unknown> =>
|
|
request<unknown>(`/session/${encodeURIComponent(sessionID)}/fork`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
shareSession: (sessionID: string): Promise<unknown> =>
|
|
request<unknown>(`/session/${encodeURIComponent(sessionID)}/share`, {
|
|
method: 'POST',
|
|
}),
|
|
unshareSession: (sessionID: string): Promise<unknown> =>
|
|
request<unknown>(`/session/${encodeURIComponent(sessionID)}/share`, {
|
|
method: 'DELETE',
|
|
}),
|
|
summarizeSession: (
|
|
sessionID: string,
|
|
payload: SummarizeOpencodeSessionInput,
|
|
): Promise<boolean> =>
|
|
request<boolean>(`/session/${encodeURIComponent(sessionID)}/summarize`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
executeSessionCommand: (
|
|
sessionID: string,
|
|
payload: ExecuteOpencodeSessionCommandInput,
|
|
): Promise<unknown> =>
|
|
request<unknown>(`/session/${encodeURIComponent(sessionID)}/command`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
};
|
|
|
|
return {
|
|
...opencodeCommandMethods,
|
|
listSkills: () => request<OpencodeSkillInfo[]>('/skill'),
|
|
listSessions: () => request<unknown[]>('/session'),
|
|
createSession: (payload: Record<string, unknown>) => request<unknown>('/session', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
updateSession: (sessionID: string, payload: UpdateOpencodeSessionInput) =>
|
|
request<unknown>(`/session/${encodeURIComponent(sessionID)}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
deleteSession: (sessionID: string) =>
|
|
request<void>(`/session/${encodeURIComponent(sessionID)}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
abortSession: (sessionID: string) =>
|
|
request<void>(`/session/${encodeURIComponent(sessionID)}/abort`, {
|
|
method: 'POST',
|
|
}),
|
|
getSessionDiff: (sessionID: string) =>
|
|
request<unknown[]>(`/session/${encodeURIComponent(sessionID)}/diff`),
|
|
revertSessionMessage: (sessionID: string, payload: RevertOpencodeSessionMessageInput) =>
|
|
request<unknown>(`/session/${encodeURIComponent(sessionID)}/revert`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
unrevertSession: (sessionID: string) =>
|
|
request<unknown>(`/session/${encodeURIComponent(sessionID)}/unrevert`, {
|
|
method: 'POST',
|
|
}),
|
|
getSessionTodos: (sessionID: string) =>
|
|
request<unknown[]>(`/session/${encodeURIComponent(sessionID)}/todo`),
|
|
getSessionStatuses: () => request<Record<string, unknown>>('/session/status'),
|
|
listQuestions: () => request<unknown[]>('/question'),
|
|
replyQuestion: (requestID: string, answers: string[][]) =>
|
|
request<void>(`/question/${encodeURIComponent(requestID)}/reply`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ answers }),
|
|
}),
|
|
rejectQuestion: (requestID: string) =>
|
|
request<void>(`/question/${encodeURIComponent(requestID)}/reject`, {
|
|
method: 'POST',
|
|
}),
|
|
getConfig: () => request<Record<string, unknown>>('/config'),
|
|
listPermissions: () => request<unknown[]>('/permission'),
|
|
replyPermission: (requestID: string, reply: OpencodePermissionReply, message?: string) =>
|
|
request<void>(`/permission/${encodeURIComponent(requestID)}/reply`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
reply,
|
|
...(message ? { message } : {}),
|
|
}),
|
|
}),
|
|
getSessionMessages: (sessionID: string) =>
|
|
request<unknown[]>(`/session/${encodeURIComponent(sessionID)}/message`),
|
|
sendSessionMessage: (sessionID: string, payload: SendOpencodeSessionMessageInput) =>
|
|
request<unknown>(`/session/${encodeURIComponent(sessionID)}/message`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(buildTextPartsPayload(payload)),
|
|
}),
|
|
promptSessionAsync: (sessionID: string, payload: SendOpencodeSessionMessageInput) => {
|
|
const requestPayload = buildTextPartsPayload(payload);
|
|
logger.info('[opencode-client] Sending prompt_async payload', summarizePromptPayload(sessionID, requestPayload));
|
|
return request<void>(`/session/${encodeURIComponent(sessionID)}/prompt_async`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestPayload),
|
|
});
|
|
},
|
|
getFileStatuses: () => request<unknown[]>('/file/status'),
|
|
findFiles: (query: string, options?: FindOpencodeFilesOptions) =>
|
|
request<unknown[]>(findFilesPath(query, options)),
|
|
searchText: (pattern: string) => request<unknown[]>(searchTextPath(pattern)),
|
|
getFileContent: (filePath: string) => request<unknown>(fileContentPath(filePath)),
|
|
};
|
|
}
|