How to package and publish an OpenCode plugin

By

Package an OpenCode v2 plugin for npm, inspect the tarball, test it in a clean consumer project, and prepare a public release with clear compatibility.

~~~

A plugin that works in your project can still fail after someone installs it. A supporting file might be missing, a relative import might point outside the package, or the entry point might use the wrong plugin API.

We’ll turn the quote tool from the OpenCode plugin tutorial into a small npm package. Then we’ll install the actual tarball in a separate project and check its registration and calculation before preparing a release.

This follows OpenCode’s v2 API and its package publishing documentation. The package is @opencode/plugin; the older @opencode-ai/plugin examples use a different API.

Create a separate package

Use a recent Node.js installation and an OpenCode v2 installation. Check opencode --version before testing it. If you keep v1 too, your v2 executable may be opencode2.

Create a folder:

mkdir opencode-quote-package
cd opencode-quote-package
mkdir src

Save this as package.json:

{
  "name": "@your-npm-user/opencode-quote",
  "version": "1.0.0",
  "type": "module",
  "exports": "./src/index.js",
  "files": ["src"],
  "license": "MIT",
  "scripts": {
    "test": "node --test"
  }
}

@your-npm-user is an explicit placeholder for your own npm scope. Replace it with an account or organization you control before a registry release, and update the later imports and tarball filename to match. The local tarball exercise can use the placeholder without creating a registry package.

The exports field identifies the module OpenCode loads. files limits the source files included in the package; npm also includes standard metadata such as the README and license. We use JavaScript so there is no compilation step or missing dist directory to handle.

Install the SDK:

npm install @opencode/plugin --save-exact

This adds the current SDK version to dependencies. Commit the lockfile in your development repository. For a reusable release, test against the OpenCode release you support rather than guessing that every future SDK will remain compatible.

Keep the implementation inside the package

Our tool calculates a total in integer cents. Save this complete file as src/quote.js:

export function quote({ quantity, unitPriceCents }) {
  if (!Number.isSafeInteger(quantity) || quantity < 1) {
    throw new RangeError('Quantity must be a positive safe integer')
  }
  if (!Number.isSafeInteger(unitPriceCents) || unitPriceCents < 0) {
    throw new RangeError('Unit price must be a non-negative safe integer')
  }
  const totalCents = quantity * unitPriceCents
  if (!Number.isSafeInteger(totalCents)) {
    throw new RangeError('Total exceeds the supported integer range')
  }
  return { quantity, unitPriceCents, totalCents }
}

The function checks the values even though the tool schema describes them too. It also checks the multiplication’s result. This is a teaching calculator, not a billing system: a real shop should look up its own authoritative prices and handle its currency rules.

Register it in src/index.js:

import { Plugin } from '@opencode/plugin'
import { quote } from './quote.js'

export default Plugin.define({
  id: 'tutorial.packaged-quote',
  async setup(ctx) {
    await ctx.tool.transform(editor => {
      editor.namespace({
        name: 'shop',
        description: 'Deterministic shop calculations'
      })
      editor.add({
        name: 'quote',
        description: 'Calculate a quote using integer cents',
        input: {
          type: 'object',
          properties: {
            quantity: { type: 'integer', minimum: 1 },
            unitPriceCents: { type: 'integer', minimum: 0 }
          },
          required: ['quantity', 'unitPriceCents'],
          additionalProperties: false
        },
        options: { namespace: 'shop', codemode: true },
        async execute(input) {
          return { content: JSON.stringify(quote(input)) }
        }
      })
    })
  }
})

The default export is the same definition used by a local v2 plugin. The package doesn’t need a separate npm-specific plugin factory or a v1 hooks object.

Both JavaScript files travel together. The import of ./quote.js stays inside src, so it still resolves after installation.

The transform only declares the tool. It does no network work or charging, and our executor only calculates a result.

Add a useful test and README

Save quote.test.js at the package root:

import test from 'node:test'
import assert from 'node:assert/strict'
import { quote } from './src/quote.js'

test('calculates integer cents and supports a free item', () => {
  assert.equal(quote({ quantity: 3, unitPriceCents: 1250 }).totalCents, 3750)
  assert.equal(quote({ quantity: 2, unitPriceCents: 0 }).totalCents, 0)
})

test('rejects invalid inputs and overflow', () => {
  for (const quantity of [0, -1, 1.5, '3', NaN]) {
    assert.throws(() => quote({ quantity, unitPriceCents: 1250 }))
  }
  assert.throws(() => quote({ quantity: 1, unitPriceCents: -1 }))
  assert.throws(() => quote({ quantity: 2, unitPriceCents: Number.MAX_SAFE_INTEGER }))
})

Run it with npm test. These tests check the calculation without a model call.

Save this small README.md:

# OpenCode quote tutorial

An OpenCode v2 server plugin providing the `shop_quote` tool.
It accepts positive integer quantities and non-negative integer unit prices
in cents, then returns the quantity, unit price, and total.

It performs no purchases, file writes, or network requests.
See the package's LICENSE file for its terms.

Add your supported OpenCode API and installation instructions as the package develops. Include an actual LICENSE file matching the license you chose in package.json. For this example, use the MIT license text with your own copyright notice. A license field alone doesn’t supply the full license text.

Inspect what npm will distribute

From the package directory, run:

npm pack --dry-run

Check that the list contains src/index.js, src/quote.js, package.json, README.md, and LICENSE. The root test file needn’t ship. Look for anything unintended, especially credentials or private fixtures.

The npm pack command creates the local archive. It doesn’t publish the package:

npm pack

With the placeholder name unchanged, this produces your-npm-user-opencode-quote-1.0.0.tgz. Inspect it:

tar -tzf your-npm-user-opencode-quote-1.0.0.tgz
tar -xOf your-npm-user-opencode-quote-1.0.0.tgz package/package.json

Verify that exports points to a file in that archive. Inspect the source contents too; a file allowlist prevents many accidental inclusions but doesn’t detect a secret pasted into an included source file.

Fig 1 · 1/3Test the archive people install
Sources checked

Install the archive in a fresh project

A linked development folder can hide missing package files. Create a sibling consumer and install the archive instead:

cd ..
mkdir quote-consumer
cd quote-consumer
npm init -y
npm install ../opencode-quote-package/your-npm-user-opencode-quote-1.0.0.tgz

Save this as check.mjs:

import assert from 'node:assert/strict'
import plugin from '@your-npm-user/opencode-quote'

let registered
await plugin.setup({
  tool: {
    async transform(edit) {
      edit({
        namespace() {},
        add(definition) { registered = definition }
      })
    }
  }
})

assert.equal(plugin.id, 'tutorial.packaged-quote')
assert.equal(registered.name, 'quote')
assert.equal(registered.options.namespace, 'shop')
const result = await registered.execute({ quantity: 3, unitPriceCents: 1250 })
assert.equal(JSON.parse(result.content).totalCents, 3750)
await assert.rejects(registered.execute({ quantity: -1, unitPriceCents: 1250 }))
console.log('Installed package registers the tool and validates its calculation')

Run node check.mjs. The script imports the installed package and checks its tool adapter using a small stand-in catalog. This exercises the real SDK import, default export, and executor. Discovery by the running OpenCode service needs another check.

For that check, save opencode.json in the consumer:

{
  "$schema": "https://opencode.ai/config.json",
  "plugins": ["./node_modules/@your-npm-user/opencode-quote"]
}

Open OpenCode v2 from this folder. In a connected session, ask:

Use the shop quote tool for 3 items at 1250 cents each.
Don't change any files. Report the total in cents.

Check for the actual shop_quote invocation returning 3750. A model calculating that number itself hasn’t shown that the plugin loaded. If you previously installed the local quote example in this same project, remove that local copy before enabling the package so both don’t register the same tool.

We use a configured local directory because OpenCode’s plugin manager doesn’t accept tarball targets in plugin add. Installing the tarball with npm and loading its installed directory tests the packaged files.

If the consumer fails while the source project passes, inspect the installed files first. Run npm ls @opencode/plugin from the consumer to see which dependency it resolved. Compare the package entry point with the archive listing. A helper accidentally imported from outside the package can work in development and disappear after installation. Keep the failing consumer until you have reproduced and corrected that problem.

Prepare the public release

Choose your real scoped package name and check it before publishing:

npm whoami
npm view @your-npm-user/opencode-quote name

Run these after replacing the placeholder. An existing public package means you need ownership of it or another name. A not-found response doesn’t establish that you control the scope; check the logged-in account and organization permissions too. Treat authentication or network failures as failures to check availability.

Review the license and README, run the tests, and create and inspect a fresh tarball under the final name. The package version identifies a release; use a new version for the next published update.

When you are ready to make it public, publish that checked archive:

npm publish ./your-npm-user-opencode-quote-1.0.0.tgz --access public

The filename must match the archive generated under your final name. This is the registry-changing step. The earlier packing and consumer tests stay local.

npm’s publishing authentication requirements include 2FA for interactive publishing, with separately configured token options. Complete the challenge when npm asks. Keep tokens out of source files, examples, and committed .npmrc files.

For later CI releases, follow npm’s trusted publishing guide to authorize a specific supported workflow through OIDC. That avoids keeping a long-lived publishing token in the repository’s release automation. Check the guide’s current runtime requirements and package settings before setting it up.

After release, verify the installed registry version in a clean consumer and configure its exact package revision through OpenCode’s plugins list. Keep the tarball test when changing the plugin; it catches a different class of mistake from tests that run in your source folder.

Tagged: AI · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about ai: