OpenCode plugin that loads .opencode/.env variables into process.env (for downstream plugins)
and into the shell.env hook (for shell commands and MCP server processes).
- Package:
@techdivision/opencode-plugin-shell-env - Runtime: Bun (peer dependency)
- Language: TypeScript (ESNext, strict mode, no build step — loaded directly as
.ts) - Plugin API:
@opencode-ai/plugin(OpenCode plugin SDK) - Entry point:
src/shell-env.ts
# Type-check (no emit — Bun loads .ts directly, no compilation)
npx tsc --noEmit
# Install dependencies
npm installThere is no build step, no linter configured, and no test framework set up yet. The TypeScript source is loaded directly by the Bun runtime at plugin load time.
Use Bun's built-in test runner (bun test). Place test files as src/*.test.ts or tests/*.test.ts.
Run a single test file: bun test src/shell-env.test.ts
opencode-plugin-shell-env/
├── src/
│ └── shell-env.ts # Plugin source (single file)
├── skills/
│ └── shell-env-usage/
│ └── SKILL.md # Skill doc for OpenCode agents
├── package.json # NPM package with opencode.plugin: true
├── plugin.json # Plugin metadata (name, category, version)
├── tsconfig.json # TypeScript config (strict, ESNext, noEmit)
├── README.md
├── CHANGELOG.md
└── .gitignore # Ignores .opencode/, node_modules/, etc.
src/— TypeScript source code. Do NOT put source inplugins/(conflicts with the opencode-link symlinker).skills/— OpenCode skill documents (SKILL.md with YAML frontmatter). Linked into.opencode/skills/by the plugin system..opencode/— Local workspace (gitignored). Never commit.
- Target: ESNext
- Module: ESNext with bundler resolution
- Strict mode: enabled (
"strict": true) - No emit: TypeScript is used for type-checking only; Bun loads
.tsdirectly - Types:
["bun-types", "node"]
// Type-only imports use the `type` keyword
import type { Plugin } from "@opencode-ai/plugin"
// Node.js built-ins: use bare specifiers
import fs from "fs"
import path from "path"- Use
import typefor types that are not needed at runtime. - Node.js built-ins (
fs,path,os, etc.) are imported as default imports. - No file extensions on imports (bundler resolution).
- No barrel files — this is a single-file plugin.
- 2-space indentation
- No semicolons
- Double quotes for strings
- Trailing commas in multi-line structures
- Blank line between function declarations
- Blank line between logical sections within functions
| Element | Convention | Example |
|---|---|---|
| Functions | camelCase | parseDotenv, loadEnvFile |
| Variables | camelCase | projectDir, envPath |
| Constants | UPPER_SNAKE_CASE | ENV_USER_EMAIL |
| Types/Interfaces | PascalCase | Plugin, Record<string, string> |
| Exported plugin | PascalCase | ShellEnvPlugin |
- Small, focused functions with a single responsibility.
- JSDoc comments on all exported and module-level functions.
- Use
Record<string, string>for key-value maps (notMapor custom types). - Prefer
for...ofloops over.forEach(). - Use early returns to reduce nesting.
- Silent failures: This plugin silently ignores errors (missing files, read errors, parse errors). This is intentional — the plugin must never crash or block OpenCode startup.
- Use empty
catch {}blocks (no error variable needed). - Return empty objects/records on failure, not
nullorundefined. - Never overwrite existing environment variables (
if (!(key in target))).
// Correct pattern for this plugin
try {
const content = fs.readFileSync(envPath, "utf-8")
return parseDotenv(content)
} catch {
return {}
}The plugin has two phases, both in a single file:
- Init phase (runs once at plugin load): Reads
.env→ setsprocess.env - Hook phase (runs on every shell invocation): Reads
.env→ setsoutput.env
export const ShellEnvPlugin: Plugin = async (input) => {
// Phase 1: Init-time injection into process.env
const parsed = loadEnvFile(input.directory)
for (const [key, value] of Object.entries(parsed)) {
if (!(key in process.env)) {
process.env[key] = value
}
}
return {
// Phase 2: Shell-time injection via hook
"shell.env": async (_input, output) => {
const envVars = loadEnvFile(input.directory)
for (const [key, value] of Object.entries(envVars)) {
if (!(key in output.env)) {
output.env[key] = value
}
}
},
}
}- Never overwrite existing env vars — OS-level exports always take precedence.
- Never throw — all errors are silently caught and ignored.
- No external dependencies beyond
@opencode-ai/pluginand Node.js built-ins. - Re-read
.envon every shell invocation — picks up changes without restart. - Read
.envonce at init — populatesprocess.envfor downstream plugins. - Source lives in
src/, notplugins/— avoids conflicts with the opencode-link symlinker.
Skills use YAML frontmatter with this structure:
---
name: skill-name
description: This skill should be used when the user asks to "trigger phrase"...
version: 1.0.0
last_updated: 2026-03-01
compatibility: opencode
metadata:
audience: developers
category: shell-env
deprecated: false
---The description field contains trigger phrases that OpenCode uses for skill matching.
This repository uses Git Flow branching:
| Branch | Purpose |
|---|---|
main |
Production releases only — tagged with v* |
develop |
Active development (default branch) |
feature/* |
Feature branches (from develop, merge back to develop) |
developis the default branch. All work starts here.mainreceives merges only fromdevelopat release time.- Never commit directly to
main— always merge fromdevelop. - Tags are created on
mainafter merging fromdevelop. - Feature branches branch from
developand merge back via PR.
# 1. On develop: bump version in package.json + plugin.json, update CHANGELOG.md
git checkout develop
# ... edit package.json, plugin.json, CHANGELOG.md ...
git add -A
git commit -m "Prepare release v1.2.0"
# 2. Merge develop into main
git checkout main
git merge develop
# 3. Tag the release on main
git tag v1.2.0
# 4. Push everything
git push origin main --tags
# 5. Switch back to develop
git checkout develop
# 6. GitHub Actions triggers on tag push → npm publishmain ──●──────────────────●──────── (tagged releases)
\ /
develop ────●──●──●──●──●──●──●────── (active development)
\ /
feat/x (feature branches)
- Version tracked in both
package.jsonandplugin.json— keep them in sync. - Follow semantic versioning. Update
CHANGELOG.mdwith each release. - The
"opencode": { "plugin": true, "category": "optional" }field inpackage.jsonmarks this as an OpenCode plugin that must be explicitly linked.