opencode initial run

This commit is contained in:
Shawn Morreau
2025-12-05 16:34:53 -05:00
parent 04c64d4794
commit 06fdedb8c5
6 changed files with 500 additions and 6 deletions
+2
View File
@@ -3,6 +3,7 @@ import { claude } from "./claude.ts";
import { codex } from "./codex.ts";
import { cursor } from "./cursor.ts";
import { gemini } from "./gemini.ts";
import { opencode } from "./opencode.ts";
import type { Agent } from "./shared.ts";
export const agents = {
@@ -10,4 +11,5 @@ export const agents = {
codex,
cursor,
gemini,
opencode,
} satisfies Record<AgentName, Agent>;
+391
View File
@@ -0,0 +1,391 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { log } from "../utils/cli.ts";
import { spawn } from "../utils/subprocess.ts";
import { addInstructions } from "./instructions.ts";
import {
agent,
type ConfigureMcpServersParams,
createAgentEnv,
installFromNpmTarball,
setupProcessAgentEnv,
} from "./shared.ts";
// opencode cli event types inferred from json output format
interface OpenCodeInitEvent {
type: "init";
timestamp?: string;
session_id?: string;
model?: string;
[key: string]: unknown;
}
interface OpenCodeMessageEvent {
type: "message";
timestamp?: string;
role?: "user" | "assistant";
content?: string;
delta?: boolean;
[key: string]: unknown;
}
interface OpenCodeTextEvent {
type: "text";
timestamp?: string;
sessionID?: string;
part?: {
id?: string;
type?: string;
text?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
interface OpenCodeStepStartEvent {
type: "step_start";
timestamp?: string;
sessionID?: string;
part?: {
id?: string;
type?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
interface OpenCodeStepFinishEvent {
type: "step_finish";
timestamp?: string;
sessionID?: string;
part?: {
id?: string;
type?: string;
reason?: string;
cost?: number;
tokens?: {
input?: number;
output?: number;
reasoning?: number;
cache?: {
read?: number;
write?: number;
};
};
[key: string]: unknown;
};
[key: string]: unknown;
}
interface OpenCodeToolUseEvent {
type: "tool_use";
timestamp?: string;
tool_name?: string;
tool_id?: string;
parameters?: unknown;
[key: string]: unknown;
}
interface OpenCodeToolResultEvent {
type: "tool_result";
timestamp?: string;
tool_id?: string;
status?: "success" | "error";
output?: string;
[key: string]: unknown;
}
interface OpenCodeResultEvent {
type: "result";
timestamp?: string;
status?: "success" | "error";
stats?: {
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
duration_ms?: number;
tool_calls?: number;
};
[key: string]: unknown;
}
type OpenCodeEvent =
| OpenCodeInitEvent
| OpenCodeMessageEvent
| OpenCodeTextEvent
| OpenCodeStepStartEvent
| OpenCodeStepFinishEvent
| OpenCodeToolUseEvent
| OpenCodeToolResultEvent
| OpenCodeResultEvent;
let finalOutput = "";
let accumulatedTokens: { input: number; output: number } = { input: 0, output: 0 };
let tokensLogged = false;
const messageHandlers = {
init: (_event: OpenCodeInitEvent) => {
// initialization event - reset state
finalOutput = "";
accumulatedTokens = { input: 0, output: 0 };
tokensLogged = false;
},
message: (event: OpenCodeMessageEvent) => {
// update finalOutput but don't log here - text handler will log
if (event.role === "assistant" && event.content?.trim() && !event.delta) {
const message = event.content.trim();
if (message) {
finalOutput = message;
}
}
},
text: (event: OpenCodeTextEvent) => {
// log from text events only to avoid duplicates
if (event.part?.text?.trim()) {
const message = event.part.text.trim();
log.box(message, { title: "OpenCode" });
finalOutput = message;
}
},
step_start: (_event: OpenCodeStepStartEvent) => {
// step start - no logging needed
},
step_finish: async (event: OpenCodeStepFinishEvent) => {
// accumulate tokens from step_finish events (they come here, not in result)
const eventTokens = event.part?.tokens;
if (eventTokens) {
const inputTokens = eventTokens.input || 0;
const outputTokens = eventTokens.output || 0;
// accumulate tokens (don't log yet - wait for result event)
accumulatedTokens.input += inputTokens;
accumulatedTokens.output += outputTokens;
}
},
tool_use: (event: OpenCodeToolUseEvent) => {
if (event.tool_name) {
log.toolCall({
toolName: event.tool_name,
input: event.parameters || {},
});
}
},
tool_result: (event: OpenCodeToolResultEvent) => {
if (event.status === "error") {
const errorMsg =
typeof event.output === "string" ? event.output : JSON.stringify(event.output);
log.warning(`Tool call failed: ${errorMsg}`);
}
},
result: async (event: OpenCodeResultEvent) => {
if (event.status === "error") {
log.error(`OpenCode CLI failed: ${JSON.stringify(event)}`);
} else {
// log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish)
const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0;
const outputTokens = event.stats?.output_tokens || accumulatedTokens.output || 0;
const totalTokens = event.stats?.total_tokens || inputTokens + outputTokens;
if ((inputTokens > 0 || outputTokens > 0) && !tokensLogged) {
await log.summaryTable([
[
{ data: "Input Tokens", header: true },
{ data: "Output Tokens", header: true },
{ data: "Total Tokens", header: true },
],
[String(inputTokens), String(outputTokens), String(totalTokens)],
]);
tokensLogged = true;
}
}
},
};
export const opencode = agent({
name: "opencode",
install: async () => {
return await installFromNpmTarball({
packageName: "opencode-ai",
version: "latest",
executablePath: "bin/opencode",
installDependencies: true,
});
},
run: async ({ payload, apiKey, mcpServers, cliPath }) => {
// 1. configure home/config directory
const tempHome = process.env.PULLFROG_TEMP_DIR!;
const configDir = join(tempHome, ".config", "opencode");
mkdirSync(configDir, { recursive: true });
// 2. initialize MCP servers and sandbox
configureOpenCodeMcpServers({ mcpServers });
configureOpenCodeSandbox({ sandbox: payload.sandbox ?? false });
if (!apiKey) {
throw new Error("anthropic_api_key is required for opencode agent");
}
// 3. prepare prompt and args
const prompt = addInstructions(payload);
const args = ["run", "--format", "json", "-m", "anthropic/claude-sonnet-4-20250514", prompt];
if (payload.sandbox) {
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
}
// 4. set up environment
const packageDir = join(cliPath, "..", "..");
setupProcessAgentEnv({ ANTHROPIC_API_KEY: apiKey, HOME: tempHome });
const env = createAgentEnv({ ANTHROPIC_API_KEY: apiKey, HOME: tempHome });
// 5. spawn and stream JSON output
let output = "";
const result = await spawn({
cmd: cliPath,
args,
cwd: packageDir,
env,
timeout: 300000,
stdio: ["ignore", "pipe", "pipe"],
onStdout: async (chunk) => {
const text = chunk.toString();
output += text;
// parse each line as JSON (opencode outputs one JSON object per line)
const lines = text.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const event = JSON.parse(trimmed) as OpenCodeEvent;
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
if (handler) {
await handler(event as never);
}
} catch {
// non-JSON lines are ignored
}
}
},
onStderr: (chunk) => {
const trimmed = chunk.trim();
if (trimmed) {
log.warning(trimmed);
}
},
});
// 6. log tokens if they weren't logged yet (fallback if result event wasn't emitted)
if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) {
const totalTokens = accumulatedTokens.input + accumulatedTokens.output;
await log.summaryTable([
[
{ data: "Input Tokens", header: true },
{ data: "Output Tokens", header: true },
{ data: "Total Tokens", header: true },
],
[String(accumulatedTokens.input), String(accumulatedTokens.output), String(totalTokens)],
]);
}
// 7. return result
return {
success: result.exitCode === 0,
output: finalOutput || output,
error: result.exitCode !== 0 ? result.stderr : undefined,
};
},
});
/**
* Configure MCP servers for OpenCode using opencode.json config file.
* OpenCode uses opencode.json with mcp section supporting remote servers with type: "remote" and url.
*/
function configureOpenCodeMcpServers({
mcpServers,
}: {
mcpServers: ConfigureMcpServersParams["mcpServers"];
}): void {
const tempHome = process.env.PULLFROG_TEMP_DIR!;
const configDir = join(tempHome, ".config", "opencode");
mkdirSync(configDir, { recursive: true });
const configPath = join(configDir, "opencode.json");
// convert to opencode's expected format
const opencodeMcpServers: Record<string, { type: "remote"; url: string; enabled?: boolean }> = {};
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
if (serverConfig.type !== "http") {
throw new Error(
`Unsupported MCP server type for OpenCode: ${(serverConfig as any).type || "unknown"}`
);
}
opencodeMcpServers[serverName] = {
type: "remote",
url: serverConfig.url,
enabled: true,
};
}
// read existing config if it exists, or create new one
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
const existingConfig = readFileSync(configPath, "utf-8");
config = JSON.parse(existingConfig);
}
} catch {
// config doesn't exist yet or is invalid, start fresh
}
config.mcp = opencodeMcpServers;
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
log.info(`MCP config written to ${configPath}`);
}
/**
* Configure OpenCode sandbox mode via opencode.json.
* When sandbox is enabled, restricts tools to read-only operations.
*/
function configureOpenCodeSandbox({ sandbox }: { sandbox: boolean }): void {
const tempHome = process.env.PULLFROG_TEMP_DIR!;
const configDir = join(tempHome, ".config", "opencode");
mkdirSync(configDir, { recursive: true });
const configPath = join(configDir, "opencode.json");
// read existing config if it exists, or create new one
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
const existingConfig = readFileSync(configPath, "utf-8");
config = JSON.parse(existingConfig);
}
} catch {
// config doesn't exist yet or is invalid, start fresh
}
if (sandbox) {
// sandbox mode: disable write, bash, and webfetch tools
config.tools = {
write: false,
bash: false,
webfetch: false,
};
} else {
// normal mode: enable all tools (or don't set tools config to use defaults)
config.tools = {
write: true,
bash: true,
webfetch: true,
};
}
// preserve MCP config if it was already set by configureOpenCodeMcpServers
// (this function is called after configureOpenCodeMcpServers, so MCP config should already exist)
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
log.info(`OpenCode config written to ${configPath} (sandbox: ${sandbox})`);
}
+97
View File
@@ -96,6 +96,17 @@ export interface InstallFromGithubParams {
githubInstallationToken?: string;
}
/**
* Parameters for installing from GitHub releases tarball
*/
export interface InstallFromGithubTarballParams {
owner: string;
repo: string;
assetNamePattern: string;
executablePath: string;
githubInstallationToken?: string;
}
/**
* NPM registry response data structure
*/
@@ -318,6 +329,92 @@ export async function installFromGithub({
return cliPath;
}
/**
* Install a CLI tool from a GitHub release tarball
* Downloads the tar.gz from GitHub releases, extracts it, and returns the path to the CLI executable
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromGithubTarball({
owner,
repo,
assetNamePattern,
executablePath,
githubInstallationToken,
}: InstallFromGithubTarballParams): Promise<string> {
log.info(`📦 Installing ${owner}/${repo} from GitHub releases...`);
// determine platform-specific asset name
const os = process.platform === "darwin" ? "darwin" : "linux";
const arch = process.arch === "arm64" ? "arm64" : "x64";
const assetName = assetNamePattern.replace("{os}", os).replace("{arch}", arch);
// fetch release from GitHub API (latest)
const releaseUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
log.info(`Fetching release from ${releaseUrl}...`);
const headers: Record<string, string> = {};
if (githubInstallationToken) {
headers.Authorization = `Bearer ${githubInstallationToken}`;
}
const releaseResponse = await fetchWithRetry(releaseUrl, headers, "Failed to fetch release");
const releaseData = (await releaseResponse.json()) as {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
};
log.info(`Found release: ${releaseData.tag_name}`);
const asset = releaseData.assets.find((a) => a.name === assetName);
if (!asset) {
throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`);
}
const assetUrl = asset.browser_download_url;
log.info(`Downloading asset from ${assetUrl}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR!;
const tarballPath = join(tempDir, assetName);
// download the asset
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
if (!assetResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream(tarballPath);
await pipeline(assetResponse.body, fileStream);
log.info(`Downloaded tarball to ${tarballPath}`);
// extract tar.gz
log.info(`Extracting tarball...`);
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
stdio: "pipe",
encoding: "utf-8",
});
if (extractResult.status !== 0) {
throw new Error(
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
);
}
// find executable in the extracted tarball
const cliPath = join(tempDir, executablePath);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found in extracted tarball at ${cliPath}`);
}
// make the file executable
chmodSync(cliPath, 0o755);
log.info(`${owner}/${repo} installed at ${cliPath}`);
return cliPath;
}
/**
* Install a CLI tool from a curl-based install script
* Downloads the install script, runs it with HOME set to temp directory, and returns the path to the CLI executable
+5
View File
@@ -38,6 +38,11 @@ export const agentsManifest = {
apiKeyNames: ["google_api_key", "gemini_api_key"],
url: "https://ai.google.dev/gemini-api/docs",
},
opencode: {
displayName: "OpenCode",
apiKeyNames: ["anthropic_api_key"],
url: "https://opencode.ai",
},
} as const satisfies Record<string, AgentManifest>;
// agent name type - union of agent slugs
+1 -3
View File
@@ -1,3 +1 @@
Add an empty commit to https://github.com/pullfrog/pullfrog/pull/5
And push it
say hello
+4 -3
View File
@@ -7,6 +7,7 @@ export interface SpawnOptions {
input?: string;
timeout?: number;
cwd?: string;
stdio?: ("pipe" | "ignore" | "inherit")[];
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
}
@@ -22,7 +23,7 @@ export interface SpawnResult {
* Spawn a subprocess with streaming callbacks and buffered results
*/
export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
const { cmd, args, env, input, timeout, cwd, onStdout, onStderr } = options;
const { cmd, args, env, input, timeout, cwd, stdio, onStdout, onStderr } = options;
const startTime = Date.now();
let stdoutBuffer = "";
@@ -35,7 +36,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
PATH: process.env.PATH || "",
HOME: process.env.HOME || "",
},
stdio: ["pipe", "pipe", "pipe"],
stdio: stdio || ["pipe", "pipe", "pipe"],
cwd: cwd || process.cwd(),
});
@@ -106,7 +107,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
});
});
if (input && child.stdin) {
if (input && child.stdin && stdio?.[0] !== "ignore") {
child.stdin.write(input);
child.stdin.end();
}