opencode-dotenv 0.4.1 → 0.5.1

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.
Files changed (3) hide show
  1. package/README.md +45 -21
  2. package/dist/index.js +221 -66
  3. package/package.json +28 -6
package/README.md CHANGED
@@ -2,16 +2,36 @@
2
2
 
3
3
  OpenCode plugin to load `.env` files at startup.
4
4
 
5
- ## Features
5
+ > **Important Limitation**
6
+ >
7
+ > This plugin **cannot** set environment variables for use in OpenCode's config file (`opencode.jsonc`). The plugin loads *after* OpenCode parses its configuration, so `{env:VARNAME}` references in config are resolved before this plugin runs.
8
+ >
9
+ > **Use this plugin for:** Managing environment variables available during chat sessions, tool executions, and bash commands.
10
+ >
11
+ > **Do not use this plugin for:** Setting API keys or other config values referenced via `{env:VAR}` syntax in `opencode.jsonc`. For those, set variables in your shell profile (`~/.zshrc`, `~/.bashrc`) before starting OpenCode.
12
+ >
13
+ > See [Architecture](docs/ARCHITECTURE.md) for details on the OpenCode startup sequence.
6
14
 
15
+ ## Features
16
+
7
17
  - Load multiple `.env` files in order via config file
8
18
  - Load `.env` from current working directory (optional)
9
19
  - Override existing environment variables (later files override earlier ones)
10
- - Configurable logging to `/tmp/opencode-dotenv.log` (enabled by default)
20
+ - Configurable logging to `~/.local/share/opencode/dotenv.log` (disabled by default)
11
21
  - Prevents double loading with load guard
12
22
  - JSONC config file format (supports comments and trailing commas)
13
23
  - **Requires Bun runtime**
14
24
 
25
+ ## Limitations
26
+
27
+ 1. **Cannot modify existing OpenCode config** - Variables loaded by this plugin cannot be referenced in `opencode.jsonc` using `{env:VAR}` syntax. OpenCode reads its config before plugins initialize.
28
+
29
+ 2. **Only affects subsequent operations** - Loaded environment variables are only available to new chat sessions, tool calls, and operations that occur AFTER plugin initialization.
30
+
31
+ 3. **No config reload capability** - Changing `.env` files while OpenCode is running will NOT trigger config re-parsing. To apply changes, restart OpenCode.
32
+
33
+ **Recommended approach:** Set environment variables in your shell profile (`.zshrc`, `.bashrc`) before starting OpenCode.
34
+
15
35
  ## Installation
16
36
 
17
37
  Add to your `opencode.jsonc`:
@@ -33,12 +53,12 @@ After publishing to npm, you can use:
33
53
 
34
54
  ## Configuration
35
55
 
36
- Create `opencode-dotenv.jsonc` in one of these locations:
56
+ Create `dotenv.jsonc` in one of these locations (searched in order, first found wins):
37
57
 
38
- 1. `~/.config/opencode/opencode-dotenv.jsonc` (recommended, global config)
39
- 2. `./opencode-dotenv.jsonc` in current working directory (project-specific)
58
+ 1. `./dotenv.jsonc` in current working directory (project-specific)
59
+ 2. `~/.config/opencode/dotenv.jsonc` (global config)
40
60
 
41
- **Note:** Config files are loaded in the order above; the first found file is used.
61
+ **Note:** Only the first found config file is used; configs are not merged.
42
62
 
43
63
  ### Config Schema
44
64
 
@@ -56,7 +76,7 @@ Config file uses **JSONC format** (JSON with Comments), which supports:
56
76
  ],
57
77
  "load_cwd_env": true,
58
78
  "logging": {
59
- "enabled": true
79
+ "enabled": false
60
80
  }
61
81
  }
62
82
  ```
@@ -64,13 +84,13 @@ Config file uses **JSONC format** (JSON with Comments), which supports:
64
84
  **Fields:**
65
85
  - `files` (array, optional): List of `.env` file paths to load in order. Later files override earlier ones.
66
86
  - `load_cwd_env` (boolean, optional): Whether to load `.env` from the directory where OpenCode is opened. Defaults to `true`.
67
- - `logging.enabled` (boolean, optional): Enable/disable logging to `/tmp/opencode-dotenv.log`. Defaults to `true`.
87
+ - `logging.enabled` (boolean, optional): Enable/disable logging to `~/.local/share/opencode/dotenv.log`. Defaults to `false`.
68
88
 
69
89
  **Notes:**
70
90
  - Use `~` for home directory (automatically expanded)
71
91
  - Paths are expanded before loading
72
92
  - If no config file exists, only loads `./.env` from cwd (if present)
73
- - Logging writes to `/tmp/opencode-dotenv.log` for debugging
93
+ - Logging writes to `~/.local/share/opencode/dotenv.log` for debugging
74
94
 
75
95
  ### Load Order
76
96
 
@@ -83,7 +103,7 @@ This ensures project-specific env vars have the highest precedence.
83
103
 
84
104
  ### Load global and project-specific .env files
85
105
 
86
- Config (`~/.config/opencode/opencode-dotenv.jsonc`):
106
+ Config (`~/.config/opencode/dotenv.jsonc`):
87
107
 
88
108
  ```jsonc
89
109
  {
@@ -100,11 +120,11 @@ Config (`~/.config/opencode/opencode-dotenv.jsonc`):
100
120
  Result:
101
121
  1. Loads `~/.config/opencode/.env`
102
122
  2. Loads `./.env` from cwd (overrides any conflicts)
103
- 3. Logs all activity to `/tmp/opencode-dotenv.log`
123
+ 3. Logs all activity to `~/.local/share/opencode/dotenv.log`
104
124
 
105
125
  ### Load multiple global files without cwd .env
106
126
 
107
- Config (`~/.config/opencode/opencode-dotenv.jsonc`):
127
+ Config (`~/.config/opencode/dotenv.jsonc`):
108
128
 
109
129
  ```jsonc
110
130
  {
@@ -126,41 +146,42 @@ Result:
126
146
  4. No logging output
127
147
 
128
148
  ### Example .env files
129
-
149
+
130
150
  `~/.config/opencode/.env`:
131
-
151
+
132
152
  ```bash
133
153
  # OpenCode Dotenv Configuration
134
- OPENCODE_API_KEY=your_api_key_here
135
154
  OPENCODE_DEBUG=true
136
155
  OPENCODE_MAX_TOKENS=100000
156
+ MY_PROJECT_KEY=secret123
137
157
  ```
138
158
 
139
- `./.env` (project-specific):
159
+ **Note:** This plugin cannot inject variables into OpenCode's configuration loading process. To set `ANTHROPIC_API_KEY` or other provider API keys, set them in your shell profile (`~/.zshrc`, `~/.bashrc`) before starting OpenCode.
140
160
 
161
+ `./.env` (project-specific):
141
162
  ```bash
142
163
  # Project-specific overrides
143
164
  OPENCODE_DEBUG=false
144
165
  PROJECT_API_KEY=project_specific_key
145
166
  ```
146
167
 
147
- Result: `OPENCODE_DEBUG` will be `false` (from cwd), `OPENCODE_API_KEY` from global, `PROJECT_API_KEY` from cwd.
168
+ Result: `OPENCODE_DEBUG` will be `false` (from cwd), `MY_PROJECT_KEY` from global, `PROJECT_API_KEY` from cwd.
148
169
 
149
170
  ### Logging
150
171
 
151
172
  View plugin activity logs:
152
173
 
153
174
  ```bash
154
- tail -f /tmp/opencode-dotenv.log
175
+ tail -f ~/.local/share/opencode/dotenv.log
155
176
  ```
156
177
 
157
- Disable logging in config:
178
+ Enable logging in config:
158
179
 
159
180
  ```jsonc
160
181
  {
161
182
  "files": ["~/.config/opencode/.env"],
162
183
  "logging": {
163
- "enabled": false
184
+ "enabled": true
164
185
  }
165
186
  }
166
187
  ```
@@ -173,7 +194,10 @@ Disable logging in config:
173
194
  opencode-dotenv/
174
195
  ├── package.json
175
196
  ├── src/
176
- └── index.ts
197
+ ├── index.ts
198
+ │ └── plugin.ts
199
+ ├── docs/
200
+ │ └── ARCHITECTURE.md
177
201
  └── dist/
178
202
  └── index.js (built)
179
203
  ```
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // @bun
2
- // src/index.ts
2
+ // src/plugin.ts
3
3
  import { homedir } from "os";
4
+ import { appendFile, mkdir } from "fs/promises";
4
5
 
5
6
  // node_modules/jsonc-parser/lib/esm/impl/scanner.js
6
7
  function createScanner(text, ignoreTrivia = false) {
@@ -806,9 +807,165 @@ var ParseErrorCode;
806
807
  ParseErrorCode2[ParseErrorCode2["InvalidCharacter"] = 16] = "InvalidCharacter";
807
808
  })(ParseErrorCode || (ParseErrorCode = {}));
808
809
 
809
- // src/index.ts
810
- var LOG_FILE = "/tmp/opencode-dotenv.log";
810
+ // src/profiler/profiler.ts
811
+ function percentile(sorted, p) {
812
+ if (sorted.length === 0)
813
+ return 0;
814
+ const index = Math.ceil(p / 100 * sorted.length) - 1;
815
+ return sorted[Math.max(0, index)];
816
+ }
817
+ function calculateStats(measurements) {
818
+ if (measurements.length === 0)
819
+ return null;
820
+ const sorted = [...measurements].sort((a, b) => a - b);
821
+ const total = sorted.reduce((sum, v) => sum + v, 0);
822
+ return {
823
+ count: sorted.length,
824
+ min: sorted[0],
825
+ max: sorted[sorted.length - 1],
826
+ avg: total / sorted.length,
827
+ p50: percentile(sorted, 50),
828
+ p95: percentile(sorted, 95),
829
+ p99: percentile(sorted, 99),
830
+ total
831
+ };
832
+ }
833
+
834
+ class Profiler {
835
+ marks = new Map;
836
+ measures = new Map;
837
+ fileMetrics = [];
838
+ initStartTime = null;
839
+ initEndTime = null;
840
+ initState = "idle";
841
+ configLoadTime = null;
842
+ configPath = null;
843
+ totalVars = 0;
844
+ startTime = performance.now();
845
+ mark(name) {
846
+ this.marks.set(name, performance.now());
847
+ }
848
+ measure(name, startMark) {
849
+ const start = this.marks.get(startMark);
850
+ if (start === undefined) {
851
+ return -1;
852
+ }
853
+ const duration = performance.now() - start;
854
+ const existing = this.measures.get(name) || [];
855
+ existing.push(duration);
856
+ this.measures.set(name, existing);
857
+ return duration;
858
+ }
859
+ record(name, duration) {
860
+ const existing = this.measures.get(name) || [];
861
+ existing.push(duration);
862
+ this.measures.set(name, existing);
863
+ }
864
+ getStats(name) {
865
+ const measurements = this.measures.get(name);
866
+ if (!measurements)
867
+ return null;
868
+ return calculateStats(measurements);
869
+ }
870
+ initStart() {
871
+ this.initStartTime = performance.now();
872
+ this.initState = "loading";
873
+ }
874
+ initComplete(state) {
875
+ this.initEndTime = performance.now();
876
+ this.initState = state;
877
+ }
878
+ recordConfigLoad(duration, path) {
879
+ this.configLoadTime = duration;
880
+ this.configPath = path;
881
+ }
882
+ recordFileLoad(path, duration, varCount, success, error) {
883
+ this.fileMetrics.push({
884
+ path,
885
+ duration,
886
+ varCount,
887
+ success,
888
+ error
889
+ });
890
+ if (success) {
891
+ this.totalVars += varCount;
892
+ this.record("file.load", duration);
893
+ }
894
+ }
895
+ getInitState() {
896
+ return this.initState;
897
+ }
898
+ getInitDuration() {
899
+ if (this.initStartTime === null)
900
+ return null;
901
+ if (this.initEndTime !== null) {
902
+ return this.initEndTime - this.initStartTime;
903
+ }
904
+ return performance.now() - this.initStartTime;
905
+ }
906
+ getTotalVars() {
907
+ return this.totalVars;
908
+ }
909
+ getFileMetrics() {
910
+ return [...this.fileMetrics];
911
+ }
912
+ export() {
913
+ return {
914
+ timestamp: new Date().toISOString(),
915
+ uptime: performance.now() - this.startTime,
916
+ initialization: {
917
+ startTime: this.initStartTime || 0,
918
+ endTime: this.initEndTime,
919
+ duration: this.getInitDuration(),
920
+ state: this.initState
921
+ },
922
+ config: {
923
+ loadTime: this.configLoadTime,
924
+ path: this.configPath
925
+ },
926
+ files: {
927
+ loadTimes: this.getStats("file.load"),
928
+ details: this.fileMetrics,
929
+ totalVars: this.totalVars
930
+ }
931
+ };
932
+ }
933
+ reset() {
934
+ this.marks.clear();
935
+ this.measures.clear();
936
+ this.fileMetrics = [];
937
+ this.initStartTime = null;
938
+ this.initEndTime = null;
939
+ this.initState = "idle";
940
+ this.configLoadTime = null;
941
+ this.configPath = null;
942
+ this.totalVars = 0;
943
+ }
944
+ startTimer(name) {
945
+ const start = performance.now();
946
+ return () => {
947
+ const duration = performance.now() - start;
948
+ this.record(name, duration);
949
+ return duration;
950
+ };
951
+ }
952
+ }
953
+ var globalProfiler = new Profiler;
954
+ // src/plugin.ts
955
+ var CONFIG_NAME = "dotenv.jsonc";
956
+ var LOG_DIR = `${homedir()}/.local/share/opencode`;
957
+ var LOG_FILE = `${LOG_DIR}/dotenv.log`;
811
958
  var LOAD_GUARD = "__opencodeDotenvLoaded";
959
+ var isTestEnv = !!process.env.BUN_TEST;
960
+ var loggingEnabled = false;
961
+ function log(message) {
962
+ if (!loggingEnabled || isTestEnv)
963
+ return;
964
+ const timestamp = new Date().toISOString();
965
+ const line = `[${timestamp}] ${message}
966
+ `;
967
+ mkdir(LOG_DIR, { recursive: true }).then(() => appendFile(LOG_FILE, line)).catch(() => {});
968
+ }
812
969
  function parseDotenv(content) {
813
970
  const result = {};
814
971
  if (typeof content !== "string")
@@ -820,114 +977,112 @@ function parseDotenv(content) {
820
977
  continue;
821
978
  const match = trimmed.match(/^export\s+([^=]+)=(.*)$/);
822
979
  const key = match ? match[1] : trimmed.split("=")[0];
823
- let value = match ? match[2] : trimmed.substring(key.length + 1);
824
- if (key) {
825
- let parsedValue = value.trim();
826
- if (!parsedValue.startsWith('"') && !parsedValue.startsWith("'")) {
827
- const inlineCommentIndex = parsedValue.indexOf(" #");
828
- if (inlineCommentIndex !== -1) {
829
- parsedValue = parsedValue.substring(0, inlineCommentIndex).trim();
830
- }
831
- }
832
- if (parsedValue.startsWith('"') && parsedValue.endsWith('"') || parsedValue.startsWith("'") && parsedValue.endsWith("'")) {
833
- parsedValue = parsedValue.slice(1, -1);
980
+ if (!key)
981
+ continue;
982
+ const value = match ? match[2] : trimmed.substring(key.length + 1);
983
+ if (value === undefined)
984
+ continue;
985
+ let parsedValue = value.trim();
986
+ if (!parsedValue.startsWith('"') && !parsedValue.startsWith("'")) {
987
+ const inlineCommentIndex = parsedValue.indexOf(" #");
988
+ if (inlineCommentIndex !== -1) {
989
+ parsedValue = parsedValue.substring(0, inlineCommentIndex).trim();
834
990
  }
835
- result[key.trim()] = parsedValue;
836
991
  }
992
+ if (parsedValue.startsWith('"') && parsedValue.endsWith('"') || parsedValue.startsWith("'") && parsedValue.endsWith("'")) {
993
+ parsedValue = parsedValue.slice(1, -1);
994
+ }
995
+ result[key.trim()] = parsedValue;
837
996
  }
838
997
  return result;
839
998
  }
840
999
  function expandPath(path) {
841
1000
  return path.replace(/^~/, homedir());
842
1001
  }
843
- var loggingEnabled = true;
844
- function logToFile(message) {
845
- if (!loggingEnabled)
846
- return;
847
- try {
848
- const timestamp = new Date().toISOString();
849
- Bun.appendFileSync(LOG_FILE, `[${timestamp}] ${message}
850
- `);
851
- } catch (e) {}
852
- }
853
1002
  async function loadConfig() {
1003
+ const timer = globalProfiler.startTimer("config.load");
854
1004
  const configPaths = [
855
- `${homedir()}/.config/opencode/opencode-dotenv.jsonc`,
856
- `${process.cwd()}/opencode-dotenv.jsonc`
1005
+ `${process.cwd()}/${CONFIG_NAME}`,
1006
+ `${homedir()}/.config/opencode/${CONFIG_NAME}`
857
1007
  ];
858
1008
  for (const configPath of configPaths) {
859
1009
  try {
860
1010
  const file = Bun.file(configPath);
861
- if (!await file.exists())
862
- continue;
863
1011
  const content = await file.text();
864
- const config = parse2(content, [], { allowTrailingComma: true });
865
- loggingEnabled = config.logging?.enabled !== false;
866
- return config;
867
- } catch (e) {
868
- logToFile(`Failed to load config: ${e}`);
869
- }
1012
+ if (!content || typeof content !== "string") {
1013
+ continue;
1014
+ }
1015
+ const config = parse2(content, [], {
1016
+ allowTrailingComma: true
1017
+ });
1018
+ if (config && typeof config === "object" && Array.isArray(config.files)) {
1019
+ loggingEnabled = config.logging?.enabled === true;
1020
+ const duration = timer();
1021
+ globalProfiler.recordConfigLoad(duration, configPath);
1022
+ return { config, path: configPath };
1023
+ }
1024
+ } catch {}
870
1025
  }
871
- return { files: [], load_cwd_env: true };
1026
+ timer();
1027
+ return { config: { files: [], load_cwd_env: true }, path: null };
872
1028
  }
873
1029
  async function loadDotenvFile(filePath) {
1030
+ const start = performance.now();
874
1031
  try {
875
1032
  const file = Bun.file(filePath);
876
- if (!await file.exists()) {
877
- logToFile(`File not found: ${filePath}`);
878
- return { count: 0, success: false };
879
- }
880
1033
  const content = await file.text();
881
- if (typeof content !== "string") {
882
- logToFile(`Invalid content type from ${filePath}: ${typeof content}`);
1034
+ if (typeof content !== "string" || !content) {
1035
+ const duration2 = performance.now() - start;
1036
+ globalProfiler.recordFileLoad(filePath, duration2, 0, false, "Empty or invalid content");
1037
+ log(`Invalid or empty content from ${filePath}`);
883
1038
  return { count: 0, success: false };
884
1039
  }
885
1040
  const envVars = parseDotenv(content);
1041
+ const count = Object.keys(envVars).length;
886
1042
  for (const [key, value] of Object.entries(envVars)) {
887
1043
  process.env[key] = value;
888
1044
  }
889
- return { count: Object.keys(envVars).length, success: true };
1045
+ const duration = performance.now() - start;
1046
+ globalProfiler.recordFileLoad(filePath, duration, count, true);
1047
+ return { count, success: true };
890
1048
  } catch (error) {
891
- logToFile(`Failed to load ${filePath}: ${error}`);
1049
+ const duration = performance.now() - start;
1050
+ const errorMsg = error instanceof Error ? error.message : String(error);
1051
+ globalProfiler.recordFileLoad(filePath, duration, 0, false, errorMsg);
1052
+ log(`Failed to load ${filePath}: ${errorMsg}`);
892
1053
  return { count: 0, success: false };
893
1054
  }
894
1055
  }
895
- var DotEnvPlugin = async (ctx) => {
1056
+ var DotEnvPlugin = async (_ctx) => {
896
1057
  if (globalThis[LOAD_GUARD]) {
897
1058
  return {};
898
1059
  }
899
1060
  globalThis[LOAD_GUARD] = true;
900
- logToFile("Plugin started");
901
- const config = await loadConfig();
902
- logToFile(`Config loaded: ${config.files.length} files, load_cwd_env=${config.load_cwd_env}, logging=${loggingEnabled}`);
1061
+ globalProfiler.initStart();
1062
+ log("Plugin started");
1063
+ const { config, path: configPath } = await loadConfig();
1064
+ log(`Config loaded from ${configPath || "default"}: ${config.files.length} files, load_cwd_env=${config.load_cwd_env}, logging=${loggingEnabled}`);
1065
+ const filesToLoad = [...config.files.map(expandPath)];
1066
+ if (config.load_cwd_env !== false) {
1067
+ filesToLoad.push(`${process.cwd()}/.env`);
1068
+ }
903
1069
  let totalFiles = 0;
904
1070
  let totalVars = 0;
905
- for (const rawPath of config.files) {
906
- const filePath = expandPath(rawPath);
907
- logToFile(`Loading: ${filePath}`);
1071
+ for (const filePath of filesToLoad) {
1072
+ log(`Loading: ${filePath}`);
908
1073
  const result = await loadDotenvFile(filePath);
909
1074
  if (result.success) {
910
1075
  totalFiles++;
911
1076
  totalVars += result.count;
912
- logToFile(`Loaded ${result.count} vars`);
913
- }
914
- }
915
- if (config.load_cwd_env !== false) {
916
- const cwdEnvPath = `${process.cwd()}/.env`;
917
- logToFile(`Loading cwd: ${cwdEnvPath}`);
918
- const result = await loadDotenvFile(cwdEnvPath);
919
- if (result.success) {
920
- totalFiles++;
921
- totalVars += result.count;
922
- logToFile(`Loaded ${result.count} vars from cwd`);
1077
+ log(`Loaded ${result.count} vars from ${filePath}`);
923
1078
  }
924
1079
  }
925
- logToFile(`Plugin finished: ${totalFiles} files, ${totalVars} vars`);
1080
+ globalProfiler.initComplete("ready");
1081
+ const initDuration = globalProfiler.getInitDuration();
1082
+ log(`Plugin finished: ${totalFiles} files, ${totalVars} vars in ${initDuration?.toFixed(2)}ms`);
926
1083
  return {};
927
1084
  };
928
- var src_default = DotEnvPlugin;
929
1085
  export {
930
- parseDotenv,
931
- src_default as default,
1086
+ DotEnvPlugin as default,
932
1087
  DotEnvPlugin
933
1088
  };
package/package.json CHANGED
@@ -1,24 +1,46 @@
1
1
  {
2
2
  "name": "opencode-dotenv",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "OpenCode plugin to load .env files at startup",
5
- "main": "./dist/index.js",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
6
7
  "type": "module",
7
- "license": "MIT",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
8
14
  "scripts": {
9
15
  "build": "bun build src/index.ts --outdir dist --target bun",
16
+ "dev": "bun --hot src/index.ts",
17
+ "test": "bun test",
18
+ "test:coverage": "bun test --coverage",
19
+ "typecheck": "tsc --noEmit",
20
+ "bench": "bun run bench/init.bench.ts",
10
21
  "prepublishOnly": "bun run build"
11
22
  },
12
23
  "files": [
13
- "dist/",
24
+ "dist",
14
25
  "README.md",
15
26
  "LICENSE"
16
27
  ],
28
+ "keywords": [
29
+ "opencode",
30
+ "plugin",
31
+ "dotenv",
32
+ "env",
33
+ "environment"
34
+ ],
35
+ "license": "MIT",
36
+ "devDependencies": {
37
+ "@types/bun": "latest",
38
+ "typescript": "^5.9.3"
39
+ },
17
40
  "peerDependencies": {
18
41
  "@opencode-ai/plugin": "*"
19
42
  },
20
43
  "dependencies": {
21
44
  "jsonc-parser": "^3.3.1"
22
- },
23
- "devDependencies": {}
45
+ }
24
46
  }