UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

161 lines (157 loc) • 6.8 kB
// OpenCode reaches many providers through the AI SDK. The Anthropic and // OpenAI clients honor ANTHROPIC_BASE_URL and OPENAI_BASE_URL; the others // take a baseURL from OpenCode's config, which OPENCODE_CONFIG_CONTENT adds // to for one session. A provider the user already points somewhere else // keeps that address — it becomes the upstream. // // Hooks come as a plugin (named in the same config): before each tool call // it asks `vantage hook opencode`, and throws to block. When a reply is done // it shows waiting alerts as a toast. import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; // Providers whose address comes from the config, with their default. const CONFIG_PROVIDERS = { google: "https://generativelanguage.googleapis.com/v1beta", openrouter: "https://openrouter.ai/api/v1", "github-copilot": "https://api.githubcopilot.com", opencode: "https://opencode.ai/zen/v1", xai: "https://api.x.ai/v1", groq: "https://api.groq.com/openai/v1", deepseek: "https://api.deepseek.com", mistral: "https://api.mistral.ai/v1", }; // The user's resolved OpenCode config, for the addresses they set. Asking // OpenCode itself is the only way to see every file it merges. function userConfig(command, env) { try { const r = spawnSync(command, ["debug", "config"], { env, encoding: "utf8", timeout: 10_000, windowsHide: true, shell: process.platform === "win32" }); return r.status === 0 ? JSON.parse(r.stdout) : {}; } catch { return {}; } } const routeCache = new WeakMap(); function opencodeRoutes(env) { const cached = routeCache.get(env); if (cached) return cached; const cfg = env.VANTAGE_OPENCODE_SKIP_CONFIG ? {} : userConfig(env.VANTAGE_AGENT_PATH || "opencode", env); const upstream = (fallback, own) => env.VANTAGE_UPSTREAM ?? own ?? fallback; const routes = [ { prefix: "/anthropic", upstream: upstream("https://api.anthropic.com/v1", env.ANTHROPIC_BASE_URL) }, { prefix: "/openai", upstream: upstream("https://api.openai.com/v1", env.OPENAI_BASE_URL) }, ]; for (const [id, url] of Object.entries(CONFIG_PROVIDERS)) { routes.push({ prefix: `/p/${id}`, upstream: upstream(url, cfg.provider?.[id]?.options?.baseURL) }); } routeCache.set(env, routes); return routes; } // The plugin, written for the session with the hook command filled in. It // runs inside OpenCode (Bun), so it only uses what Node and Bun share. export function opencodePlugin(hook, events) { return `// Written by Vantage for one OpenCode session (see https://github.com/Jovan158/vantage.ai). import { spawn } from "node:child_process"; const HOOK = ${JSON.stringify(hook)}; function vantage(payload) { return new Promise((resolve) => { let out = ""; const child = spawn(HOOK.command, HOOK.args, { stdio: ["pipe", "pipe", "ignore"], windowsHide: true }); child.stdout.on("data", (d) => (out += d)); child.on("error", () => resolve({})); child.on("close", () => { try { resolve(out ? JSON.parse(out) : {}); } catch { resolve({}); } }); child.stdin.end(JSON.stringify(payload)); }); } export const Vantage = async ({ directory, client }) => { const toast = (message) => client?.tui?.showToast?.({ body: { title: "Vantage", message, variant: "warning", duration: 10000 } })?.catch?.(() => {}); return {${events.tool ? ` "tool.execute.before": async (input, output) => { const out = await vantage({ hook_event_name: "PreToolUse", tool_name: input.tool, tool_input: output.args, cwd: directory }); const d = out.hookSpecificOutput; if (out.systemMessage) toast(out.systemMessage); if (d && (d.permissionDecision === "deny" || d.permissionDecision === "ask")) { throw new Error(d.permissionDecisionReason || "Blocked by Vantage"); } },` : ""}${events.stop ? ` event: async ({ event }) => { if (event?.type !== "session.idle") return; const out = await vantage({ hook_event_name: "Stop", cwd: directory }); if (out.systemMessage) toast(out.systemMessage); },` : ""} }; }; `; } // Deep merge for the session's config over a user's own OPENCODE_CONFIG_CONTENT; // lists (plugins, instructions) are joined. function merge(a, b) { if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b]; if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { const out = { ...a }; for (const [k, v] of Object.entries(b)) out[k] = k in out ? merge(out[k], v) : v; return out; } return b; } export const opencode = { key: "opencode", id: "opencode", name: "OpenCode", short: "OpenCode", command: "opencode", install: "npm install -g opencode-ai", // A plugin can stop a tool call but not ask about it (see hooks.ts). capabilities: { enforce: "deny-only", chatAlerts: true }, routes: opencodeRoutes, prepare(ctx) { const env = { ANTHROPIC_BASE_URL: `${ctx.proxyUrl}/anthropic`, OPENAI_BASE_URL: `${ctx.proxyUrl}/openai`, }; const config = { provider: Object.fromEntries(Object.keys(CONFIG_PROVIDERS).map((id) => [id, { options: { baseURL: `${ctx.proxyUrl}/p/${id}` } }])), }; const notes = []; if (ctx.enforce || ctx.chatAlerts) { const file = path.join(ctx.sessionDir, "vantage-opencode.js"); fs.mkdirSync(ctx.sessionDir, { recursive: true }); fs.writeFileSync(file, opencodePlugin(ctx.hook, { tool: ctx.enforce, stop: ctx.chatAlerts })); config.plugin = [pathToFileURL(file).href]; } if (ctx.enforce) notes.push("OpenCode plugins can block but not ask: actions a rule says to ask about are blocked, and OpenCode is told to leave them to you"); if (ctx.memory) config.instructions = [ctx.memoryFile]; let own = {}; try { own = ctx.env.OPENCODE_CONFIG_CONTENT ? JSON.parse(ctx.env.OPENCODE_CONFIG_CONTENT) : {}; } catch { own = {}; } env.OPENCODE_CONFIG_CONTENT = JSON.stringify(merge(own, config)); return { args: [], env, notes, memory: Boolean(ctx.memory) }; }, // `run` answers once and exits; serve, web and acp have no TUI either. isInteractive: (args) => { const first = args.find((a) => !a.startsWith("-")); return !["run", "serve", "web", "acp", "debug", "auth", "models", "stats", "export", "import", "upgrade", "mcp", "github", "agent"].includes(first ?? ""); }, };