|
1 | 1 | import { |
2 | | - AgentProviderAdapter, |
3 | | - AgentProviderNotImplementedError, |
| 2 | + AgentProviderConfigError, |
| 3 | + AgentProviderRequestError, |
4 | 4 | } from "@profullstack/sh1pt-agent-provider-shared"; |
| 5 | +import type { AgentProviderAdapter } from "@profullstack/sh1pt-agent-provider-shared"; |
| 6 | +import { execFile } from "node:child_process"; |
| 7 | +import { promisify } from "node:util"; |
5 | 8 |
|
6 | | -export const opencodeProvider: AgentProviderAdapter = { |
| 9 | +const execFileAsync = promisify(execFile); |
| 10 | + |
| 11 | +export interface CommandResult { |
| 12 | + stdout: string; |
| 13 | + stderr: string; |
| 14 | + exitCode: number; |
| 15 | +} |
| 16 | + |
| 17 | +export interface CommandRunnerOptions { |
| 18 | + cwd?: string; |
| 19 | + env: Record<string, string | undefined>; |
| 20 | + timeoutMs: number; |
| 21 | +} |
| 22 | + |
| 23 | +export type CommandRunner = ( |
| 24 | + command: string, |
| 25 | + args: string[], |
| 26 | + options: CommandRunnerOptions, |
| 27 | +) => Promise<CommandResult>; |
| 28 | + |
| 29 | +export interface OpencodeProviderOptions { |
| 30 | + env?: Record<string, string | undefined>; |
| 31 | + runner?: CommandRunner; |
| 32 | +} |
| 33 | + |
| 34 | +interface OpencodeConfig { |
| 35 | + bin: string; |
| 36 | + model?: string; |
| 37 | + agent?: string; |
| 38 | + attach?: string; |
| 39 | + dir?: string; |
| 40 | + modelsProvider?: string; |
| 41 | + timeoutMs: number; |
| 42 | +} |
| 43 | + |
| 44 | +export function createOpencodeProvider(options: OpencodeProviderOptions = {}): AgentProviderAdapter { |
| 45 | + const env = options.env ?? process.env; |
| 46 | + const runner = options.runner ?? runCommand; |
| 47 | + |
| 48 | + return { |
7 | 49 | id: "opencode", |
8 | 50 | displayName: "OpenCode", |
9 | 51 | capabilities: { chat: true }, |
10 | 52 |
|
11 | 53 | getRequiredEnv() { |
12 | | - return []; |
| 54 | + return [ |
| 55 | + { key: "OPENCODE_BIN", required: false }, |
| 56 | + { key: "OPENCODE_MODEL", required: false }, |
| 57 | + { key: "OPENCODE_AGENT", required: false }, |
| 58 | + { key: "OPENCODE_ATTACH", required: false }, |
| 59 | + { key: "OPENCODE_DIR", required: false }, |
| 60 | + { key: "OPENCODE_MODELS_PROVIDER", required: false }, |
| 61 | + { key: "OPENCODE_TIMEOUT_MS", required: false }, |
| 62 | + ]; |
13 | 63 | }, |
14 | 64 |
|
15 | | - validateEnv() { |
16 | | - // no-op for now |
| 65 | + validateEnv(candidateEnv) { |
| 66 | + resolveConfig(candidateEnv); |
17 | 67 | }, |
18 | 68 |
|
19 | 69 | async listModels() { |
20 | | - throw new AgentProviderNotImplementedError("opencode.listModels"); |
| 70 | + const config = resolveConfig(env); |
| 71 | + const args = ["models"]; |
| 72 | + if (config.modelsProvider) args.push(config.modelsProvider); |
| 73 | + const result = await runOpencode(config, runner, args, env); |
| 74 | + return parseModels(result.stdout); |
21 | 75 | }, |
22 | 76 |
|
23 | | - async chat() { |
24 | | - throw new AgentProviderNotImplementedError("opencode.chat"); |
| 77 | + async chat(req) { |
| 78 | + const config = resolveConfig(env); |
| 79 | + const prompt = renderPrompt(req.messages); |
| 80 | + if (!prompt) { |
| 81 | + throw new AgentProviderConfigError("opencode.chat requires at least one message with content"); |
| 82 | + } |
| 83 | + |
| 84 | + const args = ["run"]; |
| 85 | + if (config.model) args.push("--model", config.model); |
| 86 | + if (config.agent) args.push("--agent", config.agent); |
| 87 | + if (config.attach) args.push("--attach", config.attach); |
| 88 | + if (config.dir) args.push("--dir", config.dir); |
| 89 | + args.push(prompt); |
| 90 | + |
| 91 | + const result = await runOpencode(config, runner, args, env); |
| 92 | + const content = result.stdout.trim(); |
| 93 | + if (!content) throw new AgentProviderRequestError("OpenCode CLI returned an empty response"); |
| 94 | + return { content }; |
25 | 95 | }, |
26 | 96 |
|
27 | 97 | async healthcheck() { |
28 | | - throw new AgentProviderNotImplementedError("opencode.healthcheck"); |
| 98 | + const config = resolveConfig(env); |
| 99 | + const result = await runOpencode(config, runner, ["--version"], env); |
| 100 | + return { ok: true, message: result.stdout.trim() || "opencode available" }; |
29 | 101 | }, |
30 | 102 | }; |
| 103 | +} |
| 104 | + |
| 105 | +export const opencodeProvider = createOpencodeProvider(); |
| 106 | + |
| 107 | +function resolveConfig(env: Record<string, string | undefined>): OpencodeConfig { |
| 108 | + const timeoutMs = parseTimeout(env.OPENCODE_TIMEOUT_MS); |
| 109 | + return { |
| 110 | + bin: env.OPENCODE_BIN?.trim() || "opencode", |
| 111 | + model: nonEmpty(env.OPENCODE_MODEL), |
| 112 | + agent: nonEmpty(env.OPENCODE_AGENT), |
| 113 | + attach: nonEmpty(env.OPENCODE_ATTACH), |
| 114 | + dir: nonEmpty(env.OPENCODE_DIR), |
| 115 | + modelsProvider: nonEmpty(env.OPENCODE_MODELS_PROVIDER), |
| 116 | + timeoutMs, |
| 117 | + }; |
| 118 | +} |
| 119 | + |
| 120 | +function parseTimeout(value: string | undefined): number { |
| 121 | + if (!value) return 120_000; |
| 122 | + const parsed = Number.parseInt(value, 10); |
| 123 | + if (!Number.isFinite(parsed) || parsed <= 0) { |
| 124 | + throw new AgentProviderConfigError("OPENCODE_TIMEOUT_MS must be a positive integer"); |
| 125 | + } |
| 126 | + return parsed; |
| 127 | +} |
| 128 | + |
| 129 | +function nonEmpty(value: string | undefined): string | undefined { |
| 130 | + const trimmed = value?.trim(); |
| 131 | + return trimmed ? trimmed : undefined; |
| 132 | +} |
| 133 | + |
| 134 | +function renderPrompt(messages: { role: string; content: string }[]): string { |
| 135 | + return messages |
| 136 | + .map((message) => { |
| 137 | + const content = message.content.trim(); |
| 138 | + if (!content) return ""; |
| 139 | + return `${message.role.toUpperCase()}:\n${content}`; |
| 140 | + }) |
| 141 | + .filter(Boolean) |
| 142 | + .join("\n\n"); |
| 143 | +} |
| 144 | + |
| 145 | +async function runOpencode( |
| 146 | + config: OpencodeConfig, |
| 147 | + runner: CommandRunner, |
| 148 | + args: string[], |
| 149 | + env: Record<string, string | undefined>, |
| 150 | +): Promise<CommandResult> { |
| 151 | + try { |
| 152 | + const result = await runner(config.bin, args, { |
| 153 | + cwd: config.dir, |
| 154 | + env: { ...process.env, ...env }, |
| 155 | + timeoutMs: config.timeoutMs, |
| 156 | + }); |
| 157 | + if (result.exitCode !== 0) { |
| 158 | + const detail = formatFailure(result, env); |
| 159 | + throw new AgentProviderRequestError(`OpenCode CLI exited ${result.exitCode}${detail}`); |
| 160 | + } |
| 161 | + return result; |
| 162 | + } catch (error) { |
| 163 | + if (error instanceof AgentProviderRequestError) throw error; |
| 164 | + const result = commandErrorResult(error); |
| 165 | + const detail = formatFailure(result, env); |
| 166 | + throw new AgentProviderRequestError(`OpenCode CLI failed${detail}`); |
| 167 | + } |
| 168 | +} |
| 169 | + |
| 170 | +async function runCommand( |
| 171 | + command: string, |
| 172 | + args: string[], |
| 173 | + options: CommandRunnerOptions, |
| 174 | +): Promise<CommandResult> { |
| 175 | + const result = await execFileAsync(resolveExecutable(command), args, { |
| 176 | + cwd: options.cwd, |
| 177 | + env: options.env, |
| 178 | + timeout: options.timeoutMs, |
| 179 | + maxBuffer: 1024 * 1024, |
| 180 | + windowsHide: true, |
| 181 | + }); |
| 182 | + return { |
| 183 | + stdout: String(result.stdout ?? ""), |
| 184 | + stderr: String(result.stderr ?? ""), |
| 185 | + exitCode: 0, |
| 186 | + }; |
| 187 | +} |
| 188 | + |
| 189 | +function resolveExecutable(command: string): string { |
| 190 | + if (process.platform !== "win32") return command; |
| 191 | + if (/[\\/]/.test(command) || /\.(?:bat|cmd|exe)$/i.test(command)) return command; |
| 192 | + return `${command}.cmd`; |
| 193 | +} |
| 194 | + |
| 195 | +function commandErrorResult(error: unknown): CommandResult { |
| 196 | + const candidate = error as { stdout?: unknown; stderr?: unknown; code?: unknown }; |
| 197 | + return { |
| 198 | + stdout: String(candidate.stdout ?? ""), |
| 199 | + stderr: String(candidate.stderr ?? ""), |
| 200 | + exitCode: typeof candidate.code === "number" ? candidate.code : 1, |
| 201 | + }; |
| 202 | +} |
| 203 | + |
| 204 | +function formatFailure(result: CommandResult, env: Record<string, string | undefined>): string { |
| 205 | + const text = sanitizeCliOutput([result.stderr, result.stdout].filter(Boolean).join("\n"), env); |
| 206 | + return text ? `: ${text}` : ""; |
| 207 | +} |
| 208 | + |
| 209 | +function sanitizeCliOutput(text: string, env: Record<string, string | undefined>): string { |
| 210 | + let sanitized = text.replace(/(Bearer\s+)[^\s]+/gi, "$1[redacted]"); |
| 211 | + for (const key of ["OPENCODE_SERVER_PASSWORD", "OPENCODE_API_KEY"]) { |
| 212 | + const value = env[key]; |
| 213 | + if (value && value.length >= 4) { |
| 214 | + sanitized = sanitized.split(value).join("[redacted]"); |
| 215 | + } |
| 216 | + } |
| 217 | + return sanitized.trim().slice(0, 1000); |
| 218 | +} |
| 219 | + |
| 220 | +function parseModels(stdout: string): string[] { |
| 221 | + const models = new Set<string>(); |
| 222 | + for (const line of stdout.split(/\r?\n/)) { |
| 223 | + const matches = line.matchAll(/\b[a-z0-9][a-z0-9_.-]*\/[a-zA-Z0-9][a-zA-Z0-9_.:/+-]*/g); |
| 224 | + for (const match of matches) models.add(match[0]); |
| 225 | + } |
| 226 | + return [...models].sort(); |
| 227 | +} |
0 commit comments