Skip to content
Back to skills

Writing Opencode Plugins

ASecurity

Guides development of OpenCode plugins including project structure, testing patterns, and publishing. Use when creating or modifying OpenCode plugins.

  • 57 stars
  • 0 votes
  • 0 copies
  • 6 views
  • Added October 17, 2026
testingtypescriptgoshellbashreactnodetestinggitapici/cd

Works with

  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 17, 2026

npx -y skills add diegosouzapw/awesome-omni-skill --skill writing-opencode-plugins --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Writing Opencode Plugins?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Writing Opencode Plugins
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/diegosouzapw-writing-opencode-plugins/badge)](https://www.skillsdirectory.com/skills/diegosouzapw-writing-opencode-plugins)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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:

```ts
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:

```ts
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

- `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`:

```ts
// 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:

```ts
// 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:

```json
{
  "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:

```json
{
  "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)

```bash
# Install dependencies
bun install

# Login to npm
npm login

# Publish with version prompt
bunx np --any-branch
```

### Manual publish

```bash
# 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:

```json
{
  "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:

```markdown
---
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

1. Edit `src/main.ts` - main implementation
2. Run tests: `bun test`
3. 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)

```bash
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`:

```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:

```ts
// 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:

```ts
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:

```ts
export const Plugin = async () => ({
  "shell.env": async (input, output) => {
    const data = await loadData()
    output.env.VAR = data.value
  }
})
```

### Non-mutating output

Check before setting:

```ts
if (!(key in output.env)) {
  output.env[key] = value
}
```

### Quiet mode

Suppress console output for clean logs:

```ts
someLibrary.configure({ quiet: true })
```

## CI/CD

Example GitHub Actions:

```yaml
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

- [OpenCode Plugins Documentation](https://opencode.ai/docs/plugins/)
- [OpenCode SDK Documentation](https://opencode.ai/docs/sdk/)
- [Bun Documentation](https://bun.sh/docs)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.