Official

opencode

OpenCode agent provider: runs opencode serve and talks to it over HTTP and server-sent events.

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/opencode@0.2.0

Permissions in 0.2.0

  • Read files fs.readMediumReads files in the listed places.Read, once, the servers the previous OpenCode provider keptPlaces: its own data folder
  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode server (`opencode serve`, or the binary you choose in Settings), read its catalog from the command line when the server cannot answer, upgrade it (`opencode upgrade`), and ask or tell the npm installation that owns it about a newer versionPrograms: opencodenpm${settings.binaryPath}
  • Network access netMediumConnects to the listed hosts.Talk to the OpenCode server it starts on this computer, at the port it picks for each launch, and to the external server you choose in SettingsHosts: localhost:*${settings.serverUrl}
  • Environment variables envMediumReads the listed environment variables.Expand ~ in a configured binary, and read the OpenCode settings you set in the environment: the binary to run, an external server's address, and the server's user name and passwordVariables: HOMEOPENCODE_PATHOPENCODE_SERVER_URLOPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

Files

cli.test.ts5.8 KB
import { test } from "node:test";
import assert from "node:assert/strict";
import { inventory, parseAgents, parseModels, parseSkills } from "./cli.ts";
import { fakeApi } from "../sdk/testing.ts";
import { testRuntime } from "./testing.test.ts";
import { obj } from "./map.ts";

/// Captured from `opencode models --verbose` on 1.18.29, shortened to the
/// fields the plugin reads.
const MODELS = `opencode/big-pickle
{
  "id": "big-pickle",
  "providerID": "opencode",
  "name": "Big Pickle",
  "limit": {
    "context": 200000,
    "input": 160000
  },
  "release_date": "2025-10-17",
  "variants": {}
}
openai/gpt-5.1-codex
{
  "id": "gpt-5.1-codex",
  "providerID": "openai",
  "name": "GPT-5.1 Codex",
  "limit": {
    "context": 400000
  },
  "release_date": "2026-01-04",
  "variants": {
    "high": {},
    "low": {},
    "medium": {}
  }
}
openrouter/qwen/qwen3-coder
{
  "id": "qwen/qwen3-coder",
  "providerID": "openrouter",
  "name": "Qwen3 Coder",
  "variants": {}
}
`;

test("models are grouped by the provider of their header", () => {
  const catalog = parseModels(MODELS);
  assert.deepEqual(
    catalog.providers.map((provider) => provider.id),
    ["opencode", "openai", "openrouter"],
  );
  const openai = catalog.providers[1];
  assert.ok(openai);
  const raw = openai.models["gpt-5.1-codex"];
  assert.ok(raw);
  const model = obj(raw);
  assert.equal(model["name"], "GPT-5.1 Codex");
  assert.equal(obj(model["limit"])["context"], 400000);
  assert.equal(model["release_date"], "2026-01-04");
  const variants = model["variants"];
  assert.deepEqual(Object.keys(variants && typeof variants === "object" ? variants : {}).sort(), [
    "high",
    "low",
    "medium",
  ]);
  // The CLI has no display name for a provider, so the id stands in.
  assert.equal(openai.name, "openai");
});

test("a model id with a slash survives, and so does its JSON", () => {
  const openrouter = parseModels(MODELS).providers.find((provider) => provider.id === "openrouter");
  assert.deepEqual(Object.keys(openrouter?.models ?? {}), ["qwen/qwen3-coder"]);
});

test("single-line model bodies parse too; empty or unreadable output yields no models", () => {
  const catalog = parseModels('anthropic/claude\n{"id":"claude","providerID":"anthropic","name":"Claude"}\n');
  assert.equal(catalog.providers.length, 1);
  assert.ok(catalog.providers[0]?.models["claude"]);
  assert.deepEqual(parseModels("").providers, []);
  assert.deepEqual(parseModels("opencode/broken\n{not json}\n").providers, []);
});

/// Captured from `opencode agent list` on 1.18.29: a header line, then the
/// agent's permission rules, whose closing bracket sits in column 0.
const AGENTS = `build (primary)
  [
  {
    "permission": "*",
    "action": "allow",
    "pattern": "*"
  }
]
explore (subagent)
  [
  {
    "permission": "read",
    "action": "allow",
    "pattern": "*"
  }
]
code reviewer (subagent)
  []
`;

test("agents come from the header lines only", () => {
  const agents = parseAgents(AGENTS);
  // A name may contain a space, and the rule bodies are not agents.
  assert.deepEqual(
    agents.map((agent) => [agent.name, agent.mode]),
    [
      ["build", "primary"],
      ["explore", "subagent"],
      ["code reviewer", "subagent"],
    ],
  );
  assert.ok(
    agents.every((agent) => !agent.hidden),
    "the command line does not report hidden agents",
  );
});

test("skill entries drop the body, and output cut at a pipe buffer is named as such", () => {
  const skills = parseSkills(
    JSON.stringify([
      {
        name: "review",
        description: "Review a change.",
        location: "/w/.opencode/skills/review/SKILL.md",
        content: "---\nname: review\n---\n",
      },
      {
        name: "customize-opencode",
        description: "Edit opencode configuration.",
        location: "<built-in>",
        content: "...",
      },
    ]),
  );
  assert.deepEqual(skills, [
    { name: "review", description: "Review a change.", location: "/w/.opencode/skills/review/SKILL.md" },
    { name: "customize-opencode", description: "Edit opencode configuration.", location: "<built-in>" },
  ]);
  // Bun flushes exactly one 64 KiB buffer to a pipe: `debug skill` arrives
  // as 65536 bytes of a longer document.
  const cut = `[${JSON.stringify({ name: "x", content: "y".repeat(70_000) })}]`.slice(0, 65_536);
  assert.throws(() => parseSkills(cut), /cut at 65536 bytes/);
  assert.throws(() => parseSkills("[{"), /decoding the output/);
});

test("the inventory runs the commands one at a time, in the workspace, and reports an empty catalog", async () => {
  const runs: [string, string, string | undefined][] = [];
  const api = fakeApi({
    onSpawn: (program, args, options) => {
      runs.push([program, args.join(" "), options.cwd]);
      return undefined;
    },
  });
  const spawn = api.process.spawn;
  api.process.spawn = async (program, args = [], options = {}) => {
    const child = await spawn(program, args, options);
    const peer = api.peers.at(-1);
    assert.ok(peer);
    queueMicrotask(() => {
      peer.stdout.push(args[0] === "models" ? MODELS : AGENTS);
      peer.exit(0);
    });
    return child;
  };
  const { run } = testRuntime(api);
  const found = await run(inventory("opencode", "/w"));
  assert.deepEqual(runs, [
    ["opencode", "models --verbose", "/w"],
    ["opencode", "agent list", "/w"],
  ]);
  assert.equal(found.providers.providers.length, 3);
  assert.equal(found.agents[0]?.name, "build");

  api.process.spawn = async (program, args = [], options = {}) => {
    const child = await spawn(program, args, options);
    const peer = api.peers.at(-1);
    assert.ok(peer);
    queueMicrotask(() => {
      peer.stdout.push("no models here\n");
      peer.exit(0);
    });
    return child;
  };
  await assert.rejects(run(inventory("opencode", "/w")), /listed no models; run `opencode auth login` first/);
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.8 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.