Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Commit 4997660

Browse files
committed
feat: multi-location .env loading with last-wins merge strategy
Read .env from two locations and merge with cascading priority: 1. ~/.config/opencode/.env (global defaults) 2. <projectDir>/.opencode/.env (project overrides, wins) BREAKING: .env values now override OS environment variables, matching the opencode-cli convention (local overrides user).
1 parent 1bd574e commit 4997660

5 files changed

Lines changed: 150 additions & 57 deletions

File tree

‎AGENTS.md‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ import path from "path"
111111
This is intentional — the plugin must never crash or block OpenCode startup.
112112
- Use empty `catch {}` blocks (no error variable needed).
113113
- Return empty objects/records on failure, not `null` or `undefined`.
114-
- Never overwrite existing environment variables (`if (!(key in target))`).
114+
- Last Wins: `.env` values intentionally override existing environment variables.
115115

116116
```typescript
117117
// Correct pattern for this plugin
@@ -127,27 +127,25 @@ try {
127127

128128
The plugin has two phases, both in a single file:
129129

130-
1. **Init phase** (runs once at plugin load): Reads `.env` → sets `process.env`
131-
2. **Hook phase** (runs on every shell invocation): Reads `.env` → sets `output.env`
130+
1. **Init phase** (runs once at plugin load): Reads global + project `.env` → merges (last wins) → sets `process.env`
131+
2. **Hook phase** (runs on every shell invocation): Reads global + project `.env` → merges → sets `output.env`
132132

133133
```typescript
134134
export const ShellEnvPlugin: Plugin = async (input) => {
135-
// Phase 1: Init-time injection into process.env
136-
const parsed = loadEnvFile(input.directory)
137-
for (const [key, value] of Object.entries(parsed)) {
138-
if (!(key in process.env)) {
139-
process.env[key] = value
140-
}
135+
const projectDir = input.directory
136+
137+
// Phase 1: Merge global + project .env, inject into process.env (last wins)
138+
const merged = loadMergedEnv(projectDir)
139+
for (const [key, value] of Object.entries(merged)) {
140+
process.env[key] = value
141141
}
142142

143143
return {
144-
// Phase 2: Shell-time injection via hook
144+
// Phase 2: Shell-time injection via hook (re-reads .env on every invocation)
145145
"shell.env": async (_input, output) => {
146-
const envVars = loadEnvFile(input.directory)
146+
const envVars = loadMergedEnv(projectDir)
147147
for (const [key, value] of Object.entries(envVars)) {
148-
if (!(key in output.env)) {
149-
output.env[key] = value
150-
}
148+
output.env[key] = value
151149
}
152150
},
153151
}
@@ -156,12 +154,14 @@ export const ShellEnvPlugin: Plugin = async (input) => {
156154

157155
### Key Design Rules
158156

159-
1. **Never overwrite** existing env vars — OS-level exports always take precedence.
160-
2. **Never throw** — all errors are silently caught and ignored.
161-
3. **No external dependencies** beyond `@opencode-ai/plugin` and Node.js built-ins.
162-
4. **Re-read `.env` on every shell invocation** — picks up changes without restart.
163-
5. **Read `.env` once at init** — populates `process.env` for downstream plugins.
164-
6. **Source lives in `src/`**, not `plugins/` — avoids conflicts with the opencode-link symlinker.
157+
1. **Last Wins** — `.env` values override OS environment variables. Project `.env` overrides global `.env`.
158+
2. **Two locations** — reads `~/.config/opencode/.env` (global) and `<projectDir>/.opencode/.env` (project).
159+
3. **Never throw** — all errors are silently caught and ignored.
160+
4. **No external dependencies** beyond `@opencode-ai/plugin` and Node.js built-ins.
161+
5. **Re-read `.env` on every shell invocation** — picks up changes without restart.
162+
6. **Read `.env` once at init** — populates `process.env` for downstream plugins.
163+
7. **Source lives in `src/`**, not `plugins/` — avoids conflicts with the opencode-link symlinker.
164+
8. **Global config path** — uses `os.homedir() + "/.config/opencode"`, consistent with opencode-cli.
165165

166166
### Skill Documents
167167

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## 1.2.0
4+
5+
- **BREAKING:** Switch from "never overwrite" to "last wins" merge strategy
6+
- `.env` values now override existing OS environment variables
7+
- This matches the opencode-cli convention (local overrides user)
8+
- Add multi-location `.env` loading with cascading merge:
9+
1. `~/.config/opencode/.env` — global defaults (user email, shared API keys)
10+
2. `<projectDir>/.opencode/.env` — project-specific overrides (wins)
11+
- Refactor `loadEnvFile()` → `loadEnvFromPath()` + `loadMergedEnv()` + `getGlobalConfigDir()`
12+
- Update AGENTS.md, README.md, and skill document with new behavior
13+
314
## 1.1.1
415

516
- Fix skills directory structure for plugin linker compatibility

‎README.md‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
11
# opencode-plugin-shell-env
22

3-
Loads `.opencode/.env` variables into OpenCode's shell environment and MCP server processes.
3+
Loads `.env` variables into OpenCode's shell environment, MCP server processes, and `process.env` for downstream plugins.
44

55
## Problem
66

7-
OpenCode's `opencode.json` supports environment variable references like `{env:N8N_MCP_BEARER_TOKEN}`, but these only resolve **OS-level environment variables** (i.e., variables exported in the shell). An `.opencode/.env` file is **not** read by default.
7+
OpenCode's `opencode.json` supports environment variable references like `{env:N8N_MCP_BEARER_TOKEN}`, but these only resolve **OS-level environment variables** (i.e., variables exported in the shell). An `.env` file is **not** read by default.
88

99
## Solution
1010

11-
This plugin hooks into OpenCode's `shell.env` lifecycle event and injects variables from `<projectRoot>/.opencode/.env` into every shell command and MCP server process spawned by OpenCode.
11+
This plugin hooks into OpenCode's `shell.env` lifecycle event and injects variables from `.env` files into every shell command, MCP server process, and `process.env` for downstream plugins.
12+
13+
### .env Loading Order (Last Wins)
14+
15+
The plugin reads `.env` files from two locations and merges them — more specific sources override less specific ones:
16+
17+
| Priority | Source | Purpose |
18+
|----------|--------|---------|
19+
| 1 (base) | `~/.config/opencode/.env` | Global defaults (user email, shared API keys) |
20+
| 2 (wins) | `<projectDir>/.opencode/.env` | Project-specific overrides |
21+
22+
Both layers **override** existing OS environment variables. If a key appears in both files, the project-level value wins.
1223

1324
### Behavior
1425

15-
- Reads `<projectRoot>/.opencode/.env` on every shell invocation
16-
- **Does NOT overwrite** variables already present in the environment
17-
- Silently skips if `.opencode/.env` is missing or unreadable
26+
- Reads both `.env` files at init and on every shell invocation
27+
- **Last Wins:** project `.env` > global `.env` > OS environment
28+
- Silently skips missing or unreadable `.env` files
1829
- Parses standard `.env` syntax
1930

2031
### Supported `.env` Syntax

‎skills/shell-env/shell-env-usage/SKILL.md‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,20 @@ metadata:
1414

1515
## Overview
1616

17-
The `shell-env` plugin loads environment variables from `.opencode/.env` and makes them available in two ways:
17+
The `shell-env` plugin loads environment variables from `.env` files and makes them available in two ways:
1818

1919
1. **`process.env`** (at plugin init) — for all plugins loaded after `shell-env`
2020
2. **`shell.env` hook** (on every shell invocation) — for shell commands and MCP server processes
2121

22+
The plugin reads `.env` files from **two locations** and merges them with a "last wins" strategy:
23+
24+
| Priority | Source | Purpose |
25+
|----------|--------|---------|
26+
| 1 (base) | `~/.config/opencode/.env` | Global defaults (user email, shared API keys) |
27+
| 2 (wins) | `<projectDir>/.opencode/.env` | Project-specific overrides |
28+
29+
Both layers **override** existing OS environment variables. If a key appears in both files, the project-level value wins. This matches the "last wins" convention used by the opencode-cli plugin discovery (local overrides user).
30+
2231
This eliminates the need for each plugin to implement its own `.env` file parsing.
2332

2433
## When to Use This Skill
@@ -33,36 +42,50 @@ This eliminates the need for each plugin to implement its own `.env` file parsin
3342

3443
### Phase 1: Plugin Initialization (`process.env`)
3544

36-
When OpenCode loads the `shell-env` plugin, it reads `.opencode/.env` and injects all variables into `process.env` **before** subsequent plugins are initialized.
45+
When OpenCode loads the `shell-env` plugin, it reads `.env` files from both locations, merges them (last wins), and injects all variables into `process.env` **before** subsequent plugins are initialized.
3746

3847
```
3948
Plugin loading order (.opencode/package.json dependencies):
40-
1. shell-env ← reads .env, sets process.env
49+
1. shell-env ← reads global + project .env, sets process.env
4150
2. time-tracking ← can read process.env.OPENCODE_USER_EMAIL
4251
3. other-plugin ← can read process.env.MY_SECRET
4352
```
4453

4554
**Rules:**
46-
- Existing `process.env` values are **never overwritten** (OS-level exports take precedence)
47-
- If `.opencode/.env` is missing or unreadable, the plugin silently continues
55+
- `.env` values **override** existing OS environment variables (last wins)
56+
- Project `.env` overrides global `.env` for the same key
57+
- If either `.env` file is missing or unreadable, the plugin silently continues
4858
- Variables are available immediately after plugin init — no async delay
4959

5060
### Phase 2: Shell Execution (`shell.env` hook)
5161

52-
On every shell command or MCP server process, the plugin re-reads `.opencode/.env` and injects variables into the shell environment.
62+
On every shell command or MCP server process, the plugin re-reads both `.env` files and injects merged variables into the shell environment.
5363

5464
```
5565
Shell command execution:
5666
1. OpenCode calls "shell.env" hook
57-
2. shell-env injects .env variables into output.env
58-
3. Shell command runs with merged environment
67+
2. shell-env reads global + project .env, merges (last wins)
68+
3. Merged variables injected into output.env
69+
4. Shell command runs with merged environment
5970
```
6071

6172
**Rules:**
6273
- Re-reads `.env` on every invocation (picks up changes without restart)
63-
- Existing shell environment variables are **never overwritten**
74+
- `.env` values **override** existing shell environment variables
6475
- MCP servers configured in `opencode.json` see the variables via `{env:KEY}`
6576

77+
### Global vs. Project `.env`
78+
79+
```
80+
~/.config/opencode/.env ← shared across all projects
81+
OPENCODE_USER_EMAIL=user@example.com
82+
TEMPO_API_TOKEN=tempo_xyz789
83+
84+
<projectDir>/.opencode/.env ← project-specific, overrides global
85+
N8N_MCP_BEARER_TOKEN=n8n_api_abc123
86+
OPENCODE_USER_EMAIL=other@example.com ← wins over global
87+
```
88+
6689
## Configuration
6790

6891
### Prerequisites
@@ -225,7 +248,7 @@ Without `shell-env`, `{env:N8N_MCP_BEARER_TOKEN}` only resolves if the variable
225248
|---------|-------|----------|
226249
| `process.env.KEY` is `undefined` | shell-env loaded after the plugin | Move shell-env to first position in `.opencode/package.json` dependencies |
227250
| `process.env.KEY` is `undefined` | `.opencode/.env` file missing | Create `.opencode/.env` with the variable |
228-
| `process.env.KEY` has wrong value | OS-level export overrides `.env` | Remove the OS-level export or update it |
251+
| `process.env.KEY` has wrong value | Project `.env` overrides global `.env` and OS exports | Check both `~/.config/opencode/.env` and `.opencode/.env` |
229252
| MCP server doesn't see variable | shell-env not installed | Install and link the plugin |
230253

231254
### Variable not available in MCP server

‎src/shell-env.ts‎

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { Plugin } from "@opencode-ai/plugin"
22
import fs from "fs"
3+
import os from "os"
34
import path from "path"
45

56
/**
67
* ShellEnvPlugin - Loads .env variables into OpenCode's process and shell environment.
78
*
8-
* This plugin provides environment variables from `.opencode/.env` to:
9+
* This plugin provides environment variables from `.env` files to:
910
*
1011
* 1. **process.env** (at plugin init) — so that subsequent plugins loaded after
1112
* this one can read the variables via `process.env.KEY`. This eliminates the
@@ -19,10 +20,25 @@ import path from "path"
1920
* time-tracking plugin reads OPENCODE_USER_EMAIL from process.env, and the n8n
2021
* MCP server reads N8N_MCP_BEARER_TOKEN via the shell.env hook.
2122
*
22-
* Behavior:
23-
* - Reads `<projectRoot>/.opencode/.env` once at init and on every shell invocation
24-
* - Does NOT overwrite variables already present in the environment
25-
* - Silently skips if `.opencode/.env` is missing or unreadable
23+
* ## .env Loading Order (Last Wins)
24+
*
25+
* The plugin reads `.env` files from two locations and merges them with a
26+
* "last wins" strategy — more specific sources override less specific ones:
27+
*
28+
* | Priority | Source | Purpose |
29+
* |----------|---------------------------------|--------------------------------------|
30+
* | 1 (base) | `~/.config/opencode/.env` | Global defaults (user email, shared keys) |
31+
* | 2 (wins) | `<projectDir>/.opencode/.env` | Project-specific overrides |
32+
*
33+
* Both layers override existing OS environment variables. If a key appears in
34+
* both files, the project-level value wins. This matches the "last wins"
35+
* convention used by the opencode-cli plugin discovery (local overrides user).
36+
*
37+
* ## Behavior
38+
*
39+
* - Reads both `.env` files once at init and on every shell invocation
40+
* - Last Wins: project `.env` overrides global `.env` overrides OS env
41+
* - Silently skips missing or unreadable `.env` files
2642
*
2743
* **Important:** This plugin must be listed BEFORE other plugins in the OpenCode
2844
* configuration so that `process.env` is populated before they initialize.
@@ -34,7 +50,7 @@ import path from "path"
3450
*/
3551

3652
/**
37-
* Parses a .env file and returns a Record of key-value pairs.
53+
* Parses a .env file content string and returns a Record of key-value pairs.
3854
*
3955
* Supports:
4056
* - KEY=VALUE
@@ -101,12 +117,10 @@ function parseDotenv(content: string): Record<string, string> {
101117
}
102118

103119
/**
104-
* Reads the .env file and returns the parsed variables.
120+
* Reads a .env file from the given path and returns the parsed variables.
105121
* Returns an empty record if the file is missing or unreadable.
106122
*/
107-
function loadEnvFile(projectDir: string): Record<string, string> {
108-
const envPath = path.join(projectDir, ".opencode", ".env")
109-
123+
function loadEnvFromPath(envPath: string): Record<string, string> {
110124
if (!fs.existsSync(envPath)) {
111125
return {}
112126
}
@@ -120,28 +134,62 @@ function loadEnvFile(projectDir: string): Record<string, string> {
120134
}
121135
}
122136

137+
/**
138+
* Returns the global OpenCode config directory path.
139+
*
140+
* Uses the same convention as opencode-cli:
141+
* os.homedir() + "/.config/opencode"
142+
*
143+
* No XDG_CONFIG_HOME or platform-specific logic — consistent with opencode-cli.
144+
*/
145+
function getGlobalConfigDir(): string {
146+
return path.join(os.homedir(), ".config", "opencode")
147+
}
148+
149+
/**
150+
* Merges .env variables from global and project locations using "last wins".
151+
*
152+
* Loading order (later entries override earlier ones):
153+
* 1. ~/.config/opencode/.env (global defaults)
154+
* 2. <projectDir>/.opencode/.env (project overrides)
155+
*
156+
* @param projectDir - The project working directory (input.directory)
157+
* @returns Merged key-value record with project values winning over global
158+
*/
159+
function loadMergedEnv(projectDir: string): Record<string, string> {
160+
const globalEnvPath = path.join(getGlobalConfigDir(), ".env")
161+
const projectEnvPath = path.join(projectDir, ".opencode", ".env")
162+
163+
const globalEnv = loadEnvFromPath(globalEnvPath)
164+
const projectEnv = loadEnvFromPath(projectEnvPath)
165+
166+
// Spread merge: project values override global values (last wins)
167+
return { ...globalEnv, ...projectEnv }
168+
}
169+
123170
export const ShellEnvPlugin: Plugin = async (input) => {
124171
const projectDir = input.directory
125172

126-
// Phase 1: Inject .env variables into process.env at plugin init time.
173+
// Phase 1: Inject merged .env variables into process.env at plugin init time.
127174
// This makes them available to all plugins loaded AFTER this one via
128175
// process.env, eliminating the need for each plugin to parse .env itself.
129-
const parsed = loadEnvFile(projectDir)
130-
for (const [key, value] of Object.entries(parsed)) {
131-
if (!(key in process.env)) {
132-
process.env[key] = value
133-
}
176+
//
177+
// Last Wins: .env values OVERRIDE existing OS environment variables.
178+
// This is intentional — explicit .env configuration takes precedence
179+
// over inherited shell environment, matching the opencode-cli convention.
180+
const merged = loadMergedEnv(projectDir)
181+
for (const [key, value] of Object.entries(merged)) {
182+
process.env[key] = value
134183
}
135184

136185
return {
137-
// Phase 2: Inject .env variables into shell execution environment.
186+
// Phase 2: Inject merged .env variables into shell execution environment.
138187
// This makes them available to shell commands and MCP server processes.
188+
// Re-reads .env files on every invocation to pick up changes without restart.
139189
"shell.env": async (_input, output) => {
140-
const envVars = loadEnvFile(projectDir)
190+
const envVars = loadMergedEnv(projectDir)
141191
for (const [key, value] of Object.entries(envVars)) {
142-
if (!(key in output.env)) {
143-
output.env[key] = value
144-
}
192+
output.env[key] = value
145193
}
146194
},
147195
}

0 commit comments

Comments
 (0)