Awesome-omni-skill writing-opencode-plugins
Guides development of OpenCode plugins including project structure, testing patterns, and publishing. Use when creating or modifying OpenCode plugins.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/tools/writing-opencode-plugins" ~/.claude/skills/diegosouzapw-awesome-omni-skill-writing-opencode-plugins && rm -rf "$T"
skills/tools/writing-opencode-plugins/SKILL.mdWriting 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) => { // input.cwd - current working directory // output.env - modify shell environment }, } }
Context parameters
- Current working directorydirectory
- Git worktree pathworktree
- Project informationproject
- OpenCode SDK clientclient
- Bun shell API$
Available hooks
Common hooks:
- Inject environment variables before shell executionshell.env
- Intercept tool calls before executiontool.execute.before
- Post-process tool resultstool.execute.after
- React to file changesfile.edited
Testing strategy
Unit tests
Test core logic in isolation using
bun test:
// test/unit.test.ts 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:
// test/e2e.test.ts 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" }, "publishConfig": { "access": "public" } }
.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)
# Install dependencies bun install # Login to npm npm login # Publish with version prompt bunx np --any-branch
Manual publish
# Bump version npm version patch # or minor, or major # Publish to npm 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, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false } }
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
- main implementationsrc/main.ts - Run tests:
bun test - Shim in
auto-reloads on OpenCode restart.opencode/plugins/*.ts
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:
// Bad: Logic inside hook export const Plugin = async () => ({ "shell.env": async (input, output) => { // All logic here... } }) // Good: Extracted function async function coreLogic(path: string): Promise<Record<string, string>> { // Pure logic, easy to test } 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