guest@6admin.io:~$

Migrating OpenCode from v1 to v2: the switch script and plugins that serve both versions

OpenCode moved to v2 and the change is not cosmetic: the config format shifted, the server API and its clients have new contracts and above all v1 plugins no longer run under v2. Moving a file or renaming a config entry is not enough, the code has to be ported. The official migration doc lists the renames, but it leaves out what actually hurts in practice: the local database, the shim of a plugin installed as a local copy and the wish, for a few weeks, to keep a plugin serving both versions. Two jobs, then, which I take in the order I lived them: switch my machine first, port my plugins next.

1. Switching your machine: a script, not a sequence of gestures

A migration that touches a database is done with a backup you have tested, not with an upgrade launched on a prayer. So I wrote a script, opencode-v2-switch.sh, that chains the steps in an order where each can fail cleanly. It runs in --dry-run to show the config edits without breaking anything, refuses to start if a prerequisite is missing, --skip-backup skips the backup and --yes drops the confirmation. Here it is in full:

#!/usr/bin/env bash
# opencode v1 -> v2 switch: fresh db backup (xz max), upgrade --major,
# tui.json v1 plugin cleanup, plugins config -> v2 npm packages, caveman regen.
#
# Usage: opencode-v2-switch.sh [--dry-run] [--skip-backup] [--yes]
#   --dry-run     show the config edits, run nothing destructive
#   --skip-backup skip the fresh db backup (rollback = the previous .xz only)
#   --yes         no confirmation prompt
#
# Rollback after a real run: xz -d the newest opencode-v1-backup-db-pre-v2-*.db.xz
# and copy it over ~/.local/share/opencode/opencode.db, reinstall opencode 1.x.
set -euo pipefail

DRY_RUN=false SKIP_BACKUP=false ASSUME_YES=false
for arg in "$@"; do
	case "$arg" in
	--dry-run) DRY_RUN=true ;;
	--skip-backup) SKIP_BACKUP=true ;;
	--yes) ASSUME_YES=true ;;
	*)
		echo "unknown arg: $arg"
		exit 1
		;;
	esac
done

OC_BIN="${OPENCODE_BIN:-opencode}"
CONFIG_DIR="${HOME}/.config/opencode"
DATA_DIR="${HOME}/.local/share/opencode"
DATE=$(date +%Y%m%d)
DB="$DATA_DIR/opencode.db"
V2_PLUGINS='["@slkiser/opencode-quota", "@log0u7/opencode-mesh", "@log0u7/opencode-memory", "@log0u7/opencode-watt"]'

step() { printf '\n== %s ==\n' "$*"; }
die() {
	printf 'ABORT: %s\n' "$*" >&2
	exit 1
}

step "preflight"
command -v "$OC_BIN" >/dev/null || die "opencode not found"
command -v xz >/dev/null || die "xz not found"
command -v bun >/dev/null || die "bun not found (db backup + jsonc edit)"
command -v jq >/dev/null || die "jq not found"
free_kb=$(df -Pk "$HOME" | awk 'NR==2 {print $4}')
if [ "$free_kb" -lt 8388608 ]; then
	die "less than 8G free on $HOME (need ~5G db temp + ~2G xz)"
fi
echo "disk ok: ${free_kb} kB free"
echo "current opencode: $("$OC_BIN" --version 2>/dev/null | head -1)"

if [ "$ASSUME_YES" = false ] && [ "$DRY_RUN" = false ]; then
	printf 'This migrates %s/opencode.db IN PLACE by OpenCode 2 (rollback = the .xz backup).\nProceed? [y/N] ' "$DATA_DIR"
	read -r reply
	case "$reply" in y | Y) ;; *) die "user declined" ;; esac
fi

if [ "$SKIP_BACKUP" = true ]; then
	step "backup SKIPPED (--skip-backup)"
elif [ "$DRY_RUN" = true ]; then
	step "backup SKIPPED (dry-run)"
else
	step "fresh db backup: VACUUM INTO + integrity/prefix checks + xz -9e"
	TMPDB="$HOME/opencode-v1-backup-db-pre-v2-$DATE.db"
	bun -e "
import { Database } from 'bun:sqlite';
const db = new Database('$DB', { readonly: true });
db.run(\"VACUUM INTO '$TMPDB'\");
db.close();
console.log('vacuum done');
"
	bun -e "
import { Database } from 'bun:sqlite';
const src = new Database('$DB', { readonly: true });
const bak = new Database('$TMPDB', { readonly: true });
console.log('integrity:', bak.query('PRAGMA integrity_check').get().integrity_check);
for (const t of ['event', 'message', 'part', 'session']) {
  const maxBak = bak.query(\`SELECT max(rowid) m FROM \${t}\`).get().m;
  const bakN = bak.query(\`SELECT count(*) n FROM \${t}\`).get().n;
  const srcN = src.query(\`SELECT count(*) n FROM \${t} WHERE rowid <= \${maxBak}\`).get().n;
  if (srcN !== bakN) {
    console.error(\`\${t}: prefix mismatch (src \${srcN} vs bak \${bakN})\`);
    process.exit(1);
  }
  console.log(\`\${t}: prefix OK (\${bakN} rows)\`);
}
src.close();
bak.close();
"
	xz -9e -T0 -c "$TMPDB" >"$HOME/opencode-v1-backup-db-pre-v2-$DATE.db.xz"
	xz -t "$HOME/opencode-v1-backup-db-pre-v2-$DATE.db.xz" || die "xz archive corrupt"
	rm "$TMPDB"
	echo "backup written: $HOME/opencode-v1-backup-db-pre-v2-$DATE.db.xz"
fi

if [ "$DRY_RUN" = true ]; then
	step "upgrade SKIPPED (dry-run)"
else
	step "upgrade: 1.x latest, then 2.0"
	"$OC_BIN" upgrade
	"$OC_BIN" upgrade --major
	"$OC_BIN" --version
fi

step "tui.json: drop the v1 TUI plugin entry (it breaks v2 plugin list)"
if [ -f "$CONFIG_DIR/tui.json" ]; then
	cp "$CONFIG_DIR/tui.json" "$CONFIG_DIR/tui.json.bak-$DATE"
	if [ "$DRY_RUN" = true ]; then
		echo "would run: jq 'del(.plugin)' on tui.json"
		cat "$CONFIG_DIR/tui.json"
	else
		jq 'del(.plugin)' "$CONFIG_DIR/tui.json" >"$CONFIG_DIR/tui.json.new"
		mv "$CONFIG_DIR/tui.json.new" "$CONFIG_DIR/tui.json"
		echo "tui.json cleaned (backup: tui.json.bak-$DATE)"
	fi
else
	echo "no tui.json (nothing to clean)"
fi

step "opencode.json: plugins -> v2 npm packages (comments preserved)"
bun -e "
import { readFileSync, writeFileSync } from 'node:fs';
const path = '$CONFIG_DIR/opencode.json';
const src = readFileSync(path, 'utf8');
const idx = src.search(/\"plugin\":\s*\[/);
if (idx === -1) {
  console.error('plugin block not found in opencode.json');
  process.exit(1);
}
const match = src.slice(idx).match(/^\"plugin\":\s*\[[\s\S]*?\]/);
// keep commented-out entries (suspended plugins) for future re-enabling
const kept = match[0]
  .split('\n')
  .filter((l) => l.trim().startsWith('//'))
  .join('\n');
// closed array + comma, then the kept comments; the original trailing comma
// after the matched block is dropped to avoid a doubled comma.
const rest = src.slice(idx + match[0].length).replace(/^\s*,/, '');
const replacement = '\"plugin\": $V2_PLUGINS,\n' + kept;
if ($DRY_RUN) {
  console.log('--- current block ---');
  console.log(match[0]);
  console.log('--- replacement + following lines ---');
  console.log(replacement + rest.split('\n').slice(0, 2).join('\n'));
} else {
  writeFileSync(path, src.slice(0, idx) + replacement + rest);
  console.log('opencode.json updated (v1 format kept, v2 normalizes it at load)');
}
"

step "caveman: regenerate the shim for the running opencode version"
if command -v caveman >/dev/null; then
	if [ "$DRY_RUN" = true ]; then
		echo "would run: caveman enable opencode"
	else
		caveman enable opencode
	fi
else
	echo "caveman CLI not on PATH - run its installer manually after the switch"
fi

step "post checks"
if [ "$DRY_RUN" = false ]; then
	"$OC_BIN" --version
	"$OC_BIN" auth list || echo "auth empty? fix with: opencode auth login"
	"$OC_BIN" plugin list || true
	"$OC_BIN" mcp list || true
fi
echo
echo "done. next: start the TUI - check theme, sessions v1 (Carry the disk), MCP context7, agents."
echo "rollback: xz -d the newest opencode-v1-backup-db-pre-v2-*.db.xz over opencode.db + reinstall opencode 1.x."

1.1. Preflight and backup

The script starts by checking what it needs, opencode, xz, bun and jq, then that at least 8 GB remain on $HOME, because the backup goes through a temporary copy of the database before compression. The backup itself is not a cp:

bun -e "import { Database } from 'bun:sqlite';
  const db = new Database('\$DB', { readonly: true });
  db.run(\"VACUUM INTO '\$TMPDB'\"); db.close();"

VACUUM INTO produces a clean, consistent database even if OpenCode writes during the copy, which a cp does not guarantee on a live SQLite file. Then comes a PRAGMA integrity_check on the copy, followed by a prefix check: for each table (event, message, part, session), the backup’s row count must match the same rowid prefix in the source. If it diverges, we stop. The whole thing ends in xz -9e and the archive itself is tested with xz -t before the temporary copy is removed. Rollback then fits in one sentence: decompress the most recent .xz over opencode.db and reinstall a 1.x.

1.2. The two-step upgrade

OpenCode does not jump straight to 2.0 from any 1.x. The script therefore goes through the latest 1.x before crossing the major boundary:

opencode upgrade
opencode upgrade --major

Note that the two versions no longer live side by side by default: the v2 installer replaces the binary. If a v1 was placed by a package manager, remove it first, otherwise the two installs step on each other.

1.3. Config: let v2 normalize, clean up what breaks

The good news is that v2 reads the same locations as v1 and normalizes the fields in memory without rewriting your files. The renames are many (autoshare becomes share, permission becomes permissions, plugin becomes plugins, the package-plus-options tuple becomes an object), but you are not forced to rewrite everything by hand: an opencode.json left in v1 shape is accepted, v2 normalizes it at load time. The script takes advantage of that, it only swaps the plugin list for the v2 packages and keeps the plugin key as-is, preserving the commented-out entries along the way, those of suspended plugins you will want to re-enable later.

One point deserves attention, because it silently breaks the v2 plugin list: the TUI plugin entry in tui.json. The v2 terminal client migrates what it can from tui.json into a global cli.json on first start, but a plugin entry left in the old file disturbs the enumeration. The script removes it, backup in hand:

jq 'del(.plugin)' "$CONFIG_DIR/tui.json" > "$CONFIG_DIR/tui.json.new"
mv "$CONFIG_DIR/tui.json.new" "$CONFIG_DIR/tui.json"

1.4. The caveman subtlety

The least obvious case is caveman . Its installer does not reference a remote npm package, it declares a local copy of the plugin in OpenCode’s config. That copy is a shim tied to the format and the version it was generated for. After the switch it therefore points at the old world and makes the v2 plugin list fail. The fix is not to edit it by hand but to regenerate it, letting caveman rewrite its shim for the version now running:

command -v caveman >/dev/null && caveman enable opencode

The general rule holds for any plugin installed as a local copy: what was generated for a given host must be regenerated after a version change, not recycled.

1.5. Exit checks

The script ends with the checks that tell you whether the switch took: version, auth list, plugin list, mcp list. Nothing magic, but that is where you see whether a provider’s authentication needs redoing or a plugin is missing. The rest (the theme, the carried-over v1 sessions, the agents) you verify by opening the TUI.

2. Porting your plugins: one package, two APIs

A v1 plugin’s server side and a v2 plugin’s server side are two different APIs and nothing is translated automatically from one to the other. But you are not forced to maintain two packages: a single module can expose both implementations, with the host picking the right one. That is the pattern I kept for opencode-mesh, opencode-memory and opencode-watt.

2.1. The dual entry point

It all hangs on the entry point. The v2 implementation is built with Plugin.define, the v1 stays a function returning hooks and the default module merges the two: the v2 host calls setup(), the v1 host calls server().

import { MeshV2 } from "./plugin-v2.js";
import { MeshPlugin } from "./plugin.js";

// v1 calls server(), v2 calls setup()
export const pluginModule = {
  ...MeshV2,
  server: MeshPlugin,
} satisfies { id: string; server: typeof MeshPlugin; setup: (typeof MeshV2)["setup"] };

export default pluginModule;

In package.json, both APIs coexist in devDependencies, the old @opencode-ai/plugin and the new @opencode/plugin and the oc-plugin field plus the ./server export tell the v1 host where to find the server entry point:

"exports": {
  ".":        { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
  "./server": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"oc-plugin": ["server"]

The v1 object entry point works on OpenCode 1.19.0 and newer. For older 1.x releases you need separate entry points or package versions and the day the v1 support window closes, you drop the v1 implementation without touching the rest.

2.2. What changes, in the code

The bulk of the work is remapping the context and the hooks. In v1, the import comes from @opencode-ai/plugin, the plugin is an async function that receives input and returns an object of hooks keyed by string:

import type { Hooks, Plugin } from "@opencode-ai/plugin";

export const MeshPlugin: Plugin = async (input, rawOptions) => {
  const hooks: Hooks = {
    tool: { mesh_status: tool({ /* ... */ }) },
    event: async ({ event }) => { /* ... */ },
    "chat.message": async (msg) => { /* ... */ },
    dispose: shutdown,
  };
  return hooks;
};

In v2, the import comes from @opencode/plugin, each plugin carries a stable id that scopes its storage and you register behaviors on the domain that owns them rather than under a key:

import { Plugin } from "@opencode/plugin";

export const MeshV2 = Plugin.define({
  id: "@log0u7/opencode-mesh",
  async setup(ctx) {
    const options = ctx.options;              // former options argument
    await ctx.tool.transform((editor) => {    // former tool map
      editor.add({ name: "mesh_status", input: { /* JSON Schema */ }, async execute(input, context) { /* ... */ } });
    });
    await ctx.session.hook("model.request", (event) => { /* former chat.message */ });
    const controller = new AbortController();
    void (async () => {
      for await (const event of ctx.event.subscribe({ signal: controller.signal })) { /* former event */ }
    })();
    return async () => { controller.abort(); await shutdown(); }; // former dispose
  },
});

The correspondences that come up most:

  • input.client disappears in favor of domain methods, ctx.session, ctx.permission, ctx.tool and directory or project move under ctx.location.
  • the tool map built with the tool() helper becomes a synchronous ctx.tool.transform whose input is described in JSON Schema and which returns structured content. Since the transform must stay synchronous, external data is loaded before registration.
  • the hooks change shape: the callback receives a single mutable event instead of separate input and output. tool.execute.before becomes ctx.tool.hook("execute.before", ...), chat.message becomes ctx.session.hook("prompt", ...), permission.ask becomes ctx.permission.hook("evaluate", ...).
  • the event hook becomes a for await loop over ctx.event.subscribe({ signal }) and v1’s dispose() becomes the cleanup function returned by setup, which abort()s the loop and releases timers, subprocesses and sockets.
  • state is persisted with ctx.storage.get and ctx.storage.set, durable JSON scoped to the plugin.

Two traps the three real implementations added to this map:

  • A bare require does not survive a Node host. A require("node:sqlite") from an ES module is undefined on a Node host; Bun, which hot-loads plugins, masks the problem and the require injected by Vitest hid the failure from the suite. The fix fits in one line, createRequire(import.meta.url) and the real guard is a smoke test that loads the built package under plain Node, now part of the verification pipeline.
  • v2 domains do not speak the REST envelope. v2 in-process domains take flat project-scoped inputs and resolve payloads directly, without the v1 { data, error } envelope; passing a REST-shaped client straight at a domain (the worktree case in opencode-mesh) breaks. Wrap the domain back into the expected shape, or inject a client (options.worktreeClient).

The full signatures are in the plugin porting guide ; the point of the dual-entry pattern is that it does not force a choice right away. The package ships both, machines migrate at their own pace and you remove v1 once nobody calls it anymore.

What I take away

  • A backup you have not tested is not a backup. VACUUM INTO, integrity_check, prefix check, tested archive: each step answers a precise way a copy of live SQLite can lie.
  • Let the host normalize the config. v2 reads the v1 format and normalizes it in memory; you only hand-edit what truly breaks the enumeration, like the plugin entry in tui.json.
  • A local copy is regenerated, not recycled. The caveman shim generated for v1 must be rewritten for v2 by caveman itself, not patched.
  • A plugin can serve both versions from one entry. A module that spreads Plugin.define(...) and adds a v1 server() lets machines switch without coordination and the v1 debt comes off in one line when the time comes.
  • The built package is the only test that counts. Vitest injects a require that masked a broken ES module under Node; the smoke test loading dist/ under plain Node caught it.

Source: Migrate from V1 , V1 to V2 Plugin Migration .