OpenCode plugins have three extension points: tools, skills, and commands. They're discovered differently, and two of the three work automatically from an npm package. Commands don't. This post documents how I set up a bunx setup script to handle the gap.
How OpenCode discovers plugin artifacts
Tools and hooks are registered in src/index.ts and loaded when the plugin appears in opencode.json's plugin array. OpenCode installs npm plugins automatically at startup and caches them in ~/.cache/opencode/node_modules/.
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-sample-validator"]
}
Skills (SKILL.md files) are discovered automatically from plugin packages. No configuration needed.
Commands (.opencode/commands/*.md) are slash commands like /validation-start. These are only discovered from .opencode/commands/ or ~/.config/opencode/commands/. They are not discovered from npm packages.
So after npm publish, tools and skills work automatically. Commands need to be placed in the right directory.
Plugin structure
opencode-feature-workflow/
├── bin/
│ └── setup.js # CLI setup script
├── src/
│ ├── index.ts # Plugin entry — hooks and events
│ ├── lib/
│ │ ├── dashboard.ts # Dashboard generation
│ │ ├── statusline.ts # Session title management
│ │ ├── frontmatter.ts # YAML frontmatter parsing
│ │ ├── models.ts # Type definitions
│ │ └── feature-status-panel.ts # Status panel rendering
│ ├── commands/ # Slash commands
│ │ ├── feature-capture.md
│ │ ├── feature-init.md
│ │ ├── feature-plan.md
│ │ ├── feature-ship.md
│ │ └── feature-status.md
│ ├── skills/ # SKILL.md files
│ │ ├── feature-capture/SKILL.md
│ │ ├── feature-init/SKILL.md
│ │ ├── feature-plan/SKILL.md
│ │ ├── feature-ship/SKILL.md
│ │ ├── feature-status/SKILL.md
│ │ ├── feature-audit/SKILL.md
│ │ ├── feature-troubleshoot/SKILL.md
│ │ └── checking-backlog/SKILL.md
│ └── agents/ # Agent definitions
│ ├── project-manager.md
│ ├── security-reviewer.md
│ ├── qa-engineer.md
│ ├── api-designer.md
│ ├── frontend-architect.md
│ ├── integration-designer.md
│ ├── system-designer.md
│ ├── ux-optimizer.md
│ ├── code-archaeologist.md
│ ├── test-generator.md
│ ├── documentation-agent.md
│ └── runtime-auditor.md
├── package.json
└── tsconfig.json
Each directory serves a different discovery mechanism. More on that below.
Hooks and events (src/index.ts)
This plugin doesn't register tools. It uses hooks to watch for feature file writes and automatically regenerate the dashboard, update session titles, and track feature status. This is the full entry point:
import type { Plugin } from '@opencode-ai/plugin';
import { appendFile } from 'fs/promises';
import { join } from 'path';
import { generateDashboard, parseFeatureContext } from './lib/dashboard.js';
import { setFeatureContext, clearFeatureContext } from './lib/statusline.js';
const LOG_FILE = '/tmp/feature-workflow.log';
async function log(message: string): Promise<void> {
const ts = new Date().toISOString();
try { await appendFile(LOG_FILE, `[${ts}] ${message}\n`); } catch { /* noop */ }
}
let _pluginLoaded = false;
export const FeatureWorkflowPlugin: Plugin = async ({ client, directory }) => {
if (_pluginLoaded) return {};
_pluginLoaded = true;
await log(`PLUGIN LOADED — directory: ${directory}`);
let pendingRegeneration = false;
let lastFeatureFile: { featureId: string; fileType: string } | null = null;
let primarySessionId: string | null = null;
let currentSessionId: string | null = null;
const titleSessionIds = new Set<string>();
const featuresDir = join(directory, 'docs', 'features');
return {
// --- Pre-tool hook: detect feature file writes ---
'tool.execute.before': async (input, output) => {
if (input.sessionID) currentSessionId = input.sessionID;
const filePath = output.args?.filePath as string | undefined;
if (input.tool === 'write' && filePath) {
const match = filePath.match(
/docs\/features\/([^/]+)\/(idea|plan|shipped)\.md$/
);
if (match) {
pendingRegeneration = true;
lastFeatureFile = { featureId: match[1], fileType: match[2] };
}
}
},
// --- Post-tool hook: regenerate dashboard, update session title ---
'tool.execute.after': async (input, _output) => {
if (input.sessionID) currentSessionId = input.sessionID;
if (!pendingRegeneration || !lastFeatureFile) return;
pendingRegeneration = false;
const { featureId, fileType } = lastFeatureFile;
lastFeatureFile = null;
// Regenerate dashboard on any feature file change
try {
await generateDashboard(directory);
} catch (err: unknown) {
await log(`Dashboard generation failed: ${err}`);
}
const sessionId = primarySessionId || currentSessionId;
if (!sessionId) return;
// Update session title based on feature status transitions
if (fileType === 'plan') {
const ctx = await parseFeatureContext(join(featuresDir, featureId));
if (ctx) {
await setFeatureContext(client, sessionId, ctx);
titleSessionIds.add(sessionId);
}
} else if (fileType === 'shipped') {
for (const sid of titleSessionIds) {
await clearFeatureContext(client, sid, featureId);
}
titleSessionIds.clear();
}
},
// --- Event handler: track session IDs ---
event: async ({ event }) => {
if (event.type === 'session.updated') return; // bail — infinite loop risk
if (event.type === 'session.created') {
const info = event.properties as Record<string, unknown> | undefined;
const nested = info?.info as Record<string, unknown> | undefined;
if (nested?.id) {
currentSessionId = nested.id as string;
if (!primarySessionId) primarySessionId = currentSessionId;
}
}
},
};
};
export default FeatureWorkflowPlugin;
The tool.execute.before hook watches for writes to docs/features/[id]/idea.md, plan.md, or shipped.md. When it sees one, it flags a pending regeneration.
The tool.execute.after hook fires after the write completes. It regenerates DASHBOARD.md and updates the session title based on the transition — plan.md means "in progress", shipped.md means "done."
The event handler tracks session IDs so titles get set on the primary session, not on subagent sessions. The session.updated bail is critical — session.update() triggers session.updated events, which would create an infinite loop.
Skills (src/skills/*/SKILL.md)
Each skill is a Markdown file with YAML frontmatter. OpenCode discovers these automatically from plugin packages. Here's feature-capture:
---
name: feature-capture
description: Interactive workflow for adding items to the backlog.
---
# Add Feature to Backlog
You are executing the **ADD TO BACKLOG** workflow.
## First: Check Initialization
**Read `docs/features/DASHBOARD.md`** to verify the project is initialized.
If `docs/features/` directory does NOT exist, tell the user:
> "Run the `feature-init` skill first to set up the feature workflow structure."
## FORBIDDEN - Do Not Do These Things
- **NEVER create BACKLOG.json** - This is an old format.
- **NEVER write DASHBOARD.md** - It's auto-generated by the plugin.
- **NEVER ask the user for a feature name** - You generate the name from their description.
## REQUIRED - You Must Do This
Skills provide detailed instructions for the AI during specific workflows. The description field is how OpenCode knows when to activate the skill based on user intent.
Agents (src/agents/*.md)
Agents define specialized personas dispatched during commands like /feature-plan and /feature-ship:
---
description: Specializes in product strategy and prioritization - creating
roadmaps, defining acceptance criteria, analyzing market needs.
mode: subagent
temperature: 0.3
tools:
read: true
write: true
edit: true
grep: true
todoWrite: true
webfetch: true
---
## Quick Reference
- Creates product roadmaps and PRDs
- Analyzes market needs and competition
- Prioritizes features using RICE/MoSCoW
- Defines acceptance criteria and success metrics
Agents specify their model, temperature, and which tools they can use. This is the project-manager agent — /feature-plan dispatches it to expand requirements before architecture agents design the implementation.
Commands (src/commands/*.md)
Commands define slash commands. Unlike skills and agents, these are NOT auto-discovered from npm packages. They need to be in .opencode/commands/ or ~/.config/opencode/commands/.
---
description: Add a new feature to the backlog quickly
---
Add a new feature to the backlog by creating docs/features/[id]/idea.md.
## Quick Capture Process
1. **Ask for the problem statement** (the main input)
2. **Auto-generate feature name and ID**
3. **Offer smart defaults** (user can accept or change)
- Type: Feature | Enhancement | Bug Fix | Tech Debt
- Priority: P0 | P1 | P2
- Effort: Small | Medium | Large
- Impact: Low | Medium | High
4. **Create the feature** - Generate idea.md with all fields
This is the gap the setup script fills.
How OpenCode discovers each piece
Hooks and events — src/index.ts compiled to dist/index.js. Loaded via the plugin array in opencode.json. No extra setup beyond the config entry.
Skills — src/skills/*/SKILL.md. Auto-discovered from the npm package. No setup needed.
Agents — src/agents/*.md. Auto-discovered from the npm package. No setup needed.
Commands — src/commands/*.md. Only discovered from .opencode/commands/, not from packages. Must be copied there manually.
Two pieces need manual setup: the config entry and the command files. The setup script handles both.
The setup script (bin/setup.js)
#!/usr/bin/env node
import { readdir, readFile, writeFile, mkdir } from 'fs/promises';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(__dirname, '..');
const pkgName = 'opencode-feature-workflow';
const ourPkg = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf-8'));
const ourVersion = ourPkg.version;
const args = process.argv.slice(2);
const command = args[0];
if (command === 'setup') {
const isProject = args.includes('--project');
await setup(isProject);
} else {
console.log(`Usage: ${pkgName} setup [--project]`);
process.exit(1);
}
async function setup(projectLocal) {
const configPath = projectLocal
? resolve(process.cwd(), 'opencode.json')
: resolve(homedir(), '.config', 'opencode', 'opencode.json');
const commandsSource = resolve(packageRoot, 'src', 'commands');
const commandsTarget = projectLocal
? resolve(process.cwd(), '.opencode', 'commands')
: resolve(homedir(), '.config', 'opencode', 'commands');
console.log(`\n${pkgName} setup v${ourVersion}`);
console.log(` target: ${projectLocal ? 'project' : 'global (~/.config/opencode/)'}\n`);
// Step 1: Add plugin to opencode.json
let config;
try {
config = JSON.parse(await readFile(configPath, 'utf-8'));
} catch {
config = {};
}
if (!config.$schema) config.$schema = 'https://opencode.ai/config.json';
if (!Array.isArray(config.plugin)) config.plugin = [];
if (config.plugin.includes(pkgName)) {
console.log(` [skip] ${pkgName} already in opencode.json`);
} else {
config.plugin.push(pkgName);
console.log(` [add] ${pkgName} to opencode.json plugin array`);
}
if (!projectLocal) await mkdir(dirname(configPath), { recursive: true });
await writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
// Step 2: Copy command files
let entries;
try {
entries = (await readdir(commandsSource)).filter(f => f.endsWith('.md'));
} catch {
console.log('\nDone (no command files to copy).');
return;
}
if (entries.length > 0) {
await mkdir(commandsTarget, { recursive: true });
let copied = 0, skipped = 0;
for (const file of entries) {
const srcPath = resolve(commandsSource, file);
const destPath = resolve(commandsTarget, file);
const srcContent = await readFile(srcPath, 'utf-8');
try {
const destContent = await readFile(destPath, 'utf-8');
if (destContent === srcContent) {
console.log(` [skip] ${file} (up to date)`);
skipped++;
continue;
}
console.log(` [update] ${file}`);
} catch {
console.log(` [copy] ${file}`);
}
await writeFile(destPath, srcContent);
copied++;
}
console.log(`\nDone: plugin registered, ${copied} commands copied, ${skipped} unchanged`);
}
console.log('\nRestart OpenCode to activate.');
}
Registered as a bin entry in package.json:
{
"bin": {
"opencode-feature-workflow": "./bin/setup.js"
}
}
Usage
# Per-project
bunx opencode-feature-workflow setup --project
# Global
bunx opencode-feature-workflow setup
Output
opencode-feature-workflow setup v0.3.3
target: project
[add] opencode-feature-workflow to opencode.json plugin array
[copy] feature-capture.md
[copy] feature-init.md
[copy] feature-plan.md
[copy] feature-ship.md
[copy] feature-status.md
Done: plugin registered, 5 commands copied, 0 unchanged
Restart OpenCode to activate.
Running it again is safe — unchanged files are skipped, updated files are overwritten.
Project vs. global scope
The --project flag controls where both files go:
With --project (per-project install):
- Config:
./opencode.jsonin the current directory - Commands:
./.opencode/commands/
Without --project (global install):
- Config:
~/.config/opencode/opencode.json - Commands:
~/.config/opencode/commands/
Project-scoped installs only affect the current repo. Global installs make the plugin and its commands available everywhere. The setup script reads the --project flag and resolves paths accordingly:
const configPath = projectLocal
? resolve(process.cwd(), 'opencode.json')
: resolve(homedir(), '.config', 'opencode', 'opencode.json');
const commandsTarget = projectLocal
? resolve(process.cwd(), '.opencode', 'commands')
: resolve(homedir(), '.config', 'opencode', 'commands');
The package.json files array
Controls what ships in the npm tarball:
{
"files": [
"dist/",
"src/commands/",
"src/skills/",
"src/agents/",
"bin/"
]
}
dist/— compiled plugin code, read by the OpenCode plugin loadersrc/commands/— command .md files, copied by the setup scriptsrc/skills/— SKILL.md files, read by OpenCode skill discoverysrc/agents/— agent .md files, read by OpenCode agent discoverybin/— the setup script itself, invoked bybunx