@bivy/bivy 0.7.0-staging.93 → 0.7.0-staging.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,12 +3,13 @@
3
3
  // Universal Agent Harness — MCP config rewriting.
4
4
  //
5
5
  // The one piece of MCP governance that is unavoidably per-agent is *where* the
6
- // config lives — but the shape is near-universal. Claude Code, Codex, Cursor,
7
- // Windsurf, and most MCP hosts use the same `{ mcpServers: { name: { command,
8
- // args, env } } }` object (stdio servers) plus optional remote (url) servers.
9
- // This module rewrites that object so every stdio server launches through the
10
- // Bivy MCP proxy instead of directly turning each agent's own MCP config into
11
- // the injection point, with no agent-specific code beyond the file location.
6
+ // config lives — and, for a couple of hosts, the shape. Claude Code, Cursor,
7
+ // Windsurf, and most MCP hosts use `{ mcpServers: { name: { command, args, env
8
+ // } } }` (stdio) plus optional remote (url) servers. OpenCode is the JSON
9
+ // outlier: `{ mcp: { name: { type: "local", command: [bin, ...args],
10
+ // environment } } }` (see opencode.ai/config.json). This module rewrites both
11
+ // shapes so every stdio server launches through the Bivy MCP proxy instead of
12
+ // directly — turning each agent's own MCP config into the injection point.
12
13
  //
13
14
  // Pure functions, no I/O — unit-tested in test/harness-mcp-config.test.ts. The
14
15
  // file-location table for each agent is data (see agentMcpConfigTargets) that
@@ -109,6 +110,88 @@ export function withBivyToolsServer(config, spec, name = "bivy") {
109
110
  return { config, added: false };
110
111
  return { config: { ...config, mcpServers: { ...servers, [name]: spec } }, added: true };
111
112
  }
113
+ /** Convert a universal stdio server spec into OpenCode's local-server shape. */
114
+ export function toOpenCodeLocalServer(spec) {
115
+ const command = [spec.command ?? "", ...(spec.args ?? [])].filter((s, i) => i === 0 || s !== undefined);
116
+ // Drop a leading empty command if somehow absent — callers always pass one.
117
+ const argv = command[0] ? command : command.slice(1);
118
+ const out = { type: "local", command: argv };
119
+ if (spec.env && Object.keys(spec.env).length)
120
+ out.environment = { ...spec.env };
121
+ return out;
122
+ }
123
+ /** True when an OpenCode local server already launches through the Bivy proxy. */
124
+ export function isOpenCodeProxied(spec, launcher) {
125
+ if (!Array.isArray(spec.command) || spec.command.length === 0)
126
+ return false;
127
+ if (spec.command[0] !== launcher.command)
128
+ return false;
129
+ return spec.command.includes(PROXY_MARKER);
130
+ }
131
+ /**
132
+ * Rewrite every local stdio server in `config.mcp` to launch through the proxy:
133
+ *
134
+ * original: { type: "local", command: ["mcp-fs", "--root", "/w"], environment: {...} }
135
+ * rewritten: { type: "local",
136
+ * command: ["bivy", "mcp-proxy", "--bivy-mcp", "--server", "<name>", "--",
137
+ * "mcp-fs", "--root", "/w"],
138
+ * environment: {...} }
139
+ *
140
+ * Remote servers and already-proxied locals are left untouched (reported in
141
+ * `skipped`). Idempotent. Does not mutate the input.
142
+ */
143
+ export function routeOpenCodeThroughProxy(config, launcher) {
144
+ const rewritten = [];
145
+ const skipped = [];
146
+ const servers = config.mcp ?? {};
147
+ const nextServers = {};
148
+ for (const [name, spec] of Object.entries(servers)) {
149
+ if (!spec || typeof spec !== "object") {
150
+ nextServers[name] = spec;
151
+ skipped.push(name);
152
+ continue;
153
+ }
154
+ if (!Array.isArray(spec.command) || spec.command.length === 0 || typeof spec.command[0] !== "string" || !spec.command[0]) {
155
+ // Remote/url server, enabled-only stub, or malformed — can't wrap via stdio proxy.
156
+ nextServers[name] = spec;
157
+ skipped.push(name);
158
+ continue;
159
+ }
160
+ if (isOpenCodeProxied(spec, launcher)) {
161
+ nextServers[name] = spec;
162
+ skipped.push(name);
163
+ continue;
164
+ }
165
+ const prefix = launcher.argsPrefix ?? [];
166
+ const orig = spec.command;
167
+ nextServers[name] = {
168
+ ...spec,
169
+ type: "local",
170
+ command: [launcher.command, ...prefix, PROXY_MARKER, "--server", name, "--", ...orig],
171
+ };
172
+ rewritten.push(name);
173
+ }
174
+ return {
175
+ config: { ...config, mcp: nextServers },
176
+ rewritten,
177
+ skipped,
178
+ };
179
+ }
180
+ /**
181
+ * Insert the Bivy tools server under OpenCode's `mcp.<name>` (default "bivy").
182
+ * Idempotent: an existing entry of that name is left untouched.
183
+ */
184
+ export function withOpenCodeBivyToolsServer(config, spec, name = "bivy") {
185
+ const servers = config.mcp ?? {};
186
+ if (servers[name])
187
+ return { config, added: false };
188
+ return { config: { ...config, mcp: { ...servers, [name]: spec } }, added: true };
189
+ }
190
+ /** Basename check for OpenCode project config files we inject into. */
191
+ export function isOpenCodeConfigFile(filePath) {
192
+ const base = nodePath.basename(filePath).toLowerCase();
193
+ return base === "opencode.json" || base === ".opencode.json" || base === "opencode.jsonc" || base === ".opencode.jsonc";
194
+ }
112
195
  /** JSON MCP-config file candidates for an agent, most-specific (safest) first. */
113
196
  export function agentMcpConfigTargets(agentId, ctx) {
114
197
  const ws = (...parts) => nodePath.join(ctx.workspace, ...parts);
@@ -9,12 +9,13 @@
9
9
  // session-scoped (workspace-local files preferred) so a failure or a concurrent
10
10
  // session can't corrupt config: we snapshot the exact bytes and restore them.
11
11
  //
12
- // Only JSON configs are handled (Claude, Gemini, OpenCode, generic .mcp.json).
13
- // TOML/YAML-config agents are skipped they still run and are governed by the
14
- // FS + network channels. Unit-tested in test/harness-mcp-inject.test.ts.
12
+ // JSON configs (Claude, Gemini, generic .mcp.json) use the universal
13
+ // `mcpServers` shape; OpenCode's project `opencode.json` uses its own `mcp`
14
+ // shape (see routeOpenCodeThroughProxy). TOML/YAML (Codex, Goose) go through
15
+ // the format-specific writers. Unit-tested in test/harness-mcp-inject.test.ts.
15
16
  import fs from "node:fs";
16
17
  import path from "node:path";
17
- import { agentMcpConfigTargets, bivyToolsServerSpec, routeThroughProxy, withBivyToolsServer, } from "./mcp-config.js";
18
+ import { agentMcpConfigTargets, bivyToolsServerSpec, isOpenCodeConfigFile, routeOpenCodeThroughProxy, routeThroughProxy, toOpenCodeLocalServer, withBivyToolsServer, withOpenCodeBivyToolsServer, } from "./mcp-config.js";
18
19
  import { injectTomlMcp, injectYamlMcp, insertTomlServer } from "./mcp-config-formats.js";
19
20
  /** The proxy launcher Bivy injects — `bivy mcp-proxy …`. */
20
21
  export function bivyProxyLauncher(bivyCommand = "bivy") {
@@ -23,7 +24,9 @@ export function bivyProxyLauncher(bivyCommand = "bivy") {
23
24
  /**
24
25
  * Inject the proxy into a single JSON config file. Returns a restore thunk
25
26
  * (a no-op if the file was absent, unreadable, non-JSON, or had no stdio
26
- * servers to route). Never throws.
27
+ * servers to route). OpenCode project configs (`opencode.json`) use the
28
+ * OpenCode `mcp` shape; everything else uses the universal `mcpServers` shape.
29
+ * Never throws.
27
30
  */
28
31
  export function injectJsonMcpConfig(filePath, launcher) {
29
32
  let original;
@@ -40,7 +43,9 @@ export function injectJsonMcpConfig(filePath, launcher) {
40
43
  catch {
41
44
  return { injected: false, restore: () => { } };
42
45
  }
43
- const result = routeThroughProxy(parsed, launcher);
46
+ const result = isOpenCodeConfigFile(filePath)
47
+ ? routeOpenCodeThroughProxy(parsed, launcher)
48
+ : routeThroughProxy(parsed, launcher);
44
49
  if (result.rewritten.length === 0)
45
50
  return { injected: false, restore: () => { } };
46
51
  // Preserve the file's indentation feel by re-serializing with 2 spaces; the
@@ -116,7 +121,9 @@ export function injectMcpConfigFile(filePath, launcher) {
116
121
  * servers a file already has), this CREATES the config when absent so an agent
117
122
  * that ships no MCP config still gets the tool. Handles the most-specific JSON
118
123
  * config (session-local for claude/gemini/opencode/generic) and Codex's TOML
119
- * (`~/.codex/config.toml` — Codex has no project-local option). restore() deletes
124
+ * (`~/.codex/config.toml` — Codex has no project-local option). OpenCode gets
125
+ * its native `{ mcp: { bivy: { type: "local", command: [...] } } }` shape — the
126
+ * universal `mcpServers` key is rejected by OpenCode's schema. restore() deletes
120
127
  * a file it created and rewrites the exact original bytes of one it modified.
121
128
  * Idempotent (a `bivy` server already present is a no-op, so concurrent sessions
122
129
  * sharing a global config don't double up). Best-effort; never throws. Goose YAML
@@ -131,6 +138,7 @@ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
131
138
  return { injected: [], restore: () => { } };
132
139
  const spec = bivyToolsServerSpec({ sessionId: ctx.sessionId, endpoint: ctx.endpoint, bivyCommand });
133
140
  const ext = path.extname(target).toLowerCase();
141
+ const openCode = agentId === "opencode" || isOpenCodeConfigFile(target);
134
142
  const existed = fs.existsSync(target);
135
143
  let original;
136
144
  if (existed) {
@@ -142,7 +150,22 @@ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
142
150
  }
143
151
  }
144
152
  let nextContent;
145
- if (ext === ".json") {
153
+ if (ext === ".json" && openCode) {
154
+ let parsed = {};
155
+ if (original !== undefined) {
156
+ try {
157
+ parsed = JSON.parse(original);
158
+ }
159
+ catch {
160
+ return { injected: [], restore: () => { } };
161
+ }
162
+ }
163
+ const { config, added } = withOpenCodeBivyToolsServer(parsed, toOpenCodeLocalServer(spec));
164
+ if (!added)
165
+ return { injected: [], restore: () => { } };
166
+ nextContent = `${JSON.stringify(config, null, 2)}\n`;
167
+ }
168
+ else if (ext === ".json") {
146
169
  let parsed = {};
147
170
  if (original !== undefined) {
148
171
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.7.0-staging.93",
3
+ "version": "0.7.0-staging.96",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",