opencode2-direnv 2026.904.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +293 -0
- package/dist/index.js.map +1 -0
- package/index.ts +1 -0
- package/package.json +46 -0
- package/src/index.ts +342 -0
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# opencode2-direnv
|
|
4
|
+
|
|
5
|
+
**Seamless direnv integration for OpenCode 2**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/opencode2-direnv)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
[](https://direnv.net/)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
*Automatically load [direnv](https://direnv.net/) environment variables into OpenCode 2 sessions — and keep them in sync while you work.*
|
|
14
|
+
|
|
15
|
+
Fork of [`@simonwjackson/opencode-direnv`](https://github.com/simonwjackson/opencode-direnv), ported to the OpenCode 2 plugin API. OpenCode 1 users should use the original package.
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Overview
|
|
22
|
+
|
|
23
|
+
The plugin detects `.envrc` files and keeps the OpenCode server environment in sync with your devshell:
|
|
24
|
+
|
|
25
|
+
- **Automatic Detection** — Searches for `.envrc` from the project directory up to the git root
|
|
26
|
+
- **Shell Injection** — Injects the current direnv export into every shell OpenCode spawns (via the `shell.create.before` hook), so agent commands always see the devshell
|
|
27
|
+
- **Live Reloading** — Re-applies the devshell when `.envrc`/`flake.nix`/`flake.lock` change (debounced) or a new session starts, and *removes* variables that are dropped
|
|
28
|
+
- **Fallback Sync** — Also reconciles `process.env` so other subprocesses (LSP, MCP) inherit the devshell
|
|
29
|
+
- **Graceful Degradation** — Silently skips if direnv is not installed or no `.envrc` exists; warns in the logs when a `.envrc` is blocked
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- [OpenCode 2](https://opencode.ai/) (`opencode2`, beta)
|
|
34
|
+
- [direnv](https://direnv.net/) >= 2.0 in PATH
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Add the plugin to your OpenCode 2 configuration (`plugins`, not the v1 `plugin` key):
|
|
39
|
+
|
|
40
|
+
**Project-level** (`./opencode.json`):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"$schema": "https://opencode.ai/config.json",
|
|
45
|
+
"plugins": ["opencode2-direnv"]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Global** (`~/.config/opencode/opencode.json`):
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"plugins": ["opencode2-direnv"]
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then allow your `.envrc` once:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
direnv allow
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
Start `opencode2` in a project with an `.envrc`. Environment variables are loaded at startup, re-synced per session, and reloaded (debounced ~1.5s) when devshell files change. Outcomes are logged with a `direnv:` prefix:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
direnv: environment loaded
|
|
69
|
+
direnv: reloaded (+2 ~1)
|
|
70
|
+
direnv: .envrc is blocked. Run `direnv allow` to enable.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Local development
|
|
74
|
+
|
|
75
|
+
Local plugins load from a directory containing an `index.ts` entrypoint:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"plugins": ["/path/to/opencode-direnv"]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The root `index.ts` re-exports `src/index.ts`, so edits are picked up on restart. Typecheck and build with:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm ci
|
|
87
|
+
npx tsc --noEmit
|
|
88
|
+
npm run build
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## How It Works
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
plugin setup ────▶ direnv export json ──▶ process.env reconcile
|
|
95
|
+
│ │
|
|
96
|
+
├── shell.create.before hook ────────────┴─▶ every spawned shell gets the devshell
|
|
97
|
+
├── session.created event ──▶ re-sync per session (e.g. after `direnv allow`)
|
|
98
|
+
└── filesystem.changed event (.envrc/flake.nix/flake.lock) ──▶ debounced reload
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
1. **Discovery** — Searches upward from the plugin's location directory, stopping at the git root
|
|
102
|
+
2. **Export** — Runs `direnv export json` (fast when nothing changed; direnv's own watch caches it)
|
|
103
|
+
3. **Reconcile** — Applies additions/changes and *removes* dropped keys; explicit direnv unsets (`null` values) are honored
|
|
104
|
+
4. **Inject** — The `shell.create.before` hook copies the cached export into the environment of every shell spawned below the devshell directory
|
|
105
|
+
|
|
106
|
+
## Troubleshooting
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Verify direnv is installed and .envrc is allowed
|
|
110
|
+
which direnv && direnv status
|
|
111
|
+
|
|
112
|
+
# Verify the export works in the project directory
|
|
113
|
+
direnv export json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
If the plugin does not activate, check the server logs (`opencode2 run --standalone --print-logs --log-level debug ...`) for `direnv:` lines or plugin load errors.
|
|
117
|
+
|
|
118
|
+
## Limitations
|
|
119
|
+
|
|
120
|
+
- Reloads are debounced (~1.5s); run `direnv reload` for an immediate refresh
|
|
121
|
+
- `.envrc` must be explicitly allowed (`direnv allow`) — a blocked file is reported in the logs
|
|
122
|
+
- A changed `use flake` re-evaluates in the background; the session is never blocked
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
[MIT](LICENSE) — originally by [@simonwjackson](https://github.com/simonwjackson), ported to the OpenCode 2 plugin API by [@ankarhem](https://github.com/ankarhem).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;;AAmE5C,wBAkRE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
/**
|
|
7
|
+
* Direnv Auto-Loader Plugin for OpenCode 2
|
|
8
|
+
*
|
|
9
|
+
* Automatically loads AND keeps in sync environment variables from direnv.
|
|
10
|
+
* Shell commands run by the agent always see the current devshell state, even
|
|
11
|
+
* after the user edits .envrc/flake.nix or runs `direnv reload` mid-session.
|
|
12
|
+
*
|
|
13
|
+
* Behavior:
|
|
14
|
+
* - On plugin setup: load env once (awaited, so the first command is ready)
|
|
15
|
+
* - On session.created: re-sync per new session (e.g. after `direnv allow`)
|
|
16
|
+
* - On filesystem.changed for .envrc/flake.nix/flake.lock: debounced reload
|
|
17
|
+
* - Injects the latest direnv export into every spawned shell via the
|
|
18
|
+
* `shell.create.before` hook (primary mechanism)
|
|
19
|
+
* - Reconciles process.env as a fallback for other subprocesses (LSP, MCP) and
|
|
20
|
+
* *removes* dropped keys so removed variables don't linger
|
|
21
|
+
* - Logs outcomes with a `direnv:` prefix (blocked .envrc is always reported)
|
|
22
|
+
* - Silently skips if direnv is not installed or .envrc is missing
|
|
23
|
+
*/
|
|
24
|
+
const run = promisify(execFile);
|
|
25
|
+
/** devshell files whose modification should trigger a reload */
|
|
26
|
+
const RELEVANT_FILES = new Set([".envrc", "flake.nix", "flake.lock"]);
|
|
27
|
+
/** debounce window for background reloads (ms) */
|
|
28
|
+
const RELOAD_DEBOUNCE_MS = 1500;
|
|
29
|
+
const runText = async (file, args, cwd) => {
|
|
30
|
+
const { stdout } = await run(file, args, {
|
|
31
|
+
cwd,
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
34
|
+
});
|
|
35
|
+
return stdout;
|
|
36
|
+
};
|
|
37
|
+
export default Plugin.define({
|
|
38
|
+
id: "opencode2-direnv",
|
|
39
|
+
async setup(ctx) {
|
|
40
|
+
const directory = ctx.location.directory;
|
|
41
|
+
/**
|
|
42
|
+
* Sessions already synced by this plugin instance. Events arrive for every
|
|
43
|
+
* location, so also filter by event location where one is present.
|
|
44
|
+
*/
|
|
45
|
+
const loadedSessions = new Set();
|
|
46
|
+
/**
|
|
47
|
+
* Keys we have written into process.env. Tracked globally (process.env is
|
|
48
|
+
* shared across locations in the server process) so reloads can remove vars
|
|
49
|
+
* that the devshell no longer exports without touching anything we didn't set.
|
|
50
|
+
*/
|
|
51
|
+
const appliedKeys = new Set();
|
|
52
|
+
/** latest successful `direnv export json` payload, injected into spawned shells */
|
|
53
|
+
let currentVars = {};
|
|
54
|
+
/** cached .envrc location (only cached once successfully found) */
|
|
55
|
+
let envrcDir = null;
|
|
56
|
+
let discovered = false;
|
|
57
|
+
/** prevents overlapping `direnv export json` invocations */
|
|
58
|
+
let reloading = false;
|
|
59
|
+
let reloadTimer = null;
|
|
60
|
+
let firstLoadComplete = false;
|
|
61
|
+
const findGitRoot = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const result = await runText("git", ["rev-parse", "--show-toplevel"], directory);
|
|
64
|
+
return result.trim() || null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Find .envrc file searching from directory up to stopAt (git root or filesystem root)
|
|
72
|
+
*/
|
|
73
|
+
const findEnvrc = async (startDir, stopAt) => {
|
|
74
|
+
let current = startDir;
|
|
75
|
+
const boundary = stopAt || "/";
|
|
76
|
+
while (true) {
|
|
77
|
+
if (existsSync(join(current, ".envrc"))) {
|
|
78
|
+
return join(current, ".envrc");
|
|
79
|
+
}
|
|
80
|
+
if (current === boundary || current === "/") {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const parent = dirname(current);
|
|
84
|
+
if (parent === current) {
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
current = parent;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Resolve (and cache) the directory containing .envrc.
|
|
93
|
+
* Caches only on success; a missing .envrc is re-checked on later triggers
|
|
94
|
+
* so one created mid-session is eventually picked up.
|
|
95
|
+
*/
|
|
96
|
+
const resolveEnvrcDir = async () => {
|
|
97
|
+
if (discovered)
|
|
98
|
+
return envrcDir;
|
|
99
|
+
const gitRoot = await findGitRoot();
|
|
100
|
+
const envrcPath = await findEnvrc(directory, gitRoot);
|
|
101
|
+
if (envrcPath) {
|
|
102
|
+
envrcDir = dirname(envrcPath);
|
|
103
|
+
}
|
|
104
|
+
discovered = envrcDir !== null;
|
|
105
|
+
return envrcDir;
|
|
106
|
+
};
|
|
107
|
+
const newOutcome = () => ({
|
|
108
|
+
blocked: false,
|
|
109
|
+
unavailable: false,
|
|
110
|
+
error: false,
|
|
111
|
+
added: 0,
|
|
112
|
+
changed: 0,
|
|
113
|
+
removed: 0,
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* Re-run `direnv export json` and reconcile process.env.
|
|
117
|
+
*
|
|
118
|
+
* Idempotent and mutex-guarded: safe to call from any trigger. Cheap when
|
|
119
|
+
* nothing changed (direnv's own watch cache makes the export ~milliseconds).
|
|
120
|
+
* Returns a summary describing what (if anything) changed.
|
|
121
|
+
*/
|
|
122
|
+
const reloadEnv = async () => {
|
|
123
|
+
const outcome = newOutcome();
|
|
124
|
+
if (reloading)
|
|
125
|
+
return outcome;
|
|
126
|
+
reloading = true;
|
|
127
|
+
try {
|
|
128
|
+
const dir = await resolveEnvrcDir();
|
|
129
|
+
if (!dir) {
|
|
130
|
+
outcome.unavailable = true;
|
|
131
|
+
return outcome;
|
|
132
|
+
}
|
|
133
|
+
let jsonText;
|
|
134
|
+
try {
|
|
135
|
+
jsonText = await runText("direnv", ["export", "json"], dir);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
const stderr = error && typeof error === "object" && "stderr" in error
|
|
139
|
+
? String(error.stderr ?? "")
|
|
140
|
+
: "";
|
|
141
|
+
if (stderr.includes("is blocked")) {
|
|
142
|
+
outcome.blocked = true;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
outcome.error = true;
|
|
146
|
+
}
|
|
147
|
+
return outcome;
|
|
148
|
+
}
|
|
149
|
+
const parsed = jsonText.trim() ? JSON.parse(jsonText) : {};
|
|
150
|
+
const newVars = parsed && typeof parsed === "object" ? parsed : {};
|
|
151
|
+
// 1. Remove vars we previously applied that the devshell no longer exports.
|
|
152
|
+
// direnv may also emit explicit `null` values to signal unsets.
|
|
153
|
+
for (const key of appliedKeys) {
|
|
154
|
+
const keep = key in newVars && newVars[key] != null;
|
|
155
|
+
if (!keep && key in process.env) {
|
|
156
|
+
delete process.env[key];
|
|
157
|
+
outcome.removed++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 2. Apply current vars, counting additions and value changes.
|
|
161
|
+
const nextApplied = new Set();
|
|
162
|
+
for (const [key, value] of Object.entries(newVars)) {
|
|
163
|
+
if (value == null)
|
|
164
|
+
continue;
|
|
165
|
+
const current = process.env[key];
|
|
166
|
+
if (current === undefined)
|
|
167
|
+
outcome.added++;
|
|
168
|
+
else if (current !== value)
|
|
169
|
+
outcome.changed++;
|
|
170
|
+
process.env[key] = value;
|
|
171
|
+
nextApplied.add(key);
|
|
172
|
+
}
|
|
173
|
+
appliedKeys.clear();
|
|
174
|
+
for (const key of nextApplied)
|
|
175
|
+
appliedKeys.add(key);
|
|
176
|
+
currentVars = newVars;
|
|
177
|
+
return outcome;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
outcome.error = true;
|
|
181
|
+
return outcome;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
reloading = false;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Surface a reload result via logs.
|
|
189
|
+
*
|
|
190
|
+
* - blocked: always warn (action required by the user)
|
|
191
|
+
* - first load: confirm the environment was applied
|
|
192
|
+
* - subsequent: only log when something actually changed
|
|
193
|
+
* - no-op / unavailable / transient error: silent
|
|
194
|
+
*/
|
|
195
|
+
const logOutcome = (outcome, opts) => {
|
|
196
|
+
if (outcome.blocked) {
|
|
197
|
+
console.warn("direnv: .envrc is blocked. Run `direnv allow` to enable.");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (outcome.unavailable || outcome.error)
|
|
201
|
+
return;
|
|
202
|
+
const total = outcome.added + outcome.changed + outcome.removed;
|
|
203
|
+
if (opts.initial && !firstLoadComplete) {
|
|
204
|
+
firstLoadComplete = true;
|
|
205
|
+
if (total > 0 || appliedKeys.size > 0) {
|
|
206
|
+
console.log("direnv: environment loaded");
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (total > 0) {
|
|
211
|
+
const parts = [];
|
|
212
|
+
if (outcome.added)
|
|
213
|
+
parts.push(`+${outcome.added}`);
|
|
214
|
+
if (outcome.changed)
|
|
215
|
+
parts.push(`~${outcome.changed}`);
|
|
216
|
+
if (outcome.removed)
|
|
217
|
+
parts.push(`-${outcome.removed}`);
|
|
218
|
+
console.log(`direnv: reloaded (${parts.join(" ")})`);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Debounced background reload. Collapses rapid triggers (e.g. many file
|
|
223
|
+
* writes from an editor save) into a single reload.
|
|
224
|
+
*/
|
|
225
|
+
const scheduleReload = (delayMs) => {
|
|
226
|
+
if (reloadTimer)
|
|
227
|
+
clearTimeout(reloadTimer);
|
|
228
|
+
reloadTimer = setTimeout(() => {
|
|
229
|
+
reloadTimer = null;
|
|
230
|
+
void reloadEnv().then((outcome) => logOutcome(outcome, { initial: false }));
|
|
231
|
+
}, delayMs);
|
|
232
|
+
};
|
|
233
|
+
// Initial load: awaited so the first command of any session sees the env.
|
|
234
|
+
logOutcome(await reloadEnv(), { initial: true });
|
|
235
|
+
// Primary mechanism: inject the latest direnv export into every shell the
|
|
236
|
+
// server spawns for a working copy inside the devshell. The cached payload
|
|
237
|
+
// keeps the hook synchronous and non-blocking.
|
|
238
|
+
const shellRegistration = await ctx.shell.hook("create.before", (event) => {
|
|
239
|
+
if (!envrcDir)
|
|
240
|
+
return;
|
|
241
|
+
if (event.cwd !== envrcDir && !event.cwd.startsWith(`${envrcDir}/`))
|
|
242
|
+
return;
|
|
243
|
+
for (const [key, value] of Object.entries(currentVars)) {
|
|
244
|
+
event.env[key] = value;
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
const isRelevantLocation = (location) => !location?.directory || location.directory === directory;
|
|
248
|
+
// Event stream: per-session sync and devshell-file watch. Runs detached so
|
|
249
|
+
// plugin setup is never blocked; aborted on cleanup.
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
void (async () => {
|
|
252
|
+
try {
|
|
253
|
+
for await (const raw of ctx.event.subscribe({ signal: controller.signal })) {
|
|
254
|
+
const event = raw;
|
|
255
|
+
// Re-sync on new sessions (catches `direnv allow` and other state
|
|
256
|
+
// changes that don't touch watched files).
|
|
257
|
+
if (event.type === "session.created") {
|
|
258
|
+
const data = event.data;
|
|
259
|
+
const sessionID = data?.sessionID;
|
|
260
|
+
if (!sessionID || !isRelevantLocation(event.location))
|
|
261
|
+
continue;
|
|
262
|
+
if (loadedSessions.has(sessionID))
|
|
263
|
+
continue;
|
|
264
|
+
loadedSessions.add(sessionID);
|
|
265
|
+
const outcome = await reloadEnv();
|
|
266
|
+
logOutcome(outcome, { initial: false });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// Devshell file edited/created/deleted -> reload (debounced).
|
|
270
|
+
if (event.type === "filesystem.changed") {
|
|
271
|
+
const data = event.data;
|
|
272
|
+
const filename = (data?.file ?? "").split("/").pop() ?? "";
|
|
273
|
+
if (!isRelevantLocation(event.location))
|
|
274
|
+
continue;
|
|
275
|
+
if (RELEVANT_FILES.has(filename)) {
|
|
276
|
+
scheduleReload(RELOAD_DEBOUNCE_MS);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// stream aborted on cleanup or failed; nothing to do
|
|
283
|
+
}
|
|
284
|
+
})();
|
|
285
|
+
return () => {
|
|
286
|
+
controller.abort();
|
|
287
|
+
if (reloadTimer)
|
|
288
|
+
clearTimeout(reloadTimer);
|
|
289
|
+
void shellRegistration.dispose();
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.js"
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode2-direnv",
|
|
3
|
+
"version": "2026.0904.0",
|
|
4
|
+
"description": "OpenCode 2 plugin that automatically loads direnv environment variables",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src",
|
|
14
|
+
"index.ts"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"opencode",
|
|
21
|
+
"opencode2",
|
|
22
|
+
"direnv",
|
|
23
|
+
"plugin",
|
|
24
|
+
"nix",
|
|
25
|
+
"environment",
|
|
26
|
+
"env"
|
|
27
|
+
],
|
|
28
|
+
"author": "ankarhem",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/ankarhem/opencode-direnv.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/ankarhem/opencode-direnv/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/ankarhem/opencode-direnv#readme",
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@opencode-ai/plugin": "beta"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@opencode-ai/plugin": "^0.0.0-beta-19086",
|
|
43
|
+
"@types/node": "^22.0.0",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import { execFile } from "node:child_process"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { promisify } from "node:util"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Direnv Auto-Loader Plugin for OpenCode 2
|
|
9
|
+
*
|
|
10
|
+
* Automatically loads AND keeps in sync environment variables from direnv.
|
|
11
|
+
* Shell commands run by the agent always see the current devshell state, even
|
|
12
|
+
* after the user edits .envrc/flake.nix or runs `direnv reload` mid-session.
|
|
13
|
+
*
|
|
14
|
+
* Behavior:
|
|
15
|
+
* - On plugin setup: load env once (awaited, so the first command is ready)
|
|
16
|
+
* - On session.created: re-sync per new session (e.g. after `direnv allow`)
|
|
17
|
+
* - On filesystem.changed for .envrc/flake.nix/flake.lock: debounced reload
|
|
18
|
+
* - Injects the latest direnv export into every spawned shell via the
|
|
19
|
+
* `shell.create.before` hook (primary mechanism)
|
|
20
|
+
* - Reconciles process.env as a fallback for other subprocesses (LSP, MCP) and
|
|
21
|
+
* *removes* dropped keys so removed variables don't linger
|
|
22
|
+
* - Logs outcomes with a `direnv:` prefix (blocked .envrc is always reported)
|
|
23
|
+
* - Silently skips if direnv is not installed or .envrc is missing
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const run = promisify(execFile)
|
|
27
|
+
|
|
28
|
+
type ReloadOutcome = {
|
|
29
|
+
/** .envrc exists but is blocked (`direnv allow` needed) */
|
|
30
|
+
blocked: boolean
|
|
31
|
+
/** direnv not installed / no .envrc / discovery failed */
|
|
32
|
+
unavailable: boolean
|
|
33
|
+
/** a transient error occurred talking to direnv */
|
|
34
|
+
error: boolean
|
|
35
|
+
/** number of brand-new variables added to process.env */
|
|
36
|
+
added: number
|
|
37
|
+
/** number of existing variables whose value changed */
|
|
38
|
+
changed: number
|
|
39
|
+
/** number of previously-applied variables that were removed */
|
|
40
|
+
removed: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Minimal shapes of the v2 server events this plugin reacts to */
|
|
44
|
+
type EncodedEvent = {
|
|
45
|
+
readonly type: string
|
|
46
|
+
readonly data?: unknown
|
|
47
|
+
readonly location?: { readonly directory?: string } | undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type SessionCreatedData = { readonly sessionID?: string }
|
|
51
|
+
type FilesystemChangedData = { readonly file?: string }
|
|
52
|
+
|
|
53
|
+
/** devshell files whose modification should trigger a reload */
|
|
54
|
+
const RELEVANT_FILES = new Set([".envrc", "flake.nix", "flake.lock"])
|
|
55
|
+
|
|
56
|
+
/** debounce window for background reloads (ms) */
|
|
57
|
+
const RELOAD_DEBOUNCE_MS = 1500
|
|
58
|
+
|
|
59
|
+
const runText = async (file: string, args: string[], cwd: string) => {
|
|
60
|
+
const { stdout } = await run(file, args, {
|
|
61
|
+
cwd,
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
64
|
+
})
|
|
65
|
+
return stdout
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default Plugin.define({
|
|
69
|
+
id: "opencode2-direnv",
|
|
70
|
+
|
|
71
|
+
async setup(ctx) {
|
|
72
|
+
const directory = ctx.location.directory
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sessions already synced by this plugin instance. Events arrive for every
|
|
76
|
+
* location, so also filter by event location where one is present.
|
|
77
|
+
*/
|
|
78
|
+
const loadedSessions = new Set<string>()
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Keys we have written into process.env. Tracked globally (process.env is
|
|
82
|
+
* shared across locations in the server process) so reloads can remove vars
|
|
83
|
+
* that the devshell no longer exports without touching anything we didn't set.
|
|
84
|
+
*/
|
|
85
|
+
const appliedKeys = new Set<string>()
|
|
86
|
+
|
|
87
|
+
/** latest successful `direnv export json` payload, injected into spawned shells */
|
|
88
|
+
let currentVars: Record<string, string> = {}
|
|
89
|
+
|
|
90
|
+
/** cached .envrc location (only cached once successfully found) */
|
|
91
|
+
let envrcDir: string | null = null
|
|
92
|
+
let discovered = false
|
|
93
|
+
|
|
94
|
+
/** prevents overlapping `direnv export json` invocations */
|
|
95
|
+
let reloading = false
|
|
96
|
+
|
|
97
|
+
let reloadTimer: ReturnType<typeof setTimeout> | null = null
|
|
98
|
+
let firstLoadComplete = false
|
|
99
|
+
|
|
100
|
+
const findGitRoot = async (): Promise<string | null> => {
|
|
101
|
+
try {
|
|
102
|
+
const result = await runText("git", ["rev-parse", "--show-toplevel"], directory)
|
|
103
|
+
return result.trim() || null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find .envrc file searching from directory up to stopAt (git root or filesystem root)
|
|
111
|
+
*/
|
|
112
|
+
const findEnvrc = async (
|
|
113
|
+
startDir: string,
|
|
114
|
+
stopAt: string | null
|
|
115
|
+
): Promise<string | null> => {
|
|
116
|
+
let current = startDir
|
|
117
|
+
const boundary = stopAt || "/"
|
|
118
|
+
|
|
119
|
+
while (true) {
|
|
120
|
+
if (existsSync(join(current, ".envrc"))) {
|
|
121
|
+
return join(current, ".envrc")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (current === boundary || current === "/") {
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const parent = dirname(current)
|
|
129
|
+
if (parent === current) {
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
current = parent
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve (and cache) the directory containing .envrc.
|
|
141
|
+
* Caches only on success; a missing .envrc is re-checked on later triggers
|
|
142
|
+
* so one created mid-session is eventually picked up.
|
|
143
|
+
*/
|
|
144
|
+
const resolveEnvrcDir = async (): Promise<string | null> => {
|
|
145
|
+
if (discovered) return envrcDir
|
|
146
|
+
const gitRoot = await findGitRoot()
|
|
147
|
+
const envrcPath = await findEnvrc(directory, gitRoot)
|
|
148
|
+
if (envrcPath) {
|
|
149
|
+
envrcDir = dirname(envrcPath)
|
|
150
|
+
}
|
|
151
|
+
discovered = envrcDir !== null
|
|
152
|
+
return envrcDir
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const newOutcome = (): ReloadOutcome => ({
|
|
156
|
+
blocked: false,
|
|
157
|
+
unavailable: false,
|
|
158
|
+
error: false,
|
|
159
|
+
added: 0,
|
|
160
|
+
changed: 0,
|
|
161
|
+
removed: 0,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Re-run `direnv export json` and reconcile process.env.
|
|
166
|
+
*
|
|
167
|
+
* Idempotent and mutex-guarded: safe to call from any trigger. Cheap when
|
|
168
|
+
* nothing changed (direnv's own watch cache makes the export ~milliseconds).
|
|
169
|
+
* Returns a summary describing what (if anything) changed.
|
|
170
|
+
*/
|
|
171
|
+
const reloadEnv = async (): Promise<ReloadOutcome> => {
|
|
172
|
+
const outcome = newOutcome()
|
|
173
|
+
if (reloading) return outcome
|
|
174
|
+
reloading = true
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const dir = await resolveEnvrcDir()
|
|
178
|
+
if (!dir) {
|
|
179
|
+
outcome.unavailable = true
|
|
180
|
+
return outcome
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let jsonText: string
|
|
184
|
+
try {
|
|
185
|
+
jsonText = await runText("direnv", ["export", "json"], dir)
|
|
186
|
+
} catch (error: unknown) {
|
|
187
|
+
const stderr =
|
|
188
|
+
error && typeof error === "object" && "stderr" in error
|
|
189
|
+
? String((error as { stderr?: string }).stderr ?? "")
|
|
190
|
+
: ""
|
|
191
|
+
if (stderr.includes("is blocked")) {
|
|
192
|
+
outcome.blocked = true
|
|
193
|
+
} else {
|
|
194
|
+
outcome.error = true
|
|
195
|
+
}
|
|
196
|
+
return outcome
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const parsed = jsonText.trim() ? JSON.parse(jsonText) : {}
|
|
200
|
+
const newVars: Record<string, string> =
|
|
201
|
+
parsed && typeof parsed === "object" ? parsed : {}
|
|
202
|
+
|
|
203
|
+
// 1. Remove vars we previously applied that the devshell no longer exports.
|
|
204
|
+
// direnv may also emit explicit `null` values to signal unsets.
|
|
205
|
+
for (const key of appliedKeys) {
|
|
206
|
+
const keep = key in newVars && newVars[key] != null
|
|
207
|
+
if (!keep && key in process.env) {
|
|
208
|
+
delete process.env[key]
|
|
209
|
+
outcome.removed++
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 2. Apply current vars, counting additions and value changes.
|
|
214
|
+
const nextApplied = new Set<string>()
|
|
215
|
+
for (const [key, value] of Object.entries(newVars)) {
|
|
216
|
+
if (value == null) continue
|
|
217
|
+
const current = process.env[key]
|
|
218
|
+
if (current === undefined) outcome.added++
|
|
219
|
+
else if (current !== value) outcome.changed++
|
|
220
|
+
process.env[key] = value
|
|
221
|
+
nextApplied.add(key)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
appliedKeys.clear()
|
|
225
|
+
for (const key of nextApplied) appliedKeys.add(key)
|
|
226
|
+
currentVars = newVars
|
|
227
|
+
|
|
228
|
+
return outcome
|
|
229
|
+
} catch {
|
|
230
|
+
outcome.error = true
|
|
231
|
+
return outcome
|
|
232
|
+
} finally {
|
|
233
|
+
reloading = false
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Surface a reload result via logs.
|
|
239
|
+
*
|
|
240
|
+
* - blocked: always warn (action required by the user)
|
|
241
|
+
* - first load: confirm the environment was applied
|
|
242
|
+
* - subsequent: only log when something actually changed
|
|
243
|
+
* - no-op / unavailable / transient error: silent
|
|
244
|
+
*/
|
|
245
|
+
const logOutcome = (outcome: ReloadOutcome, opts: { initial: boolean }) => {
|
|
246
|
+
if (outcome.blocked) {
|
|
247
|
+
console.warn("direnv: .envrc is blocked. Run `direnv allow` to enable.")
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (outcome.unavailable || outcome.error) return
|
|
251
|
+
|
|
252
|
+
const total = outcome.added + outcome.changed + outcome.removed
|
|
253
|
+
|
|
254
|
+
if (opts.initial && !firstLoadComplete) {
|
|
255
|
+
firstLoadComplete = true
|
|
256
|
+
if (total > 0 || appliedKeys.size > 0) {
|
|
257
|
+
console.log("direnv: environment loaded")
|
|
258
|
+
}
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (total > 0) {
|
|
263
|
+
const parts: string[] = []
|
|
264
|
+
if (outcome.added) parts.push(`+${outcome.added}`)
|
|
265
|
+
if (outcome.changed) parts.push(`~${outcome.changed}`)
|
|
266
|
+
if (outcome.removed) parts.push(`-${outcome.removed}`)
|
|
267
|
+
console.log(`direnv: reloaded (${parts.join(" ")})`)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Debounced background reload. Collapses rapid triggers (e.g. many file
|
|
273
|
+
* writes from an editor save) into a single reload.
|
|
274
|
+
*/
|
|
275
|
+
const scheduleReload = (delayMs: number) => {
|
|
276
|
+
if (reloadTimer) clearTimeout(reloadTimer)
|
|
277
|
+
reloadTimer = setTimeout(() => {
|
|
278
|
+
reloadTimer = null
|
|
279
|
+
void reloadEnv().then((outcome) => logOutcome(outcome, { initial: false }))
|
|
280
|
+
}, delayMs)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Initial load: awaited so the first command of any session sees the env.
|
|
284
|
+
logOutcome(await reloadEnv(), { initial: true })
|
|
285
|
+
|
|
286
|
+
// Primary mechanism: inject the latest direnv export into every shell the
|
|
287
|
+
// server spawns for a working copy inside the devshell. The cached payload
|
|
288
|
+
// keeps the hook synchronous and non-blocking.
|
|
289
|
+
const shellRegistration = await ctx.shell.hook("create.before", (event) => {
|
|
290
|
+
if (!envrcDir) return
|
|
291
|
+
if (event.cwd !== envrcDir && !event.cwd.startsWith(`${envrcDir}/`)) return
|
|
292
|
+
for (const [key, value] of Object.entries(currentVars)) {
|
|
293
|
+
event.env[key] = value
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const isRelevantLocation = (location: EncodedEvent["location"]) =>
|
|
298
|
+
!location?.directory || location.directory === directory
|
|
299
|
+
|
|
300
|
+
// Event stream: per-session sync and devshell-file watch. Runs detached so
|
|
301
|
+
// plugin setup is never blocked; aborted on cleanup.
|
|
302
|
+
const controller = new AbortController()
|
|
303
|
+
void (async () => {
|
|
304
|
+
try {
|
|
305
|
+
for await (const raw of ctx.event.subscribe({ signal: controller.signal })) {
|
|
306
|
+
const event = raw as unknown as EncodedEvent
|
|
307
|
+
|
|
308
|
+
// Re-sync on new sessions (catches `direnv allow` and other state
|
|
309
|
+
// changes that don't touch watched files).
|
|
310
|
+
if (event.type === "session.created") {
|
|
311
|
+
const data = event.data as SessionCreatedData | undefined
|
|
312
|
+
const sessionID = data?.sessionID
|
|
313
|
+
if (!sessionID || !isRelevantLocation(event.location)) continue
|
|
314
|
+
if (loadedSessions.has(sessionID)) continue
|
|
315
|
+
loadedSessions.add(sessionID)
|
|
316
|
+
const outcome = await reloadEnv()
|
|
317
|
+
logOutcome(outcome, { initial: false })
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Devshell file edited/created/deleted -> reload (debounced).
|
|
322
|
+
if (event.type === "filesystem.changed") {
|
|
323
|
+
const data = event.data as FilesystemChangedData | undefined
|
|
324
|
+
const filename = (data?.file ?? "").split("/").pop() ?? ""
|
|
325
|
+
if (!isRelevantLocation(event.location)) continue
|
|
326
|
+
if (RELEVANT_FILES.has(filename)) {
|
|
327
|
+
scheduleReload(RELOAD_DEBOUNCE_MS)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// stream aborted on cleanup or failed; nothing to do
|
|
333
|
+
}
|
|
334
|
+
})()
|
|
335
|
+
|
|
336
|
+
return () => {
|
|
337
|
+
controller.abort()
|
|
338
|
+
if (reloadTimer) clearTimeout(reloadTimer)
|
|
339
|
+
void shellRegistration.dispose()
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
})
|