{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreifxq3bt4gex56mfsizoqay2ro2xyepazrpzc6nrhirtcbmalxblci",
"uri": "at://did:plc:6u4awktizhivwgqxl5j67h4k/app.bsky.feed.post/3meo3ezhlpzv2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreidmeu2mbeumdf6ffdprdb5vxiwhwe3zfziqecj7kitkxt2tntzjrm"
},
"mimeType": "image/jpeg",
"size": 200182
},
"description": "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.\n\n\n\nHow OpenCode discovers plugin artifacts\n\n\nTools 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/o",
"path": "/distributing-opencode-plugins-via-npm/",
"publishedAt": "2026-02-12T13:19:13.000Z",
"site": "https://www.subaud.io",
"tags": [
"@opencode-ai"
],
"textContent": "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.\n\n## How OpenCode discovers plugin artifacts\n\n**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/`.\n\n\n {\n \"$schema\": \"https://opencode.ai/config.json\",\n \"plugin\": [\"opencode-sample-validator\"]\n }\n\n\n**Skills** (`SKILL.md` files) are discovered automatically from plugin packages. No configuration needed.\n\n**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.\n\nSo after `npm publish`, tools and skills work automatically. Commands need to be placed in the right directory.\n\n## Plugin structure\n\n\n opencode-feature-workflow/\n ├── bin/\n │ └── setup.js # CLI setup script\n ├── src/\n │ ├── index.ts # Plugin entry — hooks and events\n │ ├── lib/\n │ │ ├── dashboard.ts # Dashboard generation\n │ │ ├── statusline.ts # Session title management\n │ │ ├── frontmatter.ts # YAML frontmatter parsing\n │ │ ├── models.ts # Type definitions\n │ │ └── feature-status-panel.ts # Status panel rendering\n │ ├── commands/ # Slash commands\n │ │ ├── feature-capture.md\n │ │ ├── feature-init.md\n │ │ ├── feature-plan.md\n │ │ ├── feature-ship.md\n │ │ └── feature-status.md\n │ ├── skills/ # SKILL.md files\n │ │ ├── feature-capture/SKILL.md\n │ │ ├── feature-init/SKILL.md\n │ │ ├── feature-plan/SKILL.md\n │ │ ├── feature-ship/SKILL.md\n │ │ ├── feature-status/SKILL.md\n │ │ ├── feature-audit/SKILL.md\n │ │ ├── feature-troubleshoot/SKILL.md\n │ │ └── checking-backlog/SKILL.md\n │ └── agents/ # Agent definitions\n │ ├── project-manager.md\n │ ├── security-reviewer.md\n │ ├── qa-engineer.md\n │ ├── api-designer.md\n │ ├── frontend-architect.md\n │ ├── integration-designer.md\n │ ├── system-designer.md\n │ ├── ux-optimizer.md\n │ ├── code-archaeologist.md\n │ ├── test-generator.md\n │ ├── documentation-agent.md\n │ └── runtime-auditor.md\n ├── package.json\n └── tsconfig.json\n\n\nEach directory serves a different discovery mechanism. More on that below.\n\n## Hooks and events (`src/index.ts`)\n\nThis 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:\n\n\n import type { Plugin } from '@opencode-ai/plugin';\n import { appendFile } from 'fs/promises';\n import { join } from 'path';\n import { generateDashboard, parseFeatureContext } from './lib/dashboard.js';\n import { setFeatureContext, clearFeatureContext } from './lib/statusline.js';\n\n const LOG_FILE = '/tmp/feature-workflow.log';\n async function log(message: string): Promise<void> {\n const ts = new Date().toISOString();\n try { await appendFile(LOG_FILE, `[${ts}] ${message}\\n`); } catch { /* noop */ }\n }\n\n let _pluginLoaded = false;\n\n export const FeatureWorkflowPlugin: Plugin = async ({ client, directory }) => {\n if (_pluginLoaded) return {};\n _pluginLoaded = true;\n await log(`PLUGIN LOADED — directory: ${directory}`);\n\n let pendingRegeneration = false;\n let lastFeatureFile: { featureId: string; fileType: string } | null = null;\n let primarySessionId: string | null = null;\n let currentSessionId: string | null = null;\n const titleSessionIds = new Set<string>();\n const featuresDir = join(directory, 'docs', 'features');\n\n return {\n // --- Pre-tool hook: detect feature file writes ---\n 'tool.execute.before': async (input, output) => {\n if (input.sessionID) currentSessionId = input.sessionID;\n\n const filePath = output.args?.filePath as string | undefined;\n if (input.tool === 'write' && filePath) {\n const match = filePath.match(\n /docs\\/features\\/([^/]+)\\/(idea|plan|shipped)\\.md$/\n );\n if (match) {\n pendingRegeneration = true;\n lastFeatureFile = { featureId: match[1], fileType: match[2] };\n }\n }\n },\n\n // --- Post-tool hook: regenerate dashboard, update session title ---\n 'tool.execute.after': async (input, _output) => {\n if (input.sessionID) currentSessionId = input.sessionID;\n if (!pendingRegeneration || !lastFeatureFile) return;\n pendingRegeneration = false;\n\n const { featureId, fileType } = lastFeatureFile;\n lastFeatureFile = null;\n\n // Regenerate dashboard on any feature file change\n try {\n await generateDashboard(directory);\n } catch (err: unknown) {\n await log(`Dashboard generation failed: ${err}`);\n }\n\n const sessionId = primarySessionId || currentSessionId;\n if (!sessionId) return;\n\n // Update session title based on feature status transitions\n if (fileType === 'plan') {\n const ctx = await parseFeatureContext(join(featuresDir, featureId));\n if (ctx) {\n await setFeatureContext(client, sessionId, ctx);\n titleSessionIds.add(sessionId);\n }\n } else if (fileType === 'shipped') {\n for (const sid of titleSessionIds) {\n await clearFeatureContext(client, sid, featureId);\n }\n titleSessionIds.clear();\n }\n },\n\n // --- Event handler: track session IDs ---\n event: async ({ event }) => {\n if (event.type === 'session.updated') return; // bail — infinite loop risk\n\n if (event.type === 'session.created') {\n const info = event.properties as Record<string, unknown> | undefined;\n const nested = info?.info as Record<string, unknown> | undefined;\n if (nested?.id) {\n currentSessionId = nested.id as string;\n if (!primarySessionId) primarySessionId = currentSessionId;\n }\n }\n },\n };\n };\n\n export default FeatureWorkflowPlugin;\n\n\nThe `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.\n\nThe `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.\"\n\nThe 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.\n\n## Skills (`src/skills/*/SKILL.md`)\n\nEach skill is a Markdown file with YAML frontmatter. OpenCode discovers these automatically from plugin packages. Here's `feature-capture`:\n\n\n ---\n name: feature-capture\n description: Interactive workflow for adding items to the backlog.\n ---\n\n # Add Feature to Backlog\n\n You are executing the **ADD TO BACKLOG** workflow.\n\n ## First: Check Initialization\n\n **Read `docs/features/DASHBOARD.md`** to verify the project is initialized.\n\n If `docs/features/` directory does NOT exist, tell the user:\n > \"Run the `feature-init` skill first to set up the feature workflow structure.\"\n\n ## FORBIDDEN - Do Not Do These Things\n\n - **NEVER create BACKLOG.json** - This is an old format.\n - **NEVER write DASHBOARD.md** - It's auto-generated by the plugin.\n - **NEVER ask the user for a feature name** - You generate the name from their description.\n\n ## REQUIRED - You Must Do This\n\n\n\nSkills 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.\n\n## Agents (`src/agents/*.md`)\n\nAgents define specialized personas dispatched during commands like `/feature-plan` and `/feature-ship`:\n\n\n ---\n description: Specializes in product strategy and prioritization - creating\n roadmaps, defining acceptance criteria, analyzing market needs.\n mode: subagent\n temperature: 0.3\n tools:\n read: true\n write: true\n edit: true\n grep: true\n todoWrite: true\n webfetch: true\n\n ---\n\n ## Quick Reference\n - Creates product roadmaps and PRDs\n - Analyzes market needs and competition\n - Prioritizes features using RICE/MoSCoW\n - Defines acceptance criteria and success metrics\n\n\nAgents 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.\n\n## Commands (`src/commands/*.md`)\n\nCommands 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/`.\n\n\n ---\n description: Add a new feature to the backlog quickly\n ---\n\n Add a new feature to the backlog by creating docs/features/[id]/idea.md.\n\n ## Quick Capture Process\n\n 1. **Ask for the problem statement** (the main input)\n 2. **Auto-generate feature name and ID**\n 3. **Offer smart defaults** (user can accept or change)\n - Type: Feature | Enhancement | Bug Fix | Tech Debt\n - Priority: P0 | P1 | P2\n - Effort: Small | Medium | Large\n - Impact: Low | Medium | High\n 4. **Create the feature** - Generate idea.md with all fields\n\n\nThis is the gap the setup script fills.\n\n## How OpenCode discovers each piece\n\n**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.\n\n**Skills** — `src/skills/*/SKILL.md`. Auto-discovered from the npm package. No setup needed.\n\n**Agents** — `src/agents/*.md`. Auto-discovered from the npm package. No setup needed.\n\n**Commands** — `src/commands/*.md`. Only discovered from `.opencode/commands/`, not from packages. Must be copied there manually.\n\nTwo pieces need manual setup: the config entry and the command files. The setup script handles both.\n\n## The setup script (`bin/setup.js`)\n\n\n #!/usr/bin/env node\n\n import { readdir, readFile, writeFile, mkdir } from 'fs/promises';\n import { resolve, dirname } from 'path';\n import { fileURLToPath } from 'url';\n import { homedir } from 'os';\n\n const __dirname = dirname(fileURLToPath(import.meta.url));\n const packageRoot = resolve(__dirname, '..');\n const pkgName = 'opencode-feature-workflow';\n\n const ourPkg = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf-8'));\n const ourVersion = ourPkg.version;\n\n const args = process.argv.slice(2);\n const command = args[0];\n\n if (command === 'setup') {\n const isProject = args.includes('--project');\n await setup(isProject);\n } else {\n console.log(`Usage: ${pkgName} setup [--project]`);\n process.exit(1);\n }\n\n async function setup(projectLocal) {\n const configPath = projectLocal\n ? resolve(process.cwd(), 'opencode.json')\n : resolve(homedir(), '.config', 'opencode', 'opencode.json');\n\n const commandsSource = resolve(packageRoot, 'src', 'commands');\n const commandsTarget = projectLocal\n ? resolve(process.cwd(), '.opencode', 'commands')\n : resolve(homedir(), '.config', 'opencode', 'commands');\n\n console.log(`\\n${pkgName} setup v${ourVersion}`);\n console.log(` target: ${projectLocal ? 'project' : 'global (~/.config/opencode/)'}\\n`);\n\n // Step 1: Add plugin to opencode.json\n let config;\n try {\n config = JSON.parse(await readFile(configPath, 'utf-8'));\n } catch {\n config = {};\n }\n\n if (!config.$schema) config.$schema = 'https://opencode.ai/config.json';\n if (!Array.isArray(config.plugin)) config.plugin = [];\n\n if (config.plugin.includes(pkgName)) {\n console.log(` [skip] ${pkgName} already in opencode.json`);\n } else {\n config.plugin.push(pkgName);\n console.log(` [add] ${pkgName} to opencode.json plugin array`);\n }\n\n if (!projectLocal) await mkdir(dirname(configPath), { recursive: true });\n await writeFile(configPath, JSON.stringify(config, null, 2) + '\\n');\n\n // Step 2: Copy command files\n let entries;\n try {\n entries = (await readdir(commandsSource)).filter(f => f.endsWith('.md'));\n } catch {\n console.log('\\nDone (no command files to copy).');\n return;\n }\n\n if (entries.length > 0) {\n await mkdir(commandsTarget, { recursive: true });\n let copied = 0, skipped = 0;\n\n for (const file of entries) {\n const srcPath = resolve(commandsSource, file);\n const destPath = resolve(commandsTarget, file);\n const srcContent = await readFile(srcPath, 'utf-8');\n\n try {\n const destContent = await readFile(destPath, 'utf-8');\n if (destContent === srcContent) {\n console.log(` [skip] ${file} (up to date)`);\n skipped++;\n continue;\n }\n console.log(` [update] ${file}`);\n } catch {\n console.log(` [copy] ${file}`);\n }\n\n await writeFile(destPath, srcContent);\n copied++;\n }\n\n console.log(`\\nDone: plugin registered, ${copied} commands copied, ${skipped} unchanged`);\n }\n\n console.log('\\nRestart OpenCode to activate.');\n }\n\n\nRegistered as a `bin` entry in `package.json`:\n\n\n {\n \"bin\": {\n \"opencode-feature-workflow\": \"./bin/setup.js\"\n }\n }\n\n\n### Usage\n\n\n # Per-project\n bunx opencode-feature-workflow setup --project\n\n # Global\n bunx opencode-feature-workflow setup\n\n\n### Output\n\n\n opencode-feature-workflow setup v0.3.3\n target: project\n\n [add] opencode-feature-workflow to opencode.json plugin array\n [copy] feature-capture.md\n [copy] feature-init.md\n [copy] feature-plan.md\n [copy] feature-ship.md\n [copy] feature-status.md\n\n Done: plugin registered, 5 commands copied, 0 unchanged\n\n Restart OpenCode to activate.\n\n\nRunning it again is safe — unchanged files are skipped, updated files are overwritten.\n\n### Project vs. global scope\n\nThe `--project` flag controls where both files go:\n\n**With`--project`** (per-project install):\n\n * Config: `./opencode.json` in the current directory\n * Commands: `./.opencode/commands/`\n\n\n\n**Without`--project`** (global install):\n\n * Config: `~/.config/opencode/opencode.json`\n * Commands: `~/.config/opencode/commands/`\n\n\n\nProject-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:\n\n\n const configPath = projectLocal\n ? resolve(process.cwd(), 'opencode.json')\n : resolve(homedir(), '.config', 'opencode', 'opencode.json');\n\n const commandsTarget = projectLocal\n ? resolve(process.cwd(), '.opencode', 'commands')\n : resolve(homedir(), '.config', 'opencode', 'commands');\n\n\n## The `package.json` `files` array\n\nControls what ships in the npm tarball:\n\n\n {\n \"files\": [\n \"dist/\",\n \"src/commands/\",\n \"src/skills/\",\n \"src/agents/\",\n \"bin/\"\n ]\n }\n\n\n * `dist/` — compiled plugin code, read by the OpenCode plugin loader\n * `src/commands/` — command .md files, copied by the setup script\n * `src/skills/` — SKILL.md files, read by OpenCode skill discovery\n * `src/agents/` — agent .md files, read by OpenCode agent discovery\n * `bin/` — the setup script itself, invoked by `bunx`\n\n",
"title": "Distributing OpenCode Plugins via npm",
"updatedAt": "2026-02-12T13:19:13.945Z"
}