513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
|
|
export interface IOpenCodeClientOptions {
|
|
baseUrl: string;
|
|
username?: string;
|
|
password?: string;
|
|
fetch?: typeof fetch;
|
|
}
|
|
|
|
export interface IOpenCodeMessagePart {
|
|
type: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface IOpenCodePromptBody {
|
|
messageID?: string;
|
|
model?: {
|
|
providerID: string;
|
|
modelID: string;
|
|
};
|
|
agent?: string;
|
|
noReply?: boolean;
|
|
system?: string;
|
|
tools?: Record<string, boolean>;
|
|
parts: IOpenCodeMessagePart[];
|
|
}
|
|
|
|
export interface IOpenCodeCommandBody {
|
|
messageID?: string;
|
|
agent?: string;
|
|
model?: {
|
|
providerID: string;
|
|
modelID: string;
|
|
};
|
|
command: string;
|
|
arguments?: string;
|
|
}
|
|
|
|
export interface IOpenCodeShellBody {
|
|
agent: string;
|
|
model?: {
|
|
providerID: string;
|
|
modelID: string;
|
|
};
|
|
command: string;
|
|
}
|
|
|
|
export interface IOpenCodeEvent {
|
|
type: string;
|
|
id?: string;
|
|
retry?: number;
|
|
data?: unknown;
|
|
raw: string;
|
|
}
|
|
|
|
export interface IRendererOpenCodeEvent {
|
|
type: string;
|
|
id?: string;
|
|
retry?: number;
|
|
data?: unknown;
|
|
}
|
|
|
|
export class OpenCodeHttpError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly status: number,
|
|
public readonly body: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export class OpenCodeServerClient {
|
|
private readonly baseUrl: string;
|
|
private readonly fetchImpl: typeof fetch;
|
|
private readonly authorizationHeader?: string;
|
|
|
|
constructor(options: IOpenCodeClientOptions) {
|
|
this.baseUrl = options.baseUrl.replace(/\/+$/g, '');
|
|
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
if (options.username || options.password) {
|
|
const username = options.username ?? 'opencode';
|
|
const password = options.password ?? '';
|
|
this.authorizationHeader = `Basic ${plugins.Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
}
|
|
}
|
|
|
|
async health<T = unknown>() {
|
|
return this.request<T>('/global/health');
|
|
}
|
|
|
|
async projects<T = unknown>() {
|
|
return this.request<T>('/project');
|
|
}
|
|
|
|
async currentProject<T = unknown>() {
|
|
return this.request<T>('/project/current');
|
|
}
|
|
|
|
async path<T = unknown>() {
|
|
return this.request<T>('/path');
|
|
}
|
|
|
|
async vcs<T = unknown>() {
|
|
return this.request<T>('/vcs');
|
|
}
|
|
|
|
async config<T = unknown>() {
|
|
return this.request<T>('/config');
|
|
}
|
|
|
|
async providers<T = unknown>() {
|
|
return this.request<T>('/provider');
|
|
}
|
|
|
|
async agents<T = unknown>() {
|
|
return this.request<T>('/agent');
|
|
}
|
|
|
|
async commands<T = unknown>() {
|
|
return this.request<T>('/command');
|
|
}
|
|
|
|
async sessions<T = unknown>() {
|
|
return this.request<T>('/session');
|
|
}
|
|
|
|
async createSession<T = unknown>(body: { parentID?: string; title?: string } = {}) {
|
|
return this.request<T>('/session', { method: 'POST', body });
|
|
}
|
|
|
|
async session<T = unknown>(id: string) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
async updateSession<T = unknown>(id: string, body: { title?: string }) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
|
}
|
|
|
|
async deleteSession<T = unknown>(id: string) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
}
|
|
|
|
async sessionStatus<T = unknown>() {
|
|
return this.request<T>('/session/status');
|
|
}
|
|
|
|
async children<T = unknown>(id: string) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/children`);
|
|
}
|
|
|
|
async todo<T = unknown>(id: string) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/todo`);
|
|
}
|
|
|
|
async messages<T = unknown>(id: string, limit?: number) {
|
|
const query = limit ? `?limit=${encodeURIComponent(`${limit}`)}` : '';
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/message${query}`);
|
|
}
|
|
|
|
async message<T = unknown>(id: string, messageID: string) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/message/${encodeURIComponent(messageID)}`);
|
|
}
|
|
|
|
async prompt<T = unknown>(id: string, body: IOpenCodePromptBody) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/message`, { method: 'POST', body });
|
|
}
|
|
|
|
async promptAsync(id: string, body: IOpenCodePromptBody) {
|
|
return this.request<void>(`/session/${encodeURIComponent(id)}/prompt_async`, { method: 'POST', body });
|
|
}
|
|
|
|
async command<T = unknown>(id: string, body: IOpenCodeCommandBody) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/command`, { method: 'POST', body });
|
|
}
|
|
|
|
async shell<T = unknown>(id: string, body: IOpenCodeShellBody) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/shell`, { method: 'POST', body });
|
|
}
|
|
|
|
async abort<T = unknown>(id: string) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/abort`, { method: 'POST' });
|
|
}
|
|
|
|
async diff<T = unknown>(id: string, messageID?: string) {
|
|
const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : '';
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/diff${query}`);
|
|
}
|
|
|
|
async revert<T = unknown>(id: string, body: { messageID: string; partID?: string }) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/revert`, { method: 'POST', body });
|
|
}
|
|
|
|
async unrevert<T = unknown>(id: string) {
|
|
return this.request<T>(`/session/${encodeURIComponent(id)}/unrevert`, { method: 'POST' });
|
|
}
|
|
|
|
async respondToPermission<T = unknown>(
|
|
sessionID: string,
|
|
permissionID: string,
|
|
body: { response: string; remember?: boolean },
|
|
) {
|
|
return this.request<T>(
|
|
`/session/${encodeURIComponent(sessionID)}/permissions/${encodeURIComponent(permissionID)}`,
|
|
{ method: 'POST', body },
|
|
);
|
|
}
|
|
|
|
async findFiles<T = unknown>(query: { query: string; type?: 'file' | 'directory'; directory?: string; limit?: number }) {
|
|
return this.request<T>(`/find/file?${new URLSearchParams(stringifyQuery(query))}`);
|
|
}
|
|
|
|
async findText<T = unknown>(query: { pattern: string }) {
|
|
return this.request<T>(`/find?${new URLSearchParams(stringifyQuery(query))}`);
|
|
}
|
|
|
|
async fileContent<T = unknown>(path: string) {
|
|
return this.request<T>(`/file/content?${new URLSearchParams({ path })}`);
|
|
}
|
|
|
|
async fileStatus<T = unknown>() {
|
|
return this.request<T>('/file/status');
|
|
}
|
|
|
|
async *events(signal?: AbortSignal): AsyncGenerator<IOpenCodeEvent> {
|
|
const response = await this.fetchImpl(`${this.baseUrl}/event`, {
|
|
headers: this.createHeaders(),
|
|
signal,
|
|
});
|
|
if (!response.ok) {
|
|
throw await this.createHttpError(response, '/event');
|
|
}
|
|
if (!response.body) {
|
|
return;
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const parts = buffer.split(/\n\n/);
|
|
buffer = parts.pop() ?? '';
|
|
for (const part of parts) {
|
|
const event = parseServerSentEvent(part);
|
|
if (event) {
|
|
yield event;
|
|
}
|
|
}
|
|
}
|
|
const finalEvent = parseServerSentEvent(buffer);
|
|
if (finalEvent) {
|
|
yield finalEvent;
|
|
}
|
|
}
|
|
|
|
private async request<T>(path: string, options: { method?: string; body?: unknown } = {}) {
|
|
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
method: options.method ?? 'GET',
|
|
headers: this.createHeaders(options.body !== undefined),
|
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
});
|
|
if (!response.ok) {
|
|
throw await this.createHttpError(response, path);
|
|
}
|
|
if (response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
const text = await response.text();
|
|
if (!text) {
|
|
return undefined as T;
|
|
}
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
private createHeaders(hasJsonBody = false) {
|
|
const headers: Record<string, string> = {};
|
|
if (hasJsonBody) {
|
|
headers['content-type'] = 'application/json';
|
|
}
|
|
if (this.authorizationHeader) {
|
|
headers.authorization = this.authorizationHeader;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
private async createHttpError(response: Response, path: string) {
|
|
const body = await response.text();
|
|
return new OpenCodeHttpError(`OpenCode request failed: ${response.status} ${path}`, response.status, body);
|
|
}
|
|
}
|
|
|
|
export const openCodeBridgeToolNames = [
|
|
'bash',
|
|
'read',
|
|
'write',
|
|
'edit',
|
|
'grep',
|
|
'glob',
|
|
'apply_patch',
|
|
] as const;
|
|
|
|
export type TOpenCodeBridgeToolName = typeof openCodeBridgeToolNames[number];
|
|
|
|
export interface IOpenCodeBridgeConfigRenderOptions {
|
|
bridgeUrlEnvName?: string;
|
|
bridgeTokenEnvName?: string;
|
|
}
|
|
|
|
export const renderOpenCodeBridgeConfigContent = () => `${JSON.stringify({
|
|
$schema: 'https://opencode.ai/config.json',
|
|
autoupdate: false,
|
|
snapshot: false,
|
|
permission: {
|
|
lsp: 'deny',
|
|
skill: 'deny',
|
|
},
|
|
}, undefined, 2)}\n`;
|
|
|
|
export const renderOpenCodeBridgeToolFiles = (options: IOpenCodeBridgeConfigRenderOptions = {}) => {
|
|
const files: Record<string, string> = {};
|
|
for (const toolName of openCodeBridgeToolNames) {
|
|
files[`tools/${toolName}.js`] = renderOpenCodeBridgeToolFile(toolName, options);
|
|
}
|
|
return files;
|
|
};
|
|
|
|
export const renderOpenCodeBridgeToolFile = (
|
|
toolName: TOpenCodeBridgeToolName,
|
|
options: IOpenCodeBridgeConfigRenderOptions = {},
|
|
) => {
|
|
const bridgeUrlEnvName = options.bridgeUrlEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_URL';
|
|
const bridgeTokenEnvName = options.bridgeTokenEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_TOKEN';
|
|
const definition = openCodeBridgeToolDefinitions[toolName];
|
|
return `import { tool } from "@opencode-ai/plugin";
|
|
|
|
const bridgeUrlEnvName = ${JSON.stringify(bridgeUrlEnvName)};
|
|
const bridgeTokenEnvName = ${JSON.stringify(bridgeTokenEnvName)};
|
|
|
|
const forwardTool = async (toolName, args, context) => {
|
|
const bridgeUrl = process.env[bridgeUrlEnvName];
|
|
const bridgeToken = process.env[bridgeTokenEnvName];
|
|
if (!bridgeUrl || !bridgeToken) {
|
|
throw new Error("Git.Zone OpenCode tool bridge is not configured.");
|
|
}
|
|
const response = await fetch(new URL("/tool/" + encodeURIComponent(toolName), bridgeUrl), {
|
|
method: "POST",
|
|
headers: {
|
|
"authorization": "Bearer " + bridgeToken,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
args,
|
|
context: {
|
|
sessionID: context.sessionID,
|
|
messageID: context.messageID,
|
|
agent: context.agent,
|
|
directory: context.directory,
|
|
worktree: context.worktree,
|
|
},
|
|
}),
|
|
});
|
|
const responseText = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(responseText || ("Git.Zone OpenCode tool bridge failed with HTTP " + response.status));
|
|
}
|
|
return responseText ? JSON.parse(responseText) : "";
|
|
};
|
|
|
|
export default tool({
|
|
description: ${JSON.stringify(definition.description)},
|
|
args: {
|
|
${definition.args.map((arg) => ` ${arg.name}: ${arg.schema},`).join('\n')}
|
|
},
|
|
async execute(args, context) {
|
|
return forwardTool(${JSON.stringify(toolName)}, args, context);
|
|
},
|
|
});
|
|
`;
|
|
};
|
|
|
|
const openCodeBridgeToolDefinitions: Record<TOpenCodeBridgeToolName, {
|
|
description: string;
|
|
args: Array<{ name: string; schema: string }>;
|
|
}> = {
|
|
bash: {
|
|
description: 'Execute a shell command on the selected remote Git.Zone workspace over SSH. The command runs on the remote host, not on the local proxy workspace. Use workdir for a remote working directory when needed.',
|
|
args: [
|
|
{ name: 'command', schema: 'tool.schema.string().describe("The command to execute on the remote host")' },
|
|
{ name: 'timeout', schema: 'tool.schema.number().optional().describe("Optional timeout in milliseconds")' },
|
|
{ name: 'workdir', schema: 'tool.schema.string().optional().describe("Remote working directory. Defaults to the selected remote project path.")' },
|
|
{ name: 'description', schema: 'tool.schema.string().describe("Clear, concise description of what this command does in 5-10 words")' },
|
|
],
|
|
},
|
|
read: {
|
|
description: 'Read a file or directory from the selected remote Git.Zone workspace. Paths may be remote absolute paths or paths relative to the selected remote project.',
|
|
args: [
|
|
{ name: 'filePath', schema: 'tool.schema.string().describe("Remote file or directory path to read")' },
|
|
{ name: 'offset', schema: 'tool.schema.number().optional().describe("The line number to start reading from (1-indexed)")' },
|
|
{ name: 'limit', schema: 'tool.schema.number().optional().describe("The maximum number of lines to read")' },
|
|
],
|
|
},
|
|
write: {
|
|
description: 'Create or overwrite a file in the selected remote Git.Zone workspace.',
|
|
args: [
|
|
{ name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to write")' },
|
|
{ name: 'content', schema: 'tool.schema.string().describe("The content to write to the remote file")' },
|
|
],
|
|
},
|
|
edit: {
|
|
description: 'Modify an existing remote file using exact string replacement.',
|
|
args: [
|
|
{ name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to modify")' },
|
|
{ name: 'oldString', schema: 'tool.schema.string().describe("The text to replace")' },
|
|
{ name: 'newString', schema: 'tool.schema.string().describe("The replacement text")' },
|
|
{ name: 'replaceAll', schema: 'tool.schema.boolean().optional().describe("Replace all occurrences of oldString")' },
|
|
],
|
|
},
|
|
grep: {
|
|
description: 'Search remote file contents in the selected Git.Zone workspace using ripgrep on the remote host.',
|
|
args: [
|
|
{ name: 'pattern', schema: 'tool.schema.string().describe("The regex pattern to search for in file contents")' },
|
|
{ name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory or file to search. Defaults to the selected remote project path.")' },
|
|
{ name: 'include', schema: 'tool.schema.string().optional().describe("File glob to include, for example *.ts or *.{ts,tsx}")' },
|
|
],
|
|
},
|
|
glob: {
|
|
description: 'Find remote files by glob pattern in the selected Git.Zone workspace using ripgrep on the remote host.',
|
|
args: [
|
|
{ name: 'pattern', schema: 'tool.schema.string().describe("The glob pattern to match remote files against")' },
|
|
{ name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory to search. Defaults to the selected remote project path.")' },
|
|
],
|
|
},
|
|
apply_patch: {
|
|
description: 'Apply a stripped-down file patch to the selected remote Git.Zone workspace. Patch paths are relative to the selected remote project unless absolute remote paths are supplied.',
|
|
args: [
|
|
{ name: 'patchText', schema: 'tool.schema.string().describe("The full patch text that describes all remote file changes to make")' },
|
|
],
|
|
},
|
|
};
|
|
|
|
export const parseServerSentEvent = (raw: string): IOpenCodeEvent | undefined => {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
|
|
const dataLines: string[] = [];
|
|
let type = 'message';
|
|
let id: string | undefined;
|
|
let retry: number | undefined;
|
|
|
|
for (const line of trimmed.split(/\r?\n/)) {
|
|
if (line.startsWith(':')) {
|
|
continue;
|
|
}
|
|
const separator = line.indexOf(':');
|
|
const field = separator === -1 ? line : line.slice(0, separator);
|
|
const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
|
|
if (field === 'event') {
|
|
type = value;
|
|
} else if (field === 'data') {
|
|
dataLines.push(value);
|
|
} else if (field === 'id') {
|
|
id = value;
|
|
} else if (field === 'retry') {
|
|
const retryNumber = Number(value);
|
|
if (Number.isFinite(retryNumber)) {
|
|
retry = retryNumber;
|
|
}
|
|
}
|
|
}
|
|
|
|
const dataText = dataLines.join('\n');
|
|
const data = parseJsonIfPossible(dataText);
|
|
if (type === 'message' && data && typeof data === 'object' && 'type' in data) {
|
|
type = String((data as { type: unknown }).type);
|
|
}
|
|
return { type, id, retry, data, raw };
|
|
};
|
|
|
|
export const sanitizeOpenCodeEventForRenderer = (event: IOpenCodeEvent): IRendererOpenCodeEvent => ({
|
|
type: event.type,
|
|
id: event.id,
|
|
retry: event.retry,
|
|
data: event.data,
|
|
});
|
|
|
|
const parseJsonIfPossible = (value: string) => {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return JSON.parse(value) as unknown;
|
|
} catch {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const stringifyQuery = (query: Record<string, string | number | boolean | undefined>) => {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (value !== undefined) {
|
|
result[key] = String(value);
|
|
}
|
|
}
|
|
return result;
|
|
};
|