| name | writing-opencode-plugins |
| description | Guides development of OpenCode plugins including project structure, testing patterns, and publishing. Use when creating or modifying OpenCode plugins. |
| license | MIT |
| compatibility | opencode |
| metadata | {"audience":"users","workflow":"general"} |
Writing OpenCode Plugins
Project structure
Use the shim pattern to separate development from plugin loading:
plugin-name/
├── src/
│ └── main.ts # Main implementation
├── .opencode/
│ ├── package.json # Plugin dependencies
│ └── plugins/
│ └── shim.ts # Loads plugin from src/
├── test/
│ ├── unit.test.ts # Unit tests
│ └── e2e.test.ts # E2E tests
├── package.json # Root package.json
└── tsconfig.json # TypeScript config
Shim file (.opencode/plugins/*.ts)
The shim file is loaded by OpenCode and re-exports from src:
export { PluginName as PluginNamePlugin } from "../../src/main"
Benefits:
- ✅ Development with TypeScript and type safety
- ✅ Easy unit/integration testing
- ✅ Source code not duplicated in .opencode/
Plugin structure
A plugin exports a function that receives context and returns hooks:
export const MyPlugin: Plugin = async ({ directory, worktree, project, client, $ }) => {
return {
"shell.env": async (input, output) => {
},
}
}
Context parameters
directory - Current working directory
worktree - Git worktree path
project - Project information
client - OpenCode SDK client
$ - Bun shell API
Available hooks
Common hooks:
shell.env - Inject environment variables before shell execution
tool.execute.before - Intercept tool calls before execution
tool.execute.after - Post-process tool results
file.edited - React to file changes
Testing strategy
Unit tests
Test core logic in isolation using bun test:
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
import { coreFunction } from "../src/main"
import { tmpdir } from "os"
import { join } from "path"
import { mkdir, writeFile, rm } from "fs/promises"
describe("coreFunction", () => {
let testDir: string
beforeEach(async () => {
testDir = join(tmpdir(), `test-${Date.now()}`)
await mkdir(testDir, { recursive: true })
})
afterEach(async () => {
await rm(testDir, { recursive: true, force: true })
})
test("handles basic case", async () => {
const result = await coreFunction(testDir)
expect(result).toBeDefined()
})
})
E2E tests
Test full plugin integration with OpenCode CLI:
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
import { $ } from "bun"
describe("E2E", () => {
test("plugin works with OpenCode CLI", async () => {
try {
const result = await $`opencode run 'test command'`.quiet()
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("expected")
} catch (error) {
if (error instanceof Error && error.message.includes("ENOENT")) {
console.log("Skipping: opencode CLI not found")
return
}
throw error
}
}, { timeout: 30_000 })
})
Test fixtures
Committed test data in test/fixtures/:
test/fixtures/
├── basic/ # Basic scenarios
├── advanced/ # Complex scenarios
└── e2e/ # E2E test workspace
Test fixtures can contain safe-to-commit test data, encryption keys, etc.
Dependencies
Root package.json
Contains dev dependencies and publishing info:
{
"name": "@scope/plugin-name",
"version": "0.1.0",
"main": "./src/main.ts",
"type": "module",
"exports": {
".": {
"types": "./src/main.ts",
"import": "./src/main.ts"
}
},
"scripts": {
"test": "bun test",
"test:unit": "bun test test/unit.test.ts",
"test:e2e": "bun test test/e2e.test.ts"
},
"dependencies": {
"@opencode-ai/plugin": "1.2.10"
},
.opencode/package.json
Plugin runtime dependencies only:
{
"name": "plugin-name-runtime",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.2.10"
}
}
OpenCode automatically runs bun install in .opencode/ at startup.
Publishing
Using np (recommended)
bun install
npm login
bunx np --any-branch
Manual publish
npm version patch
npm publish --access public
.npmignore
Exclude development files from npm package:
# Testing
test/
coverage/
# OpenCode plugin (local use)
.opencode/
.opencode.json
# Development notes
notes/
AGENTS.md
# Serena
.serena/
# GitHub CI
.github/
# Build/cache
node_modules/
dist/
out/
*.tsbuildinfo
.cache/
.eslintcache
# Logs
logs/
*.log
# Environment files
.env
.env.*
.env.keys
.env.*.keys
# macOS
.DS_Store
# IDE
.idea/
*.swp
*.swo
# npm
bun.lock
TypeScript configuration
Use strict TypeScript with Bun defaults:
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true
AGENTS.md (Bun instructions)
Include Bun-specific guidance in AGENTS.md:
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun install` instead of `npm install`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
Development workflow
- Edit
src/main.ts - main implementation
- Run tests:
bun test
- Shim in
.opencode/plugins/*.ts auto-reloads on OpenCode restart
Plugin installation
Project-level (development)
Plugin files in .opencode/plugins/ auto-load on OpenCode restart.
Global (all projects)
mkdir -p ~/.config/opencode/plugins
cp .opencode/plugins/*.ts ~/.config/opencode/plugins/
cp .opencode/package.json ~/.config/opencode/package.json
From npm (users)
Add to opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@scope/plugin-name"]
}
OpenCode auto-installs npm packages to ~/.cache/opencode/node_modules/.
Best practices
Extract testable functions
Separate core logic from hooks:
export const Plugin = async () => ({
"shell.env": async (input, output) => {
}
})
async function coreLogic(path: string): Promise<Record<string, string>> {
}
export const Plugin = async () => ({
"shell.env": async (input, output) => {
const result = await coreLogic(input.cwd)
Object.assign(output.env, result)
}
})
Error handling
Use try/catch with meaningful errors:
try {
const result = await riskyOperation()
return result
} catch (error) {
console.error("Operation failed:", error)
throw new Error(`Failed to process: ${error}`)
}
Async hooks
All hooks are async - use async/await:
export const Plugin = async () => ({
"shell.env": async (input, output) => {
const data = await loadData()
output.env.VAR = data.value
}
})
Non-mutating output
Check before setting:
if (!(key in output.env)) {
output.env[key] = value
}
Quiet mode
Suppress console output for clean logs:
someLibrary.configure({ quiet: true })
CI/CD
Example GitHub Actions:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install
- run: bun test
References